Ignoring Files in Git

Every repo accumulates files that must never be committed: build output, dependency directories, local config, editor droppings. Left unmanaged they make git status unreadable, and an unreadable git status is a safety problem rather than an aesthetic one — it is how a 40 MB binary or a credentials file ends up in a commit that someone else pulls. This page covers what .gitignore actually does (less than most people think), the one gotcha that wastes an afternoon, and where each kind of ignore rule belongs.

Sanitized — no real hosts, paths, or project names. Continues the sensor-logger example. Assumes the index and working-tree model from Git-from-First-Principles. The git clean section borrows one idea from Undoing-Things-in-Git — that the only thing git can truly destroy is work you never committed — which that page develops in full later in the series.


Three states, not two

Every file in your working directory is in exactly one of three states, and the distinction between the first two is the whole subject:

StateMeansHow git knows
TrackedGit is watching it — it appears in commits, diffs, and mergesIt has an entry in the index
UntrackedGit sees a file it has never been told aboutNo index entry, no matching ignore rule
IgnoredAn untracked file git has been told to stop mentioningNo index entry, and a matching ignore rule

“Tracked” is not a property stored on the file — it means there is a row for this path in .git/index, which is the same index from Git-from-First-Principles that holds your proposed next commit. That single sentence explains everything surprising on this page.

So .gitignore is not an instruction to stop tracking a file. It is a filter applied to the untracked list: it decides which unknown files git status complains about and which ones git add . sweeps up. It has no authority whatsoever over files that already have an index entry.

The basic mechanism

A .gitignore is a plain-text file of patterns, committed to the repo like any other file:

build/          # build output — a directory, note the trailing slash
*.log           # runtime logs
config.env      # local configuration with credentials

The payoff is a git status that shows only real work:

$ git status --short
 M sensor.c
?? scratch.txt

build/ and sensor.log are gone from the listing. They are still sitting on disk, untouched — git is simply no longer mentioning them. Nothing was deleted, and nothing was added to the repo.

Two commands make the invisible visible when you need it:

$ git status --ignored --short
 M sensor.c
?? scratch.txt
!! build/
!! sensor.log
$ git check-ignore -v build/sensor sensor.log
.gitignore:1:build/     build/sensor
.gitignore:2:*.log      sensor.log

check-ignore -v is the tool to reach for whenever a file is ignored and you don’t know why — it names the exact file, line number, and pattern responsible. In a repo with rules in several directories plus a global file, this beats reading patterns by eye.

The gotcha: ignoring a tracked file does nothing

This is the one that costs people an afternoon. You committed config.env before thinking, then added it to .gitignore — and git keeps reporting your changes to it. (The mistake is pre-made for you on the tracked-config-oops branch of the lab repo from Start-Here-What-Git-Isgit switch tracked-config-oops and run everything below against it.)

$ git status --short
 M .gitignore
 M config.env

The rule is doing exactly what it says: it filters untracked files, and config.env is tracked. The ignore rule is not being disobeyed; it is not being consulted.

The diagnosis is worth memorizing, because it looks like a bug:

$ git check-ignore -v config.env
$ echo $?
1

Silence means the file is tracked. check-ignore reports on ignore decisions, and for a tracked file no ignore decision is ever made. Add --no-index to ask the hypothetical question — “would this pattern match if the file weren’t tracked?”:

$ git check-ignore -v --no-index config.env
.gitignore:4:config.env config.env

The pattern was fine all along. The fix is to remove the index entry while leaving the file on disk:

git rm --cached config.env      # untrack; the file stays in your working directory
git commit -m "chore: stop tracking local config"

Two consequences to know before you run it. First, --cached is doing real work — plain git rm would delete the file too. Second, this is a deletion in the commit: when a colleague pulls, git will remove config.env from their working directory, because that is what the commit says. Tell them first, and expect a config.env.example to be the thing you commit in its place.

And if the file held a credential, untracking it is not the end of the story — see the secrets section below.

Patterns: shell globbing, with three differences

The syntax is close enough to shell globbing that you can mostly write it from bash instinct — *, ?, [a-z] all behave. It is glob matching, not regex: *.log is a wildcard, not “any character, zero or more times, then .log”. Three departures from shell behavior account for most confusion:

1. * does not cross a directory separator. src/*.c matches src/sensor.c but never src/drivers/sensor.c. To cross directories you need **: src/**/*.c matches at any depth, **/build matches a build directory anywhere in the tree, and logs/** matches everything under logs/.

2. Where the slash is decides where the pattern is anchored. A pattern with no slash (or only a trailing one) floats — it matches at every level of the tree. A pattern containing a slash anywhere else is anchored to the directory holding the .gitignore:

build/          # any directory named build, at any depth
/build/         # only the build directory at the repo root
tests/data/     # only tests/data, relative to this .gitignore

3. A trailing slash restricts the pattern to directories. build/ ignores the directory; build ignores a directory or a file with that name. Prefer the slash when you mean a directory — it prevents a stray file named build from vanishing silently.

Negation with ! re-includes something an earlier pattern excluded, and last matching pattern wins:

*.log
!important.log      # ...except this one

The trap: you cannot re-include a file whose parent directory is excluded. Git does not descend into an excluded directory at all, so it never sees the file to reconsider it — the same reason find -prune cannot match inside a pruned directory. This does not work:

logs/
!logs/keep.log      # never reached — git never opened logs/

Exclude the contents instead of the directory, then re-include:

logs/*
!logs/keep.log

Where a rule belongs

There are three places to put ignore rules, and choosing correctly is most of the skill. They cascade like shell startup files — a system-wide default, a per-user file, a per-project file — with the most specific winning:

LocationScopeCommitted?What belongs here
.gitignore in the repoThe project, for everyoneYesFacts about the project: build output, dependency directories, generated files
.git/info/excludeThis one cloneNoFacts about this checkout: a scratch directory, a local test harness
core.excludesFile (global)Every repo you touchNoFacts about you: editor swap files, OS metadata, tool caches
git config --global core.excludesFile ~/.gitignore_global

The judgment rule in one line: if every developer on the project would need this rule, it goes in the committed .gitignore; otherwise it doesn’t. Your editor’s swap files are not the project’s business, and adding .idea/ or *.swp to a shared .gitignore quietly makes everyone else maintain your tooling choices. Precedence runs closest-first — a .gitignore in a subdirectory overrides the repo root, which overrides info/exclude, which overrides the global file.

Repos with different rules per area put a small .gitignore in each directory rather than one sprawling root file. Keeping tests/.gitignore next to the tests it describes is the same instinct as keeping a Makefile next to what it builds.

(.gitattributes is a different file for a different job — line endings, diff drivers, merge strategies. It is not an ignore mechanism, despite the similar name and location.)

git clean: the other command that destroys work

Ignoring files keeps them out of commits; it does not remove them. When you want the junk gone from disk, that’s git clean — and per the safety model in Undoing-Things-in-Git, it belongs to the small family of commands that can destroy something git never had a copy of. Untracked files are not in the object database. There is no reflog for them.

So always dry-run first. -n shows the plan without doing it:

$ git clean -n
Would remove scratch.txt

$ git clean -nxd
Would remove build/
Would remove config.env
Would remove scratch.txt
Would remove sensor.log
Would remove tests/

The flags, and why the second listing is so much longer:

  • -n — dry run. Read the output, then re-run with -f.
  • -f — actually delete. Required; git refuses to clean without it.
  • -d — include untracked directories.
  • -xalso delete ignored files. This is the dangerous one: it takes everything .gitignore was protecting from view and deletes it, including local config you cannot regenerate. git clean -fdx gives you a pristine checkout and is the right tool for “the build is haunted” — but read the -n output every single time.

Note tests/ in that second listing: it was an empty directory. Git tracks files, not directories, so a directory with nothing committed in it is untracked by definition. That is also why an empty directory cannot be committed at all — the convention is to commit a placeholder file, usually .gitkeep, if the directory itself must exist.

.gitignore is not a security control

The most important limit on this page. .gitignore prevents an accidental future commit. It does nothing about the past, and it is not a boundary anyone is enforcing.

If a credential was ever committed, it is in the object database — permanently, by the design in Git-from-First-Principles. Deleting the file in a later commit does not help: the old commit still points at the old tree, which still points at the blob holding the secret. Neither does git revert, which appends an inverse commit and leaves history intact by design.

If it was ever pushed, assume it is compromised, and in this order:

  1. Rotate the credential. This is the step that actually fixes the problem, and the only one that works even if a colleague already cloned the repo or a runner cached it.
  2. Then, if you still want it out of history, rewrite it with git filter-repo (or the BFG). This rewrites every affected commit’s hash, so it is a coordinated operation — everyone re-clones, and any forks and merge requests must be dealt with separately.
  3. Then add the pattern to .gitignore so it can’t recur.

Doing step 3 alone, which is the instinct, protects nothing. Commit a config.env.example with dummy values so the next person knows what the file should contain.

Worktrees and ignore rules

For the parallel-agent setup in Git-Worktrees-and-Parallel-Agents this stops being cosmetic. Each worktree carries its own untracked build artifacts — its own build/, its own node_modules/, its own .venv/ — because only committed objects are shared. Without ignore rules covering them, every agent’s git status is full of another directory’s noise, and an agent running git add -A will commit a dependency tree.

The rules themselves are shared, which is what you want: a committed .gitignore arrives with the branch, and .git/info/exclude lives in the main repo’s common directory, so it applies to every linked worktree too. Write an exclude rule once in the main checkout and every agent worktree inherits it.

Lookup table

“I want to…”Command
Find out why a file is ignoredgit check-ignore -v <path>
…when that prints nothingThe file is tracked — try --no-index
Stop tracking a file but keep it on diskgit rm --cached <path>
See what’s being hidden from megit status --ignored
Add a file despite an ignore rulegit add -f <path>
Delete untracked junkgit clean -n first, then git clean -fd
Get a pristine checkoutgit clean -fdx — reread the -n output
Ignore something in just my cloneAdd it to .git/info/exclude
Ignore my editor’s files everywheregit config --global core.excludesFile ~/.gitignore_global
Commit an empty directoryYou can’t — commit a .gitkeep inside it

Next: Pull-Commit-Push-on-a-Shared-Repo — the daily loop once other people are pushing to the same repo. A clean git status stops being tidiness at that point and becomes the instrument you read to see what is actually changing under you.