diff --git a/docs/content/config.md b/docs/content/config.md index 0e554bcee..a81779e27 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -616,6 +616,34 @@ 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. + +That rule combines with `--global` in a way worth stating outright: a single global key supersedes the committed project config — and every project hook — of every repository on the machine. The supersession warning still fires in each one, but only once the key is already in effect. Keep keys repository-local, or scope them with `includeIf`, unless disabling every repository's project config is the intent. + +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. + +Use the canonical key spellings from the sections above. Git lowercases the final component of a key, so a name chosen there is lowercased with it — an alias set as `worktrunk.config.aliases.Deploy` runs as `wt deploy`. 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 only partly reachable. Keys are read from the shared git dir, so a linked worktree's `config.worktree` is never consumed. The main worktree's `config.worktree` lives in that shared dir, though, so a key placed there is read — and supplies project config for the whole repository, not only the main worktree. + +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\.'") }} + # 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 2f91dd0d9..7c9c9273c 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/config.md +++ b/plugins/worktrunk/skills/worktrunk/reference/config.md @@ -609,6 +609,37 @@ 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. + +That rule combines with `--global` in a way worth stating outright: a single global key supersedes the committed project config — and every project hook — of every repository on the machine. The supersession warning still fires in each one, but only once the key is already in effect. Keep keys repository-local, or scope them with `includeIf`, unless disabling every repository's project config is the intent. + +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. + +Use the canonical key spellings from the sections above. Git lowercases the final component of a key, so a name chosen there is lowercased with it — an alias set as `worktrunk.config.aliases.Deploy` runs as `wt deploy`. 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 only partly reachable. Keys are read from the shared git dir, so a linked worktree's `config.worktree` is never consumed. The main worktree's `config.worktree` lives in that shared dir, though, so a key placed there is read — and supplies project config for the whole repository, not only the main worktree. + +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\.' +``` + # 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 2f91dd0d9..7c9c9273c 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -609,6 +609,37 @@ 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. + +That rule combines with `--global` in a way worth stating outright: a single global key supersedes the committed project config — and every project hook — of every repository on the machine. The supersession warning still fires in each one, but only once the key is already in effect. Keep keys repository-local, or scope them with `includeIf`, unless disabling every repository's project config is the intent. + +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. + +Use the canonical key spellings from the sections above. Git lowercases the final component of a key, so a name chosen there is lowercased with it — an alias set as `worktrunk.config.aliases.Deploy` runs as `wt deploy`. 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 only partly reachable. Keys are read from the shared git dir, so a linked worktree's `config.worktree` is never consumed. The main worktree's `config.worktree` lives in that shared dir, though, so a key placed there is read — and supplies project config for the whole repository, not only the main worktree. + +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\.' +``` + # 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 3412f03c3..4b45c98ec 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2500,6 +2500,37 @@ 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. + +That rule combines with `--global` in a way worth stating outright: a single global key supersedes the committed project config — and every project hook — of every repository on the machine. The supersession warning still fires in each one, but only once the key is already in effect. Keep keys repository-local, or scope them with `includeIf`, unless disabling every repository's project config is the intent. + +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. + +Use the canonical key spellings from the sections above. Git lowercases the final component of a key, so a name chosen there is lowercased with it — an alias set as `worktrunk.config.aliases.Deploy` runs as `wt deploy`. 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 only partly reachable. Keys are read from the shared git dir, so a linked worktree's `config.worktree` is never consumed. The main worktree's `config.worktree` lives in that shared dir, though, so a key placed there is read — and supplies project config for the whole repository, not only the main worktree. + +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\.' +``` + # Shell Integration Worktrunk needs shell integration to change directories when switching worktrees. Install with: diff --git a/src/commands/alias.rs b/src/commands/alias.rs index cbd374f02..708830783 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 9ffcdc78c..4ccd5b05e 100644 --- a/src/commands/config/approvals.rs +++ b/src/commands/config/approvals.rs @@ -44,12 +44,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 436fe0ba9..54ad81b29 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 558921074..ee1296f70 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -107,31 +107,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()); @@ -144,13 +152,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": { @@ -786,6 +797,54 @@ 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. 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)?; + if let Some(superseded) = worktrunk::config::superseded_project_file_label(&repo) { + // 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'); + } + 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. + 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 593b7a10a..c0e8b204c 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -534,17 +534,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/commands/step/prune.rs b/src/commands/step/prune.rs index 270e3c440..b467ebaf6 100644 --- a/src/commands/step/prune.rs +++ b/src/commands/step/prune.rs @@ -1127,12 +1127,24 @@ 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 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() .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(()); @@ -1290,11 +1302,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/config/git_source.rs b/src/config/git_source.rs new file mode 100644 index 000000000..32417b263 --- /dev/null +++ b/src/config/git_source.rs @@ -0,0 +1,400 @@ +//! 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 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 +//! +//! - **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. +//! - **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. +//! - **Only partial worktree scope.** The bulk config read runs from the +//! common git dir, so a *linked* worktree's `config.worktree` +//! (`extensions.worktreeConfig`) is never consumed. The *main* worktree's +//! `config.worktree` lives in the common dir, though, so a key there is +//! read — and supplies project config repo-wide, not just in the main +//! worktree. The diagnostic command, run inside a linked worktree, can +//! list matching keys this source ignores. +//! +//! # 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. +//! - 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; + +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\.'"; + +/// Redact `worktrunk.config.*` values from raw `git config --list -z` output, +/// leaving every other key's value intact. +/// +/// The feature holds private, machine-specific configuration, so its values +/// must not reach the `-vv` diagnostic bundle even though the bulk config read +/// dumps the whole merged config there. This runs only on the *logged* copy of +/// the command's stdout (see `Cmd::redact_logged_stdout`), never on the bytes +/// worktrunk parses. Values of other keys are left as-is — the redaction is +/// scoped to this namespace, not a blanket config scrub. +/// +/// The `-z` stream is NUL-separated entries, each `key\nvalue` (the value may +/// itself contain newlines). An entry whose key is under +/// [`GIT_CONFIG_PREFIX`] keeps its key and gets `[REDACTED]` for its value. +pub fn redact_worktrunk_config_z(raw: &[u8]) -> Vec { + let mut out = Vec::with_capacity(raw.len()); + for (i, entry) in raw.split(|&b| b == 0).enumerate() { + if i > 0 { + out.push(0); + } + match entry.iter().position(|&b| b == b'\n') { + Some(nl) if entry[..nl].starts_with(GIT_CONFIG_PREFIX.as_bytes()) => { + out.extend_from_slice(&entry[..nl]); + out.push(b'\n'); + out.extend_from_slice(b"[REDACTED]"); + } + // A non-matching key, or an entry with no value separator: verbatim. + _ => out.extend_from_slice(entry), + } + } + out +} + +/// 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). 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!("{GIT_CONFIG_SOURCE_LABEL}: {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; + } + + // 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, leaving a later call in the same + // invocation — one that does have a superseded file to report — silently + // latched out of the only warning that names it. + static WARNED: OnceLock<()> = OnceLock::new(); + if WARNED.get().is_some() { + return; + } + + let Some(superseded) = superseded_project_file_label(repo) else { + 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!( + "{}", + 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); + } + + #[test] + fn test_redact_worktrunk_config_z_scrubs_only_this_namespace() { + // `git config --list -z` shape: `key\nvalue\0` per entry. A + // worktrunk.config.* value is replaced; every other key is untouched, + // including a multi-line value and a valueless final chunk. + let raw = b"user.email\nme@example.com\0worktrunk.config.post-start\necho SECRET\0worktrunk.config.list.url\nhttp://a\nb\0core.bare\nfalse\0"; + let out = redact_worktrunk_config_z(raw); + let s = String::from_utf8(out).unwrap(); + assert_eq!( + s, + "user.email\nme@example.com\0worktrunk.config.post-start\n[REDACTED]\0worktrunk.config.list.url\n[REDACTED]\0core.bare\nfalse\0" + ); + assert!(!s.contains("SECRET")); + assert!(!s.contains("http://a")); + } + + #[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/config/mod.rs b/src/config/mod.rs index 1c8726354..4172d833d 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, redact_worktrunk_config_z, + 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 4c4f9209a..2dddafe1f 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/diagnostic.rs b/src/diagnostic.rs index 037fe6dc5..306cd3282 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -496,12 +496,51 @@ 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. The + // bulk `git config --list -z` read redacts those same values in its logged + // output (`redact_worktrunk_config_z`), so the trace/subprocess sinks the + // report also bundles don't reintroduce them. + 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)); + } + 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 + )); + } + // 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{}", + format_config_section(&project_config_path, ConfigFileKind::Project) + )); + } + } } if output.is_empty() { @@ -557,6 +596,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 65973ba9a..755306d09 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -131,6 +131,42 @@ 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. + /// + /// 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() + .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 @@ -1067,6 +1103,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/src/git/repository/mod.rs b/src/git/repository/mod.rs index 551b4e821..7a236312c 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -1097,6 +1097,7 @@ impl Repository { fn prewarm_git_config(discovery_path: &Path) { let Ok(output) = Cmd::new("git") .args(["config", "--list", "-z"]) + .redact_logged_stdout(crate::config::redact_worktrunk_config_z) .current_dir(discovery_path) .context(path_to_logging_context(discovery_path)) .run() @@ -1128,6 +1129,7 @@ impl Repository { fn prewarm_git_config_from_common_dir(discovery_path: &Path, common_dir: &Path) { let Ok(output) = Cmd::new("git") .args(["config", "--list", "-z"]) + .redact_logged_stdout(crate::config::redact_worktrunk_config_z) .current_dir(common_dir) .context(path_to_logging_context(common_dir)) .run() @@ -1534,6 +1536,7 @@ impl Repository { let args = ["config", "--list", "-z"]; let output = Cmd::new("git") .args(args) + .redact_logged_stdout(crate::config::redact_worktrunk_config_z) .current_dir(&self.git_common_dir) .context(path_to_logging_context(&self.git_common_dir)) .run() diff --git a/src/shell_exec.rs b/src/shell_exec.rs index bd29e4110..6da3e7011 100644 --- a/src/shell_exec.rs +++ b/src/shell_exec.rs @@ -836,7 +836,12 @@ fn command_header(cmd: &str, context: Option<&str>) -> String { /// log even when nothing ran. /// /// Below Debug both targets are disabled and this is a no-op. -fn log_output(trace: &CommandTrace, stdin: Option<&[u8]>, output: Option<&std::process::Output>) { +fn log_output( + trace: &CommandTrace, + stdin: Option<&[u8]>, + output: Option<&std::process::Output>, + redact_stdout: Option, +) { // `log::max_level` (held at the verbosity/`RUST_LOG` ceiling by the // `LogTracer` cap in `logging::init`) is the coarse "deep logging on at // all?" gate that skips building the full *and* bounded output strings @@ -855,6 +860,12 @@ fn log_output(trace: &CommandTrace, stdin: Option<&[u8]>, output: Option<&std::p let (stdout, stderr) = output .map(|o| (o.stdout.as_slice(), o.stderr.as_slice())) .unwrap_or_default(); + // Redact the *logged* stdout only. The caller already holds the real + // output; this copy is what reaches `trace.log` / `subprocess.log`, so a + // command carrying private data (the `worktrunk.config.*` git-config read) + // keeps it out of the `-vv` bundle without losing the rest of its output. + let redacted = redact_stdout.map(|f| f(stdout)); + let stdout = redacted.as_deref().unwrap_or(stdout); if !stdin.is_empty() || !stdout.is_empty() || !stderr.is_empty() { tracing::debug!( target: SUBPROCESS_FULL_TARGET, @@ -1046,6 +1057,11 @@ fn kill_timed_out_tree(pid: u32) { // Builder-style command execution // ============================================================================ +/// Transform applied to a command's captured stdout before it is written to +/// the debug/subprocess logs — never to the bytes returned to the caller. See +/// [`Cmd::redact_logged_stdout`]. +type StdoutRedactor = fn(&[u8]) -> Vec; + /// Builder for executing commands with two modes of operation. /// /// - `.run()` — captures output, provides logging/semaphore/tracing @@ -1118,6 +1134,15 @@ pub struct Cmd { /// user typing it at the top level. Project aliases and hooks must NEVER /// set this — they could inject arbitrary shell into the parent session. directive_exec_file: Option, + /// When set, this transform is applied to captured stdout **before it is + /// written to the debug/subprocess logs** — never to the bytes returned to + /// the caller. It lets a command whose output carries private data keep + /// that data out of the `-vv` diagnostic bundle while still logging the + /// rest. The one user is the bulk `git config --list -z` read, which scrubs + /// `worktrunk.config.*` values (the experimental private project-config + /// source, #3454). Applied only when deep logging is on, so it costs + /// nothing on a normal run. + redact_logged_stdout: Option, } struct ExternalCommandLog { @@ -1156,6 +1181,7 @@ fn record_captured( trace: &mut CommandTrace, stdin: Option<&[u8]>, result: &std::io::Result, + redact_stdout: Option, ) { match result { Ok(output) => trace.complete(output.status.success()), @@ -1163,7 +1189,7 @@ fn record_captured( } // stdin is logged either way; stdout/stderr only when the command produced // output — a command that failed to spawn still leaves its input behind. - log_output(trace, stdin, result.as_ref().ok()); + log_output(trace, stdin, result.as_ref().ok(), redact_stdout); } /// Structured error from [`Cmd::delayed_stream`]. @@ -1259,9 +1285,22 @@ impl Cmd { external_label: None, directive_cd_file: None, directive_exec_file: None, + redact_logged_stdout: None, } } + /// Redact captured stdout before it reaches the debug/subprocess logs. + /// + /// The transform never touches the output returned from `run()`; it applies + /// only to the copy `log_output` writes when deep logging is on. Use it for + /// a command whose stdout carries data that must not land in the `-vv` + /// diagnostic bundle — currently the bulk `git config --list -z` read, + /// which passes [`crate::config::redact_worktrunk_config_z`]. + pub fn redact_logged_stdout(mut self, redactor: StdoutRedactor) -> Self { + self.redact_logged_stdout = Some(redactor); + self + } + /// Create a new command builder for the given program. /// /// The program is executed directly without shell interpretation. @@ -1648,7 +1687,12 @@ impl Cmd { } }; - record_captured(&mut trace, self.stdin_data.as_deref(), &result); + record_captured( + &mut trace, + self.stdin_data.as_deref(), + &result, + self.redact_logged_stdout, + ); let exit_code = result.as_ref().ok().and_then(|output| output.status.code()); external_log.record(exit_code); @@ -1697,6 +1741,11 @@ impl Cmd { self.timeout.is_none() && next.timeout.is_none(), "pipe_into does not support timeouts" ); + // The source's own stdout is routed to the sink (empty capture), so its + // redactor only ever matters for the source-stage log below; the sink + // is a different program and logs unredacted. No config read uses + // pipe_into today — this keeps the field honored if one ever does. + let source_redact_stdout = self.redact_logged_stdout; assert!( self.external_label.is_none() && next.external_label.is_none(), "pipe_into does not support external() logging" @@ -1851,7 +1900,7 @@ impl Cmd { // source's stdout (the OS pipe), not its own input, so it is logged // with `None` — the intermediate stream stays out of the deep log. let second_result = second_child.wait_with_output(); - record_captured(&mut second_trace, None, &second_result); + record_captured(&mut second_trace, None, &second_result, None); // Reap `first`. Its stderr is already being drained; combine // the captured stderr with the exit status into an Output. @@ -1868,7 +1917,12 @@ impl Cmd { // The source's own stdin (the commit list) is logged under ` < `, // symmetric with `run`. Only the intermediate diff stream — the // source's stdout, routed to the sink via OS pipe — stays out. - record_captured(&mut first_trace, source_stdin.as_deref(), &first_result); + record_captured( + &mut first_trace, + source_stdin.as_deref(), + &first_result, + source_redact_stdout, + ); (first_result, second_result) }); diff --git a/tests/integration_tests/config_init.rs b/tests/integration_tests/config_init.rs index 64b48d62d..c93ff66e8 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. diff --git a/tests/integration_tests/git_config_source.rs b/tests/integration_tests/git_config_source.rs new file mode 100644 index 000000000..67d13d912 --- /dev/null +++ b/tests/integration_tests/git_config_source.rs @@ -0,0 +1,573 @@ +//! 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); + }); +} + +/// `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] +fn test_git_config_scope_precedence_local_over_global(repo: TestRepo, temp_home: TempDir) { + write_user_config(&temp_home); + // 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.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()); + + 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" + ); +} + +/// 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 logs_dir = repo.root_path().join(".git").join("wt/logs"); + let report = fs::read_to_string(logs_dir.join("diagnostic.md")) + .expect("-vv run writes a diagnostic report"); + + // The config section names the active keys and omits their 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}" + ); + + // The value must not appear anywhere in the bundle — the bulk + // `git config --list -z` read redacts `worktrunk.config.*` values in its + // logged output, so the trace/subprocess sinks that would otherwise carry + // it are scrubbed too. The key name survives, and other config is intact. + for (label, path) in [ + ("diagnostic.md", logs_dir.join("diagnostic.md")), + ("trace.log", logs_dir.join("trace.log")), + ("subprocess.log", logs_dir.join("subprocess.log")), + ] { + let Ok(contents) = fs::read_to_string(&path) else { + continue; // subprocess.log only exists at -vv; skip if absent + }; + assert!( + !contents.contains("diag-private-value"), + "private value leaked into {label}:\n{contents}" + ); + } +} + +/// 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 af43060cc..10160bcc1 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/integration_tests/step_prune.rs b/tests/integration_tests/step_prune.rs index ae4ef73a7..08123eb1c 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] 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 000000000..1a80c23e4 --- /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_git_config_source.snap b/tests/snapshots/integration__integration_tests__git_config_source__config_show_git_config_source.snap new file mode 100644 index 000000000..a50c5dc5d --- /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__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 000000000..4bfc4dcbf --- /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 ----- 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 000000000..59fbdd91a --- /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 000000000..c3b167d30 --- /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 000000000..eab577f7a --- /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 c0fe08fef..53c12dfae 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -35,6 +35,7 @@ info: WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" WORKTRUNK_TEST_ZSH_INSTALLED: "0" --- @@ -597,6 +598,33 @@ 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. + +That rule combines with --global in a way worth stating outright: a single global key supersedes the committed project config — and every project hook — of every repository on the machine. The supersession warning still fires in each one, but only once the key is already in effect. Keep keys repository-local, or scope them with includeIf, unless disabling every repository's project config is the intent. + +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. + +Use the canonical key spellings from the sections above. Git lowercases the final component of a key, so a name chosen there is lowercased with it — an alias set as worktrunk.config.aliases.Deploy runs as wt deploy. 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 only partly reachable. Keys are read from the shared git dir, so a linked worktree's config.worktree is never consumed. The main worktree's config.worktree lives in that shared dir, though, so a key placed there is read — and supplies project config for the whole repository, not only the main worktree. + +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\.' + SHELL INTEGRATION Worktrunk needs shell integration to change directories when switching worktrees. Install with: