A .gitignore rule either works or it does not, and git tells you neither. This tester does what git check-ignore -v does, in your browser, and adds the two things that command leaves out: the rules that also matched but lost, and the reason a negation is not firing.
What the tester does
It implements git's pattern matching rather than approximating it: * that stops at a slash, ** that does not, character classes, the trailing slash for directories, the leading slash for anchoring, the implicit anchoring that any slash in the middle of a pattern brings with it, escaped # and !, and trailing spaces stripped unless they carry a backslash.
It also walks the path the way git does, one directory at a time. That matters more than the glob rules, because it is the reason negations behave the way they do: when a directory is excluded, git stops descending into it, and every rule about what is inside becomes unreachable.
The output line format is git's own, so you can compare it directly against what your terminal prints:
.gitignore:5:logs/ logs/keep.log
.gitignore:8:!.env.example .env.example
:: src/main.ts How to read the report
Paste your .gitignore in the first field and the paths in the second, one per line, relative to the repository root. End a directory with a slash: build/ is a directory and build is a file, and several rules treat them differently. Paths do not need to exist anywhere.
- Result. One line per path with the verdict and the deciding rule, including the directory that caused it when the path was excluded through a parent.
- Findings. Negations that cannot fire, rules that match nothing, backslash separators, trailing spaces, directory-only patterns that look like file names, and the already-tracked reminder.
- git check-ignore -v --non-matching. The same result in git's output format.
--all-rules
Shows every rule that matched a path in order, with the overridden ones struck through and the winner in green. git check-ignore -v only ever prints the winner, which is fine when you know why it won and useless when the question is why your rule did not.
--dead-rules
Lists rules that match none of the paths you pasted. Expected for a template .gitignore covering languages you do not use, and worth a look when the rule was written for one of the paths in front of you. The usual cause is anchoring.
The first answer: it is already tracked
Before the pattern rules, the rule that overrides all of them: gitignore applies only to untracked files. Once a file is in the index, git keeps reporting changes to it no matter what any ignore file says. Adding .env to .gitignore after committing .env changes nothing, and the credentials stay in the history.
| Question | Command |
|---|---|
| Is git tracking this? | git ls-files <path> |
| Stop tracking, keep the file on disk | git rm -r --cached <path> |
| Which rule decided this path? | git check-ignore -v <path> |
| What would a fresh clone ignore? | git status --ignored |
After git rm --cached the file shows up as a deletion in the next commit. That is expected: it is being deleted from the index, not from your working tree. Everyone else pulling that commit does lose their copy, though, which is worth mentioning in the commit message.
Removing the file from the index does not remove it from the history. If what leaked was a secret, rotate it. Rewriting history with git filter-repo is possible and does not help you at all if the repository was ever pushed.
The negation trap
This is the one that costs people an afternoon. It looks obviously correct:
logs/
!logs/keep.log And logs/keep.log stays ignored. The reason is in the manual in one sentence: it is not possible to re-include a file if a parent directory of that file is excluded. Git excludes logs/, stops descending, and never reaches the second line. This is a performance decision, not an oversight, and it is not going to change.
Exclude the contents instead of the directory:
logs/*
!logs/keep.log Now the directory itself is never excluded, only what is in it, and the negation is reachable. For a file further down, every directory on the way has to stay included:
logs/*
!logs/archive/
logs/archive/*
!logs/archive/keep.log The tester reports this case explicitly rather than just printing "ignored", because the verdict alone sends you looking at the negation, which is the one part of the file that is correct.
The pattern rules, in full
| Pattern | Matches |
|---|---|
build | a file or directory named build, at any depth |
build/ | only a directory named build, at any depth |
/build | build next to this .gitignore only |
doc/frotz | doc/frotz relative to this .gitignore; the slash anchors it |
*.log | any .log file at any depth |
a/*.log | .log files directly in a, not in a/b |
**/temp | temp at any depth, same as plain temp |
a/** | everything inside a |
a/**/b | a/b, a/x/b, a/x/y/b |
? | one character, never a slash |
[0-9], [!a-z] | a character class, negated with a leading exclamation mark |
!keep.txt | re-include, unless a parent directory is excluded |
\#notes | a file literally called #notes |
logs\ | a name ending in a space; unescaped trailing spaces are dropped |
Two things that are not in the table because they are not rules but mistakes. Backslashes are escape characters, never separators, so build\temp is the single name "buildtemp" and matches nothing on any platform, Windows included. And a comment only starts where # is the first character of the line: a # in the middle of a pattern is part of the pattern.
The other three ignore files
The repository .gitignore is one of four places git looks, and a rule you cannot find is usually in one of the others.
- A .gitignore in any directory from the root down to the file, deeper files overriding shallower ones. Checked first, wins first.
.git/info/exclude, per clone and never committed. The right place for a personal scratch file.- The global excludes file, wherever
core.excludesFilepoints. Unset by default; the place for.DS_Store,Thumbs.dband your editor's directory. - Command-line patterns passed to plumbing commands, which is how tooling adds temporary exclusions.
Our own take on where a rule belongs: build output, dependency directories and anything generated go in the repository file, because they are properties of the project. Editor and OS noise goes in the global file, because it is a property of your machine and nobody else should have to carry it. A .gitignore with .idea/ in it is a small argument waiting to happen on a team where half the people use something else.
The guide on gitignore not working goes through the tracked-file case and the three ignore layers in more detail.
Pattern questions
Does a gitignore pattern match the file name or the whole path?
It depends on whether the pattern contains a slash anywhere except at the end. Without a slash, build matches a directory or file called build at any depth, so it also catches src/vendor/build. With a slash inside it, doc/frontpage.txt is anchored to the directory holding the .gitignore and matches nothing else. This is the rule behind most surprises: people write logs/*.log expecting it to work everywhere, when the pattern they want is **/logs/*.log or simply *.log.
How do I use git check-ignore?
git check-ignore -v <path> prints the ignore file, the line number and the pattern that decided the path, which is the fastest way to end an argument about a rule. Add --non-matching to also see the paths that no rule touched, and -- before the paths so a leading dash is not read as an option. It works on paths that do not exist yet, which makes it useful for testing a rule before creating the file.
Why does my ! negation not work?
Because git does not descend into an excluded directory, so nothing inside it can be re-included. logs/ followed by !logs/keep.log leaves keep.log ignored, and git never even looks at it. Exclude the contents instead of the directory: logs/* then !logs/keep.log. If the path is deeper, every directory on the way has to stay included, which usually means logs/* plus !logs/sub/ plus !logs/sub/keep.log.
What is the difference between build and build/ in .gitignore?
The trailing slash restricts the rule to directories. build matches both a file and a directory called build anywhere in the tree; build/ matches only the directory. Use the slash when you mean a directory, because it stops a file of the same name from disappearing and it lets git skip the whole subtree instead of walking it.
What does a leading slash do in .gitignore?
It anchors the pattern to the directory containing the .gitignore. /build matches build at the repository root and not src/build. The same happens implicitly whenever a pattern contains a slash anywhere except at the end, so doc/frotz matches only doc/frotz at the top level, while frotz on its own matches at any depth. That asymmetry is the single most common reason a rule silently matches nothing.
What is the difference between * and ** in gitignore?
A single asterisk never crosses a directory separator, so *.log matches app.log and src/app.log by basename but a/*.log matches only one level down. Two asterisks do cross: **/temp matches temp at any depth, a/** matches everything inside a, and a/**/b matches a/b, a/x/b and a/x/y/b. The ** form only has its special meaning when it is a whole path segment.
How do I ignore all files except one?
Ignore everything, re-include the directories, then re-include the file: * on the first line, !*/ on the second so git can still descend, and !keep.txt on the third. Without the !*/ line every directory is excluded and the third rule never gets a chance to fire. This is the pattern behind the common "empty directory with a .gitkeep" recipe.
Which .gitignore rule wins when several match?
The last matching line in the last file consulted. Within one file it is simply the bottom-most match, which is why a broad rule placed after a narrow one silently undoes it. Across files, a .gitignore in a subdirectory overrides the one above it, and both override .git/info/exclude and the global excludes file. The tester above shows every rule that matched and strikes through the ones that lost.
Where is the global gitignore file?
Wherever core.excludesFile points, and by default nowhere. Run git config --global core.excludesFile to see the current value and git config --global core.excludesFile ~/.gitignore_global to set one. It is the right home for editor and OS noise such as .DS_Store, Thumbs.db and .idea/, which belong to your machine and not to the project. Rules that everyone on the team needs belong in the repository instead.
Can I ignore a file only locally, without committing the rule?
Yes, .git/info/exclude takes the same syntax and is not part of the repository, so it never gets pushed. It is the correct tool for a scratch file or a local config that only you have. The other suggestion you will find, git update-index --skip-worktree, does something different: it hides changes to a file that is already tracked, and it makes pulls fail in confusing ways as soon as someone else changes that file.
Does .gitignore work for files inside a subdirectory that has its own .gitignore?
Yes, and the deeper file wins where both match. Git reads the .gitignore in every directory from the repository root down to the file, and applies them in that order, so a rule in src/.gitignore overrides one at the root. That is useful for keeping build output rules next to the thing that builds, and it is also why a rule you cannot find may be two directories down.
Why does git status still show a file I ignored?
Either it is tracked, in which case gitignore does not apply, or the entry you added is being overridden by a later rule or a deeper .gitignore. Run git check-ignore -v on the path: no output at all means no rule matches and the pattern is wrong, and output pointing at a negation means a ! line re-included it. The tester above gives the same answer plus the rules that were overridden along the way.