.gitignore rules explained: patterns, negation, and untracking files
How gitignore pattern matching, negation and ordering actually work, global vs repo ignores, and how to untrack already-committed files.
Published 2026-09-25
What .gitignore actually controls
A .gitignore file tells Git which untracked files to leave out of git status, git add ., and similar commands. That word "untracked" matters: gitignore rules only affect files Git doesn't already know about. A file that's already committed keeps being tracked no matter what you add to .gitignore afterward — ignoring is a rule for new and unstaged files, not an instruction to forget existing ones. That distinction trips people up constantly, and it's covered separately below.
Pattern syntax
Per Git's gitignore documentation, patterns follow glob rules with a few Git-specific extensions:
# any file ending in .log, at any depth
*.log
# a directory named build, anywhere, and everything in it
build/
# config.json only at the repo root, not in subdirectories
/config.json
# .tmp files directly inside a top-level "logs" directory
logs/*.tmp
# a file or dir named "temp" at any depth
**/temp
# test.js under src/, at any depth: src/test.js, src/a/test.js, src/a/b/test.js
src/**/test.js
The building blocks:
*matches any sequence of characters except/— it doesn't cross directory boundaries.?matches exactly one character, also not/.[abc]/[a-z]match one character from a set or range.- A trailing slash (
build/) restricts the pattern to directories only, not a file with that name. - A leading slash (
/config.json) anchors the pattern to the location of the.gitignorefile itself — without it,config.jsonwould match at any depth. **matches across directory boundaries:**/foomatchesfooat any depth,foo/**matches everything insidefooat any depth, anda/**/bmatchesa/band any number of directories in between.- A leading
#starts a comment; a leading!negates (see below); to match a file that literally starts with#or!, escape it with a backslash. Comments must sit on their own line: gitignore has no trailing comments, so*.log # noteis read as a single pattern that matches nothing useful.
Ordering and negation
Patterns are read in order, and later patterns can override earlier ones within the same file. !pattern re-includes something an earlier pattern excluded:
*.html
!important.html
This ignores every .html file except important.html. It's a common pattern for "ignore this whole category, but keep specific exceptions."
There's a well-documented gotcha here, straight from Git's own docs: you cannot use ! to re-include a file if one of its parent directories was already excluded. Once a directory is ignored, Git doesn't even look inside it for performance reasons, so a negation pattern for a file within that directory has nothing to act on:
# does NOT work: build/ is already excluded, so Git never scans inside it
build/
!build/keep.txt
To make an exception work, you have to avoid excluding the parent directory outright and instead exclude its contents more narrowly, then negate the specific file:
# works: build/* excludes the CONTENTS, not the directory itself
build/*
!build/keep.txt
Where ignore rules live: repo, local, and global
Git checks multiple sources of ignore rules, and precedence runs from most specific to least, per Git's documentation:
- Patterns passed directly on the command line.
.gitignorefiles — a.gitignorein a subdirectory takes precedence over one in a parent directory for files under it, and a repo can have many.gitignorefiles at different levels.$GIT_DIR/info/exclude— repo-local rules that are not committed or shared with anyone else, useful for personal, machine-specific ignores (an IDE's working files, a local scratch folder) that don't belong in the shared.gitignore.core.excludesFile— a global, user-level ignore file (defaults to$XDG_CONFIG_HOME/git/ignore) that applies across every repository on your machine, configured once:
git config --global core.excludesFile ~/.gitignore_global
The practical split: put anything the whole team should ignore (build output, dependency folders, compiled artifacts) in the repo's committed .gitignore; put anything specific to your own machine or editor (.DS_Store, your IDE's config folder) in your global ignore file instead of cluttering the shared one with personal preferences.
Untracking a file that's already committed
This is the single most common gitignore-related support question, and the reason is the distinction from the first section: adding a pattern to .gitignore does nothing to a file Git is already tracking. If a .env file or a node_modules folder got committed before it was added to .gitignore, Git keeps tracking it — the ignore rule only stops it from being re-added if it were ever removed, or stops brand-new files from being staged.
To actually stop tracking it while keeping the file on disk:
git rm --cached path/to/file # a single file
git rm --cached -r node_modules # a directory, recursively
git commit -m "Stop tracking node_modules"
--cached removes the file from Git's index (so it stops being tracked) without deleting it from your working directory. After that commit, the existing .gitignore entry takes over and keeps it out for good — but note that the file's old contents remain in the repository's history regardless; untracking is not the same as scrubbing history, which matters a lot if what got committed was a secret. If a real secret — an API key, a database password, a token — was committed, rotating that credential (generate a fresh one, for example with this hub's password generator) is the safe fix; removing it from future commits doesn't remove it from history anyone can still check out.
Common templates and generating one
Most languages and frameworks have a well-known, mostly-stable set of paths worth ignoring — node_modules/ and .env for Node projects, __pycache__/ and *.pyc for Python, target/ for Rust and Java/Maven, .DS_Store for macOS regardless of project type. Hand-writing this list from memory is exactly the kind of thing worth generating instead of retyping per project — this hub's gitignore generator builds a starting file from your stack so you're not relying on recall for which build directories and lockfile-adjacent artifacts a given toolchain leaves behind.
A practical checklist
- Use a trailing slash (
dir/) to ignore a directory, a leading slash (/file) to anchor a pattern to the repo root — mixing these up is the most common pattern bug. - If a negation (
!pattern) isn't working, check whether a parent directory is already excluded outright — exclude its contents (dir/*) instead if you need exceptions inside it. - Team-wide ignores go in the committed
.gitignore; personal, machine-specific ignores go in.git/info/excludeor your globalcore.excludesFile. - Adding a pattern doesn't remove an already-tracked file — use
git rm --cachedfor that, and rotate any credential that was ever committed, since history still holds it.