A .gitignore rule that plainly should work, and plainly doesn't, is one of Git's more maddening five minutes. The good news is that the cause is nearly always one of a short list of things — and Git will tell you which, if you ask it the right way.
The 30-second triage. Rungit check-ignore -v path/to/file. If it names a rule, that rule is the one deciding — read it and jump to the matching section below. If it prints nothing, no rule matches at all, and the file is either already tracked or your pattern isn't matching the path you think it is.
1. The file is already tracked
This is the single most common cause, and it is not a pattern bug at all..gitignore governs which untracked files Git notices. Once a file is in the index — because someone committed it before the rule existed — Git considers the matter settled and stops consulting the ignore rules for it entirely. The rule you just wrote is correct, and inert.
Check whether that's what you're looking at:
git ls-files --error-unmatch path/to/fileIf that prints the path, the file is tracked. The fix is to remove it from the index while leaving it on disk:
git rm --cached path/to/file
git commit -m "Stop tracking path/to/file"For a directory, add -r:git rm -r --cached path/to/folder. If a long-neglected ignore file has accumulated many such files, you can re-apply the whole thing at once — but only with a clean working tree, since it restages every file in the repository:
git rm -r --cached .
git add .
git commit -m "Apply .gitignore"One caveat worth knowing before you push: for everyone else, that commitdeletes the file from their working copy when they pull. That is usually what you want for build output, and emphatically not what you want for a config file each developer maintains locally. More detail on this case lives on.gitignore not working on an already-tracked file.
2. The pattern isn't matching what you think
Assuming the file is genuinely untracked, the pattern is the next suspect. The rules that catch people most often:
- A slash anchors the pattern. A pattern containing a slash anywhere other than the end is anchored to the directory holding the
.gitignore. Sosrc/*.logmatchessrc/debug.logand nothing deeper. A pattern withno slash matches by basename at any depth:*.logcatchesdebug.log,src/debug.loganda/b/c/debug.logalike. - Trailing spaces are discarded. Git strips trailing whitespace unless you escape it, so
foois simply the rulefoo. Handy when it's a typo, confusing when you meant it — escape the final space with a backslash. - A leading
#is a comment. To ignore a file whose name genuinely starts with#, escape it:\#notes.txt. A leading!needs the same treatment:\!important. - Always use forward slashes. Backslashes are escape characters in gitignore patterns, not separators — on Windows too. A rule written
src\tempdoes not do what it looks like. - A malformed pattern fails silently. An unterminated character class such as
[abcdoes not degrade into a literal bracket; Git abandons the match and the rule quietly matches nothing, indistinguishable from never having written it.
Why a .gitignore pattern isn't working works through these one at a time with examples.
3. The .gitignore is in the wrong place
Patterns are relative to the directory containing the.gitignore, which makes location part of the rule's meaning. A rule reading /config.json in the repository root matches only the root file; the same line insidesrc/ matches only src/config.json.
Things to rule out here:
- the file is named
.gitignore, with the leading dot — notgitignore, and not.gitignore.txt, which is an easy one to end up with on Windows - it is inside the repository, not in the directory above it
- it is saved. A rule takes effect as soon as it exists on disk — you do not need to commit
.gitignorefor it to work — but an unsaved editor buffer is not on disk
Git also reads rules from two places that aren't a.gitignore at all: .git/info/exclude, which is repo-local and never committed, and your global excludes file. A rule you cannot find anywhere in the repository is often in one of them:
git config --get core.excludesFile4. Wildcards don't cross directory boundaries
* matches any run of characters except the directory separator. This is the single most common reason a rule under-matches. Against docs/api/notes.md:
docs/*.md → no match; * will not cross the slash in api/notes.md
docs/**/*.md → matchesThe full set is smaller than people expect:
*— any characters except/;?— exactly one character except/[0-9],[abc]— one character from a set;[!abc]negates the set**is meaningful in exactly three positions: leading (**/foo, matchingfooat any depth), trailing (foo/**, everything insidefoo), and between two slashes (a/**/b, zero or more directories betweenaandb)
Anywhere else, a doubled asterisk is just * — sofoo**bar buys you nothing over foo*bar.
5. Folder rules and the trailing slash
A trailing slash makes a rule directory-only:build/ matches a directory namedbuild and never a file of that name, whilebuild matches either. If a stray file namedbuild is what you're trying to catch, the slash is what's stopping you.
The more common surprise is the other direction. Once Git ignores a directory it does not descend into it, so nothing inside can be reconsidered — including by a negation on the very next line:
build/
!build/keep.txt
$ git check-ignore -v build/keep.txt
.gitignore:1:build/ build/keep.txt ← line 2 never firesIgnore the directory's contents instead, which leaves the directory itself un-ignored and therefore walkable:
build/*
!build/keep.txt
$ git check-ignore -v build/keep.txt
.gitignore:2:!build/keep.txt build/keep.txt ← the negation winsThe same shape is how you keep an otherwise-empty directory in the repository — logs/* followed by!logs/.gitkeep. Seewhen a .gitignore folder rule isn't working for the variations.
6. Negation (!) and rule precedence
Two things about ! account for nearly every negation that appears broken. The first is order: within a.gitignore, the last matching pattern decides, not the first. Identical rules in the opposite order give opposite results for important.log:
*.log
!important.log → important.log is committed
!important.log
*.log → important.log is ignoredSo broad rules first, exceptions after them. The second is the directory rule from the previous section: a negation cannot rescue a file out of an ignored directory, because Git never looks in there to find it. To whitelist deliberately, re-include each level on the way down:
# ignore everything at the top level...
/*
# ...except these
!/src
!/package.json
!/.gitignore/* matches only top-level entries, so re-including/src is enough to make Git walk into it and pick up everything beneath.
7. A nested .gitignore is overriding yours
Rules from a .gitignore in a subdirectory take precedence over rules from one higher up, for the paths beneath it. A single forgotten file three directories down can quietly countermand the root ignore file — and because the rule that wins lives somewhere you weren't looking, this one can burn real time:
.gitignore contains *.log
src/logs/.gitignore contains !debug.log
$ git check-ignore -v src/logs/debug.log
src/logs/.gitignore:1:!debug.log src/logs/debug.logNote that Git names the file that decided, which is exactly what makes this findable. To see every ignore file in the repository at once:
git ls-files --cached --others --exclude-standard -- "*.gitignore"8. Ask Git directly: git check-ignore -v
Everything above is a guess until you check. This is the authoritative answer, and it takes one command:
$ git check-ignore -v src/debug.log
.gitignore:3:*.log src/debug.logThe fields are the source file, the line number, the pattern, then a tab and the path you asked about. Three details make it much more useful:
- No output means no rule matched. If the file still shows up in
git statusandcheck-ignoreis silent, you are looking at cause 1 or cause 2 — a tracked file, or a pattern that never matched the path. --no-indexdistinguishes those two. By defaultcheck-ignorestays silent about tracked files. Add--no-indexand, if a rule prints, you have proof the pattern is fine and the file being tracked is the whole problem.- Don't read the exit code when using
-v.With-v, the exit status is 0 whenever anypattern matched — including a negation that leaves the filenot ignored. Read the printed pattern instead: if it starts with!, the file is being included, not ignored.
For the wider view, git status --ignored lists every ignored path, which is a quick way to spot a directory you never meant to exclude. And when Git is ignoring a file you wanted committed rather than the other way round,why Git isn't ignoring a file covers that direction.