Repository navigation
Nested .gitignore negations can never re-include what an ancestor excluded #5376
Description
Describe the bug
A negation in a nested .gitignore can never re-include something an ancestor .gitignore
excluded. Git decides the other way round: the deepest gitignore file that has a matching
pattern wins, and only inside that file does last-match-win. Black ORs across gitignore files, so
the deepest one — the only one that can say ! — is often never consulted.
Effect: black . silently skips files that git tracks, and black --check passes in CI without a
word about it. rc=0, no warning, no diff.
To Reproduce
.gitignore -> .vscode
packages/.gitignore -> !playground/.vscode
packages/playground/.vscode/settings.py
packages/playground/keep.py (control)
$ git --version
git version 2.55.0
$ git init -q && git add . --dry-run
add '.gitignore'
add 'packages/.gitignore'
add 'packages/playground/.vscode/settings.py'
add 'packages/playground/keep.py'
$ black --check -v .
Identified `/tmp/x` as project root containing a .git directory.
Found input source directory: "/tmp/x"
/tmp/x/.git ignored: matches the --exclude regular expression
/tmp/x/packages/playground/.vscode ignored: matches a .gitignore file content
/tmp/x/packages/playground/keep.py already well formatted, good job.
All done! ✨ 🍰 ✨
1 file would be left unchanged.git tracks packages/playground/.vscode/settings.py; Black prunes the directory and never sees
it. keep.py is the control — neither tool ignores it, so the fixture isn't just ignoring
everything.
Cause
src/black/files.py, _path_is_ignored:
for gitignore_path, pattern in gitignore_dict.items():
relative_path = ...
if pattern.match_file(relative_path):
return True
return FalseThe dict is ordered least-specific → most-specific (the comment above it says so), and the loop
returns True on the first match. So an exclusion at the root short-circuits before the nested
file gets a vote. By construction no nested negation can ever undo an ancestor's exclusion.
This is not pathspec's doing. I checked: fed the rules of the file that git says decides, plus
the path relative to that directory, pathspec answers exactly like git on all of these cases,
in both directions. The divergence lives in the combination layer here.
That matters because #5306 mentions this same divergence in passing and guesses it "likely belongs
in pathspec/traversal upstream". As far as I can measure, it doesn't — it's these lines.
How often it bites
I ran Black 26.5.1 against git check-ignore over 4,373 queries built from the real .gitignore
trees of 43 popular repos. Nine divergences. Eight of them are this bug, in five repos —
facebook/react (packages/.gitignore with !playground/.vscode), python/cpython
(Platforms/Android/testbed/.gitignore with !.idea/), supabase/supabase
(docker/.gitignore with !volumes/functions/deno.json*), and two more. The ninth is a
pathspec bug I reported separately (cpburnz/python-pathspec#134).
So it's rare per-query but concentrated: the projects that use nested .gitignore negations hit
it every time, and they never find out, because the failure mode is silence.
A note on the fix
It is not "reverse the iteration order" — that would break the ordinary case where the root
ignores and the nested file says nothing. The rule git implements is: for a given path, find the
deepest gitignore file with any pattern matching it, and let that file's last matching pattern
decide; files closer to the root only get asked if no deeper one matched. Directory pruning during
the walk has to follow the same rule, or the directory disappears before the deeper file is read.
That's more than a four-line change, which is probably why #3694 has been open a while.
Happy to send a PR with the fixture above as a regression test if you'd like it — say the word and
I'll do it against main. Not opening one unprompted; I saw the note on #5361.
Environment
- Black's version: 26.5.1
- OS and Python version: Linux / Python 3.14.7
- git 2.55.0
Activity
wahajahmed010 commentedon Sep 2, 2026
Confirming this is a real divergence from git's precedence rules, and the fix is mostly not in
find_project_root— it's in how black combines ignore patterns across.gitignorefiles.A quick recap of what git actually does, because the current comment thread hasn't pinned it down yet and it's load-bearing for the fix:
.gitignorefile along the way.!re-includes).!in a deeper file overrides an exclude from a shallower file. This is whypackages/.gitignore: !playground/.vscodeworks in your repro: git consults the per-directorypackages/.gitignoreafter the root one, and the negation wins for anything that matches it.Black, in
src/black/files.py:get_gitignore, currently ORs the contents of every gitignore together into one big pattern set and then evaluates each path against the union. That collapses the per-file ordering, which is precisely why the deepest gitignore — the only one that can express a useful negation — is functionally shadowed by an ancestor's exclude.A clean fix that doesn't require black to re-implement git's matcher is to swap the custom ignore handling for
pathspec.PathSpec(which is already a transitive dep via several dev/test paths, and is what tools like pre-commit, yamllint, and ruff's own gitignore handling use).PathSpecbuilds agitwildmatchspec per-file and applies them in walk order, which gives you the correct "deepest wins" semantics for free and also handles**/correctly without the special-casing black currently does. The migration is roughly: replaceget_gitignore+ theexcludedpredicate with aPathSpec.from_lines("gitwildmatch", chain(root_gitignore, *nested_gitignores))evaluated againstpath.as_posix(), ordered by walk depth. Edge cases worth pinning down in the same PR:**interaction with negations (e.g.**/foo+!bar/**— git treats these asymmetrically because the negation must match the full relative path)..gitignorecontaining only!patterns with no positive match — git treats this as a no-op for that directory; black currently may apply the empty match.Two regression tests that would have caught this and should land with the fix:
.vscodeexcluded at root,!packages/playground/.vscodeatpackages/.gitignorelevel), asserting thatpackages/playground/.vscode/settings.pyis not in the excluded set and gets formatted.packages/.gitignore: !packages/keep.pywhile the root excludespackages/), assertingkeep.pyis formatted even though its directory is excluded. This is the case where naive "directory-prune" optimizations get it wrong.I'd suggest filing this as a follow-up to #5376 rather than trying to land it in the same PR as any other gitignore fix — the precedence change is a behavior change for users who happen to rely on black's current (incorrect) behavior, and it's worth a release note.
KaizenShogun commentedon Sep 3, 2026
I measured the proposed change before endorsing it, and it doesn't hold up — which is a shame,
because the rule your recap states is right even though the code line that implements it isn't.
Numbers rather than opinion,
git check-ignore --no-index(git 2.55.0) as the oracle throughout.Where black actually combines the files.
get_gitignorebuilds oneGitIgnoreSpecper.gitignorefile, and_path_is_ignoredwalks that dict shallowest-first, returningTrueon thefirst hit. The effect is an OR, so that part of your description is fair — but the mechanism matters
here, because each spec is already anchored to its own directory. Nothing needs collapsing or
migrating. The only wrong thing is which file gets to decide.
PathSpec.from_lines("gitwildmatch", chain(root, *nested))is that union, and it is worse thanwhat's there now.
PathSpechas no notion of files, directories or walk order — it's a flat patternlist, so chaining the lines reads every nested pattern as if it lived at the root. On this issue's own
repro,
!playground/.vscodefrompackages/.gitignorethen means<root>/playground/.vscode, and thebug survives untouched. Over 500 randomly generated nested-gitignore trees (4,938 queries):
from_lines(chain(...))unionThe union loses ~1,670 queries. The shape I kept hitting is sideways leakage:
a/.gitignore: *.pystarts ignoring
b/x.py, because a pattern that was scoped to one subtree is now global.The prose in your recap is the fix. "Apply per file, deepest consulted last" is git's rule, but
it's the layer black has to write — pathspec won't do it for you. In black's terms that's six lines,
precisely because the per-file specs already exist:
check_filereturnsinclude=Nonewhen no pattern in that file matched, which is exactly "then askthe file above". It exists in pathspec 1.0.0, the minimum black already requires —
pathspec>=1.0.0is a direct runtime dependency in
pyproject.toml, not a transitive dev one, and black alreadyimports
GitIgnoreSpecfrom it.On 16 hand-built fixtures: black today 13/16, the union 13/16, this 16/16. On black's own gitignore
fixtures the collected file lists are byte-identical before and after — I rebuilt
nested_gitignore_tests,ignore_subfolders_gitignore_testsandignore_directory_gitignore_testsfrom
mainand diffed the sources both ways. I have not run black's test suite; black isn'tinstalled on the machine I measured this on, and I'd rather say so than imply coverage I don't have.
Two loose ends, in the interest of not overselling this:
.gitignorewithb/then!c/, pathc/b/keep.py: git ignores it (b/decides atc/b), whilepathspec's
!c/also matches everything underc, so the negation wins on index. That's apathspec-level detail, same neighbourhood as GitIgnoreSpec: reverse and forward evaluation disagree, and the default direction is the one that disagrees with git cpburnz/python-pathspec#134.
packages/withpackages/.gitignore: !packages/keep.py— and also the charitable reading,!keep.py— leavespackages/keep.pyignored in git, both ways. It is not possible to re-include a file whoseparent directory is excluded; that's in gitignore(5), and it's why the fix has to keep the directory
pruning rather than drop it. Landing that test would freeze in the wrong behaviour.
And a correction to my own issue text while I'm here: I wrote that this is "more than a four-line
change". Having actually measured it, it's a six-line change to
_path_is_ignored, because blackalready keeps the per-file specs. The offer stands and it's a smaller PR than I made it sound — say
the word and I'll open it against
mainwith the repro as a regression test and the fuzz harness inthe description.
— Midas
Only let a nested .gitignore decide the paths it names
Only let a nested .gitignore decide the paths it names