写代码二十年,每次让 Git 合并冲突只会打开编辑器手点——今天才发现它自己早就记住了你怎么解

Git 有两个功能,一个默认关着、一个默认是你最不想用的那一档,而它们解决的是同一件事:你反复解同一个冲突、以及你在冲突里根本看不出两边到底改了什么。

先说结论:git config --global rerere.enabled truegit config --global merge.conflictStyle zdiff3,两行配置。前者让 Git 记住你手工解过的冲突块,下次原样复现时直接把你的解法写回工作区;后者让冲突块多出一段「共同祖先」原文,让你看得见两边各自改了什么。加起来不到十秒,能省掉你这辈子相当一部分重复劳动。

一、rerere:Reuse Recorded Resolution,Git 里藏了二十年的功能

rerere 是 Git 内置功能,存在了大约二十年,默认关闭,绝大多数人从没打开过。官方 git-rerere 文档给出的原始场景很具体:你的长命 topic 分支和 master 反复冲突,为了不把一堆「Merge from master」的测试合并留进历史,你习惯测试合并后 git reset --hard HEAD^ 把它抹掉、继续开发——但下次真合并时,同一个冲突块你还得再手解一遍。Linus 当年就吐槽过这种无意义的测试合并把历史弄得一团糟,rerere 就是那个折中方案。

它的机制是内容寻址,不是文件寻址:

  • 有冲突的 merge / rebase / cherry-pick 发生时,Git 把冲突块原文写进 .git/rr-cache/<hash>/preimage
  • 你解决完并提交,Git 把解决结果写进同目录的 postimage
  • 下次出现同一个冲突块,Git 做一次三方合并(旧的冲突态 + 你当时的解法 + 当前冲突态),干净就把结果直接写回工作区,并打印 Resolved 'xxx' using previous resolution.

官方文档明确写了两个细节:记录冲突态只在 rerere.enabled 事先打开时才会发生(冲突发生了再打开是没用的);并且 git rerere 不动 index,你仍要 git diff 看一眼再 git add

一个可复现的最小实验:

mkdir rerere-demo && cd rerere-demo
git init -q
git config rerere.enabled true
printf 'version = 1n' > config.txt
git add . && git commit -qm init
git switch -qc feature
printf 'version = 2-featuren' > config.txt
git commit -qam feature
git switch -q main
printf 'version = 2-mainn' > config.txt
git commit -qam main
git merge feature                            # 冲突,符合预期
printf 'version = 2-mergedn' > config.txt
git add config.txt && git commit -qm merged  # rerere 在这里记下解法
git reset --hard HEAD~1                      # 假装这次合并没发生过
git merge feature                            # 打出 Resolved 'config.txt' using previous resolution.

第二步 merge 时 config.txt 里已经是 version = 2-merged,你一个字都没重打。

二、rerere 什么时候会失灵——这是被吐槽最多的地方

rerere 的匹配是按冲突块的规范化原文做哈希,不看文件名也不看 commit。这意味着:

  1. 上下文行一变,就是另一个冲突。你在冲突块前后多改一行注释、调一下顺序,哈希就变了,rerere 一声不吭,你只能重解。
  2. 空白字符、行序漂移都会打断匹配。
  3. 旧解法可能已经错了,但它照样复现。这是最危险的坑:rerere 会把一个过期的解法自信地写回文件。

官方文档里还有一条不常被提到的注意:rerere 依赖文件里的冲突标记来识别冲突,如果文件本身包含与冲突标记长得一样的行(比如你的模板或文档在讲 <<<<<<<),rerere 可能记录失败,需要用 gitattributesconflict-marker-size 绕开。

逃生舱是 git rerere forget <path>:丢掉这个文件的记忆,冲突重新冒出来让你手解。注意它只删缓存,不回滚任何已经提交的东西——如果错误复现已经被 commit 进去了,得另外用交互式 rebase 处理历史。

配套的两个开关值得知道:

  • rerere.autoUpdate true:自动把复现结果 stage 到 index,省掉一次 git add。代价是错误复现会静默进入暂存区,你就得靠 git diff --cached 自己兜底了。
  • 共享:.git/rr-cache/ 纯本地、不会 push。团队要共享就得带外同步(共享目录加软链 ln -s /shared/team-rr-cache .git/rr-cache,或者直接把 hash 目录打包分发)。因为条目是按内容哈希命名的,两份 cache 合并不会冲突覆盖,相同的冲突映射到相同目录、不相干的各占各的。

三、zdiff3:让你在冲突里看见祖先

默认的冲突风格(merge)只有两边,中间靠 ======= 分。问题是你根本看不出两边各自改了什么——你以为对方只是删了一行,实际上他把整段重写了;你以为自己改了,其实只是复制粘贴了原样。

三种风格对同一段冲突的输出差异:

  • merge(默认):只有 ours 和 theirs,你必须自己脑补谁改了什么
  • diff3:多一个 ||||||| 标记和共同祖先原文,你能看见意图,但冲突区域行数变多
  • zdiff3(Git 2.35,2022 年 Q1 引入):和 diff3 一样带祖先,但把两侧相同的公共行从冲突区域压缩到外面,同样的信息,约三分之一的文本

一个直观例子:默认风格下 AE 两行公共代码会被提取到冲突区域外,而 diff3 会把它们留在冲突区域里;zdiff3 又把它们挪出去。结果就是既能看祖先,又不让冲突块虚胖。Linux 内核的 backport 文档直接建议用 diff3 或 zdiff3,理由是它能更清楚地展示补丁实际改了什么,让你能做出更好的判断。

四、顺手提一句:合并策略这块也该更新认知了

ort(Ostensibly Recursive’s Twin)从 Git 2.34 起就是默认的合并策略,替代了老的 recursive。官方 merge-strategies 文档写得很直白:recursive 在 v2.50.0 里已经被重定向为 ort 的同义词。ort 做的是基于树的重命名检测(先目录后文件),比递归策略在重命名密集的大仓库上快得多,而且更确定性;另外 ort 的 diff 算法默认就是 histogram,而普通 diff 走的是 diff.algorithm 配置。如果你还在用老开关或者覆盖 merge-recursive,值得重新看一眼——ort 的冲突输出格式和老后端略有不同,自定义 merge 工具可能要吃这个差异。

落地清单(照抄就行)

git config --global rerere.enabled true
git config --global rerere.autoUpdate true    # 可选:省掉重复 git add
git config --global merge.conflictStyle zdiff3
# 团队共享(可选)
ln -s /shared/team-rr-cache .git/rr-cache

然后给自己定一条纪律:每次 git rebase --continue 之前先 git diff --cached 看一眼自动复现的结果。rerere 不是银弹,它只是把你已经做过的决策原样还给你;判断那个决策今天还算不算数,仍然是你的事。

一句话收尾:这两个开关的收益不是快,是你不再把同一道题做第二遍,也不再在冲突块里盲猜对方的意图。二十年的功能默认关着,多半是因为——没人在入职第一天告诉你。

评论区

0 条评论

登录后可评论。

铁锈·Rust工具链 137 阅读