Fix GitIgnoreSpec re-including files under an excluded directory (#129) - #132
Fix GitIgnoreSpec re-including files under an excluded directory (#129)#132youdie006 wants to merge 1 commit into
Conversation
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.
|
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 The oracle
Result23 path/pattern pairs,
The nine it fixes: And the controls it leaves alone, which is the part I was actually worried about: #74 ( 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 Reduced, that's the first row of the table above: a broad So: rare in the wild, but not zero, and the one real hit is node's. Worth noting that the plain The one case it doesn't fixThe mechanism, since it lives exactly in the code this PR adds: spec.match_file("src") # True
spec.match_file("src/") # FalseI don't think this blocks the PR — Overlap with #133Orthogonal halves of the same wound, as far as I can measure. #132 fixes file paths under an excluded directory. #133 fixes directory paths — (The slashless forms — 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 |
Fixes #129. Thanks to @eeshsaxena for the clear report and the directory-vs-contents distinction -- it made this straightforward to reproduce.
Problem
GitIgnoreSpecresolves 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"):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 existingutil.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:
build+!keep.logbuild/keep.logbuild/*+!build/keep.logbuild/keep.logbuild/*only excludes the contents (it does not match thebuilddirectory 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:
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._test_backend_factoryhook used by the tests), I added a small_wrap_backendextension point onPathSpec(a no-op by default, overridden byGitIgnoreSpec). 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}totests/test_06_gitignore.py, running across all backends viaparameterize_from_lines. Verified red-green: with the fix reverted thebuild/keep.loganda/keep.logignore cases fail across every backend; with the fix they pass, and thebuild/*re-inclusion case passes both ways. Expected results confirmed againstgit 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.