Git-Rebase

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

merge

如果合并时发生冲突,那么合并提交还会包含了冲突解决的信息。在IDEA中查看提交的文件改动,会发现相较于常规的两个窗口(一个显示提交前的版本,一个显示提交后的版本),合并提交的改动有三个窗口,因为提交前的版本有两个,来自不同分支。

rebase

相对于mergerebase就要复杂得多。一个可能的原因是rebase会改动提交历史,并且没有交换律。

通俗来讲,rebase的工作是找到两个分支的共同的祖先节点,并统计自共同的祖先节点开始的所有提交,在另一条分支上执行一次。此处借用《Git Pro》中的图片。

初始状态
rebase后

图中,找到experiment分支的C4节点,和master分支的C3节点的共同祖先,也就是C2节点,并将自C2开始的所有发生在一侧的提交,在另一侧重现(图中只有一个C4,如果有更多都会重现)。并将experiment的指针指向C4'这里就涉及到谁rebase到谁上的问题。体现在图中就是到底是新的C4'C3后,还是新的C3'C4后。

官方文档中,git rebase指令可以有很多变体。例如git rebase Agit rebase A Bgit rebase --onto A C B。其中A、B、C表示分支的索引或提交。指定A、B、C本质就是在解决谁rebase到谁上的问题。

merge时,合并时两个分支的地位是均等的。在A分支输入git merge B和在B分支输入git merge A,最后的结果几乎是一致的。细微的区别是合并后指向合并提交的分支是AB,但在切换到另一个分支再merge一次后(fast-forward),状态就完全一致。merge的这种两分支互换对合并后结果几乎没影响,我称之为交换律

相较于mergerebase不遵循交换律。把A放在B上和把B放在A上,这显然是两个不同的状态。由于指令的复杂,以及A、B、C互相不可交换,地位不均等,导致rebasemerge更容易犯错。

回到上面提到的三个指令git rebase Agit rebase A Bgit rebase --onto A C B。其中A表示新基B表示被rebase的分支,C表示从C开始计算到B的改动。上述指令的含义表示:C开始统计,统计到B分支指向的节点做的所有改动,将这些改动在A上重现一次,并将B的引用指向重现完后的状态。如果B省略,则B默认为当前所在分支。如果C省略,则默认为AB的最近的祖先节点。

常规实践

在实际开发中,通常有一个共享的主分支,例如master分支。开发人员从master分支上某个节点建立新分支feature,并在此上开发。相对于共享的master分支,feature通常是不共享的。master被多人共同使用,改动会造成较大影响,因此主分支上的提交历史通常不会修改。而feature分支历史的修改只影响个人。因此在涉及谁rebase到谁上的问题时,通常会将feature分支rebasemaster。因为rebase操作会修改被rebase的分支的提交历史,对应上图中原本的C4节点将无法通过新指针找到,提交历史从C2->C4,变成了C2->C3->C4'

为了将feature分支rebasemaster上,当然可以使用git rebase A B指令。但通常在GUI操作中,会先切换到feature,在进行rebase操作,相当于执行了git rebase A,把BC都省略了。这里有一个助记的方法,做的所有操作,都是在当前所在分支进行修改。根据这个助记,就能分辨签出并变基到A(Checkout and Rebase onto Current),和将A变基到B(Rebase Current onto Selected)这两个操作的实际行为了。

IDEA-rebase-本地分支

在集中式的VCS中,例如SVN,开发人员在提交代码前,需要先拉取,这个行为就像是rebase。因为开发是基于主分支某个历史状态,而主分支会在开发过程中产生新的提交。在开发完成后,就需要把从开发基于的节点(共同的祖先节点),到主分支的最新状态中的修改,重新包括进来。结果就好像是在最新状态进行的开发,而不是某个历史状态。如果将修改引入的过程中遇到了冲突,那还涉及冲突解决。在Git中使用rebase而不是merge,会产生像集中式VCS简单的线性历史记录,使得整个开发历史更直观。

rebase的撤销

如果在rebase时发生了冲突,则会在新的提交中包含冲突解决的信息。如上图中的C4C3发生了冲突,那么需要在新的C4'中解决冲突。冲突解决的结果是C4相对于C2做的修改,将不同于C4'相对于C3做的修改。

由于提交历史的改动,导致rebase的撤销要麻烦一些。rebaseexperiment处于C4节点,rebase后处于C4'节点。撤销rebase应该将experiment指针重新指向C4,通常可以使用git reset --hard指令。但此时C4已不被任何分支跟踪,所以在IDEA的分支可视化界面中,找不到指向C4的指针,也找不到C4的哈希码。此处提供两个解决方案:

  1. rebase前,在C4状态建立新分支backup
  2. 使用git reflog指令,查看指针的历史信息,在其中寻找C4的哈希码

而在merge中,撤销就简单的多。因为合并提交指向被合并前的状态,可以在GUI中选取。

IDEA中的rebase使用

在开发完后,需要将代码rebase到主分支中。此时需要位于开发的feature分支,在分支界面中选取远程maseter分支,选本地的可能会缺少最新的提交。当然也可以pull一下本地的maseter分支,再rebase到其上,但这样不优雅。选取后出现菜单如下。

IDEA-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指令中CB是同一个,那行为就等同于选了一个提交的cherry-pick

当然还是存在许多区别。简单的区别是git rebase指令中的B必须是分支,而cherry-pick的参数是提交rebase会选取BC之间连续的所有提交,而cherry-pick可以只选择其中一部分,且不要求连续。

总结

合并分支常用两种方式mergerebase

相较于mergerebase的优势在于不会新增合并提交,并且会使得整个提交历史更加简洁。但缺点是会修改提交历史导致撤销麻烦,并且指令相对merge更为复杂,不符合交换律。

想要撤销rebase恢复到之前的状态,由于修改了提交历史,需要通过git reflog指令找到修改前状态的哈希值,再通过git reset恢复。


Git-Rebase
http://dracoyus.github.io/2023/06/19/Git-Rebase/
作者
DracoYu
发布于
2023年6月19日
许可协议