The gitignore that ate a source file
CI failed on funminecraft with:
error during build:
Could not resolve "./lib/router.svelte" from "src/App.svelte"
The file existed. It was on disk, 140 lines, finished, imported from five
places, and described in detail in the commit message of the commit that
was failing -- which did not contain it. It had never been committed. The
.gitignore had eaten it, and every local signal had agreed that
everything was fine.
The bug is not that a file was forgotten. Forgetting is loud; you notice
at the diff, or at the push, or when a colleague asks where it is. This
is the other thing: the file was excluded, and the tool that would
have told you is the tool that was told not to look. git status was
clean because the file was ignored -- that is the whole problem in one
sentence.
A pattern older than the problem
The .gitignore came from the stock GitHub Python template, on the
repo's first day. Its distutils block says:
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
These are build artifacts of a setup.py-era Python project: when
distutils builds, it scatters output into lib/, parts/, var/,
wheels/ at the project root, next to setup.py. The template was
written for a flat layout, and its patterns are written the flat way:
no leading slash.
In a gitignore, that choice has a precise meaning. A pattern with no
slash in it -- lib/ -- matches a directory of that name at any
depth. The template means "the root-level lib/ that distutils
makes", and says "every directory named lib/ anywhere, forever".
funminecraft is not a flat layout. It is a monorepo: backend/,
frontend/, sys/. Nothing in the template's assumptions held, and
nothing complained, because an ignore pattern that matches nothing
costs nothing. The pattern sat on line 13, wrong from the day the repo
was born, for 345 days -- a landmine with no foot traffic, waiting
for someone to create a directory named lib/ below the root.
In August 2026, someone did. The frontend needed a router, the router
went in frontend/src/lib/, because that is where Svelte projects put
their lib code, and the pattern reached down three levels and took it.
The morning it bit
The timeline, from the commit log:
- 10:30:33 --
4a592a8, Make the guide crawlable: history routing and per-route metadata. The commit message describes the new router in detail: "frontend/src/lib/router.svelte.ts is about 140 lines and covers what nine routes need." The file is not in the commit.git add -Askipped it, without a warning, because it was ignored. The message describes work the tree does not contain. - 10:36:20 --
3f178ba, the Tortoise ORM migration. Independent work, pushed right behind it. It inherits the broken build: CI builds the frontend, and the frontend imports the missing file. - CI, a machine without the working tree, tries the build and says the only true thing anyone would hear all morning: the module is not there.
- 10:38:42 --
70c07d7, Commit the router that .gitignore was silently swallowing. The file, and the fix.
Eight minutes from the first bad commit to the fix. That speed is CI's gift -- the failure surfaced on the only machine that could see it, which is any machine that is not yours. But read the two numbers together: 345 days armed, 8 minutes to detect. The landmine was not defused by the fast detection. It was defused by reading the pattern and asking what it meant, which is the part that could have happened any time in 345 days and never did, because nothing about the pattern ever produced a signal.
There is a resonance worth naming. The swallowed file was itself an
anti-invisibility device: it replaced a hash-mode router whose URLs --
/#/blocks/stone -- never send a path to the server, so to a crawler
the site's ~2,265 database-backed pages were one URL. The router made
the pages visible to the outside world. The gitignore made the router
invisible to version control. One morning, two invisibility bugs, one
of which was hiding the fix for the other.
What the silence was made of
Three local signals, all green, all green for the same reason:
git add -Askips ignored files without a word. It is not an error to it; it is the instruction. The staging step -- the one place a human might notice a file is missing -- is the one step that was told this file does not exist.- The local build worked. The file was on disk. Vite resolved
./lib/router.svelteagainst the working tree and never asked where it came from. Every build on the author's machine passed because of the exact state that was not being shared. git statuswas clean. This is the load-bearing irony.git statusis the tool whose job is telling you what is unstaged, and it was silent because the.gitignoretold it to be. The safety check was not bypassed; it was configured. "Works on my machine" is usually an excuse, but here it is the literal mechanism: the working tree was the machine-specific state, and nothing outside it could see the file.
The two commands that name it
git check-ignore -v takes a path and tells you which rule ignores
it -- file, line number, pattern:
$ git check-ignore -v frontend/src/lib/router.svelte.ts
.gitignore:13:lib/ frontend/src/lib/router.svelte.ts
That one line is the whole diagnosis. Not "the file is ignored" -- the
line number of the pattern that is ignoring it, which points at the
bare lib/ and at the template it came from. Without -v you get the
path and a shrug; with it, you get the line to fix.
git status --ignored is the periodic audit: it lists everything being
excluded, so the ignore set is something you look at rather than
something you assume:
$ git status --ignored --porcelain | grep '^!!'
.ruff_cache/
backend/.venv/
backend/__pycache__/
frontend/dist/
frontend/node_modules/
...
Most of that list is the ignore rules working as intended. The audit's value is that a source file hiding among the caches and virtualenvs is visible -- one glance at an explicit list instead of an absence you have to reason your way to.
And the sweep, after the fix: run the old pattern set against every path in the tree, and count.
One match, out of 68 tracked paths. The sweep is what turns "we fixed the one we noticed" into "there was only the one" -- the difference between an anecdote and a measurement.
The fix, and what it deliberately did not fix
The fix anchors the six artifact patterns to the root:
/lib/
/lib64/
/parts/
/sdist/
/var/
/wheels/
and leaves dist/ unanchored on purpose, because frontend/dist
is a build artifact and should keep being ignored. That asymmetry is
the tell that the fix was read rather than applied: anchoring the whole
block would have been one sed away and would have silently un-ignored
the frontend build output, trading one silent exclusion for another.
The block now carries a comment explaining all of this, so the next
person who reads line 13 does not have to re-derive it.
The template's patterns were not wrong in general. They were written
for a layout this repo does not have, and gitignore semantics turned
"wrong for this layout" into "matches things three levels deep" with
nobody deciding anything. That is the property of ignore rules that
makes them worth treating as code: they execute on every git add,
their scope is part of their meaning, and their failures are silent by
design. A pattern is an assertion about your tree. This one was
asserting something about a flat Python project, in a repository that
was not one, for 345 days.
The sibling note, Five ways to fail without an error message, is about bugs whose signature is nothing happening. This one is the same class with a twist worth its own note: here, the nothing was manufactured. The tool that reports missing files was not missing from the pipeline -- it was present, working, and pointed away. When a check is green, it is worth one question: is this check silent because everything is fine, or because something told it to be?
Three notes on failures whose only signature is silence:
- Five ways to fail without an error message -- five bugs, one repair commit, zero error messages. The taxonomy.
- The gitignore that ate a source file -- the silence that was manufactured: the tool that would warn you is the one told not to look. (this note)
- The carriage return that breaks the deploy, not the diff -- the invisible byte: the same bytes, harmless or fatal depending on who reads them.