Prerequisites
Reproduction url
https://github.com/grahamtt/knip-gitignore-negation-repro
Reproduction access
Description of the issue
Summary: A .gitignore negation (!…) whose basename matches an ignored path un-ignores that path globally in knip, even when the negation targets a different directory. git scopes the negation correctly; knip does not, so knip analyzes files inside a git-ignored directory and reports them (e.g. as unused files).
Reproduce (npm install && npx knip in the linked repo):
Unused files (1)
build/reports/orphan.ts
build/reports/orphan.ts lives inside build/, which .gitignore ignores, so knip should not analyze it.
The trigger is one unrelated line. The .gitignore is:
node_modules/
build/
!foo/build/ <-- remove this line and the false positive disappears
!foo/build/ un-ignores foo/build/ — a non-existent, unrelated path. git still ignores the file either way:
git check-ignore build/reports/orphan.ts
# -> build/reports/orphan.ts (git ignores it)
Delete the !foo/build/ line, rerun npx knip, and there are no unused files.
Scope of the trigger (from bisecting):
- Leaks only when the negation's basename equals an ignored path's basename.
!foo/build/ leaks build/; !completely/unrelated/path/ and !keep.txt do not.
- Independent of the negation's directory prefix or trailing-slash form:
!foo/build/, !x/build, and ignoring as build/** all leak identically.
git check-ignore disagrees with knip in every leaking case.
Where it does not appear to be: the pattern converter. Feeding the same .gitignore through the exported parseAndConvertGitignorePatterns + expandIgnorePatterns, composed into picomatch(ignores, { ignore: unignores }), returns isIgnored("build/reports/orphan.ts") === true with and without the negation. So the flat pattern set looks correct. The divergence seems to arise in the directory-walk / pruning in src/util/glob-core.js, where an unignore with a colliding basename appears to stop build/ from being pruned as a directory (after which the descendant is walked and reported). That last part is a hypothesis; the black-box reproduction above is deterministic.
Expected: knip honors .gitignore with the same scoping git uses, so an unrelated negation does not cause files under an ignored directory to be analyzed.
Versions
Prerequisites
Reproduction url
https://github.com/grahamtt/knip-gitignore-negation-repro
Reproduction access
Description of the issue
Summary: A
.gitignorenegation (!…) whose basename matches an ignored path un-ignores that path globally in knip, even when the negation targets a different directory. git scopes the negation correctly; knip does not, so knip analyzes files inside a git-ignored directory and reports them (e.g. as unused files).Reproduce (
npm install && npx knipin the linked repo):build/reports/orphan.tslives insidebuild/, which.gitignoreignores, so knip should not analyze it.The trigger is one unrelated line. The
.gitignoreis:!foo/build/un-ignoresfoo/build/— a non-existent, unrelated path. git still ignores the file either way:git check-ignore build/reports/orphan.ts # -> build/reports/orphan.ts (git ignores it)Delete the
!foo/build/line, rerunnpx knip, and there are no unused files.Scope of the trigger (from bisecting):
!foo/build/leaksbuild/;!completely/unrelated/path/and!keep.txtdo not.!foo/build/,!x/build, and ignoring asbuild/**all leak identically.git check-ignoredisagrees with knip in every leaking case.Where it does not appear to be: the pattern converter. Feeding the same
.gitignorethrough the exportedparseAndConvertGitignorePatterns+expandIgnorePatterns, composed intopicomatch(ignores, { ignore: unignores }), returnsisIgnored("build/reports/orphan.ts") === truewith and without the negation. So the flat pattern set looks correct. The divergence seems to arise in the directory-walk / pruning insrc/util/glob-core.js, where an unignore with a colliding basename appears to stopbuild/from being pruned as a directory (after which the descendant is walked and reported). That last part is a hypothesis; the black-box reproduction above is deterministic.Expected: knip honors
.gitignorewith the same scoping git uses, so an unrelated negation does not cause files under an ignored directory to be analyzed.Versions