← All writing
Craft · · 7 min

Already tracked

The one rule about this file that nearly everybody has backwards, what a repo is actually for, and the three separate lists that keep getting collapsed into one.

Git Security

I cloned a client repo in July for a project I was taking over. ls -a, because that’s the first thing I do. There was a .env in the working tree. Tracked. Committed in 2022 by a developer who left in 2023, with a live Stripe secret key in it.

The repo had a .gitignore. .env was in it, and had been since early 2023, presumably added by somebody who noticed the same thing I noticed and figured that was that.

It wasn’t, and the reason is the single most misunderstood thing about this file.

You’re already on the list

.gitignore is a list of paths git should not start tracking. That’s the entire scope of it. Once a file is in the index git has stopped asking, so you can put its name in every ignore file on the machine and it will go on being committed forever. The ignore file is the bouncer at the door, and the door is the only place it works.

So the fix isn’t a line in a file. It’s this:

git rm --cached .env
git commit -m "Stop tracking .env"
Bash

That drops it from the index and leaves it sitting on disk. From then on it’s untracked, and from then on the ignore rule finally means something.

And all that gets you is the next commit. It’s in every commit up to that one, permanently, and in every clone anybody ever took, and in whatever CI cache and whatever backup and whatever laptop belonged to a contractor who was there for 6 weeks in 2023 🙃

I’ve written admiringly before about how hard git works to never actually lose anything, which is wonderful right up until the thing you’d like it to lose is a secret. Same property, wrong direction. So rotate the key first, today, before you touch the history at all. Rewriting is worth doing and it is not the part that saves you.

Some assembly required

The frame I’ve settled on decides about 90% of these arguments before I’ve had to think about them. A repo holds inputs. If a thing can be produced by running a command against what’s already in there, it belongs to whoever ran the command.

_site/. node_modules/, which is entirely described by a lockfile that is committed. .jekyll-cache/, dist/, .sass-cache/, compiled CSS if CI is the thing compiling it. Outputs, all reproducible, none of them the repo’s business.

That’s the argument for getting deploys off your laptop, coming at it from the other side. If the built output lives in the repo then what’s in there is one specific person’s build from one specific machine, and you find out whose the day it stops matching everybody else’s.

I’ve committed build output myself and I’d do it again. If a client has no build infrastructure and the deploy is a drag into an FTP client, then the built files are the deliverable and in they go. If a dependency is unmaintained and the registry might stop serving it one day, vendoring it is cheap insurance. Both fine. Avoid doing it by accident and then finding out 3 years later that nobody left in the building knows how to regenerate it.

Three lists in a trench coat

This is the opinion I’d most like to talk somebody into. I know it’s mildly unfashionable.

Output of this project’s build goes in the project’s .gitignore, committed, because everybody who builds this project produces these files and everybody needs them ignored. That list is short. On this site it’s 4 lines.

.gitignore
_site/
.jekyll-cache/
.jekyll-metadata
vendor/bundle/
gitignore

Your machine’s noise goes in a global ignore, set once with git config --global core.excludesFile ~/.gitignore_global. .DS_Store, Thumbs.db, .idea/, *.swp, whatever your editor leaves lying around. My tools are my problem. They aren’t a fact about this project and they have no business being in a file every contributor has to read.

Just-this-repo-just-for-me goes in .git/info/exclude, which is the same syntax, is never committed, and requires nobody’s agreement. I don’t think I met anyone who knew that file existed until I was about 5 years in. It’s the right home for the scratch file, the folder of client screenshots, the notes.md you’re keeping while you find your way around the codebase. It’s allowed to be as idiosyncratic as you like, on account of nobody else ever seeing it.

Won’t somebody think of the .DS_Store

The counter is obvious and it’s been put to me several times. If it isn’t in the project file, some new person on a Mac commits a .DS_Store in their first week.

And yeah, they will. Once. Then you tell them about core.excludesFile and it’s fixed for every repository they will ever touch for the rest of their career, instead of fixed for this one repo right up until they open a different one.

The failure mode of the other approach is a thing I now see constantly: a 200-line .gitignore generated off a template site, covering Xcode and Python and Windows and 4 editors, sitting in a Jekyll project. Nobody has read it. Not one soul. Nobody can tell which 4 lines are the ones actually doing something. And when a file is being ignored that shouldn’t be, there are 200 candidate explanations and no way to rank them.

Slash fiction

Four gotchas. They’re the ones I’ve watched people lose an afternoon to.

A trailing slash means directories only. build/ ignores the directory. build ignores a file or a directory with that name.

A leading slash anchors the pattern to the repo root. Without one it matches at any depth, so build also swallows src/vendor/build, which is occasionally what you wanted and occasionally the bug.

You cannot re-include a file whose parent directory is excluded. This one is genuinely surprising and it fails silently, because git never descends into an excluded directory at all, so the negation is never evaluated and your logo is gone.

.gitignore
# git never looks inside assets/, so the second line never runs
assets/
!assets/logo.svg

# this one works
assets/*
!assets/logo.svg
gitignore

And git check-ignore -v path/to/file ends every argument about any of this in about 2 seconds. It prints the file, the line number and the exact pattern doing the ignoring, including when the culprit turns out to be your global ignore instead of anything in the project. I reach for it maybe twice a month and it has yet to let me down.

Mission accomplished

That repo. Key rotated within the hour, history rewritten the following week, after a slightly awkward conversation about who else was holding a clone.

The client asked whether it was serious, and the honest answer was that the key was serious and the file was a symptom. Somebody in 2023 spotted the problem, wrote the correct line in the correct file, and reasonably concluded they’d dealt with it. Nothing anywhere told them otherwise. git status stayed quiet, the file stayed committed, and the whole thing looked handled for 2.5 years.

So there’s a line I run on the first morning of an inherited project now, which is git ls-files piped through a grep for the usual suspects. .env, *.pem, config/secrets*, anything with key or credentials in the name. It takes about 10 seconds and it’s the only way I know to ask the question the ignore file can’t answer, which is not “what are we ignoring” but “what did we start tracking before anybody thought to ignore it.”

It’s a strange category of bug, honestly, the kind where the tool and the person are both behaving exactly as documented and the outcome is a live payment key in a repo for 3 years. Nobody was careless. The file just doesn’t do the thing its name implies, and git was never going to be the one to bring it up.

Read similar posts
17 min

Exit 0 means allow

Porting an internal Claude Code safety tool from Linux to macOS took a couple months, and nearly every failure I found on the new platform failed in the permissive direction while printing a checkmark on the way out.

9 min

We billed them for the cleanup

A site I built in 2018 at a previous agency spent about two months redirecting phones to a sweepstakes page, and the invoice we sent for cleaning it up was larger than a year of the maintenance nobody had ever sold them.