前言
在早期使用 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 的提交历史会变成:
从上图的演示可以看到,当执行 rebase 操作时,git 的具体操作如下
-
寻找共同基点: Git 首先查找
feat分支和master分支的最近共同祖先。这是两个分支分叉之前的最后一个提交,即first commit。 -
重放提交: Git 将
feat分支上从共同基点之后的所有提交(这里是 commit C 和 commit D)暂时保存为补丁(patches),然后将feat分支重置到master分支的当前头部(这里是 commit B)。 -
应用补丁: 接下来,Git 依次将之前保存的每个补丁(commit C 和 commit D)应用到
master分支的新头部(commit B)之上。如果在应用某个补丁时发生冲突,Git 会暂停重放操作并要求您解决冲突。解决后,您需要用git add标记解决的文件,并用git rebase --continue继续重放操作。 -
完成 Rebase: 一旦所有补丁都被成功应用,
rebase操作完成。此时,feat分支的头部将指向应用了 commit C 和 commit D 的新提交,这些提交现在位于 commit B 之后。
rebase 的主要用途
rebase主要用于优化提交历史的管理和整合。通过将一个分支的更改重新应用到另一个分支的顶端,我们使用的场景,通常是在本地开发的分支,同步远程最新的代码,使用git rebase [远程分支] 合并代码有以下优点
- 清晰的历史线索:
rebase通过重新排列提交,使得项目历史呈现出一条清晰的直线,方便理解和追踪。 - 避免多余的合并提交:使用
rebase可以避免在合并时产生的多余合并提交,历史记录更为整洁。 - 交互式编辑提交:
git rebase的交互模式允许开发者合并提交、修改提交信息或调整提交顺序,有助于保持提交历史的清晰和专注。
在处理代码合并时,merge 也可以完成对应的操作,那么二者的区别是什么呢?还是以上面的例子为例,对比一下 merge 与 rebase 后,git 提交历史图
git merge | git rebase |
|---|---|
可以看到,使用 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,进入交互式的界面,重写提交历史。
举个🌰
- 从
master的first commit切出分支feat。 - 在
feat上新增了commit A、commit B、commit C、commit D。 master上新增了 、commit E与commit F
执行完成上述三步,git 提交历史如下
我们希望将 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 记录:
合并校验
在上述合并的例子中,我想要确保调整完成的每次 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
git切换至commit A,逐个与commit Bcommit C进行合并- 处理
commit B出现冲突,执行git rebase --skip,会使用commit A的内容覆盖commit B - 继续处理
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 命令可以显示当前正在应用的提交的补丁。这对于理解当前处理的提交更改非常有帮助。效果如下
历史还原
在交互式模式下进行 rebase 并对提交执行 squash 或 drop 等命令,或者处理冲突时使用 --skip,会将一些已经存在的 commit 在 git log 中直接删除提交。
那当我们操作失误之后,想要查找这些已经被移除的 commit 该如何处理呢?
细心的同学可能已经发现,当之前 rebase 的时候,git 并不是直接在原有 commit 基础上进行修改,而是创建了新的 commit。
那如何能够找到历史提交的信息呢?
这时,我们可以借助 git reflog 来查找和恢复这些历史提交。
Reflogs 是 Git 中用来记录本地仓库分支顶端更新的一种机制。它会跟踪所有分支顶端曾经指向过的提交,因此,reflogs 允许我们找到并切换到当前没有被任何分支或标签引用的提交。
每当分支顶端因为切换分支、拉取新变更、重写历史或添加新提交而更新时,Git 会在 reflogs 中添加一条新记录。这样一来,我们在本地创建的每一次提交都会被记录在 reflogs 中。即使在重写提交历史之后,reflogs 也会包含关于分支旧状态的信息,并允许我们在需要时恢复到该状态。
需要注意的是,reflogs 并非永久保存记录,它们通常会在 90 天后过期并自动删除。
通过执行 git reflog,你将可以看到你的所有 commit 记录。
可以根据 commit id 将 git 重新执行某个commit,例如:
git reset --hard commitId
关于rebase 的讨论
可以注意到,rebase 的操作看上去相较于merge,确实比较整洁,但本质上是做了历史改写。且本该存在的一次merge操作,也被隐藏掉了,虽然这样可以使 rebase 的提交历史看起来更整洁,但也可能掩盖掉原始开发过程中的重要上下文信息。且 rebase 的操作更为复杂,对于新手不够友好。
至于是否采用 rebase,更多的还是应该遵循团队的规范。
切莫在开发时 merge 与 rebase 混和使用,这样极易造成代码冲突