The most surprising thing about .gitignore is that it’s not just about ignoring files; it’s about telling Git what not to track, and how you tell it is surprisingly nuanced and often leads to unexpected behavior.
Let’s see it in action. Imagine you have a standard Node.js project. You’ve just run npm install, and now your directory is littered with a node_modules folder, a bunch of .log files, and maybe some IDE-specific junk. You don’t want any of that in your repository.
Here’s a common .gitignore for a Node.js project:
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Build output
dist
build
# IDE directories
.idea
.vscode
*.swp
# OS generated files
.DS_Store
Thumbs.db
# Node modules
node_modules
When you run git status after creating this .gitignore, Git should ignore everything listed. The node_modules folder, the log files, the IDE cruft – all of it is now invisible to Git’s tracking. You won’t see them as untracked files, and you won’t accidentally commit them.
But how does this actually work under the hood? Git checks the .gitignore file in your repository’s root directory first. Then, it checks .gitignore files in subdirectories. The rules are applied hierarchically. When Git encounters a file, it checks if the file’s path matches any pattern in any of the .gitignore files it knows about. A match means the file is ignored.
The patterns themselves are glob patterns, similar to what you’d use in a shell. An asterisk (*) matches zero or more characters. A question mark (?) matches a single character. Double asterisks (**) match zero or more directories. A leading slash (/) anchors the pattern to the directory the .gitignore file is in. A trailing slash (/) means the pattern only matches directories. An exclamation mark (!) at the beginning of a line negates a pattern, meaning files matching this pattern will not be ignored, even if they match a previous ignore pattern.
Let’s say you accidentally added node_modules/some-library/dist to your Git repository before you had a .gitignore in place. Now, even with node_modules in your .gitignore, that specific dist folder inside node_modules will still be tracked because Git already knows about it. To fix this, you need to tell Git to forget about the file entirely:
git rm -r --cached node_modules/some-library/dist
Then, commit that change:
git commit -m "Remove previously tracked node_modules/some-library/dist"
This command removes the file from Git’s tracking index but leaves the actual file on your filesystem. After this, your .gitignore will correctly keep it ignored going forward.
The mental model is this: .gitignore is a set of rules Git consults before deciding if a file should be considered "untracked." If a file matches an ignore rule, Git pretends it doesn’t exist for tracking purposes. This is crucial for keeping your repository clean from build artifacts, dependencies, personal configuration, and sensitive data.
A common pitfall is forgetting that .gitignore rules are evaluated relative to the directory they are in. A rule like *.log in the root .gitignore will ignore logs/app.log, but a rule like build/ in a .gitignore inside a src/ directory will only ignore src/build/, not build/ at the root.
The most powerful, and often misunderstood, aspect of .gitignore is the interaction between negation (!) and directory matching. If you have *.log and then !important.log, important.log will be tracked. But if you have logs/ and then !logs/important.log, important.log inside the logs directory will still be ignored because the logs/ pattern itself prevents Git from even looking inside the logs directory for further rules or files to track. To truly un-ignore a file within an ignored directory, you need to explicitly un-ignore the directory itself first: !logs/ followed by !logs/important.log.
Understanding this hierarchy and the negation rules is key to crafting .gitignore files that precisely control what Git tracks.
The next thing you’ll likely run into is managing .gitignore files across different environments or for specific branches, which often leads to using .git/info/exclude or global ignore configurations.