.Gitattributes and Config That Pays for Itself
The committed half of Git automation: line endings, diff and merge drivers, linguist attributes, export-ignore, plus conditional includes and the settings that remove daily friction.
The committed half of Git automation: line endings, diff and merge drivers, linguist attributes, export-ignore, plus conditional includes and the settings that remove daily friction.
You open a pull request that touches three lines, and the review page tells you the file has 412 changed lines. Each one looks identical to the line it replaced. Somewhere a teammate on Windows saved that file, and Git is now convinced the whole thing is new.
That is one of a family of daily irritations that are not your fault and need no meeting to fix — they need two files. By the end of this lesson your repository normalises its own line endings, keeps generated files out of review, merges changelogs without conflicts, and stamps your work email on work commits without you thinking about it once.
The last lesson ended on an awkward limitation: .git/hooks is
never committed, and neither is .git/config. Anything you put
in either protects one clone on one machine, and a teammate who
never runs your setup script never gets it.
.gitattributes is the exception, and that single fact is
what this lesson turns on. It is a tracked file in your
repository root, reviewed in pull requests and delivered by
every git clone. Rules the whole team needs belong here; rules
that are yours alone belong in config.
It attaches attributes to paths — labels that change how Git
diffs, merges, stores, and packages those files. Each line is a
pattern, then whitespace, then the attributes. Patterns work
like .gitignore: *.py matches anywhere, /build/ anchors to
the root. An attribute can be set (text), unset (-text), or
given a value (eol=lf), and when two lines match the same file
the last one wins.
Here is a realistic file for a mixed Python and web project; each line gets its own section below.
# .gitattributes
* text=auto # normalise line endings on commit
*.sh text eol=lf # shell scripts must stay LF
*.png binary # never diff, merge, or convert
package-lock.json linguist-generated=true
*.py diff=python # hunk headers name the def/class
CHANGELOG.md merge=union # keep new lines from both sides
/.github export-ignore # leave out of release tarballsThe symptom from the opening is the one everybody meets: a file shows as entirely rewritten, and nothing you can see has changed. Windows ends lines with a carriage return and a line feed; macOS, Linux, and Git's own storage use a line feed alone. When an editor rewrites a file with the other convention, every line differs by one invisible byte — and Git compares bytes.
text=auto is the fix. Git decides whether each file is text
and, if so, stores it with LF endings inside the repository
regardless of your working copy, converting back on checkout.
The repository becomes neutral ground and the phantom diffs
stop.
Some files cannot tolerate that flexibility. A shell script with
CRLF endings fails with a baffling bad interpreter error,
because the carriage return becomes part of the interpreter
path. That is what eol=lf and eol=crlf are for — they pin
the working copy, overriding the platform default.
These rules do not retroactively fix what is already committed. Two commands do, and tell you where you stand:
*.png binary looks like it is telling Git the file is binary.
Git already knows — it detects binary content on its own.
binary is a macro attribute, a shorthand that expands to
three settings at once:
-diff stops Git generating a textual diff, so review shows
"Binary files differ" rather than screenfuls of garbage.
-merge stops it attempting a line-by-line three-way merge; on
a conflict it leaves your version in place and tells you to pick
one. -text disables line-ending conversion, and that is the
one that matters most — a stray CRLF translation inside a PNG
corrupts it.
Generated files are a different problem. package-lock.json is
committed on purpose, but nobody reads its diff and it drowns
the ten lines of real code beside it in review.
Linguist is GitHub's language-detection library, so these
are read by GitHub rather than by Git. linguist-generated
collapses a file by default in pull request diffs and drops it
from the language statistics — which is how you stop a Python
project being labelled "HTML" because of one vendored bundle.
Its siblings are linguist-vendored for third-party code and
linguist-language=Ruby for misleading extensions.
A diff hunk header looks like @@ -18,7 +18,7 @@ — a line
number in a file that may be 1,200 lines long, when what you
want to know is which function you are looking at.
That is what a diff driver provides. Git ships drivers for around thirty languages; switching one on is one line:
The driver carries a regular expression Git searches backwards
with, from each hunk to the definition enclosing it. The result
appears after the closing @@:
Now every hunk names its own function — in git diff, in
git log -p, and in git grep --show-function. For a language
Git has no driver for, define one in config with an xfuncname
pattern matching your definition lines.
A filter attribute names a pair of commands that transform a
file in transit: clean runs on the way in when you stage,
smudge on the way out at checkout, so the repository stores one
form and your working tree sees another. Git LFS is the one
everybody has met — clean swaps a 200 MB video for a short
text pointer before it enters history, and smudge fetches the
real file back. You define the pair in config and attach it with
*.psd filter=lfs -text. Write your own rarely: a broken smudge
filter breaks every checkout.
Some files conflict every single time and the resolution is always the same — a changelog everyone appends to, a list of contributors. Git can settle those itself.
merge=union is built in. When both sides change the same
region, instead of writing conflict markers Git keeps the lines
from both, ours first, and moves on:
Use it only for genuinely append-only lists. Point it at source code and you get both versions of a function stacked on top of each other, which compiles as well as it sounds.
merge=ours means "on conflict, keep ours, discard theirs" —
right for an environment-specific file each branch owns. Here is
the gotcha that catches everyone: ours is not a built-in
merge driver. The .gitattributes line alone does nothing,
silently. A driver is a command Git runs, so supply one:
That is the Unix true command, which does nothing and exits
zero, leaving the temporary file that already holds your version
untouched. The definition lives in config, so it is not
committed and a teammate who never ran that line gets ordinary
conflicts — put it in your setup script beside core.hooksPath.
The advanced course takes real merge drivers apart properly.
git archive packages a tree as a tarball or zip, and it is
what GitHub serves when someone downloads a release. Your tests,
CI workflows, and fixtures do not belong in there, and
export-ignore leaves them out:
git archive -o dist.tar.gz v2.4.0 then omits them entirely.
Several rules from several files can match one path, and you
will want to know which won. That is git check-attr:
Reach for it whenever a file behaves oddly in a diff or merge.
For the team. Committed.
Travels with the repository, applies to every clone, and decides how Git treats files — line endings, what counts as binary, which diff driver to use.
A rule here holds whether or not anyone configured anything.
For you. Never committed.
Applies to every repository on your machine and decides how Git behaves toward you — conflict style, autostash, sorting, typo correction.
Nobody else gets these unless they set them too.
Each of these settings deletes one recurring annoyance outright, and the annoyance is named first because that is how you will remember it.
If you adopt one, make it zdiff3. The default conflict style
shows two versions and leaves you to deduce which is the edit;
zdiff3 adds the common ancestor between them, turning "which
of these is the change?" into a question you answer by reading
rather than by archaeology. It needs Git 2.35, and
push.autoSetupRemote needs 2.37.
Committing to your employer's repository with your personal
email has a long tail: the commits do not link to your account,
they may fail a CI identity check, and fixing them means
rewriting history. Setting user.email per-repository works
until the day you clone one and forget.
Conditional includes remove the forgetting. One config file delegates to another when a condition holds:
Three details make this work. The trailing slash in
gitdir:~/work/ means "that directory and everything beneath
it"; leave it off and you match only a repository named exactly
work. Order matters, because Git applies files as it reads
them, so an includeIf at the bottom overrides what came
before. And the pattern matches the .git directory's location,
not your shell's current directory.
The variants are worth knowing: gitdir/i: matches
case-insensitively, which you want on macOS and Windows;
onbranch:main applies settings only while that branch is
checked out; and hasconfig:remote.*.url: keys off the remote
rather than the path. Verify from inside a repository, the only
place a condition can be evaluated:
An alias saves keystrokes, but the ones that earn their place
save decisions. An alias beginning with ! runs a shell command
instead of a Git subcommand, from the top of the working tree,
and that is where the interesting ones live.
Run git sync then git cleanup after a pull request merges
and your branch list stops growing forever. In that pipeline
--merged lists only branches fully contained in the current
one, grep -v '[*+]' drops the current branch and any checked
out elsewhere, xargs -r skips running when the list is empty,
and -d rather than -D refuses to delete anything unmerged —
so the command cannot lose work.
That is the end of Git in Practice. You can untangle conflicts, rebase, stash, cherry-pick, run a branching model, review through pull requests, tag releases, recover anything with the reflog, automate with hooks — and now make the repository itself carry your team's rules.
The Advanced Git course goes underneath all of it. You open
the object model and see what a commit, a tree, and a blob
really are; hunt a regression with git bisect; rewrite history
at scale with git filter-repo; write real merge drivers and
let rerere replay conflict resolutions it has seen before; run
several checkouts at once with worktrees; wire up submodules
without regretting it; keep an outsized repository fast; and
sign commits so authorship can be proved rather than claimed.
Before that, spend a week with what you built. Add a
.gitattributes to the project you care most about, starting
with * text=auto and one diff= line for your main language,
and adopt three of the config settings. Then watch which small
frustrations quietly stop happening — that disappearance is
worth more than any command you can memorise.
*.png binary # exactly equivalent to:
*.png -diff -merge -textpackage-lock.json linguist-generated=true
dist/** linguist-generated=true*.py diff=python
*.rs diff=rust
*.go diff=golangCHANGELOG.md merge=union
CONTRIBUTORS merge=union/.github export-ignore
/tests export-ignore
.gitattributes export-ignoresrc/reports/monthly.py: diff: python
src/reports/monthly.py: text: autogit ls-files --eol # per file: i/lf w/crlf — stored vs disk
git add --renormalize . # restage everything through the new rules
git commit -m "chore: normalise line endings"git config --global merge.ours.driver truegit check-attr -a -- src/reports/monthly.py# "cannot rebase: you have unstaged changes"
git config --global rebase.autoStash true
# fixup! commits you drag into place by hand every time
git config --global rebase.autoSquash true
# a conflict where you cannot tell which side is the edit
git config --global merge.conflictStyle zdiff3
# branches deleted upstream haunting your tab-completion
git config --global fetch.prune true
# "fatal: the current branch has no upstream branch"
git config --global push.autoSetupRemote true
# diffs pairing one function's brace with the next one's
git config --global diff.algorithm histogram
# git branch listing alphabetically, newest buried
git config --global branch.sort -committerdate
# forty branches printed as one tall column
git config --global column.ui auto
# "git: 'stauts' is not a git command"
git config --global help.autocorrect prompt
# git status taking six seconds (macOS and Windows)
git config --global core.fsmonitor true# ~/.gitconfig
[user]
name = Ada Lovelace
email = ada@personal.dev
# Everything cloned under ~/work uses the work identity.
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work# ~/.gitconfig-work
[user]
email = ada@acme-corp.example
[commit]
gpgsign = truegit config user.email # ada@acme-corp.example
git config --show-origin user.email # and which file said so[alias]
lg = log --oneline --graph --decorate --all
fixup = commit --fixup
# Get back to a fresh main in one word.
sync = "!git switch main && git pull --prune"
# Delete every local branch already merged into this one.
cleanup = "!git branch --merged | grep -v '[*+]' \
| xargs -r git branch -d"git check-attr -a -- <path> # which attributes apply
git ls-files --eol # index vs worktree endings
git add --renormalize . # apply new text rules
git config merge.ours.driver true # enable the ours driver
git archive -o dist.tar.gz v2.4.0 # honours export-ignore
git config --list --show-origin # every value and its file
git config user.email # identity here, right now
git config --show-origin user.email # ...and which file set it@@ -18,7 +18,7 @@ def normalise_email(raw: str) -> str:
- return raw.strip()
+ return raw.strip().lower()