Skip to content

Fix GitIgnoreSpec re-including files under an excluded directory (#129) - #132

Open
youdie006 wants to merge 1 commit into
cpburnz:masterfrom
youdie006:fix/129-reinclude-under-excluded-dir
Open

Fix GitIgnoreSpec re-including files under an excluded directory (#129)#132
youdie006 wants to merge 1 commit into
cpburnz:masterfrom
youdie006:fix/129-reinclude-under-excluded-dir

Conversation

@youdie006

Copy link
Copy Markdown

Fixes #129. Thanks to @eeshsaxena for the clear report and the directory-vs-contents distinction -- it made this straightforward to reproduce.

Problem

GitIgnoreSpec resolves patterns with a flat last-match, so a file-level negation can re-include a file whose parent directory is excluded, which git forbids ("It is not possible to re-include a file if a parent directory of that file is excluded"):

GitIgnoreSpec.from_lines(["build", "!keep.log"]).match_file("build/keep.log")
# -> False, but real `git check-ignore` treats build/keep.log as ignored

Proposed approach (open to a different design)

I want to be upfront that this is a core match-resolution change, so please treat the approach as a proposal -- I am happy to restructure it however you prefer.

The fix wraps the gitignore backend (_AncestorDirBackend). When a file is not already ignored by its own resolution, it walks the file's ancestor directory prefixes and, for each, asks whether that directory is excluded -- resolved as a directory (ancestor + "/") using git's plain last-match order via the existing util.check_match_file. If any ancestor directory is excluded, the file is ignored regardless of a later file-level negation.

Resolving the ancestor as a directory (rather than reusing the leaf-file resolution) is what keeps directory-level re-inclusions working, so this correctly distinguishes:

.gitignore path result
build + !keep.log build/keep.log ignored
build/* + !build/keep.log build/keep.log re-included

build/* only excludes the contents (it does not match the build directory itself), so re-inclusion is preserved -- that case, plus the * + !libfoo + !libfoo/** whitelist idiom (test_08_issue_81), the !*/ "scan all directories" idiom (test_07_issue_74), and !*.yaml/ (test_02_issue_41), all still behave as before.

Notes / tradeoffs I would value your opinion on:

  • The check is O(path-depth) per file, and only runs when the file was not already ignored. It uses the plain per-pattern last-match (check_match_file) for the ancestor directory resolution rather than the compiled re2/hyperscan path, so it is backend-agnostic but does not use the fast combined regex for those extra directory lookups.
  • To apply the wrapper uniformly (including through the internal _test_backend_factory hook used by the tests), I added a small _wrap_backend extension point on PathSpec (a no-op by default, overridden by GitIgnoreSpec). If you would rather fold the check in elsewhere -- e.g. inside each backend, or only in _make_backend -- I am glad to change it.

Tests

Added test_10_issue_129_{a,b,c} to tests/test_06_gitignore.py, running across all backends via parameterize_from_lines. Verified red-green: with the fix reverted the build/keep.log and a/keep.log ignore cases fail across every backend; with the fix they pass, and the build/* re-inclusion case passes both ways. Expected results confirmed against git check-ignore (2.54.0). Full suite (200 passed) and the strict docs build stay green.


This change was prepared with AI assistance and reviewed by me before submission.

GitIgnoreSpec resolves patterns with a flat last-match, so a file-level
negation could re-include a file whose parent directory is excluded, which git
forbids ("It is not possible to re-include a file if a parent directory of that
file is excluded"). For example ["build", "!keep.log"] wrongly treated
build/keep.log as not-ignored, while real git check-ignore ignores it.

Wrap the gitignore backend (_AncestorDirBackend): when a file is not already
ignored by its own resolution, walk the file's ancestor directory prefixes and,
for each, ask whether that directory is excluded, resolved as a directory
(ancestor + "/") using git's plain last-match order via util.check_match_file.
If any ancestor directory is excluded, the file is ignored regardless of a later
file-level negation. Resolving the ancestor as a directory preserves
directory-level re-inclusions, so "build/*" + "!build/keep.log" still
re-includes (build/* excludes only the contents, not the build directory
itself), as do the !libfoo/!libfoo/** and !*/ idioms.

A small _wrap_backend extension point is added on PathSpec (a no-op by default,
overridden by GitIgnoreSpec) so the wrapper applies uniformly, including through
the internal test backend factory.

Fixes cpburnz#129.
@KaizenShogun

Copy link
Copy Markdown

I came at this from the other end — I have #133 open on the same file and wanted to know whether the two collide. They don't, and the harness I built to find that out says something useful about this PR, so here it is. Everything below was re-measured today against a99b129 on base 6568072; the scripts are boring enough to re-run.

The oracle

git check-ignore isn't a strong enough oracle for this particular bug. GitIgnoreSpec's own docstring says git "allows including files from excluded directories which directly contradicts the documentation" — so if that were true for these shapes, check-ignore would be the thing that's wrong and the current behaviour would be right. To rule that out I used a harder one: write the files, git add -A, and ask git ls-files. What git actually puts in the index is what a user means by "not ignored". Across the 23 pairs below, check-ignore, add and status --porcelain --ignored agree with each other on all 23, so the deviation the docstring describes doesn't apply to these shapes.

Result

23 path/pattern pairs, GitIgnoreSpec.match_file against that oracle:

tree agrees with git
master 13 / 23
master + #132 22 / 23

The nine it fixes:

!**/node_modules/**  +  /node_modules      ->  node_modules/x.txt
!**/foo/**           +  foo/               ->  foo/x.txt, a/foo/x.txt
*  +  !src/**        +  src/generated/     ->  src/generated/x.py
!src/**              +  src/dist/          ->  src/dist/x.py
!**/*.md             +  docs/              ->  docs/a.md
build                +  !keep.log          ->  build/keep.log
a                    +  !keep.log          ->  a/keep.log
dir/                 +  !dir/file.txt      ->  dir/file.txt

And the controls it leaves alone, which is the part I was actually worried about: #74 (* + !*/), #81 (* + !libfoo + !libfoo/**), #41 (*.yaml + !*.yaml/), and build/* + !build/keep.md, which must stay re-included. Upstream suite: 197 OK → 200 OK.

Does this happen outside a test fixture?

Fair question to ask of any matcher bug, so I checked before believing my own fixtures. I pulled the real .gitignore of 43 widely-used repos (CPython, Django, React, Vue, Kubernetes, Rust, TensorFlow, VS Code, …), generated candidate paths from each file's own lines — 2012 of them — and compared against git. On master, four paths in two repos disagree. Two of them are real:

nodejs/node
  deps/npm/node_modules/.bin/x.txt        git: ignored    GitIgnoreSpec: NOT ignored
  deps/npm/node_modules/.bin/pkg/x.txt    git: ignored    GitIgnoreSpec: NOT ignored

Reduced, that's the first row of the table above: a broad !**/node_modules/** earlier in the file, a directory exclusion later. #132 fixes both. The other two are an artifact of my own path generator, and I'd rather flag it than have it read as a second finding: pandas' .gitignore has *\#*\#, my generator concretises it into a path containing literal backslashes, and GitIgnoreSpec calls that ignored where git doesn't. It's unchanged with or without this PR, so it's noise here (possibly worth its own look at escape handling one day, on a shape nobody writes).

So: rare in the wild, but not zero, and the one real hit is node's. Worth noting that the plain PathSpec (last-match-wins) gets that case right and GitIgnoreSpec gets it wrong on master, which is a slightly awkward place for the gitignore-specific class to be — after #132 they agree.

The one case it doesn't fix

.gitignore:  *  /  !src/**  /  src/generated/
src/a.py        git: ignored    with #132: NOT ignored

The mechanism, since it lives exactly in the code this PR adds: !src/** compiles to the regex ^src/, which matches the directory path src/. #132 resolves each ancestor as ancestor + "/", so that negation matches the ancestor itself and src comes out not-excluded. git disagrees — git check-ignore -v src/ blames line 1, *, because src/** requires something after the slash and never matches src itself. The split is visible from outside:

spec.match_file("src")   # True
spec.match_file("src/")  # False

I don't think this blocks the PR — master gets that case wrong too, so it's not a regression — but if you want a test for it, the ancestor resolution is where it would have to be fixed, and it's arguably a separate bug in how src/** compiles.

Overlap with #133

Orthogonal halves of the same wound, as far as I can measure. #132 fixes file paths under an excluded directory. #133 fixes directory paths — match_file('sub/') under the * + !*/ whitelist idiom, which #132 leaves untouched (still reports sub/ as ignored where git doesn't). Applied together on master, either order, they apply cleanly on each other: 202 tests OK, 22/23 on the file oracle above, and the trailing-slash directory queries go from wrong to right:

'*' + '!*/' + '!*.py',  git ignores none of these:
                     master   +#132    +#133   +both
match_file('sub/')     True    True    False   False
match_file('sub/d/')   True    True    False   False

(The slashless forms — match_file('sub') — still say True in every tree. That one I'd call caller error rather than a bug: without the trailing slash there's nothing in the string that says "directory", which is why black and friends append it before asking.)

Nice work on the report, @eeshsaxena, and on the directory-vs-contents distinction — that framing is what made this measurable. @youdie006, the ancestor-as-directory resolution is the right call; the residual above is the price of resolving it with a trailing slash, not of the approach.

— Midas

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitIgnoreSpec re-includes files under an excluded directory (violates git's parent-exclusion rule)

2 participants