什么是变基?

在 Git 中整合来自不同分支的修改主要有两种方法:merge 以及 rebase

回顾merge案例

考虑如下的例子:

开发任务分叉到两个不同分支,又各自提交了更新:

image-20240327102927640

1
2
3
4
5
6
7
8
 MINGW64 /d/tmp/git-play-merge (master)
$ git log --all --oneline --graph
* 1872aaf (experiment) 新增D.java [C4-experiment]
| * b1d77e8 (HEAD -> master) 新增C.java [c3-master]
|/
* c411031 修改B.java [c2-master]
* 4b88828 修改A.java [c1-master]
* 56c457b 初次提交[c0-master]

image-20240327104639994

整合分支最容易的方法是 merge 命令。 它会把两个分支的最新快照C3C4)以及二者最近的共同祖先C2)进行三方合并,合并的结果是生成一个新的快照(并提交)。

1
2
3
4
5
$ git merge experiment
Merge made by the 'ort' strategy.
D.java | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 D.java

此时git log如下:

1
2
3
4
5
6
7
8
9
$ git log --all --oneline --graph
* a278b29 (HEAD -> master) Merge branch 'experiment' [C5--master]
|\
| * 1872aaf (experiment) 新增D.java [C4-experiment]
* | b1d77e8 新增C.java [c3-master]
|/
* c411031 修改B.java [c2-master]
* 4b88828 修改A.java [c1-master]
* 56c457b 初次提交[c0-master]

image-20240327105206916

image-20240327105238883

其实,还有一种方法:你可以提取在 C4 中引入的补丁和修改,然后在 C3 的基础上应用一次。 在 Git 中,这种操作就叫做 变基

rebase操作和原理

你可以使用 rebase 命令将提交到某一分支上的所有修改都移至另一分支上,就好像“重新播放”一样。

在上面这个例子中,运行:

1
2
3
4
5
6
MINGW64 /d/tmp/git-play-rebase (experiment)
$ git checkout experiment

MINGW64 /d/tmp/git-play-rebase (experiment)
$ git rebase master
Successfully rebased and updated refs/heads/experiment.

注意:以下2组命令是等价的:

1
2
3
4
5
$ git checkout experiment 
$ git rebase master
# 相当于
$ git rebase master experiment
# rebase过后会直接停留在experiment,不需要单独checkout过去

比如:

1
2
3
4
5
MINGW64 /d/tmp/git-play-rebase2 (master)
$ git rebase master experiment
Successfully rebased and updated refs/heads/experiment.

MINGW64 /d/tmp/git-play-rebase2 (experiment) # 停留在experiment分支

rebase前的git log如下:

1
2
3
4
5
6
7
8
MINGW64 /d/tmp/git-play-rebase (experiment)
$ git log --all --oneline --graph
* 1872aaf (HEAD -> experiment) 新增D.java [C4-experiment]
| * b1d77e8 (master) 新增C.java [c3-master]
|/
* c411031 修改B.java [c2-master]
* 4b88828 修改A.java [c1-master]
* 56c457b 初次提交[c0-master]

image-20240327104639994

rebase后的git log如下:

1
2
3
4
5
6
7
MINGW64 /d/tmp/git-play-rebase (experiment)
$ git log --all --oneline --graph
* f12abe6 (HEAD -> experiment) 新增D.java [C4-experiment]
* b1d77e8 (master) 新增C.java [c3-master]
* c411031 修改B.java [c2-master]
* 4b88828 修改A.java [c1-master]
* 56c457b 初次提交[c0-master]

image-20240327110153272

注意:git rebase master不会改变master分支!因为我们是在experiment分支上操作的!若此时查看master分支,可以发现根本没有发生变化

1
2
3
4
5
6
7
8
$ git checkout master
Switched to branch 'master'

$ git log --oneline
b1d77e8 (HEAD -> master) 新增C.java [c3-master]
c411031 修改B.java [c2-master]
4b88828 修改A.java [c1-master]
56c457b 初次提交[c0-master]

rebase的原理是首先找到这两个分支(即当前分支 experiment、变基操作的目标基底分支 master)的最近共同祖先 C2,然后对比当前分支experiment相对于该祖先C2的历次提交(本例中只有C4),提取相应的修改并存为临时文件,然后将当前分支指向目标基底 C3(即把C3当作父提交对象), 最后以此将之前另存为临时文件的修改依序应用:

rebase前:

image-20240327111051896

rebase后:

image-20240327111030422

现在回到 master 分支,进行合并操作:

1
2
3
4
5
6
$ git merge experiment
Updating b1d77e8..f12abe6
Fast-forward
D.java | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 D.java

可以看到此时的merge是通过Fast-forward完成的,而不是三方合并(即仅移动了master指针,并未生成新的提交):

1
2
3
4
5
6
7
MINGW64 /d/tmp/git-play-rebase (master)
$ git log --all --oneline --graph
* f12abe6 (HEAD -> master, experiment) 新增D.java [C4-experiment]
* b1d77e8 新增C.java [c3-master]
* c411031 修改B.java [c2-master]
* 4b88828 修改A.java [c1-master]
* 56c457b 初次提交[c0-master]

image-20240327111624936

image-20240327111706456

此时,C4' 指向的快照就和上面使用 merge 命令的例子中 C5 指向的快照一模一样了。 这两种整合方法的最终结果没有任何区别,但是变基使得提交历史更加整洁。 你在查看一个经过变基的分支的历史记录时会发现,尽管实际的开发工作是并行的,但它们看上去就像是串行的一样,提交历史是一条直线没有分叉

一般我们这样做的目的是为了确保在向远程分支推送时能保持提交历史的整洁——例如向某个其他人维护的项目贡献代码时。 在这种情况下,你首先在自己的分支里进行开发,当开发完成时你需要先将你的代码变基到 origin/master 上,然后再向主项目提交修改。 这样的话,该项目的维护者就不再需要进行整合工作,只需要快进合并便可。

请注意,无论是通过变基,还是通过三方合并,整合的最终结果所指向的快照始终是一样的,只不过提交历史不同罢了。 变基是将一系列提交按照原有次序依次应用到另一分支上,而合并是把最终结果合在一起。

移植(transplant )分支

示例1

注:以下例子来自git rebase --help文档

如下的过程演示了如何通过git rebase --onto将一个依赖了其他分支(next)的分支(topic移植到另外一个分支master上:

关于--onto 选项的说明如下:

–onto

Starting point at which to create the new commits. If the --onto option is not specified, the starting point is <upstream>. May be any valid commit, and not just an existing branch name.

首先假设我们的topic分支是基于next分支创建的,各分支的关系如下:

1
2
3
4
5
o---o---o---o---o  master
\
o---o---o---o---o next
\
o---o---o topic

其中topic分支依赖了next分支上的一些特性或功能。

我们希望将topic分支移植到master分支上,因为topic分支所依赖的next上的特性和功能已经被合并到更为稳定的分支master上,我们期望的结果如下:

1
2
3
4
5
o---o---o---o---o  master
| \
| o'--o'--o' topic
\
o---o---o---o---o next

我们可以通过如下的命令实现这样的效果:

1
git rebase --onto master next topic

该命令解释如下为:

  • 取出topic分支上topicnext分支的共同祖先之后的提交,嫁接到分支master

示例2

假设目前我们的分支情况如下:

1
2
3
4
5
                        H---I---J topicB
/
E---F---G topicA
/
A---B---C---D master

通过如下的命令:

1
git rebase --onto master topicA topicB

可以将我们的分支调整为如下:

1
2
3
4
5
             H'--I'--J'  topicB
/
| E---F---G topicA
|/
A---B---C---D master

topicB没有依赖topicA时,这种操作很有用。

一个更有趣的变基例子

在对两个分支进行变基时,所生成的“重放”并不一定要在目标分支上应用,你也可以指定另外的一个分支进行应用。 就像 从一个特性分支里再分出一个特性分支的提交历史 中的例子那样。 你创建了一个特性分支 server,为服务端添加了一些功能,提交了 C3C4。 然后从 C3 上创建了特性分支 client,为客户端添加了一些功能,提交了 C8C9。 最后,你回到 server 分支,又提交了 C10

image-20240327133411280

假设你希望将 client 中的修改合并到主分支并发布,但暂时并不想合并 server 中的修改,因为它们还需要经过更全面的测试。 这时,你就可以使用 git rebase 命令的 --onto 选项,选中在 client 分支里但不在 server 分支里的修改(即 C8C9),将它们在 master 分支上重放:

1
$ git rebase --onto master server client

以上命令的意思是:“取出 client 分支,找出处于 client 分支和 server 分支的共同祖先之后的修改(即:C8C9),然后把它们在 master 分支上重放一遍”。

指向该命令后的分支情况如下:

image-20240327150510213

实操演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
MINGW64 /d/tmp/git-transplant (client)
$ git log --all --oneline --graph
* a4136d6 (HEAD -> client) C9-client
* b59cca4 C8-client
| * 4921a6c (server) C10-server
| * ea431ca C4-server
|/
* 77a40ff C3-server
| * 9f852d7 (master) C6-master
| * c66c195 C5-master
|/
* a72cd5b C2-master
* 2f087f3 C1-master

image-20240327155102154

指向rebase操作:

1
2
3
4
# 需要先切换到client分支 !!!
MINGW64 /d/tmp/git-transplant (client)
$ git rebase --onto master server client
Successfully rebased and updated refs/heads/client.

操作后:

1
2
3
4
5
6
7
8
9
10
11
12
 MINGW64 /d/tmp/git-transplant (client)
$ git log --all --oneline --graph
* 4ad6f32 (HEAD -> client) C9-client
* e8a779b C8-client
* 9f852d7 (master) C6-master
* c66c195 C5-master
| * 4921a6c (server) C10-server
| * ea431ca C4-server
| * 77a40ff C3-server
|/
* a72cd5b C2-master
* 2f087f3 C1-master

image-20240327160328529

现在可以快进合并(fast-forward) master 分支了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ git checkout master
Switched to branch 'master'

$ git log --all --oneline --graph
* 4ad6f32 (client) C9-client # client在这里!!!
* e8a779b C8-client
* 9f852d7 (HEAD -> master) C6-master # master在这里!!!
* c66c195 C5-master
| * 4921a6c (server) C10-server
| * ea431ca C4-server
| * 77a40ff C3-server
|/
* a72cd5b C2-master
* 2f087f3 C1-master

$ git merge client
Updating 9f852d7..4ad6f32
Fast-forward # 可以发现是Fast-forward模式
....
2 files changed, 0 insertions(+), 0 deletions(-)

此时再次查看git log如下:

1
2
3
4
5
6
7
8
9
10
* 4ad6f32 (HEAD -> master, client) C9-client # 只是后移了HEAD、master指针
* e8a779b C8-client
* 9f852d7 C6-master
* c66c195 C5-master
| * 4921a6c (server) C10-server
| * ea431ca C4-server
| * 77a40ff C3-server
|/
* a72cd5b C2-master
* 2f087f3 C1-master

变基的风险

原则

呃,奇妙的变基也并非完美无缺,要用它得遵守一条准则:

不要对在你的仓库外有副本的分支执行变基

简而言之就是:如果分支已经推送到远端,则不要针对该分支进行变基操作!

如果你遵循这条金科玉律,就不会出差错。

变基操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提交

如果你已经将提交推送至某个仓库,而其他人也已经从该仓库拉取提交并进行了后续工作,此时,如果你用 git rebase 命令重新整理了提交并再次推送,你的同伴因此将不得不再次将他们手头的工作与你的提交进行整合,如果接下来你还要拉取并整合他们修改过的提交,事情就会变得一团糟。

风险演示

让我们来看一个在公开的仓库上执行变基操作所带来的问题。 假设你从一个中央服务器克隆然后在它的基础上进行了一些开发。 你的提交历史如图所示:

image-20240327162649219

然后,某人又向中央服务器提交了一些修改,其中还包括一次合并(此处为:合并提交)。 你抓取了这些在远程分支上的修改,并将其合并到你本地的开发分支,然后你的提交历史就会变成这样:

image-20240327163100756

其中

  • teamone为远程分支的名字
  • C7为一次合并提交

接下来,这个人又决定把合并操作回滚,改用变基;继而又用 git push --force 命令覆盖了服务器上的提交历史。 之后你从服务器抓取更新,会发现多出来一些新的提交。

image-20240327165019386

本地的C4及之前合并提交产生的C6仍然存在

结果就是你们两人的处境都十分尴尬。 如果你执行 git pull 命令,你将合并来自两条提交历史的内容,生成一个新的合并提交,最终仓库会如图所示:

image-20240327165351710

此时如果你执行 git log 命令,你会发现有两个提交的作者、日期、日志居然是一样的,这会令人感到混乱。 此外,如果你将这一堆又推送到服务器上,你实际上是将那些已经被变基抛弃的提交又找了回来,这会令人感到更加混乱。 很明显对方并不想在提交历史中看到 C4C6,因为之前就是他把这两个提交通过变基丢弃的。

用变基解决变基

果你 真的 遭遇了类似的处境,Git 还有一些高级魔法可以帮到你。 如果团队中的某人强制推送并覆盖了一些你所基于的提交,你需要做的就是检查你做了哪些修改,以及他们覆盖了哪些修改。

实际上,Git 除了对整个提交计算 SHA-1 校验和以外,也对本次提交所引入的修改计算了校验和—— 即 “patch-id”。

如果你拉取被覆盖过的更新并将你手头的工作基于此进行变基的话,一般情况下 Git 都能成功分辨出哪些是你的修改,并把它们应用到新分支上。

举个例子,如果遇到前面提到的 有人推送了经过变基的提交,并丢弃了你的本地开发所基于的一些提交 那种情境,如果我们不是执行合并,而是执行 git rebase teamone/master, Git 将会:

  • 检查哪些提交是我们的分支上独有的(C2,C3,C4,C6,C7)

  • 检查其中哪些提交不是合并操作的结果(C2,C3,C4)

  • 检查哪些提交在对方覆盖更新时并没有被纳入目标分支(只有 C2 和 C3,因为 C4 其实就是 C4’)

  • 把查到的这些提交应用在 teamone/master 上面

从而我们将得到与 你将相同的内容又合并了一次,生成了一个新的提交 中不同的结果,如图 在一个被变基然后强制推送的分支上再次执行变基 所示。

image-20240327165628965

要想上述方案有效,还需要对方在变基时确保 C4’ 和 C4 是几乎一样的。 否则变基操作将无法识别,并新建另一个类似 C4 的补丁(而这个补丁很可能无法整洁的整合入历史,因为补丁中的修改已经存在于某个地方了)。

在本例中另一种简单的方法是使用 git pull --rebase 命令而不是直接 git pull。 又或者你可以自己手动完成这个过程,先 git fetch,再 git rebase teamone/master

如果你习惯使用 git pull ,同时又希望默认使用选项 --rebase,你可以执行这条语句 git config --global pull.rebase true 来更改 pull.rebase 的默认配置。

只要你把变基命令当作是在推送前清理提交使之整洁的工具,并且只在从未推送至共用仓库的提交上执行变基命令,就不会有事。 假如在那些已经被推送至共用仓库的提交上执行变基命令,并因此丢弃了一些别人的开发所基于的提交,那你就有大麻烦了,你的同事也会因此鄙视你。

如果你或你的同事在某些情形下决意要这么做,请一定要通知每个人执行 git pull --rebase 命令,这样尽管不能避免伤痛,但能有所缓解。

变基 VS 合并

至此,你已在实战中学习了变基和合并的用法,你一定会想问,到底哪种方式更好。 在回答这个问题之前,让我们退后一步,想讨论一下提交历史到底意味着什么

有一种观点认为,仓库的提交历史即是 记录实际发生过什么。 它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用_谎言_掩盖了实际发生过的事情。 如果由合并产生的提交历史是一团糟怎么办? 既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。

另一种观点则正好相反,他们认为提交历史是 项目过程中发生的事。 没人会出版一本书的第一版草稿,软件维护手册也是需要反复修订才能方便使用。 持这一观点的人会使用 rebasefilter-branch 等工具来编写故事,怎么方便后来的读者就怎么写。

现在,让我们回到之前的问题上来,到底合并还是变基好?希望你能明白,这并没有一个简单的答案。 Git 是一个非常强大的工具,它允许你对提交历史做许多事情,但每个团队、每个项目对此的需求并不相同。 既然你已经分别学习了两者的用法,相信你能够根据实际情况作出明智的选择。

总的原则是,只对尚未推送或分享给别人的本地修改执行变基操作清理历史,从不对已推送至别处的提交执行变基操作,这样,你才能享受到两种方式带来的便利。