.gitignore: what to ignore and why it stops working
A .gitignore rule has no effect on a file git already tracks. That one rule explains almost every case where ignoring appears broken.
.gitignore is a text file listing patterns git should pretend it cannot see:
node_modules/
*.log
.env
.DS_Store
It is normally at the root of the repository and committed like any other file, so the whole team shares it.
And there is one rule that explains nearly every complaint about it: ignoring only applies to files git is not already tracking. Everything here was measured on git 2.50.0.
The rule that catches everyone
An untracked file matching a pattern is invisible. Add *.env to .gitignore, create secret.env, and git status shows nothing. That is the case that works.
Now the case that does not. Track a file first, then ignore it afterwards:
echo data > tracked.log
git add -A && git commit -m "track the log"
# later, add *.log to .gitignore
echo "changed" >> tracked.log
git status --short
M tracked.log
Still showing. The pattern matches, the file is in .gitignore, and git reports it as modified anyway.
.gitignore answers one question: "should I start tracking this new file?" For a file already in the repository, that question was settled at the moment it was committed, and the ignore rules are never consulted again.
Fixing an already-tracked file
Tell git to stop tracking it, while keeping it on disk:
git rm --cached tracked.log
git commit -m "stop tracking the log"
After that, modifying the file produces nothing in git status. The ignore rule finally takes effect, because the file is now untracked.
--cached is doing the important work. Without it, git rm deletes the file from disk as well. With it, git forgets the file and leaves it exactly where it is.
For a whole directory, add -r:
git rm -r --cached node_modules
The nuclear version, for when several files are wrong at once:
git rm -r --cached .
git add .
git commit -m "apply gitignore"
That untracks everything and re-adds it, and the re-add respects .gitignore. It produces one large commit, and it is the standard fix after adding a proper ignore file to a project that never had one.
One warning that matters more than the rest. Untracking a file removes it from future commits and does not remove it from history. A password committed last month is still in the repository and still visible to anyone who clones it. git rm --cached does not fix that, and the only real answer is to treat the secret as compromised and rotate it.
Debugging a pattern
When a file is ignored and you cannot see why:
git check-ignore -v secret.env
.gitignore:1:*.env secret.env
File, line number, the exact pattern, and the path it matched. This answers the question directly instead of by inspection, and it works across multiple ignore files, so it also tells you which .gitignore is responsible when a subdirectory has its own.
Silence from this command means nothing is ignoring the file, which is equally useful to know.
The patterns
| Pattern | Matches |
|---|---|
*.log |
Any .log file, at any depth |
build/ |
A directory called build, anywhere |
/build |
build only at the repository root |
doc/*.txt |
.txt files directly in doc, not deeper |
doc/**/*.txt |
.txt files at any depth under doc |
!keep.log |
Un-ignore this, even if a rule above matched |
# comment |
A comment |
Two details worth knowing.
The trailing slash means "directory only". build/ will not match a file named build, which occasionally matters and mostly just documents your intent.
A leading slash anchors to the root. Without it, patterns match at any depth, which is usually what you want and occasionally too broad.
Negation, and where it fails
*.log
!keep.log
Measured, keep.log shows up as untracked while other.log stays hidden. Later rules win, so the negation overrides the pattern above it.
There is a limitation that surprises people:
build/
!build/keep.txt
That does not work. Once a directory is excluded, git does not look inside it, so the negation never gets a chance to match. Exclude the contents instead:
build/*
!build/keep.txt
Now git descends into build, ignores its contents, and the exception applies.
Where ignore rules can live
.gitignore in the repository, committed and shared. This is for things nobody on the project should track: dependencies, build output, secrets.
A nested .gitignore in any subdirectory, applying from there down. Useful in a monorepo where one package has its own build artefacts.
A global ignore for your own machine:
git config --global core.excludesfile ~/.gitignore_global
This is where your editor's files belong. .idea/, .vscode/ and .DS_Store are facts about your setup, not about the project, and putting them in the project's .gitignore pushes your tooling choices onto everyone else. Not everyone agrees, but the global file is the tidier place for them.
.git/info/exclude for rules that are personal to one clone and should not be committed anywhere. Rare, and occasionally exactly right.
What belongs in it
The general test: can this be regenerated, or should it never be shared?
- Dependencies:
node_modules/,venv/,vendor/ - Build output:
dist/,build/,*.pyc,__pycache__/ - Secrets:
.env,*.pem, credentials files - Logs and temporary files:
*.log,tmp/ - OS and editor files:
.DS_Store,Thumbs.db
What should not be ignored: lockfiles. package-lock.json and its equivalents are the record of exactly which versions were installed, and committing them is what makes a build reproducible. Ignoring them is a common mistake that produces "works on my machine" installs.
For a starting point, GitHub's collection of templates covers most languages and frameworks. Take one and delete the parts that do not apply, rather than accepting a hundred lines you have not read.
Adding one to a project that never had it
The common real task, and it has an order that avoids surprises.
1. Find out what is currently tracked that should not be.
git ls-files | grep -E "node_modules|\.env|dist/"
git ls-files lists every tracked file, which is the authoritative answer to "what is in this repository". It is more reliable than looking at the directory, because it shows what git actually has.
2. Write the .gitignore. Start from a template and delete what does not apply.
3. Untrack what is now ignored.
git rm -r --cached .
git add .
git status
Read the status output before committing. It should show deletions for exactly the files you meant to untrack and nothing else. If it lists something you did not expect, your patterns are too broad.
4. Commit.
git commit -m "apply gitignore"
5. Tell the team. Their next pull will delete those files from their working directories, because git sees the deletion as a change. For generated files that is harmless; for a .env that everyone has locally it is worth a warning first.
Checking your work
Three commands answer the questions that come up afterwards:
git status --ignored # show ignored files as well as the rest
git ls-files # everything currently tracked
git check-ignore -v <f> # which rule matches this path
git status --ignored is the one to run when you suspect something is being hidden that should not be. The default output deliberately says nothing about ignored files, which is helpful day to day and unhelpful when a file has gone missing from git status and you want to know why.
Adding an ignored file on purpose
Occasionally you need to commit something the rules exclude:
git add -f build/required-artifact.bin
-f overrides the ignore rules for that one add. Once the file is tracked, the ignore rule stops applying to it for the reason at the top of this page, so it stays tracked from then on without needing the flag again.
Use it sparingly. A file that has to be forced past your own rules is usually a sign the rules are wrong, and editing .gitignore with a negation is clearer than a flag nobody will remember was used.
Three common mistakes
Expecting a new rule to hide an already-tracked file. It never will. This is the rule at the top of the page and it accounts for most of the confusion about .gitignore.
Committing a secret, then ignoring it. The ignore rule stops future commits; it does not remove the secret from history. Anyone who clones the repository still has it. Rotate the credential and treat the history as compromised.
Ignoring lockfiles. package-lock.json and its equivalents are what make an install reproducible. They belong in the repository, and ignoring them is a reliable way to produce builds that differ between machines.
Quick reference
git check-ignore -v <file> # which rule is ignoring this
git rm --cached <file> # stop tracking, keep on disk
git rm -r --cached . # untrack everything, then re-add
git status --ignored # show ignored files too
git add -f <file> # add something despite the rules
Want to try this in a repository where a mistake costs nothing? Work through Git basics in the browser.
More from the blog
Objects are not valid as a React child: what to render
Objects are not valid as a React child, and React tells you which one by listing its keys. In React 19 the promise version throws a different error.
Read morenpm install errors: read the code, not the stack
Every npm ERR code names one cause. ERESOLVE, E404, EJSONPARSE and the rest, with what each one means, measured against real installs.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.