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:
- Generate the first implementation from the prompt below.
- Compile with
g++ -std=c++17 -Wall -Wextra -pedantic igcheck.cpp -o igcheck. - Run the 47-vector corpus.
- Append the failing vectors and their expected output to the prompt.
- 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 /**/
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
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;
}
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
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)