← Gitignore Debugger

Gitignore Pattern Not Working? Fix Your Rule

Gitignore patterns are globs, not regular expressions, and they have a handful of behaviours that are easy to get wrong precisely because they look familiar. This is a reference for the syntax itself — what each construct matches, where it stops, and which combinations quietly do nothing.

The three questions Git asks of every pattern. Does it contain a slash anywhere but the end? Then it isanchored to the directory holding the.gitignore. Does it end with a slash? Then it matchesdirectories only. Does it start with !? Then it re-includes rather than excludes. Almost every broken pattern is a wrong answer to one of those three.

Anchoring: where the slash goes

A pattern with no slash at all matches by basename at any depth. Add a slash anywhere except the end and the pattern becomes anchored, matching relative to the directory containing the.gitignore — which usually means one specific depth:

pattern      foo    a/foo   a/b/foo
foo          yes    yes     yes        ← no slash: basename, any depth
/foo         yes    —       —          ← leading slash: this directory only
a/foo        —      yes     —          ← interior slash: exactly this path
**/foo       yes    yes     yes        ← explicit any-depth

Note that /foo and foo are different rules, while foo and **/foo are equivalent. A leading slash is the only way to say only at the top level.

* — stops at the directory separator

* matches any run of characters, including an empty one, but it will not cross a /. This single fact is behind most patterns that match one level and miss everything deeper:

a*b    matches  axb
a*b    does not match  ax/yb

One behaviour worth knowing because it differs from your shell:* does match dotfiles. Shell globbing skips names beginning with a dot; gitignore does not. The pattern* matches .hidden, and *.logmatches a file named just .log. A broad rule will therefore sweep up .env and friends whether you meant it to or not.

? and bracket expressions

? matches exactly one character, and like * it will not cross a slash — a?b does not matcha/b. Bracket expressions match one character from a set:

[0-9].txt        matches 3.txt, not a.txt
[!0-9].txt       matches a.txt, not 3.txt      ← ! negates inside brackets
[[:digit:]].txt  matches 7.txt                 ← POSIX classes are supported
[abc             matches NOTHING               ← unterminated: silent failure
\[abc\]          matches the file [abc]        ← escaped literal brackets

That fourth line is worth dwelling on. An unterminated bracket expression does not degrade into a literal [; Git abandons the match and the rule matches nothing at all, which looks exactly like never having written it. Nothing warns you.

** — meaningful in exactly three positions

** is the only construct that crosses directory boundaries, and it carries that meaning in three positions only:

**/foo     leading   → foo at any depth: foo, a/foo, a/b/foo
foo/**     trailing  → everything inside foo, at any depth
a/**/b     interior  → a/b, a/x/b, a/x/y/b

The interior form is the one people misjudge:a/**/b means zero or more directories in between, so it matches a/b as well. You rarely need to write both rules.

Anywhere else, a doubled asterisk is just an ordinary * — soa**b behaves identically to a*b and still refuses to cross a slash. Writing debug**.log buys you nothing.

Trailing / — directories only

A trailing slash restricts a rule to directories. Without it, the same name matches either a file or a directory:

rule      build (directory)   build (file)
build     ignored             ignored
build/    ignored             not ignored

So build/ is the precise way to say "the build directory, not a file that happens to be called build". The cost is that it cannot catch a stray file of that name — and, more importantly, it changes what negation can do next.

Negation with !

Two rules govern !, and between them they explain nearly every negation that appears broken.

First: the last matching pattern wins. Not the first, not the most specific. Identical rules in the opposite order give opposite results:

*.log
!keep.log        → keep.log is NOT ignored

!keep.log
*.log            → keep.log IS ignored

So broad rules first, exceptions after them.Second: a negation cannot reach into an ignored directory. Once a directory is excluded Git does not descend into it, so nothing inside can be reconsidered:

build/
!build/keep.txt        → still ignored, decided by build/ on line 1

Ignore the directory's contents instead, which leaves the directory walkable:

build/*
!build/keep.txt        → keep.txt is re-included; build/other.txt stays ignored

The same trap reappears one level down, and this is the version that catches people who already know the trick. build/* matches the subdirectory build/sub too, so a negation below it never gets evaluated:

build/*
!build/sub/keep.txt         → STILL IGNORED, decided by build/*

build/*
!build/sub
!build/sub/keep.txt         → works: the directory is re-included first

Every intermediate directory needs re-including. The general-purpose way to do that at arbitrary depth is !*/, which re-includes all directories and makes a blanket rule recursive:

# ignore everything except .c files, at any depth
*
!*/
!*.c

Without the middle line, * matches the directories themselves and Git never looks inside them to find the.c files the third line is trying to keep.

Escaping, and the mistakes worth checking first

# and ! are syntax at the start of a line, and a backslash turns them back into ordinary characters:

\!important      matches the file named !important
\#notes.txt      matches the file named #notes.txt
\*.log           matches the file named *.log — and not debug.log

A backslash also protects a trailing space, which is the only way to write a rule for a filename that ends in one: foo\ (backslash, then the space) matches the filefoo , where a bare foo would just be the rule foo.

The rest of the usual suspects, in the order worth checking them:

Confirm which pattern is deciding

When a rule still misbehaves, stop reasoning and ask Git. It names the source file, line number and pattern responsible:

$ git check-ignore -v src/logs/debug.log
.gitignore:3:*.log    src/logs/debug.log

If it prints nothing, no pattern matched — which, for a file that is still showing up in git status, usually means the file is already tracked rather than that your rule is wrong. One caveat: with-v the exit status is 0 whenever any pattern matched, including a negation that leaves the file not ignored, so read the printed pattern rather than the exit code — a leading! means the file is being included.

For the wider set of reasons a rule can fail to bite — tracked files, ignore-file placement, nested overrides — seegitignore not working.

Test a pattern before you commit it

Every behaviour on this page is one you can check in a few seconds. Paste a path and your rules into IgnoreDebugger and it reports which line decided and why it won — useful when you are drafting a pattern you have not committed, reviewing a .gitignore in someone else's pull request, or reasoning about a path that only exists on a colleague's machine. It runs entirely in your browser; nothing is uploaded.

Open the Gitignore Debugger

Frequently asked questions

What is the difference between * and ** in .gitignore?

* matches any run of characters except the directory separator, so it stays within one path segment. ** is the only construct that crosses directory boundaries.

That is why src/*.log matches src/debug.log but not src/logs/debug.log, while src/**/*.log matches both. ** is meaningful in only three positions — leading, trailing, and between two slashes — and degrades to a plain * anywhere else.

Does * match hidden files that start with a dot?

Yes. This is a real difference from shell globbing, where * skips dotfiles by default. In a .gitignore, the pattern * matches .hidden, and *.log matches a file named simply .log.

So a broad rule will sweep up dotfiles you may not have been thinking about, including .env.

Why does a/**/b match a/b with nothing in between?

Because a ** between two slashes means zero or more directories, not one or more. a/**/b therefore matches a/b, a/x/b and a/x/y/b alike.

This is usually what you want, and it means you rarely need to write both a/b and a/**/b as separate rules.

How do I ignore a file whose name contains *, ! or #?

Escape the character with a backslash. \*.log matches a file literally named *.log and nothing else, \!important matches !important, and \#notes.txt matches #notes.txt.

# and ! only need escaping at the start of a line, where they would otherwise mean comment and negation. A trailing space needs escaping too, since Git strips unescaped trailing whitespace.

Do .gitignore patterns support regular expressions?

No. They are glob patterns, closer to shell wildcards than to regexes. *, ?, ** and bracket expressions such as [0-9] are the whole vocabulary.

Constructs that look regex-like do not work: there is no alternation, no grouping and no quantifier, so *.(js|ts) and .*\.log both fail to do what a regex user expects. Bracket expressions do accept POSIX classes, so [[:digit:]].txt is valid.

Why does my character class [abc match nothing at all?

Because it is unterminated. An opening bracket with no closing bracket does not fall back to matching a literal [ — Git abandons the match, and the rule silently matches nothing, which is indistinguishable from never having written it.

If you meant a literal bracket, escape it: \[abc\] matches the filename [abc].

How do I whitelist a file several directories deep?

You have to re-include every directory on the way down, because Git does not descend into an ignored directory and so never reaches the file. build/* followed by !build/sub/keep.txt leaves the file ignored, since build/* also matches the directory build/sub.

Add the intermediate level and it works: build/*, then !build/sub, then !build/sub/keep.txt.

What does !*/ do in a .gitignore?

It re-includes every directory, which is what makes a blanket * rule recursive rather than stopping at the top level. The three-line idiom for ignoring everything except one file type is *, then !*/, then !*.c.

Without the !*/ line, * would match the directories themselves and Git would never look inside them to find the files the third rule is trying to keep.