分支
1. 分支指针
理解分支「指针」是理解分支的基础。对于初学者而言,对于分支的难懂、难用的原因在于容易混淆分支指针和分支的概念。
分支指针的核心关键在于:它是一个指针,这个指针总是指向你的提交记录中的某个节点的。
Git Repository 的默认的初始情况有一个名为 master 的指针。
为什么一个 Git Repository 初始情况下必须要有一个提交?
因为 master 指针天生存在,而指针又必然指向某个提交节点,那么一个 Git Repository 任意时刻至少必须要有一个提交节点,否则 master 指针就是无处安放啊?!
下图中有 5 个指针,分别指向 5 个不同的节点( 提交记录 )。
更复杂的一点的情况是:两个( 甚至更多 )的指针指向同一个节点。例如,下图的 hello 和 bye 指针就指向了 D 节点 。
在任意时刻,你( 程序员 )一定是在使用某个指针的。在 GitKraken 的图形化界面上,这个指针的前面就有个 ✓ 符号。上两张图中分别就是 master 和 hello 前面带 ✓ 。
「某个指针指向某个节点」这个事情是会变的!例如,通过「提交」和「硬撤」你会发现指针可以指向不同的节点。
当然,变动的总是那个带 ✓ 的指针,你想变动『别的指针』,你就要「想办法」让『别的指针』带这个 ✓ 。
2. 分支
以 xxx 指针所指向的提交( 节点 )作为「末端」,回溯( 树藤摸瓜往回摸 )到起点,这一条路就被称为 xxx 分支。
根据以上的定义,我们知道:
- xxx 指针是定义 xxx 分支的依据和根本;
- xxx 指针一定是 xxx 分支的「末端」;
- xxx 指针「变」了位置,那么就意味着 xxx 分支有了变化。
我们未来会经常看到如下的各种情况:
有 2 个分支有部分节点是重合的:
上图中,以 hello 指针所指向的节点作为末端的分支( 即,hello 分支 )和以 world 指针所指向的节点作为末端的分支( 即,world 分支 )有 3 个节点( 提交记录 )是重合的。
有 2 个分支的所有节点是全部重合的:
上图中,以 hello 指针所指向的节点作为末端的分支( 即,hello 分支 )和以 master 指针所指向的节点作为末端的分支( 即,master 分支 )的全部节点都是重合的。
在 GitKraken 的图形化显示上,两个指针是「叠」在一起的,显示在前面的是你( 程序员 )正在使用的当前指针,也就是带 ✓ 的那个指针。当你把鼠标移上去的时候,这个「叠」的效果会展开,露出指向这个节点的所有的指针。
有一个分支是另一个分支的一部分:
master 分支所记录的提交记录是 good 分支所记录的提交记录一半。或者说,good 分支所记录的提交记录「完全涵盖」了 master 分支所记录的提交历史。
有人将分支比喻成泳道,但我个人不太喜欢这种比喻。因为有些场景用泳道比喻不出来,所以这里就不展开说泳道这个说法了。
3. 分支基本操作
# | 本质 |
---|---|
查看分支 | git branch 本质上,查看分支就是在查看所有的分支指针。 |
创建分支 | git branch <新分支指针> [某提交记录] 本质上,创建分支是新增一个新的分支指针,指向当前( 或指定的节点 )。以这个指针所指向的这个节点作为末端,自然也就意味着新增了一个分支。 |
切换分支 | git checkout <分支指针> 本质上,切换分支是切换你( 程序员 )当前所使用的分支指针,简单来说,就是然另一个分支指针前面带 ✓ 。 |
删除分支 | git branch -d <分支指针> 本质上,删除分支实际上是要删除一个分支指针。这个分支指针消失了,你自然就无法使用这个分支指针指向某个节点,再溯源了,自然也就没有这个分支了。 |
4. 为什么需要分支
版本的提交不可能『依次进行,以便形成一条直线型的提交历史记录』,原因有二:
需要并行式开发
有两个以上的开发者在对同一个项目进行并行式开发。张三在 B 版本的基础上开发了 xxx 功能,形成了 C 版本;李四在 B 版本的基础上开发了 yyy 功能,形成了 D 版本。
- 个人视角:
张三视角如下: 李四视角如下:
A <--- B <--- C A <--- B <--- D
- 上帝视角:
C
/
A <--- B
\
D
如何「造」出这种情况?
- 基于 B 节点创建一个新分支并使用;
- 做些许变动,提交;
- 基于 B 节点再创建一个新分支并使用;
- 做些许变动,提交。
修复旧版本中的 bug
一方面要修复旧版本中的 bug ,而与此同时又要创建和发布新的版本。
- 前期
A <--- B <--- C <--- D
- 修复 bug 之后
C <--- D
/
A <--- B
\
E
如何「造」出这种情况?
- 当前在 D 节点;
- 基于 B 节点创建分支,并使用;
- 做出变动,提交。
项目经理操作
5. 分支合并在大多数情况下,项目的分支都会被合并到主( master )分支。合并项目分支需要使用 git merge
命令:
git merge <另一个分支名>
该命令会把『另一个分支』合并到当前分支,合并后的 Commit 属于当前分支。
你站在哪个分支上?当前分支是谁?
考虑这个问题的关键点在于:合并分支是合并「进来」。体会下,什么叫合「进」来。
模拟 git merge 的操作思路/流程:
查看「那个分支」和「我」是从哪个节点开始「分叉」的。最极端的情况,可能除了第一个节点之外,它和我就已经不一样了。
统计一下,自分叉开始,那个分支做了哪些变动。新增了啥?删除了啥?改动了啥?
把「它」的那些变动,在「我」身上做一遍。我的提交记录会向前「走」一步,多一个节点出来。
例如:
合并前:
A <--- B <--- F <---- I world
\
G <--------- H good
因为是站在 world 角度,要把 good 分支的提交记录「合进来」,所以是要站在 world 分支上( ✓ 在 world 处 ),用鼠标去「点」 good 分支,去操作 good 分支。
world 分支和 good 分支是从 B 节点开始分叉的,所以 Git 会去统计 B 节点之后 good 分支一共发生了哪些变动,也就是 G 和 H 提交记录中的那些变动。
Git 会将这些变动在 world 分支的末端( 也就是 world 指针所指向的那个节点,即 I 节点 )的基础上同样执行一些变动,从而形成一个新的节点。
合并后:
A <--- B <--- F <---- I <---- 合并点 world
\ /
G <--------------- H good
提示 2 点
合并后 world 指针向前迈了一步,而 good 指针原地没动。
想要撤销合并,可以让 world 指针「硬撤」一步,即,在 world 指针当前节点的前一个身上执行
git reset --hard
操作。
6. 冲突
什么是冲突
前面提到过
合并时的逻辑是 git 去统计「那个」分支自你俩「分叉」之后发生了哪些变动,然后在「我」身上执行一次这些变动。
合并 git merge 过程是自动的,但又不是完全自动。即,有些情况下,能自动执行;有些情况下,还是需要你( 程序员 )人工干预。
因为「我」分支和「那个」分支在同一个文件的同一个位置做出了不同的提交。对于合并后的成果,对于这个文件的这个位置,Git 不知道是像「我」分支这样,还是像「那个」分支那样。
例如:
在 B 节点时,文档的内容是 hello 和 world 。
hello 分支基于 B 节点,在文档第 3 行添加了
xxx
,形成了 C 节点。world 分支基于 B 节点,在文档第 3 行添加了
yyy
,形成了 D 节点。
现在「站在」hello 分支的角度上,要把 world 分支合进来( 纳入 world 分支的所有变动 ),Git 就会遇到一个无法自决的难题,需要你( 程序员 )的人为干预:
hello 分支和 world 分支在 B 节点分叉,即,在 B 节点之后,hello 分支和 world 分支对 Git Repository 的变动就不一样了; world 分支在文档的第 3 行加了
yyy
,那么,现在 hello 分支需要照着这个操作也来一遍。但是,现在 hello 分支当前的情况是文档的第 3 行已经有了一行内容
xxx
,怎么办?Git 不知道,或者说,它不敢动了。
这就是冲突。
上图有 4 处与分支有关:
- ① 和 ② 是提示信息,GitKraken 在告诉你,合并过程中发生了冲突。即,有些文档的变动,它做不了主,需要你( 程序员 )来裁决。
- ③ 处列出的是在这次合并过程中,有哪个、哪些文件有冲突。这些有冲突的文件此时处于一种特殊的状态:Conflicted( 有冲突 )状态。
- ④ 处是对冲突的后续处理。因为此时此刻你正在「合并过程中」,所以接下啦要么你取消合并,回到合并前,当作啥事都没发生;要么就人工裁决这些有冲突的文件该如何如何,然后继续合并。
警告
再次提醒,发生冲突时你的「合并动作」失败暂停了,即,你此时此刻仍然是处于「合并中」,后续,你要么取消合并,要么必须裁决文件的冲突,然后继续合并。不能吊在半空,占不着村,后不着店!
冲突文件的内容
你可以在磁盘上( 即,工作区 )去查看冲突文件的内容。你会发现,因为需要你的人工裁决,Git 为了便于你的裁决,它将「我分支」和「那个分支」对于这个文件的同一个位置的两种不同的改动「并排」地放在了哪个地方。
文件的内容的格式类似如下:
<<<<<<<< HEAD
这种、这种、这种改法
========
那种、那种、那种改法
>>>>>>>> xxx
以 ======
作为分隔符,<<<< HEAD
到 ====
之间的内容是「我分支」在文档的这个地方的写法;而 ====
到 >>>> xxx
之间的内容是「那个分支」( 这里的「那个分支」是 xxx 分支 ) 在文档的这个地方的写法。
解决冲突
所谓冲突就是对于同一个地方现在有两种不同写法,其解决方案无非就是 4 种:
- 采用「我」分支的写法,舍弃「那个」分支的写法。
- 采用「那个」分支的写法,舍弃「我」分支的写法。
- 都保留
- 都不要( 这种情况下,这个文件的内容就是两个分支为分叉时的那个样子 )
注意,根据具体情况的不同,对于当前的冲突无论最终你采用的是上述那种解决方案,最终你的冲突文件文件中肯定是没有 <<<< HEAD
、====
和 >>>> xxx
这三个内容的。否则就是逻辑上就是有问题的,因为这些内容是 Git 加入到文档中的,最终冲突解决后应该被删除的。
冲突和解决冲突示意图:
7. git mergetool
如果配置了 git mergetool 那么,在 Git 告知你合并冲突后,通过 git mergetool 命令启动第三方合并工具,来进行图形化界面的操作。例如,Beyond Compare 或者是 VS Code 。
如果使用 VS Code 作为第三方合并工具,那么需要在 .gitconfig 中追加如下配置:
[merge]
tool = vscode
[mergetool "vscode"]
keepbackup = false
cmd = code --wait $MERGED
trustexitcode = true
个人建议
目前我个人比较倾向于使用 VS Code 作为辅助的合并工具。因为,一方面它能够让你直面合并文件的本质,另一方面,它提供了必要的快捷操作按钮。基本上同时兼具了「本质」和「快捷」两方面。
vs code 作为 mergetool 的效果图:
另一种产生冲突的原因
上面是以「两个分支对同一个文件的同一处地方做了不同的内容变动」为例来描述冲突的诞生,除了这种情况之外,还有一种情况能产生冲突:「两个分支对于同一个文件,一个是保留并改动,另一个是删除」,这种情况合并分支时,也会产生冲突。
提示
当然,你如果硬扣细节、讲本质,这两种造成冲突的情况本质都是一样的。
例如:
在 B 节点时,文档的内容是 hello 和 world 。
hello 分支基于 B 节点,在文档第 3 行添加了
xxx
,形成了 C 节点。world 分支基于 B 节点,把文档整个都删除了,形成了 D 节点。
最终合并时,会出现冲突提示,暂停合并过程,等待人工干预。如下图。
对于这种情况,解决方案就三种( 比之前的那种情况少一种 ):
- 保留文件,采用「改」版。
- 删除文件,采用「删」版。
- 都不采用( 这种情况下,这个文件的内容就是两个分支为分叉时的那个样子 )
和前一种冲突的图形化形式相比,这种情况下,你点击查看具体冲突文件时,GitKraken 是在最上方提示如何处理。
项目经理操作
8. 快速合并:git merge --ff之前的合并操作都会产生一个合并节点( 在 GitKraken 种是一个小的、实心的点 )。
有一种情况的分支的合并可以不用生成合并节点:「一个分支的提交记录整个都是另一个分支的提交记录的一部分」这种情况下的分支合并可以不用产生合并节点。例如:
如上图,world 分支上的所有提交记录在 hello 分支上都有。这种情况下,我们「站在」world 分支上想把 hello 分支的内容「合进来」。
我们模拟一下 Git 在合并时的逻辑:
先查找 hello 分支和 world 分支未分叉时的节点。这里是 B 节点。注意,此时此刻,world 分支指针正指向 B 节点。
Git 会统计 hello 节点自分叉节点开始,一共做了哪些改动。
在 world 分支上将 hello 分支做过的变动也照样做一遍。
此时,你会发现,world 分支如果把这些变动照做一遍,那么它就会变得和 hello 分支一模一样!这种情况下,只需要让 world 指针指向 hello 指针所指向的那个节点就行了!
这种方式的合并就叫「快速合并」。和普通合并相比,快速合并不会生成合并节点,因为没有必要。
快速合并使用的命令时带 -ff 参数的 git merge 。从命名上看,这种合并也叫快进( Fast Forward )式合并。
当然,快速合并有个局限性:它只能在我们所说的这种情况下才能使用,而不像普通合并那么有普适性。