Most developers write a .gitignore once, copy it forever, and only think about it when something that should be ignored isn't. This is the mental model that fixes that: how patterns match, which rules win, and the three traps — negation, already-tracked files, and directory exclusion — behind almost every "why is Git still showing this?" moment.
It's a plain text file, conventionally named .gitignore and placed in the repository root, listing path patterns Git should not track. One pattern per line; # starts a comment. A matching untracked file stops appearing in git status and can't be swept into a commit by git add .. That's the whole job — and crucially, it's a promise about the future, not the past.
The file itself is normally committed. Shared ignore rules are part of the project, like the README: when a new teammate clones the repo, node_modules/ is already ignored because the rule came down with the clone.
Patterns are shell globs with three kinds of anchoring. Getting these straight resolves most surprises:
| Pattern | Matches | Does not match |
|---|---|---|
logs/ | Any directory named logs, at any depth | A file named logs |
logs | Any file or directory named logs, anywhere | (the slash form is just narrower) |
/build | Only the root-level build | app/build |
build | Every build at every depth | — |
*.log | Every .log file, anywhere | debug.log.1 |
docs/*.md | .md files directly in docs/ | docs/guides/x.md |
docs/**/*.md | .md files at any depth under docs/ | — |
!keep.me | Negation: un-ignores keep.me | Anything inside an ignored directory |
Three details do most of the work in that table. The trailing slash restricts a pattern to directories. The leading slash anchors it to the .gitignore's own directory. And ** means "any number of directories," which is how you reach arbitrarily nested paths without listing them.
Within one .gitignore, the last matching pattern wins. So order matters when you carve out exceptions:
*.log
!important.log
temp/*
!temp/keep.cfg # fails — temp/ already excluded the directory
That second exception is the famous trap, and it's not a Git bug — it's how the walk works. Git doesn't descend into an excluded directory at all, so it never sees temp/keep.cfg to consider un-ignoring it. The workaround is to exclude the files instead of the folder: temp/* followed by !temp/keep.cfg still fails for the same reason in modern Git (the directory itself is excluded by temp/*); the reliable form is to name files directly or restructure so exceptions live outside excluded trees.
Between files, precedence runs from broad to narrow: command-line excludes, then .git/info/exclude, then patterns deeper in the tree beat patterns nearer the root, and within the same file, later beats earlier.
Here's the rule that generates the most confusion in real repos: .gitignore applies to untracked files only. A file that was committed before you ignored it stays tracked, keeps appearing in diffs, and keeps receiving changes. Adding .env to .gitignore after committing .env protects nothing — the secret is already in history.
The fix for the index is mechanical:
git rm --cached .env
git commit -m "stop tracking .env"
The fix for history is not: removing a file from past commits means rewriting history with git filter-repo, then rotating whatever secret leaked. If you take one lesson from this page: ignore files before their first commit, especially .env.
Some noise is yours, not the project's: .DS_Store on a Mac, Thumbs.db on Windows, your editor's swap files. Repeating them in every repository is noise of a second kind. Point Git at a personal file once:
git config --global core.excludesfile ~/.gitignore_global
then fill that file with machine-specific patterns. They apply to every repo you touch, on your machine only — the right place for "my laptop's cruft" as opposed to "this project's build output."
You don't have to write these from scratch. GitHub's github/gitignore collection — the same templates offered when you create a repository — is maintained per language, OS, and editor, and dedicated to the public domain (CC0). A typical merge for a Node project on a Mac using VS Code pulls three templates: Node (143 lines: node_modules/, dist, npm-debug.log*, .env), macOS (57 lines: .DS_Store, ._*, .Trashes), and VS Code (11 lines, and worth reading — it ignores .vscode/* then deliberately re-includes settings.json, launch.json, and extensions.json with negation patterns, because those three are genuinely team-shared). Concatenated with section headers: 217 lines, about 3.3 KB, one commit, and the repo stays clean for every teammate on every OS.
Check off your languages, OS, and editors — GitHub's official CC0 templates merge into one file you can copy or download.
Open the Gitignore Generator →When a path misbehaves and you want to know which rule is responsible:
git check-ignore -v path/to/file
It prints the source file, line number, and pattern that matched — or nothing, meaning the file isn't ignored at all and something else (like being already tracked) is your real problem. Thirty seconds with check-ignore beats thirty minutes of pattern staring.
Ignore-file thinking extends past Git, too: robots.txt is the same idea for crawlers, and you can draft one with our robots.txt generator and verify rules against real URLs with the robots.txt validator. If your ignore lists grow because of generated artifacts on a schedule, the cron expression generator helps with the cleanup job that should be deleting them instead.
In the repository root, next to the .git directory. Patterns in it are matched relative to its location, so node_modules/ in the root file ignores every node_modules folder at any depth. Subdirectories can carry their own .gitignore files for rules only they need, and the global excludes file handles machine-specific patterns across all repos.
Yes — the shared one is. A committed root .gitignore distributes the team's rules to every clone, which is the whole point. The exception is machine-specific noise (.DS_Store, your editor's swap files), which belongs in your global excludes file rather than a repo file everyone inherits.
Because it was already tracked. .gitignore only filters untracked files; anything already in the index keeps being tracked. Run git rm --cached <file> and commit the removal — then the ignore rule takes over. This is the single most common .gitignore confusion.
Yes, with a leading ! — *.log then !important.log keeps that one file. The catch: negation cannot re-include a file whose parent directory was excluded, because Git doesn't descend into ignored directories at all. Exclude files (.log), not whole directories (logs/), when you plan to carve exceptions back out.
Not for tracked files, and not for history. Adding build/ to .gitignore stops future tracking of untracked build outputs, but files already committed stay in the index until removed, and every historical version keeps whatever was committed before. Purging history needs git filter-repo, which is a different, heavier operation.