ADR-017: .gitignore is evaluated by us, not by libgit2¶
Status: accepted
Relates to: ADR-011
Context¶
--dirty renders a template's working tree rather than a commit.
Its contract is one sentence: the result is what git add -A would have staged.
That is what makes --dirty safe to reason about — a --dirty render and a render of the same tree once
committed must agree, or the flag changes the output rather than just the input.
libgit2 breaks that contract in one case.
git_ignore_path_is_ignored will not let a negation in a repository .gitignore override a rule that came from a
lower-precedence ignore file — core.excludesFile or .git/info/exclude.
Git will. Measured against git2 0.21 with the vendored libgit2 1.9.6:
repository .gitignore |
core.excludesFile |
is_path_ignored |
git add -An |
|---|---|---|---|
!a |
— | false |
staged |
!a.toml |
— | false |
staged |
!mise.toml |
mise.toml |
true |
staged |
Which is not an exotic configuration.
A widespread global ignore hides mise.toml and mise.lock on the assumption that mise configuration is
personal, and a project that commits them re-includes them explicitly.
Any template rendering a mise.toml therefore ships !mise.toml in its .gitignore, and --dirty silently
dropped the file.
It surfaced through git tpl test (issue #51), where the asymmetry makes it particularly hard to see: in the
template the file is mise.toml.jinja, which the rule does not match, so rendering looks fine.
Only once a snapshot is recorded does the rendered .gitignore come to govern the snapshot's own files/
directory — and --write writes through the filesystem while read-back goes through the walk, so the MANIFEST
survived and the file it lists did not.
Three options:
- Post-correct libgit2's answer. Impossible: libgit2 reports a boolean, not the rule that decided it. There is nothing to correct against.
- Hand-roll a matcher. No new dependency, but gitignore semantics — anchoring,
**, directory-only rules, character classes, precedence between files — are exactly the kind of thing that is subtly wrong for years. - Use an existing correct matcher.
Decision¶
Evaluate the ignore stack in src/git/ignore.rs, using ignore::gitignore::GitignoreBuilder — ripgrep's matcher,
which agrees with git add -An on this and every other case tested.
IgnoreStack holds one matcher per ignore file, weakest first: core.excludesFile, .git/info/exclude, then
every .gitignore from the working-tree root down.
The innermost layer with an opinion wins; within a layer the last matching pattern wins.
That layering is the fix.
Only the matcher is used.
The crate's parallel walker is not: it reports nothing about what it skipped, and --dirty has to be able to
name the files a global rule removed.
The walk in collect_workdir stays ours, keeping its deterministic ordering, its .git skipping and its record
of what it dropped.
core.excludesFile is still resolved through libgit2's config chain, so a repository-local override means here
what it means to Git.
The walk stays repository-wide — the tree is needed whole, for partials and for lint — but the report is
narrowed in resolve to what a render reads: the tree under root, the partials outside it, and the files
declared data sources name.
Warning about a path that was never a candidate for the rendering is a warning nothing in the template can
silence, printed above every run.
Consequences¶
--dirty renders what git add -A stages.
Verified by diffing the two file lists over a tree mixing global rules, negations, nested .gitignore files and
ignored directories — that comparison, not a unit test, is the acceptance criterion, because the contract is
stated in terms of Git's behaviour.
Two things fall out.
Directory-only rules such as build/ now work: the walk stats before it asks, so it can say whether the path is
a directory.
And a template source below its repository root is handled correctly — the previous code handed libgit2 a path
relative to the template root rather than to the working tree, so outer rules matched the wrong thing or nothing
at all.
Precedence is now unit-testable without a repository, which it was not while the answer came from libgit2.
The cost is a dependency, and it brings globset and the full regex-automata.
That is a real weight in a tree that chose regex-lite elsewhere, and it is accepted here because the alternative
is option 2: gitignore globs are not regexes, so regex-lite would not have helped, and hand-translation is the
failure this buys out of.
ADR-011 is narrowed, not violated.
GitBackend still hides every git2 type, and src/git/ignore.rs names none — the hook is satisfied.
What changes is the scope of what the backend decides: it is now asked for objects, refs and config, and no
longer for a policy judgement it gets wrong.
A future backend inherits the correct behaviour instead of having to reimplement it.