Files
Andrey Kumanyaev 0cd9dcb527 excludes: treat regexp metacharacters in ignore patterns as literal text
`gortex track` on pandas reported success and produced an index holding
zero of its 1,519 Python files, with no error, warning, or non-zero exit
anywhere. Every query against that repo then answered "no callers" and
"likely unused" with full confidence — indistinguishable, from the MCP
surface, from a real result.

The cause is one line in pandas' .gitignore:

    *$

an Emacs autosave pattern, meaning "names ending in a dollar sign".
go-gitignore compiles a pattern by string-substituting it into a Go
regular expression and escapes exactly two characters on the way, "."
and "?". Everything else reaches the engine as an operator. So `*$`
became

    ^(|.*/)([^/]*)$(|/.*)$

whose `$` is an end-of-text anchor: it matches every path in the
repository. The index walk pruned every top-level directory and admitted
nothing.

The defect is general, and it cuts both ways:

  - `^*` matches everything for the same reason; `a|b` also matches "b";
    `foo+` also matches "foo"; `[!a-z]` inverts wrongly, since git spells
    class negation with "!" where the regexp engine wants "^".
  - An unbalanced "[" or "(" makes regexp.Compile fail, and
    `pattern, _ := regexp.Compile(expr)` discards the error — the line is
    dropped and the ignore silently stops applying at all.
  - "?" is escaped into a literal "?" rather than the single-character
    wildcard git defines, so pandas' `Icon?` never caught the macOS
    "Icon\r" file it was written for.

Rewrite each pattern before the library sees it: escape the
metacharacters that carry no meaning in a gitignore pattern, lower "?" to
`[^/]`, and translate bracket expressions (including the `[!…]` → `[^…]`
negation and a literal "*" or "$" inside the brackets, which the
library's later substitution passes would otherwise rewrite). An
unterminated "[" becomes literal text, as fnmatch and git read it. A
bracket expression that still refuses to compile falls back to literal
text rather than taking the whole pattern down with it. "*" and "." are
deliberately left alone — the library handles those correctly, and
pre-escaping "." would collide with its own `\.` pass.

Matcher keeps the original pattern text; the rewrite is a compiler detail
and never reaches a user-visible surface. Explain / ExplainAbsDir report
which pattern excluded a path, in the caller's wording, so an over-broad
ignore can name itself instead of being bisected by hand.

Measured against `git check-ignore` over fifteen upstream github/gitignore
templates plus pandas' own file (~470 patterns, 50 candidate paths): 27
divergences before, 2 after — one is macOS `core.ignorecase`, which git
honours and this matcher does not, and one is an artifact of passing a
bare CR through `git check-ignore --stdin`. The stock templates were
already clean; the defect only bites on hand-written lines, which is why
it survived this long.

CODEOWNERS rules and review path rules compiled their globs with the same
library directly, so an owner or review rule as ordinary as `*$` claimed
every file in the repository. Both now go through excludes.New, which
leaves one door onto go-gitignore in the tree.
2026-08-20 00:02:49 +02:00
..