How Git decides, in order
Almost every surprise comes from step 1, and almost every online tester skips it: they match each pattern against the path and take the last one that hits. Git does something else first.
- Walk the parent directories, from the repository root down. The moment one of them is ignored, that is the answer, and Git stops. It does not descend into an ignored directory at all, so no rule below can re-include anything inside it.
- Then look at the path itself. Git consults the
.gitignorefiles that govern it, deepest directory first, and stops at the first file that has any matching pattern. A shallower file is then not consulted at all, whatever it says. - Inside that one file, the last matching line wins. This is the rule everyone knows, and it only applies once the first two steps have not already decided.
- A pattern ending in
/matches directories only — and by step 1, that takes everything inside them too.
Pattern syntax
Every row is checked by a test against the same engine the tool above uses, so this table cannot drift from the behaviour.
| Pattern | Meaning | Ignores | Leaves alone |
|---|---|---|---|
build | No slash inside, so it matches a file or directory of that name at any depth. | build, src/build/, a/b/build | buildfile, src/build.ts |
build/ | A trailing slash makes it match directories only — and everything inside them. | build/, build/out.o, src/build/out.o | build, buildfile |
/build | A leading slash anchors the pattern to the directory of the .gitignore file. | build, build/out.o | src/build |
src/*.js | A slash inside also anchors it. `*` matches any run of characters except a slash. | src/app.js | src/nested/app.js, app.js |
**/node_modules | A leading `**/` means "in any directory" — the default for a pattern with no slash. | node_modules/, app/node_modules/pkg/index.js | my_node_modules |
dist/** | A trailing `/**` matches everything inside a directory, but not the directory itself. | dist/main.js, dist/a/b/main.js | dist/ |
logs/**/*.log | A `/**/` between slashes stands for zero or more directories. | logs/f.log, logs/a/b/f.log | logs/f.txt, f.log |
?.txt | `?` matches exactly one character, and never a slash. | a.txt, sub/b.txt | ab.txt |
[abc].log | A bracket expression matches one character from the set; ranges and `[!…]` work too. | a.log, c.log | d.log, ab.log |
!keep.txt | A leading `!` re-includes a path an earlier line ignored — unless a parent directory is gone. | — | keep.txt |
\#notes | A backslash escapes a leading `#` or `!`, and any wildcard, so it is taken literally. | #notes | notes |
Results that surprise people
Each of these is the verdict this tool gives, verified by a test. Paste any of them above to see the deciding line.
| .gitignore | Path | Result | Why |
|---|---|---|---|
| docs/ !docs/keep.md | docs/keep.md | Ignored — line 1: docs/ | Git never looks inside an ignored directory, so line 2 is never even read. This is the single most common “why is my file still ignored?”. |
| docs/* !docs/keep.md | docs/keep.md | Not ignored — line 2: !docs/keep.md | `docs/*` leaves the directory itself alone, so Git still descends into it and line 2 gets its say. |
| /* !/src | src/main.ts | Not ignored — no rule matched | `/*` matches only top-level names — `*` never crosses a slash — so nothing inside `src` was ignored in the first place. |
| build/ | buildfile | Not ignored — no rule matched | `build/` matches a directory called build at any depth. `buildfile` is neither that directory nor inside it. |
| *.log !sub/keep.log | sub/keep.log | Not ignored — line 2: !sub/keep.log | With only this root file the last matching line wins, so the file is kept. Add a `sub/.gitignore` containing `*.log` and the answer flips: the deepest file with any match decides on its own, and the root file — including line 2 — is not consulted at all. |
| logs | logs | Ignored — line 1: logs | The space is not part of the pattern unless you escape it as `logs\ `. To match a file whose name really ends in a space, you must. |
| src/*.js | src/nested/app.js | Not ignored — no rule matched | Write `src/**/*.js` if you meant every .js file below src. |
When this tool and git status disagree
There are three reasons, and none of them is a bug in the patterns:
- The file is already tracked. Git never ignores a file it has in the index, whatever
.gitignoresays. That is why adding a pattern for a committed file changes nothing until you rungit rm --cachedon it. - Other ignore sources.
.git/info/excludeand the globalcore.excludesFileadd patterns that are not in any.gitignore. This tool only reads the files you paste; on your machine,git check-ignore -vnames them. - Case sensitivity. Matching here is case-sensitive, which is Git's default. A repository
configured with
core.ignoreCase = true(common on macOS and Windows checkouts) can behave differently.
The same answer from the command line
In a repository, git check-ignore -v path/to/file prints the deciding file, line number and
pattern, and says nothing at all when the path is not ignored. Add -n to get a line for
non-matching paths too, and --no-index to ask about paths that are already tracked. That command
is where this tool's test vectors come from.
About this tool
The matching is a TypeScript implementation of the rules in gitignore(5) — anchoring, the
directory-only slash, *, ?, bracket expressions with POSIX classes,
** at the edges of a pattern, backslash escapes, trailing-space trimming, negation, nested
files, and the parent-directory rule. It is pinned by 147 golden vectors recorded from real
git check-ignore output (git 2.43.0) across 13 hand-built repositories, and cross-checked by a
differential fuzz that puts random .gitignore files and random paths through both this engine
and the git binary: roughly 20,000 comparisons over three seeds, with no disagreement. Both the
vectors and the fuzz compare the verdict and the file, line and pattern that decided.
No Git source code is copied: Git is GPL-2.0 and this project is not, so the behaviour was implemented from
the documentation and then verified against the program. Everything runs in your browser — the
.gitignore files and paths you paste are never uploaded.