Repository navigation
A negation is inherited by the contents of the directory it re-includes #26
Description
Activity
Correction to my own report: I said the 8 divergences were all from supabase's
docker/.gitignore. Half of them aren't — 4 come fromapps/docs/.gitignore, where the pattern has a different shape. So this doesn't need theX/**form:.gitignore: **/*/generated/**/* !**/*/generated/keepdirwith
sample/generated/keepdir/an actual directory:path git 2.55.0 this library (main, both merges in) sample/generated/keepdirnot ignored not ignored sample/generated/keepdir/ignored not ignored sample/generated/keepdir/fileignored not ignored git check-ignore -vblames line 1 for the last two, andgit add -Astages nothing under it. In supabase the re-included name is.gitkeep; the behaviour is the same with a plain directory name, so it isn't about the dot or the extension. Count of 8 stands, the attribution didn't.Separately, now that #22 and #24 are in: on the same 8,953 nested-
.gitignorequeries,maingoes from 27 divergences to 12, and the 12 are a subset of the 27 — nothing regressed. Those 12 are exactly this issue (8) and #25 (4).Cause:
appendTrailingDoubleStar(gitignore.go:594) appends an implicit**to every pattern that
isn'tdirOnly, so!gen/keepdircompiles asgen/keepdir/**and matchesgen/keepdir/f.txt.
Not the descendant loop at :429, and not theliteralSuffixfast-reject, which never fires here
becauseextractLiteralSuffixreturns""when the last segment is**.Repro, no repo needed:
gen/**/* !gen/keepdirwith
gen/keepdir/a real directory:path git 2.55.0 HEAD gen/keepdir/not ignored not ignored gen/keepdir/f.txtignored ( gen/**/*)not ignored MatchDetailblames!gen/keepdirfor the second row. Same with!gen/keepdir/.Skipping the implicit
**for negations is three lines and fixes 10, breaks 66 (12 -> 68
divergences over 8,953 queries). Removing the implicit**outright breaks 1,828.What breaks, from angular's
.gitignore:.vscode/* !.vscode/settings.jsonWith
settings.jsonas a directory, git says.vscode/settings.json/keepis not ignored:
.vscode/*is one segment deep. The library gets that right today because of the implicit**on
the negation, which stands in for the ancestor walk the matcher doesn't do. Take it away and
.vscode/*'s own implicit**matches the file.So
match()decides per pattern and git decides per directory, descending. The two cases that need
telling apart are "ancestor re-included, nothing inside matches" (not ignored) and "ancestor
re-included, something inside matches an exclusion" (ignored), and without walking the path's
ancestors they look identical.That's a design call, so no PR. Say the word and I'll measure the ancestor walk.
Bench and corpus: https://github.com/KaizenShogun/gitignore-conformance (33 repos' rule files,
git check-ignore -v --no-indexas the oracle).Correction to my last comment. Dropping the implicit
**from negations isn't the fix, but not
because the**is right: it stands in for an ancestor walk the matcher doesn't do. Add the walk
and the**can go entirely.match(gitignore.go:353) asks one question per path. git asks about every ancestor first, since it
never descends into an excluded directory (#25). The implicit**fakes the descent well
enough for exclusions, which is why removing it alone breaks 66 cases.Measured on top of
main, which is at 12 divergences / 8,953 queries after #22 and #24:build divergences vs mainancestor walk only 5 fixes 8, breaks 1 ancestor walk + no **on negations67 fixes 12, breaks 67 ancestor walk + no implicit **at all3 fixes 12, breaks 3 Row 2 is the patch I proposed last time, and it's the worst of the three: exclusions keep the extra
reach, negations lose it.The walk:
func (m *Matcher) match(relPath string, isDir bool) bool { pathSegs := strings.Split(relPath, "/") for i := 1; i < len(pathSegs); i++ { if m.matchSegs(pathSegs[:i], true) { return true } } return m.matchSegs(pathSegs, isDir) }
with the old body moved into
matchSegs(pathSegs []string, isDir bool)and
appendTrailingDoubleStarreturningsegsunchanged.It fails three tests in your suite. I ran all three against
git check-ignore2.55.0 rather than
assume they were wrong, and all three assert the opposite of git:test rules path test expects git 2.55.0 TestMatchCannotReincludeUnderExcludedParentdir/+!dir/important.txtdir/important.txtnot ignored ignored, by dir/TestMatchNegationSubdirectoryFilterabc+!abc/babc/b/b.jsnot ignored ignored, by abcTestMatchDoubleStarSlash**/a/bnot ignored ignored, by **/The first quotes gitignore(5), "It is not possible to re-include a file if a parent directory of
that file is excluded", and then asserts the library re-includes anyway, with a comment that
callersSkipDiron excluded directories so they never ask. True for a walker, which is why the gap
is invisible fromWalk, but not forMatch/MatchPathas public API: go-git is looking at
delegating to this library (go-git/go-git#877), and 7 of itsStatus()divergences come from these
two mechanisms, 6 from #26 and 1 from #25. The other two tests are inherited from
sabhiram/go-gitignore.The 3 remaining divergences are unrelated to either issue: two are
/node_modulesin a nested
.gitignoreunder a directory git prunes differently, one is a!volumes/functions/deno.json*
negation whose siblingdeno.jsonsampleI still owe you a look at.No PR, since it changes three of your tests and that's your call. If you want it, tell me which
shape and I'll justify each test change againstcheck-ignore.Bench: https://github.com/KaizenShogun/gitignore-conformance (33 repos' rule files, 8,953 questions,
git 2.55.0 as the oracle).Again it would be great to rewrite these to reduce the claudisms, would make it much easier to follow
Rewritten in place, both of the long ones. Same numbers, same code, less prose around them. If it's
still heavy, say so and I'll keep cutting.- added a commit that references this issue
on Sep 12, 2026 Thanks for the analysis and the bench numbers. Went with the parent-directory check and dropped the implicit trailing
**entirely, plus the/**-> dir-only rewrite and the descendant loop, sincefoo/**matchingfoo/itself was a separate bug that the rewrite caused. #27 has that along with agit check-ignoreconformance suite and a fuzz test; the three tests you flagged are corrected there. Would be useful to run your 8,953-query bench against that branch if you have a moment.
Re-including a directory says nothing about what is inside it, but the negation is inherited by
the contents:
!a/bre-includesa/b;a/b/keepis then decided by the lines that match it, and the only onethat does is
a/**.matchPatternalready draws exactly this line for dir-only patterns —"negations don't inherit downwards", gitignore.go:415 — and the plain-pattern path below it does
not.
Adding the trailing slash to the negation moves both answers, in opposite directions:
Found by an outside bench (33 repos' real rule files, 8,953 nested-
.gitignorequeries,git check-ignoreas the oracle), where it accounts for 8 divergences, all from supabase'sdocker/.gitignore:volumes/functions/main/**followed by!volumes/functions/main/index.ts,and
volumes/functions/**followed by!volumes/functions/deno.json*. Separate cause from #23 —it survives removing the
literalSuffixfast-reject entirely.