A Sane .gitignore and .gitattributes
· Jerwin Arnado ·
Every repo accumulates two kinds of mess: files that should never be committed
(node_modules, .env, build output) and files that are committed but diff badly
(CRLF churn, generated assets bloating every PR). Two small files fix both —
.gitignore and the less-loved .gitattributes. Neither is glamorous; both save you
from the kind of noise that makes a git status useless.
1. .gitignore: keep junk out
A .gitignore in the repo root lists path patterns Git should never track. A practical
one for a Laravel + Vue project:
# Dependencies
/node_modules
/vendor
# Environment & secrets — NEVER commit these
.env
.env.*
!.env.example
# Build output
/public/build
/public/hot
/_site
# Logs & caches
*.log
/storage/*.key
.phpunit.result.cache
# OS & editor cruft
.DS_Store
Thumbs.db
.idea/
.vscode/
Pattern rules worth knowing:
- A leading
/anchors to the repo root (/vendorignores only the top-level one). - A trailing
/matches directories only (.idea/). *is a wildcard within a path segment;**spans segments.- A leading
!negates —!.env.examplere-includes a file an earlier pattern excluded. Order matters: the negation must come after the broad rule.
2. The “already committed” gotcha
The single most common .gitignore confusion: it only affects untracked files. If
you already committed node_modules or a .env, adding it to .gitignore does nothing —
Git keeps tracking what it already tracks. You have to untrack it explicitly:
git rm -r --cached node_modules
git rm --cached .env
git commit -m "chore: stop tracking ignored files"
--cached removes the file from Git’s index but leaves it on disk — exactly what you
want for .env. If a secret was committed, note that this only stops future tracking;
the secret still lives in history and must be purged separately (and the credential
rotated).
3. A global gitignore for machine cruft
.DS_Store and editor folders are your machine’s noise, not the project’s — so don’t
push them into every repo’s .gitignore and onto your teammates. Put them in a global
ignore instead:
git config --global core.excludesFile ~/.gitignore_global
# ~/.gitignore_global — applies to every repo on this machine
.DS_Store
Thumbs.db
.idea/
*.swp
Now the repo’s .gitignore stays focused on project artifacts, and your personal OS
cruft is handled once, everywhere. This is the same separation-of-concerns instinct as
keeping machine setup out of project config.
4. .gitattributes: tame line endings and diffs
.gitattributes controls how Git treats file contents. Its highest-value job is
ending the CRLF/LF war on mixed Windows/macOS/Linux teams:
# Normalize all text files to LF in the repo, auto-convert on checkout
* text=auto
# Force LF for files that must stay LF regardless of OS
*.sh text eol=lf
*.php text eol=lf
# Mark binaries so Git never tries to diff or merge them
*.png binary
*.jpg binary
*.pdf binary
text=auto tells Git to store text with LF internally and convert to the OS default on
checkout — so a Windows dev and a Mac dev stop generating phantom “whole file changed”
diffs from invisible line-ending flips. After adding it, renormalize the repo once:
git add --renormalize .
git commit -m "chore: normalize line endings via .gitattributes"
5. .gitattributes for cleaner diffs and PRs
Two more attributes worth knowing:
# Hide generated/vendored files from diffs and language stats
package-lock.json linguist-generated=true
/public/build/** linguist-generated=true
# Collapse generated files in PR diffs (GitHub)
composer.lock -diff
linguist-generated=true tells GitHub to collapse the file in PRs and exclude it from the
repo’s language breakdown — so a lockfile change doesn’t drown the real diff or make your
project look 60% JSON. -diff suppresses the textual diff for files where it’s noise.
Caveats and best practices
- Start from a template. gitignore.io
(now
git ignore-io) and GitHub’sgithub/gitignorerepo generate solid per-language baselines. Don’t hand-write from scratch. - Never ignore your way around a committed secret.
.gitignorewon’t remove it from history. Purge it (git filter-repo) and rotate the credential — the leak is real the moment it’s pushed. - Commit
.gitattributesearly. Addingtext=autoto a repo that’s already full of CRLF produces one big renormalization diff. Do it at the start, or in a dedicated commit. .gitignoreis per-directory. A nested.gitignorecan add rules for a subtree — useful, but keep the root one authoritative to avoid scattered surprises.
Conclusion
# .gitignore → what never gets tracked (deps, .env, build output)
# .gitattributes → how tracked files behave (LF normalization, diff control)
# ~/.gitignore_global → your machine's cruft, handled once
Spend ten minutes on these at the start of a project and you buy a clean git status, a
diff that shows only real changes, and PRs nobody has to scroll past node_modules to
review. Cheap insurance against a thousand papercuts.