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 |
|
Thanks for building that harness, and for pointing it at the controls rather than only at the cases the patch was aimed at — the Your point about the oracle is well taken and I think it is the more important half of this comment. I leaned on I re-measured the residual you flagged, against real git rather than taking it from the report, on the
So it reproduces, and your reading of it holds: the two wrong rows are identical on master, and the one row this PR changes in that fixture moves toward git, not away from it. Not a regression. On the mechanism, I agree it is a separate bug, and I would put it one step earlier than the ancestor resolution. Happy to leave that for a separate change, and glad #132 and #133 turn out to be orthogonal — the directory-path half was the piece I knowingly did not touch here. Disclosure: I use AI assistance in my work, and I review and verify everything before it goes out. The measurements above are my own re-runs. |
|
I'm not thrilled with wrapping backends. It looks like a singular segment matches stronger than the equivalent
Perhaps there's a way to add another MARK (regex capture group) and use priority=3 in pathspec/_backends/simple/gitignore.py. |
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.