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-depthNote 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/ybOne 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 bracketsThat 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/bThe 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 ignoredSo 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 ignoredSo 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 1Ignore the directory's contents instead, which leaves the directory walkable:
build/*
!build/keep.txt → keep.txt is re-included; build/other.txt stays ignoredThe 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 firstEvery 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
*
!*/
!*.cWithout 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.logA 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:
- Trailing spaces are stripped unless escaped, so
baris simply the rulebar. Convenient when it was a typo, baffling when it wasn't. - Backslash is an escape character, not a separator.Always write paths with forward slashes, on Windows too — a rule written
src\tempdoes not mean what it looks like. - Case matters, machine-dependently. Patterns match case-sensitively unless
core.ignorecaseis on, which Git sets from the filesystem — usually on for Windows and macOS, off for Linux.*.LOGwill not catchdebug.logon a Linux CI runner. - There are no regexes here. No alternation, no grouping, no quantifiers:
*.(js|ts)and.*\.logboth fail. Write two rules instead of one clever one. - A comment must be on its own line.
*.log # temporaryis a pattern that includes the wordtemporary, not a rule with a note attached.
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.logIf 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.