🍱 Lunchbox Hands

git

Why Your .gitignore Isn't Working

The file is almost never the problem. Git ignores untracked files only, `!` negation is dead text inside an excluded directory, and check-ignore lies to you by default. Six rules, each demonstrated with real git 2.50.1 output, plus the untrack recipe that does not delete anything.

You added .env to .gitignore. You ran git status. The file is still there, still staged, still about to be pushed to a public repo. So you added it again, with a leading slash this time, then with a trailing slash, then you restarted your editor.

None of that is the problem. In almost every “gitignore not working” case, the pattern is matching perfectly and Git is deliberately ignoring the ignore rule — because of a rule that is documented in one sentence and read by almost nobody. Here are the six rules that explain every case, each one demonstrated below with real output from git 2.50.1.

Rule 1: gitignore only applies to untracked files

This is the big one. From the gitignore documentation: “files already tracked by Git are not affected.”

Once a file is in the index, ignore patterns stop being consulted for it. Forever. Watch:

$ git init -q && echo x > secret.env && git add -A && git commit -qm init
$ echo '*.env' > .gitignore
$ echo changed >> secret.env
$ git status --short
 M secret.env
?? .gitignore

The pattern is right there in .gitignore, it matches secret.env exactly, and Git still reports the modification. Nothing is broken. The file was tracked before the rule existed, so the rule does not apply to it.

The fix is to untrack it without deleting it:

$ git rm --cached secret.env
$ git status --short
D  secret.env
?? .gitignore

git rm --cached removes the file from the index but leaves it on disk. Commit that deletion, and from the next commit onward the ignore rule finally takes effect. Note that this does delete the file for everyone else on their next pull — which is exactly what you want for node_modules/, and exactly what you must warn your team about for anything else.

When several files are involved, reset the whole index instead of hunting them down one by one:

$ git rm -r --cached .
$ git add .
$ git status --short
A  .gitignore
D  node_modules/dep.js

Every file gets re-added except the ones your ignore rules now exclude, which show up as deletions. Nothing on disk is touched. Commit, and you are clean.

Rule 2: git check-ignore hides this from you by default

The natural debugging move is git check-ignore -v <path>, which prints the file, line number, and pattern that decided the outcome. On a tracked file it prints nothing and exits 1:

$ git check-ignore -v secret.env
$ echo $?
1

That empty output reads like “no rule matches this file,” which sends you back to editing patterns that were never wrong. check-ignore consults the index by default, and a tracked path is simply not ignorable. Add --no-index:

$ git check-ignore -v --no-index secret.env
.gitignore:1:*.env	secret.env

There is your rule, on line 1, matching perfectly. The difference between those two commands is the entire diagnosis: if --no-index finds a rule and the bare form does not, your problem is Rule 1, not your pattern.

Rule 3: You cannot re-include a file inside an excluded directory

The second sentence nobody reads, verbatim from the docs: “It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn’t list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined.”

That last clause — no matter where they are defined — means a nested .gitignore inside the excluded directory will not save you either. The obvious-looking version fails:

# .gitignore
build/
!build/keep.txt
$ git status --porcelain --ignored
!! build/
$ git check-ignore -v build/keep.txt
.gitignore:1:build/	build/keep.txt

Line 1 wins, the negation on line 2 is dead text, and git status --ignored collapses the whole thing to !! build/ — Git never descended into the directory at all. Change one character, excluding the directory’s contents instead of the directory itself:

# .gitignore
build/*
!build/keep.txt
$ git status --porcelain --ignored
!! build/junk.txt
?? build/
$ git check-ignore -v build/keep.txt
.gitignore:2:!build/keep.txt	build/keep.txt

Now line 2 wins and keep.txt is visible. This is why the VS Code preset in our .gitignore generator is written as .vscode/* followed by !.vscode/settings.json and friends, rather than the tempting .vscode/ — the trailing /* is what keeps the negations alive.

If your negation is dying, the multi-level version of the same trick works: exclude contents at each level (a/*, !a/b, a/b/*, !a/b/c.txt).

Rule 4: Last matching pattern wins — within one level of precedence

Git reads ignore patterns from four places. The docs give the order explicitly, highest to lowest:

#SourceTypical use
1Patterns passed on the command lineplumbing tools like git ls-files
2.gitignore in the path’s directory, or any parent up to the top of the working tree — lower-level files override higher-level onesversion-controlled, shared with the team
3$GIT_COMMON_DIR/info/excludethis clone only, not shared
4The file named by core.excludesFile, default $XDG_CONFIG_HOME/git/ignore (else ~/.config/git/ignore)your editor’s swap files, on every repo you touch

Within one level, the last matching pattern decides the outcome. So this file ignores debug.log, because the third line re-matches after the negation:

# .gitignore
*.log
!debug.log
*.log
$ git check-ignore -v debug.log
.gitignore:3:*.log	debug.log

Order your negations after the broad patterns they carve exceptions out of, never before. And when a rule seems to come from nowhere, check sources 3 and 4 — a stale ~/.config/git/ignore from three laptops ago is a genuinely hard bug to see, and check-ignore -v names the file for you.

Because level 2 lets deeper files override shallower ones, a nested .gitignore can re-include — as long as no parent directory is excluded (Rule 3):

# .gitignore
*.log
# keepdir/.gitignore
!z.log
$ git check-ignore -v --no-index keepdir/z.log
keepdir/.gitignore:1:!z.log	keepdir/z.log

*.log matches files, not the keepdir directory, so Git descends and the nested negation is honored.

Rule 5: A slash in the middle anchors the pattern

Patterns containing a slash anywhere except at the end are matched relative to the .gitignore file’s directory. Patterns without one match at any depth. That single difference explains most “why is it ignoring the wrong folder” reports:

# .gitignore contains: src/config
$ git check-ignore -v --no-index a/config/x.txt src/config/y.txt
.gitignore:1:src/config	src/config/y.txt

# .gitignore contains: config
$ git check-ignore -v --no-index a/config/x.txt src/config/y.txt
.gitignore:1:config	a/config/x.txt
.gitignore:1:config	src/config/y.txt

The rest of the pattern syntax, compressed:

PatternMeaning
build/Matches directories only — a file named build is not ignored
/buildAnchored to the .gitignore’s own directory
buildMatches a file or directory named build at any depth
src/buildAnchored, because the slash is not trailing
**/logsExplicitly “at any depth” — the default for slash-free patterns anyway
logs/**Everything inside logs, at any depth
a/**/bZero or more directories between a and b
*Anything except /
#commentA comment. For a literal leading #, escape it: \#file
!patternNegation. For a literal leading !, escape it: \!file

Rule 6: Trailing whitespace is silently discarded

“Trailing spaces are ignored unless they are quoted with backslash.” So a pattern that looks broken because of an invisible space is not broken at all:

# .gitignore contains: "trail.txt " (with a trailing space)
$ git check-ignore -v --no-index trail.txt
.gitignore:1:trail.txt	trail.txt

This cuts both ways — it is a convenience for stray whitespace, and a trap if you genuinely have a filename ending in a space (write file\ ). Note this is the opposite of the leading # and ! cases, which require escaping to be taken literally.

What we ship, honestly

Our .gitignore generator is a preset combiner: pick Node, Python, macOS, Windows, JetBrains, VS Code, Rust, Go, Java, or Logs, and it concatenates commented sections. It is a starting point, not an authority, and two of its presets encode opinions worth knowing about:

  • The Rust preset ignores Cargo.lock. That was the conventional library-vs-binary advice for years, but the Cargo FAQ’s current position is that cargo new tracks Cargo.lock by default and “whether you do is dependent on the needs of your package” — deterministic builds, git bisect, and MSRV verification all argue for committing it. If you are shipping a binary, delete that line.
  • The Go preset ignores vendor/. Fine if you rely on the module cache; wrong if you deliberately vendor for reproducible or air-gapped builds.

Also worth stating plainly: .gitignore is not a security control. It prevents accidental adds; it does nothing about a secret already committed, which lives in history until you rewrite it. If git log --all -- .env returns anything, rotate the credential — that is the only fix that actually works.

The short version

The assumptionWhat actually happens
”I added the pattern, so the file is ignored”Tracked files are never affected — git rm --cached first
check-ignore says no rule matches”It consults the index; add --no-index to see the real rule
build/ then !build/keep.txt keeps one file”Excluded directories are never descended into; use build/*
”My negation is at the top where I can see it”Last match wins — negations must come after the broad pattern
config and src/config are the same pattern”A mid-string slash anchors it; without one, it matches at any depth
”The pattern looks fine, so the whitespace is fine”Trailing spaces are stripped unless backslash-quoted
.gitignore protects my secrets”It prevents accidental adds; anything already committed must be rotated

Docker’s ignore file looks identical and follows entirely different matching rules — root-anchored, last-line-wins negation that does work inside excluded directories. If you maintain both, .dockerignore is not .gitignore covers the differences, and the .dockerignore generator is the companion to this one. For the everyday commands around all of this, there is the git cheat sheet.