Skip to content
28 changes: 28 additions & 0 deletions docs/content/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- PROJECT_CONFIG_END -->

## Private project config in git config

<span class="badge-experimental"></span>

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:
Expand Down
31 changes: 31 additions & 0 deletions plugins/worktrunk/skills/worktrunk/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- PROJECT_CONFIG_END -->

## 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:
Expand Down
31 changes: 31 additions & 0 deletions skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- PROJECT_CONFIG_END -->

## 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.
Comment thread
worktrunk-bot marked this conversation as resolved.

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:
Expand Down
24 changes: 11 additions & 13 deletions src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<BTreeMap<String, CommandConfig>> {
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)]
Expand Down
19 changes: 13 additions & 6 deletions src/commands/config/approvals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,19 @@ fn collect_approvable_commands(project_config: &ProjectConfig) -> Vec<Approvable
/// semantics need the config as their frame of reference (`add`, `clear
/// --stale`); the read-only `list` instead treats absence as zero commands.
fn require_project_config(repo: &Repository) -> anyhow::Result<ProjectConfig> {
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.
Expand Down
23 changes: 21 additions & 2 deletions src/commands/config/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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!(
"<bold>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 <underline>{}</>",
worktrunk::config::GIT_CONFIG_LIST_COMMAND
))
);
}
Ok(())
} else {
let project_config_exists = Repository::current()
.and_then(|repo| repo.project_config_path())
Expand Down
Loading
Loading