From ddf54de091020544248c498fdf61518168bd79ea Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:37:30 +0000 Subject: [PATCH 01/10] fix(config): rank env vars and --config-set above project entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer and specificity were separate steps: `load_with_warnings` flattened system config, user config, `WORKTRUNK_*` env vars and `--config-set` into one document, and the accessors then resolved specificity on the result, so a `[projects.""]` entry beat the global key whichever layer set it. `WORKTRUNK_WORKTREE_PATH` therefore could not override a project's `worktree-path` (#3788), and neither could a global `--config-set`. Load now applies the two invocation layers at both scopes: whatever they set is dropped from every project entry, so the global key they also set answers for it. Held back are keys the layer restates under `projects.""` (the highest layer *and* the most specific key) and hooks/aliases, whose project-scoped values append to the global ones rather than replacing them — both already run, so nothing is outranked there. --- dev/config.example.toml | 2 +- docs/content/config.md | 19 +- .../skills/worktrunk/reference/config.md | 21 +- skills/worktrunk/reference/config.md | 21 +- src/cli/mod.rs | 21 +- src/config/user/mod.rs | 188 +++++++++++++++++- src/config/user/tests.rs | 188 +++++++++++++++++- tests/integration_tests/switch.rs | 95 ++++++++- ...gration_tests__help__help_config_long.snap | 20 +- 9 files changed, 556 insertions(+), 19 deletions(-) diff --git a/dev/config.example.toml b/dev/config.example.toml index e31e2814c..e273f0a05 100644 --- a/dev/config.example.toml +++ b/dev/config.example.toml @@ -238,7 +238,7 @@ # # Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. # -# Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +# Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence). # # [projects."github.com/user/repo"] # worktree-path = ".worktrees/{{ branch | sanitize }}" diff --git a/docs/content/config.md b/docs/content/config.md index 0e554bcee..bfa7310a3 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -344,7 +344,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). ```toml [projects."github.com/user/repo"] @@ -636,6 +636,23 @@ 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 + +System config sits under user config, and within either a [`[projects."…"]` entry](@/config.md#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: + +| Source of `worktree-path` | Loses to | +|---|---| +| `--config-set 'worktree-path = …'` | — | +| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | +| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | +| global `worktree-path` in a config file | all of the above | + +To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific 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") }} + +Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 2f91dd0d9..65dcdada7 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -341,7 +341,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). ```toml [projects."github.com/user/repo"] @@ -631,6 +631,25 @@ 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 + +System config sits under user config, and within either a [`[projects."…"]` entry](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: + +| Source of `worktree-path` | Loses to | +|---|---| +| `--config-set 'worktree-path = …'` | — | +| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | +| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | +| global `worktree-path` in a config file | all of the above | + +To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 2f91dd0d9..65dcdada7 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -341,7 +341,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). ```toml [projects."github.com/user/repo"] @@ -631,6 +631,25 @@ 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 + +System config sits under user config, and within either a [`[projects."…"]` entry](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: + +| Source of `worktree-path` | Loses to | +|---|---| +| `--config-set 'worktree-path = …'` | — | +| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | +| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | +| global `worktree-path` in a config file | all of the above | + +To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4e91f75c..0692d9b54 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2232,7 +2232,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). ```toml [projects."github.com/user/repo"] @@ -2522,6 +2522,25 @@ 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 + +System config sits under user config, and within either a [`[projects."…"]` entry](@/config.md#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: + +| Source of `worktree-path` | Loses to | +|---|---| +| `--config-set 'worktree-path = …'` | — | +| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | +| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | +| global `worktree-path` in a config file | all of the above | + +To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: + +```console +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. + ### Naming convention Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically. diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 02cef87fb..7f34c10e9 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -1,6 +1,19 @@ //! User-level configuration //! //! Personal preferences and per-project approved commands, not checked into git. +//! +//! # Precedence +//! +//! A setting is resolved along two axes. [`UserConfig::load_with_warnings`] +//! flattens the *layers* — system config, user config, `WORKTRUNK_*` env vars, +//! `--config-set` — into one document; the accessors then resolve +//! *specificity* on that document, letting a `[projects."…"]` entry answer for +//! the global key of the same name. +//! +//! The two invocation layers cross the axes: they are typed for one run, so +//! they outrank a project entry as well as a global key. Load applies them at +//! both scopes, which is [`apply_invocation_layer_over_projects`] — the last +//! step before [`UserConfig::finalize`]. mod accessors; mod merge; @@ -282,6 +295,140 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { } } +/// Let the invocation layers outrank `[projects."…"]` specificity. +/// +/// Layer (system → user → `WORKTRUNK_*` env vars → `--config-set`) and +/// specificity (global key → `[projects."…"]` entry) are separate steps: +/// [`UserConfig::load_with_warnings`] flattens the layers into one document, +/// and the accessors in `accessors` resolve specificity on the result. So a +/// project entry used to beat the global key whichever layer set it, and +/// `WORKTRUNK_WORKTREE_PATH` could not override a project's `worktree-path` +/// (#3788). +/// +/// The two invocation layers are per-invocation by construction — the user +/// typed them for *this* run — so they rank above specificity: `overlay` (the +/// env and `--config-set` values that applied) drops every key it sets from +/// every project entry, leaving the global key it also set to answer for them. +/// +/// Two kinds of key are held back: +/// +/// - Keys the overlay itself restates under `projects.""` — a +/// `--config-set 'projects."…".worktree-path = …'` is both the highest layer +/// *and* the most specific key, so it stays. +/// - Hooks and aliases, whose project-scoped values *append to* the global +/// ones rather than replace them (`UserConfig::hooks`, `UserConfig::aliases`). +/// Both already run, so an env-set hook is never outranked and dropping the +/// project's would silently stop it running. +/// +/// Only removals happen here, so the merged document still deserializes and +/// still validates; [`exclusive_sibling`] covers the one pair where a +/// *partial* removal would not. +fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: &toml::Table) { + let mut global = overlay.clone(); + global.remove("projects"); + for key in compose_only_keys() { + global.remove(&key); + } + if global.is_empty() { + return; + } + + let project_scoped = overlay.get("projects").and_then(toml::Value::as_table); + let Some(toml::Value::Table(projects)) = merged_table.get_mut("projects") else { + return; + }; + for (name, entry) in projects.iter_mut() { + let Some(entry) = entry.as_table_mut() else { + continue; + }; + let restated = project_scoped + .and_then(|scoped| scoped.get(name)) + .and_then(toml::Value::as_table); + drop_overridden_keys(entry, &global, restated, &mut Vec::new()); + } +} + +/// Remove from `entry` every leaf `overlay` sets, except those `restated` +/// carries. `section` tracks the path walked so far, for +/// [`exclusive_sibling`]. +fn drop_overridden_keys<'a>( + entry: &mut toml::Table, + overlay: &'a toml::Table, + restated: Option<&'a toml::Table>, + section: &mut Vec<&'a str>, +) { + for (key, value) in overlay { + let restated_value = restated.and_then(|table| table.get(key.as_str())); + + // An exclusive pair goes as a unit, whether or not `entry` carries + // `key` itself: the project's partner alone would still win the merge. + if let Some(sibling) = exclusive_sibling(section, key) + && !restated.is_some_and(|table| table.contains_key(sibling)) + { + entry.remove(sibling); + } + + match (entry.get_mut(key.as_str()), value, restated_value) { + // Both sides are sections: recurse, so an override of one leaf + // leaves the project's sibling leaves alone. + ( + Some(toml::Value::Table(entry_table)), + toml::Value::Table(overlay_table), + restated, + ) => { + section.push(key); + drop_overridden_keys( + entry_table, + overlay_table, + restated.and_then(toml::Value::as_table), + section, + ); + section.pop(); + if entry_table.is_empty() { + entry.remove(key.as_str()); + } + } + // The overlay restates this key at project scope, so it already + // sits in `entry` and outranks the overlay's global value. + (Some(_), _, Some(_)) => {} + (Some(_), _, None) => { + entry.remove(key.as_str()); + } + (None, _, _) => {} + } + } +} + +/// The key `key` clears when both are set in `section`. +/// +/// `[commit.generation]` rejects `template` alongside `template-file` +/// (`UserConfig::validate`), and setting either clears the other when a +/// project entry merges over the global one +/// (`CommitGenerationConfig::merge_with`). +/// So an invocation layer that sets one member has to displace *both* at +/// project scope: dropping only its own key would leave the project's partner +/// to win the merge, which is the precedence this module just removed. +fn exclusive_sibling(section: &[&str], key: &str) -> Option<&'static str> { + if section != ["commit", "generation"] { + return None; + } + match key { + "template" => Some("template-file"), + "template-file" => Some("template"), + "squash-template" => Some("squash-template-file"), + "squash-template-file" => Some("squash-template"), + _ => None, + } +} + +/// Keys whose project-scoped value composes with the global one instead of +/// replacing it, derived from the schema so a new hook can't be forgotten. +fn compose_only_keys() -> Vec { + let mut keys = crate::config::schema_top_level_keys::(); + keys.push("aliases".to_string()); + keys +} + /// Load and validate a single config file. Returns the parsed table for /// merging and validates via `toml::from_str::` for rich errors. fn load_config_file( @@ -525,6 +672,10 @@ impl UserConfig { ); } + // The invocation layers that actually applied, accumulated so step 5 + // can rank them above `[projects."…"]` specificity. + let mut invocation_overlay = toml::Table::new(); + // 3. Env-var overrides (override config files) let env_vars = parse_worktrunk_env_vars(); if !env_vars.is_empty() { @@ -534,7 +685,7 @@ impl UserConfig { // needs Integer for u64, WORKTRUNK_WORKTREE_PATH=42 needs String). let file_table = merged_table.clone(); let env_overlay = migrate_env_overlay(resolve_env_overlay(&file_table, &env_vars)); - deep_merge_table(&mut merged_table, env_overlay); + deep_merge_table(&mut merged_table, env_overlay.clone()); // Env overlay broke deserialization — fall back to file-only config. // Each file was individually validated by load_config_file(), so the @@ -548,11 +699,18 @@ impl UserConfig { .collect(), }); merged_table = file_table; + } else { + invocation_overlay = env_overlay; } } // 4. CLI `--config-set` overrides (override env vars and config files) - Self::apply_cli_overrides(cli_config_overrides(), &mut merged_table, &mut warnings); + let cli_overlay = + Self::apply_cli_overrides(cli_config_overrides(), &mut merged_table, &mut warnings); + deep_merge_table(&mut invocation_overlay, cli_overlay); + + // 5. Both invocation layers outrank `[projects."…"]` specificity + apply_invocation_layer_over_projects(&mut merged_table, &invocation_overlay); Self::finalize(merged_table, warnings) } @@ -581,39 +739,46 @@ impl UserConfig { /// the merged result fails to deserialize or validate, every override is /// dropped and a [`LoadError::CliOverride`] is recorded, so a bad override /// never silently corrupts (or wipes) the lower layers. + /// + /// Returns the fragments as one table, so the caller can rank them above + /// `[projects."…"]` specificity ([`apply_invocation_layer_over_projects`]). + /// A dropped layer returns an empty table, keeping "what applied" and + /// "what outranks a project entry" the same value. fn apply_cli_overrides( overrides: &[String], merged_table: &mut toml::Table, warnings: &mut Vec, - ) { + ) -> toml::Table { if overrides.is_empty() { - return; + return toml::Table::new(); } - let base = merged_table.clone(); + let mut overlay = toml::Table::new(); for raw in overrides { // `migrate_content` returns the fragment unchanged when it is not // valid TOML, so the parse below still catches a malformed fragment // and drops the whole layer with an attributed warning. let migrated = super::deprecation::migrate_content(raw); match migrated.parse::() { - Ok(fragment) => deep_merge_table(merged_table, fragment), + Ok(fragment) => deep_merge_table(&mut overlay, fragment), Err(err) => { warnings.push(LoadError::CliOverride { err: err.to_string(), overrides: overrides.to_vec(), }); - *merged_table = base; - return; + return toml::Table::new(); } } } + let mut candidate = merged_table.clone(); + deep_merge_table(&mut candidate, overlay.clone()); + // Probe deserialize *and* validate, so a semantically-invalid override // (e.g. an empty worktree-path) drops just this layer rather than // falling through to finalize(), which would wipe the lower layers to // defaults. - let probe = match toml::Value::Table(merged_table.clone()).try_into::() { + let probe = match toml::Value::Table(candidate.clone()).try_into::() { Ok(config) => config.validate().map_err(|e| e.0), Err(err) => Err(err.to_string()), }; @@ -622,8 +787,11 @@ impl UserConfig { err, overrides: overrides.to_vec(), }); - *merged_table = base; + return toml::Table::new(); } + + *merged_table = candidate; + overlay } /// Deserialize a merged table into `UserConfig`, validate, and collect diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 1c4778bc1..fbce45bab 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -2495,12 +2495,14 @@ fn test_load_error_display_cli_override() { // ========================================================================= /// Apply `--config-set` overrides to a base table the way `load_with_warnings` -/// does, returning the merged table plus any warnings. +/// does — including the step that ranks the layer above `[projects."…"]` +/// specificity — returning the merged table plus any warnings. fn apply_overrides(base: toml::Table, overrides: &[&str]) -> (toml::Table, Vec) { let overrides: Vec = overrides.iter().map(|s| s.to_string()).collect(); let mut table = base; let mut warnings = Vec::new(); - UserConfig::apply_cli_overrides(&overrides, &mut table, &mut warnings); + let overlay = UserConfig::apply_cli_overrides(&overrides, &mut table, &mut warnings); + apply_invocation_layer_over_projects(&mut table, &overlay); (table, warnings) } @@ -2645,6 +2647,188 @@ fn test_try_parse_value() { ); } +// ========================================================================= +// apply_invocation_layer_over_projects() — invocation layers vs `[projects]` +// ========================================================================= + +const PROJECT: &str = "github.com/owner/repo"; + +/// A base table with one project entry carrying `body`. +fn base_with_project(body: &str) -> toml::Table { + format!("[projects.\"{PROJECT}\"]\n{body}").parse().unwrap() +} + +fn loaded(table: toml::Table) -> UserConfig { + toml::Value::Table(table).try_into().unwrap() +} + +#[test] +fn test_invocation_layer_outranks_project_worktree_path() { + // The reported bug (#3788), on the `--config-set` half: a project entry's + // `worktree-path` no longer beats a global key the invocation layer set. + let base = base_with_project("worktree-path = \"/from-project\"\n"); + let (table, warnings) = apply_overrides(base, &["worktree-path = \"/from-cli\""]); + assert!(warnings.is_empty()); + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-cli" + ); +} + +#[test] +fn test_env_layer_outranks_project_worktree_path() { + // The env half of the same fix, driven through the overlay + // `load_with_warnings` builds rather than the process environment. + use super::{EnvVar, migrate_env_overlay, resolve_env_overlay, try_parse_value}; + let var = EnvVar { + name: "WORKTRUNK_WORKTREE_PATH".to_string(), + segments: vec!["worktree-path".to_string()], + typed_value: try_parse_value("/from-env"), + raw_value: "/from-env".to_string(), + }; + let mut table = base_with_project("worktree-path = \"/from-project\"\n"); + let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[var])); + deep_merge_table(&mut table, overlay.clone()); + apply_invocation_layer_over_projects(&mut table, &overlay); + + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-env" + ); +} + +#[test] +fn test_invocation_layer_leaves_untouched_project_keys() { + // Only the overridden key is displaced: a project entry's other settings, + // and its sibling keys inside the same section, still apply. + let base = base_with_project( + r#"worktree-path = "/from-project" + +[projects."github.com/owner/repo".list] +full = true +branches = true +"#, + ); + let (table, warnings) = apply_overrides(base, &["list.full = false"]); + assert!(warnings.is_empty()); + let config = loaded(table); + assert_eq!(config.worktree_path_for_project(PROJECT), "/from-project"); + let list = config.list(Some(PROJECT)); + assert_eq!(list.full, Some(false), "the invocation layer wins"); + assert_eq!(list.branches, Some(true), "sibling key survives"); +} + +#[test] +fn test_invocation_layer_keeps_its_own_project_scoped_override() { + // Naming the project entry is both the highest layer and the most + // specific key, so it outranks the same layer's global key. + let base = base_with_project("worktree-path = \"/from-project\"\n"); + let (table, warnings) = apply_overrides( + base, + &[ + "worktree-path = \"/from-cli-global\"", + &format!("projects.\"{PROJECT}\".worktree-path = \"/from-cli-project\""), + ], + ); + assert!(warnings.is_empty()); + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-cli-project" + ); +} + +#[test] +fn test_invocation_layer_applies_to_pattern_entries() { + // Pattern entries are project entries too — a `*` key must not smuggle a + // project-scoped value past the invocation layer. + let base: toml::Table = "[projects.\"github.com/*\"]\nworktree-path = \"/from-pattern\"\n" + .parse() + .unwrap(); + let (table, warnings) = apply_overrides(base, &["worktree-path = \"/from-cli\""]); + assert!(warnings.is_empty()); + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-cli" + ); +} + +#[test] +fn test_invocation_layer_leaves_composing_keys_alone() { + // Per-project hooks and aliases append to the global ones rather than + // replacing them, so both already run and there is no precedence to fix. + // Dropping the project's copy would silently stop it running. + let base = base_with_project( + r#"pre-merge = "project-hook" + +[projects."github.com/owner/repo".aliases] +ship = "project-alias" +"#, + ); + let (table, warnings) = apply_overrides( + base, + &["pre-merge = \"cli-hook\"", "aliases.ship = \"cli-alias\""], + ); + assert!(warnings.is_empty()); + let config = loaded(table); + let templates = |commands: &CommandConfig| { + commands + .commands() + .map(|command| command.template.clone()) + .collect::>() + }; + assert_eq!( + templates(&config.hooks(Some(PROJECT)).pre_merge.unwrap()), + ["cli-hook", "project-hook"] + ); + assert_eq!( + templates(&config.aliases(Some(PROJECT))["ship"]), + ["cli-alias", "project-alias"] + ); +} + +#[test] +fn test_invocation_layer_displaces_exclusive_sibling() { + // `template` and `template-file` clear one another, so overriding one has + // to displace both at project scope: leaving the project's `template-file` + // would let it win the merge (and would fail validation next to the + // global `template` the same entry now carries). + let base = base_with_project( + "[projects.\"github.com/owner/repo\".commit.generation]\ntemplate-file = \"/project.txt\"\n", + ); + let (table, warnings) = apply_overrides(base, &["commit.generation.template = \"from-cli\""]); + assert!(warnings.is_empty()); + let config = loaded(table); + config.validate().expect("still validates"); + let generation = config.commit_generation(Some(PROJECT)); + assert_eq!(generation.template.as_deref(), Some("from-cli")); + assert_eq!(generation.template_file, None); +} + +#[test] +fn test_invocation_layer_noop_without_overrides() { + // No invocation layer, no change: a project entry keeps every key. + let base = base_with_project("worktree-path = \"/from-project\"\n"); + let (table, warnings) = apply_overrides(base, &[]); + assert!(warnings.is_empty()); + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-project" + ); +} + +#[test] +fn test_dropped_invocation_layer_leaves_projects_intact() { + // A `--config-set` layer that rolls back (malformed fragment) overrides + // nothing, so it must not displace the project entry either. + let base = base_with_project("worktree-path = \"/from-project\"\n"); + let (table, warnings) = apply_overrides(base, &["worktree-path = \"/from-cli\"", "garbage"]); + assert_eq!(warnings.len(), 1); + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-project" + ); +} + #[test] fn test_env_overlay_migrates_deprecated_key() { use super::{EnvVar, migrate_env_overlay, resolve_env_overlay, try_parse_value}; diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 3cd32fcb7..2dae61b84 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,99 @@ worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}" ); } +/// The invocation layers outrank `[projects."…"]` specificity: +/// `WORKTRUNK_WORKTREE_PATH` and `--config-set worktree-path` both beat a +/// project entry, while a config file's global `worktree-path` still loses to +/// one (#3788). +/// +/// End-to-end because the fix lives in config *loading*: only a real process +/// reads `WORKTRUNK_WORKTREE_PATH` off the environment. +#[rstest] +fn test_switch_create_invocation_layers_outrank_project_worktree_path(repo: TestRepo) { + set_github_remote_url(&repo); + + 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()) + }; + let switch = + |branch: &'static str| vec!["switch", "--create", branch, "--format=json", "--no-cd"]; + const CLI_TEMPLATE: &str = + r#"worktree-path = "{{ repo_path }}/../from-cli-{{ branch | sanitize }}""#; + + repo.write_test_config( + r#" +worktree-path = "{{ repo_path }}/../from-global-{{ branch | sanitize }}" + +[projects."github.com/owner/test-repo"] +worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" +"#, + ); + + // Control: within the config file, the project entry still outranks the + // global key. Without this the assertions below would hold equally if + // project entries had stopped applying at all. + let file_path = created_path(&switch("file-layer"), &[]); + assert_eq!( + file_path.file_name().unwrap(), + "from-project-file-layer", + "project entry should outrank the file's global key, got {}", + file_path.display() + ); + + let env_path = created_path( + &switch("env-layer"), + &[( + "WORKTRUNK_WORKTREE_PATH", + "{{ repo_path }}/../from-env-{{ branch | sanitize }}", + )], + ); + assert_eq!( + env_path.file_name().unwrap(), + "from-env-env-layer", + "WORKTRUNK_WORKTREE_PATH should outrank the project entry, got {}", + env_path.display() + ); + + let mut cli_args = vec!["--config-set", CLI_TEMPLATE]; + cli_args.extend(switch("cli-layer")); + let cli_path = created_path(&cli_args, &[]); + assert_eq!( + cli_path.file_name().unwrap(), + "from-cli-cli-layer", + "--config-set should outrank the project entry, got {}", + cli_path.display() + ); + + // Naming the project entry is both the highest layer and the most + // specific key, so it wins over the same layer's global key. + let mut pinned_args = vec![ + "--config-set", + CLI_TEMPLATE, + "--config-set", + r#"projects."github.com/owner/test-repo".worktree-path = "{{ repo_path }}/../from-pin-{{ branch | sanitize }}""#, + ]; + pinned_args.extend(switch("pinned")); + let pinned_path = created_path(&pinned_args, &[]); + 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..51ddb8129 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" --- @@ -361,7 +362,7 @@ User config can include a [projects] table for project-specific settings Entries are keyed by project identifier — // derived from the primary remote URL (no .git suffix), or the canonical repo path when there is no remote. Run wt config show inside the repo to see the identifier for the current project; it appears in the PROJECT CONFIG section as Identifier: …. -Scalar values (like worktree-path) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +Scalar values (like worktree-path) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a WORKTRUNK_ env var or --config-set — see how the layers rank.   [projects."github.com/user/repo"]   worktree-path = ".worktrees/{{ branch | sanitize }}" @@ -617,6 +618,23 @@ 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 + +System config sits under user config, and within either a [projects."…"] entry outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: + + Source of worktree-path Loses to + ─────────────────────────────────────────────────── ─────────────────────── + --config-set 'worktree-path = …' — + WORKTRUNK_WORKTREE_PATH --config-set + [projects."github.com/owner/repo"] in a config file either invocation layer + global worktree-path in a config file all of the above + +To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: + +  wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature + +Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. + Naming convention Config keys use kebab-case (worktree-path), while env vars use SCREAMING_SNAKE_CASE (WORKTRUNK_WORKTREE_PATH). The conversion happens automatically. From 67371fc736eed8a4e535509b2172dbfb858ed472 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:49:05 +0000 Subject: [PATCH 02/10] test(help): refresh the config-create snapshot for the regenerated example TOML --- ...tegration__integration_tests__help__help_config_create.snap | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/snapshots/integration__integration_tests__help__help_config_create.snap b/tests/snapshots/integration__integration_tests__help__help_config_create.snap index 441405d99..7f7a8c3ac 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_create.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_create.snap @@ -36,6 +36,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" --- @@ -313,7 +314,7 @@ Creates ~/.config/worktrunk/config.toml with the following content:   #   # Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`.   # -  # Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. +  # Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence).   #   # [projects."github.com/user/repo"]   # worktree-path = ".worktrees/{{ branch | sanitize }}" From 392d478eed92b4eb367c7d8fa3b95ea3084c4b20 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:00:03 +0000 Subject: [PATCH 03/10] test(config): cover every exclusive-sibling pair and drop a dead guard --- src/config/user/mod.rs | 8 +++--- src/config/user/tests.rs | 55 ++++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 7f34c10e9..c8ce4a65d 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -337,10 +337,10 @@ fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: let Some(toml::Value::Table(projects)) = merged_table.get_mut("projects") else { return; }; - for (name, entry) in projects.iter_mut() { - let Some(entry) = entry.as_table_mut() else { - continue; - }; + let entries = projects + .iter_mut() + .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); + for (name, entry) in entries { let restated = project_scoped .and_then(|scoped| scoped.get(name)) .and_then(toml::Value::as_table); diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index fbce45bab..89557a9ef 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -2788,20 +2788,49 @@ ship = "project-alias" #[test] fn test_invocation_layer_displaces_exclusive_sibling() { - // `template` and `template-file` clear one another, so overriding one has - // to displace both at project scope: leaving the project's `template-file` - // would let it win the merge (and would fail validation next to the - // global `template` the same entry now carries). - let base = base_with_project( - "[projects.\"github.com/owner/repo\".commit.generation]\ntemplate-file = \"/project.txt\"\n", - ); - let (table, warnings) = apply_overrides(base, &["commit.generation.template = \"from-cli\""]); + // Each `[commit.generation]` pair clears itself, so overriding one member + // has to displace both at project scope: leaving the project's partner + // would let it win the merge, and it would fail validation next to the + // global key the same entry now answers with. + let pairs = [ + ("template", "template-file"), + ("template-file", "template"), + ("squash-template", "squash-template-file"), + ("squash-template-file", "squash-template"), + ]; + for (overridden, partner) in pairs { + let base = base_with_project(&format!( + "[projects.\"{PROJECT}\".commit.generation]\n{partner} = \"/project.txt\"\n" + )); + let (table, warnings) = apply_overrides( + base, + &[&format!("commit.generation.{overridden} = \"from-cli\"")], + ); + assert!(warnings.is_empty(), "{overridden}: {warnings:?}"); + let config = loaded(table); + config + .validate() + .unwrap_or_else(|e| panic!("{overridden}: {e}")); + let generation = config.commit_generation(Some(PROJECT)); + let value = |key: &str| match key { + "template" => generation.template.as_deref(), + "template-file" => generation.template_file.as_deref(), + "squash-template" => generation.squash_template.as_deref(), + _ => generation.squash_template_file.as_deref(), + }; + assert_eq!(value(overridden), Some("from-cli"), "{overridden}"); + assert_eq!(value(partner), None, "{overridden} should clear {partner}"); + } + + // A `[commit.generation]` key that is in no pair displaces only itself. + let base = base_with_project(&format!( + "[projects.\"{PROJECT}\".commit.generation]\ncommand = \"project-llm\"\ntemplate-file = \"/project.txt\"\n" + )); + let (table, warnings) = apply_overrides(base, &["commit.generation.command = \"cli-llm\""]); assert!(warnings.is_empty()); - let config = loaded(table); - config.validate().expect("still validates"); - let generation = config.commit_generation(Some(PROJECT)); - assert_eq!(generation.template.as_deref(), Some("from-cli")); - assert_eq!(generation.template_file, None); + let generation = loaded(table).commit_generation(Some(PROJECT)); + assert_eq!(generation.command.as_deref(), Some("cli-llm")); + assert_eq!(generation.template_file.as_deref(), Some("/project.txt")); } #[test] From 62fa3fd14234e3b2c127ba0acafb4f6b7d2b88e7 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 9 Aug 2026 15:29:47 -0700 Subject: [PATCH 04/10] docs(config): lift precedence out of the env-var section Three of the four sources it ranks are not environment variables, so `### Precedence` sat under the wrong parent. It becomes a `##` section of its own, after the two invocation layers it ranks above have been introduced. The table's `Loses to` column restated the row order, so the ranking is a numbered list saying what the order means: `worktree-path` comes from the first source that sets it. The lead names the principle behind the order rather than the internal layer/specificity vocabulary, and hooks and aliases follow from it instead of standing as an exception. `--config-set` and the env-var intro each stated their own rank; both now point at the one section that holds it. In the loader, the function docstring no longer restates the module docstring's two axes. --- dev/config.example.toml | 2 +- docs/content/config.md | 38 ++++++++--------- .../skills/worktrunk/reference/config.md | 42 +++++++++---------- skills/worktrunk/reference/config.md | 42 +++++++++---------- src/cli/mod.rs | 42 +++++++++---------- src/config/user/mod.rs | 33 +++++++-------- ...ation_tests__help__help_config_create.snap | 2 +- ...gration_tests__help__help_config_long.snap | 38 ++++++++--------- 8 files changed, 112 insertions(+), 127 deletions(-) diff --git a/dev/config.example.toml b/dev/config.example.toml index e273f0a05..683549b1d 100644 --- a/dev/config.example.toml +++ b/dev/config.example.toml @@ -238,7 +238,7 @@ # # Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. # -# Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence). +# Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence). # # [projects."github.com/user/repo"] # worktree-path = ".worktrees/{{ branch | sanitize }}" diff --git a/docs/content/config.md b/docs/content/config.md index bfa7310a3..5a0319e2f 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -344,7 +344,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). ```toml [projects."github.com/user/repo"] @@ -634,24 +634,7 @@ On first run without shell integration, Worktrunk offers to install it. On first ## Environment variables -All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. - -### Precedence - -System config sits under user config, and within either a [`[projects."…"]` entry](@/config.md#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: - -| Source of `worktree-path` | Loses to | -|---|---| -| `--config-set 'worktree-path = …'` | — | -| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | -| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | -| global `worktree-path` in a config file | all of the above | - -To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific 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") }} - -Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. +All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix, which [outrank config files](@/config.md#precedence). ### Naming convention @@ -692,12 +675,27 @@ 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, ahead of every other source. 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. {{ 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__]'") }} This composes with aliases — an alias body can invoke `wt --config-set … ` to render a named view without changing the saved config. +## Precedence + +Sources rank by how close they are to the invocation, which puts user config above system config. Within a config file, [an entry keyed to the project](@/config.md#user-project-specific-settings) outranks the global key of the same name. So `worktree-path` comes from the first of these that sets it: + +1. `--config-set 'worktree-path = …'` +2. `WORKTRUNK_WORKTREE_PATH` +3. `[projects."github.com/owner/repo"]` in a config file +4. global `worktree-path` in a config file + +A `--config-set` that names a project entry applies to that project alone: + +{{ 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") }} + +Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. + ## Command reference {% terminal() %} diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 65dcdada7..d30fb25f7 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -341,7 +341,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). ```toml [projects."github.com/user/repo"] @@ -629,26 +629,7 @@ On first run without shell integration, Worktrunk offers to install it. On first ## Environment variables -All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. - -### Precedence - -System config sits under user config, and within either a [`[projects."…"]` entry](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: - -| Source of `worktree-path` | Loses to | -|---|---| -| `--config-set 'worktree-path = …'` | — | -| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | -| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | -| global `worktree-path` in a config file | all of the above | - -To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: - -```bash -$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature -``` - -Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. +All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix, which [outrank config files](https://worktrunk.dev/config/#precedence). ### Naming convention @@ -691,7 +672,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, ahead of every other source. 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. ```bash $ wt --config-set list.full=true list @@ -700,6 +681,23 @@ $ wt step copy-ignored --config-set 'step.copy-ignored.exclude=["target", "dist" This composes with aliases — an alias body can invoke `wt --config-set … ` to render a named view without changing the saved config. +## Precedence + +Sources rank by how close they are to the invocation, which puts user config above system config. Within a config file, [an entry keyed to the project](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. So `worktree-path` comes from the first of these that sets it: + +1. `--config-set 'worktree-path = …'` +2. `WORKTRUNK_WORKTREE_PATH` +3. `[projects."github.com/owner/repo"]` in a config file +4. global `worktree-path` in a config file + +A `--config-set` that names a project entry applies to that project alone: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. + ## Command reference ``` diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 65dcdada7..d30fb25f7 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -341,7 +341,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](https://worktrunk.dev/config/#precedence). ```toml [projects."github.com/user/repo"] @@ -629,26 +629,7 @@ On first run without shell integration, Worktrunk offers to install it. On first ## Environment variables -All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. - -### Precedence - -System config sits under user config, and within either a [`[projects."…"]` entry](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: - -| Source of `worktree-path` | Loses to | -|---|---| -| `--config-set 'worktree-path = …'` | — | -| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | -| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | -| global `worktree-path` in a config file | all of the above | - -To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: - -```bash -$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature -``` - -Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. +All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix, which [outrank config files](https://worktrunk.dev/config/#precedence). ### Naming convention @@ -691,7 +672,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, ahead of every other source. 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. ```bash $ wt --config-set list.full=true list @@ -700,6 +681,23 @@ $ wt step copy-ignored --config-set 'step.copy-ignored.exclude=["target", "dist" This composes with aliases — an alias body can invoke `wt --config-set … ` to render a named view without changing the saved config. +## Precedence + +Sources rank by how close they are to the invocation, which puts user config above system config. Within a config file, [an entry keyed to the project](https://worktrunk.dev/config/#user-project-specific-settings) outranks the global key of the same name. So `worktree-path` comes from the first of these that sets it: + +1. `--config-set 'worktree-path = …'` +2. `WORKTRUNK_WORKTREE_PATH` +3. `[projects."github.com/owner/repo"]` in a config file +4. global `worktree-path` in a config file + +A `--config-set` that names a project entry applies to that project alone: + +```bash +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. + ## Command reference ``` diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0692d9b54..54e3b9f32 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2232,7 +2232,7 @@ User config can include a `[projects]` table for project-specific settings — w Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`. -Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). +Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see [how the layers rank](@/config.md#precedence). ```toml [projects."github.com/user/repo"] @@ -2520,26 +2520,7 @@ On first run without shell integration, Worktrunk offers to install it. On first ## Environment variables -All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix. - -### Precedence - -System config sits under user config, and within either a [`[projects."…"]` entry](@/config.md#user-project-specific-settings) outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: - -| Source of `worktree-path` | Loses to | -|---|---| -| `--config-set 'worktree-path = …'` | — | -| `WORKTRUNK_WORKTREE_PATH` | `--config-set` | -| `[projects."github.com/owner/repo"]` in a config file | either invocation layer | -| global `worktree-path` in a config file | all of the above | - -To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: - -```console -$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature -``` - -Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. +All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix, which [outrank config files](@/config.md#precedence). ### Naming convention @@ -2582,7 +2563,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, ahead of every other source. 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. ```console $ wt --config-set list.full=true list @@ -2590,6 +2571,23 @@ $ wt step copy-ignored --config-set 'step.copy-ignored.exclude=["target", "dist" ``` This composes with aliases — an alias body can invoke `wt --config-set … ` to render a named view without changing the saved config. + +## Precedence + +Sources rank by how close they are to the invocation, which puts user config above system config. Within a config file, [an entry keyed to the project](@/config.md#user-project-specific-settings) outranks the global key of the same name. So `worktree-path` comes from the first of these that sets it: + +1. `--config-set 'worktree-path = …'` +2. `WORKTRUNK_WORKTREE_PATH` +3. `[projects."github.com/owner/repo"]` in a config file +4. global `worktree-path` in a config file + +A `--config-set` that names a project entry applies to that project alone: + +```console +$ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature +``` + +Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index c8ce4a65d..534c926d9 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -7,13 +7,13 @@ //! A setting is resolved along two axes. [`UserConfig::load_with_warnings`] //! flattens the *layers* — system config, user config, `WORKTRUNK_*` env vars, //! `--config-set` — into one document; the accessors then resolve -//! *specificity* on that document, letting a `[projects."…"]` entry answer for -//! the global key of the same name. +//! *specificity* on that document, so a `[projects."…"]` entry answers for the +//! global key of the same name. //! -//! The two invocation layers cross the axes: they are typed for one run, so -//! they outrank a project entry as well as a global key. Load applies them at -//! both scopes, which is [`apply_invocation_layer_over_projects`] — the last -//! step before [`UserConfig::finalize`]. +//! The two invocation layers cross the axes: they come from the invocation +//! rather than a config file, so they outrank a project entry as well as a +//! global key. [`apply_invocation_layer_over_projects`] applies them at both +//! scopes, the last step before [`UserConfig::finalize`]. mod accessors; mod merge; @@ -297,18 +297,13 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// Let the invocation layers outrank `[projects."…"]` specificity. /// -/// Layer (system → user → `WORKTRUNK_*` env vars → `--config-set`) and -/// specificity (global key → `[projects."…"]` entry) are separate steps: -/// [`UserConfig::load_with_warnings`] flattens the layers into one document, -/// and the accessors in `accessors` resolve specificity on the result. So a -/// project entry used to beat the global key whichever layer set it, and -/// `WORKTRUNK_WORKTREE_PATH` could not override a project's `worktree-path` -/// (#3788). +/// The two axes, and why the invocation layers cross them, are in the module +/// docs. Without this pass, `WORKTRUNK_WORKTREE_PATH` could not override a +/// project's `worktree-path` (#3788). /// -/// The two invocation layers are per-invocation by construction — the user -/// typed them for *this* run — so they rank above specificity: `overlay` (the -/// env and `--config-set` values that applied) drops every key it sets from -/// every project entry, leaving the global key it also set to answer for them. +/// `overlay` is the env and `--config-set` values that applied: it drops every +/// key it sets from every project entry, leaving the global key it also set to +/// answer for them. /// /// Two kinds of key are held back: /// @@ -399,7 +394,7 @@ fn drop_overridden_keys<'a>( } } -/// The key `key` clears when both are set in `section`. +/// The key that `key` clears when both are set under `section`. /// /// `[commit.generation]` rejects `template` alongside `template-file` /// (`UserConfig::validate`), and setting either clears the other when a @@ -407,7 +402,7 @@ fn drop_overridden_keys<'a>( /// (`CommitGenerationConfig::merge_with`). /// So an invocation layer that sets one member has to displace *both* at /// project scope: dropping only its own key would leave the project's partner -/// to win the merge, which is the precedence this module just removed. +/// to win the merge — the ranking this pass exists to remove. fn exclusive_sibling(section: &[&str], key: &str) -> Option<&'static str> { if section != ["commit", "generation"] { return None; diff --git a/tests/snapshots/integration__integration_tests__help__help_config_create.snap b/tests/snapshots/integration__integration_tests__help__help_config_create.snap index 7f7a8c3ac..875b55fe0 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_create.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_create.snap @@ -314,7 +314,7 @@ Creates ~/.config/worktrunk/config.toml with the following content:   #   # Entries are keyed by project identifier — `//` derived from the primary remote URL (no `.git` suffix), or the canonical repo path when there is no remote. Run `wt config show` inside the repo to see the identifier for the current project; it appears in the `PROJECT CONFIG` section as `Identifier: …`.   # -  # Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence). +  # Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a `WORKTRUNK_` env var or `--config-set` — see how the layers rank (https://worktrunk.dev/config/#precedence).   #   # [projects."github.com/user/repo"]   # worktree-path = ".worktrees/{{ branch | sanitize }}" 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 51ddb8129..7a5dac5a0 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -362,7 +362,7 @@ User config can include a [projects] table for project-specific settings Entries are keyed by project identifier — // derived from the primary remote URL (no .git suffix), or the canonical repo path when there is no remote. Run wt config show inside the repo to see the identifier for the current project; it appears in the PROJECT CONFIG section as Identifier: …. -Scalar values (like worktree-path) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key in config files, but not a WORKTRUNK_ env var or --config-set — see how the layers rank. +Scalar values (like worktree-path) replace the global value; everything else (hooks, aliases, etc.) appends, global first. An entry outranks the global key of the same name, and both lose to a WORKTRUNK_ env var or --config-set — see how the layers rank.   [projects."github.com/user/repo"]   worktree-path = ".worktrees/{{ branch | sanitize }}" @@ -616,24 +616,7 @@ On first run without shell integration, Worktrunk offers to install it. On first Environment variables -All user config options can be overridden with environment variables using the WORKTRUNK_ prefix. - -Precedence - -System config sits under user config, and within either a [projects."…"] entry outranks the global key of the same name. The two invocation layers sit above all of that, because you typed them for this one run: - - Source of worktree-path Loses to - ─────────────────────────────────────────────────── ─────────────────────── - --config-set 'worktree-path = …' — - WORKTRUNK_WORKTREE_PATH --config-set - [projects."github.com/owner/repo"] in a config file either invocation layer - global worktree-path in a config file all of the above - -To aim an invocation override at one project rather than every project, name the entry — that is both the highest layer and the most specific key: - -  wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature - -Per-project hooks and aliases are unaffected: they append to the global ones rather than replacing them, so an env-set hook and a project hook both run. +All user config options can be overridden with environment variables using the WORKTRUNK_ prefix, which outrank config files. Naming convention @@ -674,11 +657,26 @@ 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, ahead of every other source. 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.   wt --config-set list.full=true list   wt step copy-ignored --config-set 'step.copy-ignored.exclude=["target", "dist"]' This composes with aliases — an alias body can invoke wt --config-set …  to render a named view without changing the saved config. +Precedence + +Sources rank by how close they are to the invocation, which puts user config above system config. Within a config file, an entry keyed to the project outranks the global key of the same name. So worktree-path comes from the first of these that sets it: + +1. --config-set 'worktree-path = …' +2. WORKTRUNK_WORKTREE_PATH +3. [projects."github.com/owner/repo"] in a config file +4. global worktree-path in a config file + +A --config-set that names a project entry applies to that project alone: + +  wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature + +Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. + ----- stderr ----- From c0d57041c1c973953909acb3dac39a2992e396e1 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:51:49 +0000 Subject: [PATCH 05/10] fix(config): take custom columns whole, spare composing excludes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two gaps in the step that ranks the invocation layers above `[projects."…"]` specificity. `[list.custom-columns]` merges per whole column, and `ListColumnConfig::template` is required, so removing one overridden leaf both left the project's column winning the merge anyway and stranded a column that no longer deserializes — `finalize` would then answer with `UserConfig::default()`, costing the user their whole config rather than one project entry. `is_atomic_section` names the table whose children go as a unit, alongside `exclusive_sibling`'s pairs. Both are enumerations, so the step now degrades as a unit behind them: the removals land on a candidate that is kept only if it still deserializes and validates, the same all-or-nothing guarantee the env and `--config-set` layers already have. `deserialize_and_validate` is that shared probe. `step.copy-ignored.exclude` unions the project's patterns onto the global ones (`CopyIgnoredConfig::merged_with`), exactly as hooks and aliases do, so it joins them in the held-back set — otherwise `--config-set 'step.copy-ignored.exclude=…'`, the example the docs use for the flag, silently stopped a project's patterns applying. The set is now a path list, cached in a `OnceLock` the way `is_user_project_override_key` caches its schema lookup, and an empty overlay returns before building it at all. --- docs/content/config.md | 2 +- .../skills/worktrunk/reference/config.md | 2 +- skills/worktrunk/reference/config.md | 2 +- src/cli/mod.rs | 2 +- src/config/user/mod.rs | 136 ++++++++++++++---- src/config/user/tests.rs | 64 ++++++++- ...gration_tests__help__help_config_long.snap | 2 +- 7 files changed, 170 insertions(+), 40 deletions(-) diff --git a/docs/content/config.md b/docs/content/config.md index 5a0319e2f..d0ad84865 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -694,7 +694,7 @@ A `--config-set` that names a project entry applies to that project alone: {{ 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") }} -Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. +Ranking only matters where one value has to win. Hooks, aliases and `step.copy-ignored.exclude` accumulate rather than replace, so an env-set hook and a project's hook both run. ## Command reference diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index d30fb25f7..7206117ae 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -696,7 +696,7 @@ A `--config-set` that names a project entry applies to that project alone: $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature ``` -Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. +Ranking only matters where one value has to win. Hooks, aliases and `step.copy-ignored.exclude` accumulate rather than replace, so an env-set hook and a project's hook both run. ## Command reference diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index d30fb25f7..7206117ae 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -696,7 +696,7 @@ A `--config-set` that names a project entry applies to that project alone: $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature ``` -Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. +Ranking only matters where one value has to win. Hooks, aliases and `step.copy-ignored.exclude` accumulate rather than replace, so an env-set hook and a project's hook both run. ## Command reference diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 54e3b9f32..74d0a2a72 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2587,7 +2587,7 @@ A `--config-set` that names a project entry applies to that project alone: $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature ``` -Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. +Ranking only matters where one value has to win. Hooks, aliases and `step.copy-ignored.exclude` accumulate rather than replace, so an env-set hook and a project's hook both run. diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 534c926d9..59829e76d 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -310,36 +310,79 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// - Keys the overlay itself restates under `projects.""` — a /// `--config-set 'projects."…".worktree-path = …'` is both the highest layer /// *and* the most specific key, so it stays. -/// - Hooks and aliases, whose project-scoped values *append to* the global -/// ones rather than replace them (`UserConfig::hooks`, `UserConfig::aliases`). -/// Both already run, so an env-set hook is never outranked and dropping the -/// project's would silently stop it running. +/// - Composing keys, whose project-scoped values *append to* the global ones +/// rather than replace them ([`compose_only_paths`]). Both already apply, so +/// an env-set hook is never outranked and dropping the project's would +/// silently stop it running. /// -/// Only removals happen here, so the merged document still deserializes and -/// still validates; [`exclusive_sibling`] covers the one pair where a -/// *partial* removal would not. +/// Only removals happen here, but a removal can still leave a document that no +/// longer deserializes: [`exclusive_sibling`] and [`is_atomic_section`] name +/// the sections that have to go as a unit, and the whole step degrades as a +/// unit behind them — the removals land on a candidate, and a candidate that +/// stops deserializing or validating is discarded rather than handed to +/// [`UserConfig::finalize`], which would answer a stranded required field by +/// wiping the config to defaults. That is the same all-or-nothing guarantee +/// the env and `--config-set` layers already have. fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: &toml::Table) { + if overlay.is_empty() || !merged_table.contains_key("projects") { + return; + } + let mut global = overlay.clone(); global.remove("projects"); - for key in compose_only_keys() { - global.remove(&key); + for path in compose_only_paths() { + remove_path(&mut global, path); } if global.is_empty() { return; } let project_scoped = overlay.get("projects").and_then(toml::Value::as_table); - let Some(toml::Value::Table(projects)) = merged_table.get_mut("projects") else { + let mut candidate = merged_table.clone(); + if let Some(toml::Value::Table(projects)) = candidate.get_mut("projects") { + let entries = projects + .iter_mut() + .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); + for (name, entry) in entries { + let restated = project_scoped + .and_then(|scoped| scoped.get(name)) + .and_then(toml::Value::as_table); + drop_overridden_keys(entry, &global, restated, &mut Vec::new()); + } + } + + match deserialize_and_validate(&candidate) { + Ok(()) => *merged_table = candidate, + // Unreachable through today's schema — the two enumerations above + // cover every partial removal it can break — but they are enumerations, + // and the next required field would otherwise cost the user their whole + // config rather than one project entry. + Err(err) => log::debug!("keeping project precedence: {err}"), + } +} + +/// Remove `path` from `table`, pruning any table the removal leaves empty so +/// an overlay of nothing but composing keys still reads as empty. +fn remove_path(table: &mut toml::Table, path: &[String]) { + let Some((key, rest)) = path.split_first() else { return; }; - let entries = projects - .iter_mut() - .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); - for (name, entry) in entries { - let restated = project_scoped - .and_then(|scoped| scoped.get(name)) - .and_then(toml::Value::as_table); - drop_overridden_keys(entry, &global, restated, &mut Vec::new()); + if rest.is_empty() { + table.remove(key.as_str()); + } else if let Some(toml::Value::Table(child)) = table.get_mut(key.as_str()) { + remove_path(child, rest); + if child.is_empty() { + table.remove(key.as_str()); + } + } +} + +/// Deserialize `table` into [`UserConfig`] and validate it, reporting the +/// first failure. The probe every layer runs before it commits. +fn deserialize_and_validate(table: &toml::Table) -> Result<(), String> { + match toml::Value::Table(table.clone()).try_into::() { + Ok(config) => config.validate().map_err(|e| e.0), + Err(err) => Err(err.to_string()), } } @@ -365,12 +408,14 @@ fn drop_overridden_keys<'a>( match (entry.get_mut(key.as_str()), value, restated_value) { // Both sides are sections: recurse, so an override of one leaf - // leaves the project's sibling leaves alone. + // leaves the project's sibling leaves alone. An atomic section's + // children are not sections in that sense — they go whole, through + // the arms below. ( Some(toml::Value::Table(entry_table)), toml::Value::Table(overlay_table), restated, - ) => { + ) if !is_atomic_section(section) => { section.push(key); drop_overridden_keys( entry_table, @@ -416,12 +461,45 @@ fn exclusive_sibling(section: &[&str], key: &str) -> Option<&'static str> { } } -/// Keys whose project-scoped value composes with the global one instead of -/// replacing it, derived from the schema so a new hook can't be forgotten. -fn compose_only_keys() -> Vec { - let mut keys = crate::config::schema_top_level_keys::(); - keys.push("aliases".to_string()); - keys +/// A table whose entries the merge replaces whole, so removing one of an +/// entry's leaves neither removes the precedence nor leaves the entry usable. +/// +/// `[list.custom-columns]` is the case: `ListConfig::merge_with` extends +/// `custom_columns` per *column*, so a project's `Ticket` replaces the global +/// `Ticket` outright — dropping only the overridden leaf would leave the +/// project's column winning anyway, the ranking this pass exists to remove. +/// And `ListColumnConfig::template` is required, so a partial removal can +/// strand a column that no longer deserializes. +/// +/// `section` is the path of the containing table, so this asks "are this +/// table's children atomic", the way [`exclusive_sibling`] asks about a pair. +fn is_atomic_section(section: &[&str]) -> bool { + section == ["list", "custom-columns"] +} + +/// Paths whose project-scoped value composes with the global one instead of +/// replacing it, so no invocation layer displaces them. +/// +/// Hook names come from the schema, so a new hook can't be forgotten; the +/// others are the composing keys elsewhere in the tree — `[aliases]` +/// (`UserConfig::aliases`) and `step.copy-ignored.exclude` +/// (`CopyIgnoredConfig::merged_with` unions the two pattern lists). +fn compose_only_paths() -> &'static [Vec] { + static PATHS: OnceLock>> = OnceLock::new(); + PATHS.get_or_init(|| { + let mut paths: Vec> = + crate::config::schema_top_level_keys::() + .into_iter() + .map(|key| vec![key]) + .collect(); + paths.push(vec!["aliases".to_string()]); + paths.push( + ["step", "copy-ignored", "exclude"] + .map(str::to_string) + .to_vec(), + ); + paths + }) } /// Load and validate a single config file. Returns the parsed table for @@ -773,11 +851,7 @@ impl UserConfig { // (e.g. an empty worktree-path) drops just this layer rather than // falling through to finalize(), which would wipe the lower layers to // defaults. - let probe = match toml::Value::Table(candidate.clone()).try_into::() { - Ok(config) => config.validate().map_err(|e| e.0), - Err(err) => Err(err.to_string()), - }; - if let Err(err) = probe { + if let Err(err) = deserialize_and_validate(&candidate) { warnings.push(LoadError::CliOverride { err, overrides: overrides.to_vec(), diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 89557a9ef..63cab13fc 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -2754,19 +2754,27 @@ fn test_invocation_layer_applies_to_pattern_entries() { #[test] fn test_invocation_layer_leaves_composing_keys_alone() { - // Per-project hooks and aliases append to the global ones rather than - // replacing them, so both already run and there is no precedence to fix. - // Dropping the project's copy would silently stop it running. + // Per-project hooks, aliases and copy-ignored excludes append to the + // global ones rather than replacing them, so both already apply and there + // is no precedence to fix. Dropping the project's copy would silently stop + // it applying. let base = base_with_project( r#"pre-merge = "project-hook" [projects."github.com/owner/repo".aliases] ship = "project-alias" + +[projects."github.com/owner/repo".step.copy-ignored] +exclude = ["project-pattern"] "#, ); let (table, warnings) = apply_overrides( base, - &["pre-merge = \"cli-hook\"", "aliases.ship = \"cli-alias\""], + &[ + "pre-merge = \"cli-hook\"", + "aliases.ship = \"cli-alias\"", + "step.copy-ignored.exclude = [\"cli-pattern\"]", + ], ); assert!(warnings.is_empty()); let config = loaded(table); @@ -2784,6 +2792,54 @@ ship = "project-alias" templates(&config.aliases(Some(PROJECT))["ship"]), ["cli-alias", "project-alias"] ); + assert_eq!( + config.copy_ignored(Some(PROJECT)).exclude, + ["cli-pattern", "project-pattern"] + ); +} + +#[test] +fn test_invocation_layer_displaces_whole_custom_column() { + // `[list.custom-columns]` merges per column, so an override of one leaf + // has to displace the whole column: leaving the rest of the project's + // column would let it replace the global one wholesale anyway, and + // `template` is required — a column stripped of it stops deserializing, + // which would cost the user their whole config rather than one entry. + let base = base_with_project( + r#"[projects."github.com/owner/repo".list.custom-columns.Ticket] +template = "{{ vars.ticket }}" +width = 30 +"#, + ); + let (table, warnings) = apply_overrides( + base, + &["list.custom-columns.Ticket.template = \"from-cli\""], + ); + assert!(warnings.is_empty()); + let column = loaded(table).list(Some(PROJECT)).custom_columns["Ticket"].clone(); + assert_eq!(column.template, "from-cli"); + assert_eq!(column.width, None, "the column went as a unit"); + + // Restating the column at project scope keeps it, as for any other key. + let base = base_with_project( + r#"[projects."github.com/owner/repo".list.custom-columns.Ticket] +template = "{{ vars.ticket }}" +width = 30 +"#, + ); + let (table, warnings) = apply_overrides( + base, + &[ + "list.custom-columns.Ticket.template = \"from-cli\"", + &format!( + "projects.\"{PROJECT}\".list.custom-columns.Ticket.template = \"from-cli-project\"" + ), + ], + ); + assert!(warnings.is_empty()); + let column = loaded(table).list(Some(PROJECT)).custom_columns["Ticket"].clone(); + assert_eq!(column.template, "from-cli-project"); + assert_eq!(column.width, Some(30)); } #[test] 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 7a5dac5a0..947c0e809 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -677,6 +677,6 @@ A --config-set that names a project entry applies to that project alone:   wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature -Ranking only matters where one value has to win. Hooks and aliases accumulate rather than replace, so an env-set hook and a project's hook both run. +Ranking only matters where one value has to win. Hooks, aliases and step.copy-ignored.exclude accumulate rather than replace, so an env-set hook and a project's hook both run. ----- stderr ----- From a36416b83f0aa76654007804fd33c7e31ad2b783 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:54:36 +0000 Subject: [PATCH 06/10] docs(config): give the project-scoped `--config-set` its rank back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numbered ranking reads as complete, so a reader who sets both a global and a project-scoped `--config-set` for one key takes the global one as the winner. The project-scoped one wins — it is both the highest layer and the most specific key, which is the `restated` branch in `drop_overridden_keys`. "Applies to that project alone" states the scope but not the rank the pre-move text carried. --- docs/content/config.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/config.md | 2 +- skills/worktrunk/reference/config.md | 2 +- src/cli/mod.rs | 2 +- .../integration__integration_tests__help__help_config_long.snap | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/config.md b/docs/content/config.md index d0ad84865..4b7010317 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -690,7 +690,7 @@ Sources rank by how close they are to the invocation, which puts user config abo 3. `[projects."github.com/owner/repo"]` in a config file 4. global `worktree-path` in a config file -A `--config-set` that names a project entry applies to that project alone: +A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific 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") }} diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 7206117ae..ac7204e71 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -690,7 +690,7 @@ Sources rank by how close they are to the invocation, which puts user config abo 3. `[projects."github.com/owner/repo"]` in a config file 4. global `worktree-path` in a config file -A `--config-set` that names a project entry applies to that project alone: +A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: ```bash $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 7206117ae..ac7204e71 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -690,7 +690,7 @@ Sources rank by how close they are to the invocation, which puts user config abo 3. `[projects."github.com/owner/repo"]` in a config file 4. global `worktree-path` in a config file -A `--config-set` that names a project entry applies to that project alone: +A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: ```bash $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 74d0a2a72..de9c3e669 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2581,7 +2581,7 @@ Sources rank by how close they are to the invocation, which puts user config abo 3. `[projects."github.com/owner/repo"]` in a config file 4. global `worktree-path` in a config file -A `--config-set` that names a project entry applies to that project alone: +A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: ```console $ wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature 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 947c0e809..d7b310ee7 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -673,7 +673,7 @@ Sources rank by how close they are to the invocation, which puts user config abo 3. [projects."github.com/owner/repo"] in a config file 4. global worktree-path in a config file -A --config-set that names a project entry applies to that project alone: +A --config-set that names a project entry applies to that project alone, and beats a global --config-set of the same key — it is both the highest layer and the most specific key:   wt --config-set 'projects."github.com/owner/repo".worktree-path = "/tmp/scratch"' switch --create feature From 7e09513a1c375c649155dc82633b92e6128713f1 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:08:00 +0000 Subject: [PATCH 07/10] refactor(config): hold composing keys back by predicate, not path list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `step.copy-ignored.exclude` made the held-back set a nested path, and a path list needs a walker with an empty-path case that nothing reaches. Asking `is_compose_only(section, key)` inside the walk instead puts the rule alongside `exclusive_sibling` and `is_atomic_section`, which already answer per section, and leaves every arm reachable from a test. The projects table is now taken with `let … else` after the clone rather than probed twice — the `contains_key` guard made the `if let` beneath it dead. --- src/config/user/mod.rs | 94 ++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 59829e76d..3110b5c4b 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -311,9 +311,9 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// `--config-set 'projects."…".worktree-path = …'` is both the highest layer /// *and* the most specific key, so it stays. /// - Composing keys, whose project-scoped values *append to* the global ones -/// rather than replace them ([`compose_only_paths`]). Both already apply, so -/// an env-set hook is never outranked and dropping the project's would -/// silently stop it running. +/// rather than replace them ([`is_compose_only`]). Both already apply, so an +/// env-set hook is never outranked and dropping the project's would silently +/// stop it running. /// /// Only removals happen here, but a removal can still leave a document that no /// longer deserializes: [`exclusive_sibling`] and [`is_atomic_section`] name @@ -324,31 +324,32 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// wiping the config to defaults. That is the same all-or-nothing guarantee /// the env and `--config-set` layers already have. fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: &toml::Table) { - if overlay.is_empty() || !merged_table.contains_key("projects") { + if overlay.is_empty() { return; } let mut global = overlay.clone(); global.remove("projects"); - for path in compose_only_paths() { - remove_path(&mut global, path); - } if global.is_empty() { return; } let project_scoped = overlay.get("projects").and_then(toml::Value::as_table); let mut candidate = merged_table.clone(); - if let Some(toml::Value::Table(projects)) = candidate.get_mut("projects") { - let entries = projects - .iter_mut() - .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); - for (name, entry) in entries { - let restated = project_scoped - .and_then(|scoped| scoped.get(name)) - .and_then(toml::Value::as_table); - drop_overridden_keys(entry, &global, restated, &mut Vec::new()); - } + let Some(projects) = candidate + .get_mut("projects") + .and_then(toml::Value::as_table_mut) + else { + return; + }; + let entries = projects + .iter_mut() + .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); + for (name, entry) in entries { + let restated = project_scoped + .and_then(|scoped| scoped.get(name)) + .and_then(toml::Value::as_table); + drop_overridden_keys(entry, &global, restated, &mut Vec::new()); } match deserialize_and_validate(&candidate) { @@ -361,22 +362,6 @@ fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: } } -/// Remove `path` from `table`, pruning any table the removal leaves empty so -/// an overlay of nothing but composing keys still reads as empty. -fn remove_path(table: &mut toml::Table, path: &[String]) { - let Some((key, rest)) = path.split_first() else { - return; - }; - if rest.is_empty() { - table.remove(key.as_str()); - } else if let Some(toml::Value::Table(child)) = table.get_mut(key.as_str()) { - remove_path(child, rest); - if child.is_empty() { - table.remove(key.as_str()); - } - } -} - /// Deserialize `table` into [`UserConfig`] and validate it, reporting the /// first failure. The probe every layer runs before it commits. fn deserialize_and_validate(table: &toml::Table) -> Result<(), String> { @@ -396,6 +381,9 @@ fn drop_overridden_keys<'a>( section: &mut Vec<&'a str>, ) { for (key, value) in overlay { + if is_compose_only(section, key) { + continue; + } let restated_value = restated.and_then(|table| table.get(key.as_str())); // An exclusive pair goes as a unit, whether or not `entry` carries @@ -477,29 +465,27 @@ fn is_atomic_section(section: &[&str]) -> bool { section == ["list", "custom-columns"] } -/// Paths whose project-scoped value composes with the global one instead of -/// replacing it, so no invocation layer displaces them. +/// Whether the project-scoped `key` under `section` composes with the global +/// one instead of replacing it, so no invocation layer displaces it. /// -/// Hook names come from the schema, so a new hook can't be forgotten; the -/// others are the composing keys elsewhere in the tree — `[aliases]` -/// (`UserConfig::aliases`) and `step.copy-ignored.exclude` +/// Hook names come from the schema, cached the way +/// `config::is_user_project_override_key` caches its own lookup, so a new hook +/// can't be forgotten. The others are the composing keys elsewhere in the tree +/// — `[aliases]` (`UserConfig::aliases`) and `step.copy-ignored.exclude` /// (`CopyIgnoredConfig::merged_with` unions the two pattern lists). -fn compose_only_paths() -> &'static [Vec] { - static PATHS: OnceLock>> = OnceLock::new(); - PATHS.get_or_init(|| { - let mut paths: Vec> = - crate::config::schema_top_level_keys::() - .into_iter() - .map(|key| vec![key]) - .collect(); - paths.push(vec!["aliases".to_string()]); - paths.push( - ["step", "copy-ignored", "exclude"] - .map(str::to_string) - .to_vec(), - ); - paths - }) +fn is_compose_only(section: &[&str], key: &str) -> bool { + static HOOKS: OnceLock> = OnceLock::new(); + match section { + [] => { + key == "aliases" + || HOOKS + .get_or_init(crate::config::schema_top_level_keys::) + .iter() + .any(|hook| hook == key) + } + ["step", "copy-ignored"] => key == "exclude", + _ => false, + } } /// Load and validate a single config file. Returns the parsed table for From 3ca7e0d9f266ca0194647d5c600f160913bac032 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:19:02 +0000 Subject: [PATCH 08/10] fix(config): restore the no-projects guard, correct the discard comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `contains_key("projects")` half of the entry guard turned into the `let … else` below it, which sits after both clones — so a config with env vars or `--config-set` in play and no `[projects]` table paid for them on every load before bailing. The `let … else` stays as the `get_mut` and the non-table backstop. The discard arm's comment claimed it was unreachable. It isn't: step 3's env probe deserializes without validating, so a semantically-invalid env value reaches this pass in an already-invalid candidate and lands there on the pre-existing failure rather than on anything the removals did. Behavior is right either way — `finalize` reports it — but the comment named a dead arm that isn't. `test_invocation_layer_keeps_an_already_invalid_candidate_untouched` pins it, with a control showing the same overlay does remove the project's key when it validates. An overlay of nothing but composing keys still clones and probes: the path list could be pruned out of `global` before the emptiness check, a predicate asked per section can't. That is the cost of the simpler shape, on invocations that already set an env var or `--config-set`. --- src/config/user/mod.rs | 13 ++++++++----- src/config/user/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 3110b5c4b..72229d066 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -324,7 +324,7 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// wiping the config to defaults. That is the same all-or-nothing guarantee /// the env and `--config-set` layers already have. fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: &toml::Table) { - if overlay.is_empty() { + if overlay.is_empty() || !merged_table.contains_key("projects") { return; } @@ -354,10 +354,13 @@ fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: match deserialize_and_validate(&candidate) { Ok(()) => *merged_table = candidate, - // Unreachable through today's schema — the two enumerations above - // cover every partial removal it can break — but they are enumerations, - // and the next required field would otherwise cost the user their whole - // config rather than one project entry. + // Reachable two ways. A partial removal the enumerations above miss — + // none today, but they are enumerations, and the next required field + // would otherwise cost the user their whole config rather than one + // project entry. Or a candidate that was already invalid before this + // pass: step 3's env probe deserializes without validating, so an + // empty `worktree-path` from the environment lands here. The discard + // is right for both; `finalize` reports the second. Err(err) => log::debug!("keeping project precedence: {err}"), } } diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 63cab13fc..76f7e2b71 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -2697,6 +2697,38 @@ fn test_env_layer_outranks_project_worktree_path() { ); } +#[test] +fn test_invocation_layer_keeps_an_already_invalid_candidate_untouched() { + // The pass discards a candidate that does not deserialize and validate. + // Step 3's env probe only deserializes, so an empty `worktree-path` from + // the environment reaches here already invalid — and the removals are + // dropped rather than handed to `finalize`, which would answer the same + // failure by wiping the config to defaults. + use super::{EnvVar, migrate_env_overlay, resolve_env_overlay, try_parse_value}; + let empty_path = |value: &str| EnvVar { + name: "WORKTRUNK_WORKTREE_PATH".to_string(), + segments: vec!["worktree-path".to_string()], + typed_value: try_parse_value(value), + raw_value: value.to_string(), + }; + let mut table = base_with_project("worktree-path = \"/from-project\"\n"); + let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[empty_path("")])); + deep_merge_table(&mut table, overlay.clone()); + let before = table.clone(); + apply_invocation_layer_over_projects(&mut table, &overlay); + assert_eq!(table, before, "the removals are discarded as a unit"); + + // Control: the same overlay with a valid value does remove the project's + // key, so the assertion above is the discard and not a pass that found + // nothing to do. + let mut table = base_with_project("worktree-path = \"/from-project\"\n"); + let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[empty_path("/from-env")])); + deep_merge_table(&mut table, overlay.clone()); + let before = table.clone(); + apply_invocation_layer_over_projects(&mut table, &overlay); + assert_ne!(table, before); +} + #[test] fn test_invocation_layer_leaves_untouched_project_keys() { // Only the overridden key is displaced: a project entry's other settings, From ad2feb382b811965042ed501da2a8e85985ae40b Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 10 Aug 2026 03:44:06 -0700 Subject: [PATCH 09/10] fix(config): rank every layer above the entries below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass ranked the two invocation layers above `[projects."…"]` specificity and left the third boundary alone: a system config's project entry still answered for a key the user's own config set globally, which is #3788 one layer down. system: [projects.""] worktree-path = "…/from-system-project" user: worktree-path = "…/from-user-global" $ wt switch --create feature # landed in from-system-project So it becomes `merge_layer`, which every layer goes through — system, user, `WORKTRUNK_*`, `--config-set` alike. Specificity is then a rule inside a layer rather than a second axis over all of them, and the flattened document reads back as the ordered one. Dropping before the merge rather than after retires `restated`: a key the layer restates under `projects.""` lands after the drop and wins, with nothing left to recognize the case. `drop_overridden_keys` loses an argument and a match arm, `apply_cli_overrides` applies its own layer instead of handing an overlay to a later pass, and the accumulator that carried it is gone. The removals still degrade as a unit, now falling back to the plain merge so the layer applies either way. One behavior changes with `restated`. A `--config-set` that restates an atomic section at project scope states it whole: the global it also set has already displaced the entry below, so a restated `custom-columns` column no longer keeps that entry's `width`. Blending the two was what `is_atomic_section` existed to stop, so the column going as a unit on both sides of the boundary is the rule it was named for. --- docs/content/config.md | 4 +- .../skills/worktrunk/reference/config.md | 4 +- skills/worktrunk/reference/config.md | 4 +- src/cli/mod.rs | 4 +- src/config/user/mod.rs | 188 +++++++----------- src/config/user/tests.rs | 85 +++++--- tests/integration_tests/switch.rs | 49 ++++- ...gration_tests__help__help_config_long.snap | 4 +- 8 files changed, 181 insertions(+), 161 deletions(-) diff --git a/docs/content/config.md b/docs/content/config.md index 4b7010317..b89780205 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -687,8 +687,8 @@ Sources rank by how close they are to the invocation, which puts user config abo 1. `--config-set 'worktree-path = …'` 2. `WORKTRUNK_WORKTREE_PATH` -3. `[projects."github.com/owner/repo"]` in a config file -4. global `worktree-path` in a config file +3. `[projects."github.com/owner/repo"]` in the config file +4. global `worktree-path` in the config file A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index ac7204e71..1880000a8 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -687,8 +687,8 @@ Sources rank by how close they are to the invocation, which puts user config abo 1. `--config-set 'worktree-path = …'` 2. `WORKTRUNK_WORKTREE_PATH` -3. `[projects."github.com/owner/repo"]` in a config file -4. global `worktree-path` in a config file +3. `[projects."github.com/owner/repo"]` in the config file +4. global `worktree-path` in the config file A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index ac7204e71..1880000a8 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -687,8 +687,8 @@ Sources rank by how close they are to the invocation, which puts user config abo 1. `--config-set 'worktree-path = …'` 2. `WORKTRUNK_WORKTREE_PATH` -3. `[projects."github.com/owner/repo"]` in a config file -4. global `worktree-path` in a config file +3. `[projects."github.com/owner/repo"]` in the config file +4. global `worktree-path` in the config file A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index de9c3e669..f91452bfa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2578,8 +2578,8 @@ Sources rank by how close they are to the invocation, which puts user config abo 1. `--config-set 'worktree-path = …'` 2. `WORKTRUNK_WORKTREE_PATH` -3. `[projects."github.com/owner/repo"]` in a config file -4. global `worktree-path` in a config file +3. `[projects."github.com/owner/repo"]` in the config file +4. global `worktree-path` in the config file A `--config-set` that names a project entry applies to that project alone, and beats a global `--config-set` of the same key — it is both the highest layer and the most specific key: diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 72229d066..881d79ab4 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -4,16 +4,18 @@ //! //! # Precedence //! -//! A setting is resolved along two axes. [`UserConfig::load_with_warnings`] -//! flattens the *layers* — system config, user config, `WORKTRUNK_*` env vars, -//! `--config-set` — into one document; the accessors then resolve -//! *specificity* on that document, so a `[projects."…"]` entry answers for the -//! global key of the same name. +//! Sources rank by how close they are to the invocation — system config, user +//! config, `WORKTRUNK_*` env vars, `--config-set`, lowest first. Within one of +//! them a `[projects."…"]` entry outranks the global key of the same name, so +//! specificity is a rule inside a layer rather than a second axis over all of +//! them: a lower layer's entry loses to a higher layer's global key. //! -//! The two invocation layers cross the axes: they come from the invocation -//! rather than a config file, so they outrank a project entry as well as a -//! global key. [`apply_invocation_layer_over_projects`] applies them at both -//! scopes, the last step before [`UserConfig::finalize`]. +//! [`UserConfig::load_with_warnings`] flattens the layers into one document and +//! the accessors resolve specificity on the result, which is what costs the +//! order — a document holds one set of entries, and reading it can only let +//! them win. So every layer goes on through [`merge_layer`], which first drops +//! what the layer sets globally from the entries beneath it, and the flattened +//! document then reads back as the ordered one. mod accessors; mod merge; @@ -295,73 +297,68 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { } } -/// Let the invocation layers outrank `[projects."…"]` specificity. +/// Merge one layer over `merged_table`, ranking its global keys above the +/// `[projects."…"]` entries the lower layers left there. /// -/// The two axes, and why the invocation layers cross them, are in the module -/// docs. Without this pass, `WORKTRUNK_WORKTREE_PATH` could not override a -/// project's `worktree-path` (#3788). +/// Every leaf `layer` sets globally is dropped from the accumulated entries +/// first, so the global key it also set is what answers for them; the layer +/// then merges on top, its own entries included. Dropping before the merge is +/// what keeps specificity local to a layer — a key the layer restates under +/// `projects.""` lands after the drop and wins, with nothing here having +/// to recognize the case. /// -/// `overlay` is the env and `--config-set` values that applied: it drops every -/// key it sets from every project entry, leaving the global key it also set to -/// answer for them. +/// Composing keys are held back, their project-scoped values appending to the +/// global ones rather than replacing them ([`is_compose_only`]). Both already +/// apply, so there is no ranking to settle, and dropping the project's copy +/// would silently stop it running. /// -/// Two kinds of key are held back: -/// -/// - Keys the overlay itself restates under `projects.""` — a -/// `--config-set 'projects."…".worktree-path = …'` is both the highest layer -/// *and* the most specific key, so it stays. -/// - Composing keys, whose project-scoped values *append to* the global ones -/// rather than replace them ([`is_compose_only`]). Both already apply, so an -/// env-set hook is never outranked and dropping the project's would silently -/// stop it running. -/// -/// Only removals happen here, but a removal can still leave a document that no -/// longer deserializes: [`exclusive_sibling`] and [`is_atomic_section`] name -/// the sections that have to go as a unit, and the whole step degrades as a -/// unit behind them — the removals land on a candidate, and a candidate that -/// stops deserializing or validating is discarded rather than handed to -/// [`UserConfig::finalize`], which would answer a stranded required field by -/// wiping the config to defaults. That is the same all-or-nothing guarantee -/// the env and `--config-set` layers already have. -fn apply_invocation_layer_over_projects(merged_table: &mut toml::Table, overlay: &toml::Table) { - if overlay.is_empty() || !merged_table.contains_key("projects") { - return; - } - - let mut global = overlay.clone(); +/// Only removals precede the merge, but a removal can still leave a document +/// that no longer deserializes: [`exclusive_sibling`] and [`is_atomic_section`] +/// name the sections that have to go as a unit, and the removals degrade as a +/// unit behind them — they land on a candidate, and a candidate that stops +/// deserializing or validating is dropped for the plain merge rather than +/// handed to [`UserConfig::finalize`], which would answer a stranded required +/// field by wiping the config to defaults. The layer itself applies either way. +fn merge_layer(merged_table: &mut toml::Table, layer: toml::Table) { + let mut global = layer.clone(); global.remove("projects"); - if global.is_empty() { + + // Nothing to rank: no entries beneath, or nothing above them. The common + // case is a config with no `[projects]` table at all, and it pays only the + // lookup. + if global.is_empty() || !merged_table.contains_key("projects") { + deep_merge_table(merged_table, layer); return; } - let project_scoped = overlay.get("projects").and_then(toml::Value::as_table); let mut candidate = merged_table.clone(); - let Some(projects) = candidate + if let Some(projects) = candidate .get_mut("projects") .and_then(toml::Value::as_table_mut) - else { - return; - }; - let entries = projects - .iter_mut() - .filter_map(|(name, entry)| entry.as_table_mut().map(|entry| (name, entry))); - for (name, entry) in entries { - let restated = project_scoped - .and_then(|scoped| scoped.get(name)) - .and_then(toml::Value::as_table); - drop_overridden_keys(entry, &global, restated, &mut Vec::new()); + { + for entry in projects + .iter_mut() + .filter_map(|(_, entry)| entry.as_table_mut()) + { + drop_overridden_keys(entry, &global, &mut Vec::new()); + } } + deep_merge_table(&mut candidate, layer.clone()); match deserialize_and_validate(&candidate) { Ok(()) => *merged_table = candidate, // Reachable two ways. A partial removal the enumerations above miss — // none today, but they are enumerations, and the next required field // would otherwise cost the user their whole config rather than one - // project entry. Or a candidate that was already invalid before this - // pass: step 3's env probe deserializes without validating, so an - // empty `worktree-path` from the environment lands here. The discard - // is right for both; `finalize` reports the second. - Err(err) => log::debug!("keeping project precedence: {err}"), + // project entry. Or a document that was already invalid before this + // layer: step 3's env probe deserializes without validating, so an + // empty `worktree-path` from the environment lands here. Merging + // without the removals is right for both; `finalize` reports the + // second. + Err(err) => { + log::debug!("keeping project precedence: {err}"); + deep_merge_table(merged_table, layer); + } } } @@ -374,58 +371,43 @@ fn deserialize_and_validate(table: &toml::Table) -> Result<(), String> { } } -/// Remove from `entry` every leaf `overlay` sets, except those `restated` -/// carries. `section` tracks the path walked so far, for -/// [`exclusive_sibling`]. +/// Remove from `entry` every leaf `overlay` sets. `section` tracks the path +/// walked so far, for [`exclusive_sibling`] and the predicates beside it. fn drop_overridden_keys<'a>( entry: &mut toml::Table, overlay: &'a toml::Table, - restated: Option<&'a toml::Table>, section: &mut Vec<&'a str>, ) { for (key, value) in overlay { if is_compose_only(section, key) { continue; } - let restated_value = restated.and_then(|table| table.get(key.as_str())); // An exclusive pair goes as a unit, whether or not `entry` carries // `key` itself: the project's partner alone would still win the merge. - if let Some(sibling) = exclusive_sibling(section, key) - && !restated.is_some_and(|table| table.contains_key(sibling)) - { + if let Some(sibling) = exclusive_sibling(section, key) { entry.remove(sibling); } - match (entry.get_mut(key.as_str()), value, restated_value) { + match (entry.get_mut(key.as_str()), value) { // Both sides are sections: recurse, so an override of one leaf // leaves the project's sibling leaves alone. An atomic section's // children are not sections in that sense — they go whole, through // the arms below. - ( - Some(toml::Value::Table(entry_table)), - toml::Value::Table(overlay_table), - restated, - ) if !is_atomic_section(section) => { + (Some(toml::Value::Table(entry_table)), toml::Value::Table(overlay_table)) + if !is_atomic_section(section) => + { section.push(key); - drop_overridden_keys( - entry_table, - overlay_table, - restated.and_then(toml::Value::as_table), - section, - ); + drop_overridden_keys(entry_table, overlay_table, section); section.pop(); if entry_table.is_empty() { entry.remove(key.as_str()); } } - // The overlay restates this key at project scope, so it already - // sits in `entry` and outranks the overlay's global value. - (Some(_), _, Some(_)) => {} - (Some(_), _, None) => { + (Some(_), _) => { entry.remove(key.as_str()); } - (None, _, _) => {} + (None, _) => {} } } } @@ -436,7 +418,7 @@ fn drop_overridden_keys<'a>( /// (`UserConfig::validate`), and setting either clears the other when a /// project entry merges over the global one /// (`CommitGenerationConfig::merge_with`). -/// So an invocation layer that sets one member has to displace *both* at +/// So a layer that sets one member has to displace *both* at /// project scope: dropping only its own key would leave the project's partner /// to win the merge — the ranking this pass exists to remove. fn exclusive_sibling(section: &[&str], key: &str) -> Option<&'static str> { @@ -469,7 +451,7 @@ fn is_atomic_section(section: &[&str]) -> bool { } /// Whether the project-scoped `key` under `section` composes with the global -/// one instead of replacing it, so no invocation layer displaces it. +/// one instead of replacing it, so no layer displaces it. /// /// Hook names come from the schema, cached the way /// `config::is_user_project_override_key` caches its own lookup, so a new hook @@ -677,7 +659,7 @@ impl UserConfig { &result.migrated_content, ConfigFileKind::System, ) { - Ok(table) => deep_merge_table(&mut merged_table, table), + Ok(table) => merge_layer(&mut merged_table, table), Err(e) => warnings.push(e), } } @@ -713,7 +695,7 @@ impl UserConfig { &result.migrated_content, ConfigFileKind::User, ) { - Ok(table) => deep_merge_table(&mut merged_table, table), + Ok(table) => merge_layer(&mut merged_table, table), Err(e) => warnings.push(e), } } @@ -734,10 +716,6 @@ impl UserConfig { ); } - // The invocation layers that actually applied, accumulated so step 5 - // can rank them above `[projects."…"]` specificity. - let mut invocation_overlay = toml::Table::new(); - // 3. Env-var overrides (override config files) let env_vars = parse_worktrunk_env_vars(); if !env_vars.is_empty() { @@ -747,7 +725,7 @@ impl UserConfig { // needs Integer for u64, WORKTRUNK_WORKTREE_PATH=42 needs String). let file_table = merged_table.clone(); let env_overlay = migrate_env_overlay(resolve_env_overlay(&file_table, &env_vars)); - deep_merge_table(&mut merged_table, env_overlay.clone()); + merge_layer(&mut merged_table, env_overlay); // Env overlay broke deserialization — fall back to file-only config. // Each file was individually validated by load_config_file(), so the @@ -761,18 +739,11 @@ impl UserConfig { .collect(), }); merged_table = file_table; - } else { - invocation_overlay = env_overlay; } } // 4. CLI `--config-set` overrides (override env vars and config files) - let cli_overlay = - Self::apply_cli_overrides(cli_config_overrides(), &mut merged_table, &mut warnings); - deep_merge_table(&mut invocation_overlay, cli_overlay); - - // 5. Both invocation layers outrank `[projects."…"]` specificity - apply_invocation_layer_over_projects(&mut merged_table, &invocation_overlay); + Self::apply_cli_overrides(cli_config_overrides(), &mut merged_table, &mut warnings); Self::finalize(merged_table, warnings) } @@ -802,17 +773,13 @@ impl UserConfig { /// dropped and a [`LoadError::CliOverride`] is recorded, so a bad override /// never silently corrupts (or wipes) the lower layers. /// - /// Returns the fragments as one table, so the caller can rank them above - /// `[projects."…"]` specificity ([`apply_invocation_layer_over_projects`]). - /// A dropped layer returns an empty table, keeping "what applied" and - /// "what outranks a project entry" the same value. fn apply_cli_overrides( overrides: &[String], merged_table: &mut toml::Table, warnings: &mut Vec, - ) -> toml::Table { + ) { if overrides.is_empty() { - return toml::Table::new(); + return; } let mut overlay = toml::Table::new(); @@ -828,13 +795,13 @@ impl UserConfig { err: err.to_string(), overrides: overrides.to_vec(), }); - return toml::Table::new(); + return; } } } let mut candidate = merged_table.clone(); - deep_merge_table(&mut candidate, overlay.clone()); + merge_layer(&mut candidate, overlay); // Probe deserialize *and* validate, so a semantically-invalid override // (e.g. an empty worktree-path) drops just this layer rather than @@ -845,11 +812,10 @@ impl UserConfig { err, overrides: overrides.to_vec(), }); - return toml::Table::new(); + return; } *merged_table = candidate; - overlay } /// Deserialize a merged table into `UserConfig`, validate, and collect diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index 76f7e2b71..dd3830486 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -2495,14 +2495,12 @@ fn test_load_error_display_cli_override() { // ========================================================================= /// Apply `--config-set` overrides to a base table the way `load_with_warnings` -/// does — including the step that ranks the layer above `[projects."…"]` -/// specificity — returning the merged table plus any warnings. +/// does, returning the merged table plus any warnings. fn apply_overrides(base: toml::Table, overrides: &[&str]) -> (toml::Table, Vec) { let overrides: Vec = overrides.iter().map(|s| s.to_string()).collect(); let mut table = base; let mut warnings = Vec::new(); - let overlay = UserConfig::apply_cli_overrides(&overrides, &mut table, &mut warnings); - apply_invocation_layer_over_projects(&mut table, &overlay); + UserConfig::apply_cli_overrides(&overrides, &mut table, &mut warnings); (table, warnings) } @@ -2648,7 +2646,7 @@ fn test_try_parse_value() { } // ========================================================================= -// apply_invocation_layer_over_projects() — invocation layers vs `[projects]` +// merge_layer() — a layer's global keys vs the `[projects]` entries below // ========================================================================= const PROJECT: &str = "github.com/owner/repo"; @@ -2663,9 +2661,9 @@ fn loaded(table: toml::Table) -> UserConfig { } #[test] -fn test_invocation_layer_outranks_project_worktree_path() { +fn test_cli_layer_outranks_project_worktree_path() { // The reported bug (#3788), on the `--config-set` half: a project entry's - // `worktree-path` no longer beats a global key the invocation layer set. + // `worktree-path` no longer beats a global key a higher layer set. let base = base_with_project("worktree-path = \"/from-project\"\n"); let (table, warnings) = apply_overrides(base, &["worktree-path = \"/from-cli\""]); assert!(warnings.is_empty()); @@ -2688,8 +2686,7 @@ fn test_env_layer_outranks_project_worktree_path() { }; let mut table = base_with_project("worktree-path = \"/from-project\"\n"); let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[var])); - deep_merge_table(&mut table, overlay.clone()); - apply_invocation_layer_over_projects(&mut table, &overlay); + merge_layer(&mut table, overlay); assert_eq!( loaded(table).worktree_path_for_project(PROJECT), @@ -2698,7 +2695,7 @@ fn test_env_layer_outranks_project_worktree_path() { } #[test] -fn test_invocation_layer_keeps_an_already_invalid_candidate_untouched() { +fn test_layer_keeps_an_already_invalid_candidate_untouched() { // The pass discards a candidate that does not deserialize and validate. // Step 3's env probe only deserializes, so an empty `worktree-path` from // the environment reaches here already invalid — and the removals are @@ -2711,26 +2708,49 @@ fn test_invocation_layer_keeps_an_already_invalid_candidate_untouched() { typed_value: try_parse_value(value), raw_value: value.to_string(), }; + let plain_merge = |overlay: &toml::Table| { + let mut plain = base_with_project("worktree-path = \"/from-project\"\n"); + deep_merge_table(&mut plain, overlay.clone()); + plain + }; + let mut table = base_with_project("worktree-path = \"/from-project\"\n"); let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[empty_path("")])); - deep_merge_table(&mut table, overlay.clone()); - let before = table.clone(); - apply_invocation_layer_over_projects(&mut table, &overlay); - assert_eq!(table, before, "the removals are discarded as a unit"); + let plain = plain_merge(&overlay); + merge_layer(&mut table, overlay); + assert_eq!( + table, plain, + "the removals are discarded as a unit, and the layer still applies" + ); // Control: the same overlay with a valid value does remove the project's // key, so the assertion above is the discard and not a pass that found // nothing to do. let mut table = base_with_project("worktree-path = \"/from-project\"\n"); let overlay = migrate_env_overlay(resolve_env_overlay(&table, &[empty_path("/from-env")])); - deep_merge_table(&mut table, overlay.clone()); - let before = table.clone(); - apply_invocation_layer_over_projects(&mut table, &overlay); - assert_ne!(table, before); + let plain = plain_merge(&overlay); + merge_layer(&mut table, overlay); + assert_ne!(table, plain); +} + +#[test] +fn test_file_layer_outranks_lower_layer_project_entry() { + // The same rule where neither layer is an invocation one: the user file's + // global key answers for a project the system file keyed an entry to. + let mut table = base_with_project("worktree-path = \"/from-system-project\"\n"); + merge_layer( + &mut table, + "worktree-path = \"/from-user-global\"\n".parse().unwrap(), + ); + + assert_eq!( + loaded(table).worktree_path_for_project(PROJECT), + "/from-user-global" + ); } #[test] -fn test_invocation_layer_leaves_untouched_project_keys() { +fn test_layer_leaves_untouched_project_keys() { // Only the overridden key is displaced: a project entry's other settings, // and its sibling keys inside the same section, still apply. let base = base_with_project( @@ -2746,12 +2766,12 @@ branches = true let config = loaded(table); assert_eq!(config.worktree_path_for_project(PROJECT), "/from-project"); let list = config.list(Some(PROJECT)); - assert_eq!(list.full, Some(false), "the invocation layer wins"); + assert_eq!(list.full, Some(false), "the higher layer wins"); assert_eq!(list.branches, Some(true), "sibling key survives"); } #[test] -fn test_invocation_layer_keeps_its_own_project_scoped_override() { +fn test_layer_keeps_its_own_project_scoped_override() { // Naming the project entry is both the highest layer and the most // specific key, so it outranks the same layer's global key. let base = base_with_project("worktree-path = \"/from-project\"\n"); @@ -2770,9 +2790,9 @@ fn test_invocation_layer_keeps_its_own_project_scoped_override() { } #[test] -fn test_invocation_layer_applies_to_pattern_entries() { +fn test_layer_applies_to_pattern_entries() { // Pattern entries are project entries too — a `*` key must not smuggle a - // project-scoped value past the invocation layer. + // project-scoped value past a higher layer. let base: toml::Table = "[projects.\"github.com/*\"]\nworktree-path = \"/from-pattern\"\n" .parse() .unwrap(); @@ -2785,7 +2805,7 @@ fn test_invocation_layer_applies_to_pattern_entries() { } #[test] -fn test_invocation_layer_leaves_composing_keys_alone() { +fn test_layer_leaves_composing_keys_alone() { // Per-project hooks, aliases and copy-ignored excludes append to the // global ones rather than replacing them, so both already apply and there // is no precedence to fix. Dropping the project's copy would silently stop @@ -2831,7 +2851,7 @@ exclude = ["project-pattern"] } #[test] -fn test_invocation_layer_displaces_whole_custom_column() { +fn test_layer_displaces_whole_custom_column() { // `[list.custom-columns]` merges per column, so an override of one leaf // has to displace the whole column: leaving the rest of the project's // column would let it replace the global one wholesale anyway, and @@ -2852,7 +2872,10 @@ width = 30 assert_eq!(column.template, "from-cli"); assert_eq!(column.width, None, "the column went as a unit"); - // Restating the column at project scope keeps it, as for any other key. + // Restating the column at project scope wins, as for any other key — and + // states the whole column, since the layer's global already displaced the + // one below it. A column is the unit on both sides of the boundary, so + // `width` is not carried over from the entry that was displaced. let base = base_with_project( r#"[projects."github.com/owner/repo".list.custom-columns.Ticket] template = "{{ vars.ticket }}" @@ -2871,11 +2894,11 @@ width = 30 assert!(warnings.is_empty()); let column = loaded(table).list(Some(PROJECT)).custom_columns["Ticket"].clone(); assert_eq!(column.template, "from-cli-project"); - assert_eq!(column.width, Some(30)); + assert_eq!(column.width, None, "the column went as a unit here too"); } #[test] -fn test_invocation_layer_displaces_exclusive_sibling() { +fn test_layer_displaces_exclusive_sibling() { // Each `[commit.generation]` pair clears itself, so overriding one member // has to displace both at project scope: leaving the project's partner // would let it win the merge, and it would fail validation next to the @@ -2922,8 +2945,8 @@ fn test_invocation_layer_displaces_exclusive_sibling() { } #[test] -fn test_invocation_layer_noop_without_overrides() { - // No invocation layer, no change: a project entry keeps every key. +fn test_layer_noop_without_overrides() { + // No higher layer, no change: a project entry keeps every key. let base = base_with_project("worktree-path = \"/from-project\"\n"); let (table, warnings) = apply_overrides(base, &[]); assert!(warnings.is_empty()); @@ -2934,7 +2957,7 @@ fn test_invocation_layer_noop_without_overrides() { } #[test] -fn test_dropped_invocation_layer_leaves_projects_intact() { +fn test_dropped_layer_leaves_projects_intact() { // A `--config-set` layer that rolls back (malformed fragment) overrides // nothing, so it must not displace the project entry either. let base = base_with_project("worktree-path = \"/from-project\"\n"); diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 2dae61b84..c93c7651b 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -2432,15 +2432,16 @@ worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}" ); } -/// The invocation layers outrank `[projects."…"]` specificity: -/// `WORKTRUNK_WORKTREE_PATH` and `--config-set worktree-path` both beat a -/// project entry, while a config file's global `worktree-path` still loses to -/// one (#3788). +/// A layer's global key outranks the `[projects."…"]` entries below it, at +/// every boundary: `WORKTRUNK_WORKTREE_PATH`, `--config-set worktree-path` and +/// the user file's global key each beat an entry a lower layer set, while an +/// entry still outranks the global key of its own layer (#3788). /// -/// End-to-end because the fix lives in config *loading*: only a real process -/// reads `WORKTRUNK_WORKTREE_PATH` off the environment. +/// End-to-end because the rule lives in config *loading*: only a real process +/// reads `WORKTRUNK_WORKTREE_PATH` off the environment and stacks a system file +/// under the user's. #[rstest] -fn test_switch_create_invocation_layers_outrank_project_worktree_path(repo: TestRepo) { +fn test_switch_create_layers_outrank_project_worktree_path(repo: TestRepo) { set_github_remote_url(&repo); let created_path = |args: &[&str], env: &[(&str, &str)]| { @@ -2507,8 +2508,8 @@ worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" cli_path.display() ); - // Naming the project entry is both the highest layer and the most - // specific key, so it wins over the same layer's global key. + // An entry outranks the global key of its own layer, which is why naming + // the project entry pins a `--config-set` to one repo. let mut pinned_args = vec![ "--config-set", CLI_TEMPLATE, @@ -2523,6 +2524,36 @@ worktree-path = "{{ repo_path }}/../from-project-{{ branch | sanitize }}" "--config-set on the project entry should win, got {}", pinned_path.display() ); + + // The rule is the same one layer down, where no invocation is involved: + // with the entry moved to the system file, the user file's global key + // answers for this repo. Written last because it rewrites the user config. + let system_dir = tempfile::tempdir().unwrap(); + let system_config = system_dir.path().join("config.toml"); + fs::write( + &system_config, + r#" +[projects."github.com/owner/test-repo"] +worktree-path = "{{ repo_path }}/../from-system-project-{{ branch | sanitize }}" +"#, + ) + .unwrap(); + repo.write_test_config( + r#"worktree-path = "{{ repo_path }}/../from-user-global-{{ branch | sanitize }}""#, + ); + let system_path = created_path( + &switch("system-layer"), + &[( + "WORKTRUNK_SYSTEM_CONFIG_PATH", + system_config.to_str().unwrap(), + )], + ); + assert_eq!( + system_path.file_name().unwrap(), + "from-user-global-system-layer", + "the user file's global key should outrank the system file's project entry, got {}", + system_path.display() + ); } // ============================================================================ 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 d7b310ee7..afe00f020 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -670,8 +670,8 @@ Sources rank by how close they are to the invocation, which puts user config abo 1. --config-set 'worktree-path = …' 2. WORKTRUNK_WORKTREE_PATH -3. [projects."github.com/owner/repo"] in a config file -4. global worktree-path in a config file +3. [projects."github.com/owner/repo"] in the config file +4. global worktree-path in the config file A --config-set that names a project entry applies to that project alone, and beats a global --config-set of the same key — it is both the highest layer and the most specific key: From a265f03721fb83dac854284a5a58aea4165475e5 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:07:24 +0000 Subject: [PATCH 10/10] fix(config): ask the layer before cloning it, refresh the merge doc --- src/config/mod.rs | 7 +++++-- src/config/user/mod.rs | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 1c8726354..38867bd43 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,8 +8,11 @@ //! 3. **Project config** (`.config/wt.toml`) - Lifecycle hooks, checked into git //! //! System and user configs share the same schema and are merged via -//! `deep_merge_table` (user values override system values at the key level). -//! Project config is independent — different schema, different purpose. +//! `merge_layer`, which ranks each layer above the one beneath it as a whole: +//! a user value overrides the system value for the same key, and a user global +//! key also outranks a system `[projects."…"]` entry that would otherwise be +//! the more specific match. Project config is independent — different schema, +//! different purpose. //! //! See `wt config --help` for complete documentation. diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 881d79ab4..175420c7b 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -320,17 +320,17 @@ fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) { /// handed to [`UserConfig::finalize`], which would answer a stranded required /// field by wiping the config to defaults. The layer itself applies either way. fn merge_layer(merged_table: &mut toml::Table, layer: toml::Table) { - let mut global = layer.clone(); - global.remove("projects"); - // Nothing to rank: no entries beneath, or nothing above them. The common // case is a config with no `[projects]` table at all, and it pays only the // lookup. - if global.is_empty() || !merged_table.contains_key("projects") { + if !merged_table.contains_key("projects") || layer.keys().all(|key| key == "projects") { deep_merge_table(merged_table, layer); return; } + let mut global = layer.clone(); + global.remove("projects"); + let mut candidate = merged_table.clone(); if let Some(projects) = candidate .get_mut("projects") @@ -772,7 +772,6 @@ impl UserConfig { /// the merged result fails to deserialize or validate, every override is /// dropped and a [`LoadError::CliOverride`] is recorded, so a bad override /// never silently corrupts (or wipes) the lower layers. - /// fn apply_cli_overrides( overrides: &[String], merged_table: &mut toml::Table,