Found while authoring the self-test matrix for guard-main-checkout.sh (#11800). Filing rather than fixing: #11800 is explicitly scoped to coverage and rules out editing the hook.
The hole
Both worktree-first guards decide "am I in a linked worktree?" by substring-matching the git-dir path:
gitdir="$(git -C "$d" rev-parse --git-dir 2>/dev/null)"||exit 0
case"$gitdir"in*/worktrees/*) exit 0 ;; # <- treats ANY path containing /worktrees/ as a linked worktreeesac
.claude/hooks/guard-main-checkout.sh (the Edit/Write/NotebookEdit half).claude/hooks/guard-main-checkout-bash.sh line 339, return 1 in the same shape — its header line 29 states the idiom outright: git-dir matches */worktrees/* (a linked worktree)
That is not a test for a linked worktree; it is a test for the seven characters worktrees appearing anywhere in the git-dir path. A primary checkout that lives under a directory named worktrees — e.g. the ordinary layout ~/worktrees/objectstack — therefore reads as a linked worktree and both guards allow edits into it.
What makes it a defect rather than a boundary
git rev-parse --git-dir prints a relative path (.git) at the repo toplevel and an absolute one from any subdirectory. So the same unguarded primary checkout gets opposite verdicts by depth:
PRIMARY toplevel: .git
PRIMARY subdir pkg: /tmp/XXX/mainrepo/.git
ODD (path has /worktrees/) toplevel: .git
ODD subdir pkg: /tmp/XXX/worktrees/oddrepo/.git
Verdicts measured against those exact fixtures, guard-main-checkout.sh:
payload file_path | verdict | correct? |
|---|
$ODD/README.md (repo root) | BLOCK (rc=2) | yes, by accident — gitdir was relative |
$ODD/pkg/x.ts (subdirectory) | ALLOW (rc=0) | no — unguarded primary checkout |
$MAIN/README.md (control) | BLOCK (rc=2) | yes |
$MAIN/pkg/x.ts (control) | BLOCK (rc=2) | yes |
guard-main-checkout-bash.sh fails open on the same fixture, same asymmetry:
payload command | verdict |
|---|
sed -i s/a/b/ $ODD/pkg/x.ts | ALLOW (rc=0) |
echo x > $ODD/pkg/x.ts | ALLOW (rc=0) |
sed -i s/a/b/ $ODD/README.md | BLOCK (rc=2) |
sed -i s/a/b/ $NORM/pkg/x.ts (control) | BLOCK (rc=2) |
A guard whose verdict on one checkout flips with the depth of the edited file is not expressing a design posture. This is the quiet failure direction the #11800 card names: no error anywhere, edits into a shared primary checkout simply start being allowed.
Reproduction
tmp="$(mktemp -d)"; ODD="$tmp/worktrees/oddrepo"; mkdir -p "$ODD/pkg"
( cd"$ODD"&& git init -q .&& git config user.email s@e.com && git config user.name s \
&&:> README.md &&:> pkg/x.ts && git add -A && git commit -qm i ) >/dev/null 2>&1
jq -nc --arg f "$ODD/pkg/x.ts"'{tool_name:"Edit",tool_input:{file_path:$f}}' \
| .claude/hooks/guard-main-checkout.sh;echo"rc=$?"# rc=0 — allowed into a PRIMARY checkoutWhy 121 cases missed it
guard-main-checkout-bash.selftest.sh builds its fixture as mainrepo / wt / plain under a plain mktemp -d. No case places a repo under a path segment named worktrees, so the substring idiom is never separated from the structural question it stands in for.
Fix shape (not applied here)
The structural test is that a linked worktree's git-dir differs from its git-common-dir, which is true regardless of path spelling and regardless of relative-vs-absolute printing:
gd="$(git -C "$d" rev-parse --absolute-git-dir 2>/dev/null)"||exit 0
cd="$(git -C "$d" rev-parse --git-common-dir 2>/dev/null)"||exit 0
[ "$gd"!="$(cd "$(dirname "$cd")"&& pwd)/$(basename "$cd")" ] &&exit 0 # linked worktree
--absolute-git-dir alone also removes the depth asymmetry, since it never prints a relative path. Any fix wants a case in both matrices pinning the worktrees-segment primary checkout as BLOCK at both repo root and subdirectory.
Blast radius
Four files: guard-main-checkout.sh and guard-main-checkout-bash.sh in this repo, and the same two in objectui — guard-main-checkout.sh is byte-identical across the two repos (sha256 217a8b1c2d9ff9009e2db0f54d0380c1202f16f7fb99698f419ce46d19a7d2b3), so a fix here must be mirrored there.
The self-test matrix landing for #11800 pins today's ALLOW in a section explicitly labelled a known hole pointing at this issue, so CI stays green and the fix flips those two cases to block mechanically.
Adjacent but distinct from the split_segments() escaped-quote family (#11131, #11738, #11804) — that class is about shell parsing; this one is about the worktree test itself, and it is the only class shared by both guards.
Generated by Claude Code
Found while authoring the self-test matrix for
guard-main-checkout.sh(#11800). Filing rather than fixing: #11800 is explicitly scoped to coverage and rules out editing the hook.The hole
Both worktree-first guards decide "am I in a linked worktree?" by substring-matching the git-dir path:
.claude/hooks/guard-main-checkout.sh(the Edit/Write/NotebookEdit half).claude/hooks/guard-main-checkout-bash.shline 339,return 1in the same shape — its header line 29 states the idiom outright:git-dir matches */worktrees/* (a linked worktree)That is not a test for a linked worktree; it is a test for the seven characters
worktreesappearing anywhere in the git-dir path. A primary checkout that lives under a directory namedworktrees— e.g. the ordinary layout~/worktrees/objectstack— therefore reads as a linked worktree and both guards allow edits into it.What makes it a defect rather than a boundary
git rev-parse --git-dirprints a relative path (.git) at the repo toplevel and an absolute one from any subdirectory. So the same unguarded primary checkout gets opposite verdicts by depth:Verdicts measured against those exact fixtures,
guard-main-checkout.sh:file_path$ODD/README.md(repo root)$ODD/pkg/x.ts(subdirectory)$MAIN/README.md(control)$MAIN/pkg/x.ts(control)guard-main-checkout-bash.shfails open on the same fixture, same asymmetry:commandsed -i s/a/b/ $ODD/pkg/x.tsecho x > $ODD/pkg/x.tssed -i s/a/b/ $ODD/README.mdsed -i s/a/b/ $NORM/pkg/x.ts(control)A guard whose verdict on one checkout flips with the depth of the edited file is not expressing a design posture. This is the quiet failure direction the #11800 card names: no error anywhere, edits into a shared primary checkout simply start being allowed.
Reproduction
Why 121 cases missed it
guard-main-checkout-bash.selftest.shbuilds its fixture asmainrepo/wt/plainunder a plainmktemp -d. No case places a repo under a path segment namedworktrees, so the substring idiom is never separated from the structural question it stands in for.Fix shape (not applied here)
The structural test is that a linked worktree's git-dir differs from its git-common-dir, which is true regardless of path spelling and regardless of relative-vs-absolute printing:
--absolute-git-diralone also removes the depth asymmetry, since it never prints a relative path. Any fix wants a case in both matrices pinning theworktrees-segment primary checkout as BLOCK at both repo root and subdirectory.Blast radius
Four files:
guard-main-checkout.shandguard-main-checkout-bash.shin this repo, and the same two inobjectui—guard-main-checkout.shis byte-identical across the two repos (sha256217a8b1c2d9ff9009e2db0f54d0380c1202f16f7fb99698f419ce46d19a7d2b3), so a fix here must be mirrored there.The self-test matrix landing for #11800 pins today's ALLOW in a section explicitly labelled a known hole pointing at this issue, so CI stays green and the fix flips those two cases to
blockmechanically.Adjacent but distinct from the
split_segments()escaped-quote family (#11131, #11738, #11804) — that class is about shell parsing; this one is about the worktree test itself, and it is the only class shared by both guards.Generated by Claude Code