Skip to content

← Writing

engineering

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 (/vendor ignores only the top-level one).
  • A trailing / matches directories only (.idea/).
  • * is a wildcard within a path segment; ** spans segments.
  • A leading ! negates!.env.example re-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’s github/gitignore repo generate solid per-language baselines. Don’t hand-write from scratch.
  • Never ignore your way around a committed secret. .gitignore won’t remove it from history. Purge it (git filter-repo) and rotate the credential — the leak is real the moment it’s pushed.
  • Commit .gitattributes early. Adding text=auto to a repo that’s already full of CRLF produces one big renormalization diff. Do it at the start, or in a dedicated commit.
  • .gitignore is per-directory. A nested .gitignore can 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.