Skip to content

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

@yinlianghui

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-44

case "$gitdir" in
  */worktrees/*) exit 0 ;;
esac

That is a substring match on a path, not a test for a linked worktree. A primary checkout that merely lives under a directory named worktrees produces 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-dir prints 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 at TMP/worktrees/oddrepo:

payload file_path ancestor handed to git git-dir verdict today
ODD/README.md repo toplevel .git (relative) block — correct, but only by accident
ODD/brand/new/f.ts resolves up to toplevel .git (relative) block — ditto
ODD/pkg/x.ts a subdirectory absolute, contains /worktrees/ allow — WRONG
ODD/pkg/brand/new/f.ts a subdirectory absolute, contains /worktrees/ allow — WRONG

All four are pinned as cases in the matrix landing on #6451, under a KNOWN HOLE banner 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 to block and that section of the matrix becomes a mechanical edit.

Note the fixture path is not exotic: git worktree add ../repo-task is the prescribed recipe, and an operator who instead keeps trees under a worktrees/ 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-dir against --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.

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · domain:skills / priority:p2 / pm:queue

    Anchor read, not guessed. .claude/hooks/guard-main-checkout.sh ⇒ domain:skills(.claude/** 是治理面)⇒ 草稿 PR + 人工合并,⛔ 无 agent 批准。

    ⭐ 复核 objectui origin/main a472b07(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_path git-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 自己的判据。

    ⭐ 两条交接条件,都不能省

    1. 两个仓要一起动。 本卡是 objectstack-ai/objectstack#11809 的 objectui 副本,而卡面测过:两个钩子的可执行行完全相同(整个 diff 是一个注释块里的 9 行) ⇒ 缺陷同一个。⛔ 只修一个仓,会让两个仓的守卫语义分叉 —— 而这两个钩子的全部价值就在于它们一致。
    2. ⭐ 红/绿已经就位。 finding(hooks): guard-main-checkout.sh is 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

  2. self-assigned this
    on Sep 5, 2026
  3. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    Serial deferral, not a dispatch: pm:queue → pm:blocked behind the same files' in-flight card.
    Blocked-by: #7260

    Fold-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.sh and its self-test) plus guard-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:

    Unlock: when #7260 closes (its objectui PR merges), this card returns to pm:queue and dispatches together with #11809. Assigned to the seat so the wait is owned; this comment is not a claim.


    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    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.sh and guard-main-checkout-bash.sh + .selftest.sh here (the KNOWN HOLE section's two allow cases flip to block and 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-dir differs from its --git-common-dir; no path-spelling match; --absolute-git-dir alone 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 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

  5. claude commented on Sep 5, 2026

    @claude
    Contributor

    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-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 8527fa0352: 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-7259-structural-linked-worktree-test",
    "pr": "#7749",
    "sibling_pr": "objectstack-ai/objectstack#15924",
    "sibling_card": "objectstack-ai/objectstack#11809"
    }


    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