Ignoring Files with .gitignore

Every Git repository accumulates files that have no business being tracked: compiled binaries, dependency folders like node_modules/, editor settings, log files, and local secrets such as .env. A .gitignore file tells Git which paths to leave alone so git status stays clean and commands like git add . never accidentally stage junk or sensitive data. It doesn’t delete anything or hide files from your file system — it simply tells Git’s own tracking machinery which untracked paths to skip.

Overview: How .gitignore Works

Git only cares about two kinds of files: files it is already tracking (they exist in the index and in a commit) and files it isn’t. When a file isn’t tracked, Git normally reports it as “untracked” in git status and will happily stage it if you run a wildcard command like git add .. A .gitignore file is a plain text list of patterns that Git checks before showing or staging untracked files. If a path matches a pattern, Git treats it as ignored: it disappears from git status output, and wildcard adds skip it entirely.

Crucially, .gitignore only affects untracked files. If a file is already tracked — meaning it’s already been added and committed, so it already exists as a blob referenced by a tree object in some commit — adding its path to .gitignore does nothing to it. Git already knows about it and will keep tracking changes to it. To stop tracking a file that’s already committed, you have to explicitly remove it from the index with git rm --cached (covered below). Ignoring it afterward then prevents it from being re-added by accident.

Git looks for ignore patterns in several places, combined together: a repository-wide .gitignore at the project root (and optionally more .gitignore files in subdirectories, which apply to that subtree), a per-user global ignore file configured with core.excludesFile, and a repo-local, uncommitted list at .git/info/exclude. The root .gitignore is the one you commit and share with your team, since everyone working on the project needs the same rules for things like node_modules/ or dist/. The global file is for things specific to your machine or editor — .DS_Store, .vscode/ — that shouldn’t be forced on every contributor’s repo. .git/info/exclude works like a personal, local-only .gitignore for one specific clone; it’s never committed or pushed.

One more subtlety worth understanding: ignore rules are a convenience layer on top of Git’s normal add/commit flow, not a hard security boundary. You can still force-add an ignored file with git add -f, and once a file is committed, its content is a permanent blob in your project’s history — removing it from tracking later does not erase it from past commits. If a real secret ever gets committed, treat it as compromised: rotate the credential and consider history-rewriting tools (a separate, more advanced topic) to purge it from old commits.

Syntax

A .gitignore file is just a list of patterns, one per line, matched against paths relative to the location of that .gitignore file (or the repo root, for the top-level one).

Pattern Meaning
# comment Lines starting with # are comments and are ignored; blank lines are ignored too.
*.log Matches any file ending in .log anywhere the pattern applies; * matches anything except a path separator /.
build/ A trailing slash means “match a directory named build” (and everything inside it), not a file named build.
/config.local.js A leading slash anchors the pattern to the directory containing the .gitignore file, so it won’t match src/config.local.js.
logs/**/*.log ** matches zero or more nested directories, so this matches .log files at any depth under logs/.
!important.log A leading ! negates a pattern, re-including a path that an earlier pattern excluded (with the caveat below).
file?.txt ? matches exactly one character.
file[12].txt [ ] matches any one character in the set, e.g. file1.txt or file2.txt.

Examples

Example 1: Ignoring dependencies and build output in a Node.js project

Before adding a .gitignore, a fresh Node project shows everything as untracked, including the huge node_modules/ folder:

git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        node_modules/
        package-lock.json
        src/index.js

nothing added to commit but untracked files present

Create a .gitignore at the project root with the common Node.js exclusions:

# Dependencies
node_modules/

# Build output
dist/
build/

# Logs
*.log

# Environment variables
.env

Now stage and check status again:

git add .gitignore
git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   .gitignore

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        src/index.js

node_modules/ and package-lock.json… wait, package-lock.json isn’t ignored here since it isn’t in the pattern list, and it shouldn’t be — lockfiles should stay tracked so everyone installs the same dependency versions. node_modules/ has vanished from the untracked list entirely, and src/index.js remains visible because nothing ignores it. This is the core payoff: git add . is now safe to run without dragging in thousands of dependency files.

Example 2: Negation patterns to keep an empty directory placeholder

Git doesn’t track empty directories at all, so teams often keep a placeholder file like .gitkeep inside a generated folder while ignoring everything else in it:

build/*
!build/.gitkeep
mkdir -p build
touch build/.gitkeep build/output.js
git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        build/.gitkeep

nothing added to commit but untracked files present

build/output.js matches build/* and is hidden, while build/.gitkeep is re-included by the negation pattern and shows up ready to be added. Note the pattern uses build/* (contents), not build/ (the directory itself) — see the negation gotcha in Common Mistakes below for why that distinction matters.

Example 3: Untracking a file that was committed by mistake

Say .env was committed early in the project’s history before anyone added a .gitignore. Adding .env to .gitignore now won’t remove it from tracking on its own — you also need git rm --cached, which removes the file from the index (and future commits) while leaving it untouched on disk:

echo ".env" >> .gitignore
git rm --cached .env
git commit -m "chore: stop tracking .env and ignore it going forward"
rm '.env'
[main a1b2c3d] chore: stop tracking .env and ignore it going forward
 2 files changed, 1 insertion(+), 1 deletion(-)

After this commit, .env stays on your machine untouched, but it’s no longer part of the repository going forward. It still exists in every earlier commit, though, so if it ever held a real secret, rotate that secret — removing a file from tracking is not the same as purging it from history.

How It Works Step by Step

When you run git status or a wildcard git add, Git walks the working tree looking for paths that aren’t in the index yet. For each candidate path, it checks the ignore rules gathered from, in order of increasing precedence: .git/info/exclude, the global core.excludesFile, then every applicable .gitignore from the repo root down to the file’s own directory. Within a single file, patterns are read top to bottom, and the last matching pattern wins — that’s what lets a later !pattern override an earlier exclusion. If the final matching pattern is a negation, the path is treated as not ignored; otherwise it’s skipped from git status, from git add ., and from tab-completion. None of this touches the object database: ignored files never become blobs, are never part of a tree, and are never referenced by a commit unless force-added with git add -f.

Common Mistakes

Mistake: expecting .gitignore to untrack an already-committed file. Adding config/secrets.yml to .gitignore after it was already committed changes nothing — Git keeps tracking it because it’s already in the index.

echo "config/secrets.yml" >> .gitignore
git status

config/secrets.yml simply won’t appear in the ignored/untracked sense; it keeps showing up under “Changes not staged” whenever it’s edited. The fix is git rm --cached config/secrets.yml followed by a commit, exactly as in Example 3.

Mistake: excluding a whole directory, then trying to un-ignore a file inside it. Git does not descend into a directory it has already decided to ignore, so a negation on a file inside it is silently useless:

dist/
!dist/keep.txt

Because dist/ excludes the entire directory, Git never even looks inside it to evaluate !dist/keep.txt. The fix is to ignore the directory’s contents instead of the directory itself, as shown in Example 2: dist/* followed by !dist/keep.txt.

Mistake: committing personal editor or OS files into the shared .gitignore. Adding .vscode/ or .DS_Store to the project’s root .gitignore forces your editor preference on every teammate’s diff of that file. Put personal, machine-specific patterns in your global ignore file instead, configured once with git config --global core.excludesFile, and keep the project’s .gitignore focused on things the whole team genuinely needs to exclude (build output, dependency folders, secrets).

Best Practices

  • Add a .gitignore in your very first commit, before you run git add . for the first time — it’s much easier than untracking files later.
  • Commit lockfiles like package-lock.json, yarn.lock, or Pipfile.lock; never ignore them, since they pin exact dependency versions for every contributor and CI run.
  • Never rely on .gitignore alone to protect secrets you’ve already committed — if a token or password was ever pushed, rotate it, then consider history-rewriting tools to remove it from old commits.
  • Use git check-ignore -v <path> to debug exactly which pattern, in which file, is (or isn’t) ignoring a given path.
  • Keep OS and editor cruft (.DS_Store, .vscode/, *.swp) in your personal global ignore file, not the shared project one.
  • Provide a .env.example with placeholder values alongside an ignored .env, so teammates know what environment variables the project expects.
  • Group related patterns under short comments (# Dependencies, # Logs) so the file stays readable as it grows.

Practice Exercises

  • Create a new Python project with a .venv/ virtual environment folder, __pycache__/ directories, and compiled *.pyc files. Write a .gitignore that excludes all three while still tracking requirements.txt, then confirm with git status that only source files and requirements.txt show as untracked.
  • Simulate having already committed config/secrets.yml two commits ago. Add it to .gitignore and use the correct command to stop tracking it going forward, committing the change with a Conventional Commits-style message (you don’t need to purge it from earlier history for this exercise).
  • Inside a dist/ folder, use negation patterns so that everything is ignored except a single dist/.gitkeep placeholder. Verify with git status that only dist/.gitkeep appears as untracked.

Summary

  • .gitignore is a plain text list of patterns that hides matching untracked paths from git status and wildcard git add commands.
  • It only affects untracked files; an already-tracked file needs git rm --cached to stop being tracked, and ignoring it afterward prevents it from being re-added.
  • Patterns support wildcards (*, **, ?), directory anchors (trailing/leading /), and negation (!), with the last matching pattern in a file taking precedence.
  • Git reads ignore rules from a committed root .gitignore, an optional global core.excludesFile, and the local, uncommitted .git/info/exclude.
  • Negation can’t reach inside a directory that was excluded wholesale with a trailing slash — exclude its contents (dir/*) instead if you need to re-include specific files.
  • Ignoring a file is not a security measure for content already committed; rotate any leaked secrets and consider history-rewriting tools separately.