Glob patterns explained (and why .gitignore behaves that way)
By Flavio Copes
How glob patterns work: star vs double star, question marks, character classes, brace expansion, and the gitignore rules that trip everyone up.
Glob patterns are everywhere. The shell uses them. .gitignore uses them. tsconfig.json, ESLint, Prettier, CI configs — they all use some flavor of globs.
And yet almost everyone learns them by trial and error. You add a line to .gitignore, it doesn’t work, you add three more lines until something sticks.
Let’s fix that. In this post we’ll go through the syntax, and then the .gitignore rules that cause the most confusion.
The basic wildcards
* matches any number of characters, but not the / path separator.
This is the detail that matters most. src/*.ts matches src/index.ts but NOT src/utils/helpers.ts. The * stops at the slash.
** is called the globstar. It matches across directories:
src/**/*.ts
This matches src/index.ts AND src/utils/helpers.ts, at any depth.
? matches exactly one character (again, not /):
file?.txt
This matches file1.txt and fileA.txt, but not file10.txt.
Character classes
Square brackets match one character from a set:
file[12].txt
This matches file1.txt and file2.txt, nothing else.
You can use ranges too. [a-z] matches one lowercase letter. [0-9] matches one digit.
Prefix with ! (or ^) to negate the set: [!0-9] matches one character that is NOT a digit.
Brace expansion
Braces let you list alternatives:
*.{js,ts}
This expands to two patterns: *.js and *.ts. It’s a shorthand, nothing more.
Note that not every tool supports braces. The shell and most JavaScript glob libraries (like minimatch) do. .gitignore does NOT — Git treats { as a literal character.
Now, .gitignore
.gitignore uses glob syntax, but with its own rules layered on top. This is where people get confused, because the same pattern behaves differently in a .gitignore file than in your shell.
Here are the rules that matter.
Trailing slash means “directory only”
logs/
This ignores the logs directory and everything inside it. A file named logs would not be ignored.
Without the trailing slash, logs matches both a file and a directory named logs.
Leading slash anchors the pattern
A pattern without a slash matches at any depth:
debug.log
This ignores debug.log, logs/debug.log, src/anything/debug.log.
Add a leading slash and it only matches at the root:
/debug.log
Now logs/debug.log is not ignored.
Here’s the subtle part: any slash in the middle of the pattern also anchors it. logs/debug.log only matches from the root, exactly like /logs/debug.log. This surprises a lot of people.
Negation with !
Prefix a pattern with ! to re-include something a previous pattern ignored:
*.log
!important.log
Everything ending in .log is ignored, except important.log.
In .gitignore, the last matching pattern wins. Order matters. If you flip those two lines, important.log gets ignored again, because *.log matches it last.
You can’t re-include files inside an ignored directory
This is the rule that causes the most pain. This does NOT work:
node_modules/
!node_modules/my-package/index.js
Why? Because once Git ignores a directory, it doesn’t even look inside it. The negation never gets a chance to run.
The fix is to un-ignore the directory path first, then re-ignore its contents:
node_modules/*
!node_modules/my-package/
Notice the first line uses node_modules/* (ignore the contents) instead of node_modules/ (ignore the directory). That keeps the directory itself visible to Git, so the negation can work.
I wrote about the node_modules question specifically in should you commit the node_modules folder to Git? — the short answer is no, but the .gitignore mechanics above are why partial exceptions are tricky.
Gitignore vs shell globs vs minimatch
Three flavors, three sets of quirks:
- The shell expands globs against real files before your command even runs. When you type
ls *.txt,lsreceives the file names, not the pattern. Commands like find take the pattern as an argument instead (that’s why you quote it:find . -name "*.txt"), and the same applies to tar with--exclude. - minimatch (used by most JS tooling) supports braces and
**, and matches a pattern against a path string. First match wins, unless the library layers its own rules. - .gitignore has no braces, last match wins, trailing/leading slashes change meaning, and ignored directories block re-inclusion.
Same syntax family, different semantics. When a pattern “doesn’t work”, the first question to ask is: which flavor am I in?
Try it live
Reading rules is one thing, seeing them match is another. I built a glob pattern tester that runs in the browser: you paste a file tree, type patterns, and it highlights what matches. It has a gitignore mode that implements the last-match-wins and parent-directory-exclusion rules, so you can test that !node_modules/my-package/ case before fighting with Git.
A cheatsheet
| Pattern | Matches |
|---|---|
* | anything, except / |
** | anything, across directories |
? | one character, except / |
[abc] | one character from the set |
[a-z] | one character in the range |
{js,ts} | alternatives (not in .gitignore) |
dir/ | directories only (.gitignore) |
/file | anchored to the root (.gitignore) |
!pattern | negate / re-include (.gitignore) |
My advice: when a .gitignore line misbehaves, check three things in order. Is the pattern anchored by a slash? Is a parent directory already ignored? Is a later line overriding yours? One of those three explains it almost every time.
Related posts about cli: