Git-Rebase
Git
是最常用的VCS
(version control
system,版本控制系统)工具。在Git
中,多人协作开发涉及到多分支,而分支管理则必然会涉及新建分支
和分支合并
。相对于新建分支
,分支合并
复杂得多,是问题的高发地段。合并分支
通常有两种方式,merge
和rebase
。其中merge
是通过新建一个特殊的提交(本文不加区分地使用提交/节点),这个提交指向了两个节点(常规的提交只有一个父节点),这两个节点分别来自不同的分支。

如果合并时发生冲突,那么合并提交
还会包含了冲突解决的信息。在IDEA
中查看提交的文件改动,会发现相较于常规的两个窗口(一个显示提交前的版本,一个显示提交后的版本),合并提交
的改动有三个窗口,因为提交前的版本有两个,来自不同分支。
rebase
相对于merge
,rebase
就要复杂得多。一个可能的原因是rebase
会改动提交历史,并且没有交换律。
通俗来讲,rebase
的工作是找到两个分支的共同的祖先节点,并统计自共同的祖先节点开始的所有提交,在另一条分支上执行一次。此处借用《Git
Pro》中的图片。


图中,找到experiment
分支的C4
节点,和master
分支的C3
节点的共同祖先,也就是C2
节点,并将自C2
开始的所有发生在一侧的提交,在另一侧重现(图中只有一个C4
,如果有更多都会重现)。并将experiment
的指针指向C4'
。这里就涉及到谁rebase
到谁上的问题。体现在图中就是到底是新的C4'
在C3
后,还是新的C3'
在C4
后。
在官方文档中,git rebase
指令可以有很多变体。例如git rebase A
、git rebase A B
、git rebase --onto A C B
。其中A、B、C表示分支的索引或提交。指定A、B、C本质就是在解决谁rebase
到谁上的问题。
在merge
时,合并时两个分支的地位是均等的。在A
分支输入git merge B
和在B
分支输入git merge A
,最后的结果几乎是一致的。细微的区别是合并后指向合并提交
的分支是A
或B
,但在切换到另一个分支再merge
一次后(fast-forward
),状态就完全一致。merge
的这种两分支互换对合并后结果几乎没影响,我称之为交换律。
相较于merge
,rebase
不遵循交换律。把A
放在B
上和把B
放在A
上,这显然是两个不同的状态。由于指令的复杂,以及A、B、C互相不可交换,地位不均等,导致rebase
比merge
更容易犯错。
回到上面提到的三个指令git rebase A
、git rebase A B
、git rebase --onto A C B
。其中A
表示新基
,B
表示被rebase
的分支,C
表示从C
开始计算到B
的改动。上述指令的含义表示:从C
开始统计,统计到B
分支指向的节点做的所有改动,将这些改动在A
上重现一次,并将B
的引用指向重现完后的状态。如果B
省略,则B
默认为当前所在分支。如果C
省略,则默认为A
和B
的最近的祖先节点。
常规实践
在实际开发中,通常有一个共享的主分支,例如master
分支。开发人员从master
分支上某个节点建立新分支feature
,并在此上开发。相对于共享的master
分支,feature
通常是不共享的。master
被多人共同使用,改动会造成较大影响,因此主分支上的提交历史通常不会修改。而feature
分支历史的修改只影响个人。因此在涉及谁rebase
到谁上的问题时,通常会将feature
分支rebase
到master
上。因为rebase
操作会修改被rebase
的分支的提交历史,对应上图中原本的C4
节点将无法通过新指针找到,提交历史从C2
->C4
,变成了C2
->C3
->C4'
。
为了将feature
分支rebase
到master
上,当然可以使用git rebase A B
指令。但通常在GUI操作中,会先切换到feature
,在进行rebase
操作,相当于执行了git rebase A
,把B
和C
都省略了。这里有一个助记的方法,做的所有操作,都是在当前所在分支进行修改。根据这个助记,就能分辨签出并变基到A(Checkout and Rebase onto Current)
,和将A变基到B(Rebase Current onto Selected)
这两个操作的实际行为了。

在集中式的VCS
中,例如SVN
,开发人员在提交代码前,需要先拉取,这个行为就像是rebase
。因为开发是基于主分支某个历史状态,而主分支会在开发过程中产生新的提交。在开发完成后,就需要把从开发基于的节点(共同的祖先节点),到主分支的最新状态中的修改,重新包括进来。结果就好像是在最新状态进行的开发,而不是某个历史状态。如果将修改引入的过程中遇到了冲突,那还涉及冲突解决。在Git
中使用rebase
而不是merge
,会产生像集中式VCS
简单的线性历史记录,使得整个开发历史更直观。
rebase的撤销
如果在rebase
时发生了冲突,则会在新的提交中包含冲突解决的信息。如上图中的C4
和C3
发生了冲突,那么需要在新的C4'
中解决冲突。冲突解决的结果是C4
相对于C2
做的修改,将不同于C4'
相对于C3
做的修改。
由于提交历史的改动,导致rebase
的撤销要麻烦一些。rebase
前experiment
处于C4
节点,rebase
后处于C4'
节点。撤销rebase
应该将experiment
指针重新指向C4
,通常可以使用git reset --hard
指令。但此时C4
已不被任何分支跟踪,所以在IDEA
的分支可视化界面中,找不到指向C4
的指针,也找不到C4
的哈希码。此处提供两个解决方案:
rebase
前,在C4
状态建立新分支backup
- 使用
git reflog
指令,查看指针的历史信息,在其中寻找C4
的哈希码
而在merge
中,撤销就简单的多。因为合并提交
指向被合并前的状态,可以在GUI中选取。
IDEA中的rebase使用
在开发完后,需要将代码rebase到主分支中。此时需要位于开发的feature
分支,在分支界面中选取远程的maseter
分支,选本地的可能会缺少最新的提交。当然也可以pull
一下本地的maseter
分支,再rebase
到其上,但这样不优雅。选取后出现菜单如下。

其中有两个rebase
相关的选项,将A变基到B(Rebase Current onto Selected)
和使用变基拉入A(Pull into Current Using Rebase)
。那么这两个选项有什么区别,官方文档。
- Rebase Current onto Selected (for both remote and local branches) to rebase the branch that is currently checked out on top of the selected.
- Pull into Current Using Rebase (for remote branches) to fetch changes from the selected branch and rebase the current branch on top of these changes.
仔细一看好像没有什么区别。执行每个选项后,可以在Git
选项卡的控制台中观察到实际执行的git
指令,这两选项唯一的区别就是后者在rebase前会执行一次fetch,确保rebase时远程分支是最新状态。因此在实际开发中更建议使用后者。
与cherry-pick对比
如果使用了git rebase --onto A C B
形式的指令,则其行为与cherry-pick
很像。cherry-pick
可以把某个或某些提交,在其他分支上进行重现。如果指定rebase
指令中C
和B
是同一个,那行为就等同于选了一个提交的cherry-pick
。
当然还是存在许多区别。简单的区别是git rebase
指令中的B
必须是分支
,而cherry-pick
的参数是提交
。rebase
会选取B
和C
之间连续的所有提交,而cherry-pick
可以只选择其中一部分,且不要求连续。
总结
合并分支常用两种方式merge
和rebase
。
相较于merge
,rebase
的优势在于不会新增合并提交
,并且会使得整个提交历史
更加简洁。但缺点是会修改提交历史
导致撤销麻烦,并且指令相对merge更为复杂,不符合交换律。
想要撤销rebase恢复到之前的状态,由于修改了提交历史,需要通过git reflog
指令找到修改前状态的哈希值,再通过git reset
恢复。