From 90e52a43cc69ba2a2f014c02526e22ced4d30362 Mon Sep 17 00:00:00 2001 From: indexzero Date: Thu, 23 Jul 2026 12:02:47 -0400 Subject: [PATCH 1/6] feat(config): experimental worktrunk.config.* git-config project-config source Any key under the worktrunk.config. prefix in git config now supplies project configuration (#3454). Strip the prefix; the remainder is the exact .config/wt.toml key path. Selection is all-or-nothing: when any key exists, the merged effective git config is the complete project config, the file is ignored, and a warning names the superseded file. Values are strings only; schema violations fail loudly with no fallback to the file. Commands from this source pass through the same approval gate as file-based project config. Git owns precedence: keys come from the cached `git config --list -z` map, so system/global/local scopes and includes resolve before worktrunk sees them, at zero additional subprocess cost. Co-Authored-By: Claude Fable 5 --- docs/content/config.md | 20 ++ .../skills/worktrunk/reference/config.md | 23 ++ skills/worktrunk/reference/config.md | 23 ++ src/cli/mod.rs | 23 ++ src/commands/config/show.rs | 112 +++++-- src/commands/hook_commands.rs | 20 +- src/config/git_source.rs | 304 ++++++++++++++++++ src/config/mod.rs | 7 +- src/config/project.rs | 41 +++ src/git/repository/config.rs | 23 ++ tests/integration_tests/git_config_source.rs | 194 +++++++++++ tests/integration_tests/mod.rs | 1 + ...source__config_show_git_config_source.snap | 71 ++++ ...fig_source_invalid_value_fails_loudly.snap | 60 ++++ ...k_show_git_config_source_without_file.snap | 56 ++++ ...ow_git_config_supersedes_project_file.snap | 58 ++++ ...gration_tests__help__help_config_long.snap | 19 ++ 17 files changed, 1018 insertions(+), 37 deletions(-) create mode 100644 src/config/git_source.rs create mode 100644 tests/integration_tests/git_config_source.rs create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__config_show_git_config_source.snap create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__git_config_source_invalid_value_fails_loudly.snap create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_source_without_file.snap create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_supersedes_project_file.snap diff --git a/docs/content/config.md b/docs/content/config.md index b47caaeff7..d22a3affa4 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -616,6 +616,26 @@ url = "echo http://localhost:{{ branch | hash_port }}" Aliases defined here are shared with teammates. For personal aliases, use the [user config](@/config.md#aliases) `[aliases]` section instead. +## Private project config in git config + + + +Project config normally lives in `.config/wt.toml`, committed and shared. Some settings are better kept private: a hook that runs a personal script, a machine-specific dev-server URL. Git config can hold these. + +Any key under the `worktrunk.config.` prefix in git config becomes project config. Strip the prefix; what remains is the exact TOML key path from the sections above: + +{{ terminal(cmd="git config worktrunk.config.post-start 'pnpm install'|||git config worktrunk.config.list.url 'http://localhost:3000'") }} + +`.git/config` is local to the repository and never committed, so these keys stay on one machine — and every linked worktree sees them, because the local scope lives in the shared git dir. `--global` puts a key in every repository. Git's normal precedence applies: local overrides global, and conditional includes work. + +Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those keys are the complete project config and `.config/wt.toml` is ignored — a warning names the superseded file. There is no key-level merging between the two sources. To return to the file, remove the keys. + +Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. + +To list every active key with its scope and origin file: + +{{ terminal(cmd="git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.'") }} + # Shell Integration Worktrunk needs shell integration to change directories when switching worktrees. Install with: diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 83836512dd..3dd9469477 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -609,6 +609,29 @@ url = "echo http://localhost:{{ branch | hash_port }}" Aliases defined here are shared with teammates. For personal aliases, use the [user config](https://worktrunk.dev/config/#aliases) `[aliases]` section instead. +## Private project config in git config [experimental] + +Project config normally lives in `.config/wt.toml`, committed and shared. Some settings are better kept private: a hook that runs a personal script, a machine-specific dev-server URL. Git config can hold these. + +Any key under the `worktrunk.config.` prefix in git config becomes project config. Strip the prefix; what remains is the exact TOML key path from the sections above: + +```bash +$ git config worktrunk.config.post-start 'pnpm install' +$ git config worktrunk.config.list.url 'http://localhost:3000' +``` + +`.git/config` is local to the repository and never committed, so these keys stay on one machine — and every linked worktree sees them, because the local scope lives in the shared git dir. `--global` puts a key in every repository. Git's normal precedence applies: local overrides global, and conditional includes work. + +Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those keys are the complete project config and `.config/wt.toml` is ignored — a warning names the superseded file. There is no key-level merging between the two sources. To return to the file, remove the keys. + +Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. + +To list every active key with its scope and origin file: + +```bash +$ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +``` + # Shell Integration Worktrunk needs shell integration to change directories when switching worktrees. Install with: diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 83836512dd..3dd9469477 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -609,6 +609,29 @@ url = "echo http://localhost:{{ branch | hash_port }}" Aliases defined here are shared with teammates. For personal aliases, use the [user config](https://worktrunk.dev/config/#aliases) `[aliases]` section instead. +## Private project config in git config [experimental] + +Project config normally lives in `.config/wt.toml`, committed and shared. Some settings are better kept private: a hook that runs a personal script, a machine-specific dev-server URL. Git config can hold these. + +Any key under the `worktrunk.config.` prefix in git config becomes project config. Strip the prefix; what remains is the exact TOML key path from the sections above: + +```bash +$ git config worktrunk.config.post-start 'pnpm install' +$ git config worktrunk.config.list.url 'http://localhost:3000' +``` + +`.git/config` is local to the repository and never committed, so these keys stay on one machine — and every linked worktree sees them, because the local scope lives in the shared git dir. `--global` puts a key in every repository. Git's normal precedence applies: local overrides global, and conditional includes work. + +Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those keys are the complete project config and `.config/wt.toml` is ignored — a warning names the superseded file. There is no key-level merging between the two sources. To return to the file, remove the keys. + +Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. + +To list every active key with its scope and origin file: + +```bash +$ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +``` + # Shell Integration Worktrunk needs shell integration to change directories when switching worktrees. Install with: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 862799a123..14692b0079 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2495,6 +2495,29 @@ url = "echo http://localhost:{{ branch | hash_port }}" Aliases defined here are shared with teammates. For personal aliases, use the [user config](@/config.md#aliases) `[aliases]` section instead. +## Private project config in git config [experimental] + +Project config normally lives in `.config/wt.toml`, committed and shared. Some settings are better kept private: a hook that runs a personal script, a machine-specific dev-server URL. Git config can hold these. + +Any key under the `worktrunk.config.` prefix in git config becomes project config. Strip the prefix; what remains is the exact TOML key path from the sections above: + +```console +$ git config worktrunk.config.post-start 'pnpm install' +$ git config worktrunk.config.list.url 'http://localhost:3000' +``` + +`.git/config` is local to the repository and never committed, so these keys stay on one machine — and every linked worktree sees them, because the local scope lives in the shared git dir. `--global` puts a key in every repository. Git's normal precedence applies: local overrides global, and conditional includes work. + +Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those keys are the complete project config and `.config/wt.toml` is ignored — a warning names the superseded file. There is no key-level merging between the two sources. To return to the file, remove the keys. + +Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. + +To list every active key with its scope and origin file: + +```console +$ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +``` + # Shell Integration Worktrunk needs shell integration to change directories when switching worktrees. Install with: diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index 3fe0df55cd..1e6c3e5c51 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -106,31 +106,39 @@ fn handle_config_show_json() -> anyhow::Result<()> { None }; - let (project_path, project_config, project_identifier) = if let Ok(repo) = Repository::current() - { - let config = repo.load_project_config()?; - let on_disk = repo.project_config_path()?; - // When config resolved but not from an existing on-disk file, it came - // from the object-store fallback (bare repo, default branch checked out - // in no worktree — #3461). Surface that revision spec as the source so - // `path`/`exists`/`config` agree, instead of pointing `path` at a - // missing file while `config` is populated. - let path = match &on_disk { - Some(p) if p.exists() => on_disk.clone(), - _ if config.is_some() => repo - .default_branch_project_config_content() - .map(|(_, spec)| spec), - _ => on_disk.clone(), + let (project_path, project_config, project_identifier, project_source) = + if let Ok(repo) = Repository::current() { + let config = repo.load_project_config()?; + let source = config.as_ref().map(|c| match c.source { + worktrunk::config::ProjectConfigSource::GitConfig => "git-config", + worktrunk::config::ProjectConfigSource::File => "file", + }); + let from_git = source == Some("git-config"); + let on_disk = repo.project_config_path()?; + // When config resolved but not from an existing on-disk file, it + // came from the object-store fallback (bare repo, default branch + // checked out in no worktree — #3461). Surface that revision spec + // as the source so `path`/`exists`/`config` agree, instead of + // pointing `path` at a missing file while `config` is populated. + // The git-config source has no path at all (`source` names it). + let path = match &on_disk { + _ if from_git => None, + Some(p) if p.exists() => on_disk.clone(), + _ if config.is_some() => repo + .default_branch_project_config_content() + .map(|(_, spec)| spec), + _ => on_disk.clone(), + }; + let identifier = repo.project_identifier().ok(); + ( + path, + config.map(|c| serde_json::to_value(&c)).transpose()?, + identifier, + source, + ) + } else { + (None, None, None, None) }; - let identifier = repo.project_identifier().ok(); - ( - path, - config.map(|c| serde_json::to_value(&c)).transpose()?, - identifier, - ) - } else { - (None, None, None) - }; let system_path = system_config_path().or_else(default_system_config_path); let system_exists = system_path.as_ref().is_some_and(|p| p.exists()); @@ -143,13 +151,16 @@ fn handle_config_show_json() -> anyhow::Result<()> { }, "project": { "path": project_path, - // Config source resolved — an on-disk file or the object-store - // fallback — iff `config` is populated. Keying `exists` off the - // loaded config (not `path.exists()`) keeps it consistent with - // `config` in the object-store case, where `path` is a revision - // spec with no file on disk. + // Config source resolved — an on-disk file, the object-store + // fallback, or git config — iff `config` is populated. Keying + // `exists` off the loaded config (not `path.exists()`) keeps it + // consistent with `config` when `path` is a revision spec or + // absent (git-config source). "exists": project_config.is_some(), "identifier": project_identifier, + // "file" | "git-config" (experimental worktrunk.config.* source), + // absent when no config resolved. + "source": project_source, "config": project_config, }, "system": { @@ -785,6 +796,49 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> { Ok(()) } + // Experimental git-config source (#3454), mirroring `ProjectConfig::load`: + // any `worktrunk.config.*` keys in the merged effective git config are the + // project config, and the file (when one resolves) is superseded. Rendered + // first so the section reports the source that actually runs. + let git_pairs = repo.worktrunk_config_git_pairs().unwrap_or_default(); + if !git_pairs.is_empty() { + let source = format!("@ {}", worktrunk::config::GIT_CONFIG_SOURCE_LABEL); + write_heading_and_identifier(out, &repo, &source)?; + if let Some(superseded) = worktrunk::config::superseded_project_file_label(&repo) { + writeln!( + out, + "{}", + warning_message(cformat!( + "Project config file @ {superseded} is superseded by these keys" + )) + )?; + } + writeln!( + out, + "{}", + hint_message(cformat!( + "To list the keys and their origins, run {}", + worktrunk::config::GIT_CONFIG_LIST_COMMAND + )) + )?; + match worktrunk::config::render_git_source_toml(&git_pairs) { + Ok(rendered) => { + // Same validation rendering as the file branch below. + if let Err(e) = toml::from_str::(&rendered) { + writeln!(out, "{}", error_message("Invalid config"))?; + writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?; + } else { + out.push_str(&warn_unknown_keys::(&rendered)); + } + writeln!(out, "{}", format_toml(&rendered))?; + } + Err(e) => { + writeln!(out, "{}", error_message(e.to_string()))?; + } + } + return Ok(()); + } + // Resolve the effective config source, mirroring `ProjectConfig::load`: an // on-disk `.config/wt.toml` when one exists, otherwise the committed // default-branch config read from the object store (bare repo, default diff --git a/src/commands/hook_commands.rs b/src/commands/hook_commands.rs index 8763e49f71..37a6db087d 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -532,17 +532,23 @@ fn render_project_hooks( filter: Option, ctx: Option<&CommandContext>, ) -> anyhow::Result<()> { - let config_path = repo - .project_config_path()? - .context("Cannot determine project config location — no worktree found")?; + // Git-config-sourced config has no file path; name the source instead. + let source_label = match project_config { + Some(config) if config.source == worktrunk::config::ProjectConfigSource::GitConfig => { + format!("@ {}", worktrunk::config::GIT_CONFIG_SOURCE_LABEL) + } + _ => { + let config_path = repo + .project_config_path()? + .context("Cannot determine project config location — no worktree found")?; + format!("@ {}", format_path_for_display(&config_path)) + } + }; writeln!( out, "{}", - format_heading( - "PROJECT HOOKS", - Some(&format!("@ {}", format_path_for_display(&config_path))) - ) + format_heading("PROJECT HOOKS", Some(&source_label)) )?; let Some(config) = project_config else { diff --git a/src/config/git_source.rs b/src/config/git_source.rs new file mode 100644 index 0000000000..348129369c --- /dev/null +++ b/src/config/git_source.rs @@ -0,0 +1,304 @@ +//! Experimental git-config source for project configuration (#3454). +//! +//! # Purpose +//! +//! Lets a repo carry private, uncommitted project configuration in git config +//! under the `worktrunk.config.*` namespace. `.git/config` is never +//! transmitted by clone or fetch, so these keys cannot arrive from a remote — +//! they are user-authored by construction, and shared across every linked +//! worktree because the local scope lives in the common git dir. +//! +//! # Key decisions +//! +//! - **Merged effective read.** Keys come from the bulk `git config --list -z` +//! map ([`crate::git::Repository::worktrunk_config_git_pairs`]), so git +//! resolves scope precedence (system → global → local) and conditional +//! includes before worktrunk ever sees a key. Worktrunk adds no precedence +//! machinery and never distinguishes scopes. +//! - **All-or-nothing selection.** When any `worktrunk.config.*` key exists, +//! this source *is* the project config; `.config/wt.toml` (and the +//! object-store fallback) is not read. There is no key-level merging +//! between sources. A parse failure here fails the load loudly — falling +//! back to the file would silently change which config runs. +//! - **Mechanical key mapping.** Strip `worktrunk.config.`; the remainder is +//! the exact TOML key path as it would appear in `.config/wt.toml` +//! (`worktrunk.config.post-start` → top-level `post-start`, +//! `worktrunk.config.list.url` → `[list] url`). No renamed keys, no +//! git-specific schema. Git lowercases the section and the final key +//! component and preserves the middle verbatim, so keys must be written in +//! lowercase — exactly how the schema spells them. +//! - **String leaves only.** Git config values are strings; they map to TOML +//! strings, which every schema field that motivates this source accepts +//! (hooks, aliases, `list.url`, `forge.platform`, `commit.generation. +//! template-append`). Fields requiring other TOML types (currently only +//! `step.copy-ignored.exclude`, an array) are not expressible; attempting +//! one surfaces the deserialize error. Repeated keys follow git's own +//! rule: the last value wins. +//! - **Same approval gate as the file.** Commands from this source pass +//! through the ordinary project-command approval flow. Git config can +//! carry remotely-authored content via `include`/`includeIf` (e.g. a +//! cloned dotfiles repo), so source alone is not a trust signal. +//! +//! # Invariants +//! +//! - [`super::ProjectConfig::load`] is the only constructor of a +//! `GitConfig`-sourced config, so `config.source` faithfully records +//! provenance everywhere the cached config flows. +//! - The supersession warning fires exactly when selection actually ignores a +//! resolvable file — no keys → no warning; no file → no warning. + +use std::sync::OnceLock; + +use color_print::cformat; + +use crate::styling::{eprintln, hint_message, warning_message}; + +use super::{ConfigError, ConfigFileKind, ProjectConfig, ProjectConfigSource}; + +/// Namespace prefix in git config. Everything after it is a project-config +/// TOML key path. +pub const GIT_CONFIG_PREFIX: &str = "worktrunk.config."; + +/// Display label used wherever the git-config source is named as a config +/// origin (`wt config show`, `wt hook show`, parse errors). +pub const GIT_CONFIG_SOURCE_LABEL: &str = "git config (worktrunk.config.*)"; + +/// The diagnostic command that lists every active key with its scope and +/// origin file. Referenced verbatim from the supersession hint and the docs +/// so all surfaces teach the same incantation. +pub const GIT_CONFIG_LIST_COMMAND: &str = + r"git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.'"; + +/// Render `worktrunk.config.*` pairs (prefix already stripped) as a TOML +/// document string. +/// +/// Fails when a key path is malformed (empty segment) or when two keys +/// collide (one names a value where another needs a table). +pub fn render_git_source_toml(pairs: &[(String, String)]) -> Result { + let table = pairs_to_table(pairs)?; + toml::to_string(&table).map_err(|e| { + ConfigError(format!( + "Failed to render {GIT_CONFIG_SOURCE_LABEL} as TOML: {e}" + )) + }) +} + +/// Parse `worktrunk.config.*` pairs into a [`ProjectConfig`] tagged with +/// [`ProjectConfigSource::GitConfig`]. +/// +/// Emits unknown-field warnings through the same channel as file-based +/// config (per-process deduped). A schema violation is a hard error — the +/// caller must not fall back to `.config/wt.toml`. +pub fn project_config_from_git(pairs: &[(String, String)]) -> Result { + let rendered = render_git_source_toml(pairs)?; + + super::deprecation::warn_unknown_fields::( + &rendered, + std::path::Path::new(GIT_CONFIG_SOURCE_LABEL), + ConfigFileKind::Project, + ); + + let mut config: ProjectConfig = toml::from_str(&rendered).map_err(|e| { + ConfigError(format!( + "{} from {GIT_CONFIG_SOURCE_LABEL} failed to parse:\n{e}", + ConfigFileKind::Project.label(), + )) + })?; + config.source = ProjectConfigSource::GitConfig; + Ok(config) +} + +/// Build the nested TOML table from flat dotted key paths. +fn pairs_to_table(pairs: &[(String, String)]) -> Result { + let mut root = toml::Table::new(); + for (key, value) in pairs { + insert_dotted(&mut root, key, value)?; + } + Ok(root) +} + +/// Insert one `key = value` pair, creating intermediate tables along the +/// dotted path. Collisions between a value and a table at the same path are +/// errors, not silent overwrites. +fn insert_dotted(root: &mut toml::Table, key: &str, value: &str) -> Result<(), ConfigError> { + let segments: Vec<&str> = key.split('.').collect(); + if segments.iter().any(|s| s.is_empty()) { + return Err(ConfigError(format!( + "Invalid git config key {GIT_CONFIG_PREFIX}{key}: empty key segment" + ))); + } + let (leaf, path) = segments.split_last().expect("split('.') yields ≥1 segment"); + + let mut table = root; + let mut walked = String::new(); + for segment in path { + walked.push_str(segment); + table = match table + .entry(segment.to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + { + toml::Value::Table(t) => t, + _ => { + return Err(ConfigError(format!( + "Conflicting git config keys: {GIT_CONFIG_PREFIX}{walked} is a value, but {GIT_CONFIG_PREFIX}{key} needs it to be a table" + ))); + } + }; + walked.push('.'); + } + + match table.entry(leaf.to_string()) { + toml::map::Entry::Vacant(slot) => { + slot.insert(toml::Value::String(value.to_string())); + Ok(()) + } + toml::map::Entry::Occupied(_) => Err(ConfigError(format!( + "Conflicting git config keys: {GIT_CONFIG_PREFIX}{key} is set both as a value and as a table" + ))), + } +} + +/// Warn (once per process) that the git-config source is superseding a +/// project config file that would otherwise load. +/// +/// Called from [`super::ProjectConfig::load`] on the git-source selection +/// branch — the single point where supersession actually happens — so the +/// warning fires iff a resolvable file is being ignored. The file check +/// mirrors the load path's own resolution: an on-disk `.config/wt.toml` +/// (or override path), else the committed object-store fallback. +pub(crate) fn warn_superseded_project_file(repo: &crate::git::Repository) { + if super::deprecation::warnings_suppressed() { + return; + } + + let Some(superseded) = superseded_project_file_label(repo) else { + return; + }; + + static WARNED: OnceLock<()> = OnceLock::new(); + if WARNED.set(()).is_err() { + return; + } + + eprintln!( + "{}", + warning_message(cformat!( + "Using worktrunk.config.* keys from git config as the project config; ignoring {superseded}" + )) + ); + eprintln!( + "{}", + hint_message(cformat!( + "To list the keys and their origins, run {GIT_CONFIG_LIST_COMMAND}" + )) + ); +} + +/// Display label for the project config file the git-config source is +/// superseding, if one would otherwise load: the on-disk `.config/wt.toml` +/// (or override path), else the committed object-store copy's revision spec. +/// `None` when no file source resolves — then nothing is superseded. +/// +/// Shared by the load-time warning and `wt config show`, so the two surfaces +/// cannot disagree about whether supersession is happening. +pub fn superseded_project_file_label(repo: &crate::git::Repository) -> Option { + match repo.project_config_path() { + Ok(Some(path)) if path.exists() => Some(crate::path::format_path_for_display(&path)), + _ => repo + .default_branch_project_config_content() + .map(|(_, spec)| spec.to_string_lossy().into_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> { + list.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn test_top_level_hook_maps_to_flattened_key() { + let config = + project_config_from_git(&pairs(&[("post-start", "pnpm install")])).unwrap(); + assert_eq!(config.source, ProjectConfigSource::GitConfig); + // `post-start` deserializes into the `post_create` field (serde + // rename — the field kept its pre-rename name). + let cfg = config.hooks.post_create.as_ref().expect("post-start set"); + let commands: Vec<_> = cfg.commands().collect(); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].template, "pnpm install"); + } + + #[test] + fn test_nested_keys_map_to_tables() { + let config = project_config_from_git(&pairs(&[ + ("list.url", "http://localhost:{{ branch | hash_port }}"), + ("forge.platform", "github"), + ("commit.generation.template-append", "use conventional commits"), + ])) + .unwrap(); + assert_eq!( + config.list.url.as_deref(), + Some("http://localhost:{{ branch | hash_port }}") + ); + assert_eq!(config.forge.platform.as_deref(), Some("github")); + assert_eq!( + config.commit_template_append(), + Some("use conventional commits") + ); + } + + #[test] + fn test_alias_maps_to_aliases_table() { + let config = + project_config_from_git(&pairs(&[("aliases.deploy", "make deploy")])).unwrap(); + let alias = config.aliases.get("deploy").expect("alias present"); + let commands: Vec<_> = alias.commands().collect(); + assert_eq!(commands[0].template, "make deploy"); + } + + #[test] + fn test_value_table_conflict_is_an_error() { + let err = project_config_from_git(&pairs(&[ + ("list", "oops"), + ("list.url", "http://localhost:3000"), + ])) + .unwrap_err(); + assert!(err.0.contains("worktrunk.config.list"), "{}", err.0); + } + + #[test] + fn test_table_value_conflict_is_an_error() { + let err = project_config_from_git(&pairs(&[ + ("list.url", "http://localhost:3000"), + ("list", "oops"), + ])) + .unwrap_err(); + assert!(err.0.contains("worktrunk.config.list"), "{}", err.0); + } + + #[test] + fn test_empty_segment_is_an_error() { + let err = project_config_from_git(&pairs(&[("list..url", "x")])).unwrap_err(); + assert!(err.0.contains("empty key segment"), "{}", err.0); + } + + #[test] + fn test_non_string_field_fails_loudly() { + // step.copy-ignored.exclude is an array; a string leaf cannot satisfy + // it, and the error must surface rather than fall back to the file. + let err = project_config_from_git(&pairs(&[("step.copy-ignored.exclude", "target")])) + .unwrap_err(); + assert!(err.0.contains(GIT_CONFIG_SOURCE_LABEL), "{}", err.0); + } + + #[test] + fn test_file_source_is_the_default() { + let config: ProjectConfig = toml::from_str("post-start = \"x\"").unwrap(); + assert_eq!(config.source, ProjectConfigSource::File); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 1c87263544..2867ee5aec 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,7 @@ pub mod approvals; mod commands; pub(crate) mod deprecation; mod expansion; +mod git_source; mod hooks; mod project; #[cfg(test)] @@ -168,10 +169,14 @@ pub use expansion::{ template_environment, template_references_var, validate_list_column_template, validate_template, validate_template_syntax, vars_available_in, vars_map_to_value, }; +pub use git_source::{ + GIT_CONFIG_LIST_COMMAND, GIT_CONFIG_PREFIX, GIT_CONFIG_SOURCE_LABEL, render_git_source_toml, + superseded_project_file_label, +}; pub use hooks::HooksConfig; pub use project::{ ProjectCiConfig, ProjectCommitConfig, ProjectCommitGenerationConfig, ProjectConfig, - ProjectForgeConfig, ProjectListConfig, valid_project_config_keys, + ProjectConfigSource, ProjectForgeConfig, ProjectListConfig, valid_project_config_keys, }; pub use unknown_tree::{ UnknownAnalysis, UnknownTree, UnknownWarning, collect_unknown_warnings, compute_unknown_tree, diff --git a/src/config/project.rs b/src/config/project.rs index 4c4f9209a6..2dddafe1fe 100644 --- a/src/config/project.rs +++ b/src/config/project.rs @@ -175,12 +175,33 @@ impl ProjectConfig { } } +/// Where a loaded [`ProjectConfig`] came from. +/// +/// Attribution only — every source passes through the same approval gate +/// ("Project Commands Run Only After Approval" in `CLAUDE.md`). Even +/// `.git/config` content can originate remotely via an `include`/`includeIf` +/// of a file from a cloned dotfiles repo, so no source is exempt. The enum +/// exists so `wt config show` and `wt hook show` can name the active source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ProjectConfigSource { + /// `.config/wt.toml` on disk (or the committed object-store fallback). + #[default] + File, + /// `worktrunk.config.*` keys read from git config (experimental, #3454). + GitConfig, +} + /// Project-specific configuration with hooks. /// /// This config is stored at `/.config/wt.toml` within the repository and /// IS checked into git. It defines project-specific hooks that run automatically /// during worktree operations. All developers working on the project share this config. /// +/// Alternatively (experimental), the same schema can be supplied privately via +/// `worktrunk.config.*` keys in git config — see `src/config/git_source.rs`. +/// When any such key exists, that source replaces the file entirely. Commands +/// from either source pass through the same approval gate. +/// /// # Template Variables /// /// All hooks support these template variables: @@ -244,6 +265,13 @@ pub struct ProjectConfig { /// ``` #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub aliases: BTreeMap, + + /// Provenance of this config — not part of the TOML schema. Set to + /// [`ProjectConfigSource::GitConfig`] only by the git-config branch of + /// [`ProjectConfig::load`]; deserialization defaults it to `File`. + #[serde(skip)] + #[schemars(skip)] + pub source: ProjectConfigSource, } impl ProjectConfig { @@ -255,6 +283,19 @@ impl ProjectConfig { repo: &crate::git::Repository, write_hints: bool, ) -> Result, ConfigError> { + // Experimental git-config source (#3454): when any `worktrunk.config.*` + // key exists in the merged effective git config, that source is the + // complete project config and the file is not read (all-or-nothing — + // see `src/config/git_source.rs`). A parse failure is a hard error; + // falling back to the file would silently change which config runs. + let git_pairs = repo + .worktrunk_config_git_pairs() + .map_err(|e| ConfigError(format!("Failed to read git config: {e}")))?; + if !git_pairs.is_empty() { + super::git_source::warn_superseded_project_file(repo); + return super::git_source::project_config_from_git(&git_pairs).map(Some); + } + let (contents, config_path) = match repo .project_config_path() .map_err(|e| ConfigError(format!("Failed to get config path: {}", e)))? diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index d0b9f03a91..7747d1dc5d 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -130,6 +130,29 @@ impl Repository { Ok(existed) } + /// The `worktrunk.config.*` entries from the merged effective git config, + /// with the prefix stripped (experimental project-config source, #3454). + /// + /// An in-memory prefix scan over the bulk config map — no subprocess. + /// Git has already merged the scopes (system → global → local, plus any + /// includes) before `--list` emits, and for a repeated key the last + /// value wins, so the returned pairs carry git's own precedence. + /// Per git's key model the middle path segments are case-sensitive; + /// only exact-lowercase keys (the schema's own spelling) match. + /// + /// Any non-empty result means the git-config source supersedes + /// `.config/wt.toml` — the selection lives in `ProjectConfig::load`. + pub fn worktrunk_config_git_pairs(&self) -> anyhow::Result> { + let guard = self.all_config()?.read().unwrap(); + Ok(guard + .iter() + .filter_map(|(key, values)| { + let rest = key.strip_prefix(crate::config::GIT_CONFIG_PREFIX)?; + Some((rest.to_string(), values.last()?.clone())) + }) + .collect()) + } + /// Run `git config --get-regexp ` and return stdout. /// /// Distinguishes exit 1 (no matching keys — expected, returns empty diff --git a/tests/integration_tests/git_config_source.rs b/tests/integration_tests/git_config_source.rs new file mode 100644 index 0000000000..df5f5a533d --- /dev/null +++ b/tests/integration_tests/git_config_source.rs @@ -0,0 +1,194 @@ +//! Integration tests for the experimental `worktrunk.config.*` git-config +//! project-config source (#3454). +//! +//! Covers: source selection (keys present → git config wins, file ignored), +//! the supersession warning firing iff a file would otherwise load, scope +//! precedence (local over global) resolved by git itself, `include.path` +//! resolution, loud failure on unexpressible values, and approval gating of +//! git-config-sourced hooks. + +use crate::common::{ + TestRepo, repo, set_temp_home_env, setup_snapshot_settings_with_home, temp_home, wt_command, +}; +use insta_cmd::assert_cmd_snapshot; +use rstest::rstest; +use std::fs; +use tempfile::TempDir; + +fn write_user_config(temp_home: &TempDir) { + let global_config_dir = temp_home.path().join(".config").join("worktrunk"); + fs::create_dir_all(&global_config_dir).unwrap(); + fs::write( + global_config_dir.join("config.toml"), + r#"worktree-path = "../{{ repo }}.{{ branch }}" +"#, + ) + .unwrap(); +} + +/// Keys present AND a project config file present: the git-config source +/// wins, the heading names it, and the supersession warning fires. +#[rstest] +fn test_hook_show_git_config_supersedes_project_file(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.write_project_config(r#"pre-merge = "cargo test""#); + repo.commit("Add project config"); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("hook").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// Keys present, no project config file: same selection, but nothing is +/// superseded so no warning appears. +#[rstest] +fn test_hook_show_git_config_source_without_file(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("hook").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// A value the schema cannot accept as a string fails the load loudly; the +/// project config file is NOT silently used instead (all-or-nothing). +#[rstest] +fn test_git_config_source_invalid_value_fails_loudly(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.write_project_config(r#"pre-merge = "cargo test""#); + repo.commit("Add project config"); + // `step.copy-ignored.exclude` is an array in the schema; a string leaf + // cannot satisfy it. + repo.run_git(&["config", "worktrunk.config.step.copy-ignored.exclude", "target"]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("hook").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// `wt config show` names the git-config source, warns about the superseded +/// file, and dumps the mapped TOML. +#[rstest] +fn test_config_show_git_config_source(mut repo: TestRepo, temp_home: TempDir) { + repo.setup_mock_ci_tools_unauthenticated(); + write_user_config(&temp_home); + repo.write_project_config(r#"pre-merge = "cargo test""#); + repo.commit("Add project config"); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + repo.run_git(&["config", "worktrunk.config.list.url", "http://localhost:3000"]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + repo.configure_mock_commands(&mut cmd); + cmd.arg("config").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// Git resolves scope precedence before worktrunk reads the keys: a local +/// key overrides its global twin, and global-only keys still merge in. +#[rstest] +fn test_git_config_scope_precedence_local_over_global(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "--global", "worktrunk.config.list.url", "http://global:9999"]); + repo.run_git(&["config", "--global", "worktrunk.config.forge.platform", "gitlab"]); + repo.run_git(&["config", "worktrunk.config.list.url", "http://local:3000"]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["config", "show", "--format=json"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + let project = &json["project"]; + assert_eq!(project["source"], "git-config"); + assert_eq!(project["exists"], true); + assert_eq!(project["path"], serde_json::Value::Null); + assert_eq!(project["config"]["list"]["url"], "http://local:3000"); + assert_eq!(project["config"]["forge"]["platform"], "gitlab"); +} + +/// Keys reachable only through `include.path` resolve like any other git +/// config — git processes includes before worktrunk sees the merged list. +#[rstest] +fn test_git_config_source_include_path(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + let fragment = temp_home.path().join("wt-private.gitconfig"); + fs::write( + &fragment, + r#"[worktrunk "config.list"] + url = http://from-include:1234 +"#, + ) + .unwrap(); + repo.run_git(&["config", "include.path", fragment.to_str().unwrap()]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["config", "show", "--format=json"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["project"]["source"], "git-config"); + assert_eq!( + json["project"]["config"]["list"]["url"], + "http://from-include:1234" + ); +} + +/// Git-config-sourced hooks pass through the same approval gate as +/// file-based project config — nothing about the source is trusted. +#[rstest] +fn test_git_config_source_hooks_require_approval(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["hook", "show", "--format=json"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let hook = json + .as_array() + .unwrap() + .iter() + .find(|e| e["source"] == "project") + .expect("project hook present"); + assert_eq!(hook["template"], "npm install"); + assert_eq!(hook["needs_approval"], true); +} diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index af43060cca..10160bcc19 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -29,6 +29,7 @@ pub mod e2e_shell; pub mod e2e_shell_post_start; pub mod eval; pub mod for_each; +pub mod git_config_source; pub mod git_error_display; pub mod help; pub mod hook_show; diff --git a/tests/snapshots/integration__integration_tests__git_config_source__config_show_git_config_source.snap b/tests/snapshots/integration__integration_tests__git_config_source__config_show_git_config_source.snap new file mode 100644 index 0000000000..a50c5dc5d5 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__config_show_git_config_source.snap @@ -0,0 +1,71 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - config + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER CONFIG @ [TEST_CONFIG] +↳ Not found; to create one, run wt config create + +PROJECT CONFIG @ git config (worktrunk.config.*) +○ Identifier: ../origin +▲ Project config file @ _REPO_/.config/wt.toml is superseded by these keys +↳ To list the keys and their origins, run git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +  post-start = "npm install" +  +  [list] +  url = "http://localhost:3000" + +SHELL INTEGRATION +▲ Shell integration not configured +↳ To configure, run wt config shell install +  Invoked as: [PROJECT_ROOT]/target/[BUILD_MODE]/wt + +OTHER +○ wt: [VERSION] +○ git: [VERSION] +○ Hyperlinks: inactive + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__git_config_source__git_config_source_invalid_value_fails_loudly.snap b/tests/snapshots/integration__integration_tests__git_config_source__git_config_source_invalid_value_fails_loudly.snap new file mode 100644 index 0000000000..59fbdd91a1 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__git_config_source_invalid_value_fails_loudly.snap @@ -0,0 +1,60 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - hook + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +▲ Using worktrunk.config.* keys from git config as the project config; ignoring _REPO_/.config/wt.toml +↳ To list the keys and their origins, run git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +✗ Failed to load project config +  Failed to load project config +  Project config from git config (worktrunk.config.*) failed to parse: +  TOML parse error at line 2, column 11 +  | +  2 | exclude = "target" +  | ^^^^^^^^ +  invalid type: string "target", expected a sequence diff --git a/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_source_without_file.snap b/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_source_without_file.snap new file mode 100644 index 0000000000..c3b167d303 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_source_without_file.snap @@ -0,0 +1,56 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - hook + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER HOOKS @ [TEST_CONFIG] +↳ (none configured) + +PROJECT HOOKS @ git config (worktrunk.config.*) +❯ post-start: (requires approval) +  npm install + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_supersedes_project_file.snap b/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_supersedes_project_file.snap new file mode 100644 index 0000000000..eab577f7a7 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__hook_show_git_config_supersedes_project_file.snap @@ -0,0 +1,58 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - hook + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER HOOKS @ [TEST_CONFIG] +↳ (none configured) + +PROJECT HOOKS @ git config (worktrunk.config.*) +❯ post-start: (requires approval) +  npm install + +----- stderr ----- +▲ Using worktrunk.config.* keys from git config as the project config; ignoring _REPO_/.config/wt.toml +↳ To list the keys and their origins, run git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' 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 4002bac082..f38ab7ced7 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -597,6 +597,25 @@ Command templates that run as wt . See the Extending Worktrunk gui Aliases defined here are shared with teammates. For personal aliases, use the user config [aliases] section instead. +Private project config in git config [experimental] + +Project config normally lives in .config/wt.toml, committed and shared. Some settings are better kept private: a hook that runs a personal script, a machine-specific dev-server URL. Git config can hold these. + +Any key under the worktrunk.config. prefix in git config becomes project config. Strip the prefix; what remains is the exact TOML key path from the sections above: + +  git config worktrunk.config.post-start 'pnpm install' +  git config worktrunk.config.list.url 'http://localhost:3000' + +.git/config is local to the repository and never committed, so these keys stay on one machine — and every linked worktree sees them, because the local scope lives in the shared git dir. --global puts a key in every repository. Git's normal precedence applies: local overrides global, and conditional includes work. + +Selection is all-or-nothing. When any worktrunk.config.* key exists, those keys are the complete project config and .config/wt.toml is ignored — a warning names the superseded file. There is no key-level merging between the two sources. To return to the file, remove the keys. + +Values are strings, one per key. Settings that need other TOML types (such as the step.copy-ignored.exclude array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. + +To list every active key with its scope and origin file: + +  git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' + SHELL INTEGRATION Worktrunk needs shell integration to change directories when switching worktrees. Install with: From 97b8f1feced04f62f03502116e118cb6144f7839 Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 26 Jul 2026 01:04:10 -0400 Subject: [PATCH 2/6] fix(config): review fixes for the worktrunk.config.* source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from adversarial reviews of the two competing implementations, applied per the reviewed merge-fix plan: - Alias discovery routes through ProjectConfig::load, so git-config aliases appear in listings exactly when dispatch would run them. - A WORKTRUNK_PROJECT_CONFIG_PATH override — any value, including the empty kill switch — disables the git source, enforced in the sole accessor so every consumer inherits the deferral by construction. - wt config show propagates a failed git-config read instead of rendering the file as active while execution errors. - wt --diagnose names the git-config source, its key names, and the superseded file, but never the values: diagnose output is routinely pasted into public bug reports and this source exists for private configuration. - The supersession-warning latch is peeked before label resolution and set only on emit, so a no-file call cannot suppress a later born-superseded warning from wt config create --project. - Provenance docstring softened: include/includeIf can carry remotely-sourced content, which is why the approval gate applies unchanged. - Docs now state what the git source skips (file migration and its deprecation messaging — deprecated spellings may still deserialize), that per-worktree git config is unsupported, and describe the diagnostic command as listing matching keys rather than consumed values. - wt config create --project warns when the new file is born superseded; wt step prune suppresses the branch-hooks annotation under the git source (git config is branch-independent); project config resolves from a bare root via the git source, with wt config approvals framing its answers from it. - New tests: behavioral approval decline (hook must not run), includeIf resolution, both override-precedence forms, alias-listing consistency, born-superseded create, bare-root approvals, and diagnose value redaction. Co-Authored-By: Claude Fable 5 --- docs/content/config.md | 4 +- .../skills/worktrunk/reference/config.md | 4 +- skills/worktrunk/reference/config.md | 4 +- src/cli/mod.rs | 4 +- src/commands/alias.rs | 24 +- src/commands/config/approvals.rs | 19 +- src/commands/config/create.rs | 23 +- src/commands/config/show.rs | 7 +- src/commands/step/prune.rs | 19 +- src/config/git_source.rs | 45 ++- src/diagnostic.rs | 43 ++- src/git/repository/config.rs | 59 +++- tests/integration_tests/git_config_source.rs | 286 +++++++++++++++++- ...gration_tests__help__help_config_long.snap | 5 +- 14 files changed, 489 insertions(+), 57 deletions(-) diff --git a/docs/content/config.md b/docs/content/config.md index d22a3affa4..800761955b 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -632,7 +632,9 @@ Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those key Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. -To list every active key with its scope and origin file: +Use the canonical key spellings from the sections above. Deprecated spellings may still deserialize, but git-sourced configuration does not run file migration or emit deprecation guidance — `wt config update` has nothing to rewrite here. Per-worktree git config (`extensions.worktreeConfig`) is not supported: keys are read from the shared git dir, so a `config.worktree` value is never consumed. Setting `WORKTRUNK_PROJECT_CONFIG_PATH` — even to an empty value — disables this source entirely; the override names the project config source outright. + +To list the matching git keys with their scope and origin file (inside a linked worktree this can also show worktree-scoped keys, which worktrunk does not read): {{ terminal(cmd="git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.'") }} diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md index 3dd9469477..43af3b70d0 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -626,7 +626,9 @@ Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those key Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. -To list every active key with its scope and origin file: +Use the canonical key spellings from the sections above. Deprecated spellings may still deserialize, but git-sourced configuration does not run file migration or emit deprecation guidance — `wt config update` has nothing to rewrite here. Per-worktree git config (`extensions.worktreeConfig`) is not supported: keys are read from the shared git dir, so a `config.worktree` value is never consumed. Setting `WORKTRUNK_PROJECT_CONFIG_PATH` — even to an empty value — disables this source entirely; the override names the project config source outright. + +To list the matching git keys with their scope and origin file (inside a linked worktree this can also show worktree-scoped keys, which worktrunk does not read): ```bash $ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index 3dd9469477..43af3b70d0 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -626,7 +626,9 @@ Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those key Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. -To list every active key with its scope and origin file: +Use the canonical key spellings from the sections above. Deprecated spellings may still deserialize, but git-sourced configuration does not run file migration or emit deprecation guidance — `wt config update` has nothing to rewrite here. Per-worktree git config (`extensions.worktreeConfig`) is not supported: keys are read from the shared git dir, so a `config.worktree` value is never consumed. Setting `WORKTRUNK_PROJECT_CONFIG_PATH` — even to an empty value — disables this source entirely; the override names the project config source outright. + +To list the matching git keys with their scope and origin file (inside a linked worktree this can also show worktree-scoped keys, which worktrunk does not read): ```bash $ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 14692b0079..c2545255e1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2512,7 +2512,9 @@ Selection is all-or-nothing. When any `worktrunk.config.*` key exists, those key Values are strings, one per key. Settings that need other TOML types (such as the `step.copy-ignored.exclude` array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. -To list every active key with its scope and origin file: +Use the canonical key spellings from the sections above. Deprecated spellings may still deserialize, but git-sourced configuration does not run file migration or emit deprecation guidance — `wt config update` has nothing to rewrite here. Per-worktree git config (`extensions.worktreeConfig`) is not supported: keys are read from the shared git dir, so a `config.worktree` value is never consumed. Setting `WORKTRUNK_PROJECT_CONFIG_PATH` — even to an empty value — disables this source entirely; the override names the project config source outright. + +To list the matching git keys with their scope and origin file (inside a linked worktree this can also show worktree-scoped keys, which worktrunk does not read): ```console $ git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' diff --git a/src/commands/alias.rs b/src/commands/alias.rs index cbd374f028..7088307837 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -785,9 +785,10 @@ fn render_aliases_help_section( /// Callers (`augment_help`, `wt config alias show` with no name) latch /// `suppress_warnings()` before reaching here so the standard `UserConfig::load()` /// stays quiet: no deprecation warnings, no `.new` file writes, no -/// approved-commands copy. Project config is parsed directly from TOML rather -/// than via `ProjectConfig::load` because the `aliases` table has no deprecated -/// forms — skipping the migration avoids the unrelated warnings entirely. +/// approved-commands copy. Project config goes through `ProjectConfig::load` +/// so this listing reflects the same source selection as execution — in +/// particular the git-config source (`worktrunk.config.*`), whose aliases +/// must appear here exactly when dispatch would run them. /// /// Tolerates missing or unloadable config: this is a discovery surface, not /// an execution surface, so we'd rather show the built-in commands than @@ -823,17 +824,14 @@ pub(crate) fn load_aliases_for_listing() -> Vec<(String, CommandConfig, HookSour entries } -/// Parse `.config/wt.toml` directly, extracting just `aliases`, without -/// triggering `ProjectConfig::load`'s deprecation warning and hint-writing -/// side effects. See `load_aliases_for_listing` for why. +/// Load project aliases through the standard source selector, tolerating +/// discovery-time errors. Callers latch `suppress_warnings()` (see +/// `load_aliases_for_listing`), which keeps `ProjectConfig::load` quiet. fn load_project_aliases_silent(repo: &Repository) -> Option> { - let path = repo.project_config_path().ok().flatten()?; - if !path.exists() { - return None; - } - let contents = std::fs::read_to_string(&path).ok()?; - let config: ProjectConfig = toml::from_str(&contents).ok()?; - Some(config.aliases) + ProjectConfig::load(repo, false) + .ok() + .flatten() + .map(|config| config.aliases) } #[cfg(test)] diff --git a/src/commands/config/approvals.rs b/src/commands/config/approvals.rs index 9526d2774d..f50d18cf24 100644 --- a/src/commands/config/approvals.rs +++ b/src/commands/config/approvals.rs @@ -43,12 +43,19 @@ fn collect_approvable_commands(project_config: &ProjectConfig) -> Vec anyhow::Result { - let config_path = repo - .project_config_path()? - .context("Cannot determine project config location — no worktree found")?; - Ok(repo - .load_project_config()? - .ok_or(GitError::ProjectConfigNotFound { config_path })?) + // Load before resolving a path: the git-config source (worktrunk.config.*) + // can supply project config when no worktree resolves a file path at all + // (bare repo, default branch checked out in no worktree). The path is + // needed only to frame the not-found error. + if let Some(config) = repo.load_project_config()? { + return Ok(config); + } + match repo.project_config_path()? { + Some(config_path) => Err(GitError::ProjectConfigNotFound { config_path }.into()), + None => anyhow::bail!( + "No project config found — no worktree resolves .config/wt.toml, and git config has no worktrunk.config.* keys" + ), + } } /// One project command and whether its template is currently approved. diff --git a/src/commands/config/create.rs b/src/commands/config/create.rs index 436fe0ba94..54ad81b291 100644 --- a/src/commands/config/create.rs +++ b/src/commands/config/create.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use worktrunk::config::{ConfigFileKind, require_config_path}; use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; -use worktrunk::styling::{eprintln, hint_message, info_message, success_message}; +use worktrunk::styling::{eprintln, hint_message, info_message, success_message, warning_message}; /// Example user configuration file content (displayed in help with values uncommented) const USER_CONFIG_EXAMPLE: &str = include_str!("../../../dev/config.example.toml"); @@ -63,7 +63,26 @@ pub fn handle_config_create(project: bool) -> anyhow::Result<()> { "See https://worktrunk.dev/hook/ for hook documentation", ], user_config_exists, - ) + )?; + // Born superseded: existing worktrunk.config.* keys in git config are + // the project config (all-or-nothing), so the file just created will + // not be read until they are removed. Say so now, not at first use. + if !repo.worktrunk_config_git_pairs()?.is_empty() { + eprintln!( + "{}", + warning_message(cformat!( + "worktrunk.config.* keys exist in git config; the new project config will be ignored until they are removed" + )) + ); + eprintln!( + "{}", + hint_message(cformat!( + "To list the keys and their origins, run {}", + worktrunk::config::GIT_CONFIG_LIST_COMMAND + )) + ); + } + Ok(()) } else { let project_config_exists = Repository::current() .and_then(|repo| repo.project_config_path()) diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index 1e6c3e5c51..d1acb4e442 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -799,8 +799,11 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> { // Experimental git-config source (#3454), mirroring `ProjectConfig::load`: // any `worktrunk.config.*` keys in the merged effective git config are the // project config, and the file (when one resolves) is superseded. Rendered - // first so the section reports the source that actually runs. - let git_pairs = repo.worktrunk_config_git_pairs().unwrap_or_default(); + // first so the section reports the source that actually runs. A failed + // read propagates rather than defaulting to empty — swallowing it would + // render the file as active while actual execution errors on the same + // read, and diagnostics must not disagree with execution. + let git_pairs = repo.worktrunk_config_git_pairs()?; if !git_pairs.is_empty() { let source = format!("@ {}", worktrunk::config::GIT_CONFIG_SOURCE_LABEL); write_heading_and_identifier(out, &repo, &source)?; diff --git a/src/commands/step/prune.rs b/src/commands/step/prune.rs index 8575eda39c..d2d9f794e2 100644 --- a/src/commands/step/prune.rs +++ b/src/commands/step/prune.rs @@ -1125,12 +1125,23 @@ pub fn step_prune( // candidate "(different hooks on branch)" annotation in the skip hint // can compare each candidate's own `.config/wt.toml` against this // baseline. Byte-equal is approximate (whitespace differences flag too) - // but the result drives a hint, not behavior. - let invoking_project_bytes = repo - .project_config_path() + // but the result drives a hint, not behavior. When the git-config source + // (worktrunk.config.*) is active the baseline is None: git config is + // branch-independent, so per-branch file differences cannot change the + // selected hooks and the annotation would be noise. + let git_source_active = repo + .project_config() .ok() .flatten() - .and_then(|p| std::fs::read(p).ok()); + .is_some_and(|c| c.source == worktrunk::config::ProjectConfigSource::GitConfig); + let invoking_project_bytes = if git_source_active { + None + } else { + repo.project_config_path() + .ok() + .flatten() + .and_then(|p| std::fs::read(p).ok()) + }; let mut skipped_approval: Vec = Vec::new(); let check_lock = RwLock::new(()); diff --git a/src/config/git_source.rs b/src/config/git_source.rs index 348129369c..eac94c652d 100644 --- a/src/config/git_source.rs +++ b/src/config/git_source.rs @@ -4,9 +4,12 @@ //! //! Lets a repo carry private, uncommitted project configuration in git config //! under the `worktrunk.config.*` namespace. `.git/config` is never -//! transmitted by clone or fetch, so these keys cannot arrive from a remote — -//! they are user-authored by construction, and shared across every linked -//! worktree because the local scope lives in the common git dir. +//! transmitted by clone or fetch, so the source is typically local-only — but +//! not by construction: `include`/`includeIf` can pull in files that +//! originate remotely (a cloned dotfiles repo, for instance), which is +//! exactly why commands from this source keep the full approval gate. The +//! keys are shared across every linked worktree because the local scope +//! lives in the common git dir. //! //! # Key decisions //! @@ -38,6 +41,16 @@ //! through the ordinary project-command approval flow. Git config can //! carry remotely-authored content via `include`/`includeIf` (e.g. a //! cloned dotfiles repo), so source alone is not a trust signal. +//! - **No migration layer.** Deprecated spellings that deserialize via serde +//! aliases (`pre-create`/`post-create`) or live fields (`[ci]`) still work +//! here, but the file-migration rewrites and their deprecation warnings do +//! not run — `wt config update` has nothing to rewrite in git config, and +//! migration-only forms work in the file but not in this namespace. Docs +//! recommend canonical spellings. +//! - **No worktree scope.** The bulk config read runs from the common git +//! dir, so `config.worktree` values (`extensions.worktreeConfig`) are +//! never consumed. The diagnostic command, run inside a linked worktree, +//! can therefore list matching keys this source ignores. //! //! # Invariants //! @@ -46,6 +59,10 @@ //! provenance everywhere the cached config flows. //! - The supersession warning fires exactly when selection actually ignores a //! resolvable file — no keys → no warning; no file → no warning. +//! - A `WORKTRUNK_PROJECT_CONFIG_PATH` override (any value, including empty) +//! disables this source entirely, enforced in the sole accessor +//! (`Repository::worktrunk_config_git_pairs`) so every consumer inherits +//! the deferral by construction. use std::sync::OnceLock; @@ -171,11 +188,20 @@ pub(crate) fn warn_superseded_project_file(repo: &crate::git::Repository) { return; } + // Peek the latch before resolving the label (which can spawn `git show` + // in the bare/parked layout), but SET it only on emit: setting up front + // would consume it on the no-file path, and a later + // `wt config create --project` in the same invocation would then have + // its born-superseded warning silently suppressed. + static WARNED: OnceLock<()> = OnceLock::new(); + if WARNED.get().is_some() { + return; + } + let Some(superseded) = superseded_project_file_label(repo) else { return; }; - static WARNED: OnceLock<()> = OnceLock::new(); if WARNED.set(()).is_err() { return; } @@ -222,8 +248,7 @@ mod tests { #[test] fn test_top_level_hook_maps_to_flattened_key() { - let config = - project_config_from_git(&pairs(&[("post-start", "pnpm install")])).unwrap(); + let config = project_config_from_git(&pairs(&[("post-start", "pnpm install")])).unwrap(); assert_eq!(config.source, ProjectConfigSource::GitConfig); // `post-start` deserializes into the `post_create` field (serde // rename — the field kept its pre-rename name). @@ -238,7 +263,10 @@ mod tests { let config = project_config_from_git(&pairs(&[ ("list.url", "http://localhost:{{ branch | hash_port }}"), ("forge.platform", "github"), - ("commit.generation.template-append", "use conventional commits"), + ( + "commit.generation.template-append", + "use conventional commits", + ), ])) .unwrap(); assert_eq!( @@ -254,8 +282,7 @@ mod tests { #[test] fn test_alias_maps_to_aliases_table() { - let config = - project_config_from_git(&pairs(&[("aliases.deploy", "make deploy")])).unwrap(); + let config = project_config_from_git(&pairs(&[("aliases.deploy", "make deploy")])).unwrap(); let alias = config.aliases.get("deploy").expect("alias present"); let commands: Vec<_> = alias.commands().collect(); assert_eq!(commands[0].template, "make deploy"); diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 037fe6dc5f..7358415d11 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -496,12 +496,43 @@ fn config_show_output(repo: &Repository) -> Option { )); } - // Project config - if let Ok(Some(project_config_path)) = repo.project_config_path() { - output.push_str(&format!( - "\n{}", - format_config_section(&project_config_path, ConfigFileKind::Project) - )); + // Project config. When the experimental git-config source + // (worktrunk.config.*, #3454) is active, it — not the file — is the + // project config, and this report must say so: diagnose output is + // routinely pasted into public bug reports, so it names the source, the + // key names, and the superseded file, but never the values — this source + // exists specifically for private, machine-specific configuration. + match repo.worktrunk_config_git_pairs() { + Ok(pairs) if !pairs.is_empty() => { + output.push_str(&format!( + "\n{}: {}\n", + ConfigFileKind::Project.label(), + worktrunk::config::GIT_CONFIG_SOURCE_LABEL + )); + for (key, _) in &pairs { + output.push_str(&format!("{}{key}\n", worktrunk::config::GIT_CONFIG_PREFIX)); + } + if let Some(superseded) = worktrunk::config::superseded_project_file_label(repo) { + output.push_str(&format!("(superseded file: {superseded})\n")); + } + output.push_str(&format!( + "(values omitted; to inspect them, run {})\n", + worktrunk::config::GIT_CONFIG_LIST_COMMAND + )); + } + // A failed bulk-config read: note it rather than silently showing + // the file as active. Diagnose is best-effort and must not abort. + Err(e) => { + output.push_str(&format!("\n(git config read failed: {e})\n")); + } + Ok(_) => { + if let Ok(Some(project_config_path)) = repo.project_config_path() { + output.push_str(&format!( + "\n{}", + format_config_section(&project_config_path, ConfigFileKind::Project) + )); + } + } } if output.is_empty() { diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 7747d1dc5d..b25704bb7b 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -140,9 +140,22 @@ impl Repository { /// Per git's key model the middle path segments are case-sensitive; /// only exact-lowercase keys (the schema's own spelling) match. /// + /// A `WORKTRUNK_PROJECT_CONFIG_PATH` override — any value, including + /// empty — returns no pairs: the override names the config source + /// outright (see [`project_config_path`](Self::project_config_path)), + /// and the object-store fallback already defers to it for the same + /// reason. The empty-value form is the "no project config" kill switch + /// test harnesses rely on; ambient git keys must not resurrect config + /// behind it. Enforcing the deferral here, in the sole accessor, means + /// every consumer (`ProjectConfig::load`, `wt config show`, + /// `wt --diagnose`) inherits it by construction. + /// /// Any non-empty result means the git-config source supersedes /// `.config/wt.toml` — the selection lives in `ProjectConfig::load`. pub fn worktrunk_config_git_pairs(&self) -> anyhow::Result> { + if std::env::var_os("WORKTRUNK_PROJECT_CONFIG_PATH").is_some() { + return Ok(Vec::new()); + } let guard = self.all_config()?.read().unwrap(); Ok(guard .iter() @@ -962,9 +975,18 @@ impl Repository { pub fn project_config(&self) -> anyhow::Result> { self.cache .project_config - .get_or_try_init(|| match self.current_worktree().root() { - Ok(_) => ProjectConfig::load(self, true).context("Failed to load project config"), - Err(_) => Ok(None), // Not in a worktree, no project config + .get_or_try_init(|| { + // The file source needs a worktree to resolve against; the + // git-config source (worktrunk.config.*) does not — a bare + // root with keys in the bare repo's config still has project + // config. Outside a worktree, load only when such keys exist + // (the accessor is an in-memory scan, so this probe is free). + let in_worktree = self.current_worktree().root().is_ok(); + if in_worktree || !self.worktrunk_config_git_pairs()?.is_empty() { + ProjectConfig::load(self, true).context("Failed to load project config") + } else { + Ok(None) + } }) .map(Option::as_ref) } @@ -1036,6 +1058,37 @@ mod tests { assert_eq!(cmd_err.command_string(), "git config --unset inva lid.key"); } + #[test] + fn test_worktrunk_config_pairs_honor_conditional_include() { + // Keys reachable only through an `includeIf.gitdir:` condition must + // resolve like any other git config — git evaluates the condition + // before the bulk `--list` read this accessor scans. + let test = TestRepo::with_initial_commit(); + let fragment = test.root_path().join("private-worktrunk.gitconfig"); + std::fs::write( + &fragment, + "[worktrunk \"config.list\"]\n\turl = http://from-includeif:1234\n", + ) + .unwrap(); + + use path_slash::PathExt as _; + let condition = format!( + "includeIf.gitdir:{}/.git/.path", + test.root_path().to_slash_lossy() + ); + test.run_git(&["config", &condition, fragment.to_str().unwrap()]); + + let repo = Repository::at(test.root_path()).unwrap(); + let pairs = repo.worktrunk_config_git_pairs().unwrap(); + assert_eq!( + pairs, + vec![( + "list.url".to_string(), + "http://from-includeif:1234".to_string() + )] + ); + } + #[test] fn test_config_read_failure_is_command_error() { // Corrupting the config after the repository is open (the bulk map diff --git a/tests/integration_tests/git_config_source.rs b/tests/integration_tests/git_config_source.rs index df5f5a533d..14d3d26219 100644 --- a/tests/integration_tests/git_config_source.rs +++ b/tests/integration_tests/git_config_source.rs @@ -73,7 +73,11 @@ fn test_git_config_source_invalid_value_fails_loudly(repo: TestRepo, temp_home: repo.commit("Add project config"); // `step.copy-ignored.exclude` is an array in the schema; a string leaf // cannot satisfy it. - repo.run_git(&["config", "worktrunk.config.step.copy-ignored.exclude", "target"]); + repo.run_git(&[ + "config", + "worktrunk.config.step.copy-ignored.exclude", + "target", + ]); let settings = setup_snapshot_settings_with_home(&repo, &temp_home); settings.bind(|| { @@ -95,7 +99,11 @@ fn test_config_show_git_config_source(mut repo: TestRepo, temp_home: TempDir) { repo.write_project_config(r#"pre-merge = "cargo test""#); repo.commit("Add project config"); repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); - repo.run_git(&["config", "worktrunk.config.list.url", "http://localhost:3000"]); + repo.run_git(&[ + "config", + "worktrunk.config.list.url", + "http://localhost:3000", + ]); let settings = setup_snapshot_settings_with_home(&repo, &temp_home); settings.bind(|| { @@ -114,8 +122,18 @@ fn test_config_show_git_config_source(mut repo: TestRepo, temp_home: TempDir) { #[rstest] fn test_git_config_scope_precedence_local_over_global(repo: TestRepo, temp_home: TempDir) { write_user_config(&temp_home); - repo.run_git(&["config", "--global", "worktrunk.config.list.url", "http://global:9999"]); - repo.run_git(&["config", "--global", "worktrunk.config.forge.platform", "gitlab"]); + repo.run_git(&[ + "config", + "--global", + "worktrunk.config.list.url", + "http://global:9999", + ]); + repo.run_git(&[ + "config", + "--global", + "worktrunk.config.forge.platform", + "gitlab", + ]); repo.run_git(&["config", "worktrunk.config.list.url", "http://local:3000"]); let mut cmd = wt_command(); @@ -125,7 +143,11 @@ fn test_git_config_scope_precedence_local_over_global(repo: TestRepo, temp_home: set_temp_home_env(&mut cmd, temp_home.path()); let output = cmd.output().unwrap(); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); let project = &json["project"]; @@ -158,7 +180,11 @@ fn test_git_config_source_include_path(repo: TestRepo, temp_home: TempDir) { set_temp_home_env(&mut cmd, temp_home.path()); let output = cmd.output().unwrap(); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(json["project"]["source"], "git-config"); assert_eq!( @@ -167,6 +193,248 @@ fn test_git_config_source_include_path(repo: TestRepo, temp_home: TempDir) { ); } +/// A `WORKTRUNK_PROJECT_CONFIG_PATH` override names the config source +/// outright: with the override set, git keys are ignored and the override +/// file loads. +#[rstest] +fn test_project_config_path_override_beats_git_source(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "from git config"]); + let override_path = temp_home.path().join("override-wt.toml"); + fs::write(&override_path, "post-start = \"from override file\"\n").unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.env("WORKTRUNK_PROJECT_CONFIG_PATH", &override_path) + .args(["config", "show", "--format=json"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["project"]["source"], "file"); + assert_eq!( + json["project"]["config"]["post-start"], + "from override file" + ); +} + +/// The empty-override kill switch stays authoritative: no project config at +/// all, even with git keys present. +#[rstest] +fn test_empty_override_disables_git_source(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "from git config"]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.env("WORKTRUNK_PROJECT_CONFIG_PATH", "") + .args(["config", "show", "--format=json"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["project"]["exists"], false); + assert_eq!(json["project"]["source"], serde_json::Value::Null); +} + +/// Alias discovery reflects the selected source: a git-config alias appears +/// in the listing, the superseded file's alias does not — discovery and +/// dispatch must name the same command set. +#[rstest] +fn test_alias_listing_reflects_git_config_source(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.write_project_config( + r#"[aliases] +file-alias = "echo from file" +"#, + ); + repo.commit("Add project config"); + repo.run_git(&[ + "config", + "worktrunk.config.aliases.git-alias", + "echo from git", + ]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["config", "alias", "show"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("git-alias"), + "git-config alias missing from listing:\n{stdout}" + ); + assert!( + !stdout.contains("file-alias"), + "superseded file alias must not be listed:\n{stdout}" + ); +} + +/// `wt config create --project` warns when the file it creates is born +/// superseded by existing git keys. +#[rstest] +fn test_config_create_project_warns_when_born_superseded(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["config", "create", "--project"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(repo.root_path().join(".config/wt.toml").exists()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("will be ignored until they are removed") + && stderr.contains("worktrunk.config."), + "born-superseded warning missing:\n{stderr}" + ); +} + +/// Declining approval for a git-config hook keeps it from running — the gate +/// blocks execution, not just reporting. Piped stdin is non-interactive, so +/// the prompt path lists the commands and refuses; the hook artifact must +/// not exist afterward. +#[rstest] +fn test_git_config_hook_declined_does_not_run(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&[ + "config", + "worktrunk.config.pre-start", + "echo ran > git-config-hook-artifact.txt", + ]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["switch", "--create", "gated-feature"]) + .current_dir(repo.root_path()) + .stdin(std::process::Stdio::piped()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("needs approval") && stderr.contains("pre-start"), + "git-config hook should enter the project approval path:\n{stderr}" + ); + assert!( + !repo + .root_path() + .join("git-config-hook-artifact.txt") + .exists(), + "declined git-config hook must not run" + ); +} + +/// At a bare root with no worktrees, the git-config source still supplies +/// project config — `wt config approvals clear --stale` frames its answer +/// from it instead of erroring "no worktree found". +#[rstest] +fn test_bare_root_approvals_clear_stale_uses_git_source(_repo: TestRepo) { + let bare = crate::common::BareRepoTest::new(); + let status = std::process::Command::new("git") + .args(["-C", bare.bare_repo_path().to_str().unwrap()]) + .args(["config", "worktrunk.config.post-start", "npm install"]) + .status() + .unwrap(); + assert!(status.success()); + + let mut cmd = bare.wt_command(); + cmd.args(["config", "approvals", "clear", "--stale"]) + .current_dir(bare.bare_repo_path()); + + let output = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected git-config source to satisfy the project-config requirement:\n{stderr}" + ); + assert!( + stderr.contains("No stale approvals to clear"), + "unexpected output:\n{stderr}" + ); +} + +/// The diagnostic report names the git-config source and its key names, but +/// never the values — diagnose output is routinely pasted into public bug +/// reports, and this source exists for private configuration. +#[rstest] +fn test_diagnostic_report_omits_git_config_values(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.write_project_config(r#"pre-merge = "cargo test""#); + repo.commit("Add project config"); + repo.run_git(&[ + "config", + "worktrunk.config.post-start", + "echo diag-private-value", + ]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["list", "-vv"]).current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.output().unwrap(); + + let report = fs::read_to_string( + repo.root_path() + .join(".git") + .join("wt/logs") + .join("diagnostic.md"), + ) + .expect("-vv run writes a diagnostic report"); + + // Scope the value-absence assertion to the config section: the -vv trace + // section embeds raw subprocess output, which includes the whole + // `git config --list -z` listing — a pre-existing disclosure surface for + // ALL git config values, not something this feature introduces or can + // fix here. The contract under test is that the *config section* names + // keys without values. + let section_start = report + .find("Project config: git config (worktrunk.config.*)") + .unwrap_or_else(|| panic!("report should name the git-config source:\n{report}")); + let section = &report[section_start..]; + let section = §ion[..section.find("\n\n").unwrap_or(section.len())]; + assert!( + section.contains("worktrunk.config.post-start"), + "config section should name the active keys:\n{section}" + ); + assert!( + section.contains("values omitted"), + "config section should state values are omitted:\n{section}" + ); + assert!( + !section.contains("diag-private-value"), + "private hook body leaked into the config section:\n{section}" + ); +} + /// Git-config-sourced hooks pass through the same approval gate as /// file-based project config — nothing about the source is trusted. #[rstest] @@ -181,7 +449,11 @@ fn test_git_config_source_hooks_require_approval(repo: TestRepo, temp_home: Temp set_temp_home_env(&mut cmd, temp_home.path()); let output = cmd.output().unwrap(); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); let hook = json .as_array() 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 f38ab7ced7..7ff72060a6 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -612,7 +612,10 @@ Selection is all-or-nothing. When any worktrunk.config.* key exists, tho Values are strings, one per key. Settings that need other TOML types (such as the step.copy-ignored.exclude array) cannot be expressed here. Hooks and aliases defined this way go through the same approval prompt as file-based project config. -To list every active key with its scope and origin file: +Use the canonical key spellings from the sections above. Deprecated spellings may still deserialize, but git-sourced configuration does not run file migration or emit deprecation guidance — wt config update has nothing to rewrite here. Per-worktree git config (extensions.worktreeConfig) is not supported: keys are read from the shared git dir, so a config.worktree value is never consumed. Setting WORKTRUNK_PROJECT_CONFIG_PATH — even to an empty value — disables this source entirely; the override +names the project config source outright. + +To list the matching git keys with their scope and origin file (inside a linked worktree this can also show worktree-scoped keys, which worktrunk does not read):   git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' From f2dc7034baf3559029e9e20f9bdb1d80489ea19b Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 26 Jul 2026 05:29:46 -0400 Subject: [PATCH 3/6] test(config): close the coverage gaps codecov flagged on the git source - wt config show snapshots for the two error renderings: a git-source value that violates the schema (Invalid config + gutter) and colliding key paths (conflict error), matching the file branch's treatment. - wt step prune --dry-run under the git source exercises the suppressed branch-hooks baseline. - A bare root without keys exercises the no-project-config guard and its path-free error. - A repeat-call unit test exercises the supersession-warning latch peek. - The diagnose Err arm collapses into the file fallback (best-effort surface; the file section already reports read failures) and the infallible-in-practice TOML render error map shrinks to a passthrough, removing two unreachable specialized handlers instead of pretending to test them. Co-Authored-By: Claude Fable 5 --- src/config/git_source.rs | 29 ++++-- src/diagnostic.rs | 10 +- tests/integration_tests/git_config_source.rs | 92 +++++++++++++++++++ ...nfig_show_conflicting_git_source_keys.snap | 68 ++++++++++++++ ..._config_show_invalid_git_source_value.snap | 75 +++++++++++++++ 5 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__config_show_conflicting_git_source_keys.snap create mode 100644 tests/snapshots/integration__integration_tests__git_config_source__config_show_invalid_git_source_value.snap diff --git a/src/config/git_source.rs b/src/config/git_source.rs index eac94c652d..b0af7cdd33 100644 --- a/src/config/git_source.rs +++ b/src/config/git_source.rs @@ -90,14 +90,12 @@ pub const GIT_CONFIG_LIST_COMMAND: &str = /// document string. /// /// Fails when a key path is malformed (empty segment) or when two keys -/// collide (one names a value where another needs a table). +/// collide (one names a value where another needs a table). Serializing the +/// built table cannot fail in practice — it holds only string leaves and +/// nested tables — so that arm is a plain error passthrough. pub fn render_git_source_toml(pairs: &[(String, String)]) -> Result { let table = pairs_to_table(pairs)?; - toml::to_string(&table).map_err(|e| { - ConfigError(format!( - "Failed to render {GIT_CONFIG_SOURCE_LABEL} as TOML: {e}" - )) - }) + toml::to_string(&table).map_err(|e| ConfigError(format!("{GIT_CONFIG_SOURCE_LABEL}: {e}"))) } /// Parse `worktrunk.config.*` pairs into a [`ProjectConfig`] tagged with @@ -328,4 +326,23 @@ mod tests { let config: ProjectConfig = toml::from_str("post-start = \"x\"").unwrap(); assert_eq!(config.source, ProjectConfigSource::File); } + + #[test] + fn test_superseded_warning_latch_short_circuits_repeat_calls() { + // A first successful emit sets the process latch; later calls return + // at the peek without re-resolving the label. Output is not asserted + // (a parallel test may legitimately have latched warning + // suppression); this exercises the latch path itself. + let test = crate::testing::TestRepo::with_initial_commit(); + std::fs::create_dir_all(test.root_path().join(".config")).unwrap(); + std::fs::write( + test.root_path().join(".config/wt.toml"), + "pre-merge = \"cargo test\"\n", + ) + .unwrap(); + test.run_git(&["config", "worktrunk.config.post-start", "echo hi"]); + let repo = crate::git::Repository::at(test.root_path()).unwrap(); + warn_superseded_project_file(&repo); + warn_superseded_project_file(&repo); + } } diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 7358415d11..255341f86a 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -520,12 +520,10 @@ fn config_show_output(repo: &Repository) -> Option { worktrunk::config::GIT_CONFIG_LIST_COMMAND )); } - // A failed bulk-config read: note it rather than silently showing - // the file as active. Diagnose is best-effort and must not abort. - Err(e) => { - output.push_str(&format!("\n(git config read failed: {e})\n")); - } - Ok(_) => { + // No keys — or a failed bulk read (corrupt config): fall through to + // the file section. Diagnose is best-effort, and the file section's + // own "(read failed: …)" reporting covers the broken-config story. + _ => { if let Ok(Some(project_config_path)) = repo.project_config_path() { output.push_str(&format!( "\n{}", diff --git a/tests/integration_tests/git_config_source.rs b/tests/integration_tests/git_config_source.rs index 14d3d26219..2ae6ef5b9b 100644 --- a/tests/integration_tests/git_config_source.rs +++ b/tests/integration_tests/git_config_source.rs @@ -117,6 +117,98 @@ fn test_config_show_git_config_source(mut repo: TestRepo, temp_home: TempDir) { }); } +/// `wt config show` renders the schema violation when a git-source value +/// cannot satisfy its field — the same "Invalid config" treatment the file +/// branch gives a bad `.config/wt.toml`. +#[rstest] +fn test_config_show_invalid_git_source_value(mut repo: TestRepo, temp_home: TempDir) { + repo.setup_mock_ci_tools_unauthenticated(); + write_user_config(&temp_home); + repo.run_git(&[ + "config", + "worktrunk.config.step.copy-ignored.exclude", + "target", + ]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + repo.configure_mock_commands(&mut cmd); + cmd.arg("config").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// Colliding key paths (one key names a value where another needs a table) +/// surface as an error in `wt config show` rather than a silent overwrite. +#[rstest] +fn test_config_show_conflicting_git_source_keys(mut repo: TestRepo, temp_home: TempDir) { + repo.setup_mock_ci_tools_unauthenticated(); + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.list", "oops"]); + repo.run_git(&[ + "config", + "worktrunk.config.list.url", + "http://localhost:3000", + ]); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + repo.configure_mock_commands(&mut cmd); + cmd.arg("config").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + +/// `wt step prune` runs with the git-config source active; the per-branch +/// hooks annotation baseline is suppressed (git config is +/// branch-independent), and the command completes normally. +#[rstest] +fn test_step_prune_dry_run_with_git_source(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + repo.run_git(&["config", "worktrunk.config.post-start", "npm install"]); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["step", "prune", "--dry-run"]) + .current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// At a bare root with no worktrees and no git keys, there is genuinely no +/// project config: operations that require one report that plainly instead +/// of pretending a worktree problem. +#[rstest] +fn test_bare_root_without_keys_has_no_project_config(_repo: TestRepo) { + let bare = crate::common::BareRepoTest::new(); + + let mut cmd = bare.wt_command(); + cmd.args(["config", "approvals", "clear", "--stale"]) + .current_dir(bare.bare_repo_path()); + + let output = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success()); + assert!( + stderr.contains("No project config found"), + "unexpected error:\n{stderr}" + ); +} + /// Git resolves scope precedence before worktrunk reads the keys: a local /// key overrides its global twin, and global-only keys still merge in. #[rstest] diff --git a/tests/snapshots/integration__integration_tests__git_config_source__config_show_conflicting_git_source_keys.snap b/tests/snapshots/integration__integration_tests__git_config_source__config_show_conflicting_git_source_keys.snap new file mode 100644 index 0000000000..1a80c23e45 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__config_show_conflicting_git_source_keys.snap @@ -0,0 +1,68 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - config + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER CONFIG @ [TEST_CONFIG] +↳ Not found; to create one, run wt config create + +PROJECT CONFIG @ git config (worktrunk.config.*) +○ Identifier: ../origin +↳ To list the keys and their origins, run git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +✗ Conflicting git config keys: worktrunk.config.list is a value, but worktrunk.config.list.url needs it to be a table + +SHELL INTEGRATION +▲ Shell integration not configured +↳ To configure, run wt config shell install +  Invoked as: [PROJECT_ROOT]/target/[BUILD_MODE]/wt + +OTHER +○ wt: [VERSION] +○ git: [VERSION] +○ Hyperlinks: inactive + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__git_config_source__config_show_invalid_git_source_value.snap b/tests/snapshots/integration__integration_tests__git_config_source__config_show_invalid_git_source_value.snap new file mode 100644 index 0000000000..4bfc4dcbf7 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__git_config_source__config_show_invalid_git_source_value.snap @@ -0,0 +1,75 @@ +--- +source: tests/integration_tests/git_config_source.rs +info: + program: wt + args: + - config + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER CONFIG @ [TEST_CONFIG] +↳ Not found; to create one, run wt config create + +PROJECT CONFIG @ git config (worktrunk.config.*) +○ Identifier: ../origin +↳ To list the keys and their origins, run git config --show-scope --show-origin --get-regexp '^worktrunk\.config\.' +✗ Invalid config +  TOML parse error at line 2, column 11 +  | +  2 | exclude = "target" +  | ^^^^^^^^ +  invalid type: string "target", expected a sequence +  [step.copy-ignored] +  exclude = "target" + +SHELL INTEGRATION +▲ Shell integration not configured +↳ To configure, run wt config shell install +  Invoked as: [PROJECT_ROOT]/target/[BUILD_MODE]/wt + +OTHER +○ wt: [VERSION] +○ git: [VERSION] +○ Hyperlinks: inactive + +----- stderr ----- From f8747c682d58e07f0dc4e12e01409a2fb52bf68f Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 26 Jul 2026 14:08:22 -0400 Subject: [PATCH 4/6] fix(config): guard the prune hint at differs; restore the diagnose Err arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the follow-up commits: - wt step prune suppressed the "(different hooks on branch)" annotation by nulling the comparison baseline, which inverts it: a candidate with a committed .config/wt.toml compares Some(_) != None and flags the exact case where git-config hooks are identical across branches. The suppression now lives on the differs computation itself, pinned by a test with a committed file and an unapproved git-config pre-remove. - The diagnose Err arm returns: a failed bulk-config read is reported as such instead of falling through and printing .config/wt.toml as the active source — the file section's "(read failed)" covers a different read, so the fall-through misattributed, in the report meant to diagnose exactly that failure. Its uncovered line is an honest, effectively untestable gap, and is documented as one. Co-Authored-By: Claude Fable 5 --- src/commands/step/prune.rs | 23 +++++++++----- src/diagnostic.rs | 14 +++++--- tests/integration_tests/step_prune.rs | 46 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/commands/step/prune.rs b/src/commands/step/prune.rs index d2d9f794e2..3fcd1505e8 100644 --- a/src/commands/step/prune.rs +++ b/src/commands/step/prune.rs @@ -1126,9 +1126,10 @@ pub fn step_prune( // can compare each candidate's own `.config/wt.toml` against this // baseline. Byte-equal is approximate (whitespace differences flag too) // but the result drives a hint, not behavior. When the git-config source - // (worktrunk.config.*) is active the baseline is None: git config is - // branch-independent, so per-branch file differences cannot change the - // selected hooks and the annotation would be noise. + // (worktrunk.config.*) is active the annotation is suppressed at the + // `differs` computation — git config is branch-independent, so + // per-branch file differences cannot change the selected hooks — and the + // baseline isn't loaded (it would go unread). let git_source_active = repo .project_config() .ok() @@ -1299,11 +1300,17 @@ pub fn step_prune( info_message(cformat!("Skipped {label} (approval required)")) .to_string(); let _ = job_tx.send(RemovalJob::PrintSkip(line)); - let differs = path.as_deref().is_some_and(|wt_path| { - let candidate_bytes = - std::fs::read(wt_path.join(".config").join("wt.toml")).ok(); - candidate_bytes != invoking_project_bytes - }); + // The guard, not the baseline, suppresses the annotation + // under the git-config source: with the baseline `None`, a + // candidate that has a committed `.config/wt.toml` would + // compare `Some(_) != None` and flag the exact case where + // hooks are identical across branches. + let differs = !git_source_active + && path.as_deref().is_some_and(|wt_path| { + let candidate_bytes = + std::fs::read(wt_path.join(".config").join("wt.toml")).ok(); + candidate_bytes != invoking_project_bytes + }); skipped_approval.push(SkippedApproval { path, differs }); continue; } diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 255341f86a..3329fb7318 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -520,10 +520,16 @@ fn config_show_output(repo: &Repository) -> Option { worktrunk::config::GIT_CONFIG_LIST_COMMAND )); } - // No keys — or a failed bulk read (corrupt config): fall through to - // the file section. Diagnose is best-effort, and the file section's - // own "(read failed: …)" reporting covers the broken-config story. - _ => { + // A failed bulk-config read: note it rather than silently showing + // the file as active. Diagnose is best-effort and must not abort. + // The line is effectively untestable (a config corrupted after the + // command started but before this report renders) — an honest + // coverage gap, kept because falling through would misattribute the + // active source in the very report meant to diagnose the failure. + Err(e) => { + output.push_str(&format!("\n(git config read failed: {e})\n")); + } + Ok(_) => { if let Ok(Some(project_config_path)) = repo.project_config_path() { output.push_str(&format!( "\n{}", diff --git a/tests/integration_tests/step_prune.rs b/tests/integration_tests/step_prune.rs index ae4ef73a74..08123eb1cf 100644 --- a/tests/integration_tests/step_prune.rs +++ b/tests/integration_tests/step_prune.rs @@ -1250,6 +1250,52 @@ fn test_prune_pre_remove_needs_approval(mut repo: TestRepo) { ); } +/// With the git-config source (`worktrunk.config.*`) supplying the hooks, the +/// `(different hooks on branch)` annotation must not appear: git config is +/// branch-independent, so a candidate's committed `.config/wt.toml` cannot +/// change which hooks run. Pins the guard on the `differs` computation — with +/// the baseline merely `None`, a candidate that has a committed file would +/// compare `Some(_) != None` and flag exactly this case. +#[rstest] +fn test_prune_skip_hint_no_branch_annotation_under_git_source(mut repo: TestRepo) { + // Committed project file so the candidate worktree carries one; hooks + // come from git config (unapproved → the candidate is skipped with the + // hint this test inspects). + repo.write_project_config(r#"pre-merge = "cargo test""#); + repo.commit("Add project config"); + let wt_path = repo.add_worktree("merged"); + repo.commit("Advance default branch"); + repo.run_git(&[ + "config", + "worktrunk.config.pre-remove", + "echo ran > prune-git-source-marker.txt", + ]); + + let output = repo + .wt_command() + .args(["step", "prune", "--foreground", "--min-age=0s"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "prune should skip the unapproved candidate, not abort; stderr:\n{stderr}" + ); + assert!( + stderr.contains("(approval required)"), + "git-config pre-remove is unapproved, so the candidate skips; stderr:\n{stderr}" + ); + assert!( + !stderr.contains("(different hooks on branch)"), + "branch-independent git-config hooks must not be annotated as differing; stderr:\n{stderr}" + ); + assert!( + wt_path.exists(), + "the worktree must not be removed when its hooks aren't approved" + ); +} + /// An unmerged worktree is outside prune's removal set, so the `pre-remove` it /// would run is never part of the approval gate. #[rstest] From cd884800b2e8d41e92cd15c0a62c610668571a4d Mon Sep 17 00:00:00 2001 From: indexzero Date: Wed, 29 Jul 2026 12:29:18 -0400 Subject: [PATCH 5/6] test(config): isolate the scope-precedence test's global git config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_git_config_scope_precedence_local_over_global wrote its "global" worktrunk.config.* keys with `git config --global`, which lands in the process-shared test gitconfig (test_gitconfig_path) that every parallel test points GIT_CONFIG_GLOBAL at. Those keys then bled into other tests' merged `git config --list`, non-deterministically shifting, e.g., the TOML line number in test_git_config_source_invalid_value_fails_loudly (line 2 → line 8) whenever the two tests overlapped. Give the global tier its own GIT_CONFIG_GLOBAL file so the global scope stays private to this test. No other test writes --global. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests/git_config_source.rs | 36 +++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/integration_tests/git_config_source.rs b/tests/integration_tests/git_config_source.rs index 2ae6ef5b9b..7d5a9c72d9 100644 --- a/tests/integration_tests/git_config_source.rs +++ b/tests/integration_tests/git_config_source.rs @@ -214,23 +214,33 @@ fn test_bare_root_without_keys_has_no_project_config(_repo: TestRepo) { #[rstest] fn test_git_config_scope_precedence_local_over_global(repo: TestRepo, temp_home: TempDir) { write_user_config(&temp_home); - repo.run_git(&[ - "config", - "--global", - "worktrunk.config.list.url", - "http://global:9999", - ]); - repo.run_git(&[ - "config", - "--global", - "worktrunk.config.forge.platform", - "gitlab", - ]); + // The global tier gets its own GIT_CONFIG_GLOBAL file. Writing `--global` + // would land in the process-shared test gitconfig (`test_gitconfig_path`), + // leaking `worktrunk.config.*` keys into every parallel test's merged + // config; a private file keeps the global scope local to this test. + let global_config = temp_home.path().join("global-gitconfig"); + let write_global = |key: &str, value: &str| { + let ok = std::process::Command::new("git") + .args([ + "config", + "--file", + global_config.to_str().unwrap(), + key, + value, + ]) + .status() + .unwrap() + .success(); + assert!(ok, "failed to write global git config {key}"); + }; + write_global("worktrunk.config.list.url", "http://global:9999"); + write_global("worktrunk.config.forge.platform", "gitlab"); repo.run_git(&["config", "worktrunk.config.list.url", "http://local:3000"]); let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); - cmd.args(["config", "show", "--format=json"]) + cmd.env("GIT_CONFIG_GLOBAL", &global_config) + .args(["config", "show", "--format=json"]) .current_dir(repo.root_path()); set_temp_home_env(&mut cmd, temp_home.path()); From f022b6960bec6f9b11ccd9ba6aa036fef585d10a Mon Sep 17 00:00:00 2001 From: indexzero Date: Wed, 29 Jul 2026 13:32:11 -0400 Subject: [PATCH 6/6] test(config): cover the git-source patch lines codecov flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the codecov/patch gaps on the worktrunk.config.* changes: - git_source: the supersession-warning latch drops its untestable race-loser guard for a race-tolerant `let _ = WARNED.set(())`; the top peek still suppresses the common re-entry. - config show: the superseded-file warning and the diagnostic-command hint move from writeln!(…)? to push_str — writing into a String is infallible, so `?` left an uncoverable error region. - diagnostic: three in-process unit tests exercise config_show_output's git-source branch (names source + keys, omits values), the file fallback, and the failed-read arm; the superseded-file note builds via map/unwrap_or_default so its region records under coverage. - config.rs: project_config reverts to upstream's form. Upstream now has current_worktree().root() fall back to the discovery path instead of erroring, so load() is always reached and handles the git-config source itself — the earlier in_worktree/git-keys guard (and its dead else) is unnecessary. - config create: an error-path test (`.config` is a regular file, so create_dir_all fails) covers the create call's `?`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/config/show.rs | 22 +++---- src/config/git_source.rs | 7 ++- src/diagnostic.rs | 81 +++++++++++++++++++++++++- src/git/repository/config.rs | 15 +---- tests/integration_tests/config_init.rs | 20 +++++++ 5 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index d1acb4e442..50e3e705ce 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -808,22 +808,24 @@ fn render_project_config(out: &mut String) -> anyhow::Result<()> { let source = format!("@ {}", worktrunk::config::GIT_CONFIG_SOURCE_LABEL); write_heading_and_identifier(out, &repo, &source)?; if let Some(superseded) = worktrunk::config::superseded_project_file_label(&repo) { - writeln!( - out, - "{}", - warning_message(cformat!( + // push_str, not writeln!(…)? — the write into a String is + // infallible, so `?` leaves an uncoverable error region. + out.push_str( + &warning_message(cformat!( "Project config file @ {superseded} is superseded by these keys" )) - )?; + .to_string(), + ); + out.push('\n'); } - writeln!( - out, - "{}", - hint_message(cformat!( + out.push_str( + &hint_message(cformat!( "To list the keys and their origins, run {}", worktrunk::config::GIT_CONFIG_LIST_COMMAND )) - )?; + .to_string(), + ); + out.push('\n'); match worktrunk::config::render_git_source_toml(&git_pairs) { Ok(rendered) => { // Same validation rendering as the file branch below. diff --git a/src/config/git_source.rs b/src/config/git_source.rs index b0af7cdd33..d8b487b2fb 100644 --- a/src/config/git_source.rs +++ b/src/config/git_source.rs @@ -200,9 +200,10 @@ pub(crate) fn warn_superseded_project_file(repo: &crate::git::Repository) { return; }; - if WARNED.set(()).is_err() { - return; - } + // Race-tolerant: the peek above already suppresses the common re-entry; + // in the rare case two threads pass it before either sets the latch, + // both emit once, which is preferable to an untestable race-loser guard. + let _ = WARNED.set(()); eprintln!( "{}", diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 3329fb7318..9bc9adc900 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -512,9 +512,10 @@ fn config_show_output(repo: &Repository) -> Option { for (key, _) in &pairs { output.push_str(&format!("{}{key}\n", worktrunk::config::GIT_CONFIG_PREFIX)); } - if let Some(superseded) = worktrunk::config::superseded_project_file_label(repo) { - output.push_str(&format!("(superseded file: {superseded})\n")); - } + let superseded = worktrunk::config::superseded_project_file_label(repo) + .map(|s| format!("(superseded file: {s})\n")) + .unwrap_or_default(); + output.push_str(&superseded); output.push_str(&format!( "(values omitted; to inspect them, run {})\n", worktrunk::config::GIT_CONFIG_LIST_COMMAND @@ -592,6 +593,80 @@ mod tests { "); } + #[test] + fn test_config_show_output_names_git_source_and_superseded_file() { + // Git keys plus a committed .config/wt.toml: the report names the + // source and the superseded file, lists the key names, and omits the + // values (#3454). + let test = worktrunk::testing::TestRepo::with_initial_commit(); + std::fs::create_dir_all(test.root_path().join(".config")).unwrap(); + std::fs::write( + test.root_path().join(".config/wt.toml"), + "pre-merge = \"cargo test\"\n", + ) + .unwrap(); + test.run_git(&[ + "config", + "worktrunk.config.post-start", + "echo private-value", + ]); + + let repo = Repository::at(test.root_path()).unwrap(); + let output = config_show_output(&repo).unwrap_or_default(); + assert!( + output.contains("worktrunk.config.post-start"), + "should name the active keys:\n{output}" + ); + assert!( + output.contains("superseded file:"), + "should name the superseded file:\n{output}" + ); + assert!( + output.contains("values omitted"), + "should omit values:\n{output}" + ); + assert!( + !output.contains("echo private-value"), + "must not leak the hook body:\n{output}" + ); + } + + #[test] + fn test_config_show_output_falls_back_to_file_without_git_keys() { + // No git keys: the report shows the project config file section. + let test = worktrunk::testing::TestRepo::with_initial_commit(); + std::fs::create_dir_all(test.root_path().join(".config")).unwrap(); + std::fs::write( + test.root_path().join(".config/wt.toml"), + "pre-merge = \"cargo test\"\n", + ) + .unwrap(); + + let repo = Repository::at(test.root_path()).unwrap(); + let output = config_show_output(&repo).unwrap_or_default(); + assert!( + output.contains("Project config:"), + "should render the file section:\n{output}" + ); + } + + #[test] + fn test_config_show_output_reports_git_config_read_failure() { + // A corrupt git config makes the bulk `git config --list -z` read + // fail; the diagnostic must note that rather than silently rendering + // `.config/wt.toml` as the active project source (#3454). + let test = worktrunk::testing::TestRepo::with_initial_commit(); + let repo = Repository::at(test.root_path()).unwrap(); + // Corrupt after opening: `all_config` populates lazily on first read. + std::fs::write(test.root_path().join(".git/config"), "[bad\n").unwrap(); + + let output = config_show_output(&repo).unwrap_or_default(); + assert!( + output.contains("git config read failed"), + "diagnostic should report the failed git-config read:\n{output}" + ); + } + #[test] fn test_format_config_section_empty_file() { let tmp = TempDir::new().unwrap(); diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index b25704bb7b..fb6864e9fc 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -975,18 +975,9 @@ impl Repository { pub fn project_config(&self) -> anyhow::Result> { self.cache .project_config - .get_or_try_init(|| { - // The file source needs a worktree to resolve against; the - // git-config source (worktrunk.config.*) does not — a bare - // root with keys in the bare repo's config still has project - // config. Outside a worktree, load only when such keys exist - // (the accessor is an in-memory scan, so this probe is free). - let in_worktree = self.current_worktree().root().is_ok(); - if in_worktree || !self.worktrunk_config_git_pairs()?.is_empty() { - ProjectConfig::load(self, true).context("Failed to load project config") - } else { - Ok(None) - } + .get_or_try_init(|| match self.current_worktree().root() { + Ok(_) => ProjectConfig::load(self, true).context("Failed to load project config"), + Err(_) => Ok(None), // Not in a worktree, no project config }) .map(Option::as_ref) } diff --git a/tests/integration_tests/config_init.rs b/tests/integration_tests/config_init.rs index 64b48d62d2..c93ff66e8f 100644 --- a/tests/integration_tests/config_init.rs +++ b/tests/integration_tests/config_init.rs @@ -120,6 +120,26 @@ run = "echo hello" }); } +/// `wt config create --project` propagates `create_config_file`'s error when +/// the config directory can't be created. Here `.config` already exists as a +/// regular file, so `create_dir_all(".config")` fails and the error surfaces +/// (covers the create call's `?` error path on the git-config branch, #3454). +#[rstest] +fn test_config_create_project_errors_when_config_dir_is_a_file(repo: TestRepo) { + fs::write(repo.root_path().join(".config"), "not a dir").unwrap(); + + let output = repo + .wt_command() + .args(["config", "create", "--project"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "create should fail when .config is a regular file; stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + /// Running `wt config create --project` from inside a repo's `.git` directory /// (not inside a worktree, not a bare repo) must fail with the generic /// "no worktree found" error rather than the bare-repo-specific message.