大家好,我是小富。
《十万个why》系列持续更新中
这个问题我刚工作的时候也困惑过。merge 用得好好的,代码也能合进去,为什么组里非要求用 rebase?rebase 还经常冲突,处理起来比 merge 麻烦,图什么?
后来我在一个十几人的团队里维护过一个跑了两年多的项目,才真正理解这个规范背后的道理。
先说 merge 到底有什么问题
merge 本身没有错,它忠实地记录了"两条分支在这个点合并了"这个事实。问题出在当团队人多、分支多的时候,merge 产生的提交历史会变成什么样。
假设你从 main 拉了一个 feature 分支,开发了三天。这三天里其他同事往 main 推了十几个 commit。你为了保持同步,中间 merge 了两次 main 到你的分支。最后开发完了,再把 feature merge 回 main。
这时候你的 git log 大概长这样:
* Merge branch 'main' into feature/user-export
|\
| * fix: 修复订单查询分页问题
| * feat: 新增商品标签功能
| * fix: 修复登录超时处理
* | feat: 导出功能添加进度条
* | feat: 用户导出增加筛选条件
|/
* Merge branch 'main' into feature/user-export
|\
| * refactor: 重构通知服务
| * fix: 修复缓存穿透问题
* | feat: 用户导出基础功能
|/
* chore: 升级 Spring Boot 版本
如果就你一个人在搞,这已经不太好看了。但一个十几人的团队,每个人每天都在往 main 合代码,每个人的 feature 分支中间还多次 merge main,最终的 git log 就是一张密密麻麻的地铁线路图,各种分叉和汇合交织在一起。
这种历史记录,打开来除了头疼没有任何用处。
历史不可读,出事就抓瞎
Git 历史可读不可读,平时可能感觉无所谓。但一旦线上出了问题需要排查,你就知道有多重要了。
git log 是排查问题时最常用的手段之一。线上出了 Bug,第一反应是看最近谁提交了什么改动。如果 git log 里一半都是 Merge branch 'main' into xxx 这种无意义的合并节点,你要从里面找到真正引入问题的那个 commit,等于在一堆噪音里找信号。
更关键的是 git bisect。这个命令可以用二分法自动帮你定位是哪个 commit 引入了 Bug,在排查历史问题时极其好用。但如果提交历史里全是交叉的 merge 节点,bisect 会在分叉路径上跳来跳去,定位效率大幅下降,有时候甚至会给出误导性的结果。
还有 git blame。你想看某一行代码是谁在什么时候改的,结果 blame 指向了一个 merge commit,这个 merge commit 里包含了二十几个其他人的提交,你还得再去翻才能找到真正修改那一行的人。
这些工具在干净的线性历史上跑得很顺畅,一到复杂的合并历史上就各种磕绊。公司规范要求 rebase,核心原因就是为了保住这些工具的可用性。
rebase 做了什么
rebase 做的事情很简单:把你在 feature 分支上的 commit 摘下来,接到目标分支的最新节点后面,让它看起来像是你"刚从最新的 main 拉出来,一口气写完"的。
同样是上面那个场景,用 rebase 之后的 git log:
* feat: 导出功能添加进度条
* feat: 用户导出增加筛选条件
* feat: 用户导出基础功能
* fix: 修复订单查询分页问题
* feat: 新增商品标签功能
* fix: 修复登录超时处理
* refactor: 重构通知服务
* fix: 修复缓存穿透问题
* chore: 升级 Spring Boot 版本
一条直线,每个 commit 都是一个有意义的改动,没有任何合并噪音。git log 直接能看出来这段时间做了什么,git bisect 也能正常二分,git blame 指向的就是真正改那一行的人。
Code Review 也会好很多
这一点很多人没意识到。
你提一个 PR,reviewer 打开一看,里面夹着三个 Merge branch 'main' into feature/xxx 的 commit,每个 merge commit 的 diff 里混着其他人的改动。reviewer 得在你的代码和别人代码之间来回切换,分辨哪些是你写的,哪些是合进来的。
如果你 rebase 过了,PR 里只有你自己的 commit,每个 commit 对应一个独立的改动点,reviewer 可以一个一个 commit 看,逻辑非常清晰。
我做 Code Review 的时候对这个感受很深。两个 PR 改动量差不多,一个 rebase 过历史很干净,一个没 rebase 全是 merge 节点,前者我可能二十分钟看完,后者得翻半天。Review 效率直接影响的是整个团队的交付节奏。
那 rebase 的坑呢
rebase 不是没缺点,它最大的问题是会改写提交历史。
merge 是在两条线之间建了一座桥,原来的历史不动。rebase 是把你的 commit 一个个拆下来,重新在新的基础上重放,每个 commit 的 hash 都变了。
这带来一个严格的规则:不要对已经推到远程并且别人在用的分支做 rebase。
比如你和同事共同在一个分支上开发,你本地 rebase 了然后 force push,同事那边的历史就跟远程对不上了,轻则要手动处理一堆冲突,重则丢代码。
所以公司规范说的"用 rebase",完整的意思是:
在你自己的 feature 分支上,合并 main 的最新代码时用 rebase 而不是 merge。在合回 main 之前,先 rebase 到 main 最新节点。但 main 分支本身的合并,该用 merge 还是用 merge。
具体操作就是这样:
# 你在 feature 分支上开发,要同步 main 的最新代码
git fetch origin
git rebase origin/main
# 开发完了,提 PR 之前再 rebase 一次确保是最新的
git fetch origin
git rebase origin/main
git push --force-with-lease # 注意是 force-with-lease 不是 force
# PR 合并到 main,通常由 GitLab/GitHub 的按钮完成
# 很多团队选择 squash merge 或 rebase merge
--force-with-lease 比 --force 安全,它会检查远程分支有没有被别人更新过,如果有就拒绝推送,防止你覆盖别人的代码。
处理冲突确实比 merge 麻烦
这是 rebase 被吐槽最多的地方。
merge 处理冲突是一次性的,不管中间有多少 commit,你只需要解决一次冲突。而 rebase 是逐个 commit 重放的,如果你的分支有 10 个 commit,其中 3 个跟 main 有冲突,你就得解决 3 次。
更坑的是,有时候第 1 个 commit 的冲突解决方式会影响第 3 个 commit 的冲突内容,处理起来比较烧脑。
对付这个问题有两个实际的办法:
第一,勤 rebase。不要等 feature 分支开发了一周再去 rebase main,每天或者每隔一两天 rebase 一次,每次冲突量小,处理起来就简单。
第二,提 PR 之前先 squash 自己的 commit。如果你的 feature 分支有 15 个 commit,先用 git rebase -i 合并成 2-3 个有意义的 commit,再 rebase 到 main 上。commit 少了,冲突次数也就少了。
merge 也不是一无是处
说了这么多 rebase 的好处,但有些场景 merge 才是对的。
比如长期维护的 release 分支。release/1.0 分支上需要 cherry-pick 一些 hotfix,这时候你就是需要 merge commit 来记录"这个修复是从 main 合过来的"这个事实。rebase 会抹掉这个信息。
再比如两个团队各自开发了很久的大分支要整合,用 merge 保留各自的提交历史和时间线,方便之后追溯"这个功能是 A 团队做的还是 B 团队做的"。
所以不是 rebase 绝对比 merge 好,而是在日常 feature 分支开发的场景下,rebase 产出的历史对团队协作更友好。
说在最后
公司规范要求用 rebase 不是在搞形式主义,是在帮你维护一份可读、可追溯、可排查的提交历史。
日常开发感觉不到它的价值,等到线上出了问题要翻 git log 定位原因、要用 bisect 二分查 Bug 的时候,你就会感谢当初保持历史干净的那个人。
一句话:merge 保留了所有的真实,但真实不等于有用。rebase 整理过的历史丢掉了一些合并细节,换来的是整个团队都能高效协作的清晰主线。
