.gitignore
On the one rule about this file that nearly everybody has backwards, what a repository is actually for, and the three separate lists that get collapsed into one.
I cloned a repository in July for a project I was taking over. ls -a, because that’s the first thing I do, and there was a .env in the working tree. Tracked. Committed in 2022 by somebody who left the company in 2023, with a live payment provider secret key in it.
The repository had a .gitignore. .env was in it. It had been added in early 2023, presumably by somebody who noticed the same thing I did and thought they’d dealt with it.
They hadn’t, and the reason is the single most misunderstood thing about this file.
It doesn’t apply to files git is already watching
.gitignore is a list of paths git should not start tracking. That’s the whole scope of it. Once a file is in the index, git has stopped asking, and you can put its name in every ignore file on the machine and it will keep being committed forever.
So the fix isn’t a line in a file, it’s:
git rm --cached .env
which removes it from the index and leaves it on disk. Commit that, and from then on it’s untracked, and from then on the ignore rule finally means something.
And that only stops it appearing in the next commit. It’s in every commit up to that one, permanently, and in every clone anybody has taken, and in whatever CI cache and whatever backup and whatever laptop belonging to a contractor who was there for six weeks in 2023.
What a repository is for
The frame I’ve settled on, and it decides about 90% of the cases without any thought at all.
A repository holds the inputs. If a thing can be produced by running a command against what’s already in there, it belongs to whoever ran the command, not to the repository.
_site/. node_modules/, which is fully described by a lockfile that is committed. .jekyll-cache/, dist/, .sass-cache/, compiled CSS if you compile it in CI. All outputs. All reproducible. None of them the repository’s business.
That’s the same argument I made in 2019 about moving deploys off my laptop, coming from the other direction. If the built output lives in the repository, then what’s in there is one specific person’s build, made on one specific machine, and you find out whose the day it stops matching everybody else’s.
A repository holds the inputs. Anything a command can produce from them belongs to whoever ran the command.
I should be fair to the other side, because 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 they go in. If a dependency is unmaintained and the registry might one day stop serving it, vendoring it is cheap insurance. Both fine. The thing worth avoiding is doing it by accident and then discovering three years later that nobody knows how to regenerate it.
Three lists that keep getting collapsed into one
This is the opinion I’d most like to talk somebody into, and it’s slightly 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. This list is short. On this site it’s four lines.
_site/
.jekyll-cache/
.jekyll-metadata
vendor/bundle/
Your machine’s noise goes in a global ignore, set once, via git config --global core.excludesFile ~/.gitignore_global. .DS_Store, Thumbs.db, .idea/, *.swp, whatever your editor leaves behind. My tools are my problem. They aren’t a fact about this project and they shouldn’t be in a file that every contributor reads.
The counter is obvious and I’ve had it put to me several times: if it isn’t in the project file, some new person on a Mac will commit a .DS_Store in their first week. And yes, 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, rather than fixed for this one repository 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 from a template site, covering Xcode and Python and Windows and four editors, sitting in a Jekyll project. Nobody has read it. Nobody can tell which four lines are secretly necessary. And when something is being ignored that shouldn’t be, there are 200 candidate explanations.
Just this repository, just for you goes in .git/info/exclude, which is the same syntax, is never committed, and requires nobody’s agreement. I don’t think I met anybody who knew about this file until about five years in. It’s the right home for the scratch file, the folder of client screenshots, the notes.md you’re keeping while you learn the codebase.
The syntax bits that actually bite
Four, and they’re the four 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 to the repository root. Without one, the pattern 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. If you’ve written assets/ and then !assets/logo.svg, git never descends into assets/ at all, so the negation is never evaluated and your logo is gone. You need assets/* for the first line, so the directory is walked and its contents are excluded individually.
And git check-ignore -v path/to/file ends every argument about this in two seconds. It prints the file, the line number and the exact pattern doing the ignoring, including when the culprit is your global ignore rather than anything in the project. I reach for it maybe twice a month and it has never once failed to be the fastest route to the answer.
Nine lines
That repository. Key rotated within the hour, history rewritten the following week after a slightly awkward conversation about who else had 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 had spotted the problem, written the correct line in the correct file, and reasonably assumed that was that. Nothing about the tool told them otherwise. git status stayed quiet, the file stayed committed, and the whole thing looked handled for two and a half years.
The .gitignore I eventually wrote them has nine lines in it. The old one had 160 🫠