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

> How glob patterns work: star vs double star, question marks, character classes, brace expansion, and the gitignore rules that trip everyone up.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-10 | Topics: [CLI](https://flaviocopes.com/tags/cli/) | Canonical: https://flaviocopes.com/glob-patterns-explained/

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?](https://flaviocopes.com/should-commit-node-modules-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`, `ls` receives the file names, not the pattern. Commands like [find](https://flaviocopes.com/linux-command-find/) take the pattern as an argument instead (that's why you quote it: `find . -name "*.txt"`), and the same applies to [tar](https://flaviocopes.com/linux-command-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](https://flaviocopes.com/tools/glob-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.
