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 appear in the shell, .gitignore, tsconfig.json, ESLint, Prettier, and CI configuration. Each tool uses its own flavor.
Most people learn them by trial and error. You add a line to .gitignore, it doesn’t work, then add more patterns until something sticks.
We’ll go through the syntax first, then the .gitignore rules that cause the most confusion.
The basic wildcards
* matches any number of characters, but not the / path separator.
The slash makes the difference here. src/*.ts matches src/index.ts but not src/utils/helpers.ts because 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.
Not every tool supports braces. The shell and most JavaScript glob libraries, including minimatch, do. .gitignore does not, so 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.
They belong to the same syntax family, but their rules differ. When a pattern does not work, first check which flavor you are using.
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) |
When a .gitignore line misbehaves, first check whether a slash anchors the pattern. Then look for an ignored parent directory and any later line that overrides yours. One of these usually explains it.
Want me to talk about your product? You can sponsor this site.