Skip to content

chore(repo): ignore .worktrees/ so an agent worktree inside the checkout is not untracked - #17468

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-17154-gitignore-worktrees
Sep 10, 2026
Merged

os-litant merged 1 commit into
mainfrom
claude/issue-17154-gitignore-worktrees

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #17154

The defect

.gitignore:129 carried .claude/worktrees/ and nothing else. A linked worktree created at
CHECKOUT/.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 of
untracked path that must never be committed is exactly what it was pointing at: that directory
holds a .git file (not a directory) whose content is gitdir: /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, HEAD 344d4757 before the commit), with the
scratch worktree still live on disk for the "after" reading:

git status --porcelain git check-ignore -v .worktrees
before ?? .worktrees/ exit 1, no output — not ignored
after clean (-uall shows 0 paths matching worktrees) exit 0 — .gitignore:131:.worktrees/

The class test holds on the scratch path: test -f .worktrees/scratch-17154/.git is true (it is a
FILE), and its first bytes are gitdir:. The scratch worktree was removed afterwards
(git worktree remove exit 0; git worktree list back to 4, none inside a checkout).

The entry is narrow, not broad. .worktrees/ is the exact directory name, dot-prefixed and
directory-only, in the same unanchored form as its neighbour .codex/. Measured with
git check-ignore -v --no-index on the final tree:

IGNORED       .worktrees/                    from .gitignore:131:.worktrees/
IGNORED       packages/spec/.worktrees/      from .gitignore:131:.worktrees/
IGNORED       .claude/worktrees/             from .gitignore:129:.claude/worktrees/   (unchanged)
NOT ignored   worktrees/
NOT ignored   my-worktrees/
NOT ignored   .worktrees-backup/
NOT ignored   a.worktrees/
NOT ignored   docs/worktrees/

⛔ 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:

git add -A
  warning: adding embedded git repository: .worktrees/scratch
  exit 0
git diff --cached --name-only   =>  .worktrees/scratch     (1 entry)
git ls-files --stage            =>  160000 3c458b98… 0  .worktrees/scratch

An agent obeying the stop hook stages one gitlink (mode 160000), not the .git file and not
the second checkout's contents. The hazard is real and the magnitude claim is not: what lands is a
submodule-shaped entry with no .gitmodules row, pointing at a commit that exists in one container
only — 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.sh is registered on Edit|Write|NotebookEdit only, and its repo predicate
    explicitly allows a linked worktree. It never sees a git worktree add.
  • guard-main-checkout-bash.sh recognises 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.
    git is not in that list, so git worktree add fails open, by the design its own header
    argues for: "a guard that blocks work it does not understand gets switched off — after which it
    guards nothing."
  • guard-tree-enum.selftest.sh:126 pins an explicit expect allow for
    git worktree add ../objectstack-issue-13305 … — the sibling spelling, with nothing in the guard
    distinguishing an inside-the-checkout one.
  • guard-shared-stash.sh, guard-process-kill.sh and guard-governed-enqueue.sh are about the
    stash, 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:

  • 实际业务需求. Measured, not speculated. The card's own census: 5 registered worktrees in that
    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.
  • 项目长远合理性. The two ends are not substitutes. .gitignore is 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-track into a directory beside the checkout), and
    guard-main-checkout.sh reprints that same recipe. An argv sniffer for git worktree add would be
    a heuristic shell parse bolted onto a rule that is already declared — a workaround, not an
    enforcement point.
  • 防 AI 写代码犯错. This axis decides it. The hazard is not "a worktree exists inside the
    checkout"; it is "an agent is told to commit git plumbing." The ignore line removes the instruction
    at its source — measured above, git status --porcelain is clean with the live worktree still on
    disk, 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 -c wrapper, a wrapper script, a
    relative 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 -A and clean; a hook is enforced by one process on the shapes it happens to
    parse. Prevention is the strictly weaker guarantee here.
  • 创业阶段不扩散需求. A sixth guard, its self-test, its escape-hatch variable and its matcher row
    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 .gitignore only ⇒ not governed ⇒ ordinary seat
review and queue landing. Had the answer been prevent, .claude/hooks/** would have made this a
governed 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:

governed-surface predicate: 0 of 1 path(s) hit the register (5 surfaces, repo-agnostic).
  ✅  NOT governed — ordinary queue landing applies to a PR with exactly this file list.

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:

A path holding a .git file whose content begins gitdir: is a linked worktree. It is one
container's administrative state, never repository content, and must never be committed — whatever
the path is called.

Where that test can live, and where it cannot:

  1. Not in .gitignore. A pathspec cannot express a content predicate. .gitignore can only ever
    close the instance, which is why this PR closes the instance and writes the class down here.
  2. The component that gives the wrong instruction is the user-level stop hook, and it lives outside
    this repository
    (~/.claude/stop-hook-git-check.sh). ⛔ Nothing in this PR, or in any PR against
    this repo, can change it. That is the honest boundary on the card's second end.
  3. A repo-side gate is possible and is not one line. The measurement above makes the detector even
    cheaper than the gitdir: file test — the thing that actually lands is a mode 160000 gitlink
    with no .gitmodules row
    , a one-expression predicate over git diff --cached --name-only or
    git ls-files --stage. Nothing in scripts/, .githooks/ or .github/workflows/ refuses one
    today (160000 appears in the tree only as two skip comments in scripts/check-nul-bytes.mjs). It
    needs a gate script, a self-test, a package.json entry 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 $?).

gate exit its own verdict line
node scripts/check-closing-keyword-parity.mjs 0 check-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 0 ✓ 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 0 ✓ 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-census 0 check-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-bytes 0 check-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-probe 0 OK 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 0 ✓ 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 0 ✓ 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:

Run reconciliation — 7 derived, 7 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 7 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 7 derived famil(ies) accounted for — 7 run, 0 NOT-MEASURED
  (a DERIVED zero — all 7 recorded an exit code and none of them is 3).

Not run locally, by scope: the repo-wide scans (pnpm lint and the rest of the Lint & Repo Gates
farm) 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 — .gitignore is repo-root config, on
the 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 mode 160000 gitlink with no .gitmodules row,
    which is what an agent obeying its stop hook actually stages (measured above: git add -A exit 0
    with a warning: only). Ruled a note rather than a card because it is not a one-line fix — it needs
    a gate script, a self-test, a package.json entry and CI wiring, i.e. a new verification surface
    this card's gate family does not cover. Taker: none currently — no open PR and no queued card
    touches .githooks/pre-commit or adds a check:* in this area, so it is written here for the
    reviewing seat to file if it disagrees with the note-not-card call.
  • noted, not filed: .gitignore had no trailing newline on main (line 129 ended the file
    mid-line; git hash-object 694c9dc6). Appending after it necessarily rewrites that line in the
    diff, which is why a one-entry change reads as 3 insertions(+), 1 deletion(-). Corrected as an
    unavoidable consequence of the append, not as a separate cleanup. Taker: this PR.
  • AGENTS.md is 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 the
    convention 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 published
exports moves; the diff is one ignore entry and one comment line.


Generated by Claude Code

…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 os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 10, 2026 16:43
@os-litant
os-litant enabled auto-merge September 10, 2026 16:43
@os-litant
os-litant added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 8645848 Sep 10, 2026
31 checks passed
@os-litant
os-litant deleted the claude/issue-17154-gitignore-worktrees branch September 10, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xs skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants