Skip to content

source-control: worktree_root cannot vary per Git identity, so a multi-account machine has one root for two identities #2612

Description

@kyle-sexton

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:

  1. per-repository override — a named exception
  2. per-identity override — keyed on where the repository lives
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions