Context: #2597
Problem
worktree_root is a single machine-wide value. On a machine running multiple Git identities — a work account for organization repositories and a personal account for a separate tree, each with its own configuration — one root cannot express where each identity's worktrees belong. Personal worktrees must not land in a work-managed tree, or the reverse.
There is also no per-repository exception mechanism: a repository that legitimately wants its own root (a dotfiles checkout, for instance) has nowhere to say so.
Proposed change
Resolve the root through Git's own conditional-configuration mechanism, most specific first:
- per-repository override — a named exception
- per-identity override — keyed on where the repository lives
- machine-global default
This composes with the portable-convention proposal: if the root lives in a git config key, includeIf supplies per-identity resolution with no new machinery, and a single git -C <repo> config --get returns the right answer per repository with no flag and no prompt.
[user]
name = <name>
useConfigOnly = true # NO user.email here — see below
[melodic]
worktreeroot = <default> # plain default FIRST — below an includeIf it would win
[includeIf "gitdir/i:<work-tree-root>/"]
path = ~/.config/git/identity-work.inc
[includeIf "gitdir/i:<personal-tree-root>/"]
path = ~/.config/git/identity-personal.inc
[includeIf "gitdir/i:**/dotfiles/.git"] # per-repo exception, survives re-clone
path = ~/.config/git/repo-dotfiles.inc
Evidence — verified in a hermetic lab, git 2.55.0.windows.3
Environment isolated with GIT_CONFIG_NOSYSTEM=1 and GIT_CONFIG_GLOBAL/HOME redirected to throwaway files; lab deleted afterward.
The mechanism works for a custom key. includeIf splices an entire file at the directive's position and is not key-aware, so melodic.worktreeroot resolves exactly as user.email does. And git -C chdirs before repository discovery, so the condition evaluates against the target repository's gitdir, never the cwd — confirmed by reading each of two repositories from inside the other. A fleet tool iterating repositories from elsewhere gets correct answers.
Linked worktrees do not invert the model. Tested both directions — a work-path repository with a worktree at a personal path, and the reverse:
| Location |
--git-dir |
resolved root |
work/repoA (main) |
work/repoA/.git |
work |
personal/wt-of-A (linked) |
work/repoA/.git/worktrees/wt-of-A |
work |
personal/repoB (main) |
personal/repoB/.git |
personal |
work/wt-of-B (linked) |
personal/repoB/.git/worktrees/wt-of-B |
personal |
A linked worktree's $GIT_DIR is always under its main repository, so a tree-anchored gitdir: classifies it with its repository. Every worktree of a repository agrees on where that repository's worktrees belong — which is what root placement wants.
Corollary: a pattern anchored at a worktree's own tree path matches nothing, ever. That presents as "includeIf is broken" rather than "wrong target", and is the likely first misdiagnosis.
Constraints that must be documented alongside any recommendation:
- Use
gitdir/i: for the identity layer, not hasconfig:. libgit2 (gitui, TortoiseGit, git2, nodegit, pygit2) implements gitdir:, gitdir/i:, and onbranch: but not hasconfig: — and unrecognized conditions fail silently (its own test asserts this; libgit2#6641 is open). JGit and go-git resolve no includeIf at all. A hasconfig:-based identity split means those clients commit with the wrong address, silently. hasconfig: is fine for the melodic.* key, which only a CLI-shelling tool reads.
gitdir: is case-sensitive even on case-insensitive NTFS. A pattern with a lowercase drive letter, an 8.3 short name, or different casing than the canonical gitdir matches nothing. Only the pattern author's spelling matters — caller-side spelling is normalized. Always use gitdir/i: on Windows.
- Precedence is parse order, not specificity. A plain
[melodic] worktreeroot placed below the includeIf block silently overrides every identity include. Two matching includeIf sections: last-parsed wins, both retained under --get-all, no warning.
- Attribution requires
--show-origin. --show-scope collapses a conditionally-included file to global; only --show-origin names the actual file. It mixes relative paths for local/worktree scope with absolute for global, so resolve before displaying.
- Junction-anchored patterns match nothing in either direction, contradicting the manpage's symlink/realpath claim. Anchor at canonical target paths.
- Bare repositories have no
/.git suffix, so any pattern written **/<name>/.git silently misses them.
- The per-repository exception must be a name-keyed global include, not
.git/config. .git/config is not cloned, so a repo-local exception vanishes on re-clone — and the motivating exception here, a dotfiles repository, commonly has two peer clones (a ghq checkout and a chezmoi source outside ghq root). A local exception must be set twice and is lost twice.
- Version floors:
gitdir:/gitdir/i: 2.13, onbranch: 2.23, hasconfig:remote.*.url: 2.36, extensions.worktreeConfig 2.20, and worktree:/worktree/i: 2.56. A config authored for 2.56 degrades silently on 2.55.
- Per-worktree overrides are possible only via
config.worktree. Every worktree of a repository shares a gitdir prefix, so no gitdir: pattern can distinguish two worktrees of one repository. Behind extensions.worktreeConfig, config.worktree beats repo-local for that worktree and reports a distinct worktree scope.
- Identity includes must set more than
user.email. user.signingkey, gpg.ssh.allowedSignersFile, core.sshCommand, and any url.*.insteadOf all leak from global otherwise. url.<base>.insteadOf is identity-blind (longest match wins) and must live inside each identity file.
user.useConfigOnly is inert if a global user.email exists — verified three ways. It only refuses when email is absent from global entirely, so a "sensible work default" defeats it silently.
Failure modes are uniformly quiet. Unknown keyword, hasconfig/i: (not a supported form), missing colon, a path pointing at a nonexistent file, and any 2.56-only condition on 2.55 all produce rc=0 and zero bytes of stderr. A typo'd condition is indistinguishable from an unset key. Two facts make that worse rather than merely inconvenient: on Windows the auto-guessed fallback email is GetUserNameExW(NameUserPrincipal) — the Active Directory UPN — so a missed condition on a personal repository produces a plausible corporate address rather than an obvious error; and a wrong SSH signing key verifies Good locally, because Git derives the principal from the signature and never compares signer to author (git verify-commit passes, %G? returns G, only the forge shows Unverified).
Two useful primitives fall out: git config --list emits includeif.<condition>.path for every declared condition, matched or not, so declared conditions can be diffed against what actually fired; and git log -1 --format='%G? | signer=%GS | author=%ae' detects the signing mismatch.
Acceptance criteria
- Two repositories under different identity trees resolve to different worktree roots with no operator input.
- A named repository exception resolves to its own root and survives re-cloning.
- A linked worktree resolves to its repository's root regardless of where the worktree sits.
- The report can name which rule supplied a repository's root.
- A doctor/conformance mode detects the silent failure classes listed above — at minimum: dubious-ownership fallback, unmatched-but-declared conditions, scoped reads skipping includes, a plain value below an
includeIf, gitdir: without /i on Windows, junction-anchored patterns, and identity partials where user.email is set without the signing keys.
Context: #2597
Problem
worktree_rootis a single machine-wide value. On a machine running multiple Git identities — a work account for organization repositories and a personal account for a separate tree, each with its own configuration — one root cannot express where each identity's worktrees belong. Personal worktrees must not land in a work-managed tree, or the reverse.There is also no per-repository exception mechanism: a repository that legitimately wants its own root (a dotfiles checkout, for instance) has nowhere to say so.
Proposed change
Resolve the root through Git's own conditional-configuration mechanism, most specific first:
This composes with the portable-convention proposal: if the root lives in a git config key,
includeIfsupplies per-identity resolution with no new machinery, and a singlegit -C <repo> config --getreturns the right answer per repository with no flag and no prompt.Evidence — verified in a hermetic lab, git 2.55.0.windows.3
Environment isolated with
GIT_CONFIG_NOSYSTEM=1andGIT_CONFIG_GLOBAL/HOMEredirected to throwaway files; lab deleted afterward.The mechanism works for a custom key.
includeIfsplices an entire file at the directive's position and is not key-aware, somelodic.worktreerootresolves exactly asuser.emaildoes. Andgit -Cchdirs before repository discovery, so the condition evaluates against the target repository's gitdir, never the cwd — confirmed by reading each of two repositories from inside the other. A fleet tool iterating repositories from elsewhere gets correct answers.Linked worktrees do not invert the model. Tested both directions — a work-path repository with a worktree at a personal path, and the reverse:
--git-dirwork/repoA(main)work/repoA/.gitpersonal/wt-of-A(linked)work/repoA/.git/worktrees/wt-of-Apersonal/repoB(main)personal/repoB/.gitwork/wt-of-B(linked)personal/repoB/.git/worktrees/wt-of-BA linked worktree's
$GIT_DIRis always under its main repository, so a tree-anchoredgitdir:classifies it with its repository. Every worktree of a repository agrees on where that repository's worktrees belong — which is what root placement wants.Corollary: a pattern anchored at a worktree's own tree path matches nothing, ever. That presents as "includeIf is broken" rather than "wrong target", and is the likely first misdiagnosis.
Constraints that must be documented alongside any recommendation:
gitdir/i:for the identity layer, nothasconfig:. libgit2 (gitui, TortoiseGit,git2, nodegit, pygit2) implementsgitdir:,gitdir/i:, andonbranch:but nothasconfig:— and unrecognized conditions fail silently (its own test asserts this; libgit2#6641 is open). JGit and go-git resolve noincludeIfat all. Ahasconfig:-based identity split means those clients commit with the wrong address, silently.hasconfig:is fine for themelodic.*key, which only a CLI-shelling tool reads.gitdir:is case-sensitive even on case-insensitive NTFS. A pattern with a lowercase drive letter, an 8.3 short name, or different casing than the canonical gitdir matches nothing. Only the pattern author's spelling matters — caller-side spelling is normalized. Always usegitdir/i:on Windows.[melodic] worktreerootplaced below theincludeIfblock silently overrides every identity include. Two matchingincludeIfsections: last-parsed wins, both retained under--get-all, no warning.--show-origin.--show-scopecollapses a conditionally-included file toglobal; only--show-originnames the actual file. It mixes relative paths for local/worktree scope with absolute for global, so resolve before displaying./.gitsuffix, so any pattern written**/<name>/.gitsilently misses them..git/config..git/configis not cloned, so a repo-local exception vanishes on re-clone — and the motivating exception here, a dotfiles repository, commonly has two peer clones (a ghq checkout and a chezmoi source outsideghq root). A local exception must be set twice and is lost twice.gitdir:/gitdir/i:2.13,onbranch:2.23,hasconfig:remote.*.url:2.36,extensions.worktreeConfig2.20, andworktree:/worktree/i:2.56. A config authored for 2.56 degrades silently on 2.55.config.worktree. Every worktree of a repository shares a gitdir prefix, so nogitdir:pattern can distinguish two worktrees of one repository. Behindextensions.worktreeConfig,config.worktreebeats repo-local for that worktree and reports a distinctworktreescope.user.email.user.signingkey,gpg.ssh.allowedSignersFile,core.sshCommand, and anyurl.*.insteadOfall leak from global otherwise.url.<base>.insteadOfis identity-blind (longest match wins) and must live inside each identity file.user.useConfigOnlyis inert if a globaluser.emailexists — verified three ways. It only refuses when email is absent from global entirely, so a "sensible work default" defeats it silently.Failure modes are uniformly quiet. Unknown keyword,
hasconfig/i:(not a supported form), missing colon, apathpointing at a nonexistent file, and any 2.56-only condition on 2.55 all produce rc=0 and zero bytes of stderr. A typo'd condition is indistinguishable from an unset key. Two facts make that worse rather than merely inconvenient: on Windows the auto-guessed fallback email isGetUserNameExW(NameUserPrincipal)— the Active Directory UPN — so a missed condition on a personal repository produces a plausible corporate address rather than an obvious error; and a wrong SSH signing key verifies Good locally, because Git derives the principal from the signature and never compares signer to author (git verify-commitpasses,%G?returnsG, only the forge shows Unverified).Two useful primitives fall out:
git config --listemitsincludeif.<condition>.pathfor every declared condition, matched or not, so declared conditions can be diffed against what actually fired; andgit log -1 --format='%G? | signer=%GS | author=%ae'detects the signing mismatch.Acceptance criteria
includeIf,gitdir:without/ion Windows, junction-anchored patterns, and identity partials whereuser.emailis set without the signing keys.