Worktree creation writes an ABSOLUTE core.hooksPath into config.worktree, so worktrees run the MAIN checkout's hooks

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 5 comments · opened Aug 22, 2026

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.worktree instead. 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:

  1. 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.

  1. 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 |
| registered tool-made worktrees | **5 of 5** carry the same
longpaths + absolute hooksPath pair |
| the one hand-made worktree beside them | **no**
config.worktree` at all |

Repro

  1. In a repo using husky (so .git/config has the relative core.hooksPath = .husky/_), create a

worktree via EnterWorktree or an Agent spawn with isolation: "worktree".

  1. From inside it: git config --show-scope --get-all core.hooksPath
local     .husky/_
worktree  /abs/path/to/MAIN/.husky/_        # ← wins
  1. Add a distinctive echo to 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 = true is 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.

View original on GitHub ↗

5 Comments

foma-agent · 9 days ago

The control table already isolates the writer: plain git worktree add leaves no config.worktree, and 5/5 tool-made worktrees share the longpaths + absolute hooksPath pair.

A useful acceptance fixture, using the two silent-failure modes already in the report:

  1. After EnterWorktree / isolation: "worktree", git config --show-scope --get-all core.hooksPath has no worktree-scope override, or only a relative one that resolves inside this worktree.
  2. A distinctive line added only to the worktree's hook fires on a push from that worktree.
  3. A gate removed only in the worktree does not still run from the main checkout.
  4. If this worktree has no .husky/_ (or equivalent) shim, do not "fix" it by writing a relative hooksPath — that disables hooks entirely. Write nothing.

The expected default is the control: write nothing. Git already resolves a relative shared core.hooksPath per worktree.

eliseomdq · 7 days ago

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 add and 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.

foma-agent · 7 days ago

This is independent production evidence for the write-nothing control already in the report: NestMux creates a worktree per pane with plain git worktree add and 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 relative hooksPath still 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-scope hooksPath (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.

praetoros · 20 hours ago

(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/config carries the correct relative value:

$ git config --local --get core.hooksPath
.vite-hooks/_

As in the OP, the shared config is left intact and the override lands in
.git/worktrees/<name>/config.worktree:

[core]
	longpaths = true
	hooksPath = F:\\work\\repo\\.vite-hooks\\_

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.resolve output — see the
code below). Git itself runs the hook fine, but husky-style dispatchers are sh scripts that
derive paths from $0. With a backslashed $0, the dispatcher's

export PATH="$d/node_modules/.bin:$PATH"

produces a backslashed PATH entry; resolving the .bin shim through it loses the drive letter
under MSYS, and node resolves the module under the Git-for-Windows install root. Every commit in
the worktree then fails:

Error: Cannot find module 'C:\Program Files\Git\work\repo\node_modules\vite-plus\bin\vp'
VITE+ - pre-commit script failed (code 1)

Isolated repro of the PATH mechanism — same directory, two spellings:

PATH='F:\work\repo/node_modules/.bin:/usr/bin' sh -c 'vp --version'
#   Cannot find module 'C:\Program Files\Git\work\repo\node_modules\vite-plus\bin\vp'
PATH='/f/work/repo/node_modules/.bin:/usr/bin' sh -c 'vp --version'
#   vp v0.2.5

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 the
start time of a new desktop session process to the second:

$ Get-CimInstance Win32_Process | ? Name -eq claude.exe | select CreationDate
30/08/2026 7:39:22 PM        # newest session process

$ stat -c '%y' .git/worktrees/<name>/config.worktree
2026-08-30 19:39:22.310      # rewritten the same 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 config write from a linked worktree was probed in this repo and lands in the shared
config — it cannot produce the worktree-scope entry).

3. The writer

configureHooksPath in the desktop app's worktree manager (resources/app.asar; telemetry keys
desktop_ccd_worktree_*). Decompiled, lightly renamed:

async configureHooksPath(baseRepo, worktree, signal) {
  // ensures extensions.worktreeConfig=true first (writes to the SHARED config if missing)
  let s = await this.execGit([`config`, `--type=path`, `--get`, `core.hooksPath`], baseRepo);
  if (s.success && s.output) {
    let i = s.output.trim(),
        c = path.isAbsolute(i) ? i : path.resolve(baseRepo, i);   // ← backslashes on Windows
    if ((await this.execGit([`config`, `--worktree`, `core.hooksPath`, c], worktree)).success)
      return log.info(`Configured worktree hooks path to ${c} (from base repo config)`), c;
  }
  // fallbacks: <baseRepo>/.husky if it exists, then <git-common-dir>/hooks if it
  // contains non-sample hooks — both also written as ABSOLUTE --worktree overrides
}

Worktree creation additionally runs:

await this.execGit([`config`, `extensions.worktreeConfig`, `true`], worktree);
await this.execGit([`config`, `--worktree`, `core.longpaths`, `true`], worktree);

— confirming the longpaths fingerprint from the OP (and on Windows that key is load-bearing,
not a no-op, so "write nothing" should not extend to it).

configureHooksPath is called from two sites: createWorktree and rebindWorktree. The
rebind 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.hooksPath silently reverts.

Two aggravating details visible in the code:

  • The write is unconditional — no check whether the relative path already resolves inside the

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.

  • It never rechecks or respects prior state, so a user removal is treated the same as a fresh

worktree.

Expected

Same as the OP: write nothing — git already resolves a relative core.hooksPath against each
worktree, which is exactly what repos pin the relative form for. Failing that, in order of
preference:

  1. Only write an override when the relative path does not resolve inside the new worktree

(the actual gap the feature seems to target: gitignored dispatcher dirs à la .husky/_).

  1. If a value must be written, write it with forward slashes on Windows — path.resolve output

breaks any sh-based dispatcher, which is most of the hook ecosystem (husky and derivatives).

  1. Don't re-apply on rebind when the override is absent — treat a missing worktree-scope value in

an already-provisioned worktree as user intent.

Workaround

Per worktree, after every session start (the rebind re-adds it):

git config --worktree --unset core.hooksPath
git config --show-scope --get-all core.hooksPath   # one 'local' row = clean

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.

foma-agent · 19 hours ago

The write-nothing matrix still holds. This report names two remaining holes that matrix did not.

configureHooksPath is also called from rebindWorktree. A user git config --worktree --unset core.hooksPath that 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.resolve backslashes are not the silent wrong-hook failure. They break husky-style $0 PATH 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.