让 Codex 统一换行前,先证明 Git 暂存的到底是哪组字节
把工作区、索引 blob 与检出策略分开检查,让真实修改留在 diff 里。
- 先分别读取工作区和索引 blob 的原始字节
- 用路径属性声明可共享的换行策略
- 重新暂存之前分清已有修改与归一化范围
- 保留完整 diff,用忽略行尾 CR 的视图辅助审阅
- 二进制保留原字节,以独立预期验证转换
1. 先问清楚:编辑器里的 CRLF,会原样进入提交吗?
你让 Codex 修改一个脚本,审阅时却看到整份文件都变了。可能是逻辑重写,也可能是编辑器把 LF 换成了 CRLF。另一个更隐蔽的情况是:磁盘上仍是 CRLF,Git 暂存的内容却已经是 LF。只看编辑器状态栏、git status 或一张 diff 截图,无法说明究竟发生了哪一种变化。
本文用四个原创小文件做一个可复验实验:先把 CRLF 原样提交,再引入 .gitattributes,重新暂存已有文件,最后比较 HEAD、索引 blob 和工作区的原始字节。随后插入一次真正的内容修改,检查它是否会被“只是换行”的解释掩盖。你会得到一套可以交给 Codex 的核对方法,而不是一条直接扫过整个项目的修复命令。
实际运行范围:2026-10-13,Linux x86_64、Git 2.52.0、Python 3.12.14。代码只创建和删除它自己拥有的临时 Git 仓库,不接收现有仓库路径、不访问网络。我们模拟不同 Git 配置,没有运行 Windows、cmd.exe、Codex 客户端或模型;因此不把下面结果称为跨平台端到端验收。
2. 把三个开关放在正确的位置
text 决定路径是否参与行尾转换,文本入库时以 LF 形式保存;text=auto 让 Git 判断文本或二进制;-text 关闭这条路径的行尾转换。eol=lf 和 eol=crlf 约定检出到工作区时的风格。项目里的 .gitattributes 可以随提交共享。Git v2.52.0 的属性文档给出了这几个属性的语义。
core.autocrlf 是 Git 配置。没有显式路径策略时,true 会为本例的新文本做入库 LF、检出 CRLF;input 只做入库转换;false 本身不要求自动转换。false 不是“禁止所有转换”:显式的 text/eol 属性仍然生效。本例还把 core.eol 固定为 lf,避免让平台缺省值进入对照。配置语义见Git 2.52.0 git-config。
一个常见迁移陷阱是:给旧仓库加上 * text=auto,就以为原来提交进去的 CRLF 立即消失。本文专门测试了旧 blob:只新增属性并普通 add,旧 CRLF 仍可保留;再执行 renormalize 才得到 LF。属性声明、重新暂存和重新检出,是三个不同动作。
3. 三个完整文件,先在空目录复跑
把文末三个源码块分别保存为 line_endings.py、test_line_endings.py 和 verify_isolation.py,放在同一个新建目录。保存为 UTF-8,不要复制代码块外的标题。需要已安装的 Git 和 Python 3;以下运行环境使用 Python 3.12.14。没有第三方 Python 包,也没有远程仓库或凭据。
python -B -m unittest -v test_line_endings.py
python -B line_endings.py > report.json
python -B verify_isolation.py > isolation.json
第一条命令应以 Ran 26 tests 和 OK 结束。第二条输出完整的字节计数、十六进制、SHA-256、Git 对象编号和 diff。第三条分别在正常与被刻意污染的父环境里运行报告和测试,验证报告相同、无关目录与哨兵文件未变。重定向会覆盖当前目录的同名 JSON,因此请使用新的实验目录。
运行器里的路径均由 TemporaryDirectory 创建。它移除继承的 GIT_* 重定向与配置注入,使用空 HOME、空 Git 模板、空 hooks 目录,以及隔离的系统/全局配置来源。这是减少实验污染的措施,不是对恶意 Git 可执行文件或恶意操作系统的安全沙箱。正常退出时临时仓库会被清理;异常断电等情况可能留下临时目录。
4. 第一步:固定四份输入,不让编辑器替你决定字节
生成器使用 bytes 字面量写文件。例如 legacy.md 的输入是 b"plan\r\nship\r\n",总计 12 字节、2 组 CRLF;不是先写普通字符串再依赖本机文本模式。sample.bin 刻意含有 NUL、0xff 和两组 CRLF,表示这些字节可能属于二进制格式,而不是可以任意改写的排版。
run.sh b"#!/bin/sh\r\necho ready\r\n"
legacy.md b"plan\r\nship\r\n"
build.cmd b"@echo off\r\necho ready\r\n"
sample.bin b"\x00PACK\r\n\x01\xffEND\r\n"
基线仓库尚无 .gitattributes,且 core.autocrlf=false。四份文件先被原样暂存并提交。测试的预期值独立写成固定十六进制,没有导入生成器的 FILES 常量再把它当答案。尺寸也有独立断言:三个文本的 LF 版本分别是 21、10、21 字节,二进制保持 14 字节。
SHA-256 针对文件内容本身。Git 对象编号是另一件事:本例明确创建 SHA-1 格式仓库,并验证 SHA-1(blob 头部 + 内容) 与索引对象编号一致。不要把报告里的 64 位十六进制 payload SHA-256 和 40 位 Git blob ID 互相比较,也不要假设所有仓库都使用 SHA-1。
5. 第二步:新增策略,再对已有文件重新暂存
本例使用下面的项目策略。通用规则放前面,更具体的规则放后面;.sh 与 .md 检出为 LF,.cmd 检出为 CRLF,.bin 不做行尾转换。.gitattributes 自己也固定为 LF。扩展名只是这个合成项目的约定,真实仓库应按格式和消费者要求确认。
* text=auto
.gitattributes text eol=lf
*.sh text eol=lf
*.md text eol=lf
*.cmd text eol=crlf
*.bin -text
下面是生成器在它自己的临时仓库里执行的关键步骤。先显式 add 新的属性文件,再重新暂存已有文件;同时放一个 scratch.txt 作为未跟踪对照。
git add -- .gitattributes
git add --renormalize -- .
git diff --cached --name-only
git ls-files --eol
--renormalize 重新应用 clean 处理,并隐含 -u。所以它不是“把所有新文件加进来”:本例 scratch.txt 仍未跟踪;新建的 .gitattributes 需要前面的显式 add。它也不是只修改换行的专用编辑器,已有文件的其他未暂存改动可能一并进入索引。Git 2.52.0 git-add 的说明是这一步的依据。
本例为了构建可控的迁移场景,在临时仓库里设置 core.safecrlf=false;这不是建议你在真实项目里关闭保护。更不需要删除真实工作区、运行 reset --hard,或强制重新检出全部文件来“修复”这次实验。
已有的分段暂存也可能被覆盖。2026-10-20 补测把同一路径安排成三个不同版本:HEAD 是 ship,索引已暂存 review,工作区又改成 hold。只对 run.sh 执行 renormalize 时,legacy.md 的索引仍是 review;把 legacy.md 纳入路径范围后,它的索引变成 hold。命令读取工作区内容重新暂存,不是在原有暂存版本上只转换 CRLF。因此执行前还要分别审阅暂存和未暂存 diff,记录原本的暂存边界;不能把它当成保留部分暂存的操作。
先处理缺失文件,再决定范围。在本文的 Git 2.52.0 临时样本中,删除已跟踪的 build.cmd 后运行 git add --renormalize -- . 返回 128,报 unable to stat;这次失败前后的 git write-tree 结果相同,索引里的原 blob 也未消失。这是该版本、该样本的实测边界,不保证任意过滤器或其他失败都能原子回滚。遇到失败应先读 stderr、重新检查工作区与索引,并确认缺失文件的意图;不要改用 git add -A 当作无害重试,也不要自动恢复或删除真实项目文件。下方两项新增测试只操作自建临时样本。
6. 第三步:读取索引 blob,而不是猜测磁盘文件
重新暂存之后,下面的输出中 legacy.md 和 run.sh 是 i/lf w/crlf。i 是索引内容识别,w 是工作区内容识别,attr 是路径属性。二进制的 -text 是 Git 的识别结果,不表示文件里没有 CRLF 字节。Git v2.52.0 的 ls-files 文档说明了这几列。
i/lf w/lf attr/text eol=lf .gitattributes
i/lf w/crlf attr/text eol=crlf build.cmd
i/lf w/crlf attr/text eol=lf legacy.md
i/lf w/crlf attr/text eol=lf run.sh
i/-text w/-text attr/-text sample.bin
读取 HEAD:legacy.md 得到旧提交,读取 :legacy.md 得到索引所引用的 blob。运行器使用 git cat-file blob 的原始输出,再由 Python 按 bytes 计算摘要;没有经过编辑器、Unicode 换行归一化或 textconv 过滤。读取对象的命令见git-cat-file 文档。
git cat-file blob HEAD:legacy.md
git cat-file blob :legacy.md
git rev-parse :legacy.md
屏幕会把不可见字符藏起来,所以报告保存十六进制。legacy.md 的旧字节是 706c616e0d0a736869700d0a;索引字节是 706c616e0a736869700a。索引 payload SHA-256 是:
0d97cf81aee6d486fb6982f5ecf3d19d3aafde328931d635899c9197f6de533d
这个变化只是每行少了一个 0d。测试分别比对固定 hex、字节数、CRLF/LF/孤立 CR 计数,再从独立 hex 算 SHA-256;不是让当前程序生成自己的预期答案。对现实文件,还应明确哈希是在读取哪一层、哪一个时点的字节。
7. 第四步:重新检出后,工作区才体现 eol 策略
为了强制观察检出路径,生成器仅删除自己创建的四个临时样本,再用 checkout-index 从索引重建。这个有破坏性的辅助步骤只存在于一次性样本仓库,不能照搬到有未保存修改的项目。它没有产生新的提交。
| 路径 | HEAD 字节 | 索引字节 | add 后工作区 | 重检出后工作区 |
|---|---|---|---|---|
| run.sh | 23 | 21 | 23 | 21 |
| legacy.md | 12 | 10 | 12 | 10 |
| build.cmd | 23 | 21 | 23 | 23 |
| sample.bin | 14 | 14 | 14 | 14 |
run.sh 与 legacy.md 的磁盘字节数在重新检出后才缩小。build.cmd 的索引是 21 字节 LF,工作区恢复为 23 字节 CRLF,这恰好符合它的策略。sample.bin 四个时点都保持 14 字节,其 SHA-256 也不变。
i/lf w/lf attr/text eol=lf .gitattributes
i/lf w/crlf attr/text eol=crlf build.cmd
i/lf w/lf attr/text eol=lf legacy.md
i/lf w/lf attr/text eol=lf run.sh
i/-text w/-text attr/-text sample.bin
因此,“暂存 blob 已归一化”与“现有工作区文件立即被重写”不能画等号。真实项目迁移时,还需要安排团队的编辑器设置、后续检出和跨平台测试;本例的 Linux 配置对照无法代替这些验证。
8. 第五步:看两份 diff,让真正的改动留下来
先保留完整暂存 diff,再用较窄的行尾 CR 忽略视图辅助阅读。不要一开始就加 -w 忽略全部空白,那会让需要关注的缩进或空格变化一起消失。--ignore-cr-at-eol 仅改变本次比较方式,没有修改索引,也没有证明内容安全。Git 2.52.0 git-diff说明了这个选项与 --cached 的含义。
git diff --cached --no-ext-diff --no-textconv -- legacy.md
git diff --cached --no-ext-diff --no-textconv --ignore-cr-at-eol -- legacy.md
git diff --cached --check
在纯归一化对照里,普通 diff 能看到 plan 与 ship 两行的行尾变化,而忽略行尾 CR 后输出为空。负对照则在迁移之前把 ship 改成 hold。重新暂存会包含这个真实修改,即使忽略 CR,仍能看到删除 ship、增加 hold。报告中的原始 diff 保留 CR,下面用可读标记表示它:
仅换行:
- plan<CR><LF> + plan<LF>
- ship<CR><LF> + ship<LF>
忽略行尾 CR 后:无差异
混入真实修改:
- ship<CR><LF>
+ hold<LF>
忽略行尾 CR 后:这项差异仍在
git diff --cached --check 在本例返回 0,只说明这份 diff 没触发相应空白与冲突标记检查。它不是业务测试、格式完整性检查或“只有换行变更”的证明。
9. 三类负例,阻止过度自信
- 混合行尾:输入 one\r\ntwo\n,开启 core.autocrlf=true 和 core.safecrlf=true。普通 git add 返回非零,mixed.txt 没有进入索引。测试检查失败和未暂存状态,而不是忽略 stderr 后继续提交。
- 把二进制强行标成 text:将 sample.bin 错配为 text eol=lf,两组 CRLF 被压成 LF,索引从 14 字节变为 12 字节,摘要改变。正确的 -text 对照保持原字节。自动检测、扩展名和 safecrlf 都不能替代格式知识。
- 归一化范围有限:one\rtwo\r\n 的孤立 CR 保留下来;last\r\nline 不会凭空补最终换行。转换 CRLF 不等于完整清理任意换行格式,更不等于编码转换。
另外两项测试检查“只新增策略但不重新暂存”和“text=auto 下原有 CRLF blob”的情况,提醒迁移者核对已有数据。这个教程不处理 UTF-16/UTF-32、working-tree-encoding、自定义 clean/smudge、Git LFS、合并冲突或子模块;遇到这些情况,应为对应格式和工具链补独立实验,不能把此处四份样本的结果直接推广。
10. 交给 Codex 的任务,先要证据再谈改写
下面是一份可改写的任务说明。第一阶段只调查;如果需要迁移,再单独确认路径范围和暂存操作。不要让“统一换行”无意中扩大成全仓重排、编码重写或覆盖他人的未提交工作。
先只读调查,不修改配置、文件或索引。
1. 报告当前分支、工作区和暂存区是否已有修改、缺失文件或部分暂存。
2. 列出相关 .gitattributes、路径实际属性和 Git 换行配置来源。
3. 对选定文件分别说明 HEAD、索引 blob、工作区的行尾与原始字节摘要。
4. 解释哪些只是 CRLF/LF 差异,哪些包含真实内容变化。
5. 给出拟迁移路径、二进制排除项、原暂存边界与验证方案,等我确认后再暂存。
如获准迁移:保持修改范围明确,单独审阅归一化 diff;
同时保留完整 diff 和忽略行尾 CR 的辅助视图,运行项目现有测试。
不得删除工作区文件、强制检出、提交或推送,除非我另行要求。
在真实仓库中,git check-attr text eol -- 路径 和带来源的配置查询有助于解释覆盖关系;还要检查更深层 .gitattributes 与 .git/info/attributes。本文隔离环境是为了让实验可重复,不能拿它覆盖用户仓库里的真实约定。只有把现有修改与换行策略厘清,才值得决定是否生成一个独立的归一化提交。
11. 完整源码:line_endings.py
生成临时仓库、执行配置对照并输出 JSON。样本、处理流程和报告字段均为本文原创约定。
"""Disposable Git line-ending lab. Python stdlib + installed Git; no network."""
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import tempfile
# Fixture inputs are bytes: editor newline settings cannot change their meaning.
FILES = {
"run.sh": b"#!/bin/sh\r\necho ready\r\n",
"legacy.md": b"plan\r\nship\r\n",
"build.cmd": b"@echo off\r\necho ready\r\n",
"sample.bin": b"\x00PACK\r\n\x01\xffEND\r\n",
}
POLICY = (b"* text=auto\n.gitattributes text eol=lf\n"
b"*.sh text eol=lf\n*.md text eol=lf\n"
b"*.cmd text eol=crlf\n*.bin -text\n")
def describe(data):
"""Count byte sequences, not Unicode lines or logical records."""
pairs = data.count(b"\r\n")
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(),
"crlf": pairs, "lone_lf": data.count(b"\n") - pairs,
"lone_cr": data.count(b"\r") - pairs,
"hex": data.hex()}
class Lab:
"""Always owns a new temporary repo; deliberately has no repo-path argument."""
def __enter__(self):
self.tmp = tempfile.TemporaryDirectory(prefix="git-eol-lab-")
self.base = Path(self.tmp.name)
self.repo = self.base / "repo"
self.repo.mkdir()
self.home = self.base / "home"
self.home.mkdir()
self.empty = self.base / "empty"
self.empty.mkdir()
# Strip repository redirects, alternate indexes, config injection, etc.
self.env = {k: v for k, v in os.environ.items()
if not k.upper().startswith("GIT_")}
self.env.update(HOME=str(self.home), XDG_CONFIG_HOME=str(self.home),
GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM=os.devnull,
GIT_CONFIG_GLOBAL=os.devnull, GIT_ATTR_NOSYSTEM="1",
GIT_TERMINAL_PROMPT="0", GIT_PAGER="cat", LC_ALL="C",
GIT_AUTHOR_DATE="2026-10-13T00:00:00+00:00",
GIT_COMMITTER_DATE="2026-10-13T00:00:00+00:00")
self.git_bin = shutil.which("git")
if not self.git_bin:
self.tmp.cleanup()
raise RuntimeError("Git is required on PATH")
try:
self.git("init", "--quiet", "--object-format=sha1", "-b", "main",
"--template=" + str(self.empty))
for key, value in {
"user.name": "Line Ending Lab", "user.email": "lab@example.invalid",
"core.autocrlf": "false", "core.eol": "lf",
"core.safecrlf": "false", "core.attributesFile": os.devnull,
"core.excludesFile": os.devnull, "core.hooksPath": str(self.empty),
"commit.gpgsign": "false", "color.ui": "false",
}.items():
self.git("config", "--local", key, value)
except BaseException:
self.tmp.cleanup()
raise
return self
def __exit__(self, *exc):
self.tmp.cleanup()
def git(self, *args, check=True):
p = subprocess.run([self.git_bin, "-C", str(self.repo), *args],
env=self.env, capture_output=True, timeout=20)
if check and p.returncode:
raise RuntimeError(f"git {args!r}: {p.returncode}\n"
+ p.stderr.decode("utf-8", "replace"))
return p
def write(self, name, data):
# All callers use synthetic top-level fixture names.
if not name or Path(name).name != name or name in {".", "..", ".git"}:
raise ValueError("expected a top-level fixture filename")
(self.repo / name).write_bytes(data)
def blob(self, name, revision=""):
return self.git("cat-file", "blob", revision + ":" + name).stdout
def checkout_fixture(self, name):
# Destructive only inside this fresh temporary repository.
(self.repo / name).unlink()
self.git("checkout-index", "--force", "--", name)
return (self.repo / name).read_bytes()
def seed(self):
for name, data in FILES.items():
self.write(name, data)
self.git("add", "--", *FILES)
self.git("commit", "--quiet", "-m", "Synthetic CRLF baseline")
def normalize(self, dirty=False):
self.seed()
self.write("scratch.txt", b"untracked\r\n")
self.write(".gitattributes", POLICY)
self.git("add", "--", ".gitattributes")
if dirty:
self.write("legacy.md", b"plan\r\nhold\r\n")
self.git("add", "--renormalize", "--", ".")
def diff(self, *args):
return self.git("diff", "--no-ext-diff", "--no-textconv", "--cached",
*args).stdout.decode("utf-8", "replace")
def migration():
with Lab() as lab:
lab.normalize()
before_checkout = lab.git("ls-files", "--eol").stdout.decode()
snapshots = {}
for name in FILES:
snapshots[name] = {
"head": describe(lab.blob(name, "HEAD")),
"index": describe(lab.blob(name)),
"worktree_after_add": describe((lab.repo / name).read_bytes()),
"worktree_after_checkout": describe(lab.checkout_fixture(name)),
"index_object_id": lab.git("rev-parse", ":" + name).stdout.decode().strip(),
}
return {"snapshots": snapshots, "ls_files_before_checkout": before_checkout,
"ls_files_after_checkout": lab.git("ls-files", "--eol").stdout.decode(),
"staged_names": lab.diff("--name-only").splitlines(),
"ordinary_diff": lab.diff("--", "legacy.md"),
"ignore_cr_diff": lab.diff("--ignore-cr-at-eol", "--", "legacy.md"),
"untracked": lab.git("ls-files", "--others", "--exclude-standard").stdout.decode(),
"diff_check_exit": lab.git("diff", "--cached", "--check", check=False).returncode}
def config_case(autocrlf, attributes=b""):
with Lab() as lab:
lab.git("config", "--local", "core.autocrlf", autocrlf)
if attributes:
lab.write(".gitattributes", attributes)
lab.git("add", "--", ".gitattributes")
lab.write("example.txt", b"one\r\ntwo\r\n")
lab.git("add", "--", "example.txt")
index = describe(lab.blob("example.txt"))
worktree = describe(lab.checkout_fixture("example.txt"))
return {"autocrlf": autocrlf, "attributes": attributes.decode(),
"index": index, "checkout": worktree}
def negative_cases():
with Lab() as lab:
lab.normalize(dirty=True)
dirty = lab.diff("--ignore-cr-at-eol", "--", "legacy.md")
with Lab() as lab:
lab.git("config", "--local", "core.autocrlf", "true")
lab.git("config", "--local", "core.safecrlf", "true")
lab.write("mixed.txt", b"one\r\ntwo\n")
p = lab.git("add", "--", "mixed.txt", check=False)
mixed = {"exit": p.returncode, "stderr": p.stderr.decode(),
"staged": lab.git("ls-files").stdout.decode()}
with Lab() as lab:
lab.write(".gitattributes", b"*.bin text eol=lf\n")
lab.write("sample.bin", FILES["sample.bin"])
lab.git("add", "--", ".gitattributes", "sample.bin")
forced = {"input": describe(FILES["sample.bin"]),
"wrongly_normalized": describe(lab.blob("sample.bin"))}
return {"real_content_change_diff": dirty,
"mixed_with_safecrlf": mixed, "binary_forced_text": forced}
def report():
with Lab() as lab:
git_version = lab.git("--version").stdout.decode().strip()
return {"environment": {"python": platform.python_version(),
"system": platform.system(), "machine": platform.machine(),
"git": git_version, "real_windows_test": False},
"migration": migration(),
"matrix": [config_case(v) for v in ("false", "input", "true")],
"explicit_lf_over_true": config_case("true", b"*.txt text eol=lf\n"),
"explicit_crlf_over_false": config_case("false", b"*.txt text eol=crlf\n"),
"negative_cases": negative_cases()}
if __name__ == "__main__":
print(json.dumps(report(), ensure_ascii=False, indent=2))
12. 完整测试:test_line_endings.py
26 项 unittest,包含固定 hex、长度、行尾计数、摘要、对象编号、已有暂存边界、缺失文件失败和负例。断言失败时先检查输入、配置与 Git 版本,不要直接把实际结果复制成预期值。
"""Independent byte expectations: do not derive expected bytes from lab FILES."""
import hashlib
from pathlib import Path
import unittest
from unittest.mock import patch
from line_endings import Lab, config_case, describe, migration, negative_cases
# Written independently from the fixture generator. Hex fixes every input byte.
EXPECTED = {
"run.sh": ("23212f62696e2f73680d0a6563686f2072656164790d0a",
"23212f62696e2f73680a6563686f2072656164790a", 2, 21),
"legacy.md": ("706c616e0d0a736869700d0a", "706c616e0a736869700a", 2, 10),
"build.cmd": ("406563686f206f66660d0a6563686f2072656164790d0a",
"406563686f206f66660a6563686f2072656164790a", 2, 21),
"sample.bin": ("005041434b0d0a01ff454e440d0a",
"005041434b0d0a01ff454e440d0a", 2, 14),
}
class NormalizationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.result = migration()
cls.neg = negative_cases()
def test_exact_head_and_index_bytes(self):
for name, (old, new, _, _) in EXPECTED.items():
with self.subTest(name=name):
row = self.result["snapshots"][name]
self.assertEqual(row["head"]["hex"], old)
self.assertEqual(row["index"]["hex"], new)
def test_lengths_and_line_end_counts(self):
for name, (_, _, pairs, size) in EXPECTED.items():
with self.subTest(name=name):
row = self.result["snapshots"][name]
self.assertEqual(row["head"]["crlf"], pairs)
self.assertEqual(row["index"]["bytes"], size)
self.assertEqual(row["index"]["lone_cr"], 0)
self.assertEqual(row["index"]["crlf"], 2 if name.endswith(".bin") else 0)
self.assertEqual(row["index"]["lone_lf"], 0 if name.endswith(".bin") else 2)
def test_payload_sha256_from_independent_hex(self):
for name, (old, new, _, _) in EXPECTED.items():
row = self.result["snapshots"][name]
self.assertEqual(row["head"]["sha256"], hashlib.sha256(bytes.fromhex(old)).hexdigest())
self.assertEqual(row["index"]["sha256"], hashlib.sha256(bytes.fromhex(new)).hexdigest())
def test_git_object_id_includes_blob_header(self):
for name, (_, new, _, _) in EXPECTED.items():
raw = bytes.fromhex(new)
expected_oid = hashlib.sha1(f"blob {len(raw)}\0".encode() + raw).hexdigest()
self.assertEqual(self.result["snapshots"][name]["index_object_id"], expected_oid)
def test_add_does_not_rewrite_worktree(self):
for row in self.result["snapshots"].values():
self.assertEqual(row["worktree_after_add"], row["head"])
def test_checkout_follows_per_path_policy(self):
for name, (old, new, _, _) in EXPECTED.items():
expected = old if name.endswith((".cmd", ".bin")) else new
self.assertEqual(self.result["snapshots"][name]["worktree_after_checkout"]["hex"], expected)
def test_staged_names_are_exact(self):
self.assertEqual(self.result["staged_names"],
[".gitattributes", "build.cmd", "legacy.md", "run.sh"])
def test_untracked_file_is_not_added(self):
self.assertEqual(self.result["untracked"], "scratch.txt\n")
def test_noise_disappears_only_in_comparison(self):
self.assertIn("-plan\r\n", self.result["ordinary_diff"])
self.assertIn("+plan\n", self.result["ordinary_diff"])
self.assertEqual(self.result["ignore_cr_diff"], "")
self.assertIn("legacy.md", self.result["staged_names"])
def test_real_content_edit_remains_visible(self):
diff = self.neg["real_content_change_diff"]
self.assertIn("-ship\r\n", diff)
self.assertIn("+hold\n", diff)
def test_renormalize_replaces_selected_index_version(self):
with Lab() as lab:
lab.seed()
lab.write(".gitattributes", b"*.md text eol=lf\n*.sh text eol=lf\n")
lab.git("add", "--", ".gitattributes")
lab.write("legacy.md", b"plan\r\nreview\r\n")
lab.git("add", "--", "legacy.md")
self.assertEqual(lab.blob("legacy.md"), b"plan\nreview\n")
lab.write("legacy.md", b"plan\r\nhold\r\n")
# A different pathspec does not consume this file's unstaged edit.
lab.git("add", "--renormalize", "--", "run.sh")
self.assertEqual(lab.blob("legacy.md"), b"plan\nreview\n")
# Selecting it restages the worktree version, not the old index version.
lab.git("add", "--renormalize", "--", "legacy.md")
self.assertEqual(lab.blob("legacy.md"), b"plan\nhold\n")
self.assertEqual(lab.blob("legacy.md", "HEAD"), b"plan\r\nship\r\n")
self.assertEqual((lab.repo / "legacy.md").read_bytes(), b"plan\r\nhold\r\n")
def test_missing_tracked_file_fails_without_index_change(self):
with Lab() as lab:
lab.seed()
lab.write(".gitattributes", b"* text=auto\n")
lab.git("add", "--", ".gitattributes")
# Only remove the synthetic sample in this owned disposable repository.
(lab.repo / "build.cmd").unlink()
before = lab.git("write-tree").stdout
result = lab.git("add", "--renormalize", "--", ".", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn(b"unable to stat", result.stderr)
self.assertEqual(lab.git("write-tree").stdout, before)
self.assertEqual(lab.blob("build.cmd"), b"@echo off\r\necho ready\r\n")
self.assertFalse((lab.repo / "build.cmd").exists())
def test_whitespace_check_passes_fixture(self):
self.assertEqual(self.result["diff_check_exit"], 0)
def test_mixed_endings_rejected_without_staging(self):
row = self.neg["mixed_with_safecrlf"]
self.assertNotEqual(row["exit"], 0)
self.assertIn("LF would be replaced by CRLF", row["stderr"])
self.assertEqual(row["staged"], "")
def test_forcing_binary_to_text_changes_bytes(self):
row = self.neg["binary_forced_text"]
self.assertEqual(row["wrongly_normalized"]["hex"], "005041434b0a01ff454e440a")
self.assertNotEqual(row["input"]["sha256"], row["wrongly_normalized"]["sha256"])
def test_false_keeps_crlf(self):
row = config_case("false")
self.assertEqual(row["index"]["hex"], "6f6e650d0a74776f0d0a")
self.assertEqual(row["checkout"]["hex"], "6f6e650d0a74776f0d0a")
def test_input_stores_and_checks_out_lf(self):
row = config_case("input")
self.assertEqual(row["index"]["hex"], "6f6e650a74776f0a")
self.assertEqual(row["checkout"]["hex"], "6f6e650a74776f0a")
def test_true_stores_lf_checks_out_crlf(self):
row = config_case("true")
self.assertEqual(row["index"]["hex"], "6f6e650a74776f0a")
self.assertEqual(row["checkout"]["hex"], "6f6e650d0a74776f0d0a")
def test_eol_lf_overrides_autocrlf_true(self):
row = config_case("true", b"*.txt text eol=lf\n")
self.assertEqual(row["checkout"]["hex"], "6f6e650a74776f0a")
def test_eol_crlf_with_autocrlf_false(self):
row = config_case("false", b"*.txt text eol=crlf\n")
self.assertEqual(row["index"]["hex"], "6f6e650a74776f0a")
self.assertEqual(row["checkout"]["hex"], "6f6e650d0a74776f0d0a")
def test_policy_alone_does_not_rewrite_existing_blob(self):
with Lab() as lab:
lab.seed()
lab.write(".gitattributes", b"*.md text eol=lf\n")
lab.git("add", "--", ".gitattributes")
self.assertEqual(lab.blob("legacy.md"), b"plan\r\nship\r\n")
lab.git("add", "--renormalize", "--", "legacy.md")
self.assertEqual(lab.blob("legacy.md"), b"plan\nship\n")
def test_auto_preserves_legacy_until_renormalize(self):
with Lab() as lab:
lab.seed()
lab.write(".gitattributes", b"* text=auto\n")
lab.git("add", "--", ".gitattributes", "legacy.md")
self.assertEqual(lab.blob("legacy.md"), b"plan\r\nship\r\n")
lab.git("add", "--renormalize", "--", "legacy.md")
self.assertEqual(lab.blob("legacy.md"), b"plan\nship\n")
def test_lone_cr_and_missing_final_newline(self):
with Lab() as lab:
lab.write(".gitattributes", b"*.txt text eol=lf\n")
lab.write("lone.txt", b"one\rtwo\r\n")
lab.write("last.txt", b"last\r\nline")
lab.git("add", "--", ".gitattributes", "lone.txt", "last.txt")
self.assertEqual(lab.blob("lone.txt"), b"one\rtwo\n")
self.assertEqual(lab.blob("last.txt"), b"last\nline")
def test_environment_redirects_removed(self):
with patch.dict("os.environ", {"GIT_DIR": "/does-not-exist",
"GIT_WORK_TREE": "/does-not-exist",
"GIT_INDEX_FILE": "/does-not-exist/index",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.autocrlf",
"GIT_CONFIG_VALUE_0": "true", "GIT_CONFIG_PARAMETERS": "invalid"}):
with Lab() as lab:
self.assertNotIn("GIT_DIR", lab.env)
self.assertEqual(lab.git("config", "core.autocrlf").stdout, b"false\n")
self.assertEqual(Path(lab.git("rev-parse", "--show-toplevel").stdout.decode().strip()), lab.repo)
def test_temp_repo_is_removed(self):
with Lab() as lab:
root = lab.base
self.assertTrue(root.exists())
self.assertFalse(root.exists())
def test_byte_counter_empty_and_mixed(self):
self.assertEqual((describe(b"")["crlf"], describe(b"")["lone_lf"]), (0, 0))
row = describe(b"a\r\nb\nc\r")
self.assertEqual((row["crlf"], row["lone_lf"], row["lone_cr"]), (1, 1, 1))
if __name__ == "__main__":
unittest.main(verbosity=2)
13. 完整隔离复跑:verify_isolation.py
在无关临时目录启动两个子进程组,注入 GIT_DIR、GIT_INDEX_FILE、配置覆盖、默认对象格式等污染;检查正常/污染报告完全相同,并确认哨兵与工作目录未被改写。它使用固定的合成数据,不读取你的全局配置内容。
"""Run normal and polluted-parent checks without touching a user's repository."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
HERE = Path(__file__).resolve().parent
def run(script, env, cwd):
result = subprocess.run([sys.executable, "-B", str(HERE / script)],
cwd=cwd, env=env, capture_output=True, timeout=60)
if result.returncode:
raise RuntimeError(result.stderr.decode("utf-8", "replace"))
return result
def verify():
with tempfile.TemporaryDirectory(prefix="eol-parent-check-") as directory:
base = Path(directory)
home = base / "hostile-home"
home.mkdir()
victim = base / "sentinel-index"
victim.write_bytes(b"NOT AN INDEX: must remain untouched\n")
attrs = base / "attributes"
attrs.write_text("* -text\n", encoding="utf-8")
global_config = home / ".gitconfig"
global_config.write_text("[core]\n autocrlf = true\n safecrlf = true\n"
f" attributesFile = {attrs.as_posix()}\n"
"[commit]\n gpgsign = true\n", encoding="utf-8")
before = {p.name: p.read_bytes() for p in (victim, attrs, global_config)}
normal = run("line_endings.py", os.environ.copy(), base)
polluted = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home),
GIT_DIR=str(base / "missing.git"), GIT_COMMON_DIR=str(base / "common"),
GIT_WORK_TREE=str(base), GIT_INDEX_FILE=str(victim),
GIT_OBJECT_DIRECTORY=str(base / "objects"),
GIT_CONFIG_GLOBAL=str(global_config),
GIT_CONFIG_SYSTEM=str(base / "missing-system"),
GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="core.autocrlf",
GIT_CONFIG_VALUE_0="true", GIT_CONFIG_PARAMETERS="invalid",
GIT_DEFAULT_HASH="sha256", GIT_EXTERNAL_DIFF="missing-diff-helper",
GIT_TEMPLATE_DIR=str(base / "missing-template"))
poisoned = run("line_endings.py", polluted, base)
normal_tests = run("test_line_endings.py", os.environ.copy(), base)
poisoned_tests = run("test_line_endings.py", polluted, base)
if json.loads(normal.stdout) != json.loads(poisoned.stdout):
raise AssertionError("normal and polluted reports differ")
for path in (victim, attrs, global_config):
if path.read_bytes() != before[path.name]:
raise AssertionError(f"sentinel changed: {path.name}")
if {p.name for p in base.iterdir()} != {"hostile-home", "sentinel-index", "attributes"}:
raise AssertionError("unexpected output in unrelated working directory")
return {"reports_identical": True,
"report_sha256": hashlib.sha256(normal.stdout).hexdigest(),
"polluted_keys": sorted(k for k in polluted if k.startswith("GIT_")),
"sentinels_unchanged": True, "unrelated_cwd_unchanged": True,
"normal_tests": normal_tests.stderr.decode(),
"polluted_tests": poisoned_tests.stderr.decode(),
"polluted_report": json.loads(poisoned.stdout)}
if __name__ == "__main__":
print(json.dumps(verify(), ensure_ascii=False, indent=2))
14. 验收记录与可回查资料
2026-10-20 补测后,正常环境与污染父环境各通过 26 项测试,两个报告完全一致。运行环境仍为 Linux x86_64、Git 2.52.0、Python 3.12.14。已核对 4 个文件在 4 个观察时点的 bytes、行尾计数和内容摘要;5 组配置对照;混合行尾拒绝、二进制误标、真实内容修改、旧 blob 与孤立 CR/末尾换行边界;新增检查了选定路径重新暂存会覆盖原索引版本,以及缺失已跟踪文件时的失败与索引树未变。三个 SVG 已离线渲染并目视检查。页面未进行浏览器版式、移动端或页面交互验收。
完整程序和测试已在本页展开,复制即可复跑;本文不依赖一个尚未验证的公开下载地址。图、合成样本、测试和说明为原创材料。所有 Git 事实引用官方文档,实验结果来自上面的本地代码。
- Git v2.52.0:gitattributes 源文档
- Git 2.52.0:core.autocrlf、core.eol、core.safecrlf
- Git 2.52.0:git add --renormalize
- Git v2.52.0:git ls-files --eol 源文档
- Git 2.52.0:暂存比较与 --ignore-cr-at-eol
- Git 2.52.0:读取 blob 原始内容
资料核查日期:2026-10-13。git-scm 的 2.52.0 属性与 ls-files 入口会跳转至最近修改版本,故这两项直接链接 Git 官方仓库的 v2.52.0 标签文档;实际命令运行版本仍是 Git 2.52.0。
core.autocrlf=false 是否意味着所有文件都不转换?
不是。它关闭该配置提供的自动转换行为,但路径上的 text/eol 属性仍可启用转换。本例在 false 下为文本设置 eol=crlf,索引是 LF,检出后是 CRLF。
新增 .gitattributes 后,旧提交会被改写吗?
不会自动改写旧提交。本文显式重新暂存已有文件,改变的是索引中拟用于下一次提交的内容;HEAD 仍保留原来的 CRLF blob。
renormalize 会只处理换行吗?
不能这样假设。它重新应用 clean 过程并把工作区内容重新暂存:不仅会带入 ship 改成 hold 的修改,也会覆盖已经暂存的 review 版本,破坏原来的部分暂存边界。先分别审阅暂存和未暂存 diff,并确认路径范围;真实仓库还可能配置其他过滤器。
--ignore-cr-at-eol 后没有输出,能直接提交吗?
这只说明该次比较没有显示忽略行尾 CR 后的差异。仍需检查完整暂存 diff、文件名单、二进制字节、已有修改和项目测试。
这些配置结果证明 Windows 也验收通过了吗?
没有。所有命令运行在 Linux,明确设置不同 Git 配置;没有运行 Windows、cmd.exe、跨平台编辑器或真实 Codex 会话。