Jitsumi
Back to the blog

Git Line Endings: Fix CRLF/LF Churn with .gitattributes

Stop CRLF/LF changes from overwhelming code reviews. Inspect the index, introduce a shared policy, and normalize tracked files without hiding real edits.

A magnifying glass over abstract code lines beside a laptop, illustrating careful review of file changes.

A two-line fix should not turn into a pull request that appears to rewrite an entire file. When the extra changes are CRLF/LF conversions, the durable fix is a repository-level line-ending policy—not asking everyone to hide whitespace in their diff viewer.

Start by inspecting what Git has staged and what is actually on disk. Then agree on a .gitattributes policy, normalize existing tracked files in a separate change, and inspect the result before committing. Keep unfinished feature work out of that migration.

This guide is for developers maintaining a repository across Windows, macOS or Linux. It assumes an ordinary, fully checked-out repository with an existing commit. The worked example was tested with Git 2.47.3 on Linux; it is not a claim of native Windows or macOS testing.

First, distinguish the working file from the next commit

LF is a line-feed character; CRLF is a carriage return followed by a line feed. An editor can display either file as the same lines of text. Git's staging area, or index, is a separate snapshot from the working files you edit. GitHub's line-ending guide explains why a shared repository policy helps collaborators avoid unexpected conversion differences.

Run these read-only commands from the repository root:

git status --short
git ls-files --eol
git diff --stat
git diff --cached --stat

Read the --eol output from left to right. The i/ column describes the index, w/ describes the working file, and attr/ shows the applicable text/line-ending attributes. Git documents those columns in the git ls-files reference.

Example

What it tells you

What to do next

i/lf w/crlf attr/text eol=crlf

LF is staged; the working copy uses the requested CRLF format.

Do not treat the differing columns alone as an error.

i/crlf w/crlf attr/

The index contains CRLF and no text policy is displayed.

Check whether the project intends to normalize this file.

i/lf w/mixed attr/text=auto eol=lf

The index is LF, but the working file mixes line endings.

Inspect the file and the tool that last saved it.

i/-text w/-text attr/-text

Git treats this path as non-text and text normalization is disabled.

Preserve its bytes; do not force-convert it.

The first row is important: a CRLF working file can coexist with a correctly normalized LF index. Your editor's status bar alone does not tell you what the next commit will contain.

For a suspicious path, ask Git which rules apply:

git check-attr text eol filter working-tree-encoding -- scripts/build.sh
git config --show-origin --get core.autocrlf
git config --show-origin --get core.eol

Replace the example path with your own. A config lookup can print nothing and exit with status 1 when that key is unset; that is different from a broken repository. The configuration reference documents these settings and lookup behaviour. git check-attr reports effective attributes, including rules inherited from other attribute files.

Pause a bulk migration if filter or working-tree-encoding is set and you do not understand it. Attributes can describe more than newline conversion. The Git book's attributes chapter illustrates content filters and binary-file handling; replacing an existing attributes file can break those arrangements.

Choose one policy the repository can carry

For a project that wants LF text files by default and CRLF Windows command scripts, the following is a reasonable starting policy. Merge it into the existing root .gitattributes; do not blindly replace that file.

# Detect text and use LF in working copies by default.
* text=auto eol=lf

# Exceptions for this project's Windows command scripts.
*.bat text eol=crlf
*.cmd text eol=crlf

# Preserve known binary formats byte-for-byte.
*.png -text
*.jpg -text
*.zip -text

This is a deliberate LF-default policy, including on Windows—not a universal recommendation for every repository. Keep additional exceptions when your tools or file formats require them. Do not assume a filename extension proves that every file using it has the same requirements.

text=auto lets Git detect text. eol selects the working-copy line ending for text; normalized text is stored as LF in the index. -text switches text normalization off for matching paths. Later matching rules can override individual attributes, and more local attribute files can affect the result. The gitattributes manual is the authority for those rules.

Do not use * text as a shortcut for detection: that forces files to be treated as text. Our example also keeps the known binary exceptions explicit so a reviewer can see the intent.

A global core.autocrlf change affects your other repositories and does not distribute a project policy to colleagues. Diagnose it, but do not make everyone change their global setup to compensate for an undocumented repository convention. A committed policy is easier to review alongside the code.

Normalize existing files without mixing in feature work

Before changing attributes, save unfinished work through your normal reviewed commit or backup workflow. Stop if a merge, rebase or conflict resolution is in progress. Inspect git status and require a clean starting point; a clean result does not replace a backup of valuable untracked or ignored material.

Create a dedicated branch, then edit .gitattributes using the agreed policy:

git status
git switch -c chore/line-endings

From the repository root, stage the policy and reapply normalization:

git add -- .gitattributes
git add --renormalize .
git status --short
git diff --cached --stat

The separate first command matters when .gitattributes is new. --renormalize operates on tracked files; it does not automatically add unrelated untracked files. It reapplies Git's clean processing, so configured filters may also run. These behaviours are specified in the git add documentation.

Treat the resulting staged patch as a proposal, not a finished fix. Unexpected binary changes, deleted files or application edits are reasons to stop and investigate. A repository-wide command deserves a repository-wide review.

Now compare the ordinary patch with a narrowly filtered view:

git diff --cached
git diff --cached --ignore-cr-at-eol
git diff --cached --check

--ignore-cr-at-eol ignores carriage returns at line ends during comparison. It is narrower than --ignore-space-at-eol, and much narrower than -w, which ignores all whitespace. None of these options changes the stored patch. The git diff reference defines the differences.

In a pure conversion, the filtered view should remove the CRLF/LF noise in the affected text files. Your new attributes file will still appear. Any remaining code change needs an explanation. Review the ordinary diff too: a filtered view is not proof that a patch is safe, and whitespace can carry meaning in some formats.

The last command checks for configured whitespace problems and introduced conflict markers. It does not run your application or validate the line-ending policy. Jitsumi's guide to checking staged changes with git diff --check explains that separate check in more detail.

After reviewing the patch and running the project's tests, commit the normalization separately from functional changes:

git commit -m "Define and normalize repository line endings"

That separation gives reviewers a clear question: did the policy change only the intended file representations? It also avoids burying a bug fix under hundreds of mechanical line changes.

What the worked example actually showed

Our disposable test repository started with core.autocrlf=false, a CRLF shell script, a CRLF command script, a mixed-ending text file and a small binary fixture containing null bytes and CRLF bytes. We committed those inputs before adding the policy shown above. No global Git settings were changed.

After git add --renormalize ., the shell script's actual output included:

i/lf    w/crlf  attr/text=auto eol=lf    scripts/build.sh

The staged version had changed to LF, but the existing working file still contained CRLF. Staging normalization is not the same operation as rewriting every working file on disk. This observation is why the workflow checks both columns rather than promising that the editor will immediately display LF.

We then committed the migration and made three fresh local clones, using core.autocrlf=false, true and input respectively during cloning. In all three, the explicit policy produced LF for the shell script, CRLF for the command script, unchanged binary bytes, and a clean Git status.

The test also staged a real edit from hello to goodbye. It remained visible under the CR-at-end-of-line filter:

-printf "hello\n"
+printf "goodbye\n"

All 23 assertions in this fixture passed. They covered index bytes, mixed-ending normalization, excluded untracked files, binary preservation, fresh-clone behaviour, visible application edits and safe unstaging. This is bounded evidence for this policy and these files—not a test of every editor, custom filter, encoding, Git version or operating system.

For your repository, use a separate fresh clone of the migration branch as the acceptance check. Inspect representative files with git ls-files --eol, confirm git status is clean, and run the normal build or tests there. Check the actual tools and platforms your team supports before merging. Do not delete the original working directory to perform this check.

Stop safely when the result is wrong

Before committing, you can remove an affected path from the staging area while keeping its working-file contents:

git restore --staged -- scripts/build.sh

The git restore reference distinguishes restoring the index from restoring the working tree. Do not drop --staged or add --worktree casually: those are different operations. Unstaging does not undo an edited attributes file or repair a conversion already applied by an editor.

When a binary path appears in the conversion, inspect its original bytes, correct the attribute rule, and stage the intended bytes again. If an editor already changed a file destructively, recover it from a known-good copy; changing .gitattributes cannot reconstruct lost data.

If Git refuses a conversion under core.safecrlf, investigate rather than disabling that protection globally. It checks whether conversion is reversible under the current settings, as described in the configuration reference linked above. A migration may intentionally change representations, but that decision still requires reviewing the affected files.

If different machines behave differently after the migration, compare effective attributes and configuration origins. Check nested .gitattributes files and local .git/info/attributes, then check which editor or generator writes the file. Agree on what that tool should emit instead of repeatedly renormalizing its output.

The finish line is not “the diff viewer is quiet.” It is a reviewable policy, an isolated normalization commit, intact binary files, and a fresh checkout that behaves as the project expects.

Share this post