What is a .gitignore file?
A .gitignore file is a plain text file that tells Git which files to leave out of version control. Each line is a pattern — *.log, node_modules/, .env — and any untracked path matching a pattern is hidden from git status, skipped by git add ., and never committed.
The word untracked is carrying most of the weight in that sentence, and it explains more cases of gitignore not working than every pattern bug combined. Git only consults .gitignore for files it is not already tracking. Once a file has been committed, Git keeps tracking it forever, no matter what you add to .gitignore afterwards.
What the Gitignore Debugger does
Paste a file path and your .gitignore contents, and this .gitignore debugger reports the same verdict Git would reach — ignored or not ignored — along with the specific rule and line number responsible. It then lists every rule that matched the path, in the order Git evaluates them, and marks the one that actually decided the outcome.
That last part is what a plain gitignore checker usually leaves out. Learning that a file is ignored is rarely the hard part. Learning which of your forty rules did it — and why the rule you expected to win lost — is. It works in both directions, too: why Git is ignoring your file when you want it committed, and why it is not ignoring one you want gone.
The matcher reimplements Git's own pattern rules: basename versus anchored patterns, * never crossing a /, the three ** forms, directory-only / suffixes, character classes, escaped trailing spaces. Its behaviour is checked against real git check-ignore output rather than guessed at.
How to use it
- Enter the path as it sits in your repository, relative to the repo root —
src/config/local.json, notC:\projects\app\src\config\local.json. Use forward slashes; Windows backslashes are converted for you. - Paste your whole
.gitignore. Resist trimming it down to the one rule you suspect — precedence between rules is usually the actual bug, and cutting the file throws that information away. - Read the verdict, then read the rule chain beneath it. The chain is where a gitignore rule not working stops being a mystery.
To test gitignore rules against a directory rather than a file, end the path with a slash — build/. That distinction matters, because a rule ending in / only ever matches directories.
Why gitignore rules sometimes don't work
Roughly in order of how often each one turns out to be the culprit:
- The file is already tracked.
.gitignoreis not retroactive. If the file was committed before the rule existed, the rule is skipped entirely. Rungit rm --cached path/to/fileand commit that; the file stays on disk, and Git stops tracking it. - A later rule overrode it. Git does not stop at the first pattern that matches.
- A parent directory is ignored. The rule you wrote for the file is never even reached.
- The pattern is anchored when you thought it was loose. A pattern containing a slash anywhere but the end is anchored to the directory holding the
.gitignore, sosrc/*.logmatchessrc/debug.logand nothing deeper. A pattern with no slash at all matches by basename at any depth:*.logcatchesdebug.log,src/debug.loganda/b/c/debug.logalike. - The pattern is malformed. An unterminated character class such as
[abcdoes not fall back to a literal[. Git aborts the match, and the rule silently matches nothing at all — indistinguishable from never having written it. In the debugger it simply never appears in the list of matching rules.
Gitignore rule precedence: the last match wins
This is the one that surprises people. Within a single .gitignore, the last pattern that matches a path decides its fate — not the first. These two files contain identical rules and produce opposite results for important.log:
*.log
!important.log → important.log is committed!important.log
*.log → important.log is ignoredSo put broad rules first and exceptions after them. Across files, a .gitignore in a subdirectory overrides one higher up for paths beneath it. Git also reads .git/info/exclude, which is repo-local and never committed, and your global core.excludesFile. A rule you cannot find in any .gitignore is often hiding in one of those two — git check-ignore -v prints the file and line that decided.
Negation rules, and why ! often fails
Prefixing a pattern with ! re-includes what an earlier rule ignored. It has two limits, and the second accounts for nearly every report of gitignore negation not working.
First, something earlier has to have ignored the path. !important.log on its own does nothing, because there was never anything to override.
Second, and far more surprising: you cannot re-include a file inside an ignored directory. This does not work, and no amount of reordering will fix it:
build/
!build/keep.txtWhen build/ is ignored, Git never descends into it. It does not examine build/keep.txt, so it never reaches line 2. Ignore the directory's contents instead, which leaves the directory itself walkable:
build/*
!build/keep.txtTo rescue something deeper, every intermediate directory has to stay reachable as well — build/*, then !build/assets/, then !build/assets/logo.svg.
Ignored parent directories
Git's directory walk stops at the first ignored directory it meets, and everything below that point inherits the verdict without being examined. That is why an ignored parent beats any rule written for the file itself, however specific that rule looks.
When this happens, the debugger names the parent that blocked the walk rather than just saying the file is ignored, and it still shows the negation rules you wrote for the file — flagged as unreachable, because Git never got far enough to consider them. Fix the directory first and the rules below it start working on their own.
Common .gitignore mistakes
- Adding the rule after committing the file. The rule is correct and has no effect. See
git rm --cachedabove. - Trailing comments.
#only starts a comment at the beginning of a line, so*.log # debug outputis one long pattern that matches nothing you own. - Backslashes as separators. In
.gitignore,\is an escape character, never a path separator.src\configmeans the literal namesrcconfig. Always writesrc/config. - Expecting
*to cross directories.*stops at a slash, sosrc/*/test.jsreaches exactly one level down. Usesrc/**/test.jsfor any depth. - Dropping the trailing slash.
logsmatches a file namedlogsas well as the directory;logs/matches only the directory. - Assuming ignoring removes history. A rule stops future commits. It does not delete a secret you have already pushed — rotate the credential.
A few worked examples
Path src/logs/debug.log against these rules is not ignored. The negation is the last match, and no parent directory blocks the walk, so it is reached and it wins:
*.log
!src/logs/debug.logPath build/keep.txt against these rules is ignored, decided by build/ on line 1 matching the parent directory. Line 2 never fires:
build/
!build/keep.txtPath docs/api/notes.md against this rule is not ignored, because the pattern is anchored by its slash and * will not cross the one in api/notes.md. docs/**/*.md is what was meant:
docs/*.mdPaste any of these into the gitignore tester above to see the full rule chain. When you are back at a terminal, git check-ignore -v path/to/file is the built-in equivalent — it names the source file, line number and pattern that decided, and it is worth knowing.