让 Codex 复核 Git rerere:冲突解法复用了,业务规则还对吗?
相同的文本冲突,可以对应不同版本的业务答案。先保留索引审阅边界,再决定是否接受复用结果。
- 区分工作区的文本复用与索引中的人工接受
- 用固定业务预期揭示旧解法的语义失效
- 重建当前冲突并用forget替换旧记录
- 再次合并验证新解法,最后才显式暂存提交
1. 同一段冲突,再次出现时业务规则可能已经变了
一条长期维护的功能分支反复合入主线,每次都卡在同一行配置。第一次人工改对,第二次又重做同样的选择,很容易让人想把上次答案直接搬过来。Git 的 rerere 可以记录冲突与人工解法,在之后匹配的冲突上复用解法;它节省的是重复编辑,业务上的正确性仍需单独验收。
本练习把问题压缩为一个运费函数。基线在订单满100元时免邮,主线把门槛改为80,功能分支把同一行改为120。经业务确认,第一版实际规则为90。第一次手工解决后,第二次演练能复用90;之后业务改为110,冲突两侧的文本仍是80与120,旧解法也可能再次被复用。本篇要亲眼看到这个失败,再替换旧解法。
这些门槛与订单均为虚构整数单位,不涉及实际支付。脚本运行的是本地真实 Git,不是模拟 Git 返回值;没有运行 Codex、模型或远程仓库。给 Codex 的任务指令是待执行模板,不能把本实验写成 Codex 实测记录。
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冲突。