Skip to content
FLAVIO COPES
flaviocopes.com

Glob patterns explained (and why .gitignore behaves that way)

By

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:

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

PatternMatches
*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)
/fileanchored to the root (.gitignore)
!patternnegate / 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.

Tagged: CLI · All topics
~~~

Related posts about cli: