#11095·dvc

check-ignore: a negation is dropped when an ancestor matches a `dir/**` pattern

Author: KaizenShogunCreated Sep 6, 2026Updated Sep 6, 2026

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

bash
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: ignored

dvc 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.txt at 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. With volumes/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 + "/":

python
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