Free lesson · Git Mastery
Ignoring Files (.gitignore)
Ignoring Files (.gitignore)
A note from Olena Marchenko, Software Engineer II at Microsoft Azure DevTools.
The First Big Trap
About a week into my first job as a junior developer at a digital agency in Warsaw - this was 2020, my first real engineering role after a year of self-teaching - I made a beginner mistake that I now see at least once a month from new engineers I mentor. I cloned the team's repository, ran npm install, started working, and at the end of the day ran git add . && git commit -m "first changes" && git push. The push succeeded. I felt productive.
The next morning, the senior engineer who had set up the repository asked me, very politely, why I had pushed a node_modules folder containing 47,000 files to the team's repository.
I had pushed node_modules. I had also pushed a .env file containing what I thought were development credentials but were actually production credentials someone had shared with me by accident. The cleanup took the rest of the day. We had to rewrite the team's branch history (Module 8 covers how) and rotate the leaked credentials. My second day on the job was largely apologizing.
That mistake had a single fix: a properly configured .gitignore file. This topic exists so you don't repeat my embarrassment.
What .gitignore Does
A .gitignore file tells Git which files to leave untracked - not show in git status output, not allow into the staging area, not track in commits. The patterns in the file describe what to ignore.
node_modules/
.env
*.log
.DS_Store
This is a small but realistic .gitignore. The first line ignores the entire node_modules folder. The second ignores any file named .env. The third ignores any file ending in .log. The fourth ignores macOS's hidden .DS_Store metadata files. Each line is a pattern; files matching any pattern are excluded from Git tracking.
The file lives at the root of your repository, named exactly .gitignore (with the leading dot). After it's committed, every developer who clones the repository gets the same ignore rules.
Pattern Syntax
The patterns in .gitignore follow specific rules. Understanding them prevents the most common gotchas.
Plain file or directory name. Matches any file or directory with that name, anywhere in the repository:
node_modules
.env
.DS_Store
These match the named files or directories at any depth in the tree. Node_modules matches the top-level folder and any nested node_modules folder inside subdirectories. The pattern is recursive by default - it applies at every level of the tree.
Trailing slash to specify directories only. Useful when you want to match a directory but not a file of the same name:
build/
logs/
The trailing slash restricts the pattern to directories. Build matches a directory called build but not a file called build. Without the slash, both would be ignored. This precision matters in projects that happen to have both a folder and a file with the same name.
Glob patterns with asterisks. Standard shell glob syntax - * matches anything except /:
*.log
*.tmp
*.swp
*.pyc
The asterisk matches any sequence of characters except a slash, so star dot log matches anything ending in .log within a single directory level. To match across directory levels, use double asterisk - Topic later in this section covers it.
Double asterisk for recursive matching. Matches anything across directory levels:
**/cache/
src/**/test-output/
Double asterisk matches across directories. The first line ignores any folder called cache at any depth. The second ignores test-output directories anywhere under src - at any nesting level. Useful when a pattern needs to apply everywhere in the tree but only inside a specific top-level area.
Leading slash for repository-root anchoring. A pattern starting with / only matches at the root, not in subdirectories:
/config
/build
The leading slash anchors the pattern to the repository root. Slash config ignores a config folder at the root but not nested ones inside subdirectories. Without the leading slash, the pattern would match config at any depth. Use the anchored form when you have a top-level folder by name but also legitimate same-named folders deeper in the tree that you want tracked.
Negation with !. Re-include something that a previous pattern excluded:
*.log
!important.log
The exclamation mark negates a previous pattern. This combination ignores all .log files except important.log. The order matters - the negation must come after the exclusion, and you can't re-include a file whose parent directory is excluded. Negations are useful for "ignore everything in this folder except these specific files."
A common use of negation: ignoring an entire dist/ folder but keeping a specific file inside:
dist/*
!dist/index.html
Ignore everything inside dist except index.html. Useful when you have a build output folder where one file should be committed - for example, a deployment marker or a versioned artifact - but the rest is generated and shouldn't be tracked.
Comments with #. Lines starting with # are comments:
# Dependencies
node_modules/
# Environment files
.env
.env.local
# IDE state
.vscode/
.idea/
Hash marks start comments. Use them to organize the file into sections. For .gitignore files of any meaningful size, comments make the structure scannable.
The Three Scopes of Ignore Rules
You can put ignore patterns in three different places, each with different reach:
flowchart TD
A[Repository .gitignore<br/>checked into repo<br/>applies to all collaborators] --> X[Effective rules<br/>for this repo]
B[~/.gitignore_global<br/>per-machine<br/>your own preferences] --> X
C[.git/info/exclude<br/>per-repo, per-machine<br/>not shared] --> X
This diagram shows the three ignore scopes. Repository .gitignore is checked into the repo and applies to everyone who clones it. Global gitignore lives in your home directory and applies across all your repositories - useful for personal preferences like editor state. Per-repository exclude file lives inside .git slash info and applies only to that one local clone - useful for ignoring something for yourself without affecting collaborators. The three sets of rules combine; a file matching any rule from any scope is ignored.
Repository .gitignore. The most common form. Lives at the root of the project, gets committed, applies to everyone. This is where project-specific ignores belong - node_modules, dist, language-specific build outputs, etc.
Global gitignore. Personal preferences that apply across all your repositories. Configured with:
git config --global core.excludesFile ~/.gitignore_global
This points Git's global config at a file in your home directory. After running this, any patterns in that file are ignored across all your repositories. Use this for personal preferences - your editor's swap files, your operating system's hidden metadata, your debugger's state - that aren't relevant to other team members.
Then create the file:
touch ~/.gitignore_global
This creates an empty file at the location you just told Git about. The touch command on Unix creates a file if it doesn't exist and updates its modified time if it does. On Windows you can use type nul greater-than the path instead. After this, the file exists; the next step is adding patterns to it.
A reasonable starting global ignore file:
# OS-generated files
.DS_Store
Thumbs.db
desktop.ini
# Editor backup files
*.swp
*.swo
*~
# IDE settings (if not shared at team level)
.vscode/
.idea/
# Personal scratch files
TODO.local
scratch.*
This is a typical global gitignore. The first section catches operating-system hidden files that don't belong in any repository. The second catches editor backups. The third catches IDE folders, though some teams prefer to track these at the repo level. The fourth catches personal scratch files that some engineers like to keep alongside their work but never share.
Per-repository excludes. Patterns specific to your local copy of one repository, not shared with collaborators. Lives at .git/info/exclude inside the repository. Use it when you want to ignore something just for yourself without modifying the team's shared .gitignore.
I use this rarely. The common case is when I'm experimenting with a local debug script that I don't want my collaborators to know about. Adding it to .git/info/exclude keeps it untracked locally without committing anything to the shared .gitignore.
Language and Tool Templates
You almost never need to write a .gitignore from scratch. GitHub maintains a public collection of .gitignore templates for every common language and tool. There's also gitignore.io (now at toptal.com/developers/gitignore), which generates combined templates from multiple selections.
For a Python + macOS + VS Code project:
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
.venv/
env/
ENV/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# VS Code
.vscode/
*.code-workspace
.history/
This is a typical project gitignore for a Python project on macOS using VS Code. The Python section ignores compiled files, build outputs, and packaging metadata. The virtual environments section covers the common folder names. The macOS section ignores Finder metadata. The VS Code section ignores workspace and history files. You don't need to memorize this; gitignore.io and the GitHub gitignore repository have templates for every common combination.
When starting a new project, generating the initial .gitignore from a template is the right move. Add to it as project-specific patterns emerge.
When You Already Tracked Something You Should Have Ignored
This is the situation I was in on day two at the digital agency. I had committed node_modules and .env. Adding them to .gitignore afterward doesn't help - Git already knows about them and tracks them. The .gitignore only affects untracked files.
The fix is to explicitly untrack them without deleting from your working directory:
git rm --cached <file>
git rm --cached -r <directory>
The dash dash cached flag removes the file or directory from Git's tracking but leaves it in place on your working directory. After running this and committing, the file is no longer tracked. Future changes to it won't appear in git status. The dash r flag is needed for directories - recursive removal. After running these commands, also add the path to .gitignore so it stays ignored from now on.
For my specific situation:
git rm --cached -r node_modules
git rm --cached .env
echo "node_modules/" >> .gitignore
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Stop tracking node_modules and .env"
This is the full recovery sequence. Untrack node_modules recursively. Untrack .env. Add both to .gitignore so they stay ignored going forward. Stage the updated .gitignore. Commit. The previous history still contains the files - git rm dash dash cached doesn't rewrite history - but going forward they're properly ignored. To remove them from history entirely, you'd need to rewrite history; Module 8 covers that and it's rarely worth doing.
The lesson I took from my mistake: set up .gitignore before the first commit. Generating it from a template at project initialization costs thirty seconds and prevents the entire category of problem.
What Should Almost Always Be Ignored
A short list of categories that should be in .gitignore for nearly any project:
- Dependencies -
node_modules/,vendor/,venv/, package manager caches - Build outputs -
dist/,build/,out/,target/ - Compiled files -
*.pyc,*.o,*.class - Environment files -
.env,.env.local, anything containing credentials - OS metadata -
.DS_Store,Thumbs.db - Editor and IDE state -
.vscode/,.idea/,*.swp(though some teams share IDE config deliberately; check team conventions) - Logs -
*.log,logs/ - Temporary files -
*.tmp,*~,tmp/
What should not be ignored:
- Source code - obviously
- Configuration files that the project needs - package.json, requirements.txt, pyproject.toml
- Example environment files -
.env.example(committed) is fine;.env(real values) is not - Documentation - README, docs, anything humans need to read
- Test files and test fixtures - these are part of the code
- CI/CD configuration -
.github/workflows/,.gitlab-ci.yml
A Special Case: Large Files
For projects with large binary files - design files, media assets, ML model weights - .gitignore isn't usually the right tool. The files are needed for the project but bloat the Git repository if committed normally. Git LFS is the proper solution; Topic 17.8 covers it in depth.
The general principle: ignore what shouldn't be tracked. For what should be tracked but is too large for Git itself, use Git LFS.
What I Set Up Now on a New Project
When I start a new project, my first three commands in the new folder are:
git init
curl -sL https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore > .gitignore
git add .gitignore
git commit -m "Initial .gitignore"
This is the workflow for starting a new Node.js project. Initialize Git. Download GitHub's official Node.gitignore template. Stage and commit it as the first commit. From this point on, the project is properly ignoring everything that shouldn't be tracked. The curl URL points at GitHub's gitignore repository; substitute Python, Go, Rust, or whichever language fits the project. This setup takes about ten seconds and prevents an entire class of accidents.
The thirty seconds at the start of the project is what would have saved me a full day at the agency. The discipline costs almost nothing and the alternative is real.
What Comes Next
This is the last topic in Module 2. Module 3 covers working with files in more depth - git rm, git mv, file permissions, and binary file handling. Module 4 covers history viewing, which is where Git starts to feel powerful instead of obligatory.
What Goes Wrong
Warning: Common
.gitignoremistakes:Adding
.gitignoreafter the file you wanted to ignore. If Git already tracks it,.gitignorehas no effect on it. Usegit rm --cachedto untrack first.Committing secrets before adding them to
.gitignore. Once pushed, the secret is in history forever. Rotate the credential immediately and consider history rewriting (Module 8) - but assume the secret was leaked.Missing the leading slash for root-anchored patterns. Without it, the pattern matches at every directory level, which may not be what you want.
Using a single asterisk where you need a double.
*.logmatches only within a single directory level;**/*.logmatches across all levels.Forgetting that negation can't re-include files whose parent is ignored. Once a directory is ignored, files inside it can't be un-ignored individually. Negate the directory pattern, then re-ignore specific files inside.
Committing IDE configuration that's user-specific. Some IDE files are project-shared (like
.editorconfig); some are user-specific (like.vscode/settings.jsonwith personal preferences). Decide deliberately per project.Mixing global and repo concerns. Personal preferences belong in your global gitignore. Project-specific patterns belong in the repo's
.gitignore. Don't put your personal IDE preferences in the team's shared file.
Try It
Exercise: Practice ignore patterns:
- In a practice repository, create some files:
app.py,app.log,node_modules/example.txt. Rungit statusand note that all three appear as untracked.- Create a
.gitignorewith*.logandnode_modules/. Rungit statusagain - the log file and the folder should be gone from the output.- Add a file called
important.logto the repository. It's ignored. Add!important.logto your.gitignoreafter the*.logline. Rungit status-important.logshould now appear as untracked.- Practice the recovery pattern: commit a file, then add it to
.gitignore. Notice nothing happens - the file is still tracked. Usegit rm --cached <file>and commit. Now the file is properly ignored going forward.- Set up a global gitignore with
git config --global core.excludesFile ~/.gitignore_globaland add a few OS-level patterns to it. Verify withgit config --global core.excludesFile.- Visit
toptal.com/developers/gitignoreand generate a.gitignorefor your favorite language and tools. Skim the result; note how many patterns you wouldn't have thought of yourself.