DEV Community

Finley Zhou
Finley Zhou

Posted on

Case Study: A .gitignore Validator in C++ — 47 Vectors, 3 Rounds, 2 Gaps

Git's ignore rules look simple until you implement them. In one afternoon I used a free model to write a C++ validator for .gitignore files, and the first version passed 31 of 47 spec vectors. Three rounds later it passed all 47, but a differential test against git check-ignore still found two gaps.

The conclusion is not that the model was wrong. The conclusion is that a spec corpus is a gate, not a proof.

Background

The trigger was a concrete problem. A repository I help maintain kept receiving commits that should have been ignored: build artifacts, editor swap files, and one 40 MB log that slipped through twice. The .gitignore had grown to 90 lines, and nobody could predict what it matched.

I wanted a tiny CLI that answers one question: given a path, is it ignored or not? No dependencies, no git plumbing, one binary.

Goal

The goal was a single-file C++17 tool, igcheck, that reads a .gitignore file and a list of paths from stdin. For each path it prints ignored or not-ignored. The behavior must match git's documented semantics, not my intuition about them.

Implementation

The strategy was to generate the first version with a free model, then run a gated loop. I used MonkeyCode's free model access for generation and its free server option to run the compile–test cycle without provisioning my own runner.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The loop had five steps:

  1. Generate the first implementation from the prompt below.
  2. Compile with g++ -std=c++17 -Wall -Wextra -pedantic igcheck.cpp -o igcheck.
  3. Run the 47-vector corpus.
  4. Append the failing vectors and their expected output to the prompt.
  5. Repeat until the corpus is green.

The prompt summarized the format, not the full spec:

Write a single-file C++17 program that reads a .gitignore file and
a list of paths from stdin. For each path, print "ignored" or
"not-ignored". Implement git's documented semantics:
- blank lines and lines starting with # are ignored
- ! negates a pattern
- * matches any sequence except /
- ? matches one character except /
- [abc] matches one of a, b, c
- a trailing / means the pattern matches directories only
- a pattern without / matches basenames in any directory
- a pattern with / (other than trailing) is anchored
- ** has three special forms: leading **/, trailing /**, and /**/
Enter fullscreen mode Exit fullscreen mode

The prompt omitted two details because I forgot them. That omission became the most useful part of the experiment.

The test corpus

I built 47 vectors from the gitignore documentation and from real-world failure reports. The corpus is the artifact that made the loop work.

Category Vectors What it checks
Comments and blanks 4 # lines, empty lines, escaped \#
Basename patterns 6 *.log matches in any directory
Anchored patterns 6 /build matches only at the root
Directory-only 5 trailing / matches directories only
Negation 7 !keep.log, re-inclusion order
** forms 9 leading, trailing, and middle **
Character classes 4 [abc], [!a], ranges
Escapes 6 \, \*, trailing spaces

Each vector is a pair: a path and an expected verdict. Running the corpus is one command:

printf 'build/\n*.log\n!keep.log\n' > .gitignore
printf 'build/out.o\nsrc/keep.log\n' | ./igcheck
Enter fullscreen mode Exit fullscreen mode

Expected output: ignored for build/out.o, not-ignored for src/keep.log.

Results

Round 1: 31 of 47 passed. Failures clustered in two areas: ** handling and negation order. The generated code treated ** as a plain double wildcard, so a/**/b did not match a/x/y/b. It also applied ! rules before parent-directory exclusion, so a ! pattern could re-include a file whose parent directory was already excluded. Git evaluates patterns in order and, for wildcard exclusions, never descends into an excluded directory at all.

Round 2: 44 of 47. The ** cases passed. Three negation cases still failed.

Round 3: 47 of 47. Green.

Green on my corpus was not green on git. I generated 500 random paths and compared igcheck against git check-ignore -v in a scratch repository. Two mismatches surfaced.

Gap 1: trailing spaces. Git ignores trailing spaces in a pattern unless they are escaped with a backslash. The generated code trimmed them unconditionally. My prompt never mentioned the rule, so the model never implemented it.

Gap 2: re-inclusion inside an excluded directory. Git cannot re-include a file when a parent directory is excluded by a wildcard pattern, because it never descends into that directory. The generated code descended anyway, so !foo/bar/baz incorrectly won against foo/*.

Both gaps were fixed in round 4. The first fix was a trim rule. The second was a descent check:

// Condensed from the final generated code (round 4).
// git descends into an excluded directory only when the
// excluding pattern contains no wildcard.
bool can_reinclude(const std::string& dir, const Patterns& ps) {
  for (const auto& p : ps) {
    if (p.negated) continue;
    if (p.matches(dir) && p.has_wildcard) return false;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Final state: 47/47 spec vectors, 500/500 differential paths. The differential loop was a few lines of bash:

for p in $(generate_paths 500); do
  a=$(printf '%s\n' "$p" | ./igcheck .gitignore)
  b=$(git -C repo check-ignore -q --no-index "$p" && echo ignored || echo not-ignored)
  [ "$a" = "$b" ] || echo "MISMATCH: $p -> $a vs $b"
done
Enter fullscreen mode Exit fullscreen mode

Lessons

A spec corpus is a gate, not a proof. 47 green vectors told me the model had read my prompt. The differential test told me it had read git's actual behavior. Both were necessary.

The prompt is part of the implementation. The two gaps were not model hallucinations. They were rules I omitted from the prompt. The model faithfully implemented what I asked for; the bug was in the specification I gave it.

Free infrastructure changes the economics of small tools. The whole loop — generation, compilation, 47-vector runs, four rounds, 500 differential paths — cost nothing beyond my attention. I would not have built this tool if every compile-and-ask cycle had a price tag.

Feed failures back verbatim. The round-2 prompt was not a rewrite request. It was the failing vectors plus the expected output. That is the highest-bandwidth feedback a model can receive.

Who should not use this approach

If you need a general-purpose ignore engine, use libgit2 or shell out to git check-ignore. This tool is a validator for one repository's policy, not a replacement for git. And if your .gitignore is three lines long, you do not need any of this.

If you keep a .gitignore that nobody fully understands, the 47-vector corpus is a good starting point for your own gate. A free model and a free server are enough to run it.

Top comments (0)