You have written the rule. The file is still there ingit status, in the staging area, in the diff. What follows is a diagnostic order of operations for one stubborn file — each step rules out a cause, so by the end you either have the answer or a single command that gives it to you.
Establish this first. Rungit ls-files --error-unmatch path/to/file. If it prints the path, the file is tracked and no ignore rule will ever apply to it — skip to step 1. If it errors, the file is untracked and you are genuinely debugging a pattern: start at step 2.
1. The file is tracked — which includes merely staged
.gitignore decides which untracked files Git pays attention to. Once a path is in the index, Git treats its fate as already settled and stops consulting the ignore rules for it. The rule you wrote is correct and inert.
The part that catches people out is that tracked does not meancommitted. A file staged with git add — or swept up by a git add . you ran before writing the rule — is in the index and therefore exempt, even though it has never been committed:
$ git add staged.log # never committed, just staged
$ git check-ignore -v staged.log
# nothing. exit 1.
$ git check-ignore -v --no-index staged.log
.gitignore:1:*.log staged.logThat pair of commands is the clearest possible diagnosis: the pattern matches fine, and the index is what's overriding it. Ingit status --short the same file shows asA rather than ??. Either way the fix is to take it out of the index and leave it on disk:
git rm --cached path/to/file
git commit -m "Stop tracking path/to/file"If it was only staged and never committed,git rm --cached is enough on its own and there is nothing to commit. For a directory, add -r.
2. The path Git tests isn't the path you typed
Assuming the file really is untracked, the next question is what path the pattern is being compared against. Git matches patterns against the file's path relative to the directory holding the.gitignore, and whether a pattern is anchored to that directory or floats to any depth depends entirely on whether it contains a slash.
Four rules that all look like they targetdebug.log, against the same three files:
rule debug.log src/debug.log src/logs/debug.log
debug.log ignored ignored ignored
/debug.log ignored — —
src/debug.log — ignored —
**/debug.log ignored ignored ignoredSo a pattern with no slash at all matches by basename everywhere. A pattern with a slash anywhere but the endis anchored, and matches at exactly one depth. If your rule issrc/debug.log and the file is actually atsrc/logs/debug.log, the rule is doing precisely what it says and precisely not what you wanted.
3. The pattern doesn't match the filename
Once the depth is right, the pattern text itself is next:
- Case matters, and machine-dependently. Patterns are matched case-sensitively unless
core.ignorecaseis on, so*.LOGdoes not catchdebug.logon a case-sensitive filesystem. Git sets that option automatically from the filesystem when the repository is created — usually on for Windows and macOS, off for Linux — which is exactly how a rule works on your laptop and fails on a Linux CI runner. Check withgit config --get core.ignorecase, and write patterns in the same case as the files. - A trailing slash restricts the rule to directories.
debug.log/will never match a file calleddebug.log, no matter how correct the rest of it looks. - Trailing spaces are discarded unless escaped with a backslash, so a rule that looks padded is really the trimmed version.
- A leading
#or!is syntax.To match a filename that genuinely starts with one, escape it:\#notes.txt,\!important. - Use forward slashes only. A backslash is an escape character in gitignore patterns, not a separator — on Windows as well.
- A malformed pattern fails silently. An unterminated character class like
[abcdoes not fall back to a literal bracket; Git abandons the match and the rule matches nothing at all.
4. A wildcard stopped at a directory boundary
* matches any run of characters except the directory separator, which is the usual reason a rule under-matches by one level:
src/*.log → matches src/debug.log, but not src/logs/debug.log
src/**/*.log → matches both? is the single-character equivalent and stops at/ too. ** is only meaningful in three positions — leading (**/debug.log), trailing (src/**) and between two slashes (src/**/debug.log). Anywhere else it degrades to a plain*, so debug**.log gains you nothing.
5. A nested .gitignore is deciding instead
Rules from a .gitignore closer to the file take precedence over rules from further up, and their patterns are relative to their own directory. A leading slash therefore anchors to that directory, not the repository root:
src/.gitignore contains /debug.log
$ git check-ignore -v src/debug.log
src/.gitignore:1:/debug.log src/debug.log
$ git check-ignore -v src/logs/debug.log
# no match — the rule is anchored to src/The same mechanism lets a nested file re-include something the root ignored, which is worth checking when a rule seems to be working everywhere except one subtree. To list every ignore file in the repository at once:
git ls-files --cached --others --exclude-standard -- "*.gitignore"Two more sources never show up in that listing:.git/info/exclude, which is repo-local and uncommitted, and your global excludes file — git config --get core.excludesFile.
6. A negation rule re-included it
If the file is ignored almost everywhere but not here, look for a!. The last matching pattern wins, so a negation placed after a broad rule carves an exception out of it:
*.log
!src/logs/debug.log
$ git check-ignore -v debug.log
.gitignore:1:*.log debug.log ← ignored
$ git check-ignore -v src/logs/debug.log
.gitignore:2:!src/logs/debug.log src/logs/debug.log ← re-includedBoth files match *.log; the second one also matches the negation, and because the negation is later it decides. If that exception is not what you want, move it above the broad rule or delete it. Ordering is the whole mechanism here — broad rules first, exceptions after.
7. Ask Git which rule decided
Every step above is a hypothesis. This is the answer, and it is one command:
$ git check-ignore -v src/logs/debug.log
.gitignore:3:*.log src/logs/debug.logThe fields are the source file, the line number and the pattern, then a tab and the path you asked about. Three things make it far more useful than it first appears:
- Silence means no rule matched. If the file still shows in
git statusand this prints nothing, you are looking at step 1 or step 2 — tracked, or a pattern that never matched the path. --no-indexseparates those two. It tells Git to ignore the index when deciding. If a rule appears only with that flag, you have proof the pattern is fine and being tracked is the entire problem.- Don't trust the exit code with
-v. With-vthe status is 0 whenever any pattern matched, including a negation that leaves the file not ignored. Read the printed pattern instead: a leading!means the file is being included.
git status --ignored gives the complementary view, listing every path currently being excluded — useful for spotting a whole directory you never meant to catch. For the broader tour of why ignore rules misfire, including folder rules and whitelisting, seegitignore not working.