chore(repo): ignore .worktrees/ so an agent worktree inside the checkout is not untracked - #17468
Merged
Merged
Conversation
…ckout is not untracked
`.gitignore:129` carried `.claude/worktrees/` and nothing else, so a linked
worktree created at `<checkout>/.worktrees/NAME` — one of five registered
worktrees in a live container chose that path — showed up as `?? .worktrees/`.
A stop hook that asks for untracked files to be committed then points at the
one kind of path that must never be committed: the directory holds a `.git`
FILE whose content is `gitdir: /abs/path/.git/worktrees/NAME`, an absolute
pointer into one container's administrative state, plus a whole second
checkout of the repository.
Measured in a dedicated worktree, before and after:
git worktree add .worktrees/scratch HEAD
git status --porcelain before: `?? .worktrees/` after: clean
git check-ignore -v .worktrees before: exit 1 (no match) after: exit 0,
`.gitignore:131:.worktrees/`
The entry is the exact directory name, dot-prefixed and directory-only, in the
same unanchored form as its neighbour `.codex/`. It matches `.worktrees/` at
any depth and nothing else: `worktrees/`, `my-worktrees/`, `.worktrees-backup/`
and `docs/worktrees/` all still read as not ignored.
Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 10, 2026
os-litant
marked this pull request as ready for review
September 10, 2026 16:43
os-litant
enabled auto-merge
September 10, 2026 16:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17154
The defect
.gitignore:129carried.claude/worktrees/and nothing else. A linked worktree created atCHECKOUT/.worktrees/NAME— inside the shared primary checkout — therefore showed up as?? .worktrees/, and the session stop hook then asked for it to be committed. The one kind ofuntracked path that must never be committed is exactly what it was pointing at: that directory
holds a
.gitfile (not a directory) whose content isgitdir: /abs/path/.git/worktrees/NAME,plus a whole second checkout of the repository.
This PR adds one entry beside :129, with one comment line naming why.
Repro, before and after
Run in a dedicated worktree (
objectstack-issue-17154, HEAD344d4757before the commit), with thescratch worktree still live on disk for the "after" reading:
git status --porcelaingit check-ignore -v .worktrees?? .worktrees/-uallshows 0 paths matchingworktrees).gitignore:131:.worktrees/The class test holds on the scratch path:
test -f .worktrees/scratch-17154/.gitis true (it is aFILE), and its first bytes are
gitdir:. The scratch worktree was removed afterwards(
git worktree removeexit 0;git worktree listback to 4, none inside a checkout).The entry is narrow, not broad.
.worktrees/is the exact directory name, dot-prefixed anddirectory-only, in the same unanchored form as its neighbour
.codex/. Measured withgit check-ignore -v --no-indexon the final tree:⛔ No
*worktrees*.One correction to the card, measured
The card and comment 5603016615 both describe the bad commit as putting the
gitdir:pointer — and"81,839 files" — into the repository. Probed in a throwaway repo outside every checkout, that is not
what git does:
An agent obeying the stop hook stages one gitlink (mode
160000), not the.gitfile and notthe second checkout's contents. The hazard is real and the magnitude claim is not: what lands is a
submodule-shaped entry with no
.gitmodulesrow, pointing at a commit that exists in one containeronly — every clone and every CI checkout then carries a phantom path. And git reports it as
warning:at exit 0, which is precisely the shape an agent does not stop on.Ignore or prevent — the judgement
Measured first: nothing in this repo prevents it today. All six PreToolUse hooks were read
against
.claude/settings.json's matchers:guard-main-checkout.shis registered onEdit|Write|NotebookEditonly, and its repo predicateexplicitly allows a linked worktree. It never sees a
git worktree add.guard-main-checkout-bash.shrecognises a write target only in the two redirection operators(greater-than, and the doubled form) and in
sed -i,perl -i,tee,cp,mv,rm,touch.gitis not in that list, sogit worktree addfails open, by the design its own headerargues for: "a guard that blocks work it does not understand gets switched off — after which it
guards nothing."
guard-tree-enum.selftest.sh:126pins an explicitexpect allowforgit worktree add ../objectstack-issue-13305 …— the sibling spelling, with nothing in the guarddistinguishing an inside-the-checkout one.
guard-shared-stash.sh,guard-process-kill.shandguard-governed-enqueue.share about thestash, process kills and the merge queue.
⇒ the triage clause "if the guard hooks already forbid it elsewhere, make the two agree" has
nothing to reconcile: there is no existing prevention for this ignore line to contradict.
Decision: ignore, not prevent. On the four axes:
container, 4 outside, 1 inside. This container while writing the PR: 4 registered, 0 inside. So the
inside spelling is a one-in-five outlier at its worst reading. The ignore line closes it at zero
cost and at any frequency; a preventive hook would have to be right about every legitimate
git worktree add, including the ones the guards' own self-tests perform inside temp fixture repos.There is no named demand for prevention beyond this single occurrence.
.gitignoreis the declaration surface for"git must not treat this as repo content" — contract-first, at the layer that owns the contract.
Where an agent puts its worktree is a convention question AGENTS.md Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11 already
owns in prose (it spells the sibling path,
--no-trackinto a directory beside the checkout), andguard-main-checkout.shreprints that same recipe. An argv sniffer forgit worktree addwould bea heuristic shell parse bolted onto a rule that is already declared — a workaround, not an
enforcement point.
checkout"; it is "an agent is told to commit git plumbing." The ignore line removes the instruction
at its source — measured above,
git status --porcelainis clean with the live worktree still ondisk, so the stop hook never fires and no agent is walked toward the bad commit. A prevention hook
would not remove the instruction: it would harden one spelling while every documented fail-open
hole of the existing Bash guard (a variable-expanded path, a
bash -cwrapper, a wrapper script, arelative target with no cwd in the payload) still creates the path — and would then do so with no
ignore line behind it. 声明即强制: the ignore is enforced by git's own pathspec engine on every
status,add -Aandclean; a hook is enforced by one process on the shapes it happens toparse. Prevention is the strictly weaker guarantee here.
is a permanent maintenance surface bought with one measured occurrence. 「我们是一个创业项目,应该
先专注于核心能力」 — the core capability is that parallel agents do not commit each other's
plumbing, and one line delivers it. Nor is anything staged: the ignore lands complete, with no
window left open for a follow-up hook.
Consequence for the terminal: the diff is
.gitignoreonly ⇒ not governed ⇒ ordinary seatreview and queue landing. Had the answer been prevent,
.claude/hooks/**would have made this agoverned PR that stays draft for the human terminal.
node scripts/pm/check-governed-merges.mjs --test .gitignore(file list taken three-dot,git diff --name-only origin/main...HEAD) — exit 0:The class answer
Prior art #11440 and #10781 were both fixed by path, which is why this recurs. The class, stated as
the mechanical test the card asks for:
Where that test can live, and where it cannot:
.gitignore. A pathspec cannot express a content predicate..gitignorecan only everclose the instance, which is why this PR closes the instance and writes the class down here.
this repository (
~/.claude/stop-hook-git-check.sh). ⛔ Nothing in this PR, or in any PR againstthis repo, can change it. That is the honest boundary on the card's second end.
cheaper than the
gitdir:file test — the thing that actually lands is a mode160000gitlinkwith no
.gitmodulesrow, a one-expression predicate overgit diff --cached --name-onlyorgit ls-files --stage. Nothing inscripts/,.githooks/or.github/workflows/refuses onetoday (
160000appears in the tree only as two skip comments inscripts/check-nul-bytes.mjs). Itneeds a gate script, a self-test, a
package.jsonentry and CI wiring — a new verification surface— so it is recorded under Acceptance notes rather than smuggled into this PR.
Verification
Gate families derived from the final diff, in the worktree, at
5a1408d6:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— 1 path(
.gitignore), 7 commands, all declared whole-tree. Every exit code captured before any pipe(redirect to a log, then read
$?).node scripts/check-closing-keyword-parity.mjscheck-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 8286 tracked file(s), all registered).node scripts/check-closing-keyword-parity.mjs --self-test✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.node scripts/check-comment-mask-corpus.mjs✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 6567 files, 0 disagree, 0 unparseable, 60.1s (comparator self-test: 26 cases pass).pnpm check:driver-memory-censuscheck-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers".pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8279 text file(s) -- 8279 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).pnpm check:refd-timer-probeOK check-refd-timer-probe: 6562 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.pnpm check:watch-hint-literal✓ check-watch-hint-literal: 69 declaration(s) across 4 rostered name(s) … every rostered name non-empty, and no unrostered spelling of the idiom in the tree.pnpm check:pm-governed-merges✓ check-governed-merges --self-test: 274 assertions … live: the real generator declared 9 output(s) and certified this tree(dispatch-named; outside this card's derivation)Reconciliation —
node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack, exit 0:Not run locally, by scope: the repo-wide scans (
pnpm lintand the rest of theLint & Repo Gatesfarm) are CI's run, not this seat's; the 14 pending-changeset families do not apply (see below); the 49
artifact-roster families are scored silent for every card in the tree and none of their rosters sits in
a directory this path is in.
Changeset: none,
skip-changeset. Nothing published moves —.gitignoreis repo-root config, onthe fast track (
docs/adr/**·.claude/**·scripts/pm/**· repo-root config · private packages ·comments), and no package's
files[]ships it. The label is applied on this PR.Acceptance notes
noted, not filed:no repo-side gate refuses a mode160000gitlink with no.gitmodulesrow,which is what an agent obeying its stop hook actually stages (measured above:
git add -Aexit 0with a
warning:only). Ruled a note rather than a card because it is not a one-line fix — it needsa gate script, a self-test, a
package.jsonentry and CI wiring, i.e. a new verification surfacethis card's gate family does not cover. Taker: none currently — no open PR and no queued card
touches
.githooks/pre-commitor adds acheck:*in this area, so it is written here for thereviewing seat to file if it disagrees with the note-not-card call.
noted, not filed:.gitignorehad no trailing newline onmain(line 129 ended the filemid-line;
git hash-object694c9dc6). Appending after it necessarily rewrites that line in thediff, which is why a one-entry change reads as
3 insertions(+), 1 deletion(-). Corrected as anunavoidable consequence of the append, not as a separate cleanup. Taker: this PR.
AGENTS.mdis off this card's file surface (PR docs(agents): a PR declaring clause-② grades its changeset at least minor #17415 holds it, at the human terminal), so theconvention half the card raises — "whatever guidance leaves the path ambiguous" — is not touched
here. Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11 already spells the sibling path; nothing in this PR weakens it.
Clause-②: no— repository hygiene only: no accept set, no public surface, no spec or publishedexportsmoves; the diff is one ignore entry and one comment line.Generated by Claude Code