← 返回博客

不再害怕交互式 rebase:实用指南

tutorial git

Git 中最被回避的工具

问一屋子开发者哪个 Git 命令让他们紧张,git rebase -i 一定名列前茅。交互式 rebase 名声在外:它会重写历史,会在编辑器里打开一个陌生的 todo list,而且感觉很容易把东西搞坏。于是大多数人干脆避开它,结果他们的 pull request 带着 "wip"、"fix typo"、"actually fix it" 这样的 commit 就提交上去了。

这很可惜,因为交互式 rebase 是把杂乱的 branch 变成干净、易于审查的历史的最佳工具。在本指南中,我们将通过一个贯穿全文的具体例子来揭开它的神秘面纱:一个有 9 个杂乱 commit 的功能 branch,我们会在打开 pull request 之前把它重塑成 3 个干净的 commit。如果你还在纠结 rebase 是否适合你的工作流程,先从我们的 rebase 与 merge 对比开始读起。本文假设你已经决定使用 rebase,并希望把它用好。

为什么干净的历史很重要

在 pull request 之前整理 commit 不是为了美观。整洁的历史会在三个非常实际的方面带来回报:

  • 可审查性。审查者阅读三个逻辑清晰的 commit:"Add login form with validation"、"Add remember me option"、"Add error messages",可以独立审查每个改动。同样的代码分散在九个半成品 commit 中,就会迫使他们一次性审查整个 diff,审查质量随之下降。
  • git bisect。当几周后出现 bug 时,git bisect 会遍历历史来找出引入它的那个 commit。这只有在每个 commit 都能独立构建且自成体系时才有效。充满 "wip" commit、甚至无法编译的历史会让 bisect 几乎毫无用处。
  • 回退。如果某个功能需要撤掉,revert 一个自包含的 commit 轻而易举。要 revert 一个分散在九个交错 commit 中的功能,则是一下午的考古工作。

贯穿全文的例子:9 个杂乱的 commit

这就是我们要整理的 branch。这是一份再正常不过的工作历史,其实每个人都是这样写代码的:

$ git log --oneline main..feature/login
c9d0e1f oops forgot the css file
b8c9d0e add error messages
a7b8c9d wip 2
f6a7b8c add remember me checkbox
e5f6a7b actually fix it
d4e5f6a fix typo
c3d4e5f add form validation
b2c3d4e wip
a1b2c3d add login form skeleton

工作时这样 commit 没有任何问题。问题只在于它以这个样子被交付出去。我们的目标是最终得到三个 commit:

  1. Add login form with validation
  2. Add remember me option
  3. Add error messages with styling

开始 rebase:git rebase -i HEAD~9

要重写最近的 9 个 commit,运行:

git rebase -i HEAD~9

HEAD~9 的意思是"当前 commit 往前数 9 步的那个 commit",在那个点之后的所有内容都可以编辑。Git 会在编辑器中打开 todo list

pick a1b2c3d add login form skeleton
pick b2c3d4e wip
pick c3d4e5f add form validation
pick d4e5f6a fix typo
pick e5f6a7b actually fix it
pick f6a7b8c add remember me checkbox
pick a7b8c9d wip 2
pick b8c9d0e add error messages
pick c9d0e1f oops forgot the css file

在动手之前要先理解两件事。第一,这只是一个文本文件:在你保存并关闭它之前什么都不会发生,如果你删掉所有行,或者没有保存有意义的改动就关闭,rebase 会被无害地取消。第二,顺序是最旧的在最上面,与 git log 正好相反。Git 会从上到下重放这些 commit,应用每行开头的动词。

六个动词

pick

原样保留这个 commit。这是每一行的默认值。

reword

保留 commit 的改动,但停下来让你编辑 commit 消息。非常适合在不碰代码的情况下修正 "wip 2"。

edit

在这个 commit 处暂停 rebase。你可以 amend 它、把它拆分成多个 commit,或者测试它,然后用 git rebase --continue 继续。这是最高级的动词,也是你用得最少的一个。

squash

把这个 commit 合并到它上面的那个 commit 中,并打开编辑器让你把两条 commit 消息合并成一条。

fixup

和 squash 类似,但完全丢弃这个 commit 的消息。上面的 commit 保持其消息不变。这正是 "fix typo" 这类 commit 该用的:它们的消息不包含任何值得保留的信息。

drop

彻底删除这个 commit 及其改动。从 todo list 中删掉这一行效果相同,但写上 drop 能让你的意图更明确。

一步步整理这个 Branch

现在把这些应用到我们的 9 个 commit 上。需要两种操作:重新排序修改动词

先说重新排序。查看 diff 后发现,"oops forgot the css file" 实际上是 remember me 复选框的样式表,而不是错误消息的。在 todo list 中重新排序就是移动一行:剪切 c9d0e1f 那一行,粘贴到 remember me 组的正后方。Git 会按新顺序重放这些 commit。

然后是动词。直到 "actually fix it" 为止的所有内容都属于登录表单,所以它们都折叠进第一个 commit。我们对 "add form validation" 使用 squash,因为我们想把它的消息合并到最终消息中;而对那些消息理应消失的噪音 commit 使用 fixup。编辑后的 todo list 如下:

pick a1b2c3d add login form skeleton
fixup b2c3d4e wip
squash c3d4e5f add form validation
fixup d4e5f6a fix typo
fixup e5f6a7b actually fix it
pick f6a7b8c add remember me checkbox
fixup a7b8c9d wip 2
fixup c9d0e1f oops forgot the css file
reword b8c9d0e add error messages

保存并关闭。Git 重放这些 commit,并停下来两次:一次让你为第一组写合并后的消息(输入 "Add login form with validation"),另一次是为了 reword(输入 "Add error messages with styling")。对于 remember me 那个 commit,快速 reword 一下也可以,这里我们保留了 pick,因为它原来的消息已经够体面了。结果如下:

$ git log --oneline main..feature/login
3f4e5d6 Add error messages with styling
2e3d4c5 Add remember me option
1d2c3b4 Add login form with validation

九个 commit 变成了三个,每个都自包含且易于审查。注意所有的哈希都变了:这些是全新的 commit。请记住这个细节,它正是下面黄金法则的核心。

高手的做法:--autosquash

等你熟练之后,可以边工作边准备好整理工作,而不是留到最后再收拾。当你修复了某个早先 commit 中的问题时,这样记录它:

git commit --fixup a1b2c3d

Git 会创建一个消息为 fixup! add login form skeleton 的 commit。之后运行:

git rebase -i --autosquash main

Git 会预先填好 todo list,每个 fixup! commit 都已被移到其目标下方并标记为 fixup。你只需保存并关闭。要把这设为默认行为,配置一次即可:

git config --global rebase.autosquash true

黄金法则:永远不要 rebase 已共享的 commit

rebase 并不会修改 commit,而是用新的 commit 替换它们。如果队友已经 pull 了旧的 commit 并在其基础上开展了工作,重写这些 commit 就会产生两条分叉的历史,下一次 merge 就会变成一团重复和冲突的烂摊子。

规则很简单:只 rebase 尚未 push 的 commit,或者只在你一个人工作的 branch 上的 commit。在打开 pull request 之前整理自己的功能 branch,是教科书级别的安全场景。如果你已经 push 了这个 branch 但你仍然是它唯一的作者,用 git push --force-with-lease 推送重写后的历史,它会拒绝覆盖其间别人 push 的工作。永远不要重写 main 或任何团队会从中 pull 的 branch。

当事情出错时

rebase 进行到一半时,可能发生两种情况。如果某个被重放的 commit 与较早的改动冲突,Git 会暂停并要求你解决冲突,然后 git add 这些文件并运行 git rebase --continue。而如果在任何时候你感到迷失了方向,还有一个彻底的逃生按钮:

git rebase --abort

它会把 branch 恢复到你开始之前的确切状态,毫不犹豫。只要你记得 --abort 的存在,进行中的 rebase 就永远困不住你。

如果 rebase 已经完成,你才发现结果是错的呢?即便如此,也没有任何东西丢失。旧的 commit 仍然存在:Git 会把它们保留数周,而 reflog 记录了你的 branch 曾经指向过的每一个位置,所以你可以用一次 reset 回到 rebase 之前的状态。我们在用 git reflog 恢复丢失的 commit 中详细介绍了这道安全网。如果你的失误更小,只是最后一个 commit 需要修改,那你根本不需要 rebase:请参阅如何撤销 Git commit

GitSquid 中的交互式 Rebase

todo list 很强大,但在文本文件里编辑动词正是让人紧张的那个环节。GitSquid 用可视化的交互式 rebase 取而代之:你通过 drag & drop 重新排序 commit,并从下拉选择器中为每个 commit 选择操作(pick、squash、fixup、reword 或 drop)。整个计划在执行任何操作之前一目了然,这消除了大部分的恐惧感。

GitSquid 甚至在你开始之前就能帮上忙:免费版就包含的 Conflict Predictor 会在你启动 rebase 之前显示哪些文件会发生冲突,让你提前知道自己要面对什么,而不是做到一半才发现冲突。rebase 可以直接从 commit 图上的 branch 和 commit 右键菜单中使用。

快速参考

命令 / 动词 作用
git rebase -i HEAD~n 交互式重写最近的 n 个 commit
pick 原样保留 commit
reword 保留改动,编辑消息
edit 在此 commit 处暂停以 amend 或拆分
squash 合并到前一个 commit,合并消息
fixup 合并到前一个 commit,丢弃此消息
drop 彻底删除 commit
git commit --fixup <hash> 记录一个针对早先 commit 的修复
git rebase -i --autosquash 自动把 fixup! commit 放入 todo list
git rebase --continue 解决冲突后继续
git rebase --abort 取消并恢复到 rebase 之前的状态

交互式 rebase 会用强大的能力回报你的一点点练习。先在一个可以随便丢弃的 branch 上练手,记住 --abort 的存在,遵守黄金法则,把 "wip, fix typo, actually fix it" 变成干净的历史就会成为日常。如果你更愿意直观地看到整个计划而不是编辑文本文件,免费下载 GitSquid,在你的下一个功能 branch 上试试可视化 rebase。