让 Codex 复核 Git rerere:冲突解法复用了,业务规则还对吗?

相同的文本冲突,可以对应不同版本的业务答案。先保留索引审阅边界,再决定是否接受复用结果。

你能带走什么
  • 区分工作区的文本复用与索引中的人工接受
  • 用固定业务预期揭示旧解法的语义失效
  • 重建当前冲突并用forget替换旧记录
  • 再次合并验证新解法,最后才显式暂存提交

1. 同一段冲突,再次出现时业务规则可能已经变了

一条长期维护的功能分支反复合入主线,每次都卡在同一行配置。第一次人工改对,第二次又重做同样的选择,很容易让人想把上次答案直接搬过来。Git 的 rerere 可以记录冲突与人工解法,在之后匹配的冲突上复用解法;它节省的是重复编辑,业务上的正确性仍需单独验收。

本练习把问题压缩为一个运费函数。基线在订单满100元时免邮,主线把门槛改为80,功能分支把同一行改为120。经业务确认,第一版实际规则为90。第一次手工解决后,第二次演练能复用90;之后业务改为110,冲突两侧的文本仍是80与120,旧解法也可能再次被复用。本篇要亲眼看到这个失败,再替换旧解法。

这些门槛与订单均为虚构整数单位,不涉及实际支付。脚本运行的是本地真实 Git,不是模拟 Git 返回值;没有运行 Codex、模型或远程仓库。给 Codex 的任务指令是待执行模板,不能把本实验写成 Codex 实测记录。

四轮合并:记录90、复用90、旧90在新规则失败、替换为110再复用;显式add前仍保留未合并索引。
原创流程示意图。第三轮的失败是必要证据;图中结果只对应本篇合成夹具。

2. 先把文本复用和接受合并分成两道关

在本例的临时仓库中设定 rerere.enabled=true、rerere.autoupdate=false,并在 merge 命令显式加上 --no-rerere-autoupdate。这样即使工作目录中已出现复用后的解法,冲突路径在索引里仍保留未合并状态;完成检查后,再由 git add 接受文件。这不会阻止其他本来能够干净合并的路径进入索引,也不构成权限隔离。

Git merge 官方说明区分了复用工作目录内容与自动更新索引,并建议通过关闭自动更新来检查潜在的错误合并。本例进一步用 git ls-files -u 检查该冲突文件的三个索引阶段,而不是根据文件里有没有冲突标记判断完成。

重复演练全部在脚本创建的新临时仓库内进行。不要把里面的 checkout、merge --abort 或写文件步骤直接搬到已有项目。真实仓库先检查未提交修改,保留必要工作,再讨论是否启动合并;merge --abort 不能保证找回在合并前就存在的复杂未提交修改。这里也没有更改全局 Git 配置、推送分支或接触凭据。

3. 固定验收值:不要从待验收函数反推答案

四个输入固定为89、90、109、110,普通运费固定为10。第一版对应预期为10、0、0、0;第二版对应预期为10、10、10、0。测试中的两组值来自本练习事先定义的业务规则,直接写在用例中,不是运行 shipping_fee 后生成“正确答案”。

第一次合并的两边都不是最终业务决定,所以不能用“选 ours”或“选 theirs”作为通用修法。人工第一次写90,并先跑第一版验收。后来只在主线更新 test_shipping.py 中的规则;shipping.py 两侧保持原样,以便构造“文本冲突相同、验收背景变化”的负例。

这些用例专门覆盖两个版本的门槛附近,不代表完整电商规则。负金额、货币精度、运费地区、折扣、退货与输入类型均未建模。换成真实业务时,应由规则负责人提供更完整的边界与回归用例。

4. 完整可运行实验:保存为 lab.py

环境需要 Python 3 与支持这些参数的 Git。本次实际运行版本为 Python 3.12.14、Git 2.52.0。脚本只使用标准库,创建一个新的临时目录,结束后默认清理自己创建的仓库;不用安装第三方包,不访问网络。先保存下列完整代码,再执行 python3 lab.py。需要保留仓库与 commands.log 时使用 python3 lab.py --keep-fixture;输出的 RETAINED_FIXTURE 是这一次生成的目录。确认不再需要后只删除该目录,勿使用通配符清理其他目录。异常时默认也会清理夹具;--keep-fixture 可用于保留失败现场。没有参数可指定既有仓库;Windows 可使用已配置的 Python 启动命令,但本次没有做 Windows 实测。

脚本移除继承的 GIT_* 环境变量,关闭系统与全局 Git 配置读取,并将本仓库 hooksPath 指向新建临时根目录内的空路径,以减少个人配置对合成实验的影响。这个隔离措施只属于测试夹具,不是给现有团队仓库关闭 hook 的建议。每次执行创建不同目录,不复用旧结果。

import argparse
import os
import shutil
import sys
from pathlib import Path
import subprocess
import tempfile

parser = argparse.ArgumentParser(description='Four real Git merges in an owned temporary fixture')
parser.add_argument('--keep-fixture', action='store_true', help='retain the generated repository and commands.log for manual review')
args = parser.parse_args()
root = Path(tempfile.mkdtemp(prefix='rerere-review-'))
try:
    repo = root / 'repo'
    repo.mkdir()
    # Isolate this synthetic repository from user Git configuration and hooks.
    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_TERMINAL_PROMPT='0', LC_ALL='C')
    log = []

    def git(*args, expected=0):
        p = subprocess.run(['git', *args], cwd=repo, env=env,
                           text=True, capture_output=True)
        log.append('$ git ' + ' '.join(args) + '\n' + p.stdout + p.stderr)
        if p.returncode != expected:
            raise RuntimeError(log[-1] + f'Unexpected exit: {p.returncode}')
        return p.stdout

    def write(name, text):
        (repo / name).write_text(text, encoding='utf-8')

    def shipping(threshold):
        write('shipping.py', f'def shipping_fee(total):\n    return 0 if total >= {threshold} else 10\n')

    def oracle(version):
        # Fixed acceptance values: not derived from the candidate implementation.
        cases = {1: [(89, 10), (90, 0), (109, 0), (110, 0)],
                 2: [(89, 10), (90, 10), (109, 10), (110, 0)]}[version]
        write('test_shipping.py', 'from shipping import shipping_fee\n'
              + f'cases = {cases!r}\n'
              + 'for total, expected in cases:\n'
              + '    actual = shipping_fee(total)\n'
              + '    if actual != expected:\n'
              + '        raise SystemExit(f"FAIL total={total} actual={actual} expected={expected}")\n'
              + 'print("PASS four fixed cases")\n')

    def test(expected):
        command = [sys.executable, *(['-O'] if sys.flags.optimize else []), '-B', 'test_shipping.py']
        p = subprocess.run(command,
                           cwd=repo, env=env, text=True, capture_output=True)
        log.append('$ ' + ' '.join(command) + '\n' + p.stdout + p.stderr)
        if p.returncode != expected:
            raise RuntimeError(log[-1])
        result = (p.stdout + p.stderr).strip()
        expected_text = ('PASS four fixed cases' if expected == 0 else
                         'FAIL total=90 actual=0 expected=10')
        if result != expected_text:
            raise RuntimeError('Unexpected business test output: ' + result)
        return result

    def conflict():
        git('merge', '--no-commit', '--no-ff', '--no-rerere-autoupdate',
            'topic', expected=1)
        if len(git('ls-files', '-u', '--', 'shipping.py').splitlines()) != 3:
            raise RuntimeError('Expected three unmerged index entries')

    git('init', '-b', 'main')
    git('config', 'user.name', 'Fixture Author')
    git('config', 'user.email', 'fixture@example.invalid')
    git('config', 'core.hooksPath', str(root / 'empty-hooks'))
    git('config', 'rerere.enabled', 'true')
    git('config', 'rerere.autoupdate', 'false')
    shipping(100)
    oracle(1)
    git('add', 'shipping.py', 'test_shipping.py')
    git('commit', '-m', 'synthetic base')
    git('switch', '-c', 'topic')
    shipping(120)
    git('add', 'shipping.py')
    git('commit', '-m', 'topic proposes 120')
    git('switch', 'main')
    shipping(80)
    git('add', 'shipping.py')
    git('commit', '-m', 'main proposes 80')

    # Round 1: record an accepted manual resolution, then abort the rehearsal.
    conflict()
    if '<<<<<<<' not in (repo / 'shipping.py').read_text():
        raise RuntimeError('First merge must expose a conflict')
    shipping(90)
    print('ROUND1 manual:', test(0))
    git('rerere')
    git('merge', '--abort')

    # Round 2: same conflict reuses 90, yet the index remains unresolved.
    conflict()
    if (repo / 'shipping.py').read_text() != 'def shipping_fee(total):\n    return 0 if total >= 90 else 10\n':
        raise RuntimeError('Recorded resolution was not reused')
    print('ROUND2 reused:', test(0), '; unmerged_index_entries=3')
    git('merge', '--abort')

    # Round 3: business acceptance changes outside the conflicting file.
    oracle(2)
    git('add', 'test_shipping.py')
    git('commit', '-m', 'policy v2 requires free shipping from 110')
    conflict()
    if '<<<<<<<' in (repo / 'shipping.py').read_text():
        raise RuntimeError('Expected clean text from the old resolution')
    print('ROUND3 stale reuse:', test(1), '; unmerged_index_entries=3')
    # Re-create conflict from index stages before forgetting its old resolution.
    git('checkout', '--conflict=merge', '--', 'shipping.py')
    git('rerere', 'forget', 'shipping.py')
    shipping(110)
    print('ROUND3 repaired:', test(0))
    git('rerere')
    git('merge', '--abort')

    # Round 4: prove the replacement resolution is reused; only then stage it.
    conflict()
    if (repo / 'shipping.py').read_text() != 'def shipping_fee(total):\n    return 0 if total >= 110 else 10\n':
        raise RuntimeError('Replacement resolution was not reused')
    print('ROUND4 replacement:', test(0), '; unmerged_index_entries=3')
    git('diff', '--check')
    git('add', 'shipping.py')
    if git('ls-files', '-u'):
        raise RuntimeError('Unresolved index remains after explicit add')
    git('diff', '--cached', '--check')
    git('commit', '-m', 'accept reviewed resolution for policy v2')
    if git('status', '--porcelain'):
        raise RuntimeError('Fixture should finish clean')
    (root / 'commands.log').write_text('\n'.join(log), encoding='utf-8')
    print('FINAL clean; all four rounds verified')

finally:
    if args.keep_fixture:
        (root / 'commands.log').write_text('\n'.join(locals().get('log', [])), encoding='utf-8')
        print('RETAINED_FIXTURE=' + str(root))
    else:
        shutil.rmtree(root)
        print('CLEANUP removed owned temporary fixture')

5. 四轮实际结果:成功复用也会把旧错误带回来

本次实际运行得到下列输出。默认运行会在结束后删除自建临时目录,关键判断均由脚本检查。

ROUND1 manual: PASS four fixed cases
ROUND2 reused: PASS four fixed cases ; unmerged_index_entries=3
ROUND3 stale reuse: FAIL total=90 actual=0 expected=10 ; unmerged_index_entries=3
ROUND3 repaired: PASS four fixed cases
ROUND4 replacement: PASS four fixed cases ; unmerged_index_entries=3
FINAL clean; all four rounds verified
CLEANUP removed owned temporary fixture

第一轮出现真实内容冲突,写入90并通过第一版测试,再执行 git rerere 记录解法。随后 abort 只是结束这次演练;第二轮的成功复用表明已记录的解法仍可用于下一次相同冲突。第二轮虽然测试通过,脚本仍确认存在3个未合并索引条目,没有直接提交。

第三轮是负对照。主线已提交第二版验收,Git 日志仍出现 Resolved 'shipping.py' using previous resolution,工作目录里没有冲突标记;但是90元订单返回0,第二版规则期待10,测试进程以1退出。这证明“文本看起来合好了”无法推出“业务规则符合当前版本”。我们没有通过修改预期值把失败抹掉。

修复后第四轮再次从原分支冲突出发,确认复用文件中的门槛确实是110、第二版四个固定用例通过,而且显式 add 前仍有3个未合并索引条目。最后才执行差异检查、add、确认没有未合并路径与 commit,并检查仓库状态干净。这里的最终提交只存在于新建的合成仓库中。

6. 发现旧解法不合适,怎样重新记录

本例第三轮故意保留未合并索引,因此能够先用索引中的两侧版本重建冲突,再让 rerere 忘记这个当前冲突的旧解法。以下步骤会改变文件,只应在理解并保留现有修改后执行;本篇脚本只对自建夹具运行。

git checkout --conflict=merge -- shipping.py
git rerere forget shipping.py
# 根据已经确认的第二版业务规则编辑为110,然后运行测试
python3 -B test_shipping.py
git rerere

git rerere 官方说明将 forget 定义为重置匹配路径上当前冲突的已记录解法。这不是按文件名删除所有历史记忆的通用命令,也不等于清空整个缓存。本例重新记录后又执行一次完整合并,验证替换后的解法真正参与了复用,而不是仅在当前文件上改对了数字。

遇到首次就没有冲突、没有复用、或索引条目数量不符,先停下来检查分支提交与 Git 版本。本例各处均对返回码和状态作检查;不要用“忽略所有非零退出码”掩盖环境问题。merge 返回1只在这个已知冲突步骤中被接受,随后还要验证索引形态;运行别的命令失败仍立即停止。

若当前冲突已被暂存或合并已提交,这套重建步骤的前提不成立,不能照抄。先检查状态并保留工作,明确需要修复的是当前文件、已记录解法还是已提交的业务代码,再选择对应流程。不要为了复现实验直接重置已有项目的历史。

7. 交给 Codex 的任务单与人工验收清单

先独立跑通上面的合成实验,再把以下说明交给能够访问指定练习目录的 Codex。这里的授权对象应是脚本自行创建的临时仓库;--keep-fixture 会打印其实际目录,不是让脚本接管一个已有项目。这里没有依赖某个特定客户端入口或版本功能。

运行 lab.py --keep-fixture,只操作脚本自行创建的临时仓库。
保留输出中的 RETAINED_FIXTURE 路径,不访问网络或远程仓库。
不要改全局配置,不操作其他仓库,也不要推送。
保留完整命令记录、Git/Python版本与四轮结果。
解释为什么第二轮文件无冲突标记,索引仍然未合并。
第三轮必须保留原先固定的第二版预期值,展示旧90解法失败,
不能改测试、跳过失败或直接把失败解释成成功。
按照脚本恢复当前冲突、忘记旧解法、按规则写110,
重新演练确认110被复用后,才在这个临时仓库暂存并提交。
提交前分别报告测试、未合并索引、差异检查结果。
任何状态与脚本不符就停下,报告实际命令、退出码与差异。
不要声称这些测试覆盖了完整运费业务或真实生产部署。

人工审阅时核对五项:第三轮是否真的失败;第二版预期是否在失败前已固定;修复是否只改实现与当前冲突解法;第四轮是否重新触发了复用;显式 add 是否发生在验收之后。只看最终 PASS 会错过最有价值的反例。

本次已验证的是本地合成 Git 分支、真实 rerere 行为、固定业务用例与恢复顺序。未验证 Codex 的执行表现、不同 Git 版本、二进制冲突、复杂 rename、rebase、并行操作或生产仓库。真实工作中仍需审查完整差异和适用测试,避免把可复用的文本答案当成业务审批。

8. 复跑、优化模式与证据边界

2026 年 10 月 14 日在 Linux x86_64、Git 2.52.0、Python 3.12.14 上重跑了公开源码、正文提取代码与下载包中的代码,每种都使用普通模式与 python3 -O。程序用显式条件和异常检查,不依赖会被 -O 删除的 assert;子测试也传播优化模式。固定预期独立于 shipping_fee 的实现。

验证另做两个程序突变负例:将修复值110留成90,应在第二版验收失败;跳过 forget 并继续旧90,也不能以成功结束。两个负例在普通和优化模式下均必须返回非零,而且默认清理临时夹具。正常实验中第三轮测试返回1是预期现象,整段实验仍应返回0;不要把两种退出码混淆。

下载包只含本文、完整 lab.py、README 与原创示意图,不含真实仓库、个人配置或执行时生成的 .git 缓存。默认运行不留下仓库;如需人工看命令与索引,使用 --keep-fixture 后检查打印出的目录。脚本不接受现有仓库路径,不修改全局配置,不联网、不推送。它仍依赖可信的本地 Git 与 Python 安装,并非运行任意第三方代码的沙箱。

资料核对于 2026 年 10 月 14 日:git rerere 官方文档与git merge 官方文档。当前在线文档可能晚于实测 Git 2.52.0,本篇不据此宣称其他版本或所有冲突形态都经过验证。

9. 下载离线练习包

下载完整离线练习包(ZIP),或复制正文中的完整 lab.py。包内 article.html 包含教程与源码;不依赖网站才能运行。

没有冲突标记就可以提交吗?

不可以据此判断。本文关闭 rerere 自动更新索引,复用后的文件虽然干净,仍有三个未合并索引条目;还必须核对当前业务用例与差异,再显式暂存。

forget 会删除这个文件的所有历史解法吗?

不是。它重置匹配路径上当前冲突的已记录解法。本例在保留未合并索引时重建冲突,然后忘记并重录当前解法。

这是否证明 rerere 不能用?

没有。实验显示它减少重复文本编辑,但业务规则变化需要额外验收。保留人工审阅边界并运行适用测试,仍然可以利用复用结果。

这是 Codex 实测吗?

不是。本篇运行的是本地真实 Git 与 Python 合成实验,Codex 任务单是未执行模板。没有验证模型、生产仓库、Windows、rebase或复杂rename冲突。