Skip to content

🐛 Unrelated .gitignore negation un-ignores a sibling path globally #1891

Description

@grahamtt

Prerequisites

Reproduction url

https://github.com/grahamtt/knip-gitignore-negation-repro

Reproduction access

  • I've made sure the reproduction is publicly accessible

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

  • knip 6.27.0
  • node 24.16.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions