Project-scope hooks do not reach git worktrees: branch-local when tracked, absent entirely when `.claude/` is gitignored
Summary
Project-scope hooks (.claude/settings.json) are the one piece of project configuration that does not reach a git worktree. The docs section "What worktrees share with the main checkout" lists three things that do carry over from the main checkout: the .git directory, project-scope plugins, and saved permission approvals, and it states that all three apply whether the worktree was made with --worktree, with git worktree add, or by the desktop app. Hooks and project settings are not on that list, and in practice they arrive by only one route: git checking out a tracked file.
That route fails in two independent ways:
- Branch-local when tracked. A hook committed on one branch does not exist in a worktree checked out on a different branch until that branch merges the commit. A worktree created from
origin/mainbefore the hook landed simply does not have it. - Never delivered when
.claude/is gitignored. Many repos gitignore.claude/, and the worktree docs themselves recommend gitignoring.claude/worktrees/. Git cannot deliver a file it does not track, so a gitignored projectsettings.jsonreaches no worktree at all..worktreeincludeis the documented remedy for gitignored files, but it is processed only for worktrees Claude Code creates with git (--worktree, subagent worktrees, desktop parallel sessions). A worktree made withgit worktree add, which is the documented manual path and the one any external multi-session tooling uses, gets nothing.
The consequence is the part worth fixing: a guardrail expressed as a project hook is absent exactly in the checkouts where parallel sessions do their most concurrent work, and nothing says so. It is not obviously broken. It is invisible.
What I expected
Either project hooks resolve from the repository's main working tree the way permission approvals already do since v2.1.211, or a worktree that has no project scope says so. Today a project with no hooks and a worktree that could not receive the project's hooks look identical from inside the session.
Repro A: gitignored .claude/
- In a git repo, add
.claude/to.gitignore. - Create
.claude/settings.jsonwith aPreToolUsehook on theBashmatcher that denies a command you can trigger on demand. - Run
claudein the main checkout and confirm the hook fires. git worktree add ../wt-a -b wt-acd ../wt-a && claude, then run the same command.
Actual: the worktree has no .claude/ directory, no project hook is loaded, the command runs. /hooks shows nothing from project scope. There is no message distinguishing "this project has no hooks" from "this project's hooks could not be delivered here".
Expected: the project's hook applies, or the session states that project scope contributed nothing because .claude/ is not present in this working tree.
Repro B: tracked but branch-local
- In a repo where
.claude/settings.jsonis tracked, create a worktree from the default branch:git worktree add ../wt-b -b wt-b. - On a different branch, commit a new
PreToolUseguard into.claude/settings.json. Do not merge it. cd ../wt-b && claudeand trigger the guarded action.
Actual: the guard does not exist in that worktree and never fires. Every sibling worktree stays ungoverned until it merges. Adding a guard is therefore an N-branch rollout, and the interval during which some worktrees are governed and others are not is exactly the interval in which the guard is being added because something went wrong.
Expected: a repository-level guard governs the repository's worktrees, or the divergence is visible.
Environment
- Windows 11, PowerShell 7
- Claude Code desktop app plus VS Code sessions on the same machine
- Several concurrent sessions against one repo, each in its own
git worktree addcheckout - Reproduced across recent 2.1.x builds; the branch-local mechanism is structural rather than version-specific
What this cost to work around
A public MIT repo of multi-session tooling I maintain routes entirely around this. Citing paths relative to that repo:
scripts/worktree/install-gate.ps1installs the guard at user scope, not project scope, and the file carries a "WHY USER SCOPE" block that is a direct transcription of this issue: a project hook lives on one branch and does not exist in the other worktrees until each merges it, and where.claude/is gitignored git cannot deliver it at all.- It installs into every Claude config root on the machine (
~/.claudeplus each alternate-login config dir), because wiring only one leaves the other logins ungated. That is where the editor-hosted parallel sessions actually run. - Because a user-scope hook fires in every repository on the machine, it needs an allowlist file outside any working tree naming which checkouts it governs. That file exists only because the hook could not be scoped to a project.
- The hook script is installed as a copy outside every working tree. A hook whose script path points inside a checkout vanishes on a branch switch, and in my testing a hook whose script is missing exits non-zero but not 2, so the tool call proceeds silently. The guard is then off everywhere and nothing reports it.
- Because the copy can go stale against the source, the installer carries a
-Statusmode that hashes the installed script against the source and prints STALE when they differ, plus a check that every rule the installed script implements is actually wired to a matcher. Both exist purely to detect the failure modes introduced by copying. scripts/worktree/new.ps1carries a related note where it resolves a per-worktree setup hook:git worktree addcannot deliver a file git does not know about, "which is exactly the class of failure where a fresh worktree comes up subtly unusable and nothing says why."
Measured on the repo this was developed against: more than half the worktrees had no project settings whatsoever, and a live editor session was working in one of them with zero coordination context. It could not see its peers and they could not see it.
Possible directions
Not prescriptive, in rough order of how much I would want them:
- Resolve project settings and hooks from the repository's main working tree for sessions in a linked worktree, the way permission approvals already do since v2.1.211 and project-scope plugins do since v2.1.200. This fixes both mechanisms at once and makes the "What worktrees share" list consistent.
- Process
.worktreeincludewhen a session starts in a worktree it did not create, so agit worktree addcheckout can seed a gitignored.claude/the same way--worktreedoes. This covers the gitignored case but not the branch-local one. - At minimum, make absence visible. Report which scopes contributed hooks (user, project, local) at session start or in
/hooks, so a project scope that contributed nothing is distinguishable from a project with nothing to contribute. Everything above is recoverable if the operator can see it; today they cannot.
Related, and how this differs
- #28041 (
claude --worktree:.claude/subdirectories not copied to worktree) is the closest existing report. It covers the copy step for one creation path and is framed around skills and agents; its top comment asked for.worktreeinclude, which has since shipped. It does not cover branch-locality of a tracked hook, does not covergit worktree addworktrees, and does not raise the guardrail-absence consequence. If maintainers prefer, this is foldable into #28041 as the general case. - #70466 (
EnterWorktreedoes not process.worktreeinclude) is the same family: another creation path that skips the gitignored-file copy. This issue is about the paths.worktreeincludewas never intended to cover. - #71913 asks for
.worktreeincludeto also handle non-gitignored untracked files. Orthogonal. - #76897 (PreToolUse hooks stop firing after
EnterWorktree) is a different defect: there the worktree has a byte-identicalsettings.jsonand the hooks still do not fire. Here the settings file is genuinely not there. - #83411 mentions project hooks breaking in desktop worktrees, but through uninitialized submodules holding the hook scripts, not through the settings file failing to arrive.
---
Corroborating code, if useful: the workarounds described above are implemented in
<https://github.com/wshallwshall/claude-multisession> (MIT, PowerShell 7 + Windows-first). Paths
cited in this report are relative to that repository. It is one user's tooling, not an endorsed or
official approach -- linked as evidence that the problem is real and what it costs to work around,
not as a recommendation.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗