Repository navigation
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>
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
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…bjectstack-ai#18414) Part of objectstack-ai#17472. Authored by the Claude Code session `session_017ef78bLdybu3AffehKkhfk` (https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk). Nothing in this repository read index modes, so `git add -A` over a nested git repository — a linked worktree, a nested clone, a vendored checkout — staged exactly one entry at mode `160000` at **exit 0** with only a `warning:` line, and every clone afterwards carried a submodule pointer to a commit that exists in no clone of this repository. This adds the gate that refuses it. ⛔ This is **not** the untracked-signal half; PR objectstack-ai#17468 owns that one and closed it by ignoring `.worktrees/`. This is the **stage**, and it is path-blind on purpose: the class generalises past any path, so there is no path list here to fall out of date. ## The shape chosen, and the ones rejected `scripts/check-gitlink-declared.mjs` enumerates the index (`git ls-files --stage -z`) and refuses any entry at mode `160000` that no tracked `.gitmodules` row declares. Root `package.json` gains `check:gitlink-declared` in the house spelling (`--self-test` then the live run), and `.github/workflows/lint.yml` gains an unconditional step in `Lint & Repo Gates`, beside `Raw control-byte guard` — its structural sibling: whole-index population, a hygiene property of what a **clone** receives, and a defect whose only native signal is a warning on a command that exits 0. Four decisions, each with the alternative it beat: 1. **"undeclared", not "no gitlinks at all".** A flat ban is the stronger rule and was rejected: it bans the legitimate case along with the accident, and the two are told apart by a fact every repository with a real submodule already writes down. `git submodule add` writes the declaration and the pointer in one act, so a real submodule passes the day it is added — no exemption, no allowlist, no flag. It also makes the card's acceptance shape possible at all: a flat ban has no passing direction to test. 2. **The declaration is read out of the INDEX**, via `git config --blob :.gitmodules`, not off the working tree. A `.gitmodules` present on disk and never staged would otherwise vouch for a gitlink — and that combination *is* the clone-side hazard: the clone receives the pointer and not the file that explains it. A declaration that does not travel with the commit declares nothing. The self-test pins this direction separately. 3. **Refusing from day one, with the report half as `--list`.** The card suggested "report-only, then refusing". A staged rollout buys time to clear a backlog, and there is no backlog — the tree carries zero gitlinks — so a report-only phase would be a phase in which the gate refuses nothing and finds nothing, and the day it started refusing would be the first day it was ever exercised. `--list` prints every gitlink and how each is judged (including a declaration with no gitlink beside it, reported and never a finding), whether or not anything is a finding. 4. **No `.githooks/pre-commit` wiring.** That is the earliest possible refusal point and it is outside the dispatched file surface (`scripts/`, root `package.json`, `.github/workflows/lint.yml`). The gate is written so the wiring is a one-liner if the maintainers want it: `git()` deliberately does **not** scrub the ambient git environment, so an inherited `GIT_INDEX_FILE` — the index a hook is being asked about — is the index it judges. No new runtime dependency: `git` itself is the `.gitmodules` parser (it is git config syntax, and a second reader of line continuations, quoting and subsection escaping would be a second dialect to keep in step). **The manual floor was not hit.** ## Self-test, both directions, as output The gate ships a two-direction self-test over throwaway repositories under a temp dir — never inside this checkout — driven through the same `scan()` the live run calls. ``` $ node scripts/check-gitlink-declared.mjs --self-test ✓ check-gitlink-declared --self-test: 36 assertions over throwaway git repos (real scan() path) exit=0 ``` Its forward fixture is the card's own measurement, reproduced with a literal `git add -A` rather than a hand-assembled index, and the fixture is asserted before the gate is (a run in which `git add -A` staged nothing would otherwise look exactly like a gate that works). The six batteries and their floors are declared in the script; the floor requires the set of batteries that registered assertions to equal the set declared, so a section that stops running names itself instead of going quiet. The same two directions on the **production** path — the script's own `main()`, its failure text and its exit code — measured in a throwaway repo outside every checkout: ``` $ git add -A # the card's own measurement warning: adding embedded git repository: vendor/thing hint: You've added another git repository inside your current repository. exit=0 $ git ls-files --stage 100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0 readme.md 160000 4707cf6e9b8a1d7cda7df17d731cd4b7066b300d 0 vendor/thing DIRECTION 2 -- a bare gitlink fails $ node scripts/check-gitlink-declared.mjs check-gitlink-declared: 1 index entry is a gitlink that .gitmodules does not declare • vendor/thing -- mode 160000, commit 4707cf6e A mode-160000 index entry is a SUBMODULE POINTER. Staging a nested git ... (remedy text elided here; it is in the script) exit=1 DIRECTION 1 -- the same index, plus the row `git submodule add` writes $ node scripts/check-gitlink-declared.mjs check-gitlink-declared: OK (3 index entries -- 1 gitlink(s) at mode 160000; 1 submodule path(s) declared in .gitmodules; every gitlink is declared). exit=0 ``` The nested repository sits at `vendor/thing` rather than under `.worktrees/` deliberately: the ignore PR objectstack-ai#17468 landed covers that one path, and this gate is about the class. On this repository the live run is: ``` $ pnpm check:gitlink-declared check-gitlink-declared: OK (8726 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare). exit=0 ``` The gitlink count is printed unconditionally, `0` included: a summary that named gitlinks only when it found some would make "there are none" and "I did not look" render identically. ## The card's citation, corrected The card body and its triage comment both state that `160000` *"appears in the tree only as two skip comments in `scripts/check-nul-bytes.mjs`"*. Re-derived at `7358c1c5b` with controls taken from the probed tree itself: | reading | value | | --- | --- | | `git grep 160000` across the tree | **0** — the literal appears nowhere | | firing control `git grep -i gitlink` | **2** — `scripts/check-nul-bytes.mjs:193`, `:521`, both prose skip comments | | firing control `git grep -ic nul scripts/check-nul-bytes.mjs` | **76** | | dark control (a nonsense token) | **0** | | `git grep -i gitmodules` | **0**, and no `.gitmodules` file exists | | `git ls-files --stage` mode histogram | `100644` × 8691, `100755` × 34 — no other mode | The substance holds and holds harder than the card claimed; the citation form does not. ⛔ `160000` cannot be used as a firing control when re-deriving that zero — it gives a double zero. ## Changeset: `skip-changeset`, measured against `files[]` The sole criterion is whether anything **published** moves, so this was measured rather than argued from the path names, over the 83 tracked manifests at `298e245de`: | reading | value | | --- | --- | | tracked `package.json` manifests | 83 | | of those, published (not `private: true`) | 70 | | published packages whose directory is the repo root | **0** | | published packages with no `files[]` (i.e. shipping their whole directory) | **0** | | published `files[]` entries escaping their own package directory (`../` or a leading slash) | **0** | | published `files[]` entries naming `scripts` at all | **0** | | positive control — `@objectstack/spec` `files[]` | `dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json` | The root manifest is `@objectstack/spec-monorepo`, `private: true` — it is never published. Every published package declares a `files[]` and none of them can reach a repo-root path, so none of this PR's three paths can be inside any tarball. ⇒ `skip-changeset`, applied on the PR. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **63** families for this change set; the new gate discovers itself and is placed under the always-runs whole-tree heading with its liveness spelling vouched (`a git ls-files enumeration of the tracked corpus`). All 63 ran, at `bfa686a61` for the sweep and `298e245de` for the two re-runs named below. **58 exit 0.** The other **five exit 3 — `PREREQUISITE NOT MET`, which is NOT MEASURED and neither a pass nor a finding**: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content` and `check:type-check-debt` all read built package output, and no package was built in this worktree. They are derived only because the diff touches the root manifest; this diff adds no package source and moves no `files[]` content, so it cannot move them, and CI runs them after a build. That narrowing is declared here rather than smoothed over. Beyond the derived set: - `pnpm lint` — the **full** repo-wide run, not a narrowed one, re-run on the final commit `298e245de`: `eslint . --no-inline-config --format json` over **6790** files, **0 errors, 0 warnings**, exit 0. The file count is read off eslint's own `--format json` output and the population is eslint's own config resolution, not a guess; this repo's single `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules) for any file, so nothing in this diff can move the verdict on a file it does not touch. - `node scripts/check-ci-filter-parity.mjs --self-test` — exit 0, 47 assertions. - `node scripts/pr-labels.mjs --self-test` — exit 0, `VERDICT: pr-labels self-test PASSED`. - `pnpm check:nul-bytes`, `pnpm check:entry-guard`, `pnpm check:parse-guard` — exit 0 (all three are inside the derived 63). - `pnpm check:pm-dispatch-gates` — exit 0, `dispatch-gates self-test: 1730 cases pass`. Run twice, once per commit; on the final commit `298e245de` the battery took 753.4s on this box. **No verify lock was taken**: nothing here builds or tests a package, so no command needed `scripts/pm/os-verify-lock.sh`. There is no VERDICT line to quote, and that is a fact about this diff rather than a step skipped. ## Acceptance notes - **The gate caught its own author, before it was even wired up.** The first draft of this script declared its `-z` delimiter by writing the backslash-u escape for the NUL byte as a string literal. The editing tool materialised that escape into a raw NUL byte on disk, and the byte-discipline self-scan found it at line 113 — the accident source `check-nul-bytes.mjs`'s header documents, landing on the very file being written *about* a git-plumbing delimiter, and a case that `check:nul-bytes` would have caught at push time had the self-scan not. Fixed the way that header prescribes: built from the byte value with `String.fromCharCode(0)`, never written as a literal anywhere in the file. - **A `.gitmodules` row with no gitlink beside it** is the mirror defect (a declared submodule that is not in the index). It is *reported* by `--list` and deliberately **not** a finding: it is a different subject, and this gate refuses exactly one thing. Noted, not filed — no PR or person is heading for it, and nothing in the tree can produce one today. - **`.githooks/pre-commit` is the earliest refusal point** and is outside this PR's file surface; see decision 4 above for why the gate is nonetheless written to be wired there without a change. - **`summarise()` counts index ENTRIES**, so a conflicted index (the same path at stages 1, 2 and 3) inflates its gitlink count while the finding list stays one row per path. The number a reader acts on is the finding count, and `findOffenders` is pinned on that shape. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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