[BUG] `claude --worktree` overwrites the `core.hooksPath` of `$GIT_COMMON_DIR/config`

Status Open
Reported on v2.1.50
Maintainer reply None cached
Activity 13 comments · opened Feb 21, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

claude --worktree overwrites the core.hooksPath of $GIT_COMMON_DIR/config to $GIT_COMMON_DIR/hooks. This ends up as one of the following:

  1. If $GIT_DIR/config-core.hooksPath hasn't been specified, it's effectively no-op. Read https://git-scm.com/docs/githooks
  2. Otherwise, the core.hooksPath is overwritten, which is _destructive_ (if it points to some non-default path)

I guess that the original intention was to set the hooks for the newly created worktree to the main repo's one. But as I mentioned above, the actual behavior turns out to be merely modifying the $GIT_COMMON_DIR config. If it wants to touch the _worktree-specific_ thing, git config --worktree is the answer (which requires setting another config, though). But even git config --worktree is pointless since, if sharing hooks is the goal, it's already achieved by git-config resolution.

What Should Happen?

core.hooksPath of $GIT_DIR/config must be untouched when Claude Code creates a worktree internally (either by claude --worktree or EnterWorktree tool).

Error Messages/Logs

Steps to Reproduce

  1. Start with any repo with a custom core.hooksPath pointing to anything other than $GIT_DIR/hooks
  2. cd into the repo and execute claude --worktree
  3. Overwritten.

Claude Model

None

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.50

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

_No response_

View original on GitHub ↗

12 Comments

zzJinux · 5 months ago

not stale

joearasin · 5 months ago

Seconding this. The worktree setup function is checking for .husky and .git/hooks. It does not check whether core.hooksPath is already configured.

zzJinux · 4 months ago

Not fixed yet in v2.1.114

incrediblehulf · 3 months ago

Adding empirical confirmation — Claude Code 2.1.140 on Windows 11, Git 2.35.1.windows.2. Three independent reproductions across separate sessions over three days (2026-05-11, 2026-05-12, 2026-05-13), each from a clean .githooks state.

Minimal repro on Windows:

  1. git config core.hooksPath .githooks in the primary clone (relative path)
  2. Verify: git config --get core.hooksPath.githooks
  3. From a Claude Code session with CWD at the primary clone, call EnterWorktree(name: "test")
  4. Check immediately: git config --get core.hooksPathC:\Claude\datalake\main\.git\hooks (Windows-backslash absolute path to git's default hooks dir)

The drift value is consistently the Windows-backslash absolute form (C:\...\.git\hooks, not /c/.../.git/hooks), suggesting a Windows-native code path is writing to .git/config.

Downstream impact: our .githooks/pre-commit enforces TLS-cert verification, secret scanning, schema-drift checks, RLS on new tables, and migration idempotency. When core.hooksPath points at a stale absolute path, git silently runs zero hooks (verified — invalid core.hooksPath does NOT fall back to .git/hooks). In one drift event, ~25 commits bypassed the entire local chain before CI caught a TLS violation the local hook would have rejected.

Local workaround we shipped: auto-correct the drift in our post-EnterWorktree bootstrap script.

HP=$(git -C "$PRIMARY_CLONE" config --get core.hooksPath 2>/dev/null || echo "")
if [[ "$HP" != ".githooks" ]]; then
  echo "core.hooksPath drift detected: '$HP' → auto-correcting"
  git -C "$PRIMARY_CLONE" config core.hooksPath .githooks
fi

Contains practical impact for us. Still creates an exposure window between EnterWorktree and the bootstrap step. Would appreciate either (a) preserving a pre-existing custom core.hooksPath value during EnterWorktree's setup, or (b) scoping the change via extensions.worktreeConfig so the write doesn't leak into the shared .git/config.

zoltanmaric · 3 months ago

The workaround we're using involves adding a WorktreeCreate hook that mimics what Claude's default hook seems to do, minus the bug:

Add this to .claude/settings.json:

  "hooks": {
    "WorktreeCreate": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/create-claude-worktree.sh"
          }
        ]
      }
    ]
  },

And the contents of create-claude-worktree.sh:

#!/usr/bin/env bash

set -euo pipefail

# Claude passes hook input on stdin as JSON. The WorktreeCreate payload includes
# "name", a user-provided or auto-generated slug for the new worktree.
name="$(jq -r '.name')"

# The hook may run from the primary checkout or from an existing worktree. Git
# lists the primary worktree first in porcelain output, so use that as the stable
# parent for all .claude/worktrees/<name> directories.
primary_root="$(git worktree list --porcelain | awk '/^worktree / { print substr($0, 10); exit }')"
path="$primary_root/.claude/worktrees/$name"

if git worktree list --porcelain | awk '/^worktree / { print substr($0, 10) }' | grep -Fxq "$path"; then
    printf '%s\n' "$path"
    exit 0
fi

if git show-ref --quiet --verify "refs/heads/$name"; then
    git worktree add "$path" "$name" >&2
    printf '%s\n' "$path"
    exit 0
fi

# Match Claude's default base selection: start from the remote default branch.
base_ref="$(git symbolic-ref --short refs/remotes/origin/HEAD)"
git worktree add -b "$name" "$path" "$base_ref" >&2
printf '%s\n' "$path"
CullerierG · 3 months ago

Not yet fixed in v2.1.146

Additional repro: Agent tool with isolation: "worktree" on Linux

Confirming this also triggers on Linux via the Agent tool when a skill spawns sub-agents with isolation: "worktree". The write goes to the main repo's .git/config, not a
worktree-specific config.

How we confirmed: Replaced /usr/bin/git with a wrapper that logs calls containing core.hooksPath. Caught:

caller: claude --plugin-dir ... /ai-council-review:ai-council-review head
args: git config core.hooksPath /path/to/repo/.git/hooks

The write is atomic (.git/config.lock.git/config rename), so watching the file with inotify misses it — you need IN_MOVED_TO on the parent directory.

Impact: pre-commit install hard-fails with "Cowardly refusing to install hooks with core.hooksPath set" after every agent session, breaking any setup script that calls it.

Workaround: SessionStart hook in .claude/settings.local.json:
```json
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "git -C /path/to/repo config --unset-all core.hooksPath 2>/dev/null; pre-commit install -f"
}]
}]
}
}

emw3 · 2 months ago

Still present in v2.1.163 (Linux, WSL2). Adding two things this thread doesn't have yet: a deterministic minimal repro with timestamps, and the actual code path inside the binary that causes it — including why it fires on repos that don't use husky at all.

Repro (deterministic, 2/2)

Repo config: core.hooksPath = .githooks (relative, tracked hooks dir — the documented git config core.hooksPath .githooks setup).

Watchdog: 1-second poll logging git config core.hooksPath + .git/config mtime, then two EnterWorktree tool calls with a restore in between:

22:07:47  .githooks                                          (baseline)
22:07:54  /home/<user>/projects/<repo>/.git/hooks            ← EnterWorktree #1
22:08:46  .githooks                                          (manual restore)
22:08:51  .githooks                                          (ExitWorktree — writes config but preserves value)
22:09:15  /home/<user>/projects/<repo>/.git/hooks            ← EnterWorktree #2

Flips within seconds of each EnterWorktree, every time. ExitWorktree never restores it. Until the user notices, the repo's tracked hooks (in our case a commit-msg author guard and a pre-push lint/typecheck gate) are silently disabled for the main checkout and all sessions.

The code path (extracted from the v2.1.163 binary)

The worktree-setup routine that runs after git worktree add (minified; variables renamed):

// H = main repo root, $ = freshly created worktree path
let huskyDir = path.join(H, ".husky"),
    gitHooks = path.join(H, ".git", "hooks"),
    hooksSource = null;

for (let dir of [huskyDir, gitHooks])           // ① first existing dir wins
  try { if ((await stat(dir)).isDirectory()) { hooksSource = dir; break } } catch {}

if (hooksSource) {
  let configured = await readConfig(main, "core", null, "hooksPath");
  if (configured !== hooksSource) {              // ② relative ".githooks" vs absolute path → never equal
    await git(["config", "core.hooksPath", hooksSource], { cwd: $ });   // ③ writes SHARED config
    // logs: "Configured worktree to use hooks from main repository: <path>"
  }
}

Three compounding problems:

  1. The .git/hooks fallback is always armed. .git/hooks exists in every git repository (sample hooks), so hooksSource is always truthy even in repos with no husky and no custom hook manager. This is why the bug hits everyone, not just husky users.
  2. The idempotence guard can never pass when the user has a relative core.hooksPath (the common, documented form): it string-compares .githooks to an absolute path. So the write re-applies on every worktree creation — restoring the config only protects you until the next EnterWorktree (ours flipped back within 20 minutes, from a parallel session's worktree).
  3. git config without --worktree (only cwd differs) writes $GIT_COMMON_DIR/config — shared by the main checkout and every worktree — exactly as the OP diagnosed.

Why the write is unnecessary in the first place

For the non-husky case the write is a no-op at best (.git/hooks is git's default) and destructive at worst (clobbers a custom value). For relative values like .githooks, git already resolves the path against each worktree's own checkout — which contains the tracked hooks — so worktrees get working hooks with no configuration at all. The only arguably-legitimate case is an untracked absolute hooks dir (husky's .husky/_), and that one already resolves correctly from the shared config without rewriting it.

Suggested fix: drop the .git/hooks fallback entirely, and skip the write when the existing core.hooksPath is a relative path (it already works in worktrees). If a per-worktree override is ever truly needed, it must be git config --worktree behind extensions.worktreeConfig, never the shared config.

Also confirming @CullerierG's report: we see the same write from the Agent tool with isolation: "worktree", and our incident review shows the earlier flips came from background sessions creating worktrees — which makes this especially nasty in multi-session/agent-heavy workflows: any session entering a worktree disarms hooks for all of them.

raine · 2 months ago

Funny how this tool thinks it has any business just overwriting your git config values, and does not even leave an escape hatch to disable that behavior

devshorts · 2 months ago

This is absurd, I tried to using work trees today and all my hooks exploded. This took me forever to figure out what was going on. Insane to have a tool silently editing these configs.

orenmagid · 1 month ago

Some binary archaeology that this thread doesn't have yet: the behavior changed in v2.1.179 — the destructive .git/hooks pinning is gone, but the silent shared-config write is still there in current versions.

I extracted the worktree post-create setup function from published binaries (@anthropic-ai/claude-code-darwin-arm64 from npm, located by searching for the log string Configured worktree to use hooks from main repository):

  • ≤ v2.1.173 (verified in 2.1.173, published 2026-06-11): the function picks the first existing directory of [<main>/.husky, <main>/.git/hooks] — and .git/hooks always exists — string-compares it to the configured core.hooksPath, and on mismatch runs git config core.hooksPath <main>/.git/hooks with cwd = the new worktree. Plain git config from a linked worktree writes to $GIT_COMMON_DIR/config, so a repo configured with core.hooksPath = .githooks gets it replaced by the absolute, empty default — hooks silently die repo-wide. This is the destructive variant everyone in this thread has hit (matches @emw3's code-path analysis of 2.1.163).
  • v2.1.179 (published 2026-06-16) through current v2.1.207: the .husky/.git/hooks fallback is gone. The function now only acts when the configured value is relative: it absolutizes it against the main repo root and writes that back — still via plain git config (no --worktree, no extensions.worktreeConfig), still into the shared config. So core.hooksPath = .githooks becomes core.hooksPath = /abs/path/to/repo/.githooks. Hooks keep firing (worktrees now execute the main checkout's copy), but the tool is still silently mutating the user's repo config, and anything that expects the literal relative value (validators, dotfile sync, docs) breaks.

Observed in the wild across a multi-repo portfolio (macOS, 2.1.205–207): every repo that ever hosted a worktree-isolated agent and never set core.hooksPath now carries hooksPath = <repo>/.git/hooks (fingerprint of the old variant — functionally a no-op, but nobody wrote that by hand); the one repo using a relative tracked .githooks had it rewritten to the absolute form.

Workaround that survives current versions: configure the absolute path yourself — git config core.hooksPath "$(git rev-parse --show-toplevel)/.githooks". The current function short-circuits when the stored value is already absolute, so it never writes.

Suggested fix: scope the write per-worktree (extensions.worktreeConfig + git config --worktree), or skip entirely when core.hooksPath is already configured — a relative value pointing at a tracked hooks dir already resolves correctly from linked worktrees.

Related open issues describing the same write: #66993, #67196, #67914, #72714.

versalarchitect · 1 month ago

Following up on the git config --worktree suggestion in this issue: it appears to have shipped, and it moved the bug rather than fixing it.

On Claude Code 2.1.217 (macOS, Darwin 25.5.0, git 2.50.1), worktree creation no longer writes to the shared $GIT_COMMON_DIR/config — that half is genuinely fixed, and I can confirm the shared core.hooksPath was left correct and untouched. It now writes into .git/worktrees/<name>/config.worktree instead, but still as an absolute path to the main checkout's working tree.

For repos that keep hooks in-tree (core.hooksPath = .githooks, the committed-hooks pattern), the new behaviour is worse than the old one:

  1. It pins to the main checkout's working tree. A worktree therefore runs whatever hooks the main checkout's currently checked-out branch happens to have — not the hooks belonging to the code being pushed from that worktree.
  2. If that branch predates the hooks directory, the path resolves to nothing and git skips the hook silently. No warning, no error, no output. The only symptom is the absence of the hook's own output, which is invisible unless you already know what it should print.
  3. config.worktree wins on read, so the obvious repair — git config core.hooksPath .githooks — lands in .git/config and is silently shadowed. You can "fix" it repeatedly and change nothing. git config --worktree is required to touch the file that actually decides, which is not where anyone thinks to look.

Point 3 is what makes this hard to diagnose: .git/config reads correctly the whole time.

Observed impact in one repo: a pre-push gate was dead in 7 of 7 worktrees created after the repo adopted core.hooksPath, while the 7 created before it were fine — the pin only appears once there is a value to copy. The main checkout happened to sit on a branch predating the hooks directory, so every one of those worktrees pushed completely ungated for five days. The failure is entirely silent; it was found only by noticing the hook's banner line missing from a push.

Suggested fix: a relative core.hooksPath already resolves correctly per worktree — I verified this with real pushes from a linked worktree. So for relative values the copy step is unnecessary, and resolving them to absolute is precisely what breaks them. Either skip the copy when the configured value is relative, or write it relative to each worktree's own root rather than the main checkout's.

Workaround for anyone hitting this, since the pin is re-asserted on every new worktree: clear it from a repo-side install script, e.g. in a prepare/postinstall step —

git config --worktree --unset core.hooksPath 2>/dev/null || true
git config core.hooksPath .githooks 2>/dev/null || true

(The --unset must come first, and both need guarding — --worktree errors when extensions.worktreeConfig isn't enabled, e.g. in a plain clone or a CI checkout.)

lswingrover · 1 month ago

Corroborating this on macOS, with an additional manifestation worth flagging for whoever picks up the fix:

When the clone has extensions.worktreeConfig=true, the same git config core.hooksPath <absolute> write (cwd = the new worktree) does not land in the shared .git/config the way the other reports describe — it lands worktree-scoped, in .git/worktrees/<name>/config.worktree. So instead of a single shared-config clobber, you get a per-worktree stale absolute override that independently shadows the correct shared value in every generated worktree. Same root cause, but a fix that only guards the shared-config path would miss this variant.

Also observed: that config.worktree is co-written with core.longpaths=true at creation. A plain git worktree add writes neither key, which is what let me attribute the write to the tool rather than to git.

Context: Claude Code worktrees under <repo>/.claude/worktrees/*, git 2.50.1, macOS. As #66993 / #72714 already conclude, the correct behavior is to write the relative form (which git resolves per-worktree) or to leave core.hooksPath untouched entirely.

Showing cached comments. Read the full discussion on GitHub ↗