check-ignore: a negation is dropped when an ancestor matches a `dir/**` pattern
Bug Report
Description
A ! line stops re-including when an ancestor directory is matched by a dir/** pattern. git
re-includes in that situation, because X/** excludes the contents of X and not X itself, so
the "you cannot re-include under an excluded directory" rule never fires.
Reproduce
git init -q . && dvc init -q
mkdir -p volumes/functions
touch volumes/functions/deno.json volumes/functions/other.py
printf 'volumes/functions/**\n!volumes/functions/deno.json\n' | tee .dvcignore > .gitignore
git check-ignore -q volumes/functions/deno.json && echo "git: ignored" || echo "git: NOT ignored"
dvc check-ignore -q volumes/functions/deno.json && echo "dvc: ignored" || echo "dvc: NOT ignored"volumes/functions/deno.json git: NOT ignored dvc: ignored <-- diverges
volumes/functions/other.py git: ignored dvc: ignoreddvc 3.67.1, pathspec 1.1.1, git 2.55.0, Python 3.14, Linux. The same verdict comes out of the
library (DvcIgnoreFilter.is_ignored_file), so it is not a check-ignore-only discrepancy like
the one discussed in #10122.
Two controls, so the variable is pinned
- Plain negation works today.
ignore.txt+!no-ignore.txtat the root: git and dvc agree, both APIs. Whatever #10122 was about, this is not a repeat of it. - Drop the
**and dvc is right. Withvolumes/functions/+!volumes/functions/deno.json, git also refuses to re-include (the directory itself is excluded), and dvc says the same. So the variable is the**, not the negation.
Where it happens
DvcIgnorePatterns._ignore walks the ancestor prefixes of a path and breaks on the first one that
matches, which is the right shape for git's rule. The ancestor is tested through
_find_matching_pattern, which — for a directory — also tries path + "/":
paths = [path]
if is_dir and not path.endswith("/"):
paths.append(f"{path}/")pathspec compiles volumes/functions/** to ^volumes/functions/, and that regex matches the
probe string volumes/functions/. So the ancestor is judged excluded, the loop breaks at i = 2,
and the basename where !volumes/functions/deno.json would have won is never reached.
The trailing-slash probe cannot simply be dropped: a genuine directory pattern X/ compiles to
^X(?P<ps_d>/) and needs it. What is missing is the distinction between a pattern that excludes
X and one that only excludes what is under it.
Worth knowing before choosing a fix: on the same two lines,
pathspec.GitIgnoreSpec.from_lines(...).match_file("volumes/functions/deno.json") returns False
— the correct answer. The dependency you already depend on resolves this case; it is dvc's own
ancestor walk that diverges from it.
How I found it, and how rare it is
I maintain a conformance bench that compares tools against git check-ignore --no-index on rule
files harvested from real repositories, and dvc is in it because it is the one project I could find
that promises the per-directory layer rather than improvising it. Over 4441 queries from 64
repositories, this is the only divergence dvc produces. The failing case is
supabase/supabase's docker/.gitignore, which has exactly this shape:
volumes/functions/**
!volumes/functions/deno.json*Bench, corpus and the reduction scripts: https://github.com/KaizenShogun/gitignore-conformance — happy to run any candidate fix through it.
— Midas
Source: treeverse/dvc