git rebase 用法详解与工作原理

 

git rebase 用法详解与工作原理

正如我对代码有洁癖一样, 我对 commit message 也有同样的感受, 提交 commit message 之前, 一定是严格符合规范的, 之前也写过一篇配置自动检测 commit message文章。 在尝试使用 git rebase -i 合并 commit message 之后, 我体会到了它的强大, 于是写篇文章记录一下学习 git rebase 的过程

理解 rebase 命令

rebase 就是我们所说的变基, 那什么是变基呢? 通俗的讲就是将提交到某一分支的所有修改都移动到另一分支上, 就好像“重新播放”一样。我这里录了一个动图, 方便理解

1

如图我们有两个分支, mainfeature, 提交顺序就如图所示, 在 C1点从 main 分支创建一个新分支 feature, 切回 main, 提交 C2节点,再切回 feature 分支,提交 C3, 再切回 main, 提交 C4, 再切回 feature, 提交 C5, 此时的展示结果如下图

2

此时, 我们在 feature 分支上, 然后执行

git rebase main

rebase 的执行过程是首先找到这两个分支的最近共同祖先提交 C1, 然后对比当前分支相对于该祖先提交的历次提交(C3C5),提取相应的修改并存为临时节点(C3'C5'), 然后将当前分支指向目标基底 main 所指向的提交 C4,最后以此为新的基端将之前另存为临时节点的修改依序应用。

我们也可以按上文理解成将 feature 分支的基础从提交 C1 改为了提交 C4, 看起来就像是从提交 C4 创建了该分支, 并提交了 C3 和 C5。但实际上这只是看起来, 在内部 Git 复制了提交 C3 和 C5 的内容, 创建新的提交 C3' 和 C5'并将其应用到特定基础上(C1 -> C2 -> C4), 尽管新的 feature 分支和之前看起来是一样的, 但它是由全新的提交组成的。

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

这里有个点需要注意一下, 就是 rebase 以后,并不是按照提交时间重新排序的, 可以看一下下面这张图

3

两个分支(master 和 feature)提交顺序是 A -> B -> C -> D -> E, rebase 以后提交顺序变成了 A -> C -> D -> B -> E, 可以看到 B 节点的时间是落后于 C 节点和 D 节点的

主要用途

rebase 一般用于重写历史提交, 这里又大致分为两种场景

  1. 合并分支
  2. commit 进行操作,多用于删除、合并、修改

合并分支

合并分支在 git 中有两种方式, 一种是使用 merge, 另一种便是 rebase

假设我们有如下分支

       C2---C3 feature
      /
C0---C1---C4---C5 main

现在我们将分别使用 mergerebase, 将 main 分支的 C4 和 C5 合并到 feature 分支上, 并在 feature 分支上新增一个提交 C6, 然后再将 feature 分支合入 main, 最后对比两种方法所形成的提交历史的区别

使用 merge
  1. 切换到 feature 分支上: git checkout feature
  2. 合并 main 分支的更新: git merge main
  3. 新增一个提交 C7: git add. && git commit -m 'C7'
  4. 切回 master 分支并执行快速合并: git checkout main && git merge feature

执行过程如下图所示:

4

可以看到, 当在 feature 分支上执行 git merge main 时, 会自动生成一个 merge 节点 C6, 提交历史如下, 显示两条线

5

使用 rebase
  1. 切换到 feature 分支上: git checkout feature
  2. 合并 main 分支的更新: git rebase main
  3. 新增一个提交 C7: git add. && git commit -m 'C6'
  4. 切回 master 分支并执行快速合并: git checkout main && git merge feature

执行过程如下图

6

我们将得到如下历史提交:

7

可以看到, 使用 rebase 方法形成的提交历史完全是线性的, 同时比 merge 方法少了一次 merge 提交, 看上去更加整洁。

使用 rebase 的交互模式重写提交历史

git rebase 命令有标准和交互两种模式, 之前的示例我们用的都是默认的标准模式, 在命令后添加 -i 或者 --interactive 选项即可使用交互模式。

两种模式的区别

我们前面提到过, rebase 是 「在另一个基端之上重新应用提交」,而在重新应用的过程中, 这些提交会被重新创建,自然也可以进行修改。在 rebase 的标准模式下,当前工作分支的提交会被直接应用到分支的顶端;而在交互模式下,则允许我们在重新应用之前通过编辑器以及特定的命令规则对这些提交进行合并、重新排序及删除等重写操作。

两者最常见的使用场景也因此有所不同:

  1. 标准模式常用于在当前分支中集成来自其他分支的最新修改
  2. 交互模式常用于对当前分支的提交历史进行编辑,如将多个小提交合并成大的提交

不仅仅是分支

虽然我们之前的示例都是在不同的两个分支之间执行 rebase 操作,但事实上 rebase 命令传入的参数并不仅限于分支。

任何的提交引用,都可以被视作有效的 rebase 基底对象,包括一个提交 ID、分支名称、标签名称或 HEAD~1 这样的相对引用。

自然地,假如我们对当前分支的某次历史提交执行 rebase, 其结果就是会将这次提交之后的所有提交重新应用在当前分支,在交互模式下,即允许我们对这些提交进行更改。

重写提交历史

终于进入到本文的主题了, 前面提到, 假如我们在交互模式对当前分支的某次提交执行 rebase, 即(间接)实现了对这次提交之后的所有提交进行重写。接下来我们将通过下面的示例进行详细介绍。

假设我们在 feature 分支有如下提交:

74199cebdd34d107bb67b6da5533a2e405f4c330 (HEAD -> feature) commit F
e7c7111d807c1d5209b97a9c75b09da5cd2810d4 commit E
d9623b0ef9d722b4a83d58a334e1ce85545ea524 commit D
73deeedaa944ef459b17d42601677c2fcc4c4703 commit C
c50221f93a39f3474ac59228d69732402556c93b commit B
ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A

接下来我们将要执行的操作是:

  • 将B、 C 合并为一个新的提交,并仅保留原提交 C 的提交信息
  • 删除提交 D
  • 将提交 E 移动到提交 F 之后并重新命名(即修改提交信息) 为提交 H
  • 在提交 F 中加入一个新的文件更改, 并重新命名提交为 G

由于我们需要修改的提交是 B→C→D→E,因此我们需要将提交 A 作为新的「基端」,提交 A 之后的所有提交会被重新应用:

git rebase -i ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 # 参数是提交 A 的 ID

接下来会进入到如下的编辑器界面:

pick c50221f commit B
pick 73deeed commit C
pick d9623b0 commit D
pick e7c7111 commit E
pick 74199ce commit F

# 变基 ef13725..74199ce 到 ef13725(5 个提交)
#
# 命令:
# p, pick <提交> = 使用提交
# r, reword <提交> = 使用提交,但修改提交说明
# e, edit <提交> = 使用提交,进入 shell 以便进行提交修补
# s, squash <提交> = 使用提交,但融合到前一个提交
# f, fixup <提交> = 类似于 "squash",但丢弃提交说明日志
# x, exec <命令> = 使用 shell 运行命令(此行剩余部分)
# b, break = 在此处停止(使用 'git rebase --continue' 继续变基)
# d, drop <提交> = 删除提交
......

(注意上面提交 ID 之后的提交信息只起到描述作用,在这里修改它们不会有任何效果。)

具体的操作命令在编辑器的注释中已解释的相当详细,所以我们直接进行如下操作:

  1. 对提交 B、C 做如下修改

    pick c50221f commit B
    s 73deeed commit C
    

    由于提交 B 是这些提交中的第一个, 因此我们无法对其执行 squash 或者 fixup 命令(没有前一个提交),我们也不需要对提交 B 执行 reword 命令以修改其提交信息, 因为之后在将提交 C 融合到提交 B 中时, 会允许我们对融合之后的提交信息进行修改。

​ 注意该界面提交的展示顺序是从上到下由旧到新,因此我们将提交 C 的命令改为 s (或者 squash) 或者(或 fixup)会将其融合到 (上方的)前一个提交 B, 两个命令的区别为是否保留 C 的提交信息。

  1. 删除提交 D

    d d9623b0 commit D
    
  2. 移动提交 E 到提交 F 之后并修改其提交信息:

    pick 74199ce commit F
    r e7c7111 commit E
    
  3. 在提交 F 中加入一个新的文件更改:

    e 74199ce commit F
    
  4. 保存退出

接下来会按照从上到下的顺序依次执行我们对每一个提交所修改或保留的命令:

  1. 对提交 B 的 pick 命令会自动执行,因此不需要交互。

  2. 接着执行对提交 C 的 squash 命令,会进入一个新的编辑器界面允许我们修改合并了B、C 之后的提交信息

    # 这是一个 2 个提交的组合。
    # 这是第一个提交说明:
          
    commit B
          
    # 这是提交说明 #2:
          
    commit C
    ......
    

    我们将 commit B 这一行删除后保存退出,融合之后的提交将使用 commit C 作为提交信息。

  3. 对提交 D 的 drop 操作也会自动执行,没有交互

  4. 执行 rebase 的过程中可能会发生冲突,这时候 rebase 会暂时中止,需要我们编辑冲突的文件去手动合并冲突。解决冲突后通过 git add/rm <conflicted_files> 将其标记为已解决,然后执行 git rebase --continue 可以继续之后的 rebase 步骤;或者也可以执行 git rebase --abort 放弃 rebase 操作并恢复到操作之前的状态。

  5. 由于我们上移了提交 F 的位置,因此接下来将执行对 F 的 edit 操作。这时将进入一个新的 Shell 会话:

    停止在 74199ce... commit F
    您现在可以修补这个提交,使用
          
      git commit --amend 
          
    当您对变更感到满意,执行
          
      git rebase --continue
    

    我们添加一个新的代码文件并执行 git commit --amend 将其合并到当前的上一个提交(即 F),然后在编辑器界面中将其提交信息修改为 commit G,最后执行 git rebase --continue 继续 rebase 操作。

  6. 最后执行对提交 E 的 reword 操作,在编辑器界面中将其提交信息修改为 commit H

大功告成!最后让我们确认一下 rebase 之后的提交历史:

64710dc88ef4fbe8fe7aac206ec2e3ef12e7bca9 (HEAD -> feature) commit H
8ab4506a672dac5c1a55db34779a185f045d7dd3 commit G
1e186f890710291aab5b508a4999134044f6f846 commit C
ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A

完全符合预期,同时也可以看到提交 A之后的所有提交 ID 都已经发生了改变,这也印证了我们之前所说的 Git 重新创建了这些提交。

好了好了, 文章就先写到这里吧, 后面好多内容都是直接 copy 的第一个参考链接的😂, 文章拖得时间有点久, 就先这样吧, 工作中完全够用了。。。🐶

参考:

  1. https://www.waynerv.com/posts/git-rebase-intro/#理解-rebase-命令

  2. https://git-scm.com/book/zh/v2/Git-分支-变基

  3. https://learngitbranching.js.org/?locale=zh_CN