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 False
The 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
Describe the bug
A negation in a nested
.gitignorecan never re-include something an ancestor.gitignoreexcluded. 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, andblack --checkpasses in CI without aword about it. rc=0, no warning, no diff.
To Reproduce
git tracks
packages/playground/.vscode/settings.py; Black prunes the directory and never seesit.
keep.pyis the control — neither tool ignores it, so the fixture isn't just ignoringeverything.
Cause
src/black/files.py,_path_is_ignored:The dict is ordered least-specific → most-specific (the comment above it says so), and the loop
returns
Trueon the first match. So an exclusion at the root short-circuits before the nestedfile 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, plusthe path relative to that directory,
pathspecanswers 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-ignoreover 4,373 queries built from the real.gitignoretrees of 43 popular repos. Nine divergences. Eight of them are this bug, in five repos —
facebook/react(packages/.gitignorewith!playground/.vscode),python/cpython(
Platforms/Android/testbed/.gitignorewith!.idea/),supabase/supabase(
docker/.gitignorewith!volumes/functions/deno.json*), and two more. The ninth is apathspecbug I reported separately (cpburnz/python-pathspec#134).So it's rare per-query but concentrated: the projects that use nested
.gitignorenegations hitit 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