掌握 Git Rebase:提升代码管理效率的终极指南!

1,258 阅读12分钟

image.png

前言

在早期使用 git rebase 时,我常常带着一丝抵触情绪。这主要是因为我对 rebase 的理解不够深入,再加上一些操作失误,导致在合并远程代码时经常引发连续的冲突,需要反复解决同一个问题。这种情况逐渐让我放弃了使用 rebase。然而,在团队中,有些同事更偏向于使用合并(merge),而另一些则坚持使用 rebase。一次通过提交历史回溯问题的经历让我意识到,混合使用 merge 和 rebase 会使整个提交树看起来杂乱不堪,现在是时候正视并深入理解 rebase 命令了。

理解 rebase 命令

官方文档对 rebase 介绍是:"在另一个基础提示之上重新应用提交内容"。 这段话听起来还是挺抽象的,可以通俗的理解为:「将分支的基础从一个提交改成另一个提交,使其看起来就像是从另一个提交中创建了分支一样」

我们以最常使用的git rebase [branch]命令为例:

# 1. 主分支为 master,A 同学从 master 切出分支 feat,进行需求开发。
git checkout -b feat
# 2. B 同学从 master 切出分支 bugfix,进行问题修复。
git checkout bugfix
# 3. B 同学开发完成,新增 commit B,并合入 master。
git merge bugfix
# 4. A 同学本地开发产生了两个 commit C 与 commit D,为了减少冲突,执行 rebase 同步 master
git rebase master

执行完成,git 的提交历史会变成: git-rebase-1.gif

从上图的演示可以看到,当执行 rebase 操作时,git 的具体操作如下

  1. 寻找共同基点: Git 首先查找 feat 分支和 master 分支的最近共同祖先。这是两个分支分叉之前的最后一个提交,即 first commit

  2. 重放提交: Git 将 feat 分支上从共同基点之后的所有提交(这里是 commit C 和 commit D)暂时保存为补丁(patches),然后将 feat 分支重置到 master 分支的当前头部(这里是 commit B)。

  3. 应用补丁: 接下来,Git 依次将之前保存的每个补丁(commit C 和 commit D)应用到 master 分支的新头部(commit B)之上。如果在应用某个补丁时发生冲突,Git 会暂停重放操作并要求您解决冲突。解决后,您需要用 git add 标记解决的文件,并用 git rebase --continue 继续重放操作。

  4. 完成 Rebase: 一旦所有补丁都被成功应用,rebase 操作完成。此时,feat 分支的头部将指向应用了 commit C 和 commit D 的新提交,这些提交现在位于 commit B 之后。

rebase 的主要用途

rebase主要用于优化提交历史的管理和整合。通过将一个分支的更改重新应用到另一个分支的顶端,我们使用的场景,通常是在本地开发的分支,同步远程最新的代码,使用git rebase [远程分支] 合并代码有以下优点

  • 清晰的历史线索rebase 通过重新排列提交,使得项目历史呈现出一条清晰的直线,方便理解和追踪。
  • 避免多余的合并提交:使用 rebase 可以避免在合并时产生的多余合并提交,历史记录更为整洁。
  • 交互式编辑提交git rebase 的交互模式允许开发者合并提交、修改提交信息或调整提交顺序,有助于保持提交历史的清晰和专注。

在处理代码合并时,merge 也可以完成对应的操作,那么二者的区别是什么呢?还是以上面的例子为例,对比一下 mergerebase 后,git 提交历史图

git mergegit rebase
Pasted image 20240709100223.pngPasted image 20240709095956.png

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

不同的平台在处理 MR 合并时,虽然执行的是 git merge,但是不同的配置选项,会有不同的效果

附:关于 github 处理 merge 的几个选项

  • Merge Commit:这是 GitHub 默认的合并方式。使用这种策略时,所有来自特性分支的提交会被保留,并在目标分支上创建一个新的合并提交。这个合并提交有两个父提交,一个是目标分支的当前提交,另一个是特性分支的最后提交。

  • Squash and Merge:将特性分支的所有提交压缩成一个单独的提交,并将这个提交添加到目标分支上。这种方式不会创建一个实际的合并提交,因此在目标分支的历史中看不到特性分支的存在。

  • Rebase and Merge:首先将特性分支上的所有提交变基到目标分支的头部,然后再将这些提交直接添加到目标分支上,不创建合并提交。

rebase 高阶操作

除了简单的 git rebase [branch] 外,git rebase 还有着许多参数,如:

git rebase [-i | --interactive] [<选项>] [--exec <命令>]
	[--onto <新基础> | --keep-base] [<上游仓库> [<分支>]]
git rebase [-i | --interactive] [<选项>] [--exec <命令>] [--onto <新基础>]
	--root [<分支>]

以下是各个参数的简要介绍,详细文档请👉🏻点击查看

参数简要介绍
-i交互式的重写提交历史
--exec这个选项允许你在每次 rebase 过程中的每个提交上运行一个或多个命令,一般与其它命令组合使用
--onto允许你将指定的提交从一个分支变基到另一个不同的基点
--keep-base用于在变基时保持原有基点不变,重新应用分支上的提交。

接下来将会以实际的场景,介绍如何使用这些命令

重写提交历史

我们前面提到, rebase 是「在另一个基端之上重新应用提交」,而在重新应用的过程中,这些提交会被重新创建,自然也可以进行修改。在 rebase 的标准模式下,当前工作分支的提交会被直接应用到传入分支的顶端;那如果我们想要对提交历史做自定义的修改,比如移除某个 commit,合并几个 commit,调整 commit 的顺序等。使用git rebase [branch] -i,进入交互式的界面,重写提交历史。

举个🌰

  1. masterfirst commit 切出分支 feat
  2. feat 上新增了 commit Acommit Bcommit Ccommit D
  3. master上新增了 、commit Ecommit F

执行完成上述三步,git 提交历史如下

Pasted image 20240710105552.png

我们希望将 C 的改动剔除,AB 改动合并为 A + B,并且将 D 的改动放置在最前面,改完之后的效果是: first commit -> E -> F -> D -> A+B

切换至 feat 分支,使用 git rebase master -i 进入交互页面

pick 8bbb2d4 commit A
pick 3b08b6c commit B
pick 971f24d commit C
pick ce5d845 commit D
# 以下是注释,我们只需操作上面的内容即可
# p, pick <提交> = 使用提交
# r, reword <提交> = 使用提交,但修改提交说明
# e, edit <提交> = 使用提交,进入 shell 以便进行提交修补
# s, squash <提交> = 使用提交,但融合到前一个提交
# f, fixup <提交> = 类似于 "squash",但丢弃提交说明日志
# x, exec <命令> = 使用 shell 运行命令(此行剩余部分)
# b, break = 在此处停止(使用 'git rebase --continue' 继续变基)
# d, drop <提交> = 删除提交
#

按照我们的诉求,只需要这么修改就可以完成我们的诉求。

pick ce5d845 commit D
pick 8bbb2d4 commit A
squash 3b08b6c commit B
drop 971f24d commit C

使用 squash 会在处理完后,让用户填写新的 commit 信息,执行git log 查看一下 commit 记录:

Pasted image 20240711100432.png

合并校验

在上述合并的例子中,我想要确保调整完成的每次 commit 都能搞符合我们的lint 规范,那么我可以怎么做呢?

我们还是以上面的例子为例。我们可以在每次处理完成加上exec '命令',就可以

git rebase master -i --exec "echo '执行 echo 命令了'"

执行完成进入交互页面,原先的代码就会变成这样:

pick 07a3823 commit D
exec echo '执行 echo 命令了'
pick eb44384 commit A + commitB
exec echo '执行 echo 命令了'
pick 230ff64 commit H
exec echo '执行 echo 命令了'
pick 91d9a1b commit H-1
exec echo '执行 echo 命令了'

同样的,你也可以执行git rebase master -i 进入交互界面后,手动添加 exec 命令。这个指令非常适用于自动化测试或代码检查。

冲突解决

当在出现冲突后,git rebase 将会被中断,以下是几种冲突的方式:

git rebase (--continue | --skip | --abort | --quit | --edit-todo | --show-current-patch)
git rebase --continue

当你在 rebase 过程中遇到冲突后,解决完所有冲突并且用 git add 命令标记解决后,使用 git rebase --continue 来继续之前中断的 rebase 操作。这个命令会尝试继续应用剩余的提交。

git rebase --skip

出现冲突后,当你不想包含这个特定的提交,可以使用 git rebase --skip 跳过这个提交。这通常用于放弃某个提交所引入的更改。

举个例子: 在使用 rebase 前,大家的状态如下所示,其中 A 与 B、C 都存在冲突,在 feat 执行 git rebase master

master: first -> A
feat:   first -> B -> C
  1. git 切换至 commit A,逐个与 commit B commit C 进行合并
  2. 处理 commit B 出现冲突,执行 git rebase --skip,会使用 commit A 的内容覆盖 commit B
  3. 继续处理 commit C,出现冲突,修改完成,执行 git rebase --continue

执行完成后,大家的提交历史变为如下,其中 commit B 被丢弃。

master: first -> A
feat:   first -> A -> C
git rebase --abort

如果你想完全停止 rebase 过程,并将仓库恢复到 rebase 开始前的状态,可以使用 git rebase --abort。这是在遇到复杂问题时回到安全状态的一种方式。

git rebase --quit

git rebase --quit 会退出 rebase 过程,但不会恢复到 rebase 开始前的状态。这将会留下您在中途已经完成的部分操作。

git rebase --edit-todo

这个命令允许你在交互式 rebase 过程中编辑还未执行的操作列表。它在更复杂的 rebase 操作中非常有用,比如您需要修改提交的顺序,删除某些提交,或改变提交的方式(比如从 pick 改为 squash)。

我们还是以上面的例子为例,在执行rebase 前大家的提交记录如下:

master: first -> A
feat:   first -> B -> C

在 feat 分支执行 git rebase master。会进入冲突处理。此时运行,git rebase --edit-todo,会进入交互界面,由于只剩下 commit C 未做处理,所以交互页面只剩下 commit C 信息,界面如下:

pick 8bbb2d4 commit C

我们将 pick 改为 squash,那么 commit B 与 commit C 会在处理完成后进行合并。执行完成后,提交历史会变为:

master: first -> A
feat:   first -> A -> B+C
git rebase --show-current-patch

当你在解决 rebase 过程中的冲突时,git rebase --show-current-patch 命令可以显示当前正在应用的提交的补丁。这对于理解当前处理的提交更改非常有帮助。效果如下 Pasted image 20240712105808.png

历史还原

在交互式模式下进行 rebase 并对提交执行 squash 或 drop 等命令,或者处理冲突时使用 --skip,会将一些已经存在的 commitgit log 中直接删除提交。

那当我们操作失误之后,想要查找这些已经被移除的 commit 该如何处理呢?

细心的同学可能已经发现,当之前 rebase 的时候,git 并不是直接在原有 commit 基础上进行修改,而是创建了新的 commit。 Pasted image 20240712215749.png

那如何能够找到历史提交的信息呢?

这时,我们可以借助 git reflog 来查找和恢复这些历史提交。

ReflogsGit 中用来记录本地仓库分支顶端更新的一种机制。它会跟踪所有分支顶端曾经指向过的提交,因此,reflogs 允许我们找到并切换到当前没有被任何分支或标签引用的提交。

每当分支顶端因为切换分支、拉取新变更、重写历史或添加新提交而更新时,Git 会在 reflogs 中添加一条新记录。这样一来,我们在本地创建的每一次提交都会被记录在 reflogs 中。即使在重写提交历史之后,reflogs 也会包含关于分支旧状态的信息,并允许我们在需要时恢复到该状态。

需要注意的是,reflogs 并非永久保存记录,它们通常会在 90 天后过期并自动删除。

通过执行 git reflog,你将可以看到你的所有 commit 记录。

Pasted image 20240712221102.png 可以根据 commit id 将 git 重新执行某个commit,例如:git reset --hard commitId

reset-head.gif

关于rebase 的讨论

可以注意到,rebase 的操作看上去相较于merge,确实比较整洁,但本质上是做了历史改写。且本该存在的一次merge操作,也被隐藏掉了,虽然这样可以使 rebase 的提交历史看起来更整洁,但也可能掩盖掉原始开发过程中的重要上下文信息。且 rebase 的操作更为复杂,对于新手不够友好。 至于是否采用 rebase,更多的还是应该遵循团队的规范。

切莫在开发时 merge 与 rebase 混和使用,这样极易造成代码冲突

参考文献