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