Skip to content

chore: .gitignore covers a symlinked node_modules, not only the directory form (#15763) - #15821

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-15763-gitignore-node-modules-symlink
Sep 5, 2026
Merged

chore: .gitignore covers a symlinked node_modules, not only the directory form (#15763)#15821
baozhoutao merged 1 commit into
mainfrom
claude/issue-15763-gitignore-node-modules-symlink

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15763

.gitignore:2 is node_modules/. The trailing slash makes the pattern match a directory only. In a linked worktree whose node_modules is a symlink to a shared install, git does not see a directory, so the rule does not match and the path is left untracked-and-not-ignored at the repo root.

This adds the slashless form beside it. The existing node_modules/ line is untouched — the diff is the new rule plus its one-line comment, with no modification and no deletion. This repo tracks node_modules in no shape, so covering both forms costs nothing.

Why it is worth a rule rather than a recipe fix: repair 2 (the dispatch recipe returning to AGENTS.md's pnpm install form) treats how the symlink gets created; this treats the consequence. Even with every seat installing properly, one hand-made symlink leaves the hazard back. That hazard is the git add -A class this repo has recorded twice already (#10781, #12583, both closed): an untracked, non-ignored path at the repo root is one careless git add -A away from committing a symlink to an absolute container path.

Reproduction — measured, not read off the diff

A throwaway linked worktree detached at origin/main (d8d27768a), with ln -s /home/user/objectstack/node_modules node_modules. Exit codes captured before any pipe.

BEFORE (base .gitignore)

$ git check-ignore -v node_modules
EXIT=1        # no output — NOT ignored
$ git status --short
?? node_modules

Positive controls in the same tree, same command, same run — so the exit 1 is a reading and not a broken probe:

$ git check-ignore -v .DS_Store   →  .gitignore:19:.DS_Store    .DS_Store    EXIT=0
$ git check-ignore -v .turbo      →  .gitignore:104:.turbo      .turbo       EXIT=0

The derivation, verbatim:

dispatch-gates: change set derived from git - 1 path(s) vs merge base d8d27768a of 'origin/main' and HEAD
  (committed 0, working tree 0, untracked 1; three-dot semantics, never 'origin/main..HEAD')
  - node_modules

Local gates for this card (paste into the dispatch prompt):
  - pnpm --filter @objectstack/spec run check:llms-txt   [lint.yml]   matched via node_modules
      gate source 'node_modules/@objectstack/spec/CHANGELOG.md'

Stronger than the card recorded. The phantom path does not only widen the count — it matches a check family, through a gate source that lives inside node_modules. A dev's "gates I ran" list was being seeded with a family derived from a path that can never appear in any PR.

AFTER (this branch's .gitignore, same worktree, same symlink)

$ git check-ignore -v node_modules
.gitignore:4:node_modules       node_modules
EXIT=0
$ git status --short
M  .gitignore                   # the checked-out fix; no `?? node_modules`

Controls still answer, at their shifted line numbers — itself corroboration that the file really changed under them:

$ git check-ignore -v .DS_Store   →  .gitignore:21:.DS_Store    EXIT=0
$ git check-ignore -v .turbo      →  .gitignore:106:.turbo      EXIT=0

And the derivation counts the real change set only:

dispatch-gates: change set derived from git - 1 path(s) vs merge base d8d27768a of 'origin/main' and HEAD
  (committed 0, working tree 1, untracked 0; three-dot semantics, never 'origin/main..HEAD')
  - .gitignore

No check family names the given paths in its own source, and no workflow's path filter schedules one for them.

The phantom check:llms-txt family is gone.

The directory form is still ignored

Symlink replaced with a real node_modules/ directory containing a file:

$ git check-ignore -v node_modules        →  .gitignore:4:node_modules   node_modules       EXIT=0
$ git check-ignore -v node_modules/foo    →  .gitignore:4:node_modules   node_modules/foo   EXIT=0

It answers line 4, not line 2, because git's last-matching-pattern wins. Line 2 is therefore now redundant in effect. It is kept deliberately, per the ruling, so this diff modifies nothing that other seats share.

A ruling expectation this run falsified

Triage's hint asked that git check-ignore -v node_modules/foo stop reporting beyond a symbolic link after the fix. It does not, and no ignore rule could make it. Measured on this branch, with the symlink in place:

$ git check-ignore -v node_modules/foo
fatal: pathspec 'node_modules/foo' is beyond a symbolic link
EXIT=128

That fatal is git's pathspec traversal refusal — it happens while resolving the path, before ignore rules are ever consulted. The control above proves the mechanism: with a real directory the identical command exits 0. So the beyond a symbolic link reading is a property of the symlink, not of .gitignore, and it is untouched by this PR either way.

dispatch-gates.mjs --self-test under a symlinked node_modules — reported, not changed

Asked for as a measurement. It still refuses, on this branch, with the fix in the tree:

Error: git check-ignore exited 128: fatal: pathspec
  'node_modules/.check-driver-conformance-selftest/src/leftover-probe' is beyond a symbolic link
    at ignoreVerdicts (scripts/pm/dispatch-gates.mjs:7210:11)
    at exposedScratchDirs (scripts/pm/dispatch-gates.mjs:7256:20)
    at selfTest (scripts/pm/dispatch-gates.mjs:19498:25)

Node exits 1 on the uncaught throw; the 128 is the underlying git check-ignore status the message quotes. (The card and the claim both say "exit 128" — that is the inner status, not the script's.)

Same mechanism as above: exposedScratchDirs probes a path inside node_modules, and git refuses to resolve a pathspec through a symlink. No .gitignore rule can reach it. Per the ruling, the self-test is not changed here.

Verification

check result
pnpm check:nul-bytes EXIT=0check-nul-bytes: OK (scanned 7628 text file(s) -- 7628 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes)
pnpm check:refd-timer-probe EXIT=05982 source file(s) swept
pnpm check:watch-hint-literal EXIT=060 declaration(s) across 4 rostered name(s)
node scripts/check-closing-keyword-parity.mjs NOT MEASURED (exit 3: PREREQUISITE NOT MET - the dependency 'yaml' is not installed)
node scripts/check-closing-keyword-parity.mjs --self-test NOT MEASURED (exit 3, same missing yaml)
node scripts/check-comment-mask-corpus.mjs NOT MEASURED (ERR_MODULE_NOT_FOUND: '@typescript-eslint/parser')
pnpm check:driver-memory-census NOT MEASURED (exit 3: ts-parse: PREREQUISITE NOT MET - the dependency 'typescript' is not installed)

The four NOT MEASURED rows are dependency refusals in a worktree with no pnpm install, not findings — each gate says so itself and exits before running a check. They are whole-tree declared families named on every card; CI runs them with deps installed. Named rather than dropped.

Also run:

Changeset

None, skip-changeset. Root .gitignore publishes nothing from any released package, which is exactly the carve-out AGENTS.md states for that label:

A bug fix in a released package takes a patch changeset — never none, and ⛔ never skip-changeset: that label is for a diff that publishes nothing from any released package.

Fleet note

.gitignore is shared by every seat. The change is additive — it removes no coverage, and every path ignored before is still ignored.


🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…tory form (#15763)

`.gitignore:2` is `node_modules/` — the trailing slash makes it match a
DIRECTORY only. In a linked worktree whose `node_modules` is a symlink to a
shared install, the rule does not match: `git check-ignore -v node_modules`
exits 1 and `git status --short` prints `?? node_modules`.

Add the slashless form beside it (the existing line is untouched). This repo
tracks `node_modules` in no shape, so covering both forms costs nothing, and it
closes the `git add -A` hazard recorded twice before (#10781, #12583): an
untracked, non-ignored path at the repo root is one careless `git add -A` away
from committing a symlink to an absolute container path.

Measured effect it also removes: `dispatch-gates.mjs --changed` counts that
untracked path as part of the change set, and even derives a gate family from
it via `node_modules/@objectstack/spec/CHANGELOG.md`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Flipped ready + auto-merge enabled (09:38Z, method: MERGE). All seven required contexts on 9bb5b040f read non-failing: Lint & Repo Gates success, TypeScript Type Check success, Test Core success, Dogfood Regression Gate success, Governed Surface Queue Guard success, Build Core skipped, Temporal Conformance (live PG + MySQL) skipped; no other red. git merge-tree --write-tree origin/main <branch> → EXIT=0 against f141e156b. Watched; on landing #15763 closes.


Generated by Claude Code

@baozhoutao
baozhoutao added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 53d02e9 Sep 5, 2026
31 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15763-gitignore-node-modules-symlink branch September 5, 2026 10:31
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