Documents / Decisions / adr-c87e444975e8
A code glob a repository ignores entirely is still the code it governs
Why .gitignore drops files from a code: glob only while the glob reaches something else, and why a glob that reaches nothing but ignored files is honoured instead of erased.
Decision¶
.gitignore governs what a code: glob fingerprints, and narrows what it matches
only while the glob reaches something else.
One rule, two halves:
- A glob that reaches ignored files and tracked ones drops the ignored ones.
src/**reachingsrc/bin/is A code glob hashes what the repository tracks, not what it builds unchanged, and dropping the build output there is what stops one compile drifting every glob at once. - A glob that reaches nothing but ignored files keeps them. It matches, it fingerprints, and it is watched like any other.
The hardcoded floor — .git, __pycache__, .mypy_cache, .ruff_cache, .pytest_cache —
is absolute in both halves.
What this reverses, and what it keeps¶
A code glob hashes what the repository tracks, not what it builds said a path git would ignore "does not count as a match". That sentence
is withdrawn; every other sentence in it stands, including the reason the engine reads the
tree's own .gitignore files and nothing else.
The argument it was written from is about files a glob sweeps up beside the ones it was
aimed at. .gitignore answers "is this the repository's source", and for a broad glob that
is exactly the right question. It is the wrong question for a glob that reaches nothing else:
nobody sweeps a vendored clone up by accident, so a path written into code: by hand is a
statement that this document is governed by it — whatever git's opinion of whether it belongs
in this repository's history.
Ignoring a path and governing it are orthogonal. A vendored dependency, a shallow submodule, a mirrored upstream clone, a generated API client committed nowhere: all are read-only inputs a document can legitimately be governed by, and all are routinely gitignored precisely because they are not this repository's source.
What it cost to find out¶
288 of 288 governed documents in one adopting store lost their invalidation at once, and 102
live findings went to none. What replaced them was 288 unmatched-code findings — a message
reading "update the pattern", on 288 patterns that were correct.
The direction matters more than the count. 0.27.0 over-reported; 0.28.0 under-reported silently, and an empty queue is indistinguishable from a clean one. Where the two rules disagree, the conservative one is the one that still reports.
The workaround an adopter found is the measure of the defect: move the exclusion from
.gitignore to .git/info/exclude, which docir deliberately does not read. It works, and
it costs the exclusion the one property it had — .gitignore is committed, and
.git/info/exclude is per-machine and invisible to everyone who clones.
Two passes, not one predicate¶
_files_under walks once dropping ignored files, and walks again keeping them only if the
first walk found nothing. matches does the same with its short-circuit intact.
A single classify-as-you-go pass was the obvious shape and is worse: an ignored directory can
no longer be pruned, so a broad glob descends into every node_modules and .venv it reaches
to discover it did not need them. Two passes keep the common case at exactly its old cost,
including the pruning, and pay a second walk only for a pattern that used to resolve to
nothing — which is the case that was silent.
Why the floor stays absolute¶
Re-admitting the floor on the second pass would hash bytecode again (A ** glob hashes the package's bytecode, so it drifts on its own)
through the door this decision opens: src/**/__pycache__/** reaches nothing but .pyc
files, finds nothing on the first pass, and would be honoured on the second. A glob reaching
nothing but generated output governs nothing anyone wrote, which is the one case where
"somebody wrote this by hand" is not evidence of intent — the pattern is broad, not specific.
What upgrading costs¶
Nothing re-reports. A mixed glob's digest is computed exactly as before, so no stored baseline changes value and no document drifts on upgrade.
A glob that 0.28.0 silenced comes back with no baseline — 0.28.0 dropped it, because there was
nothing it would fingerprint — so it surfaces as code-unwatched (A code glob nothing is watching is a finding, not a silence) and docir check --fix arms it. That is the recovery path, and it is the same
one every other unwatched glob takes.
A teammate on 0.28.0 reading the same store computes no digest for those globs and reports
unmatched-code on them, exactly as it does today. Neither build refuses anything, so this
clears the bar Verify a change against the published build, not just this one sets; what it costs is that those globs are a per-build
opinion until everyone upgrades, in the direction where the older build over-reports.
Consequences¶
A code: glob is now the author's statement about scope, and .gitignore is a tool for
removing noise from inside one rather than a veto over it. Exclusions the repository does not
ignore — a committed src/generated/ — remain unexpressible, unchanged from
A code glob hashes what the repository tracks, not what it builds and for the same reason: code: globs would need a second grammar.