Repository navigation
finding(hooks): guard-main-checkout.sh reads any git-dir path containing /worktrees/ as a linked worktree — a PRIMARY checkout under a dir named worktrees is unguarded, and by DEPTH #7259
Description
Activity
分诊 ·
domain:skills/priority:p2/pm:queueAnchor read, not guessed.
.claude/hooks/guard-main-checkout.sh⇒domain:skills(.claude/**是治理面)⇒ 草稿 PR + 人工合并,⛔ 无 agent 批准。⭐ 复核 objectui
origin/maina472b07(2026-09-05T01:02:08Z),那三行一字不差还在:gitdir="$(git -C "$d" rev-parse --git-dir 2>/dev/null)" || exit 0 case "$gitdir" in */worktrees/*) exit 0 ;; esac
⇒ 这是对一个路径的子串匹配,不是对「是否为 linked worktree」的检验。
定级 p2
⭐ 后果是这道钩子存在的理由本身:一个仅仅位于名为
worktrees的目录之下的主检出,会产生匹配该模式的 git-dir,钩子exit 0⇒ 一次对共享主检出的无守卫编辑,而那正是 worktree-first 要阻止的确切失败(HEAD 被别的 agent 切走、刚写的文件被 reset 掉)。⭐ 而且它是实测的、按深度分叉的,不是论证的 —— 卡面给了完整真值表:
git rev-parse --git-dir在仓库顶层打印相对路径(.git),从任何子目录打印绝对路径;叠加钩子的最近祖先走查,同一个无守卫检出按深度得到相反判决:payload file_pathgit-dir 今日判决 ODD/README.md.git(相对)block —— 正确,但纯属偶然 ODD/pkg/x.ts绝对,含 /worktrees/allow —— 错 ODD/pkg/brand/new/f.ts绝对,含 /worktrees/allow —— 错 ⇒ ⛔ 顶层文件被拦住、子目录文件被放行 —— 这比「一律放行」更糟,因为它会让人以为守卫在工作。
不是 p1:触发需要一个特定的目录布局(把树放在
worktrees/父目录下),且没有实测到的事故。⚠️ ⭐ 但升级条件写在这里,供推翻:一旦有任何一次「共享主检出被无守卫编辑」的事故与这个布局对上,它就是 p1,⛔ 无需再论证 —— 因为受损的是别人未提交的工作,而那是不可恢复的。⚠️ 而卡面提醒的那句值得原样保留:这个夹具路径并不古怪 ——git worktree add ../repo-task是被规定的配方,而一个改为把树统一放在worktrees/父目录下的操作者,得到的是一个静默无守卫的主检出。边界测试 —— 治理面 + 门禁行为变更
修它会让今天被放行的编辑开始被拦 ⇒ 收紧,方向正确,但它改变一道 PreToolUse 钩子的判决 ⇒ ⭐
.claude/**治理面,人工合并,GOVERNED_APPROVERS=os-zhuang/hotlong。⭐ 修法方向 —— 卡面给出了 git 能回答的那个真问题
git 能回答的真问题是这个 git-dir 是不是某个仓库的 linked worktree,例如比较
--git-dir与--git-common-dir(二者恰好在 linked worktree 中不同),而不是对路径做模式匹配。⇒ ⛔ 不要把
*/worktrees/*改得更精确(比如锚定/.git/worktrees/)—— 那仍然是在猜路径。换成 git 自己的判据。⭐ 两条交接条件,都不能省
- 两个仓要一起动。 本卡是
objectstack-ai/objectstack#11809的 objectui 副本,而卡面测过:两个钩子的可执行行完全相同(整个 diff 是一个注释块里的 9 行) ⇒ 缺陷同一个。⛔ 只修一个仓,会让两个仓的守卫语义分叉 —— 而这两个钩子的全部价值就在于它们一致。 - ⭐ 红/绿已经就位。 finding(hooks):
guard-main-checkout.shis the only PreToolUse guard with no self-test — the one both CLAUDE.md files name by path as the enforcement mechanism #6451 落地的 self-test 矩阵把上面四行全部钉住,置于一个KNOWN HOLE横幅之下,横幅点名本卡并声明这是今日行为的记录,不是意图行为的记录。⇒ 修好之后,最后两行翻成block,那一节矩阵变成一次机械编辑。 ⛔ 修复 PR 必须同时翻转它们,否则矩阵会红。
⭐ 立卡方式记一笔:#6451 的调度明确禁止在一个「只加矩阵」的 PR 里削弱或「修好」守卫(
.claude/**是治理面),于是那个 PR 钉住今日行为并把洞大声说出来,再单独立卡。⇒ 这是治理面上的正确做法:先让洞可见且有红/绿,再走人工合并去修它。⛔ 本席不认领、不派发、不合并、不代裁。
Generated by Claude Code
- 两个仓要一起动。 本卡是
Serial deferral, not a dispatch:
pm:queue→pm:blockedbehind the same files' in-flight card.
Blocked-by: #7260Fold-or-serial answered on the five gates: gate ① fails — #7260 is an input defect (the hook never reads
notebook_path), this card is a predicate defect (*/worktrees/*is a substring test, not a linked-worktree test); triage's own reading is the same (修 #7259 不会修好本卡,反之亦然). Different defect, different fix ⇒ not one family; the same two files (.claude/hooks/guard-main-checkout.shand its self-test) plusguard-main-checkout-bash.sh:339, which carries the same idiom ⇒ hard-serial behind the #7260 / objectstack #11810 flight (launched 2026-09-05T01:5xZ).Recorded at deferral so the unblock does not re-derive it:
- The fix is the structural test, not a sharper pattern: a linked worktree is where
git rev-parse --git-dirand--git-common-dirdiffer (compare after resolving both to absolute paths; at a primary checkout's toplevel both read.git).--absolute-git-diralone makes the guard fail open at every depth (objectstack console: grid grouping and dashboard category axes/legends order a select field's values alphabetically, not in the field's declared option order #11809's own sharpening note). - Both guards carry the idiom:
guard-main-checkout.sh:42-44andguard-main-checkout-bash.sh:339, in both repos; the matrix rows under KNOWN HOLE finding(hooks):guard-main-checkout.shreads any git-dir path containing/worktrees/as a linked worktree — a PRIMARY checkout under a dir namedworktreesis unguarded, and by DEPTH #7259 flip toblockand the banner goes; the bash guard's self-test is checked for the same rows. - Two repos move together: this card and objectstack console: grid grouping and dashboard category axes/legends order a select field's values alphabetically, not in the field's declared option order #11809 (devx,
pm:blockedwithout aBlocked-by:line — its blocker, the console: Monaco loads from cdn.jsdelivr.net; offline/CSP installs fall back to a textarea and log uncaught errors #11800 matrix, has landed) land as one flight, one worktree per repo, taken over under the same cross-lane rule as list filter builder: "Add filter" defaults to the hidden Organization field, lists hidden system fields first, and offers both "Is empty" and "Is null" #11810. - Governed in both repos: draft PRs, human merge,
Fixes #7259/Fixes #11809.
Unlock: when #7260 closes (its objectui PR merges), this card returns to
pm:queueand dispatches together with #11809. Assigned to the seat so the wait is owned; this comment is not a claim.
Generated by Claude Code
- The fix is the structural test, not a sharper pattern: a linked worktree is where
Claim: PM loop round 7 — 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-7259-structural-linked-worktree-test(this repo) ·claude/issue-11809-structural-linked-worktree-test(objectstack)
Worktree:objectui-issue-7259·objectstack-issue-11809
Domain:domain:skills
File surface:.claude/hooks/guard-main-checkout.sh+.selftest.shandguard-main-checkout-bash.sh+.selftest.shhere (the KNOWN HOLE section's twoallowcases flip toblockand the banner goes; the bash matrix gains the fixture); in objectstack 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 (it fails open at every depth — the card's own measurement).
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": 7259,
"status": "done",
"premise_still_valid": true,
"summary": "[objectui half of a two-repo flight; sibling PR https://github.com/objectstack-ai/objectstack/pull/15924, sibling card https://github.com/objectstack-ai/objectstack/issues/11809] 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 8527fa0352: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-7259-structural-linked-worktree-test",
"pr": "#7749",
"sibling_pr": "objectstack-ai/objectstack#15924",
"sibling_card": "objectstack-ai/objectstack#11809"
}
Generated by Claude Code
- added a commit that references this issue
on Sep 7, 2026 - added a commit that references this issue
on Sep 9, 2026
Found while porting the sibling repo's self-test matrix for this hook (#6451). Out of scope for that card — that PR pins today's behaviour and says the hole out loud; it deliberately does not change the hook (
.claude/**is governed surface, and the dispatch fence forbids weakening or "fixing" the guard in a matrix-only PR). Filed rather than fixed.This is the objectui copy of
objectstack-ai/objectstack#11809. The two hooks' executable lines are identical (their whole diff is 9 lines inside one comment block), so the defect is the same one, present here.The line
.claude/hooks/guard-main-checkout.sh:42-44That is a substring match on a path, not a test for a linked worktree. A primary checkout that merely lives under a directory named
worktreesproduces a git-dir matching the pattern, and the guard exits 0 — an unguarded edit into a shared primary checkout, which is the exact failure worktree-first exists to stop.Measured, not argued — and it is depth-dependent
git rev-parse --git-dirprints a RELATIVE git-dir (.git) at a repo's toplevel and an ABSOLUTE one from any subdirectory. Combined with the hook's nearest-existing-ancestor walk, the same unguarded checkout gets opposite verdicts by depth. With a fixture repo atTMP/worktrees/oddrepo:file_pathODD/README.md.git(relative)ODD/brand/new/f.ts.git(relative)ODD/pkg/x.ts/worktrees/ODD/pkg/brand/new/f.ts/worktrees/All four are pinned as cases in the matrix landing on #6451, under a
KNOWN HOLEbanner that names this card and states they are a record of today's behaviour, not of intended behaviour. When this is fixed, the last two flip toblockand that section of the matrix becomes a mechanical edit.Note the fixture path is not exotic:
git worktree add ../repo-taskis the prescribed recipe, and an operator who instead keeps trees under aworktrees/parent directory gets a silently unguarded primary checkout.Shape of the fix (not asserted)
The real question git can answer is whether the git-dir is a linked worktree of some repo, e.g. comparing
--git-diragainst--git-common-dir(they differ exactly in a linked worktree), rather than pattern-matching the path. Whoever picks this up should decide; both repos should move together, and the matrix on #6451 gives the change a red/green.Filed unassigned; no
domain:*or type label set.