.gitattributes and Line Endings
Windows text editors traditionally end each line with two characters, carriage-return and line-feed (CRLF, \r\n), while macOS and Linux use just a line-feed (LF, \n). Git stores files as raw bytes, so if two teammates on different operating systems save the same file, every single line can register as changed even though nothing meaningful was edited. .gitattributes is the file that tells Git how to normalize line endings (and how to treat binary files, diffs, and merges) on a per-repository, per-path basis, so this stops happening.
Overview: why line endings become a Git problem
Git’s object model doesn’t know anything about “text” versus “code” versus “binary” by default — a blob is just a sequence of bytes, hashed with SHA-1 to produce its object ID. If src/app.js is 500 bytes with LF endings on your Mac and someone else’s Windows editor rewrites it with CRLF endings, the byte sequence changes completely, Git computes a different blob hash, and the file shows up as 100% modified in git diff — even though every visible character is identical. Multiply that across dozens of files and a whole team, and you get diff noise, spurious merge conflicts, and pull requests that look like they touched everything.
Git has two related mechanisms for dealing with this: the core.autocrlf configuration setting (a per-machine, global fix) and the .gitattributes file (a per-repository, per-path fix that’s checked into version control so everyone gets the same behavior automatically). core.autocrlf is a blunt instrument — it converts line endings for every text file, based on nothing but the local Git config of whoever is committing. .gitattributes is precise: it lets the repository itself declare “these files are text and should always be stored as LF,” “these are Windows batch scripts and should always be CRLF,” and “these are binary and Git should never touch them.” Because .gitattributes lives in the repo and is committed like any other file, it travels with the project — a new contributor gets correct behavior the moment they clone, with no manual setup.
Internally, Git applies attributes at two moments: on checkout (writing a blob out to your working directory) and on checkin (reading your working directory file back into a blob before it’s added to the index). With text=auto, Git detects whether a file looks like text, normalizes it to LF when storing it in the repository (checkin), and — depending on core.autocrlf — may convert it back to the platform’s native ending when writing it to your working directory (checkout). The object stored in the repository stays LF-normalized regardless of platform, which is what keeps blob hashes stable across machines.
Syntax
A .gitattributes file lives at the root of a repository (or in any subdirectory, where it applies to that directory and below) and contains one pattern-and-attribute line per rule:
<pattern> <attribute1> <attribute2> ...
- pattern — a gitignore-style glob, e.g.
*.sh,*.png,docs/**, or*for every file. text— marks the file as text.text=autolets Git guess based on content; baretextforces it;-textforces it off (treat as binary).eol=lf/eol=crlf— forces a specific line ending in the working directory for this pattern, overridingcore.autocrlffor these files only.binary— shorthand for-text -diff -merge: never convert line endings, never try to line-diff or line-merge the file.diff— controls whether/howgit diffshows changes for a path;-diffsuppresses textual diffs.linguist-*,export-ignore, etc. — other tooling (GitHub’s language detection,git archive) also reads this file; not all attributes are about line endings.
Rules are matched top-to-bottom, and a later matching line for the same attribute overrides an earlier one — so put the broad * text=auto catch-all first and more specific overrides below it.
Examples
Example 1: setting core.autocrlf per machine
Before touching .gitattributes, it’s worth understanding the per-machine setting it’s meant to complement:
# Check your current setting
git config --get core.autocrlf
# On Windows: convert LF to CRLF on checkout, CRLF to LF on commit
git config --global core.autocrlf true
# On macOS/Linux: never convert on checkout, but strip CRLF on commit if present
git config --global core.autocrlf input
Output:
true
git config --get prints whatever value is currently set (or nothing, and a non-zero exit status, if it’s unset). The two --global commands produce no output on success — they just write to your ~/.gitconfig. This is a real fix, but it depends on every contributor remembering to set it correctly on every machine, which is exactly the gap .gitattributes closes.
Example 2: adding a .gitattributes file and normalizing an existing repo
# .gitattributes at the repo root
# Default: let Git handle text files automatically
* text=auto
# Always LF, regardless of OS, for scripts
*.sh text eol=lf
# Always CRLF for Windows batch files
*.bat text eol=crlf
# Treat these as binary - never touch them
*.png binary
*.jpg binary
*.zip binary
# PDFs can't be meaningfully diffed as text either
*.pdf binary
Output:
(this is a file's contents, not a command — it defines how Git treats
each matching path during checkin, checkout, and diff)
After committing this file, it only affects new checkins and checkouts by default — files already stored with mixed endings aren’t retroactively fixed. To apply it to everything already tracked, re-run the checkin conversion across the whole tree:
git add --renormalize .
git status
git commit -m "chore: normalize line endings via .gitattributes"
Output:
Changes to be committed:
modified: src/app.js
modified: src/utils.js
modified: scripts/deploy.sh
git add --renormalize . re-reads every tracked file through the current .gitattributes rules and re-stages any whose stored (blob) form would change under the new rules. The resulting commit typically shows only line-ending changes — the visible file contents are unchanged, but every affected blob gets a new SHA-1 hash because the underlying bytes are now consistent.
Example 3: verifying attributes with git check-attr
git check-attr text eol -- src/deploy.sh src/logo.png
Output:
src/deploy.sh: text: set
src/deploy.sh: eol: lf
src/logo.png: text: unset
src/logo.png: eol: unset
git check-attr tells you exactly which rule Git will apply to a given path, which is invaluable when .gitattributes rules stack up and it’s not obvious which line “wins” for a particular file. Here it confirms deploy.sh is treated as text with LF endings enforced, while logo.png matched the binary rule and has no text/eol behavior at all.
How it works step by step
When you run git add on a file covered by a text attribute, Git reads the working-directory bytes, converts any CRLF sequences to LF (this is the “checkin” conversion), hashes the normalized content to produce a blob object, and stores that blob in the index and eventually the repository. The object database only ever holds the LF-normalized version. When you later run git checkout, git switch, or git clone, Git reads that stored blob and, depending on the resolved eol/core.autocrlf settings for that path, either writes it out as-is (LF) or converts it back to CRLF for your working directory (the “checkout” conversion). This is why two people with different core.autocrlf settings, working through the same .gitattributes file, can both see native line endings locally while the repository itself stays byte-for-byte consistent. Because text=auto also affects diffing, Git compares the normalized form internally, so a file that only differs by line-ending style before normalization won’t manufacture a fake diff after it.
Common Mistakes
Mistake: relying only on core.autocrlf and skipping .gitattributes. This works only as long as every contributor sets the same value correctly, forever. One teammate on the default Windows Git install with autocrlf misconfigured is enough to reintroduce CRLF into the repository, and nothing in the repo itself catches it.
warning: LF will be replaced by CRLF in src/app.js.
The file will have its original line endings in your working directory
That warning means Git is silently converting on checkout for you right now — it’s informative, not an error, but it’s a sign the repo has no committed .gitattributes pinning the behavior for everyone. Fix: commit a .gitattributes with at least * text=auto so the rule travels with the repo instead of living only in each person’s global config.
Mistake: adding .gitattributes but never renormalizing. Adding the file only changes behavior for future checkins; files already committed with the “wrong” ending stay that way until you explicitly renormalize.
git add .gitattributes
git commit -m "chore: add .gitattributes"
This commits the rules but touches no other files, so the repository still contains the old, inconsistent blobs. Fix: follow up with git add --renormalize . and commit the result, as shown in Example 2.
Mistake: marking binary files as text (or vice versa). Applying text=auto too broadly (say, a bare * pattern with no binary exceptions) can cause Git to attempt line-ending conversion on a .png or .zip file, corrupting it. Fix: always pair a broad text=auto rule with explicit binary rules for known binary extensions, as in Example 2.
Best Practices
- Commit a
.gitattributesfile to every repository, even solo projects — it costs nothing and protects future collaborators. - Start every
.gitattributeswith* text=autoas the default, then add specific overrides below it. - Explicitly mark known binary types (images, archives, fonts, compiled artifacts) as
binaryso Git never attempts to diff, merge, or convert them. - Force
eol=lfon shell scripts and other Unix-oriented files so they never accidentally gain CRLF endings that break execution on Linux/macOS or in CI. - After introducing or editing
.gitattributesin an existing repo, always rungit add --renormalize .and review the resulting diff before committing. - Treat
core.autocrlfas a personal safety net, not the primary fix —.gitattributesis the one source of truth that applies to everyone who clones the repo. - Use
git check-attrwhen a file’s diff or merge behavior looks surprising — it tells you exactly which rule matched.
Practice Exercises
- Create a new repository, add a shell script and a small PNG image, and write a
.gitattributesthat keeps the script as LF-only text and the image as binary. Usegit check-attrto confirm both are resolved the way you intended. - Simulate the mixed-endings problem: commit a JavaScript file without any
.gitattributes, then changecore.autocrlfand re-save the file so it gains CRLF endings. Rungit diffand observe the noise. Then add* text=auto, renormalize, and confirm the diff goes quiet. - Add a
*.bat text eol=crlfrule alongside a*.sh text eol=lfrule in the same.gitattributes, add one file of each type, and usegit check-attron both to verify each keeps its own forced ending independent of your localcore.autocrlfsetting.
Summary
- Windows uses CRLF and macOS/Linux use LF line endings; mismatched endings make Git see whole files as changed even when content is identical.
core.autocrlfis a per-machine Git config setting that converts endings on checkin/checkout, but it depends on every contributor configuring it correctly..gitattributesis a committed, per-repository file that defines line-ending and binary-handling rules for specific paths, so behavior is consistent for everyone who clones the repo.* text=autois the recommended baseline rule; addeol=lf/eol=crlfoverrides and explicitbinaryrules as needed.- Adding
.gitattributesto an existing repo doesn’t retroactively fix already-committed files — rungit add --renormalize .and commit the result. git check-attrshows exactly which attribute rule Git will apply to a given path, which is the fastest way to debug unexpected diff or line-ending behavior.
