Repository navigation
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
Activity
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 -> ALLOWSo 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.mdand$ODD/brand/new/f.tsblock;$ODD/pkg/x.tsand$ODD/pkg/brand/new/f.tsare 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.shunder theKNOWN HOLE #11809section, two expectingblockand two expectingallow, with the wrong pair marked. When this is fixed, the twoallowcases flip toblockand the section heading goes away.Using
--absolute-git-dirin place of--git-dirremoves 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
Claim: PM loop round 7 — cross-lane takeover by the skills seat (this card is
domain:devx; its twin objectui#7259 isdomain: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.shandguard-main-checkout-bash.sh+.selftest.shhere (the KNOWN HOLE #11809 cases flip fromallowtoblock, the banner goes); in objectui the same four files. The test becomes structural: a linked worktree is one whose--git-dirdiffers from its--git-common-dir; no path-spelling match;--absolute-git-diralone 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 bothmains; 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-changesetwhere the repo has it (objectui:check-changeset-presencedecides); per the maintainer's 2026-09-05 rule the seat addsneeds-user-decision+ a 维护者速读 at ACCEPT. The PRs carryFixes #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
- added a commit that references this issue
on Sep 5, 2026 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-dirprints 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-diralone 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 branchesclaude/issue-*-linked-worktree-is-structural, while the dispatch brief namesclaude/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'sguard-main-checkout-bash.shand 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, allwant=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/objectstackderived its own changeset from git (4 paths, working tree) and listed 13 commands; all 13 run, all exit=0, reconciled with--ran— verdict lineRun reconciliation - 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN. Whole-repopnpm lintthroughscripts/pm/os-verify-lock.shslotissue-11809:VERDICT command-exit 0(held 79s, waited 0s, shared box).check-governed-merges.mjs --teston 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-expressionsfirst returned exit 3 PREREQUISITE NOT MET (unbuilt workspace package).@objectstack/formulaand@objectstack/lintwere 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-repopnpm lint(turbo, 47/47 tasks) 0 through the same container-global lock (slotissue-7259), all four hook matrices 0, andcheck-governed-queue-guard.mjs --teston the four paths exit 3 GOVERNED. Changeset: objectui's authorityscripts/check-changeset-presence.mjsprintsNo 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 gotskip-changesetvia 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 realgit submodule addfixture — 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 saysclaude/issue-11809-linked-worktree-is-structural/claude/issue-7259-linked-worktree-is-structural; the dispatch brief saysclaude/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
- added a commit that references this issue
on Sep 9, 2026
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