#2369·go-git

Status(): a directory matched by X/** is pruned from the walk, so !X/keep never re-includes (regression in 70ab8844)

Author: KaizenShogunCreated Sep 9, 2026Updated Sep 15, 2026

Worktree.Status() reports a file as ignored when its directory matched X/** and a later rule re-includes the file. The pattern matcher gets it right on every version I tested, so this is the walk, not gitignore.

Bisected to 70ab8844 ("worktree: skip ignored directories during Status walk", #2048, 2026-05-04). Its parent 89091499 agrees with git; 70ab8844 does not; main today (8372f31) still does not.

Repro

bash
mkdir -p t/volumes/functions && cd t && git init -q
printf 'volumes/functions/**\n!volumes/functions/deno.json*\n' > .gitignore
touch volumes/functions/deno.jsonsample volumes/functions/otro.txt

git status --porcelain --ignored
# ?? .gitignore
# ?? volumes/
# !! volumes/functions/otro.txt      <- only otro.txt is ignored

git check-ignore -v agrees, rule by rule:

volumes/functions/deno.jsonsample  ->  .gitignore:2:!volumes/functions/deno.json*   (NOT ignored)
volumes/functions/otro.txt         ->  .gitignore:1:volumes/functions/**            (ignored)

Through go-git, opening that worktree and reading Status(), deno.jsonsample is reported as not untracked, i.e. ignored:

version deno.jsonsample via Status() otro.txt
git (reference) not ignored ignored
v5.19.2 not ignored ✔ ignored ✔
89091499 (parent of the commit) not ignored ✔ ignored ✔
70ab8844 ignored ✘ ignored ✔
main 8372f31 ignored ✘ ignored ✔

gitignore.NewMatcher(ReadPatterns(...)).Match([]string{"volumes","functions","deno.jsonsample"}, false) returns false — correct — on all four, including main. Only the Status() walk disagrees, which is why the pattern tests don't catch it.

Why it happens

The walk prunes a directory when the matcher calls it ignored, and for volumes/functions the matcher says true — because ** happily matches the empty remainder. git check-ignore volumes/functions/ says the same thing (.gitignore:1:volumes/functions/**). But git's own walk does not treat that as a reason to stop descending: X/** excludes the contents of X, and a later !X/keep can still re-include one of them. Asking Match(dir, true) and pruning on the answer conflates "this directory's contents are excluded" with "nothing inside can be re-included".

The commit also fixed the mirror case, so please don't just revert it

Same shape, but with the directory itself excluded (X/ instead of X/**) — here git really does refuse to descend, and the re-inclusion is dead:

bash
printf 'volumes/functions/\n!volumes/functions/deno.json*\n' > .gitignore
git status --porcelain --ignored
# !! volumes/          <- everything below is ignored, the negation cannot reach in
version deno.jsonsample via Status()
git (reference) ignored
v5.19.2 not ignored ✘
89091499 not ignored ✘
70ab8844, main ignored ✔

So 70ab8844 traded one wrong family for another: it made X/ correct and X/** wrong. What separates them isn't whether the matcher says the directory is ignored — it says yes to both — but whether the pattern excluded the directory itself or only its contents.

How much it costs

I run a conformance bench that replays 33 real repositories' whole .gitignore trees against git check-ignore as the oracle: 4,463 matcher queries and 2,224 through Status(). main diverges on 1 of 2,224 through Status(), and this is it — the case above is supabase/supabase's docker/.gitignore, verbatim. One query sounds like a corner, so here's the wider count from the same corpus: 5 of the 33 repositories write "ignore everything, keep this" with a negation reaching inside — elasticsearch, grafana, node, next.js, supabase — 14 rule lines between them. My corpus only materialises paths that hit the exact combination once; the construct itself is the one the gitignore docs recommend for the job.

I chased it past the matcher because Status() is the door a consumer actually reaches for: someone using go-git to find out what's untracked never calls ReadPatterns themselves, so a matcher that's right and a walk that isn't still hands them a wrong answer.

Bench, corpus and the go-git adapter, if you want to reproduce any row: https://github.com/KaizenShogun/gitignore-conformance