Worktree creation writes an ABSOLUTE core.hooksPath into config.worktree, so worktrees run the MAIN checkout's hooks
Preflight Checklist
- [x] I have searched existing issues. This is not a duplicate of #27474 / #72714 / #85039 — see the first section: on 2.1.237 the shared config is left intact and the absolute path is written to
config.worktreeinstead. That variant is not described by any open issue. - [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.237)
Version: Claude Code 2.1.237 (macOS arm64)
Not a duplicate of the known shared-config bug — this is the half that is still wrong
#27474, #72714 and #85039 all report worktree creation clobbering the shared config
($GIT_COMMON_DIR/config / .git/config). On 2.1.237 that no longer happens here — the shared
config keeps its correct relative value:
$ git config --local --get core.hooksPath
.husky/_ # ← intact, not rewritten
What is written is a per-worktree override, in the worktree scope #27474 suggested moving to:
$ cat .git/worktrees/<name>/config.worktree
[core]
longpaths = true
hooksPath = /Users/…/<MAIN CHECKOUT>/.husky/_ # ← absolute, points at another checkout
So the destructive half looks fixed. But the path is still absolute, and #27474's author already
called that out: *"even git config --worktree is pointless since, if sharing hooks is the goal,
it's already achieved by git-config resolution."* That is exactly right, and the absolute form has
its own failure mode, which is the reason for this report.
Consequence
extensions.worktreeConfig = true makes the worktree scope outrank the shared one, and husky's shim
resolves the real script from its own location:
s=$(dirname "$(dirname "$0")")/$n # .husky/_/pre-push -> .husky/pre-push
With $0 absolute, $s is absolute too, so every hook fired from the worktree executes the MAIN
checkout's scripts — on whatever branch that checkout happens to be sitting on. Nothing errors; the
hook simply prints the steps that other branch has.
Two ways it bites, and the second is the dangerous one:
- A PR that ADDS a hook step gets no local exercise. The step never fires. This is how we found
it: a new pre-push step was present in the worktree's .husky/pre-push and simply absent from the
git push output.
- A PR that WEAKENS or REMOVES a hook step still runs the old, stronger hook — so the push goes
green on a change that disabled a gate. For any team whose hooks are the only gate, that is a
silently removed safety net.
A quieter third: whichever branch the main checkout is on defines the gate for every worktree.
Ours was on a feature branch, not main, for the whole period.
Attribution — by control, not by reading
| check | result |
|---|---|
| plain git worktree add (control) | writes no config.worktree; core.hooksPath resolves to .husky/_ from local scope |
| husky 9.1.7 source | writes ` ${dir}/_ — **relative** — and never mentions longpaths |git grep
| across the repo | nothing tracked writes either key |longpaths
| registered tool-made worktrees | **5 of 5** carry the same + absolute hooksPath pair |config.worktree` at all |
| the one hand-made worktree beside them | **no**
Repro
- In a repo using husky (so
.git/confighas the relativecore.hooksPath = .husky/_), create a
worktree via EnterWorktree or an Agent spawn with isolation: "worktree".
- From inside it:
git config --show-scope --get-all core.hooksPath
local .husky/_
worktree /abs/path/to/MAIN/.husky/_ # ← wins
- Add a distinctive
echoto that worktree's.husky/pre-push, commit, push. The line does not
appear — the main checkout's hook ran instead.
Expected
Write nothing. Git already resolves a relative core.hooksPath per worktree, which is what the
repo's shared config asks for. If a value must be written, write the relative one.
Workaround
Per worktree, worktree-scoped so it touches nothing shared:
git config --worktree core.hooksPath .husky/_
⚠️ Only safe when that worktree has its own .husky/_ shim. If it does not, switching to a relative
path aims git at a directory that does not exist and disables hooks entirely — worse than the
bug. We hit that case for real on one worktree.
Secondary observations
core.longpaths = trueis written unconditionally on macOS, where it is a no-op (it is a Windows
setting). Harmless, but it is the fingerprint that identified the writer.
- Possibly related: #69802 (ExitWorktree orphans the worktree). Of 13 registered worktrees here, 5
pointed at directories that no longer exist, and 4 directories existed with no registration at all.
5 Comments
The control table already isolates the writer: plain
git worktree addleaves noconfig.worktree, and 5/5 tool-made worktrees share thelongpaths+ absolutehooksPathpair.A useful acceptance fixture, using the two silent-failure modes already in the report:
EnterWorktree/isolation: "worktree",git config --show-scope --get-all core.hooksPathhas no worktree-scope override, or only a relative one that resolves inside this worktree..husky/_(or equivalent) shim, do not "fix" it by writing a relativehooksPath— that disables hooks entirely. Write nothing.The expected default is the control: write nothing. Git already resolves a relative shared
core.hooksPathper worktree.The absolute path and the shared-config clobbering are two different bugs with two different fixes, and this report is right that only one of them got fixed. Git already resolves core.hooksPath from the shared config for every worktree, so the worktree-scope override adds nothing, and once extensions.worktreeConfig is on, the absolute form guarantees the hooks that run belong to whatever branch the main checkout happens to be sitting on.
I work on NestMux, which creates a git worktree per pane. We run a plain
git worktree addand write no git config into the new worktree at all; anything project specific goes through setup commands the user declares, and those run inside the worktree. That was not foresight, we just never had a reason to write config there, but it does mean this failure never showed up for us.If the goal is sharing hooks across worktrees, git's own resolution already does it, so dropping the override looks more useful than making it relative.
This is independent production evidence for the write-nothing control already in the report: NestMux creates a worktree per pane with plain
git worktree addand writes no git config. Project-specific setup is user-declared commands that run inside the worktree.That matches the hand-made control (no
config.worktree) and is why dropping the override is better than rewriting it as relative. A relativehooksPathstill fails the missing-shim case: if this worktree has no.husky/_, writing.husky/_disables hooks entirely. Git's shared-config resolution already shares hooks when that is the goal; the worktree-scope write only exists to lose.Acceptance checks unchanged: after
EnterWorktree, no worktree-scopehooksPath(or only a relative path that resolves inside this worktree); a worktree-only hook addition must fire; a worktree-only gate removal must not run the main hook.(AI Written, human raised and validated)
Confirmed on Windows — with two escalations beyond the original report, plus the writer's actual
code from the desktop bundle.
Environment: Claude Code 2.1.246 (bundled with the Claude desktop app 1.37937, Windows 11),
git 2.54.0.windows.1. Repo uses vite-plus commit
hooks, so the shared
.git/configcarries the correct relative value:As in the OP, the shared config is left intact and the override lands in
.git/worktrees/<name>/config.worktree:1. On Windows this is a hard commit blocker, not a silently-wrong hook
The absolute path is written with backslashes (it is Node
path.resolveoutput — see thecode below). Git itself runs the hook fine, but husky-style dispatchers are
shscripts thatderive paths from
$0. With a backslashed$0, the dispatcher'sproduces a backslashed PATH entry; resolving the
.binshim through it loses the drive letterunder MSYS, and node resolves the module under the Git-for-Windows install root. Every commit in
the worktree then fails:
Isolated repro of the PATH mechanism — same directory, two spellings:
So on Windows the failure mode is not "the wrong checkout's hooks run silently" — it is "no
commit in any tool-made worktree succeeds until the override is removed". The same hooks work in
the main checkout and in a manually made worktree, so it reads like a Git Bash or tooling bug and
costs a debugging session before the config override is suspected.
2. The override is re-applied on every session bind, not just at creation
Removing the override does not stick. On this machine,
config.worktree's mtime matches thestart time of a new desktop session process to the second:
— in a worktree whose override had been explicitly unset hours earlier the same day. Seven
recurrences documented here since 2026-08-13, each correlated with a session (re)start in an
existing worktree, never with anything else (the repo's own hook tooling was ruled out by reading
its source: it only ever writes the relative value with no scope flag, and a scope-less
git configwrite from a linked worktree was probed in this repo and lands in the sharedconfig — it cannot produce the worktree-scope entry).
3. The writer
configureHooksPathin the desktop app's worktree manager (resources/app.asar; telemetry keysdesktop_ccd_worktree_*). Decompiled, lightly renamed:Worktree creation additionally runs:
— confirming the
longpathsfingerprint from the OP (and on Windows that key is load-bearing,not a no-op, so "write nothing" should not extend to it).
configureHooksPathis called from two sites:createWorktreeandrebindWorktree. Therebind call is what resurrects the override every time a session is bound to an existing
worktree, and why a user-applied
git config --worktree --unset core.hooksPathsilently reverts.Two aggravating details visible in the code:
worktree. Here the hook scripts are tracked files, so every worktree has them and the shared
relative value works everywhere with no override at all.
worktree.
Expected
Same as the OP: write nothing — git already resolves a relative
core.hooksPathagainst eachworktree, which is exactly what repos pin the relative form for. Failing that, in order of
preference:
(the actual gap the feature seems to target: gitignored dispatcher dirs à la
.husky/_).path.resolveoutputbreaks any
sh-based dispatcher, which is most of the hook ecosystem (husky and derivatives).an already-provisioned worktree as user intent.
Workaround
Per worktree, after every session start (the rebind re-adds it):
Only safe when the worktree has its own hooks directory; respelling the absolute path with
forward slashes also unblocks commits but keeps hooks pointed at the main checkout's tree, with
the branch-skew hazards described in the OP.
The write-nothing matrix still holds. This report names two remaining holes that matrix did not.
configureHooksPathis also called fromrebindWorktree. A usergit config --worktree --unset core.hooksPaththat reappears on the next session bind is not a write-nothing receipt. Unset must survive rebind, or write-nothing is a session-start ritual.On Windows,
path.resolvebackslashes are not the silent wrong-hook failure. They break husky-style$0PATH dispatch, so no commit in a tool-made worktree succeeds. If an override is written at all, a backslashed absolute path is a commit blocker.The relative shared value already resolved inside this worktree (tracked hook scripts). The unconditional write is the instruction to lose. I did not run Windows or decompile the asar.