Skip to content

Check parent directories in Match and add git check-ignore conformance tests - #27

Merged
andrew merged 3 commits into
mainfrom
conformance-tests
Sep 12, 2026
Merged

Check parent directories in Match and add git check-ignore conformance tests#27
andrew merged 3 commits into
mainfrom
conformance-tests

Conversation

@andrew

@andrew andrew commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Match scanned patterns against the full path only. Git checks ignore rules per directory during tree traversal and skips an excluded directory entirely, so any path under it is ignored and a later ! line cannot re-include it. Match now checks each parent directory of the queried path first, which fixes #25 and #26.

With the parent check in place the implicit trailing ** that compilePattern appended to every non-dir-only pattern is redundant, and it was the direct cause of #26 (!foo compiled as !foo/** and matched descendants). It is removed, along with the /** -> dir-only rewrite and the dir-only descendant loop in matchPattern. matchSegments gains a flag requiring a trailing ** to consume at least one path segment, so foo/** no longer matches foo itself.

Two more differences from git turned up while building the tests:

  • foo/** and foo/**/ matched the directory foo itself; in git they match only its contents. Handled by the trailing-** flag above.
  • A segment of three or more asterisks (***, ****) was compiled as a single-segment glob; in git it is equivalent to **. Any all-* segment of length two or more is now compiled as **.

extractLiteralSuffix treated a * inside a bracket expression as the glob-star boundary, so [a*]b produced a suffix of ]b and the fast-reject skipped ab. That was masked on main by the implicit trailing **; the extractor now bails when the suffix contains ].

Four existing tests asserted the opposite of git check-ignore 2.55.0 and are corrected: TestMatchDoubleStarSlash, TestMatchCannotReincludeUnderExcludedParent, TestMatchDoubleStarTrailingDir, TestMatchNegationSubdirectoryFilter. TestMatchAgainstGitCheckIgnore never invoked git and is renamed TestMatchTable.

New tests in conformance_test.go:

  • TestConformance builds a temp repo per case under testdata/conformance/, materialises the listed paths, runs git check-ignore --no-index -z -v -n --stdin once over all of them, and compares the results with the library. Eighteen cases covering the two issues, the four corrected tests, the findings above, and a few real-world pattern shapes.
  • TestConformanceHarnessSelfCheck runs git check-ignore -q per path and compares against the batched parse, so a parsing bug in the harness surfaces separately from a matcher bug.
  • TestConformanceFuzz (skipped under -short) generates random pattern sets and paths and compares against git. GITIGNORE_FUZZ_ROUNDS and GITIGNORE_FUZZ_SEED control the run size.

HOME, XDG_CONFIG_HOME, GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM are pointed at an empty temp dir for these tests so neither the git subprocess nor New reads user-level excludes.

Benchmarks: shallow-path matches are roughly twice as fast because the literal-suffix fast-reject now applies to most patterns instead of almost none; MatchDeepPath (depth 8) is about 44% slower from the parent loop; Walk is unchanged. Allocations unchanged.

…e tests

Match now tests each parent directory of a path before the path itself,
so a file under an excluded directory is reported as ignored and a
negation cannot re-include it, matching git's per-directory traversal.

The implicit trailing ** and the /** rewrite are removed; matchSegments
takes a flag so a trailing ** must consume at least one segment. A
whole-segment run of two or more asterisks now compiles as **. Four
existing tests that asserted the opposite of git are corrected.

Fixes #25.
Fixes #26.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Guard stderr access in conformance_test.go so command startup failures report test failures instead of panicking.

Pull request overview

Aligns ignore matching with Git’s parent-directory traversal semantics and adds broad git check-ignore conformance coverage.

Changes:

  • Checks excluded parent directories before matching descendants.
  • Corrects trailing **, multi-star, and bracket-suffix behavior.
  • Adds fixture-based, self-checking, and fuzz conformance tests.
File summaries
File Description
wildmatch.go Enforces required consumption for trailing **.
testdata/conformance/vscode-star-idiom/paths Adds VS Code idiom query paths.
testdata/conformance/vscode-star-idiom/gitignore Adds VS Code idiom patterns.
testdata/conformance/real-world-vscode/paths Adds real-world VS Code paths.
testdata/conformance/real-world-vscode/gitignore Adds real-world VS Code patterns.
testdata/conformance/real-world-node/paths Adds Node.js query paths.
testdata/conformance/real-world-node/gitignore Adds Node.js ignore patterns.
testdata/conformance/real-world-generated/paths Adds generated-tree paths.
testdata/conformance/real-world-generated/gitignore Adds generated-file patterns.
testdata/conformance/nested-doublestar-both-ends/paths Adds nested ** paths.
testdata/conformance/nested-doublestar-both-ends/gitignore Adds nested ** patterns.
testdata/conformance/negation-under-excluded-dir/paths Adds excluded-parent cases.
testdata/conformance/negation-under-excluded-dir/gitignore Adds negation-under-exclusion patterns.
testdata/conformance/negation-under-excluded-dir-slash/paths Adds slash-exclusion cases.
testdata/conformance/negation-under-excluded-dir-slash/gitignore Adds slash-exclusion patterns.
testdata/conformance/negation-reincluded-dir-contents/paths Adds re-inclusion cases.
testdata/conformance/negation-reincluded-dir-contents/gitignore Adds re-inclusion patterns.
testdata/conformance/negation-not-inherited/paths Adds negation inheritance cases.
testdata/conformance/negation-not-inherited/gitignore Adds negation inheritance patterns.
testdata/conformance/negation-not-inherited-dironly/paths Adds directory-only inheritance cases.
testdata/conformance/negation-not-inherited-dironly/gitignore Adds directory-only negation patterns.
testdata/conformance/multi-star-segment/paths Adds multi-star query paths.
testdata/conformance/multi-star-segment/gitignore Adds multi-star patterns.
testdata/conformance/exclusion-then-negation-same-dir/paths Adds ordering cases.
testdata/conformance/exclusion-then-negation-same-dir/gitignore Adds exclusion/negation patterns.
testdata/conformance/doublestar-slash-only/paths Adds **/ cases.
testdata/conformance/doublestar-slash-only/gitignore Adds slash-only ** patterns.
testdata/conformance/doublestar-not-parent/paths Adds trailing ** cases.
testdata/conformance/doublestar-not-parent/gitignore Adds non-directory trailing ** patterns.
testdata/conformance/doublestar-not-parent-dironly/paths Adds directory-only trailing ** cases.
testdata/conformance/doublestar-not-parent-dironly/gitignore Adds directory-only trailing ** patterns.
testdata/conformance/dir-only-basics/paths Adds directory-only query paths.
testdata/conformance/dir-only-basics/gitignore Adds directory-only patterns.
testdata/conformance/bracket-with-star/paths Adds bracket-expression paths.
testdata/conformance/bracket-with-star/gitignore Adds bracket-star patterns.
testdata/conformance/anchored-and-wildcard/paths Adds anchored and wildcard paths.
testdata/conformance/anchored-and-wildcard/gitignore Adds anchored and wildcard patterns.
gitignore.go Implements parent checks and revised pattern compilation.
gitignore_test.go Updates tests for Git-compatible semantics.
conformance_test.go Adds Git conformance, self-check, and fuzz tests.
Review details

Suppressed comments (1)

conformance_test.go:284

  • If exec.Command fails before starting a process, the error is not an *exec.ExitError, so ok is false but this branch still dereferences ee.Stderr and panics while reporting the failure. Guard the stderr access (or use CombinedOutput) so startup errors produce a test failure instead of a nil-pointer panic.
		if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 1 {
			t.Fatalf("git check-ignore: %v\n%s", err, ee.Stderr)
  • Files reviewed: 40/40 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@KaizenShogun

Copy link
Copy Markdown
Contributor

Ran it. 66f08d2b, 8,953 queries, git 2.55.0 as the oracle: 0 divergences.

Compared as sets rather than counts, against main at 12: fixes all 12, breaks nothing.

build divergences
main (after #22, #24) 12
ancestor walk only 5
+ no implicit ** 3
+ no descendant loop 1
this branch 0

The middle three are patches I built while measuring the two issues, so they're only useful as a
gradient: each one leaves a residue the next removes, and yours removes the last of it. The 1 that
survived my best build was supabase/supabase, docker/.gitignore, volumes/functions/** matching
volumes/functions itself — the trailing-** flag in matchSegments is what takes it out.

Control, because a zero on its own doesn't prove the bench still detects anything: I re-measured
main today, same harness, same corpus, and it still answers 12.

Two things the zero does not cover, both from your PR description:

  • *** as a segment, and * inside a bracket expression. Zero of the 10,281 pattern lines in the
    corpus contain either, so nothing here exercises those two fixes. Your conformance cases do.
  • The corpus asks about a path and about its own last segment as a directory, never about the
    intermediate directories of a path. That's the exact shape of the ancestor walk, so it's the
    gap I'd least like to have — I'm adding that variant next and will say if it moves.

For the record the only other subject at 0 on this corpus is libgit2 with
#7339 applied; main there is at 40.

Bench and corpus: https://github.com/KaizenShogun/gitignore-conformance

@KaizenShogun

Copy link
Copy Markdown
Contributor

Added the variant I said I'd add: the directories a path is reached through, which none of the
three shipped ones ask about. 1,010 queries, 1,001 of them path strings no frozen variant carries.
This branch: 0.

The zero isn't the interesting part. Same harness, same day, every git-pkgs build I have on disk:

build divergences / 1,010
main before #22 and #24, and after 1
the four patches I measured for #25 and #26 1
this branch 0

Same row all six times: supabase's docker/.gitignore, volumes/functions/**, and the directory
volumes/functions itself. A trailing ** matches the contents, not the container. The
matchSegments flag is the only thing that has got that right here — it was a real bug in main
all along, and the only place I'd seen it before was as breakage of my own candidate patch, where
a bug in the library and a bug in the patch look identical.

Hand-checked the shape I'd have bet on breaking under that flag:

a/**
!a/**/b

git says a/ is not ignored and a/c/ is. The branch agrees on both; main gets the first wrong.

The oracle, since a directory question needs one stated: check-ignore d/, !! d/ in
status --ignored and "nothing staged below" all fire together on d/* — in wildmatch * matches
the empty string too — so agreement between the three proves nothing. This uses a re-inclusion
probe instead: canary in the directory, negation in that directory's own rule file so a deeper one
can't overrule it, then see what git stages. Nothing was dropped for oracle disagreement.

Size, honestly: one finding in 1,010 questions, and 40 of those questions are ignored paths at all.
Most intermediate directories in a real repo are quiet. This is a coverage hole closed, not a
harvest — but it's the hole shaped like the parent-directory loop you added, so I'd rather it were
asked before the merge than after.

build_between_l2.py and corpus/cases_l2_between.json:
https://github.com/KaizenShogun/gitignore-conformance

@andrew
andrew marked this pull request as ready for review September 12, 2026 22:23
@andrew
andrew merged commit 30f606b into main Sep 12, 2026
7 checks passed
@andrew
andrew deleted the conformance-tests branch September 13, 2026 00:45
@KaizenShogun

Copy link
Copy Markdown
Contributor

Ran it again on what you tagged rather than on the branch: v1.3.0 (e35f1a8), which carries #31, #32 and #30 on top of this. Same harness, git 2.55.0 as the oracle.

corpus v1.3.0
8,953 queries, root rule files of 43 repos 0
1,010 intermediate-directory queries 0

Answer for answer identical to the branch I measured here, so the three merges that went in behind this one moved the tree without moving a single verdict — including #30, which is the one I'd have bet on: a fast-reject that skips whole groups is the same shape as the literalSuffix bug in #23.

Control, same harness today, because a zero proves nothing on its own: the pre-#27 build still answers 12 and 1, same rows as before.

What the zero doesn't reach, so it isn't read as wider than it is: no pattern line in this corpus contains a *** segment or a * inside a bracket expression, and nothing in it comes near the limits from #31 and #32. Those are covered by your conformance cases, not by mine.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A negation under an excluded directory takes effect; git says it cannot

3 participants