Git「 分支整合 」

分支整合,简而言之就是整合来自不同分支的修改,或者说将一个分支的变更集成到另一个分支,这是一种特别常见的操作,尤其是在多人协作解决不同开发任务的情境之下。

在 Git 中主要有三种方式可以实现分支的整合:Merge、Rebase 和 Cherry-pick。

1. Merge

Merge 被称为合并,使用方式具体如下:

bash 复制代码
# 将 <branch_name> 分支(目标分支)的更改合并到当前分支
git merge <branch_name>

其具体的合并行为受到两个分支之间的状态关系和 Git 配置的影响,分为两种模式:快进合并(Fast-forward merge)和三向合并(Three-way merge)。

1.1. 快进合并

当存在从当前分支尖端到目标分支的线性路径时(条件),可能会发生快进合并。此时 Git 只会简单的将当前分支的指针向前移动(即"快进")到目标分支的尖端。

📚 补充说明:这个条件可以简单理解为顺着当前分支走下去能够到达这个目标分支,即目标分支是当前分支的直接后继,它包含了当前分支的所有提交。更简单的理解就是两个分支的历史记录没有发生分叉。

案例演示

例如,some-feature 是基于 main 这个线上分支创建的新功能分支,并且随着工作的进展向前推进了两个新的提交,但此时线上出现 bug 必须紧急修复,于是又基于 main 分支创建了一个 hotfix 分支,在该分支上工作直到问题解决,此时历史分支记录如下图所示:

可以看到,hotfix 分支和 main 分支没有分叉,前者是后者的直接后继。由于 bug 已经在 hotfix 分支上被成功修复,现在需要将 hotfix 分支合并回 main 分支来部署到线上,于是使用 git merge 命令来达到上述目的:

bash 复制代码
$ git checkout main
$ git merge hotfix
Updating f42c576..3a0874c
Fast-forward
 index.html | 2 ++
 1 file changed, 2 insertions(+)

由 Fast-forward 这个词可以得知,在合并的时候 Git 采用了快进合并模式,直接将分支 main 的 HEAD 指针向前移动到了分支 hotfix 的最新提交。

1.2. 三向合并

当两个分支存在分叉点,Git 会执行三向合并(Three-way merge)。三向合并的名字来源于它使用了三个"点"(或者说提交/版本)来执行合并:两个分支的最新提交,以及这两个分支的最近共同祖先。这种合并方式会做更多的操作,其具体的基本步骤如下:

  1. 首先,Git 会找到当前分支和目标分支的最近共同祖先;
  2. 然后,分别找到这两个分支相对于这个共同祖先所做的变更;
  3. 最后,尝试将这两组变更和共同祖先进行合并(如果有冲突,需要手动解决);

在成功执行三向合并后,Git 会创建一个新的合并提交。这个新的合并提交会包含两个父提交:目标分支的最新提交和当前分支的最新提交,其内容是对两个分支自共同祖先以来的所有更改进行的整合,以及解决合并过程中出现的所有冲突的更改。

案例演示

在上一小节的案例基础上,线上 bug 已经解决并发布,因此修复该 bug 的 hotfix 分支就不再需要了,可以使用带 -d 选项的 git branch 命令来删除分支:

bash 复制代码
$ git branch -d hotfix

准备回到之前被打断工作的 some-feature 分支中继续开发新功能,并且进行了一次最终提交完成了新功能的开发,此时历史分支记录如下图所示:

由于新功能已经完成开发,现需要将 some-feature 分支合并到 main 分支中进行发布上线,执行以下命令:

bash 复制代码
$ git checkout main
Switched to branch 'main'
$ git merge some-feature
Merge made by the 'recursive' strategy.
index.html |    1 +
1 file changed, 1 insertion(+)

这和之前合并 hotfix 分支的时候看起来有一点不一样,这是因为当前 some-feature 分支和 main 分支已经出现分叉,不满足快进合并的条件,Git 不得不做一些额外的工作,使用两个分支的末端所指的快照(commit G 和 commit F)以及这两个分支的公共祖先节点(commit C),做一个三向合并,最后得到的结果如下图所示:

从这个图中不难看出,相较于快进合并只是简单地将目标分支向前移动到另一个分支所在的最新位置而言,三向合并在提交历史中新创建的合并提交,能够更加明确地显示出分支合并的情况。

如果想要在快进合并的时候也创建合并提交以保存记录,则可以使用 --no-ff 选项执行 git merge。

bash 复制代码
git merge --no-ff <branch_name>

此命令将指定的分支合并到当前分支中,但始终生成合并提交(即使是快进合并),这对于记录存储库中发生的所有合并很有用。

2. Rebase

Rebase 操作和 Merge 一样也有两种不同的模式:标准模式和交互式模式。但与 Merge 不同的是,Merge 的两种工作模式功能相同只是工作方式不同,而 Rebase 的两种模式直接代表着两种不同的功能以及不同的应用场景:

  1. Rebase 只有在标准模式下是用于在当前分支中集成来自其他分支的最新修改。
  2. 交互模式是对提交历史高度定制,通常用来整理提交历史。

Tips:后续所提及的 Rebase 操作,如果没有明确说明,都是默认的标准模式,交互模式相关内容在 Git「 基础常用命令 」中有具体介绍。

2.1. 基础操作及原理

Rebase 通过以下命令来整合来自不同分支的修改:

bash 复制代码
# basebranch 是目标基底分支
# topicbranch 是待变基分支(可选参数,如果没有指定,默认为当前分支)
git rebase <basebranch> <topicbranch>

Pro Git 书籍对于该命令的描述是 Reapply commits on top of another base tip,从字面上理解是在另一个基端之上重新应用提交。也就是说,将分支的基础从一个提交变更为另一个提交,因此 rebase 通常也被称为变基。这听起来有点抽象,可以结合下面的案例演示来具体理解。

案例演示

假设你正在 Feature 专用分支上开发一个新功能,这个 Feature 分支是基于 Main 分支在 A 处的提交节点创建的,并且现在 Feature 分支基于 A 往前继续更新了两次,到了 E 节点。与此同时,团队开发的其他成员也在 Main 分支上迭代提交了两次,从 B 到 C 节点。也就是说,两者从 A 开始就走向了分叉。

然而,Main 分支上的两次新提交与你正在开发的功能相关,现在你想将特性分支更新到主分支的最新状态以确保该特性分支的稳定性,该怎么办呢?

此时可以使用 rebase,具体操作如下:

bash 复制代码
# Feature 分支是待变基分支(当前分支)
git checkout Feature

# Main 是变基操作的目标基底分支
git rebase Main

执行上述命令后,Git 会做以下事情:

首先找到这两个分支(即当前分支 Feature、 变基操作的目标基底分支 Main)的最近共同祖先提交 B,然后对比当前分支相对于该祖先提交的历次提交,提取相应的修改并保存为临时文件(C、D 和 G),紧接着将当前分支指向目标基底分支 Main 的最新提交 F,最后以此作为新的基端将之前另存为临时文件的修改依序应用。

整个过程如下图所示:

从内容角度来看,通过 rebase 进行操作后,Feature 分支似乎是直接基于 Main 分支的最新提交 C 创建的,而不是基于最初的共同祖先提交 A 创建。⚠️ 需要注意的是,实际上 Feature 分支上的提交历史已经被重写(commit SHA 被改变),因为 Git 会为待变基分支的每个提交创建全新的提交来重写历史记录。

最终的提交历史是这个样子:

2.2. 进阶操作:--onto 选项

git rebase 可以加上 --onto 选项来更精细地控制 rebase 操作。这个命令的基本形式是:

bash 复制代码
git rebase --onto newbase upstream branch

以上命令的意思是:"取出 branch 分支,找出它从 upstream 分支分歧之后的补丁,然后把这些补丁在 newbase 分支上重新应用,让 branch 看起来像直接基于 newbase 修改一样"。

案例演示

假设有如下提交历史:

现在希望将 Feature2 中的修改合并到主分支并发布,但暂时并不想合并 Feature1 中的修改, 因为它们还需要经过更全面的测试。这时候就可以使用 git rebase 命令的 --onto 选项,选中在 Feature2 分支里但不在 Feature1 分支里的修改,将它们在 Main 分支上重新应用:

bash 复制代码
git rebase --onto Main Feature1 Feature2

效果如下:

接下来,执行操作将 Feature2 合并到 Main 主分支中:

bash 复制代码
git checkout Main
git merge Feature2

最后得到的提交历史如下:

2.3. Rebase 使用法则

在实际的开发中,通常会发现 Rebase 的使用率会比 Merge 的使用率要低很多。这主要是因为 Rebase 操作的复杂性以及它对提交历史的影响,使得初学者往往被建议远离它。但其实如果能够谨慎并正确地使用 Rebase(遵循 Rebase 的使用法则),可以让开发团队的工作变得更加轻松。

不要变基公共历史记录,即永远不要在公共分支上使用它,这就是 rebase 的使用法则。 下面会使用一个示例来说明在公共分支上使用 rebase 会导致什么样的问题。执行以下命令将 Main 公共分支变基到 Feature 非公共分支:

bash 复制代码
# Main 分支是待变基分支(当前分支)
git checkout Main

# Feature 是变基操作的目标基底分支
git rebase Feature

得到的提交历史如下:

你会发现 Main 中的所有提交移到了 Feature 的顶端。问题在于这只发生在你的存储库中,所有其他开发人员仍在使用原始 Main 分支。由于变基会产生全新的提交,Git 会认为你的 Main 分支的历史记录与其他所有人的历史记录有所不同。如果你尝试将 rebase 了的 Main 分支推送回远程仓库,Git 将阻止你这样做,因为你本地的 Main 分支和远程的 Main 分支出现了分叉,解决这个问题的核心要点是同步这两个 Main 分支,唯一方法是使用 Merge 将它们重新合并在一起后再推送至远程仓库:

bash 复制代码
git fetch origin
git merge origin/Main
git push

这种方式得到的提交历史如下图所示:

这会产生一个额外的合并提交和两组包含相同变更的提交(C 和 C' 以及 D 和 D'),这是一个非常令人困惑的情况。并且往往实际场景远比这个示例场景要复杂得多,多次这样的操作会使得提交历史变得混乱。综上所述,在运行 git rebase 之前,一定要确认是否还有其他人使用这个分支,如果有,请暂停操作;如果没有,则可以随意重写历史记录。

2.4. Rebase vs Merge

现在,我们已经掌握了 Rebase 和 Merge 的基本操作和原理,这两者都可以将一个分支的变更集成到另一个分支,但他们的工作方式和效果截然不同,理解它们之间的差异非常重要,可以帮助开发者根据具体的需求和情况选择最适合的策略。

注意⚠️:在 Rebase 和 Merge 进行比较的时候,主要比较的是 Rebase 的标准模式和 Merge 的三向合并。因为在大多数情况下,需要合并的分支并不是当前分支的直接后继,所以 Merge 合并通常指的就是三向合并;而 Rebase 的标准模式则用于将当前分支的修改整合到另一目标分支上。

案例演示

结合 2.1 小节的案例演示,其中描述的场景通过使用 merge 会得到怎样的结果呢?执行以下命令:

bash 复制代码
git checkout Feature
git merge Main

该操作会在 feature 分支中创建一个新的 merge commit,它将两个分支的历史联系在一起。最终提交历史如下图所示:

该提交历史和使用 rebase 操作得到的结果(图 7)对比,不难发现,rebase 相较于 merge,会得到一个更为线性和整洁的提交历史。这是两者最直观的区别,它们之间更深层次的差异如下:

Merge

merge 是一种非破坏性的操作,不会修改原始分支的历史。git merge 的工作原理是通过创建一个新的合并提交将目标分支的更改集成到当前分支,产生的合并提交可以帮助开发者清晰地看到各个分支的合并点,这在多人协作或需要保留分支完整历史时非常有用;但从另一方面来看,这些合并提交也可能使分支历史变得复杂难以理解。

Rebase

与 merge 相反,rebase 是始终破坏性操作,会修改原始分支的历史。它的原理是首先找到当前分支和要 rebase 到的分支的最近共同祖先,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,最后在要 rebase 到的分支上重新应用。最终会得到一个新的、线性的提交历史,就像是在一个分支上连续提交一样,更加清晰明了,但也会丢失一些信息,比如原来的分支结构和合并点。

2.5. Rebase 和 Merge 的最佳实践

通过对比 Rebase 和 Merge,我们可以看出它们各有优缺点,根据于此很容易总结出一些 Rebase 和 Merge 的最佳实践,主要有以下 3 点:

假设你一直在主分支 mian 之外的一个特性功能分支 feature(基于主分支创建)上工作。

  1. 使用 rebase 拉取主分支的最新更新到特性功能分支

主分支已经取得了新的进展,你希望将主分支的最新更新添加到你的功能分支中,在这种情况下,git rebase 是一个好选择,其原因在于它可以保持功能分支的历史记录干净,看起来就像一直在使用最新主分支,这有助于以后将你的功能分支干净地合并回主分支。

想象一下,如果主分支提交非常活跃,并且这些提交和你的功能分支是息息相关的,这意味着你的功能分支需要频繁合并主分支的更新,使用 merge 的话,每次合并主分支时,它都将产生一个额外的合并提交,这可能会严重污染你的功能分支历史记录,并且可能使其他开发人员难以理解项目的历史记录。

具体可以参考附录给出的例子进行深入理解。

使用 rebase 拉取主分支的最新更新到当前特性功能分之的步骤如下:

bash 复制代码
git fetch origin
git rebase origin/main

# 或者
git checkout main
git pull
git checkout feature
git rebase main

附录:

假设在 Merge VS Rebase 的示例场景基础之上,Feature 分支当前又有新的提交,F 和 G 节点,并且现在 Feature 上的特性功能已经开发完成,要将 Feature 分支使用 merge 合并回 Main 分支。

如果是使用 merge 将主分支 Main 的 B、C 节点合并到 Feature 分支,最后得到的历史记录如下:

如果是使用 rebase 将主分支 Main 的 B、C 节点合并到 Feature 分支,最后得到的历史记录如下:

  1. 使用交互式 rebase 整理特性功能分支上的提交

在特性功能分支上进行开发的过程中,往往会存在这种情况,一个功能模块代码分了好几次进行 commit,虽然在开发过程中,将这个功能拆分成多个提交可能有助于理解和调试,但是在将这个特性分支合并到主分支时,这些细节可能并不重要,甚至可能会干扰其他人理解你的代码。在这种情况下,可以使用交互式 Rebase 来整理特性功能分支上提交历史,将多个 commit 全部 rebase 成一个 commit,这样就是一个干净的 commit。

  1. 使用 merge 合并特性功能分支到主分支

当完成了特性功能分支上的工作并准备将其合并回主分支时,git merge 是一个好选择。这会在目标分支上创建一个新的合并提交,清楚地标记特性功能的开始和结束。操作步骤如下:

bash 复制代码
# 首先,确保当前主分支上,并且主分支是最新的
git checkout main
git pull
# 然后,使用 git merge 命令将特性功能分支合并到主分支
git merge feature

这些最佳实践有助于团队协作和代码维护,同时也能最大程度地减少可能出现的问题,但并非绝对必须遵循于此。在特定情况下,可能需要根据团队的工作流程、项目的需求或个人偏好做出调整,但需要明智地权衡使用 Rebase 和 Merge 带来的影响。

3. Cherry pick

Cherry-pick 的作用是选择某一分支上的指定提交,并将它应用到另一个分支上。与 Merge、Rebase 将整个分支的变更集成到目标分支对比而言,Cherry-pick 是一种更精细的操作方式。

使用方式

bash 复制代码
git cherry-pick <commit-hash>

以下是合并特定提交的步骤:

  1. 首先使用 git checkout target_branch 切换到要接受合并的目标分支上,也就是要将提交应用到的分支。
  2. 然后使用 git log 或其他 Git 命令来查找要合并的提交的哈希值(commit hash)。
  3. 接着运行 git cherry-pick <commit_hash> 命令来完成合并。
  4. Git 将尝试应用指定的提交到目标分支。如果没有冲突,它会成功合并,并在目标分支上创建一个新的提交。如果有冲突,需要解决这些冲突,然后使用 git cherry-pick --continue 继续完成合并。

这样,就将特定提交从一个分支合并到了另一个分支中。

如果想要 cherry-pick 多个提交,可以在 git cherry-pick 命令后面列出所有想要 cherry-pick 的提交的哈希值。例如:

bash 复制代码
git cherry-pick <commit1> <commit2> <commit3> ...

这将会按照给出的顺序将这些提交应用到当前分支。每个提交都会生成一个新的提交,新提交的内容与原始提交相同,但是它有一个新的哈希值。也可以使用范围来 cherry-pick 多个提交。例如,想要 cherry-pick commit1 到 commit3 之间的所有提交(包括 commit1 和 commit3),可以使用以下命令:

bash 复制代码
# 请注意 ^ 符号
git cherry-pick <commit1>^..<commit3>

4. 代码冲突解决

不管是使用 Merge、Rebase 还是 Cherry-pick 进行整合来自不同分支的修改操作,在操作的过程中都有可能会出现代码冲突问题,需要将冲突解决完毕后才能继续完成操作。如何解决代码冲突?我们以 merge 合并为例:

如果在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没法干净的合并它们,在合并它们的时候就会产生合并冲突。

此时 Git 做了合并,但是没有自动地创建一个新的合并提交。Git 会暂停下来,等待我们人工手动去解决合并产生的冲突。当发生冲突时,可以在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:

任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。Git 会在有冲突的文件中加入标准的冲突解决标记,使我们可以打开这些包含冲突的文件然后手动解决冲突。出现冲突的文件会包含一些特殊区段,看起来像下面这个样子:

不难看出, ======= 把 <<<<<<< 和 >>>>>>> 这个区段的代码分为了上下两部分, 上部分的代码来自于当前分支,下部分的代码来自于 test1 分支,为了解决冲突,我们必须选择使用由 ======= 分割的两部分中的一个。可以借助 vscode 提示工具,即 <<<<<<< 上方的按钮 Accept Current Change|Accept Incoming Change | Accept Both Change | Compare Chagne 来进行操作。这四个按钮选项所对应的含义如下:

  • Accept Current Chagne 选择当前的修改
  • Accept Incoming Change 选择合并的修改
  • Accept Both Change 接受两者
  • Compare Chagne 比较查看

当然,除了通过选择不同的选项按钮来处理冲突以外,也可以自行合并这些内容。

在解决了所有文件里的冲突之后,使用 git add . (或者点击 Merge Changes 右边的 + 号按钮),告诉 Git 把这些手动解决冲突的文件标记为已解决并添加到暂存区,然后使用 git merge --continue 或 git commit 命令完成合并并创建合并提交,默认情况下提交信息看起来像下面这个样子:

如果觉得上述的信息不够充分,不能完全体现分支合并的过程,可以使用 Vim 命令修改上述信息,以 # 开头的行将被忽略,若不需要修改,使用默认的 commit message "Merge branch test1 into test2",只需直接退出 Vim 编辑即可。整个过程主要会涉及到的 Vim 命令有:使用 i 键进入编辑模式;使用 :q 键退出 Vim;使用 :wq 保存并退出 Vim。

代码冲突出现的场景不仅仅只有在使用 merge、rebase 和 cherry-pick 的时候,在执行 git pull 操作的时候也可能会发生,比如有一个文件,你和另一个人同时修改某段代码的一部分,他把修改完的代码提交了以后,当你使用 git pull 拉代码的时候,就会发现跟你的冲突了。总之,不管是什么场景下发生的代码冲突,都可以参考上述过程进行解决。

参考资料:

Merging vs. rebasing

git merge

相关推荐
咖啡の猫32 分钟前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲3 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5813 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路4 小时前
GeoTools 读取影像元数据
前端
ssshooter4 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry5 小时前
Jetpack Compose 中的状态
前端
百思可瑞教育5 小时前
Git 对象存储:理解底层原理,实现高效排错与存储优化
大数据·git·elasticsearch·搜索引擎
dae bal6 小时前
关于RSA和AES加密
前端·vue.js
柳杉6 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog6 小时前
低端设备加载webp ANR
前端·算法