Git 换行归一化:工作区还是 CRLF,暂存区为什么已经变成 LF
编辑器显示没变化,不代表暂存内容没有转换
跨平台项目里,一份文本文件在编辑器中看起来完全相同,Git 却可能把整篇内容显示成修改。先不要急着调整全局配置,而应区分工作区磁盘上的字节,以及准备进入提交的暂存区内容。换行归一化发生在这些表示之间,检查其中一份不能证明另一份也相同。
LF 是一个换行字节,CRLF 是回车与换行两个字节。对被指定为文本的路径,Git 可以在加入暂存区时把换行规范为 LF,再按规则检出为需要的形式。文件内容、索引内容和检出策略因此是三个相关但不同的问题。
只在自动清理的实验仓库里检查字节
下面要求 Python 三及已安装 Git,本文在 Linux 上执行。脚本创建独立临时仓库,禁用继承的 Git 配置与属性来源,使用空模板,不读取或修改当前项目。Python 直接写入字节,避免文本写入接口和编辑器提前转换换行,使观察对象保持明确。
实验先把带 CRLF 的文本和含零字节的文件加入索引,再引入属性规则并重新归一化。后面的删除动作仅针对脚本刚创建的演示文件,目的是强制从索引重新生成工作副本,不能把这段删除方式照搬到包含未保存改动的项目。
AI概念配图,非真实界面
import os
import pathlib
import subprocess
import tempfile
with tempfile.TemporaryDirectory(prefix='git-eol-demo-') as folder:
base = pathlib.Path(folder)
repo, template = base / 'repo', base / 'template'
repo.mkdir()
template.mkdir()
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
GIT_ATTR_NOSYSTEM='1')
def git(*args):
return subprocess.run(
['git', '-c', 'core.attributesFile=' + os.devnull,
'-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', *args],
cwd=repo, env=env, check=True, capture_output=True).stdout
git('init', '--quiet', '--template=' + str(template))
text_path = repo / 'demo.txt'
binary_path = repo / 'raw.bin'
crlf, lf = b'one\r\ntwo\r\n', b'one\ntwo\n'
binary = b'\x00one\r\ntwo\r\n'
text_path.write_bytes(crlf)
binary_path.write_bytes(binary)
git('add', '--', 'demo.txt', 'raw.bin')
assert git('show', ':demo.txt') == crlf
attrs = repo / '.gitattributes'
attrs.write_bytes(b'demo.txt text eol=lf\nraw.bin -text\n')
git('add', '--', '.gitattributes')
git('add', '--renormalize', '--', 'demo.txt', 'raw.bin')
assert git('show', ':demo.txt') == lf
assert text_path.read_bytes() == crlf
assert git('show', ':raw.bin') == binary
print('renormalized: index LF, existing worktree CRLF')
text_path.unlink()
git('checkout-index', '--', 'demo.txt')
assert text_path.read_bytes() == lf
print('fresh checkout with eol=lf: LF')
attrs.write_bytes(b'demo.txt text eol=crlf\nraw.bin -text\n')
git('add', '--', '.gitattributes')
text_path.unlink()
git('checkout-index', '--', 'demo.txt')
assert text_path.read_bytes() == crlf
assert git('show', ':demo.txt') == lf
assert binary_path.read_bytes() == binary
print('fresh checkout with eol=crlf: CRLF; index stays LF')
print('binary bytes preserved; isolated repository cleaned on exit')text 与 eol 分别决定哪一段转换
第一次加入时没有文本规则且自动转换关闭,索引保存原有 CRLF。设定 demo.txt text eol=lf 后,重新归一化让索引变成 LF,但工作文件当时仍保留原字节。这说明暂存不是把磁盘文件就地重写;看到编辑器仍显示 CRLF,并不能认定规则无效。
代码随后从索引生成新副本,此时工作区才得到 LF。把属性改为 eol=crlf 后再次生成副本,工作区恢复成 CRLF,索引依然是 LF。eol 描述的是检出表示,不意味着仓库内部也必须存成相同格式;两边不同正可能是预期的跨平台安排。
归一化已有文件,需要一次明确的迁移
新增属性文件不会神奇地重写过去的提交,也不等于所有已跟踪内容立即重新入库。git add --renormalize 会重新应用已跟踪文件的清理过程。本例只指定两个演示路径,便于核对;真实项目应先确认工作区状态,再把换行迁移与业务修改分开审查。
属性文件本身也需要纳入版本控制,否则只有你当前工作副本知道这套规则。团队规则通常放在仓库的 .gitattributes 中,按路径表达;个人与系统设置另有优先级。排查时可以用 git check-attr 查看目标文件最终取得的属性,用 git ls-files --eol 对照索引和工作区。
对 raw.bin 明确设置 -text,示例验证其中回车换行字节始终不变。不要把所有文件一律当作文本处理,压缩包、图片和其他二进制内容里的字节组合可能完全不是行尾。text=auto 会尝试判断文件性质,而显式 text 是你给出的分类决定,应对应实际文件格式。
用字节结果判断迁移是否完成
验收时先看属性是否匹配目标路径,再检查暂存差异和两侧字节,最后进行一次实际检出验证。只看某条警告,或者只看编辑器右下角的换行标记,都不足以覆盖整个过程。代码同时断言二进制字节与索引内容,就是为了避免只修好表面显示。
真实仓库中还可能配置其他清理过滤器,因此重新归一化不总是只处理换行。执行前应确认相关属性与过滤器,审查即将提交的内容。团队达成明确规则后,保持一次纯格式迁移,能让后续代码评审更容易识别真正的逻辑变化。


