让 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 客户端或模型;因此不把下面结果称为跨平台端到端验收。

legacy.md 工作区12字节CRLF,经重新暂存后索引是10字节LF,工作区在重新检出前仍保持CRLF。
图 1:用两个字节流解释 i/lf 与 w/crlf 同时出现的原因(原创示意)

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。属性声明、重新暂存和重新检出,是三个不同动作。

Linux上五组配置对照。false无属性保留CRLF;input为LF和LF;true为LF和CRLF;显式eol控制指定路径检出结果。
图 2:每行使用全新的仓库和同一份输入字节,避免旧 blob 影响配置对照(原创实测图)

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.sh23212321
legacy.md12101210
build.cmd23212323
sample.bin14141414

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 后:这项差异仍在
纯行尾变化在忽略CR后消失,但ship改成hold仍可见。空diff没有删除暂存改动,还需完整diff、文件名单、二进制hash及项目测试。
图 3:两个真实执行过的对照,区分行尾噪声与内容变化(原创示意)

git diff --cached --check 在本例返回 0,只说明这份 diff 没触发相应空白与冲突标记检查。它不是业务测试、格式完整性检查或“只有换行变更”的证明。

9. 三类负例,阻止过度自信

  1. 混合行尾:输入 one\r\ntwo\n,开启 core.autocrlf=true 和 core.safecrlf=true。普通 git add 返回非零,mixed.txt 没有进入索引。测试检查失败和未暂存状态,而不是忽略 stderr 后继续提交。
  2. 把二进制强行标成 text:将 sample.bin 错配为 text eol=lf,两组 CRLF 被压成 LF,索引从 14 字节变为 12 字节,摘要改变。正确的 -text 对照保持原字节。自动检测、扩展名和 safecrlf 都不能替代格式知识。
  3. 归一化范围有限: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 事实引用官方文档,实验结果来自上面的本地代码。

资料核查日期: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 会话。