Articles
Published · October 2, 2026

How to Write a .gitignore File: Patterns, Negation, and the Gotchas

A .gitignore file looks simple until a negation pattern doesn't work or a config file keeps showing up in git status anyway. Here's how the pattern syntax actually behaves, where negation hits its biggest limitation, and how to fix a file that's already tracked.

A .gitignore file looks trivial until a negation pattern silently fails or a secret config file gets committed anyway. Anyone keeping dotfiles, build artifacts, or local config out of a repo runs into the same handful of gotchas — this covers the pattern syntax, where negation hits its limit, and how to fix a file that's already tracked. For the broader context of managing project config day to day, see editing dotfiles on Mac and iOS.

Pattern Syntax Basics

A .gitignore file is a plain text list of patterns, one per line, that tells Git which untracked files to leave alone. A few rules cover most of it: a line starting with # is a comment, a blank line is just a separator, and * matches anything except a slash, while ? matches a single character. Need to match a literal special character instead? Escape it with a backslash — \* matches a literal asterisk, \# matches a literal leading hash.

# ignore build output and local env files
dist/
*.log
.env

That's enough syntax to ignore 80% of what shows up in a typical project. The remaining 20% — negation, anchoring, and ** — is where most .gitignore files quietly go wrong.

Negation and Its Biggest Gotcha

A leading ! negates a pattern: anything excluded by an earlier line becomes included again. If you actually need to match a filename that starts with !, escape it as \!important!.txt.

*.html
!index.html

That works — index.html gets tracked even though every other .html file is ignored. But negation has a hard limit: you can't re-include a file if a parent directory of that file is already excluded. Git won't descend into excluded directories to check further rules — that's a performance call — so a pattern aimed at a file inside one has no effect, no matter where it's defined.

This one line looks like it should work, but doesn't:

/foo/*
!/foo/bar

If /foo itself was excluded by an earlier, broader pattern, !/foo/bar never gets evaluated. The fix is to stage the exclusion and negation at every level on the way down:

/*
!/foo
/foo/*
!/foo/bar

Each step un-excludes just enough of the tree for the next pattern to matter.

Leading Slashes, Trailing Slashes, and Double Asterisks

Slashes change what a pattern anchors to. /hello.* — slash at the start — only matches hello.txt or hello.c at the top level, not a/hello.java. Drop the leading slash and frotz/ matches frotz at any depth: both frotz/ and a/frotz qualify. A trailing slash, meanwhile, restricts a pattern to directories only; leave it off and the pattern can match a file or a directory of the same name.

** has its own small set of rules worth memorizing:

  • **/foo matches a file or directory named foo anywhere in the tree
  • abc/** matches everything inside abc, at any depth
  • a/**/b matches a/b, a/x/b, and a/x/y/b — zero or more directories in between

The Already-Tracked-File Trap

Here's the one that catches almost everyone at least once: adding a pattern to .gitignore does nothing for a file Git already tracks. .gitignore only governs untracked files. So a committed config file with real secrets in it keeps showing up in git status and git diff, no matter how you phrase the pattern.

The fix is two steps, not one:

git rm --cached config/secrets.yml
echo "config/secrets.yml" >> .gitignore

git rm --cached removes the file from Git's index while leaving it untouched on disk, which is what actually stops the tracking. Add -r for a directory. Only after that does the .gitignore pattern have anything to apply to.

Scoping Rules Beyond the Repo

Not every ignore rule belongs in the repo's own .gitignore. There are three levels, each for a different purpose:

  • Repo `.gitignore` — committed and shared with every collaborator. Use it for things the whole team agrees to ignore: build output, node_modules, .env.
  • Global gitignore — set once with git config --global core.excludesFile ~/.config/git/ignore, applies across every repository on your machine, and is never shared. Good for editor swap files or OS clutter like .DS_Store that have nothing to do with any one project.
  • `.git/info/exclude` — local to a single repo's working copy, never committed. Useful for a personal scratch file you don't want to impose on anyone else.

The same local-versus-shared split shows up in other dotfiles too — SSH config files, for instance, often mix host blocks you want to commit to a dotfiles repo with per-machine overrides you'd rather keep local.

If a pattern isn't doing what you expect at any of these levels, run git check-ignore -v <path>. It prints the exact file and line number that matched, which turns a guessing game into a one-command lookup.

Doing This with Carets

If you're maintaining .gitignore files alongside the rest of your project's config — .env.example, .editorconfig, a README.md — Carets is built for exactly that mix. It's a native, fast editor for iPhone, iPad, and Mac that handles Markdown, plain text, and code in one place, with syntax highlighting that makes a .gitignore file's comments and patterns easy to scan at a glance.

Two things make it a good fit here: files, projects, and tags let you keep a project's dotfiles next to its notes and docs instead of scattered across apps, and because everything stays in plain text, your .gitignore patterns are exactly as portable as the repo they describe — no proprietary format standing between you and git commit.

Carets is free to try on iPhone, iPad, and Mac — search "Carets" on the App Store.

Frequently Asked Questions

Why doesn't my negation pattern (!) bring a file back?

If a parent directory of that file is already excluded, Git never descends into it to evaluate further rules — so a negation pattern targeting a file inside an excluded directory has no effect. You have to un-exclude the directory first (for example with a pattern like !/foo/* at the same level it was excluded), not just the file.

I added a file to .gitignore but Git still tracks changes to it. Why?

.gitignore only governs untracked files. If the file was committed before you added the pattern, Git keeps tracking it regardless. Run git rm --cached <file> (add -r for a directory) to remove it from the index while leaving it on disk, then the ignore rule takes effect going forward.

What's the difference between .gitignore, the global gitignore, and .git/info/exclude?

A repo's .gitignore is committed and shared with every collaborator. The global gitignore (set via git config --global core.excludesFile, conventionally ~/.config/git/ignore) applies across every repository on your machine but isn't shared. .git/info/exclude is local to one repo's working copy and is never committed — useful for personal scratch patterns you don't want to impose on teammates.

How do I debug why a file is or isn't being ignored?

Run git check-ignore -v <path>. It prints exactly which .gitignore file and line number matched the path, which is the fastest way to find a typo or an unexpected parent-directory exclusion.

Conclusion

Most .gitignore problems come down to one of two things: the parent-directory negation limit, or forgetting that a file is already tracked. Learn those two gotchas and the rest of the syntax — wildcards, anchoring slashes, ** — is straightforward. When a pattern still doesn't behave the way you expect, git check-ignore -v will tell you exactly which rule fired. The same plain-text, Git-friendly discipline pays off when version-controlling your notes with Git.