Choosing What Git Tracks
Keep your repository to source and nothing else. .gitignore pattern syntax, why ignoring a tracked file does nothing, git rm --cached, check-ignore, and global ignores.
Keep your repository to source and nothing else. .gitignore pattern syntax, why ignoring a tracked file does nothing, git rm --cached, check-ignore, and global ignores.
A repository should hold the recipe, not the cake. Somewhere on your disk right now there is probably a dependency folder larger than everything you have ever written, a config file with a live database password in it, and a scattering of files your editor left behind without asking. None of that is your project, and none of it belongs in history.
By the end of this lesson your repository will contain your source and nothing else, and you will know exactly how to pull something back out when it slips through anyway.
The junk that ends up in repositories falls into four families, and it helps to be able to name them.
Regenerable. One command rebuilds them.
node_modules/, dist/, target/, __pycache__/,
.venv/. Enormous, often machine-specific, full of binaries
compiled for your operating system and absolute paths out of
your home directory.
Permanent in a way almost nothing else in Git is.
.env, credentials.json, private keys, *.pem. This is
the family that gets its own section below, and the reason
this lesson exists at all.
Not dangerous. Just noise.
.DS_Store, Thumbs.db, .idea/, *.swp. Clutters every
diff and starts arguments about whose editor settings win.
Forever, and every clone pays for them.
Video, datasets, *.log, *.sqlite. Git keeps every
version of every file, so a 200 MB asset edited ten times is
two gigabytes every future clone downloads — even after you
delete it.
You tell Git what to skip with a .gitignore file, usually
in the root of your repository. The format is deliberately
boring: one pattern per line, lines starting with # are
comments, and blank lines are ignored so you can group rules with
breathing room.
Files matching those patterns become ignored: Git will not
list them in git status, and git add . walks straight past
them. Commit the .gitignore itself so everyone who clones the
project ignores the same things — it is one of the few files
whose whole job is to be shared.
A bare name matches that name anywhere in the repository, at any depth — as a file or as a directory.
Adding a trailing slash restricts the match to directories only.
This matters more than it looks: build would also ignore a
compiled executable named build, while build/ ignores only
the folder.
An asterisk matches any run of characters except a slash, which makes it the workhorse for extensions.
A question mark matches exactly one character.
Two asterisks cross directory boundaries, which is how you reach into nested paths.
By default patterns float — they match at every level. A leading
slash anchors the pattern to the directory containing the
.gitignore, which for a root-level file means the repository
root.
A slash anywhere else in the pattern anchors it too, so
doc/frotz/ matches only the top-level doc/frotz, while
frotz/ matches it anywhere.
An exclamation mark negates a pattern, re-including something an earlier line excluded. Order matters: the last matching rule wins.
Now the gotcha that costs people an afternoon. You cannot un-ignore a file inside an ignored directory. Git does not descend into an excluded directory at all, so it never sees the file you are trying to rescue.
Any directory can have its own .gitignore, and its patterns
apply to that directory and everything beneath it. A rule about
compiled Python belongs next to the Python code, not in a root
file a front-end developer has to scroll past.
The files compose from the top down, and the nearest one has
the final say. A pattern in api/.gitignore beats a conflicting
pattern in the root .gitignore, exactly like a local variable
shadowing a global one. So a subdirectory can re-include
something the root ignored — as long as the root did not ignore
the whole directory, per the rule above.
Here is the rule that trips up nearly everyone, and the reason
so many people conclude .gitignore "does not work".
.gitignore only affects untracked files. It is a bouncer at
the door, not a way to remove someone already sitting at the bar.
Once a file has been committed even once, Git considers it
tracked, and it will keep reporting and committing changes to
that file no matter what patterns you write.
The fix is to untrack it while leaving it on your disk:
git rm --cached removes the file from the index — Git's
record of what it is tracking — but does not touch the copy in
your working directory. The file stays exactly where it is and
Git stops watching it. Because it is now untracked, your
.gitignore finally takes effect.
To re-apply your ignore rules across a whole repository, clear the index and rebuild it:
Commit or stash your in-progress work first, and read that
git status carefully — the only deletions listed should be
files you meant to stop tracking.
Say a .env with a real API key made it into a commit last week.
Removing it now, in a new commit, does not remove it from
history. The old commit still contains it, and anyone with the
repository can read it with a single git show. A later deletion
hides it from the current checkout and nothing more.
If that commit was ever pushed, treat the credential as leaked. Not "probably fine" — leaked. Clones, forks, CI caches, and your host's backups may all hold a copy, and no command you run locally reaches any of them. The only step that genuinely helps is rotating the secret: issue a new key and revoke the old one.
Purging a file from every past commit is possible, but it rewrites every commit ID from that point forward and forces everyone to re-clone. That belongs to the advanced course, and it is never a substitute for rotation.
When a file is invisible and you cannot see why, stop guessing and ask Git directly:
The answer reads as source file, line number, and the pattern
that matched, followed by the path. It tells you not only which
rule is ignoring the file but which file the rule lives in —
what you need when a global ignore or a nested .gitignore is
the culprit. If nothing prints, no rule matches.
By default check-ignore also consults the index, so a tracked
file reports as not ignored, which is the truth. Add --no-index
to ask whether the pattern would match if the file were
untracked. That distinction is often the whole diagnosis.
To see everything you are currently ignoring:
Your editor is your business. If you use Vim and your teammate
uses an IDE that scatters .idea/ folders, neither preference
belongs in the project's .gitignore — that file should describe
the project, not the tools of whoever set it up.
Put personal patterns in a global ignore instead:
Those patterns now apply in every repository on your machine, and
nobody has to review them. Git also reads ~/.config/git/ignore
with no configuration at all, if you would rather set nothing.
For patterns that belong to one repository but should not be
shared, edit .git/info/exclude. Same syntax, same effect, but
it lives inside .git/ and is never committed — ideal for a
scratch file like notes-to-self.md or a local override you use
while debugging.
You rarely need to write a .gitignore from scratch.
https://gitignore.io generates one for any combination of
language, framework, editor, and operating system. GitHub keeps a
curated collection at https://github.com/github/gitignore, where
most of those generated files ultimately come from.
Here is a realistic root .gitignore for a project with a Node
front end and a Python backend:
Note the !.env.example line. Committing a placeholder that
lists which variables are needed, with no real values, is the
kindest thing you can do for the next person who clones the
project.
Your repository now holds what it should, and you can prove it
with git check-ignore instead of hoping. That is a quieter
git status, smaller clones, and one fewer way to leak a key.
Next comes undoing things safely — unstaging a file you added by mistake, discarding an edit you regret, and stepping back from a commit without losing work. It pairs naturally with this lesson: the moment you notice something has slipped into your history is the moment you want those commands at hand. And once you push to a shared remote, what you committed becomes everyone's, which is why the contents are worth getting right first.
For practice, run git rm -r --cached . and git add . on a
project you already have, and read the git status carefully
before committing. Whatever shows up as deleted is the stuff that
should never have been there. Fixing it now is a five-minute job;
finding it in a year is not.
# .gitignore
# Dependencies
node_modules/
# Secrets
.env.env # matches .env, config/.env, apps/api/.envbuild/ # matches the build/ directory anywhere
# does NOT match a file called build*.log # matches error.log, logs/app.log
*.tmpdraft?.txt # matches draft1.txt and draftA.txt
# does NOT match draft10.txt or draft.txtdocs/**/*.pdf # any .pdf at any depth under docs/
**/logs/ # a logs/ directory anywhere
build/** # everything inside build/, at any depth/build/ # only the build/ folder at the repo root
build/ # any build/ folder, including src/build/*.log
!important.log # keep this one, ignore the rest# Does NOT work — Git never looks inside assets/
assets/
!assets/logo.svg
# Works — ignore the contents, then rescue one file
assets/*
!assets/logo.svg.gitignore:4:build/ build/app.js# ~/.gitignore_global
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp# --- Dependencies ---
node_modules/
.venv/
venv/
# --- Build output ---
dist/
build/
*.egg-info/
__pycache__/
*.py[cod]
# --- Secrets and local config ---
.env
.env.*
!.env.example # keep the template, ignore real values
*.pem
# --- Test and tooling caches ---
coverage/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# --- Logs and local databases ---
*.log
npm-debug.log*
*.sqlite3git rm --cached .envgit rm -r --cached . # untrack everything (files stay)
git add . # re-add, now obeying .gitignore
git status # only ignored files show as deleted
git commit -m "Apply .gitignore to tracked files"git check-ignore -v build/app.jsgit status --ignoredgit config --global core.excludesFile ~/.gitignore_globalgit check-ignore -v <path> # which rule ignores this path
git check-ignore -v --no-index <path> # ignore the index too
git status --ignored # list ignored files
git rm --cached <file> # untrack, keep file on disk
git rm -r --cached . # untrack everything (files stay)
git add . # re-add, now obeying .gitignore
git commit -m "Apply .gitignore" # record the untracking
git config --global core.excludesFile ~/.gitignore_global
git config --global init.templateDir ~/.git-template