作者:Julia Evans · 原文发表于 2023 年 11 月 6 日
原文:git rebase: what can go wrong?。本文为授权中文译写,保留作者第一人称、个人经验和不确定性;明确标注的“编者注”为技术边界补充。文中命令和输出均来自文章讨论,未在本次编辑中执行。
你好!最近和大家聊 Git 时,我反复听到一种说法:“我讨厌 rebase。”人们对此似乎有很强烈的感受,这让我很惊讶,因为我一直在用 rebase,却很少遇到什么问题。
我的经验是:如果很多人都非常坚决地持有与我不同的看法,通常是因为他们在这件事上的经历与我不同。
于是,我在 Mastodon 上问:
今天我在想使用
git rebase的取舍。我觉得 rebase 的目标是获得漂亮的线性提交历史,而我很喜欢这样的历史。但使用 rebase 有什么代价?它在实际工作中给你造成过什么问题?我真正想听的是你亲身遇到的具体糟糕经历,而不是观点,也不是“重写历史不好”这类笼统说法。
我收到了大量精彩的回答,下面会尽力把它们整理出来。如果我知道某个问题的解决办法或变通方式,也会一并提到。问题涉及反复解决冲突、大批提交、撤销变基、共享分支上的强制推送、代码审查、元数据丢失、回退、中间提交损坏、弄混继续变基与修改提交、拆分提交、复杂变基,以及长期分支。最后还会谈谈提交纪律和一种使用 squash and merge 的工作流。
我的目的不是说服大家相信 rebase 很糟糕、应该放弃使用它——我自己肯定还会继续用!但看到这么多问题后,我觉得:如果没有先说明怎样安全使用,就向新手推荐 rebase,应当更加谨慎。我也开始想,是否有更简单、更不容易误操作的办法来整理提交历史。
先说明我假设的 Git 工作流
首先,我知道大家使用 Git 的方式很多。本文讨论的是我在团队里熟悉的工作流:
- 团队通过一个中心化的 GitHub 或 GitLab 仓库协作。
- 有一个中心分支
main,并且禁止向它强制推送。 - 大家在功能分支上写代码,然后向
main发起 pull request。 - 每合并一个 pull request,就从
main部署 Web 服务。 - 修改
main的唯一途径,是在 GitHub 或 GitLab 上发起并合并 pull request。
这当然不是唯一“正确”的 Git 工作流。它很符合“我们运行着一个 Web 服务”的团队;开源项目或按版本发布的桌面软件,工作方式通常会略有不同。但这是我熟悉的方式,所以我会以此为背景。
两种不同的 rebase
开始之前,还有一个重要区别:大家反复提到的其实是两种 rebase,只有其中一种通常需要处理合并冲突。
- 以当前提交的某个祖先为基点,例如用
git rebase -i HEAD^^^^^^^把许多小提交压成一个提交。只要做的仅仅是压缩这些提交,就不需要解决合并冲突。 - 把提交变基到一个已经发生分叉的分支上,例如
git rebase main。这可能产生合并冲突。
我觉得区分这两种情况很有用:有时我想到的是第一种 rebase,它比较不容易出问题;而那些正在为 rebase 苦恼的人,想到的可能是第二种。
编者注:第一种情况的讨论以原有线性提交序列、顺序不变且只做 squash 为前提;后面涉及重排、改写内容和已有合并提交的情况,不应套用“不会产生冲突”这句话。

现在来逐个看看这些问题。
反复解决同一个冲突,很烦
如果你做了许多很小的提交,有时就会陷入噩梦般的循环:同一个合并冲突得解决 10 次。你还可能白费力气地处理某些冲突,例如在一段代码上费劲解决冲突,后面的一个提交却会把那段代码删掉。
有几种办法能改善这种情况:
- 先用
git rebase -i HEAD^^^^^^^^^^^把所有小提交压成一个大提交,再用git rebase main变基到另一个分支。这样,冲突只需要处理一轮。 - 用
git rerere自动复用相同冲突的解决结果。“rerere”是“reuse recorded resolution”的缩写,也就是复用已经记录的解决方案。它会记下你之前如何解决冲突,并在之后重用。我自己没试过,不过我想,设置git config rerere.enabled true后,它就会自动帮忙。
如果我发现自己在同一次 rebase 中需要不止一次地解决合并冲突,通常会运行 git rebase --abort 停下来,把提交压成一个,再试一次。
一次变基很多提交,很难
我把提交变基到另一个分支时,通常只处理 1~2 个提交。有时可能是 5 个!一般没有冲突,也能顺利完成。
有些人描述的情况则是:把很多不同作者写下的几百个提交变基到另一个分支上。听起来真的很难,我一点也不羡慕这种任务。
撤销一次 rebase,很难
好几个人告诉我,他们刚开始使用 rebase 时曾经把它搞砸,永久丢失了一周的工作,只能重新做一遍。
这里的问题是:撤销一次出错的 rebase,比撤销一次出错的 merge 复杂得多。比如,出错的 merge 有时可以用类似 git reset --hard HEAD^ 的命令撤销。许多刚接触 rebase 的人甚至不知道,变基也可以撤销。我觉得不难理解他们为什么会这样想。
编者注:git reset --hard HEAD^ 只有在当前提交确实是那次合并、且第一父提交就是目标状态等条件满足时,才对应上述例子,不能当作通用撤销命令。所有 reset --hard 都可能丢弃未提交的已跟踪文件修改,阻挡目标文件写入的未跟踪路径也可能被移除;执行前必须确认目标提交并保存当前工作。下面是原文的恢复案例,不应在工作仓库中故意制造丢失。
话虽如此,出错的 rebase 的确可以撤销。下面是用 git reflog 恢复的例子:
- 假设你做了一次糟糕的变基。原文举例为运行
git rebase -I HEAD^^^^^,然后在交互列表中删掉 3 个提交。这里的大写-I是原文写法;交互式选项实际为小写-i,即git rebase -i HEAD^^^^^。这只是说明事故情境,不是让你照做。 - 运行只读查询命令
git reflog。原文给出了类似下面的输出。
ee244c4 (HEAD -> main) HEAD@{0}: rebase (finish): returning to refs/heads/main
ee244c4 (HEAD -> main) HEAD@{1}: rebase (pick): test
fdb8d73 HEAD@{2}: rebase (start): checkout HEAD^^^^^^^
ca7fe25 HEAD@{3}: commit: 16 bits by default
073bc72 HEAD@{4}: commit: only show tooltips on desktop
- 找到时间上紧接在
rebase (start)之前的那条记录。在这个例子中,它是ca7fe25;由于 reflog 按从新到旧排列,所以它显示在开始记录的下一行。 - 原文通过
git reset --hard ca7fe25将当前分支、索引和工作区恢复到那个提交。这个哈希仅属于原文示例,不能直接用于你的仓库。
还有两种大家提到的办法:
- 原文说,
@会指向当前分支,因此可以用git reset --hard @{1}让分支回到先前的位置。 - 另一种办法是在变基之前,用
git switch -c backup建立一个“备份分支”。这样就可以方便地回到旧提交,不必再去翻 reflog。
编者注:这里要区分两个记法:单独的 @ 是 HEAD 的简写,而 @{1} 表示当前分支 reflog 中的前一个值;它并不保证永远就是你想恢复的变基前位置,必须先核对记录。某些 shell 还要求给含花括号的修订表达式加引号。git switch -c backup 会同时创建并切换到 backup,因此应切回原功能分支后再变基,避免改写本想保存的引用;也可以只创建备份引用而不切换。若开启了 rebase.updateRefs,还需注意它可能同步更新相关分支引用。reflog 是本地恢复线索,会过期,也不能恢复从未记录进 Git 的所有修改,不能替代备份。以上补充依据 Git 修订表达式文档、switch 文档、rebase 文档及 reflog 文档。
向共享分支强制推送,可能丢失工作
有几个人提到了下面这种情况:
- 你和另一个人一起在同一个分支上工作。
- 你推送了一些改动。
- 对方把分支变基,然后运行
git push --force,也许还是不小心这么做的。 - 这时你再运行
git pull,事情就乱了:出现fatal: Need to specify how to reconcile divergent branches错误。 - 在收拾残局的过程中,你可能丢掉一些提交,尤其当参与者里有人不太熟悉 Git 时。
这比前面“撤销 rebase 很难”的情况更糟,因为丢失的提交可能分散在好几个人的仓库中。比一个人翻 reflog 更糟的,就是好几个人一起各自翻 reflog。
这种事从没发生在我身上,因为我参与多人协作的分支一直只有 main,而它一直受到保护,不能强制推送。在我的经验里,代码只有通过 pull request 才能进入 main。也就是说,我几乎从未处在可能触发这种问题的情境中。但我完全能理解它为什么会给人带来麻烦。
我知道的主要预防办法是:
- 不要对共享分支进行变基。
- 必须强制推送时,使用
--force-with-lease,检查从你上次 fetch 之后,是否又有人向该分支推送过提交。
这里的“从上次 fetch 之后”很重要。原文特别提醒:如果你紧接着运行 git fetch 和 git push --force-with-lease,就可能失去它原本提供的那层保护。
编者注:更准确地说,没有显式指定预期提交值的 --force-with-lease,会把本地远程跟踪引用作为比较依据。手动或后台 fetch 更新了这个依据后,即便你还没有审阅、整合对方的工作,检查也可能通过。这并不意味着 fetch 后所有检查都消失了,而是它不能证明你已经保留了对方的改动。原文将其表述为“完全不会保护你”,本译写在此补足具体条件;它也不能替代共享分支上的事先协调。参见 Git push 官方文档。
我也很好奇,为什么大家会在共享分支上执行 git push --force。他们给出的理由包括:
- 大家正在共同开发一个功能分支,需要把它变基到
main。他们的设想是,通过非常仔细的协商,保证这次变基不会丢掉任何东西。 - 作为开源维护者,有时需要给贡献者的分支做一次变基,修复合并冲突。
- 刚接触 Git,在网上读到一段用
git rebase和git push --force解决问题的说明,还没理解后果就照着做了。 - 平时习惯在个人分支上执行
git push --force,结果不小心把同样的操作用到了共享分支上。
强制推送会让代码审查更难
这里的情境是:
- 你在 GitHub 上提交了一个 pull request。
- 其他人留下了一些审查意见。
- 你修改代码来回应意见,又通过 rebase 整理提交,然后强制推送。
- 审查者回来后,很难看出从上次审查以来究竟改了什么,因为所有提交看起来都成了“新提交”。
避免这个问题的一种办法是:先通过新增提交回应审查意见,等 PR 获得批准后,再用 rebase 重新整理全部提交。
我觉得,有些审查者会比另一些人更介意这件事,它多少取决于个人偏好。这也可能是 GitHub 特有的问题,其他代码审查工具也许有更好的处理方式。
丢失提交元数据
如果你用 rebase 压缩提交,可能会丢失重要的提交元数据,例如 Co-Authored-By。如果你给提交做了 GPG 签名,变基后原来的签名也会丢失。
可能还有一些会丢失的元数据,是我现在没有想到的。
我没遇到过这个问题,所以也不确定该怎样避免。我感觉,给提交做 GPG 签名似乎没有以前那么流行了。
编者注:上一句是作者在 2023 年的个人观察,并非对当前签名使用率的统计。变基产生的是新提交,不能沿用旧提交的签名;这与是否可以给新提交重新签名是两回事。
回退改动变得更麻烦
有人提到,他们很看重能够轻松撤销任何一次分支合入,因为这个分支可能把某些功能弄坏。如果该分支含有多个提交,又通过 rebase 的方式合入,就得撤销多个提交,才能取消整个分支的改动。
如果使用 merge 工作流,我想,只要回退那个合并提交,就能撤销整次分支合入。
编者注:这里的“撤销整个分支”指撤销它带来的改动。实际回退合并提交时还需选择正确的主线父提交,并考虑后续再次合并的影响;原文没有提供这部分操作步骤。
rebase 可能弄坏中间提交
如果你努力维护一份非常干净的提交历史,希望每个提交都能通过测试——这很值得敬佩!——rebase 仍可能让其中一些中间提交变坏,无法通过测试,即使最终提交上的测试全部通过。
听说可以用 git rebase -x,在变基的每个步骤运行测试套件,检查测试是否仍然通过。不过,我自己从未这样做过。
编者注:-x 需要指定要执行的测试命令,并非单独写出这个选项就会自动发现测试;它会执行命令,因此测试脚本本身的文件、网络和环境副作用也需要审查。配合 autosquash 时,执行点还有额外规则。本次只核对文档,没有运行测试套件。
本该继续 rebase,却误用了修改提交
有几个人提到,解决合并冲突时,他们误把 git rebase --continue 写成了 git commit --amend。
这之所以容易混淆,是因为在 rebase 过程中编辑文件,可能有两种不同原因:
- 你在
git rebase -i中把一个提交标记为edit,目的是修改该提交。改好后需要用git commit --amend更新它。 - 你遇到了合并冲突。解决冲突后,需要运行
git rebase --continue。
这两种情况很容易弄混,因为操作时的感觉十分相似。我想,问题通常是这样发生的:
- 开始一次 rebase。
- 遇到合并冲突。
- 解决冲突,并运行
git add file.txt。 - 接着运行
git commit,因为你平时在git add之后习惯这样做。 - 但这里本来应该运行
git rebase --continue!现在却多出了一个奇怪的提交,而且提交说明、作者,或者两者都可能不对。
编者注:在第一种 edit 情形下,完成所需的 --amend 后,仍要继续变基;它并不是“amend 或 continue 永远二选一”。应根据 Git 当前暂停的原因和提示操作。
在交互式 rebase 中拆分提交,很难
rebase 的意义就在于整理提交历史,用它把提交合起来也相当容易。但如果你想把一个提交拆成两个更小的提交呢?这就没那么简单了,尤其是要拆的提交还在历史中靠前几步的位置!
虽然我觉得自己已经很熟悉 rebase,其实也不太清楚该怎么做。我大概会先执行类似 git reset HEAD^^^ 的命令,再用 git add -p 从头重新组织全部提交。
有一位读者分享了使用 rebase 拆分提交的工作流。
编者注:上面的 reset 没有 --hard,默认行为与硬重置不同,但仍会移动当前分支并重置索引;它不是任意仓库都可照抄的拆分步骤。本文保留作者当时的个人做法,没有把外链扩写成一套未经验证的操作教程。
复杂的 rebase,很难
如果你想在一次 git rebase -i 中做太多事情——既重排提交,又合并提交,还修改某个提交——局面就可能变得很难理解。
为了避免这种情况,我更喜欢一次 rebase 只做一件事。如果想做两种不同的整理,就分两次 rebase 来完成。
长期分支反复 rebase,会很烦
如果一个分支持续存在很久,比如一个月,不断重新变基会令人痛苦。也许等到最后只做一次 merge、只处理一轮冲突,会更简单。
理想当然是根本不保留长期分支,从而绕开这个问题。但现实并不总能如愿。
其他问题
还有几个我觉得没那么常见的问题:
- 停止 rebase 的方式不对。如果一次变基已经出了问题,却用
git reset --hard来试图中止,而不是git rebase --abort,那么在真正正确地停止变基之前,仓库的行为可能一直很奇怪。 - 与合并提交之间的意外交互。有人说:“如果为了保持某个分支的历史整洁而给工作副本做 rebase,但项目本身在使用 merge,结果可能很难看。你运行
rebase -i HEAD~4,而往前第四个提交恰好是合并提交时,交互式编辑器里可能会出现几十个提交。”另一个人的体会是:“我吃过亏,后来学会了:只要已经从其他分支合并过任何东西,就绝不再 rebase。”
编者注:这些引语是在描述读者经历,不是对所有 Git 变基模式的统一限制。包含合并提交的拓扑与简单线性历史不同,不能只按“往前几步”猜测待重写范围。
rebase 与提交纪律
我看过很多围绕 rebase 的争论。我一直在想,为什么会这样,并注意到大家对“提交纪律”的要求大致处于几个不同层次:
- 什么都可以。提交说明写成“wip”“fix”“idk”“add thing”也没关系。
- 发起 pull request 时整理成一个合理的提交。把那些杂乱的小提交压成一个,并写出像样的说明,通常就是 PR 的标题。
- 漂亮的原子提交。每项改动都拆成数量合适的提交,每个提交都有良好的说明,合在一起能讲清楚这次改动的来龙去脉。
我觉得,即使在同一家公司里,不同人也常常有不同层次的提交纪律要求,而我看过很多由此引起的争论。我个人大部分时候属于第二层。我想,有些人口中的“干净的提交历史”,可能指的是第三层。
我觉得,第一层和第二层都很容易在不使用 rebase 的情况下实现。第一层什么也不用做;第二层则可以在 GitHub 上点击“squash and merge”,或者在命令行中运行 git switch main; git merge --squash mybranch。
但要达到第三层,你就需要 rebase,或者 GitUp 之类的其他工具,帮助你组织提交,把故事讲好。
我一直在想:大家争论别人“应不应该”使用 rebase 时,真正争论的会不会是,团队至少应该要求哪一层提交纪律?
我觉得,实际选择还取决于改动规模。如果大家的 pull request 本来就很小,把它压成一个提交通常无伤大雅;但如果你正在做的是一项 6000 行的改动,大概就应该拆成多个提交了。
一种 squash and merge 工作流
有几个人提到了一种不使用 rebase 的工作流:
- 照常创建提交。
- 定期执行
git merge main,把main合并进自己的分支,需要时解决冲突。 - 工作完成后,使用 GitHub 的“squash and merge”,把全部改动压成一个提交。原文把它类比为命令行中的
git checkout main; git merge --squash mybranch。这样,最终合入主分支的历史中就没有那些“不好看”的合并提交。
编者注:以上两处命令行类比都省略了最后的提交步骤:git merge --squash 只准备工作区和索引,不会自动创建那个单一提交,还需要审查结果并提交。这是命令行与托管平台按钮之间的关键差别;作者前面假设 main 只能通过 PR 修改,因此命令行类比也不意味着可以绕过团队的分支保护和发布流程。参见 Git merge 文档。
我最初以为,这样会把自己分支上的提交日志弄得太难看。但看来,git log main..mybranch 只会显示属于自己分支这一侧的提交,像这样:
$ git log main..mybranch
756d4af (HEAD -> mybranch) Merge branch 'main' into mybranch
20106fd Merge branch 'main' into mybranch
d7da423 some commit on my branch
85a5d7d some other commit on my branch
当然,这里的目标不是逼迫那些已经写出漂亮原子提交的人把它们全压成一个。只是为另一类情况提供一种简单选择:当历史是“add new feature; wip; wip; fix; fix; fix; fix; fix;”这种杂乱的样子时,不用 rebase 也能把它整理好。
我很想听听其他使用类似工作流的人怎么说,以及它在实际工作中是否好用。
问题比我预想的多
在开始这次讨论时,我真心觉得:“rebase 挺好的,能出什么问题?”但这里的许多问题,实际上以前也发生在我身上。只是这些年来,我学会了怎样避开它们,或者在出问题时把它们修好。
除了“绝不要向共享分支强制推送”,我几乎没见过有人系统分享使用 rebase 的最佳实践。坦白说,这些问题让我在向别人推荐 rebase 时犹豫了很多。
回顾一下,我自己遵守的 rebase 规则是:
- 如果一次变基进展不对,就用
git rebase --abort停下来,不要硬着头皮让它结束。 - 知道怎样用
git reflog撤销一次失败的变基。 - 不要把一大堆细碎提交直接拿去变基。分两步做:先类似
git rebase -i HEAD^^^^地整理,再执行git rebase main。 - 一次
git rebase -i不做超过一种整理。保持简单。 - 绝不向共享分支强制推送。
- 绝不变基已经推送到
main的提交。
感谢 Marco Rogers 鼓励我思考大家在使用 rebase 时遇到的问题,也感谢 Mastodon 上所有提供帮助的人。












暂无评论内容