■ the answer first

.gitignore "not working" almost always means the file is already tracked — a file in the index is immune to any rule you write.

git check-ignore -v <path> decides it: printed rule = a rule problem; silence = tracked and no rule applies.

Fix: git rm --cached <path>, commit — the rule works from that commit forward.

THE TWO DOORS

One command, two outcomes — git check-ignore -v sends you through one of them:

DOOR 1 · it prints a rule

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

a rule applies — the wrong one, or in the wrong scope. The problem lives in the .gitignore file: rule order, negation traps, or which ignore file owns the path.

DOOR 2 · silence

$ git check-ignore -v src/.env
$ # (nothing — no rule applies)

the file is tracked anyway. Confirm with git ls-files --error-unmatch <path>, then git rm --cached <path> and commit the removal. The silence is the diagnostic.

exit codes are scriptable: 0 = ignored, 1 = not ignored — git check-ignore -q debug.log && echo ignored || echo tracked-or-unruly

THE SPECIMEN — A GITIGNORE, LINE BY LINE

The negation trap, annotated. Why !build/keep.me never rescues the file:

build/ignores the directory wholesale — git never looks inside again
!build/keep.medead letter: you cannot re-include a file inside an excluded directory
build/*the fix — ignore the contents, not the directory
!build/keep.menow alive: the parent path is walkable, the exception applies
*.log then !important.loglast matching rule wins — the negation after the rule re-includes the one file

THE FULL CAUSE TABLE

SymptomReal causeFix
Ignored file still shows in git statusCommitted before the rule — in the indexgit rm --cached + commit
git add . keeps re-adding itSame: tracking beats ignoringBulk: git rm -r --cached . → git add .
!build/keep.me never rescues the fileParent dir excluded — git skips it wholesalebuild/* + !build/keep.me
A later rule overrides yoursLast matching rule winsReorder, or read the winner via check-ignore -v
Rule works for teammates, not you (or reverse)Nested .gitignore / exclude / global mismatchMatch the scope table below
VS Code shows it modified after ignoringTracked — or a stale SCM viewgit rm --cached; reload window first
Want edits quiet, file still trackedIntentional local divergencegit update-index --skip-worktree

THE THREE SCOPES

Rules can live in three places, and "works for teammates, not for me" is almost always a scope mismatch:

Reading check-ignore -v output names the source directly: the ignore file, then line-number:pattern, then the path — so when a surprise rule wins, the winner identifies itself.

verify: git check-ignore -v debug.log → .gitignore:3:*.log debug.log

THE UNTRACK, STEP BY STEP

# untrack one file (the local copy survives — --cached touches the index only)
git rm --cached .env
git commit -m "stop tracking .env"

# verify the ignore rule now applies
git check-ignore -v .env

From this commit forward, .gitignore owns the path: edits no longer show in git status, and git add . will not pick the file up again.

If it was a secret, untracking is not the fix. The file stays in history — removing it from the latest commit is not enough. Rotating the credential is the real fix; history rewriting is the cosmetic one.

■ honest stamp Tested on: git 2.4x · Linux · macOS · Windows · VS Code SCM view. First published 2026-09-17. The index-immunity mechanism, last-match-wins precedence, the directory-negation rule and the exit codes are git's documented behaviour — the specimen above is the exact shape of every .gitignore that ever "didn't work".

end of the two doors · nothing fetched · everything local