CRLF 转换把 2 行改动撑成 3000 行 diff

一次换行符规范化把 2 行改动变成几千行 CRLF/LF diff。先判断自己属于哪一类,再修好 .gitattributes、renormalize 一次,并彻底防止复发。

你在 Windows 上只改了 src/api/routes.ts 里两行,运行 git diff 却看到三千行变更——整个文件每一行都「变了」。或者同事的 PR 显示一大片红绿 diff,真正的改动只有一行 import,但在 raw 视图里每一行绿色行末都带着一个 ^M。是 Git 的 core.autocrlf.gitattributes 规则在暂存时把 CRLF 转成了 LF(或反过来),于是 diff 触及了每一行。现在仓库里的文件处于混合换行符状态,每次从 Windows 机器提交都可能再炸一次。

最快的修法(多数情况): 提交一个内容为 * text=auto.gitattributes,然后运行 git add --renormalize . 并提交这一次结果。这一个规范化 commit 会把整个仓库在 index 里统一成 LF,之后的 diff 就干净了。本文剩下的部分用来帮你确认是否属于这种情况,以及处理边角案例(二进制文件被当成文本、必须保留 CRLF 的批处理脚本、被污染的 blame 历史)。

先判断:你属于哪一类?

运行下面三条命令,对照输出。它们能告诉你问题是 index 与工作区不一致、编辑器问题,还是二进制文件被破坏。

命令你看到的输出诊断跳到
git config core.autocrlfWindows 上是 true,其他机器未设或 false各机器配置不一致原因 1
git ls-files --eol src/api/routes.tsi/lf w/crlf attr/text=autoindex 是 LF,工作区是 CRLF(checkout 过时)原因 2
file src/api/routes.ts... with CRLF line terminators,而 .gitattributes 写的是 eol=lf编辑器保存了 CRLF,覆盖了属性原因 3
git show HEAD:assets/font.woff2 | file -真二进制却返回 ASCII text二进制文件被误判为文本原因 4
git diff --check几乎每一行都报 whitespace error仓库里早已混入混合换行符原因 5

这里最有用的诊断命令是 git ls-files --eol。输出格式是 i/<eol> w/<eol> attr/<attr> <file>i/ 是 index(已提交)的版本,w/ 是你的工作区,attr/ 是当前生效的 .gitattributes 规则。i/lf w/crlf 说明 index 是对的、只是本地文件漂移了——这是「diff 显示全文件都改了」最常见的原因。

常见原因

按命中率从高到低排列。

1. Windows 上 core.autocrlf=true,其他机器是 false 或未设

Windows 版 Git 安装时默认把 core.autocrlf 设为 true:checkout 时把 LF 转成 CRLF,commit 时再把 CRLF 转回 LF。当一个 macOS 开发者提交了 LF 文件,Windows 开发者 checkout(磁盘上变成 CRLF)后又在配置不一致的情况下 git add,Git 就把 CRLF 字节当成新内容暂存——于是每一行都「变了」。

怎么判断:在 Windows 机器上 git config core.autocrlf 返回 truegit diff --check 对受影响文件每一行都报 whitespace error。如果 git diff --ignore-cr-at-eol HEAD 显示空 diff,就确认了差异只是行末的回车符。

2. 文件已提交之后才加上或改动 .gitattributes

有人提交了 * text=auto 想从此规范化,却没有重新规范化仓库里已有的文件。现在 git status 显示几百个文件被修改:checkout 按规则把工作区转成了 CRLF,但 index 里还是原始字节。

怎么判断git ls-files --eol 对很多文件打印 i/lf w/crlf——index 是 LF,工作区是 CRLF。修法是下面 Step 2 的一次 git add --renormalize . commit,而不是逐个文件去较劲。

3. Windows 上编辑器配置成了 CRLF

VS Code 在 Windows 上把 files.eol 全局设成了 \r\n(或者文件曾以 Auto 检测打开后又被重新保存成 CRLF)。即使 .gitattributes 写了 text eol=lf,每次保存仍会带上 CRLF。.gitattributes 控制的是 Git 存储的内容,不是编辑器写到磁盘上的内容——所以一个保存 CRLF 的编辑器照样会制造脏工作区。

怎么判断file src/api/routes.ts 返回 “with CRLF line terminators”,而对应的 .gitattributes 规则写的是 eol=lf。VS Code 右下角状态栏显示 CRLF,点它可以把当前打开的文件切回 LF

4. 二进制文件没在 .gitattributes 里标注,被当成文本

某个 .png.woff2 没被标为 binary。Git 的 text=auto 启发式把它猜成了文本,试图规范化它的换行符,结果破坏了二进制内容并产生整文件 diff。

怎么判断file assets/font.woff2 返回真实的二进制类型,但 git show HEAD:assets/font.woff2 | file - 返回 ASCII text(错的——Git 改写了字节)。被 Git「修正」掉的二进制里任何 0x0D 字节都已经丢失。

5. 仓库里早已提交了混合换行符

已提交的文件本身就混着 LF 和 CRLF 行(多人协作、一次糟糕的合并)。Git 在暂存时规范化,输出在每一处混合行上都与输入不同,于是产生噪音 diff。

怎么判断:对一个本该纯 LF 的文件,git grep -c $'\r' -- src/api/routes.ts 返回非零计数;或者 cat -A src/api/routes.ts 在行末显示 ^M

最短修复路径

Step 1:在仓库根目录创建或修正 .gitattributes

cat > .gitattributes << 'EOF'
# 默认:让 Git 自动判断文本/二进制,文本统一以 LF 存储。
# 这是 GitHub 官方推荐的基线。
* text=auto

# 如果还想让工作区磁盘上也强制 LF(更严格),把上面那行换成
# "* text=auto eol=lf"。Git 2.10 起支持。两种写法不要同时用。

# 在所有平台上都必须 CRLF 的文件。
*.bat   text eol=crlf
*.cmd   text eol=crlf
*.sln   text eol=crlf
*.ps1   text eol=crlf

# 在所有平台上都必须 LF 的文件(否则会坏)。
*.sh    text eol=lf

# 真正的二进制——绝不碰换行符。
*.png   binary
*.jpg   binary
*.jpeg  binary
*.gif   binary
*.ico   binary
*.woff  binary
*.woff2 binary
*.ttf   binary
*.eot   binary
*.pdf   binary
*.zip   binary
*.gz    binary
*.tar   binary
EOF
git add .gitattributes
git commit -m "chore: add .gitattributes to normalize line endings"

* text=auto(GitHub 文档里的默认写法)把文本以 LF 存进仓库、按各平台原生换行符 checkout——Windows 用户磁盘上仍然是 CRLF,这通常正是你想要的。* text=auto eol=lf 是更严格的变体,会让所有平台磁盘上也强制 LF;只有当团队已经统一在编辑器里用 LF 时才用它。两种写法不要同时写。

Step 2:重新规范化所有现有文件(GitHub 推荐的做法)

# 先把未提交的工作保存掉,让规范化 commit 干净。
git add -u
git commit -m "chore: save work before line-ending renormalize"

# 让新的 .gitattributes 规则作用到每一个被跟踪的文件。
git add --renormalize .

# 检查改了什么——这些应当只是换行符层面的 diff。
git status
git diff --cached --stat

# 把规范化作为一个独立 commit 提交(对 blame 很重要)。
git commit -m "chore: normalize all line endings per .gitattributes"

git add --renormalize . 是干这件事的现代、安全命令——它把每个被跟踪文件重新走一遍 .gitattributes 过滤后再暂存,不删除任何东西。(老办法 git rm --cached -r . && git add . 也能用,但更吵,而且可能漏掉规则改动后变成被忽略的文件。优先用 --renormalize。)

把这次规范化保持成一个单独、独立的 commit,并记下它的 SHA——Step 5 会用它来保持 git blame 干净。

Step 3:把所有开发者机器的 core.autocrlf 设为 false

git config --global core.autocrlf false
git config --global core.eol lf

既然由 .gitattributes 控制换行符,autocrlf 就是多余的,还会把上面那些各机器冲突重新引回来。false 让 Git 完全交给 .gitattributes

Step 4:修好 Windows 编辑器设置(并提交它)

在工作区的 .vscode/settings.json 里写好并提交进仓库,让每个贡献者都继承:

{
  "files.eol": "\n"
}

这让 VS Code 在所有平台都以 LF 保存。再配一个 .editorconfig,让 JetBrains、Sublime、Vim 遵循同样规则:

root = true

[*]
end_of_line = lf
insert_final_newline = true

Step 5:规范化 commit 之后保持 git blame 干净

一次全仓库 renormalize 会重写许多文件的每一行,于是 git blame 会把它们全部归到那一个 chore commit 上。把它屏蔽掉:

# 把规范化 commit 的 SHA 记进一个被跟踪的文件。
echo "<normalize-commit-sha>" >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: ignore line-ending renormalize in blame"

# 让本地 blame 和 GitHub 界面都使用它。
git config blame.ignoreRevsFile .git-blame-ignore-revs

GitHub、GitLab 和现代版 git blame 都支持 .git-blame-ignore-revs,每一行真正的作者都会被保留。

Step 6:用一次干净 checkout 验证修复

git ls-files --eol | grep -v 'w/lf' | grep -v binary   # 预期没有剩下的文本文件
git diff --check                                        # 预期无输出
# 端到端抽查一个文件:
git stash && git checkout -- . && git diff --check && git stash pop

如果 git ls-files --eol 对文本文件只显示 w/lf(加上你刻意设的 eol=crlf 例外),并且 git diff --check 没有输出,仓库就规范化好了。

预防建议

  • 在每个新仓库的第一个 commit 里就提交 .gitattributes,赶在任何混合换行符文件落地之前。
  • 用 Git 配置模板统一 core.autocrlf = false,让新 clone 一开始就正确:git config --global init.templateDir ~/.git-templates
  • .gitattributes 一起提供带 end_of_line = lf.editorconfig;VS Code、JetBrains、Sublime、Vim 都支持。
  • 在 CI 步骤或 pre-commit hook 里加 git diff --check(或 git ls-files --eol 断言),在回归合并进来之前就拦住。
  • CONTRIBUTING.md 里写明 Windows 贡献者必须 core.autocrlf = false
  • 每当有新的 .gitattributes 规则落地,通知所有人各运行一次 git add --renormalize .,让工作副本收敛而不丢失未暂存的改动。

常见问答 (FAQ)

Q: 规范化 commit 之后,git diff 对某些文件还是显示几千行变更。为什么? A: 这些文件 index 里已是新的 LF 内容,但工作区还是上一次 checkout 留下的 CRLF(git ls-files --eol 显示 i/lf w/crlf)。运行 git checkout -- <file> 用正确换行符重新 checkout,或者再跑一次 git add --renormalize . 把它们重新规范化暂存。

Q: 我们有个 Windows 构建工具要求 CRLF 批处理脚本,怎么保住它们? A: 加上 *.bat text eol=crlf*.cmd text eol=crlf(需要的话再加 *.ps1*.sln)。这些文件在所有平台(包括 macOS 和 Linux)都会以 CRLF checkout,其余文件保持 LF。

Q: text=auto 对所有项目都安全吗? A: 多数情况安全——它用的是和 git diff 一样的二进制/文本启发式判断。风险在于「长得像文本的二进制」(某些 .svg.dxf 或专有格式)。把这些明确标为 binary,启发式就动不了它们。

Q: 审一个被 CRLF 噪音淹没的 PR,最好怎么看? A: 用 git diff --ignore-cr-at-eol(以及 git log -p --ignore-cr-at-eol)。它把 CRLFLF 视为相等,只显示真正的内容改动——比 --ignore-all-space 更精准,后者还会把真正的缩进改动一起藏掉。不过审查只是权宜之计:用 .gitattributes 修掉根因,才能让 blame、git log -S 和 bisect 都保持准确。

Q: Windows 上的 WSL(Linux 子系统)会遇到这个问题吗? A: WSL 里的 Git 行为和 Linux 一致——core.autocrlf 默认 false,不会自动转换。坑在于在 Windows 宿主文件系统(/mnt/c/...)上编辑文件,那里会被 Windows 工具碰到。把 WSL 项目放在 Linux 文件系统(~/...)下,就能避免跨系统的换行符漂移。

相关阅读

标签: #git #version-control #排查