一次让我被迫加班的merge冲突
2023年11月,我们团队在发布前合并一个功能分支时炸了。12个开发者、4个feature分支同时往develop合并,结果产生了47个冲突文件。merge commit把三个分支的提交历史像面条一样搅在一起,代码review根本无法进行。我花了一整天才把冲突理清,发布延期两天。那周我就在想:merge和rebase到底该怎么选?
先看结论
如果你急着上线,直接跳到「三种工作流」小节。但如果你吃过merge的亏,建议把原理看完。我给出的建议基于真实压测,不是拍脑袋:
- 个人开发分支或未推送的本地提交:用rebase,保持线性历史
- 多人协作的共享分支:绝对不要rebase,用merge --no-ff保留合并上下文
- 从主干同步更新到功能分支:用rebase(前提是功能分支只有你自己在用),减少无谓的merge commit
Git版本:2.43.0,操作系统:Ubuntu 22.04 LTS,代码仓库:私有GitLab 16.5。下面所有测试均基于这个环境。
merge和rebase到底差在哪
先看一张最简单的示意图。假设master在C2之后,你从C2拉出feature分支,提交了F1和F2。同时master被别人推了C3和C4。
# merge方式
# master: C1---C2---C3---C4---M
# \ /
# feature: F1---F2
# rebase方式
# master: C1---C2---C3---C4
# |
# feature: F1'--F2'
merge产生一个额外的merge commit(M),把两条分支的历史粘在一起。rebase把F1和F2摘下,接到C4后面,产生F1'和F2'。这两者的本质差异是:merge保留「发生了什么」的时间线,rebase伪造了「事情是按顺序发生的」假象。
merge的优缺点
- 优点:不篡改历史。冲突只发生在merge commit那一次。所有人都能看清分支从哪来、到哪去
- 缺点:历史图混乱。feature分支多的时候,graph看着像一碗意大利面。出了bug要二分定位时,merge commit会干扰bisect
rebase的优缺点
- 优点:历史线性,log清爽。git bisect时每一个commit都是可编译、可测试的(前提是每个commit你都保证能跑)
- 缺点:破坏了「原始时间线」。共享分支rebase会重写他人已拉取的commit,导致别人本地历史和远端不一致,必须force push才能推上去
rebase的内部原理
rebase不是把commit「移动」过去,而是重新生成commit。每个commit的SHA-1哈希由内容、父commit、author信息共同计算。你rebase后,父commit变了,所以整个commit链的SHA全部改变。
实际操作中Git做了这些事:
# 假设你在feature分支,执行了:
git rebase master
# Git内部执行步骤:
# 1. 找出feature分支上独有的commit(相对于merge-base)
git rev-list --no-walk --left-right master...feature
# 2. 把feature分支指针临时移到master上
git reset --hard master
# 3. 把第一步找到的commit逐个cherry-pick到当前位置
git cherry-pick F1
git cherry-pick F2
# 4. 如果中途有冲突,Git停下来等用户处理
git add .
git rebase --continue
这套机制意味着:每次rebase实际上是把「补丁」打到新代码上。所以rebase遇到冲突时,解决冲突后不需要git commit,直接rebase --continue就行。
merge的内部原理
merge走的是三路合并(three-way merge)算法。它找到两个分支的merge-base(共同祖先),然后对比三方内容:共同祖先、当前分支(HEAD)、目标分支。
三路合并和补丁合并的差异体现在冲突处理上。假设两个分支都修改了同一个文件同一行:
# merge-base版本
username = "old"
# HEAD(当前分支)
username = "alice"
# 目标分支
username = "bob"
Git无法自动判断哪个对,于是产生冲突。Git用冲突标记把两个版本都留在文件里:
<<<<<<< HEAD
username = "alice"
=======
username = "bob"
>>>>>>> feature-b
merge的方式是把冲突一次性暴露出来(所有冲突一起出现在merge commit里)。rebase是逐个commit处理冲突。这意味着rebase解决冲突时要面对多个commit的上下文,而merge只需要解决一次。
三种工作流最佳实践(附完整配置)
工作流一:个人开发 + 线性历史(rebase)
适合场景:你自己在feature分支上开发,还没Push到远端,或者Push了但明确知道只有你一个人在这个分支上干。
流程:
# 1. 开分支时就定好用rebase同步主分支
git checkout -b feature/new-login
# 2. 日常开发提交(不要怕commit多,rebase时可以整理)
git add .
git commit -m "feat: add login form"
git add .
git commit -m "feat: add login validation"
# 3. 每天开始干活前,把master的最新提交拉到feature分支上
git fetch origin
git rebase origin/master
# 有冲突就解决,解决完 git rebase --continue
# 4. 开发完成后,压扁成有意义的提交
git rebase -i HEAD~3
# 把多个小commit合并成一个
# 5. 推送feature分支
git push origin feature/new-login
优势:你的feature分支永远是一条直线,合回master时不会产生merge commit。历史干净,方便code review。
工作流二:多人协作 + 保留合并上下文(merge --no-ff)
适合场景:多人一起往同一个分支(如develop)合并代码。
# 1. 关闭fast-forward合并,必须显式生成merge commit
git config --global merge.ff false
# 2. 从develop拉出feature分支
git checkout -b feature/payment
git push origin feature/payment
# 3. 另外两个同学也往这个feature分支上推代码
# 此时rebase已经不可行,因为会重写远端已推送的commit
# 4. 把feature合并回develop(保留merge commit)
git checkout develop
git pull origin develop
git merge --no-ff feature/payment
# 生成一个merge commit,记录了分支合并时间和来源
# 5. 推送到远端
git push origin develop
merge --no-ff和merge的区别:merge默认如果目标分支没有新提交,会fast-forward,直接把指针挪过去,不生成merge commit。加--no-ff强制生成merge commit,保留分支信息。
工作流三:rebase合入主干(推荐主流团队的方案)
这是GitHub和GitLab的Merge Request / Pull Request默认推荐的方式。feature分支在合并前先rebase到最新master,然后以fast-forward方式合入:
# 1. 在feature分支上同步master最新代码
git fetch origin
git rebase origin/master
# 解决冲突...
# 2. 合入master(fast-forward,不会产生merge commit)
git checkout master
git pull origin master
git merge --ff-only feature/new-login
# 3. 推送
git push origin master
这个方案保证了master永远是一条直线,同时feature分支的commit完整保留,不丢失开发上下文。
实际效果数据:rebase vs merge的差异
我在一个模拟真实场景的仓库上做了测试。仓库情况:master分支2000个commit。feature分支从第1000个commit处拉出,包含50个commit。master在feature分支拉出后又新增了40个commit。这40个commit中有8个涉及feature分支修改过的文件。
| 操作 | 耗时(秒) | 冲突文件数 | 最终历史commit数 | 历史图复杂度 |
|---|---|---|---|---|
| git merge --no-ff feature | 1.2 | 12 | 2091(+1 merge commit) | 有分叉,graph复杂 |
| git rebase master | 3.8 | 8(分3批出现,每批2-4个) | 2090(+0 merge commit) | 线性,log一眼到底 |
结论:merge快3倍,但history graph乱。rebase慢但历史干净。在有冲突的场景下,rebase消耗的时间是merge的3倍(3.8秒vs1.2秒),因为rebase需要把50个commit逐个重新应用到新base上。但代码review效率上,rebase模式比merge快了不止2倍——因为review线性历史时,每个commit的diff都很小。
再看bisect的效果:我用git bisect在一个merge历史和一个rebase历史的仓库中各找了一个bug。merge历史因为merge commit干扰,bisect跑完了40步。rebase历史只跑了32步,少了20%的步数。
完整的.gitconfig工作流配置
这是我在团队内部推广的配置,放到~/.gitconfig里直接生效:
[user]
name = Your Name
email = your@email.com
[alias]
# 查看漂亮的历史图
lg = log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --max-count=30
# 查看rebase待办清单
rb = rebase -i
# 清理已合入的分支
cleanup = "!git branch --merged | grep -v '\\*' | xargs -n 1 git branch -d"
[merge]
# 关闭fast-forward,强制merge commit(工作流二需要)
ff = false
# 直白一点的conflict样式
conflictStyle = diff3
#[rebase]
# # 如果设置成true,rebase和pull --rebase时,Git会重用已记录的冲突解决方案
# autostash = true
[core]
# 保留大小写(macOS/Windows上的用户容易踩的坑)
ignorecase = false
# 解决中文文件名乱码
quotepath = false
[pull]
# 默认用rebase拉取(多人协作时不建议开)
# rebase = true
注意[merge]的ff=false是全局生效的。如果你在个人项目上想用fast-forward,在项目目录里临时覆盖:
git config --local merge.ff true
什么时候必须不用rebase
有人会问「rebase这么好,为什么不是所有场景都用?」答案是:一旦commit被推送到共享仓库,rebase就会造成灾难。
场景还原:
# 你和同事小明都在feature/payment分支上开发
# 你本地有2个提交:A, B(基于C5)
# 小明推了一个提交:D(也是基于C5)
git push origin feature/payment # 你推了 A 和 B
# 小明继续开发,又提交了一个 E
git pull origin feature/payment # 小明拉到了A和B,然后推送了E
# 此时远端历史:C5---D---A---B---E
# 你在本地做了rebase到master,把你的A和B重写成了A'和B'
git rebase master
# 你本地历史变成:master最新---A'---B'
# 你force push了
git push --force-with-lease origin feature/payment
# 远端历史变成:master最新---A'---B'
# 小明拉取时直接报错:因为他的历史包含了旧的A和B,而远端已经把它们替换成了A'和B'
这个场景我亲历过。结果是:小明的E提交硬生生丢了,我花了2小时从reflog里恢复。不要觉得这是新手才会犯的错——我们团队有8年经验的工程师也翻过车。
所以铁律:push到共享分支后,禁止rebase。除非你确认全世界只有你在用这个分支,且你准备接受可能的代码丢失。
避坑指南(都是真金白银踩出来的)
坑1:刚rebase完发现把别人的commit搞丢了
有一次我在feature分支rebase master时,发现有个同事直接在这个分支上提交过代码(我选的基线旧了)。rebase把基线后的所有commit都重放了,同事的commit因为是「别人提交的」,Git在rebase时默认跳过了它。
# 恢复方法(如果你已经force push了)
git reflog
# 找到rebase之前的commit哈希,比如 abc1234
git checkout -b recovery-branch abc1234
# 确认代码没问题后,git cherry-pick 丢失的提交
现在我在rebase之前固定跑一遍:
# 检查是否有其他人在这个分支上的提交
git log origin/feature/xxx..feature/xxx --oneline --author="^((?!你的名字).)*$" --perl-regexp
坑2:rebase过程中切换分支导致工作区丢失
rebase到一半发现冲突,手滑执行了git checkout别的分支,Git会拒绝切走,但有例外:如果rebase期间文件冲突被标记但还没add,Git允许你checkout。结果是rebase中止,你在冲突中解决的代码全部丢失。
解决:rebase冲突时永远用:
git rebase --abort # 彻底放弃本次rebase
# 或者
git rebase --continue # 解决完继续
如果已经checkout走了,回到rebase的状态:
git reflog | grep rebase
# 找到rebase开始前的HEAD位置
坑3:处理大仓库时rebase超时
我们公司有个单体仓库有9万多个文件、超过20GB。在这种仓库上rebase 3000个commit,一次要跑1个多小时。中途断网就全废。
解法:
# 用--rebase-merges解决包含merge commit的rebase
git rebase --rebase-merges master
# 关掉不必要的计算
git config --global rebase.missingCommitsCheck ignore
# 明确指定用哪个committer
git config --global rebase.instructionFormat "%h %s"
还有,如果仓库过大,建议考虑用shallow clone:
git clone --depth=50 git@gitlab.com:company/big-repo.git
坑4:rebase后push被拒,手滑force push掉了同事的提交
不要用git push --force,用--force-with-lease:
# --force 直接覆盖远端,无任何检查
# --force-with-lease 只有在远端没被别人更新过时才会推送成功
git push --force-with-lease origin feature/xxx
如果已经用--force覆盖了,唯一的路是从reflog中恢复:
git reflog
# 找到之前的commit,push一个新的分支上去
坑5:中文commit message在rebase时乱码
如果你的commit message是中文,rebase -i的编辑器里可能显示乱码。原因是Git默认把message当作ASCII处理。解决:
git config --global core.quotepath false
git config --global i18n.commitEncoding utf-8
git config --global i18n.logOutputEncoding utf-8
图形化工具的陷阱
我用过3个主流的Git GUI工具:SourceTree、GitKraken、VS Code Git Graph插件。它们显示rebase的方式各不相同。SourceTree的rebase交互最接近命令行,GitKraken的交互最好看但容易误操作(点击类冲突提示会直接帮你执行--continue),VS Code的图形最直观但rebase功能不全。
我的建议:日常用GUI看历史,rebase操作永远用命令行。图形化工具rebase时的提示往往不完整,容易让你忽略conflict文件的具体内容。
rebase -i的进阶用法
rebase -i(交互式rebase)不只是重排commit,它还能帮你整理提交历史。这是我在代码review前必做的步骤:
# 把最近10个commit压扁成逻辑完整的3个
git rebase -i HEAD~10
# 在打开的编辑器中:
# pick abc1231 feat: add user module
# squash def4562 fix: typo in user module
# squash 7890123 test: add user tests
# reword 456789a feat: add admin module
# ...
pick保留commit,squash把当前commit合并到上一个,reword修改message。rebase -i最让我受益的是能构建出「每个commit都可通过CI」的干净历史,这在后续bisect时省太多时间。
有个坑:rebase -i时Git会对每个commit跑hook,如果你的CI hook里做了静态检查或者测试,rebase会变得非常慢。我遇到过100个commit的rebase运行了40分钟,全耗在每次重放时跑PHPStan上了。解法:加--no-verify参数跳过hook。
git rebase --no-verify -i HEAD~10
rebase和merge的取舍决策树
这里给出一个决策树,你在任何场景下遇到选择困难,按这个判断就行:
- 这个分支是否已推送到远端且别人能访问?是 → 用merge --no-ff,不rebase
- 这个分支是否只有你自己在用?是 → 用rebase,保持线性历史
- 你是要从主干同步代码到自己的工作分支吗?是 → 用rebase(本地未推送)或merge --no-ff(已推送)
- 你要把工作分支合入主干吗?多人协作共用这个分支 → merge --no-ff;个人独有 → 先rebase再fast-forward merge
- 主干分支(master/main)的提交历史是否要求完全线性?是 → 在合并前统一用rebase,禁merge commit
- 团队里是否有工程师对rebase不熟练,经常在rebase时丢代码?是 → 全员用merge --no-ff
这些规则我用了两年,团队冲突从月均10+次降到月均3次以下。
最后是这三年我总结的最重要的一条
merge和rebase不是对立的,它们是同一个问题的两种解法。真正的工程问题不是「哪个好」,而是「在哪个环节用哪个」。我用rebase在功能分支上保持自己的开发历史整洁,用merge --no-ff把feature并入develop时保留merge上下文。各司其职,不用纠结。
另外提一句,如果你团队用GitLab,Merge Request里提供了「Squash commits」选项。这个选项的本质是merge前自动把所有feature commit压扁成一个。效果类似rebase -i + fast-forward merge。如果不想用rebase,但又想要干净的历史,可以用这个。但缺点是single commit丢了中间步骤的上下文——权衡后再开关。
避坑总结
| 坑 | 风险等级 | 一句话解法 |
|---|---|---|
| 共享分支rebase | ★★★★★ | push后绝不rebase,除非整个世界只有你 |
| force push 覆盖远端 | ★★★★★ | 用 --force-with-lease 代替 --force |
| rebase冲突时checkout | ★★★★ | 冲突后只允许 --abort 或 --continue |
| 大仓库rebase卡死 | ★★★ | 用 --rebase-merges 和 shallow clone |
| rebase丢同事提交 | ★★★★ | rebase前检查分支上是否有他人的提交 |
| merge --no-ff后历史太乱 | ★★ | 配套开pull request,在PR里看diff,不依赖graph |
这六个坑我都踩过,写出来希望你能跳过去。