From 12ef7e60f13ea3b398cfab63eefbc45d2bc52eca Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:07:50 +0000 Subject: [PATCH 1/2] docs(config): state the layer-vs-specificity precedence rule `WORKTRUNK_WORKTREE_PATH` and `--config-set worktree-path` set the global key; a `[projects.""]` entry beats the global key whichever layer set it. The docs promised env vars override "all user config options" without naming the specificity axis, so a project entry silently winning read as a bug. Documents the rule under a new Precedence heading, cross-references it from the `--config-set` section, and pins all three behaviors with an integration test. Refs #3788 --- docs/content/config.md | 10 +- .../skills/worktrunk/reference/config.md | 12 ++- skills/worktrunk/reference/config.md | 12 ++- src/cli/mod.rs | 12 ++- tests/integration_tests/switch.rs | 98 ++++++++++++++++++- ...gration_tests__help__help_config_long.snap | 11 ++- 6 files changed, 149 insertions(+), 6 deletions(-) diff --git a/docs/content/config.md b/docs/content/config.md index 0e554bcee..610cb9a79 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -636,6 +636,14 @@ On first run without shell integration, Worktrunk offers to install it. On first All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. +### Precedence + +Layer and specificity are separate axes. Layer decides which document supplies a key — system config, then user config, then `WORKTRUNK_` env vars, then `--config-set`. Specificity is applied to the merged result: a [user project-specific setting](@/config.md#user-project-specific-settings) beats the global key of the same name, whichever layer set each one. + +So `WORKTRUNK_WORKTREE_PATH` overrides a global `worktree-path`, but a `[projects."github.com/owner/repo"]` entry that sets `worktree-path` still wins over it. To override a project entry for one invocation, name that entry — `wt config show` prints the identifier to use as the key: + +{{ terminal(cmd="wt --config-set 'projects.__WT_QUOT__github.com/owner/repo__WT_QUOT__.worktree-path = __WT_QUOT__/tmp/scratch__WT_QUOT__' switch --create feature") }} + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. @@ -675,7 +683,7 @@ Override the LLM command in CI to use a mock: ## Inline config overrides (`--config-set`) -`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. +`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. It is the highest layer, not the most specific key — overriding a `[projects."…"]` setting means naming that entry, per [the precedence rules](@/config.md#precedence). {{ terminal(cmd="wt --config-set list.full=true list|||wt step copy-ignored --config-set 'step.copy-ignored.exclude=[__WT_QUOT__target__WT_QUOT__, __WT_QUOT__dist__WT_QUOT__]'") }} diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 2f91dd0d9..c4f9abf3c 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -631,6 +631,16 @@ On first run without shell integration, Worktrunk offers to install it. On first All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. +### Precedence + +Layer and specificity are separate axes. Layer decides which document supplies a key — system config, then user config, then `WORKTRUNK_` env vars, then `--config-set`. Specificity is applied to the merged result: a [user project-specific setting](https://worktrunk.dev/config/#user-project-specific-settings) beats the global key of the same name, whichever layer set each one. + +So `WORKTRUNK_WORKTREE_PATH` overrides a global `worktree-path`, but a `[projects."github.com/owner/repo"]` entry that sets `worktree-path` still wins over it. To override a project entry for one invocation, name that entry — `wt config show` prints the identifier to use as the key: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. @@ -672,7 +682,7 @@ $ WORKTRUNK_COMMIT__GENERATION__COMMAND="echo 'test: automated commit'" wt merge ## Inline config overrides (`--config-set`) -`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. +`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. It is the highest layer, not the most specific key — overriding a `[projects."…"]` setting means naming that entry, per [the precedence rules](https://worktrunk.dev/config/#precedence). ```bash $ wt --config-set list.full=true list diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 2f91dd0d9..c4f9abf3c 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -631,6 +631,16 @@ On first run without shell integration, Worktrunk offers to install it. On first All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. +### Precedence + +Layer and specificity are separate axes. Layer decides which document supplies a key — system config, then user config, then `WORKTRUNK_` env vars, then `--config-set`. Specificity is applied to the merged result: a [user project-specific setting](https://worktrunk.dev/config/#user-project-specific-settings) beats the global key of the same name, whichever layer set each one. + +So `WORKTRUNK_WORKTREE_PATH` overrides a global `worktree-path`, but a `[projects."github.com/owner/repo"]` entry that sets `worktree-path` still wins over it. To override a project entry for one invocation, name that entry — `wt config show` prints the identifier to use as the key: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. @@ -672,7 +682,7 @@ $ WORKTRUNK_COMMIT__GENERATION__COMMAND="echo 'test: automated commit'" wt merge ## Inline config overrides (`--config-set`) -`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. +`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. It is the highest layer, not the most specific key — overriding a `[projects."…"]` setting means naming that entry, per [the precedence rules](https://worktrunk.dev/config/#precedence). ```bash $ wt --config-set list.full=true list diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4e91f75c..e1fc125bf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2522,6 +2522,16 @@ On first run without shell integration, Worktrunk offers to install it. On first All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. +### Precedence + +Layer and specificity are separate axes. Layer decides which document supplies a key — system config, then user config, then `WORKTRUNK_` env vars, then `--config-set`. Specificity is applied to the merged result: a [user project-specific setting](@/config.md#user-project-specific-settings) beats the global key of the same name, whichever layer set each one. + +So `WORKTRUNK_WORKTREE_PATH` overrides a global `worktree-path`, but a `[projects."github.com/owner/repo"]` entry that sets `worktree-path` still wins over it. To override a project entry for one invocation, name that entry — `wt config show` prints the identifier to use as the key: + +```console +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. @@ -2563,7 +2573,7 @@ $ WORKTRUNK_COMMIT__GENERATION__COMMAND="echo 'test: automated commit'" wt merge ## Inline config overrides (`--config-set`) -`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. +`--config-set ` overrides any user config key for a single invocation, with higher priority than both config files and `WORKTRUNK_` env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later `--config-set` replaces an earlier one for the same key. It is the highest layer, not the most specific key — overriding a `[projects."…"]` setting means naming that entry, per [the precedence rules](@/config.md#precedence). ```console $ wt --config-set list.full=true list diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 3cd32fcb7..4bb5f12dc 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -8,7 +8,7 @@ use ansi_str::AnsiStr; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use tempfile::TempDir; // Snapshot helpers @@ -2432,6 +2432,102 @@ worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}" ); } +/// Layer and specificity are separate axes: `WORKTRUNK_WORKTREE_PATH` and +/// `--config-set worktree-path` set the *global* key, and a +/// `[projects.""]` entry still beats the global key whichever layer set +/// it. Overriding a project entry means naming that entry. +/// +/// Pins the precedence documented under "Environment variables → Precedence" +/// in the `wt config` help text (#3788). +#[rstest] +fn test_switch_create_project_worktree_path_outranks_invocation_layers(repo: TestRepo) { + set_github_remote_url(&repo); + repo.write_test_config( + r#" +[projects."github.com/owner/test-repo"] +worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" +"#, + ); + + let created_path = |args: &[&str], env: &[(&str, &str)]| { + let mut cmd = repo.wt_command(); + cmd.args(args); + for (key, value) in env { + cmd.env(key, value); + } + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "switch --create should succeed, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + PathBuf::from(json["path"].as_str().unwrap()) + }; + + // Env var sets the global key, so the project entry still wins. + let env_path = created_path( + &[ + "switch", + "--create", + "env-layer", + "--format=json", + "--no-cd", + ], + &[( + "WORKTRUNK_WORKTREE_PATH", + "{{ repo_path }}/../from-env-{{ branch | sanitize }}", + )], + ); + assert_eq!( + env_path.file_name().unwrap(), + "from-project-env-layer", + "project entry should outrank WORKTRUNK_WORKTREE_PATH, got {}", + env_path.display() + ); + + // Same for `--config-set` on the global key — it is the highest layer, + // not the most specific key. + let cli_global_path = created_path( + &[ + "--config-set", + r#"worktree-path = "{{ repo_path }}/../from-cli-{{ branch | sanitize }}""#, + "switch", + "--create", + "cli-global", + "--format=json", + "--no-cd", + ], + &[], + ); + assert_eq!( + cli_global_path.file_name().unwrap(), + "from-project-cli-global", + "project entry should outrank a global --config-set, got {}", + cli_global_path.display() + ); + + // Naming the project entry does override it. + let pinned_path = created_path( + &[ + "--config-set", + r#"projects."github.com/owner/test-repo".worktree-path = "{{ repo_path }}/../from-pin-{{ branch | sanitize }}""#, + "switch", + "--create", + "pinned", + "--format=json", + "--no-cd", + ], + &[], + ); + assert_eq!( + pinned_path.file_name().unwrap(), + "from-pin-pinned", + "--config-set on the project entry should win, got {}", + pinned_path.display() + ); +} + // ============================================================================ // PR Syntax Tests (pr:) // ============================================================================ diff --git a/tests/snapshots/integration__integration_tests__help__help_config_long.snap b/tests/snapshots/integration__integration_tests__help__help_config_long.snap index c0fe08fef..2ac589855 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -35,6 +35,7 @@ info: WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" WORKTRUNK_TEST_ZSH_INSTALLED: "0" --- @@ -617,6 +618,14 @@ On first run without shell integration, Worktrunk offers to install it. On first All user config options can be overridden with environment variables using the WORKTRUNK_ prefix. +Precedence + +Layer and specificity are separate axes. Layer decides which document supplies a key — system config, then user config, then WORKTRUNK_ env vars, then --config-set. Specificity is applied to the merged result: a user project-specific setting beats the global key of the same name, whichever layer set each one. + +So WORKTRUNK_WORKTREE_PATH overrides a global worktree-path, but a [projects."github.com/owner/repo"] entry that sets worktree-path still wins over it. To override a project entry for one invocation, name that entry — wt config show prints the identifier to use as the key: + +  wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature + Naming convention Config keys use kebab-case (worktree-path), while env vars use SCREAMING_SNAKE_CASE (WORKTRUNK_WORKTREE_PATH). The conversion happens automatically. @@ -656,7 +665,7 @@ Override the LLM command in CI to use a mock: Inline config overrides (--config-set) ---config-set  overrides any user config key for a single invocation, with higher priority than both config files and WORKTRUNK_ env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later --config-set replaces an earlier one for the same key. +--config-set  overrides any user config key for a single invocation, with higher priority than both config files and WORKTRUNK_ env vars. The value is a TOML fragment, so arrays and tables work directly; the flag is global (works before or after the subcommand), repeatable, and a later --config-set replaces an earlier one for the same key. It is the highest layer, not the most specific key — overriding a [projects."…"] setting means naming that entry, per the precedence rules.   wt --config-set list.full=true list   wt step copy-ignored --config-set 'step.copy-ignored.exclude=["target", "dist"]' From 60f05768e474a6d6c04928ef265a02a50535173d Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:22:07 +0000 Subject: [PATCH 2/2] test(switch): control for each invocation layer before the project entry --- tests/integration_tests/switch.rs | 62 ++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 4bb5f12dc..394b87b74 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -2437,17 +2437,15 @@ worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}" /// `[projects.""]` entry still beats the global key whichever layer set /// it. Overriding a project entry means naming that entry. /// +/// Both halves are load-bearing, so each layer runs once with no project entry +/// present — proving it reaches `worktree-path` at all — before the project +/// entry is added and shown to outrank it. +/// /// Pins the precedence documented under "Environment variables → Precedence" /// in the `wt config` help text (#3788). #[rstest] fn test_switch_create_project_worktree_path_outranks_invocation_layers(repo: TestRepo) { set_github_remote_url(&repo); - repo.write_test_config( - r#" -[projects."github.com/owner/test-repo"] -worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" -"#, - ); let created_path = |args: &[&str], env: &[(&str, &str)]| { let mut cmd = repo.wt_command(); @@ -2465,6 +2463,58 @@ worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" PathBuf::from(json["path"].as_str().unwrap()) }; + // Controls, with no `[projects."…"]` entry in play: both invocation layers + // do reach `worktree-path`. Without these, the assertions below would hold + // equally if the env var and the global `--config-set` were ignored + // outright, so they would stop discriminating "applied to the global key, + // then lost on specificity" from "never applied at all". + repo.write_test_config(""); + let env_only_path = created_path( + &[ + "switch", + "--create", + "env-control", + "--format=json", + "--no-cd", + ], + &[( + "WORKTRUNK_WORKTREE_PATH", + "{{ repo_path }}/../from-env-{{ branch | sanitize }}", + )], + ); + assert_eq!( + env_only_path.file_name().unwrap(), + "from-env-env-control", + "WORKTRUNK_WORKTREE_PATH should set the global key, got {}", + env_only_path.display() + ); + + let cli_only_path = created_path( + &[ + "--config-set", + r#"worktree-path = "{{ repo_path }}/../from-cli-{{ branch | sanitize }}""#, + "switch", + "--create", + "cli-control", + "--format=json", + "--no-cd", + ], + &[], + ); + assert_eq!( + cli_only_path.file_name().unwrap(), + "from-cli-cli-control", + "--config-set should set the global key, got {}", + cli_only_path.display() + ); + + repo.write_test_config( + r#" +[projects."github.com/owner/test-repo"] +worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" +"#, + ); + // Env var sets the global key, so the project entry still wins. let env_path = created_path( &[