Standardising line endings with gitattributes Jump to heading

Line-ending problems announce themselves the same way every time: someone opens a pull request that changes two lines and the diff shows four hundred files entirely rewritten. The cause is that each machine decides independently how to convert line endings on checkout and commit, so a Windows machine with one setting and a Linux machine with another disagree about what the file even contains. Attributes move the decision into the repository, where it is the same for everyone. This recipe applies it without losing history, within Git configuration management at scale.

When to use this approach Jump to heading

  • Diffs occasionally show whole files as changed when nothing was edited.
  • The team uses a mix of Windows, macOS and Linux machines.
  • Shell scripts have failed with a bad interpreter error.
  • A merge conflicted on every line of an untouched file.
  • If everyone is on the same platform and nothing has ever gone wrong, this is still cheap insurance but not urgent.

Step 1 β€” See what is actually stored Jump to heading

The index is what matters; the working copy is a rendering of it.

# Line endings as stored in the index, and as they appear in the working tree
git ls-files --eol | head -20
# Count files stored with CRLF, which is usually the surprise
git ls-files --eol | awk '$1 ~ /crlf/ {n++} END {print n+0 " file(s) stored with CRLF"}'
# Verification: and what each machine is currently doing
git config --get core.autocrlf || echo "autocrlf unset (good β€” let attributes decide)"
Where the conversion happensAttributes decide how content is normalised on the way into the index and how it is rendered on checkout. Without them each machine's autocrlf setting decides independently, so two machines can disagree about what the same commit contains.Working treeplatform endingsNormalise on addattributes decideLF in the indexIndexone canonical formRender on checkoutplatform, per attributethe middle two boxes are the same for everyone once attributes exist

Step 2 β€” Declare the policy Jump to heading

cat > .gitattributes <<'ATTR'
# Default: detect text, store LF in the index, check out LF everywhere.
* text=auto eol=lf

# Files that must keep a specific ending regardless of platform
*.sh        text eol=lf
*.bash      text eol=lf
*.bat       text eol=crlf
*.cmd       text eol=crlf
*.ps1       text eol=crlf

# Binary: never convert, never diff as text
*.png       binary
*.jpg       binary
*.pdf       binary
*.woff2     binary
*.patch     -text
ATTR
# Verification: what will Git do with each kind of file?
git check-attr text eol -- src/main.sh scripts/setup.bat assets/logo.png

*.patch -text is worth including: a patch file’s content is line endings, so normalising it corrupts the patch. The same applies to any format where the bytes are the point β€” test fixtures that assert on exact content, for example.

Step 3 β€” Renormalise, in one commit that says so Jump to heading

Adding attributes does not change files already stored. Renormalising does, and it must be a separate, isolated commit.

# Safety first: the working tree must be clean
git status --porcelain | head
test -z "$(git status --porcelain)" || { echo "commit or stash first"; exit 1; }
git add --renormalize .
git status --short | wc -l
git commit -m 'chore: renormalise line endings to LF

Applies the policy in .gitattributes to files already stored with CRLF.
No content changed other than line endings. Use --ignore-all-space or
git blame -w to see through this commit.'
# Verification: nothing but line endings changed
git show --stat HEAD | tail -3
git diff HEAD~1 HEAD --ignore-all-space --stat    # expect an empty diff

That last check is the one that proves the commit is safe: with whitespace ignored, a pure renormalisation shows no changes at all.

SAFETY WARNING β€” a renormalisation commit touches nearly every file, so it will conflict with every open branch. Do it when open work is minimal, announce it, and tell people to rebase rather than merge β€” a merge across the renormalisation resolves line endings in both directions and produces a mess. Record the commit id so it can be added to a blame ignore list.

Step 4 β€” Keep the commit out of everyone’s way Jump to heading

# Record it so blame skips it by default
echo "$(git rev-parse HEAD)  # renormalise line endings" >> .git-blame-ignore-revs
git add .git-blame-ignore-revs && git commit -m 'chore: ignore the renormalisation commit in blame'
# Each clone opts in once (or via the team config include)
git config --local blame.ignoreRevsFile .git-blame-ignore-revs
# Verification: blame now attributes lines to their real authors
git blame -- src/main.sh | head -3

Most forges honour .git-blame-ignore-revs automatically, so this single file fixes blame both locally and in the web interface β€” the difference between a renormalisation everybody resents and one nobody notices a month later.

With and without a blame ignore fileWithout the ignore file, every line in every text file is attributed to the renormalisation commit, and blame becomes useless for the whole repository. With it, blame skips that commit and shows the real author of each line.No ignore fileWith ignore-revsblame showsthe renormalisationthe real authoruseful for historynoyesforge web viewalso brokenalso fixedcostnoneone file, one committhis file is the difference between a safe cleanup and a permanently damaged blame

Step 5 β€” Prevent it recurring Jump to heading

# A check that fails if anything is stored with CRLF where policy says LF
git ls-files --eol | awk '$1 ~ /i\/crlf/ && $4 !~ /\.(bat|cmd|ps1)$/ {print; n++} END {exit n>0}' \
  || { echo "::error::files stored with CRLF against policy"; exit 1; }
# And the setting that must NOT be on, because attributes should decide
test "$(git config --get core.autocrlf || echo false)" = "false" \
  || echo "warning: core.autocrlf is set; attributes should own this decision"
# Verification: run both on a clean clone
git clone "$(git remote get-url origin)" /tmp/eol-check && (cd /tmp/eol-check && git ls-files --eol | grep -c 'i/crlf')
Applying the policy, in the right orderDeclare the policy, make sure nothing is in flight, renormalise in one isolated commit, record it for blame, and add a check that stops the problem returning. The isolation of the renormalisation commit is what makes it reviewable.Attributespolicy declaredstep 1Quiet periodopen branches minimalstep 2Renormaliseone isolated commitstep 3Blame ignorehistory stays usablestep 4CI checkit cannot returnstep 5skipping step 4 is what makes people remember the cleanup for years

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should core.autocrlf be set at all? Jump to heading

Preferably not. It is a per-machine setting that predates attributes and competes with them, so leaving it unset keeps a single source of truth. Where a corporate installer has set it globally, the attributes still win for files they cover β€” but the audit should flag it, because it will confuse the next person who investigates a conversion problem.

What about files that must keep CRLF? Jump to heading

Mark them explicitly with eol=crlf. Windows batch files and PowerShell scripts are the usual cases, and a few tools genuinely require it. Being explicit is better than relying on a platform default, because the default is what caused the original problem.

Will the renormalisation break git bisect? Jump to heading

It will make one commit in history look enormous, but bisect works on content rather than diffs, so it still functions. What it can break is a build that bisect runs on a commit just before renormalisation on a platform that expects the other ending β€” rare, and worth knowing about when a bisect produces a surprising result near that commit.