| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
|
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 oraclegit 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. Result23 path/pattern pairs, GitIgnoreSpec.match_file against that oracle:
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/") # FalseI 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 #133Orthogonal 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 |
Sorry, something went wrong.
|
Thanks for building that harness, and for pointing it at the controls rather than only at the cases the patch was aimed at — the #74/#81/#41 and build/* + !build/keep.md set is exactly what I would have wanted checked. Your point about the oracle is well taken and I think it is the more important half of this comment. I leaned on git check-ignore, and you are right that GitIgnoreSpec's own docstring makes that circular for this bug class: if git really did re-include out of excluded directories, check-ignore would be the thing to distrust. git add -A + git ls-files is not subject to that objection, since it reports what git actually decided to track. I will use that in future work on this file. I re-measured the residual you flagged, against real git rather than taking it from the report, on the * / !src/** / src/generated/ fixture:
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. !src/** compiles to the regex ^src/, which matches the directory path src/ itself. gitignore(5) says a trailing /** "matches everything inside", i.e. abc/** matches files inside abc, so that pattern should not match abc/ at all — the regex wants something after the slash. Real git agrees: it blames * for src/, meaning !src/** never re-included the directory. Fixing the compilation of dir/** would make the ancestor lookup correct for free and would not need the ancestor code to special-case anything, which is why I would rather not paper over it inside the ancestor resolution this PR adds. 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. |
Sorry, something went wrong.
|
I'm not thrilled with wrapping backends. It looks like a singular segment matches stronger than the equivalent seg/* or seg/**. See this table:
Perhaps there's a way to add another MARK (regex capture group) and use priority=3 in pathspec/_backends/simple/gitignore.py. |
Sorry, something went wrong.
|
Dropping the wrapper — agreed, it does not belong there. I tried the priority = 3 route you First, your table reproduces exactly. I checked it against what git actually tracks rather than build + !ignore.log build/ignore.log ignored build/* + !keep.log build/keep.log tracked build/** + !keep.log build/keep.log tracked build + !build/ignore.log build/ignore.log ignored build/* + !build/keep.log build/keep.log tracked build/** + !build/keep.log build/keep.log tracked Why a third priority alone does not close itGiving an excluded _DIR_MARK match priority = 3 fixes your six rows, but breaks three existing
And a fourth case shows why order-sensitive patching does not work either:
So the ancestor's resolved state gates whether a file negation may apply, while file patterns What does work, in one pass and no wrapperTrack the two answers separately inside the existing loop: a _DIR_MARK match decides the if match.match.groupdict().get(_DIR_MARK):
if dir_include is None or not is_reversed:
dir_include, dir_index = include, index
elif file_include is None or not is_reversed:
file_include, file_index = include, index
...
if dir_include:
return (dir_include, dir_index)
elif file_include is not None:
return (file_include, file_index)
else:
return (dir_include, dir_index)match_file gets shorter — the priority ladder, the is_reversed split and the early break all ResultsThe whole suite passes unmodified — 204 passed, 245 subtests, including the three above and I have this on top of the current _backends layout rather than the file my PR was opened |
Sorry, something went wrong.
|
@youdie006 Thanks for the evaluation. Yeah, force push to replace the current #132 pull. The one pass with no wrapper is much cleaner. |
Sorry, something went wrong.
A file negation could re-include a file whose parent directory is excluded, which git forbids. The three gitignore backends resolved every match through one priority ladder, so a later file pattern always outranked an earlier directory exclusion. Track two answers instead: a directory-marker match decides the ancestor, every other match decides the file. An excluded ancestor wins; otherwise the file answer stands. The priority field, the is_reversed split and the early break all collapse into the two accumulators, since last-match-wins is first-match-wins in reverse order. Fixes cpburnz#129.
|
Force-pushed. The wrapper and the _wrap_backend hook are gone; this is now the one-pass version on top of current master. Each backend keeps two accumulators instead of one priority ladder: a _DIR_MARK match decides the ancestor, every other match decides the file. An excluded ancestor wins, otherwise the file answer stands. The priority field, the is_reversed split and the early break all fall out, since last-match-wins is first-match-wins in reverse order. All three backends, since re2 and hyperscan share the same ladder: pathspec/_backends/hyperscan/gitignore.py | 54 +++++++++++----------- pathspec/_backends/re2/gitignore.py | 45 +++++++++--------- pathspec/_backends/simple/gitignore.py | 51 +++++++++------------ tests/test_06_gitignore.py | 76 +++++++++++++++++++++++++++++++ Against git truth (git init, write .gitignore, git add -A, git ls-files --error-unmatch), on your six rows plus the ancestor-re-inclusion cases and the two dir-negation controls: master this branch build + !ignore.log tracked ignored <- git: ignored build + !build/ignore.log tracked ignored <- git: ignored build/ + !ignore.log tracked ignored <- git: ignored build + !keep.log tracked ignored <- git: ignored a + !keep.log tracked ignored <- git: ignored a + !a/b/keep.log tracked ignored <- git: ignored build/* + !build/keep.log tracked tracked build/** + !build/keep.log tracked tracked build + !build/ + !build/keep.log tracked tracked build + !*/ + !keep.log tracked tracked * + !build + !build/keep.log tracked tracked *.txt + !test1/ ignored ignored *.yaml + !*.yaml/ tracked tracked 9/15 -> 15/15. Tests: three cases appended to test_06_gitignore.py, using parameterize_from_lines so they run on every backend and configuration. Reverting only pathspec/ and keeping them fails 16 subtests across simple/re2/hyperscan in forward, reverse and shuffle order; with the change, 208 passed, 551 subtests passed. I installed google-re2 and hyperscan locally, so those subtests really ran rather than skipping. |
Sorry, something went wrong.
|
I ran the force-pushed one-pass version (07ce100) against an independent conformance bench, because a change to core match resolution deserves a number that doesn't come from the test suite it ships with. The bench is 9,852 (.gitignore, path) cases harvested from 43 real repositories, with git check-ignore as the oracle: KaizenShogun/gitignore-conformance. Failures against git, GitIgnoreSpec, same corpus:
The three backends agreeing at the same six is itself worth something — on master they don't. As sets, not counts: 64 fixed, 1 new. The 64 are the real-world shape of #129 (.idea/ + !.idea/runConfigurations/ in elasticsearch, .claude/ re-inclusions in langchain, 15 paths under Debug/, Release/ and _UpgradeReport_Files/ in nodejs/node). It also fixes the case I raised in #134 — !.clang-format + Debug/ over Debug/.clang-format — and fixes it in re2 and hyperscan too, which my patch there did not touch. The one regression, minimised to two lines and arbitrated with git check-ignore: .gitignore: .*
!**/node_modules/**
path: vendor/deps/npm/node_modules/.bin/x.txt
git: not ignored
#132: ignored (master: not ignored, correct)
.* excludes the ancestor .bin, so the new ancestor rule fires — but the later negation re-includes node_modules/**, and that includes the ancestor itself. The parent-exclusion rule only applies while the parent is still excluded at the end of resolution. This is the mirror image of #129 and I'd expect it to be an ordering question inside the ancestor accumulator rather than a design problem. It comes from nodejs/node's real .gitignore; the reduction above is mine. For completeness, the 5 remaining failures are pre-existing on master and unrelated to this PR: all are directory paths against X/** patterns (.settings/ in kubernetes, .superset/ in supabase, three in tensorflow) where git does not ignore the directory itself. — Midas |
Sorry, something went wrong.
|
@KaizenShogun Thanks for the supporting eval! Can you open a new issue for the 1 new and 5 remaining failures? It's fine to combine them all into one issue. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
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"):
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:
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:
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.