Tags gitCRLFline endingsgitattributescode review
All posts

git

One line changed and git recorded 295

When line endings flip from LF to CRLF, git treats every line as modified. Measured numbers for the diff, the blame damage, and why -w beats --ignore-rev.

August 21, 2026 5 min

I edited one comment line and the commit showed 295 lines changed. The file was 295 lines long, so that was all of it.

Nothing else on screen had moved. Git disagreed, and it was right, because the thing that changed was a character you cannot see.

Line endings are content that git compares

Pressing enter writes an actual character into the file, and git compares it like any other byte. There are two conventions for what that character is: LF (\n), used on macOS and Linux, and CRLF (\r\n), used on Windows. CR and LF are inherited from typewriters, where one returned the carriage to the left margin and the other advanced the paper.

Git compares bytes, so const a = 1 followed by \n and the same text followed by \r\n are two different lines. Flip a whole file from one convention to the other and every single line reads as modified.

Nothing errors. The code still compiles, the app still runs, the rendered output is identical. Only the history changes.

Tools cause this without being asked. Any tool that rewrites a file wholesale (an editor saving, a formatter rewriting, a script reading and writing back) emits its own default ending rather than preserving what was there.

The measurement

I built a throwaway repo, dropped in a real 295-line source file from my project, and compared two commits.

Editing one comment line:

1 file changed, 1 insertion(+), 1 deletion(-)

The same edit, with the file saved as CRLF:

1 file changed, 295 insertions(+), 295 deletions(-)

A reviewer now has to find one real change inside 295 lines of noise. In practice nobody does that. They approve it.

Blame is the part you cannot shrug off

A messy diff is a bad afternoon. The same shape of problem shows up whenever a green check turns out to mean nothing, which I wrote about separately. The permanent damage lands in git blame, which is how you answer “why is this line like this” when you are three months removed from writing it.

After committing the CRLF version:

295 summary fix: tidy up a comment

Every line in the file now belongs to a commit whose message is “tidy up a comment”. Months of authorship history, from however many people, collapsed into one misleading label. The history is not gone, but the tool you actually reach for cannot see past it.

My first attempt failed to reproduce it

Worth recording, because it turns out to be the most important part.

My first run produced this:

1 file changed, 1 insertion(+), 1 deletion(-)

I had converted the entire file to CRLF and git reported one line. For a moment I concluded the problem does not really happen.

The cause was core.autocrlf = input on my machine, which silently normalizes CRLF back to LF on the way into the index. Git was quietly repairing every file I tried to break. With that setting off, 295 appeared immediately.

So the same edit, committed the same way, produces different history depending on whose machine ran the commit. That is why this shows up sporadically and feels random: the rule is not absent, it is per-developer. One Windows contributor, or one person with a different config, and the problem appears only on the files they touch.

Recovering blame, and a result I did not expect

-w tells blame to ignore whitespace differences, and a trailing CR counts as whitespace:

git blame -w path/to/file
294 summary base
  1 summary fix: tidy up a comment

294 of 295 lines returned to their real authors, and the one line I genuinely edited stayed with the new commit. Exactly correct.

The commonly recommended option for this situation is --ignore-rev, which asks blame to skip a specific commit. By name it sounds like the more precise tool. I measured it:

281 summary base
 14 summary fix: tidy up a comment

Only 281 lines recovered. Fourteen stayed misattributed. The better-named option performed worse.

I do not know exactly why those fourteen survive. My guess is that blame walks hunks rather than individual lines, so lines adjacent to the real edit get absorbed into it, but I did not verify this.

The same flags exist for diffs:

git diff -w                    # ignore whitespace
git diff --ignore-cr-at-eol    # ignore only the trailing CR

Both collapsed the 295-line diff back to one line. Useful during review, but they hide the noise rather than remove it. What landed in the repository is unchanged.

Preventing it

Commit a .gitattributes at the repository root:

* text=auto
*.bat text eol=crlf
*.sh  text eol=lf

The first line normalizes text files to LF on the way into the repository regardless of what the working tree contains. I tested it under the exact condition that produced 295, with core.autocrlf disabled, and got:

1 file changed, 1 insertion(+), 1 deletion(-)

You can achieve the same effect with core.autocrlf, but that lives in each developer’s local config. New contributors do not have it, and there is no convenient way to confirm who does. .gitattributes is checked in, so it applies identically to everyone who clones.

The remaining lines are exceptions. Windows batch files need CRLF to execute correctly and shell scripts need LF, so normalizing everything would break both.

Checking my own repository: 2 of 3,860 tracked files contain CR, both of them gradlew.bat. That is correct for batch files, but nothing in the repository recorded that it was intentional.

A note in the commit message is not enough

Writing “includes line ending normalization” in the commit message genuinely helps reviewers, and it beats saying nothing.

But blame does not read commit messages. Three months later the line still points at that commit, and the reason the line exists is still missing.

A better ordering:

  1. Prevent it with .gitattributes.
  2. When it happens anyway, isolate the line ending change into its own commit.
  3. Record that commit’s hash in .git-blame-ignore-revs, which GitHub and recent git versions read automatically.

Step 2 is what makes step 3 possible. Once normalization and real edits share a commit, neither can be separated from the other.

Scope

Measured on 2026-08-21 with git 2.40.0, using a single 295-line file. Different files produce different numbers; the ratio is what transfers, not the absolute count.

The incident that prompted this happened elsewhere and could not be reproduced directly, so I reconstructed a case with matching symptoms. Whether the original had the same cause is something I cannot confirm.

The explanation for the fourteen lines --ignore-rev leaves behind is a guess. I will revisit it if I verify it.

Previous Wiring subscriptions: a full Play Store checklist