Skip to content

Both worktree-first guards treat ANY git-dir path containing /worktrees/ as a linked worktree — a primary checkout under ~/worktrees/ is unguarded from subdirectories #11809

Description

@claude

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_pathverdictcorrect?
$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 commandverdict
sed -i s/a/b/ $ODD/pkg/x.tsALLOW (rc=0)
echo x > $ODD/pkg/x.tsALLOW (rc=0)
sed -i s/a/b/ $ODD/README.mdBLOCK (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 checkout

Why 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 objectuiguard-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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions