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 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:

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

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)

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.

Tagged: CLI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cli: