引言 — 同样的代码,不同的历史
关于 merge 和 rebase 的争论,大多被当成口味之争来消费。一方拿出干净的一条直线的截图,另一方拿出"发生过的事就该原样留下"的截图。可只要在提交哈希这个层面看看这两个命令实际上造出了什么,争论的一半就会自动化解。
先说核心。合并只会多创建一个提交,不会碰已有的提交。变基不是把已有的提交搬走,而是创建内容相同、父提交不同的新提交。所以哈希会变。这一句话就是"共享的分支不做变基"这条规则的唯一依据,规则的例外能到哪里也从这里推出来。
提交哈希告诉你的事 — 变基不移动提交
先准备一个已经分叉的分支。
$ git log --oneline --graph --all
* 8c377e2 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 6d466fd 결제 요청 타임아웃 조정
| * c76f2e6 (main) 로깅 포맷 통일
|/
* a2338b7 주문 API 스키마 정리
在这里做一次变基。
$ git rebase main
Rebasing (1/2)
Rebasing (2/2)
Successfully rebased and updated refs/heads/feature/checkout.
$ git log --oneline --graph --all
* 4a17a14 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 7040fea 결제 요청 타임아웃 조정
* c76f2e6 (main) 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
提交信息一样,改动内容也一样,可哈希全变了。8c377e2 变成了 4a17a14,6d466fd 变成了 7040fea。看一眼提交对象长什么样,理由马上就说得通了。
$ git cat-file -p HEAD
tree c9cff0bf1786d3c413655ad220869022721584a4
parent 7040fea9c7bc15cd1d77bb26d843c8ba81140fec
author Demo <d@e.com> 1785081971 +0900
committer Demo <d@e.com> 1785081971 +0900
결제 실패 재시도 추가
提交对象里装着父提交的哈希,而提交的哈希就是对这份内容整体做哈希得到的值。父提交变了,提交哈希就一定会变。也就是说,变基不是移动提交的命令,而是重写提交的命令,而重写这个词的准确含义正是"创建了新对象,并把分支引用挪了过去"。
那原来的提交去哪儿了呢。它们没有消失,只是不再被任何东西指向而已。
$ git reflog show feature/checkout
4a17a14 feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout onto c76f2e6
8c377e2 feature/checkout@{1}: commit: 결제 실패 재시도 추가
6d466fd feature/checkout@{2}: commit: 결제 요청 타임아웃 조정
a2338b7 feature/checkout@{3}: branch: Created from HEAD
如果想复查变基的结果,有专门把两个版本并排比较的命令。重新去看别人被强制推送过的分支时尤其有用。
$ git range-diff main feature/checkout@{2} feature/checkout
1: 6d466fd = 1: 7040fea 결제 요청 타임아웃 조정
-: ------- > 2: 4a17a14 결제 실패 재시도 추가
黄金法则真正的边界线
"已经推送过的提交不要变基"这句话被引用得很多,但稍微不准确。问题的本质不在于有没有推送,而在于那个提交对象是否存在于别人的仓库里,以及是否有人在它上面叠了工作。
同事的克隆里 8c377e2 原样留着。你把换成 4a17a14 的分支强制推上去,同事下次拉取时就会同时拥有自己本地的旧提交和新提交。这时同事不假思索地合并,就会造出同一处改动进去两次的历史,冲突正是从这里开始的。
所以这条规则改写成下面这样更准确。不要重写别人已经在上面叠了工作的提交。按这个标准看,常说的那些例外其实不是例外,而是规则的正常适用。
- 还没有人检出过的、属于自己的评审分支,做变基没问题。很多团队把评审期间的变基作为惯例允许,原因就在这里。
- 只整理本地独有提交的变基永远是安全的。下面的配置把它变成默认行为。
$ git config --global pull.rebase true
$ git config --global rebase.autoStash true
问题出在下一步。要把变基后的分支推上去就需要强制推送,而这里常见的建议是危险的。
# 常见建议 — 会把别人的提交一起抹掉
$ git push -f origin feature/checkout
# 至少要这样
$ git push --force-with-lease origin feature/checkout
To github.com:example/shop.git
+ 8c377e2...4a17a14 feature/checkout -> feature/checkout (forced update)
这里还要再往里走一步。--force-with-lease 检查的是远程跟踪引用是否还等于自己最后看到的值,可如果 IDE 或后台任务刚刚做过一次 fetch,那个引用早就被刷新成最新的了。即使进来了自己没看到的提交,检查也照样通过。堵住这个洞的选项是另外一个。
$ git config --global push.useForceIfIncludes true
$ git push --force-with-lease origin feature/checkout
如果像栈一样叠了好几个分支,下面这个选项会把下层分支的引用也一起挪过去。不知道它的话,每次变基都得手工把下游分支重新接上。
$ git rebase --update-refs main
$ git config --global rebase.updateRefs true
合并提交是信息还是噪音
主张要去掉合并提交的依据,多半是"日志太乱"。这个抱怨大部分靠会用工具就能解决。
合并提交有两个父提交。第一个父提交是接受合并的那一侧,也就是 main 的上一个状态,第二个父提交是并进来的分支。只沿着第一个父提交走,剩下的就只有从 main 视角看到的整合顺序。
$ git log --oneline --graph --first-parent main
* 40600e3 Merge branch 'feature/checkout'
* c76f2e6 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
写发布说明、或者要看"什么时候进了什么"的时候,想要的正是这张图。bisect 也能用同样的方式跳过分支内部,只按整合单位收敛。
$ git bisect start --first-parent
$ git bisect bad main
$ git bisect good v2.14.0
所以合并提交本身并不是噪音。真正的噪音是没有整合意图的合并提交。不做变基、习惯性地拉取时生成的那种 Merge branch 'main' into feature 就属于这类。它不含任何信息,只把图搅乱。加上下面这条配置,那种合并提交根本就不会产生。
$ git config --global pull.ff only
归结起来,对立的框架本身就错了。选项不是"要不要允许合并提交",而是让合并提交记录人的决定,还是任由工具随时打上去。
压缩合并给你什么,又拿走什么
压缩(squash)合并把分支上的提交全部合成一个再叠上去。得到的东西很清楚。main 上的一个提交精确对应一个 PR,那个提交处于通过了 CI 的状态,回退也只要 revert 一个提交就结束。评审单位和历史单位一致,这个好处比想象中大。
被拿走的东西同样清楚,只是很少被人谈起。
第一,bisect 的分辨率被固定在 PR 的大小上。一个 800 行的 PR 被压缩之后,bisect 只能帮你收敛到那 800 行,再往里就得靠人读。反过来,如果单个提交还在就能收得更细,但分支中间的提交从来没有通过过 CI,构建可能是坏的。这种时候告诉它跳过即可。
$ git bisect skip
第二,会失去 cherry-pick 的精度。哪怕只想把那个 PR 里改进日志的一行搬到热修复分支上,留下来的提交也只有那一坨 800 行。
第三,这是最常踩的坑,在 Git 看来那个分支从来没有被合并过。因为压缩提交的父提交并不是原来的分支。结果就是下面这条命令不会把已经并入的分支列出来。
$ git branch --merged main
hotfix/coupon-null
* main
同样的道理,压缩合并过的分支不删掉而继续用,再合并一次时,Git 会把共同祖先之后的改动全都当成新的,把已经并入的内容也当作冲突塞给你。使用压缩合并的团队,必须同时带上"合并后立刻删除源分支"这条规则。
变基过程中同一个冲突反复出现的原因
合并只需要解决一次冲突,变基却在每个提交上把同一个地方再问一遍。这是烦躁的来源,但行为是准确的。
合并只比较两端和共同祖先这三个点。变基则是把提交一个一个重新应用,每次都重新做一遍三方合并。如果同一个文件的同一段被自己的三个提交连着改过,那么这一段的冲突就会遇到三次。变基不是一次合并,而是 N 次合并。
解决办法是让它把同一个解决方式记下来。
$ git config --global rerere.enabled true
$ git rebase main
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Recorded preimage for 'src/checkout/retry.ts'
error: could not apply 6d466fd... 결제 요청 타임아웃 조정
# 解决冲突后继续
$ git add src/checkout/retry.ts
Recorded resolution for 'src/checkout/retry.ts'
$ git rebase --continue
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Resolved 'src/checkout/retry.ts' using previous resolution.
最后一行就是 rerere 在干活的声音。它把冲突之前的状态和你给出的结果配成一对存起来,等同样的冲突状态再次出现时,就原样套用存下来的结果。中断变基之后重来一次,它也照样还在。
顺便把让冲突解决本身变轻松的配置也一起加上会更好。默认的冲突标记只显示两边的结果,而下面这条配置会连共同祖先的原文一起显示出来。只有知道双方各自从什么出发,而不是对方改了什么,才能正确地合并。
$ git config --global merge.conflictStyle zdiff3
快进、强制合并提交,以及团队策略的选择
如果分支没有分叉,合并根本不会创建新对象,只是把引用往前挪。
$ git merge feature/checkout
Updating c76f2e6..4a17a14
Fast-forward
retry.txt | 2 ++
1 file changed, 2 insertions(+)
反过来,如果一定要把合并这件事记录下来,就强制创建合并提交。
$ git merge --no-ff feature/checkout
Merge made by the 'ort' strategy.
retry.txt | 2 ++
1 file changed, 2 insertions(+)
走到这一步,策略选择就不是口味问题,而是团队实际在用什么的问题。是否真的会跑 bisect,是否需要发布追踪,评审单位是提交还是 PR。
| 策略 | main 历史的形状 | 是否记录 PR 边界 | bisect 分辨率 | 回退方式 | 适合的团队 |
|---|---|---|---|---|---|
| 强制合并提交 | 能看到分叉的图 | 留在合并提交里 | 按提交,可用第一父提交收窄 | 以第一父提交为基准 revert 合并提交 | 看重发布追踪和审计历史的团队 |
| 变基后快进 | 完全的一条直线 | 不留 | 按提交,最细 | 一个提交一个提交地 revert | 把每一个提交当作评审单位的团队 |
| 压缩合并 | 一条直线,每个 PR 一个提交 | 一个提交就是一个 PR | 固定在 PR 单位 | revert 一个提交就完事 | 按 PR 评审并立刻删分支的团队 |
| 变基后合并提交 | 直线上的整合点 | 留在合并提交里 | 按提交 | revert 合并提交 | 既要历史够细又要记录边界的团队 |
如果只能挑一个标准,这样问更实用。出故障的时候,你打算怎么在这个仓库里找到原因提交。真正会用 bisect 的团队,提交的细密程度就是资产;靠 PR 说明和 issue 链接追踪的团队,一个提交对应一个 PR 要方便得多。答案定了,剩下的自然就跟上来了。
结语 — 如果只记住一条规则
关于 merge 和 rebase 的建议很多,依据却只有一条。变基不移动提交,而是新建提交,因此哈希会改变。共享分支的规则由此而来,需要强制推送的理由由此而来,同一个冲突反复出现的理由由此而来,被压缩过的分支不被认作已合并的理由也由此而来。
Git 对象模型如何决定命令的行为,在用 Git 内部结构理解命令一篇里接着讲。如果回退更急,按情况回退一篇是更快的路。
현재 단락 (1/117)
关于 merge 和 rebase 的争论,大多被当成口味之争来消费。一方拿出干净的一条直线的截图,另一方拿出"发生过的事就该原样留下"的截图。可只要在提交哈希这个层面看看这两个命令实际上造出了什么,...