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 worktree
esac
  • .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 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 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

Activity

  1. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Sharpening the trigger condition, measured while pinning this in the #11800 matrix (PR #11814) — my first reading of it was wrong, and the correction matters for the fix.

    The verdict does not depend on the depth of the edited file. It depends on whether the nearest existing ancestor directory — the one the hook's walk hands to git -C — is the repo toplevel:

    nearest existing ancestor of $ODD/brand/new/f.ts     = $ODD     -> git-dir: .git                        -> BLOCK
    nearest existing ancestor of $ODD/pkg/brand/new/f.ts = $ODD/pkg -> git-dir: /…/worktrees/oddrepo/.git   -> ALLOW
    

    So a brand-new file several levels deep still blocks if every one of its parent directories is missing (the walk climbs to the toplevel and gets the relative .git), while an edit to an existing file in any subdirectory fails open. Concretely: $ODD/README.md and $ODD/brand/new/f.ts block; $ODD/pkg/x.ts and $ODD/pkg/brand/new/f.ts are allowed into a primary checkout.

    That makes the reachability worse than the original table suggested, not better — in a real repo almost every edit is to a file in a subdirectory that already exists.

    Both spellings are pinned in .claude/hooks/guard-main-checkout.selftest.sh under the KNOWN HOLE #11809 section, two expecting block and two expecting allow, with the wrong pair marked. When this is fixed, the two allow cases flip to block and the section heading goes away.

    Using --absolute-git-dir in place of --git-dir removes the toplevel/subdirectory asymmetry on its own, but does not close the hole — it makes the guard fail open at every depth instead of some. The substring test has to be replaced by the structural one (git-dir differs from git-common-dir) for the fix to be real.

    Generated by Claude Code


    Generated by Claude Code

  2. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round 7 — cross-lane takeover by the skills seat (this card is domain:devx; its twin objectui#7259 is domain:skills, the two hooks are byte-identical, and the pair lands as one flight — receipt for the devx seat below); unblocked when #7260 closed (objectui PR #7686 MERGED 13:39Z); the structural test replaces the substring idiom in both worktree-first guards, in this repo and in objectstack (#11809) as one flight, byte-identical executable lines
    Session: session_019RfFHiRCSs3JXLK4cwcfox
    Branch: claude/issue-11809-structural-linked-worktree-test (this repo) · claude/issue-7259-structural-linked-worktree-test (objectui)
    Worktree: objectui-issue-7259 · objectstack-issue-11809
    Domain: domain:devx (card) — executed by the skills seat under the cross-lane takeover rule: a mechanical S/M blocker whose twin is this seat's, with this receipt; the devx seat keeps the card and may take it back by saying so here.
    File surface: .claude/hooks/guard-main-checkout.sh + .selftest.sh and guard-main-checkout-bash.sh + .selftest.sh here (the KNOWN HOLE #11809 cases flip from allow to block, the banner goes); in objectui the same four files. The test becomes structural: a linked worktree is one whose --git-dir differs from its --git-common-dir; no path-spelling match; --absolute-git-dir alone is refused (fails open at every depth — os-steve's 2026-08-24 measurement on this card).
    Container & model: M, mode:subagent, model: opus (dispatch-gates.mjs --tier: .claude/hooks/** hits no mandated glob; clause ② not engaged — the guard's contract is unchanged, its predicate is corrected)
    Clause-②: no
    Serial constraints cleared: objectui PR #7686 and objectstack PR #15665 (the path-key table) are on both mains; no open PR touches either hook pair (scan 13:5xZ). GOVERNED (.claude/**) ⇒ one draft PR per repo, in-seat review, os-zhuang + hotlong, human merge, skip-changeset where the repo has it (objectui: check-changeset-presence decides); per the maintainer's 2026-09-05 rule the seat adds needs-user-decision + a 维护者速读 at ACCEPT. The PRs carry Fixes #7259 / Fixes #11809.

    Decision re-read (13:5xZ): triage's p2 stands (the hole is the hook's reason to exist, and it fails open on exactly the common case — an existing file in a subdirectory); the fix shape both cards name and os-steve's 2026-08-24 correction sharpened is the ruled shape; no ruling owed.

    Edited by the seat 15:1xZ: the branch line and the objectui file surface now read what landed (objectstack PR #15924 / objectui PR #7749). The 13:59Z text named an earlier branch spelling and scoped objectui to one hook pair; the dispatch brief, written later, ruled four files per repo and the dev followed it.


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
    "issue": 11809,
    "status": "done",
    "premise_still_valid": true,
    "summary": "[objectstack half of a two-repo flight; sibling PR https://github.com/objectstack-ai/objectui/pull/7749, sibling card https://github.com/objectstack-ai/objectui/issues/7259] Replaced the */worktrees/* substring idiom in BOTH worktree-first guards, in both repos, with the structural test: a linked worktree is one whose git-dir differs from its git-common-dir (a primary checkout has them equal, and so does a submodule, so neither needs a special case). --git-common-dir prints RELATIVE to the directory queried, so it is resolved against that directory and both sides go through one canonicalisation helper; ablating just that resolution turns 47 matrix cases from block to allow, which is the direct evidence that --absolute-git-dir alone is not a fix, as the card's second comment warned. Exactly four files per repo, no fifth file in either. TWO THINGS FOR THE SEAT TO RECONCILE: (1) the 13:59Z Claim comment on both cards names branches claude/issue-*-linked-worktree-is-structural, while the dispatch brief names claude/issue-*-structural-linked-worktree-test; I followed the brief as the later and explicit instruction, so the landed branches do NOT match the claim comment's spelling. (2) the same claim comment scopes objectui to 'the first pair only', while the brief rules four files per repo; I followed the brief, so objectui's guard-main-checkout-bash.sh and its matrix moved too. Also observed, not a repo defect: the platform appends its own _Generated by [Claude Code]_ footer to a PR body, so both bodies now carry that line twice; not re-patched, since re-posting footers is the documented wrong move.",
    "tests": "RED/GREEN, self-tests landed first so the flip is shown both ways. Old hooks were read out of HEAD into a scratch dir (git show HEAD:path), so the checked-in tree was never mutated and no restore leg was owed. NEW matrices vs OLD hooks, identical in both repos: guard-main-checkout.selftest.sh 114 passed / 2 failed (FAIL want=block got=allow $ODD/pkg/x.ts, $ODD/pkg/brand/new/f.ts); guard-main-checkout-bash.selftest.sh 124 passed / 2 failed (sed -i s/a/b/ $ODD/pkg/x.ts, echo x > $ODD/pkg/x.ts). Only the SUBDIRECTORY cases redden — the toplevel ones passed against the old hook too, because they blocked by accident; that is the card's sharpened trigger condition reproducing exactly. NEW matrices vs NEW hooks, every hook matrix in each repo, all exit=0: objectstack guard-governed-enqueue 50/0, guard-main-checkout-bash 126/0, guard-main-checkout 116/0, guard-shared-stash 51/0, guard-tree-enum 36/0; objectui guard-main-checkout-bash 126/0, guard-main-checkout 116/0, guard-shared-stash 48/0, guard-tree-enum 36/0. Bash matrix 121 to 126 (new fixture + 5 cases); Edit/Write stays 116 (4 cases flipped, not added). NON-VACUITY: the recipe printed in the matrix footer was re-aimed at the line that now exists and then RUN, so it is not a dead mutation — deleting the linked-worktree escape reddens 29 cases; the deletion was confirmed on disk by a before/after grep count (1 to 0) before the reading was trusted, and the mutation ran on a scratch copy. ABLATION on the load-bearing line: dropping the resolve-common-dir-against-$d line reddens 47 cases, all want=block got=allow — the fail-open-at-every-depth direction. Landed-on-disk confirmed the same way (1 to 0). No build/dist is involved: these are shell hooks executed directly, so there is no dist staleness class here. GATES objectstack at HEAD 8527fa0: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived its own changeset from git (4 paths, working tree) and listed 13 commands; all 13 run, all exit=0, reconciled with --ran — verdict line Run reconciliation - 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN. Whole-repo pnpm lint through scripts/pm/os-verify-lock.sh slot issue-11809: VERDICT command-exit 0 (held 79s, waited 0s, shared box). check-governed-merges.mjs --test on the four paths: exit 3, GOVERNED, .claude/** x4. NOT MEASURED, reported as such and not as a red: pnpm --filter @objectstack/lint run check:doc-formula-expressions first returned exit 3 PREREQUISITE NOT MET (unbuilt workspace package). @objectstack/formula and @objectstack/lint were built through the verify lock and the gate then ran exit=0. GATES objectui at HEAD 2248bba, family derived from that repo's own AGENTS.md, package.json and .github/workflows (the objectstack script is not the authority for that tree): check-changeset-presence 0, check:control-bytes 0, check:shell-escape-residue 0, check:governed-queue-guard self-test 0, whole-repo pnpm lint (turbo, 47/47 tasks) 0 through the same container-global lock (slot issue-7259), all four hook matrices 0, and check-governed-queue-guard.mjs --test on the four paths exit 3 GOVERNED. Changeset: objectui's authority scripts/check-changeset-presence.mjs prints No source or published contract of a released package changed in this range, so no changeset is owed, so no changeset file and NO skip-changeset label was invented there; objectstack got skip-changeset via the additive POST endpoint with a comparison read-back (before {size/m} -> after {size/m, skip-changeset}, union matches, nothing stripped). Every exit code captured before any pipe (cmd > log 2>&1; E=$?); no gate result read through | head. CROSS-REPO RESIDUAL: guard-main-checkout.sh 10 changed diff lines, all inside comment blocks, 41 code lines IDENTICAL; guard-main-checkout-bash.sh 82 changed diff lines (76 comments + 6 lines of message prose inside the blocked-message heredoc), 342 code lines IDENTICAL after stripping comments, blanks and heredoc bodies. No executable line differs. SUBMODULE: measured directly on a real git submodule add fixture — git-dir and git-common-dir are both .git/modules/NAME, so it reads as a primary checkout and BLOCKS with no special case; the matrix's existing submodule pins stay green, so no new case was needed and there is no open question here. CI: both PRs polled in the foreground to completion — objectstack 32/32 runs complete, 20 success + 12 skipped, 0 failures (incl. Lint & Repo Gates, Governed Surface Queue Guard, Check Changeset); objectui 30/30 complete, 27 success + 3 skipped, 0 failures (incl. Hook Self-Tests, Governed Surface Queue Guard, Changeset Declaration, Shell Escape Residue Scan, Lint).",
    "mcp_calls": "1 — one MCP GitHub call (issue_read on objectstack#11809); the REST probe returned 200 on a repo-scoped read, so every other read and write went through REST per the channel rule",
    "open_questions": [
    {
    "question": "The landed branch names do not match the 13:59Z Claim comment on either card. The claim says claude/issue-11809-linked-worktree-is-structural / claude/issue-7259-linked-worktree-is-structural; the dispatch brief says claude/issue-11809-structural-linked-worktree-test / claude/issue-7259-structural-linked-worktree-test. I followed the brief and flagged it rather than silently picking a side.",
    "options": [
    "A — leave it: the PRs are open, green and linked from both cards, and the claim comment's branch line is simply stale",
    "B — the seat edits the two Claim comments so the branch line matches what actually landed, keeping the claim receipt usable as an identity marker",
    "C — rename both branches to the claim's spelling, which closes and reopens both PRs and loses their check history"
    ],
    "recommendation": "B. The claim comment is the seat's identity marker for parallel agents, so it should describe reality; editing one line costs nothing. C is strictly worse — it discards two fully green check histories to fix a spelling. A leaves a receipt that points at branches that do not exist."
    }
    ],
    "out_of_scope_findings": [],
    "branch": "claude/issue-11809-structural-linked-worktree-test",
    "pr": "#15924",
    "sibling_pr": "objectstack-ai/objectui#7749",
    "sibling_card": "objectstack-ai/objectui#7259"
    }


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions