Skip to content

Nested .gitignore negations can never re-include what an ancestor excluded #5376

Description

@KaizenShogun

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions