git merge vs rebase:提交离开你的机器了吗?
同一棵树,两条历史——5d48940 变回 6b2be0a 的那次实测、共享分支上的警告,以及选错时用 reflog 退场的办法。
■ space · 随笔
一个问题就够:提交离开你的机器了吗?没推送过 → rebase。别人可能已经 pull 过的 → merge。
merge 记录发生过的事——一个提交、两个父提交,SHA 一个都不变。rebase 会重写:在下面的实测里,提交 5d48940 回来时变成了 6b2be0a。
rebase 跑偏了?git reflog + git reset --hard HEAD@{1} 就能把分支放回去。什么都没丢。
两条历史
两条命令落地的是同一份代码;分歧在于历史之后该怎么讲。我在 git 2.47.3 的一次性环境里各跑了一遍,每次都是同样五个提交——merge 那一轮保住了所有原始 SHA,多打了一个结;rebase 那一轮改掉了功能提交的身份。同一棵树,不同的历史:
fig——带撇号的提交是同样的改动、新的提交 ID:c3 进去时是 5d48940,出来成了 6b2be0a
验证:git log --oneline --graph → 一个 "|\\\\" 结意味着 merge,一条直线意味着 rebase 赢了
merge 什么时候变成 fast-forward
如果你开分支之后 main 一直没动,那就没东西可合并——git 只是把标签往前滑。这就是 fast-forward,也是 rebase 派不用强推就能拿到干净直线的原因:把你的功能分支 rebase 到 main 上,merge 就变成 fast-forward,图保持一条直线。实测结果——五个提交,零个结:
* 67c0442 c4 feature 2 # rebased, then merged — fast-forward
* 6b2be0a c3 feature 1 # new SHAs — 5d48940 before the rebase
* 1398836 c5 main moved on
* facd5a4 c2 main work
* f45b000 c1 base
验证:git merge --ff-only feature → 输出里出现 "Fast-forward",且不会创建 merge 提交
代价是:那些带撇号的 SHA 除了你的机器哪里都不存在。推上去之后,同事的克隆里还留着原件——“同一个”提交有了两个版本。
哪些提交还只属于你?
答案是一条命令,不是一种感觉。git log origin/main..HEAD 列出的就是 origin 从没见过的提交——这些可以 rebase。如果列表里出现别的克隆可能已有的提交——推送过的、开着 PR 的、你以前推送过又强推过的分支——那它就不再只属于你,本地图怎么说都没用。没推送的工作正是 rebase 的用武之地:把它重放到更新后的基线上,你的 PR 读起来就是一条干净的序列。这也是 git pull --rebase 对私有分支是合理默认值的原因——它把你的本地提交重放到拉下来的内容之上,而不是每次同步都织一个 merge 结。看分支,别凭感觉:先 git fetch origin,再用 git status -sb 看 ahead/behind 这一对。
共享分支上会坏什么
每个还留着旧提交的克隆,现在都和你的机器对不上现实了。rebase 给你的提交换上新 SHA;你推送过的分支和你重写过的分支不再有共同祖先,下一次推送就得用 --force——而所有 pull 过旧版本的人都要把那份“已不存在”的提交再合并一遍。这些复制品会在之后每一次 merge 里冒出来,解开要搭上一个下午。钉在具体 SHA 上的评审评论也会在同一个动作里脱落——rebase 一个开着的 PR,评审上下文就随旧提交 ID 一起蒸发。规则只有一句话,官方书里也把它当警告而非建议来写:
不要 rebase 仓库之外、别人可能已在其上展开工作的提交。——Pro Git 第 2 版《Git Branching — Rebasing》
撤销一次 rebase
旧提交在 rebase 之后还活着——reflog 就是它们躲的地方。rebase 移动的是分支指针,不删除对象。实测里那次恢复,发生在 reset --hard 扔掉两个提交之后:
$ git reflog | head -4
1398836 HEAD@{0}: reset: moving to HEAD~2
67c0442 HEAD@{1}: merge feature: Fast-forward
1398836 HEAD@{2}: checkout: moving from feature to main
67c0442 HEAD@{3}: rebase (finish): returning to refs/heads/feature
$ git reset --hard HEAD@{1}
HEAD is now at 67c0442 c4 feature 2
一行命令,分支恢复,暂存和未暂存的工作一并回来。reflog 条目默认约 90 天后过期,所以救援是有时限的。
验证:git reflog | head -3 → rebase 前的顶端就在 HEAD@{1},一次 reset 之遥
六种情况
| 情况 | 命令 | 为什么赢 |
|---|---|---|
| 你的功能分支,从没推送过 | git rebase main | 线性历史,一条干净序列方便评审 |
| 别人会从中 pull 的分支 | git merge | 不重写 SHA——任何人的克隆里都不会出现重复提交 |
| 本地 main 分叉了,但没推送过 | git pull --rebase | 同步不打结;你的提交留在最上面 |
| fork 追上游 | git merge upstream/main | PR 仍挂着它那些提交 |
| 必须立刻落地的热修 | git merge --no-ff | 修复从哪进来的可见标记,即使分支本可 fast-forward |
| rebase 跑偏了 | git reset --hard HEAD@{1} | reflog 还留着 rebase 前的顶端(约 90 天) |
那 --squash 放哪?它把一个分支的改动合成一个未提交的大块放进暂存区——没有 merge 提交,也不连着分支自己的历史。squash 是第三个答案,用于落地净改动后丢弃分支;它不重写任何东西,只是拒绝记录分支的内部步骤。那团队能不能二选一?很多都这么干:本地工作 rebase、进共享分支 merge 是最常见的策略,全仓库“一切都 rebase”也行——只要共享分支永远不被重写,这是每场工作流辩论里唯一能活下来的那句话。十秒版本,最后一次:提交离开你的机器了吗?没有 → rebase。有 → merge。万一选错的还是赢了,reflog 给你出口。
两条历史到此为止 · 什么都没 fetch · 全程本地