Git实践

avatar
FE @字节跳动

一、Git原理

Git 合并原理

Git的合并策略是多种纬度的:

  • 有我们平时常用的:分支合并
  • 以及分支合并的基础:文件合并
  • 还有我们查看Git log tree时候:Git节点的合并规则

本小节将全面的手把手教你如何深入理解 Git 的合并原理

文件合并策略:三项合并(Three-Way Merge)

在我们讲解Git的分支合并策略之前,需要先了解一下 Git 合并两个文件里不同内容的基本原则——三项合并(Three-Way Merge)

当我们合并两个内容不同的文件时,我们文件的内容(Yours)和对方文件的内容(Theirs)不一致,Git在这个时候需要判断我们合并的最红结果到底是哪个。

whiteboard_exported_image_(2)-transformed.png

然而,如果只把两个文件进行比对,可以得到下面三种情况:

  • Case 1:我们没有对 File 文件进行改动,对方修改了 File 文件内容

  • Case2:对方没有对 File 文件进行改动,我们修改了 File 文件内容

  • Case3:我们都修改了 File 文件内容

显然,当我们只有两个文件的时候,Git 无法判断是以上哪种情况,此时 Git 没办法帮我们做自动合并,所以这个时候我们需要进行——三项合并,所谓三项合并,就是找到需要合并的两个文件的一个基础,以这个基础来判断,到底属于哪种情况

如果我们的基准文件【Base】的内容是"php is the best language",说明我们没有修改文件,而【Theirs】将文件内容修改为了"java is the language",即上述的 Case 1。 同理如果基准文件的内容为"php is the best language",即为 Case 2

whiteboard_exported_image (3)_result.png

如果【Base】的内容和【Yours】和【Theirs】都不一样的话,说明我们和对方都对该文件同一行进行了修改,这种情况即冲突,需要我们手动去解决,即 Case3

whiteboard_exported_image (4)_result.png

分支合并策略

🤔阅读之前的思考当我们使用 Git merge 来合并分支的时候,发生了什么 ?

当我们在开发分支Feature开发过程中,使用Git mergemaster分支的更新合并到Feature分支后,在Feature分支上会新增一个Commit节点,用于记录这一次的合并动作

whiteboard_exported_image (5)_result.png

🤔 那这个新增的 Commit 节点是根据什么原则生成的呢?在有冲突的情况下又是怎么样的呢?

根据上面的问题,其实Git在执行 Git merge 的时候有不同的合并策略,我们平时最常遇到的,也是 Git 默认的策略是—— Recursive策略

下面我们就来具体看看合并策略具体有哪些,它们的合并规则是怎么样的吧~

一点 Tips:Git 的合并策略指的是 Git merge 过程中可通过-s , --strategy=进行设置所使用的strategy的模式

这里的合并策略包括 :⭐️ recursive,resolve, octopus ,ours,subtree 五种策略

⭐️ Recursive

当两个分支有分叉的情况下进行 Git merge就会默认使用—— Recursive 模式,Recursive 模式 是最重要和最常用的策略。

该算法的基本思路是:递归查找两个分支的最短共同祖先节点,然后以此节点作为基准节点进行递归三项合并。下面我们来看看 Recursive 模式下的合并会有哪些情况

Case One 单个最短共同祖先节点

节点内的文字代表当前该 Commit 的文件的内容

最简单也是最常见的情况是:需要合并的两个节点A,B,它们的共同最短祖先节点是A,根据三项合并的原理,可以得出结论:节点B是新增的修改内容,所以两者合并的结果是B

whiteboard_exported_image (6)_result.png

Case Two 多个最短共同祖先节点

节点内的文字代表当前该 Commit 的文件同一位置的内容

为方便描述,给节点加上序号以作区分,但是实际内容并不改变

梦想很美好,然而现实却很骨感,并非所有的情况都像 Case one 那样简单

合并节点 C(1) 和节点 B(3),可以看到它们的最短公共祖先有两个:节点 B(1) 和 节点 A(2)

  • 如果以节点B(1)作为基准,节点 C(1) 和节点 B(3) 的合并结果为:B
  • 如果以节点A(2)作为基准,节点 C(1) 和节点 B(3) 的合并结果根据三项合并原理,这里是有冲突的,需要手动进行解决

选择不同的祖先节点作为基准,得到的合并结果不一样,这明显是不合理的,所以我们需要继续递归往前找,直到我们找到唯一的最短公共祖先节点

whiteboard_exported_image (7)_result.png

接下来,我们以节点 B(1) 和 节点 A(2) 为要合并的两个节点,去寻找它们的最短公共节点,由图1.2.5可知为 A(1),所以根据三项合并得到节点 B(1) 和 节点 A(2) 合并结果为:B

image.png

最后,我们把节点 B(1) 和 节点 A(2)的合并结果:节点 B 作为一个临时节点,以临时节点 B 作为节点 C(1) 和节点 B(3) 最短公共祖先,进行三项合并得到合并结果为 C,至此,我们就完成了我们的Git merge

image.png

对于更加复杂的情况,比如在递归寻找最短公共祖先的时候,发现此时的祖先节点仍有多个,那么 Git 会重复上述的操作继续往上找,直到我们找到唯一的祖先节点,然后形成虚拟节点,递归往下合并,最后得到合并结果。

Recursive 模式会尽量帮我们减少冲突,但遇到真的冲突的时候,Git 还是会提示需要我们手动地去解决的~

有趣小知识:在Git v2.33.0版本之前,recursive 策略都是 Git 合并的默认策略,之后的默认合并策略改为了 ort 策略(as reflected in its acronym — "Ostensibly Recursive’s Twin")

  • ort 策略修复了递归策略处理次优的极端情况,并且在大型存储库中明显更快—特别是涉及许多重命名的情况

  • ort 策略所有的参数和 recursive 策略一模一样,官方指出,它的出现就是为了替代 recursive 策略😫

Resolve

This can only resolve two heads (i.e. the current branch and another branch you pulled from) using a 3-way merge algorithm. It tries to carefully detect criss-cross merge ambiguities. It does not handle renames.

上面这段描述来自 Git 官网对 Resolve 策略的解释 👉 Git-scm.com/docs/Git-me…

简单的中文解释是:同 Recursive 策略一样,Resolve 策略只能用于合并两个分支(即当前分支和另一个分支),并且使用三项合并算法。它会试图仔细检测十字交叉合并(criss-cross merge)下的分歧,且它不处理重命名。

上面的给人感觉好像什么都说了,又什么都没明白,小小的脑袋充满了大大的疑惑:它到底是如何解决冲突的?它和Recursive 策略又有什么区别?

下面我们就来看看 Resolve 策略是怎么工作的吧~

Criss-cross

在上文 Recursive 小节中有说道,Recursive 在遇到多个最短公共节点时会把它们合并为一个虚拟节点,再递归往前找到最短公共节点

但是,我们并不是每次都能幸运地继续往前找到新的公共节点的,下图可以看到A(3)和B(3)进行合并,它们有两个最短公共节点 A(2) 和 B(2),然而我们发现节点 A(2) 和节点 B(2),它们没有唯一共同祖先了

所以这里,如何去处理这种情况,就是 Recursive 策略和 Resolve 策略的不同之处所在!

image.png

Recursive 策略选择将两个节点作为合并基础。为了实现这一点,它合并节点 A(2) 和节点 B(2) 为虚拟节点,我们暂且称为 X,X 将作为节点 A(3) 和节点 B(3) 的合并基础

Resolve 策略,它将随机地从节点 A(2) 和节点 B(2) 中选择一个,作为合并基础。具体选择哪个节点取决于所使用的算法,该算法没有指定,并且可能会随着 Git 版本的不同而变化。

image.png

其他策略

Octopus

这种合并方式用于两个以上的分支,但是在遇到冲突需要手动合并时会拒绝合并。这种合并方式更适合于将多个分支捆绑在一起的情况,也是多分支合并的默认合并策略。

ours

如果不冲突,那么与默认的合并方式相同。但如果发生冲突,将自动应用自己这一方的修改。

Subtree

此策略使用的是修改后的递归三路合并算法。与 recursive 不同的是,此策略会将合并的两个分支的其中一个视为另一个的子树,就像 Git subtree 中使用的子树一样。

Head合并策略

  Fast-Forward

  在两个分支不存在分叉的情况下,`Git merge`**默认**使用最简单的合并策略 —— **Fast-Forward 模式**

  即分支会直接把当前 Head 指针指向所 merge 的分支的最新 Commit 节点

image.png

  Rebase

  Git rebase 也是一种经常被用来做合并的方法,其与Git merge的最大区别是,他会更改变更历史对应的 commit 节点。

image.png

Feature分支在从master分支切出去之后新增了两个 commit 节点commit-feature-1commit-feature-2,master 分支也新增了两个 commit 节点

  此时如果在Feature分支上进行Git rebase master后,Git 会以 master 分支为基础,新增commit-feature-1*commit-feature-2*代替Feature分支中的commit-feature-1commit-feature-2两个节点

有趣小知识:Feature分支新增两个全新的 commit 节点的 commitID 和之前的 commit 节点的commitID 是不一样的,原因是因为:新的 commit 指向的 parent 变了,所以对应的 SHA1 值也会改变,所以没办法复用原 Feature分支中的 commit

具体原理可见 👉 www.lzane.com/tech/Git-in…

二、踩坑场景介绍

MR始终包含错误的revert commit代码

踩坑场景重现

在开发业务需求的时候,公共开发分支为 epic_v2.6.2_beta,个人开发分支为 epic_v2.6.2_beta_zyj,在某次合入公共开发分支的mr中(epic_v2.6.2_beta_zyj ---> epic_v2.6.2_beta)发现:

问题1:mr 中携带了错误代码,这些代码是 epic_v2.5.1 的 commit 的 revert(相当于是该 mr 把 epic_v2.5.1的某些commit 给 revert 掉了)

问题2:并且该 revert diff 找不到是由哪次对应的 commit 带来的

问题3:epic_v2.6.2_beta 在强制 push 后重置了分支代码,epic_v2.6.2_beta_zyj 拉取了该分支的改动,但后续但凡是由 epic_v2.6.2_beta_zyj 分支进行提交的 mr 中都会带上 revert 的代码

image.png

踩坑原因&问题解释

我们以上面的流程为例画一个简单的示意图:

epic_v2.6.2_beta_zyj ``1``为最初从 epic_v2.6.2_beta 拉出的来本地分支节点

epic_v2.6.2_beta_zyj ``2 为错误拉取 epic_v2.5.1 分支生成的节点

epic_v2.6.2_beta ``1``为从 epic_v2.6.2 分支切出来生成的节点

epic_v2.6.2_beta ``2``为由 epic_v2.6.2_beta_zyj_2节点 和 epic_v2.6.2_beta_1 节点通过recursive策略生成的节点

image.png

可以看到 epic_v2.6.2_beta_zyj_2 节点和 epic_v2.6.2_beta_1 节点的共同父节点是 epic_v2.5.1 节点,以 epic_v2.5.1 节点作为 base 我们做三项合并,可以得到的结果是 ----> epic_v2.6.2_beta_zyj_2 节点把 epic_2.5.1带来的改动都删了, 所以这里解释了上述的问题1

同样的,我们拉取了共同开发分支的 reset 操作到本地 epic_v2.6.2_beta_zyj,继续在 epic_v2.6.2_beta_zyj 进行开发,这个时候我们提 mr (epic_v2.6.2_beta_zyj ---> epic_v2.6.2_beta)并且我们会得到下图

image.png

epic_v2.6.2_beta 4节点和epic_v2.6.2_beta_zyj 3的共同父节点是epic_v2.6.2_beta 3,通过三项合并原理,Git 得到的结论仍然是:epic_v2.6.2_beta_zyj_4 节点把 epic_2.5.1 带来的改动都删了,所以这里解释了上述的问题3

改进措施

之所以导致这些问题的根因是因为:本地开发时候已经接受了远端错误分支的 commit(在没有把 Head 回退到 接受这些错误 commit 之前的状态就继续在这基础上进行开发了) ,产生了新的节点,导致 Git 做三项合并的时候会以 epic_v2.5.1为 base 进行比对,所以我们要做的就是在拉取了错误代码的时候 使用 Git merge --abort 或者 Git reset 去回退到原来的 Head 节点

  1. ❌ [不推荐] 手动撤回 但是但凡是 手动的/人为的 都有存在出错的可能性,所以还是建议通过 Git 命令去处理

  2. ✅ [推荐-还未有任何改动] Git merge --abort ****会直接恢复 Head 到 merge 操作之前的状态

  3. ✅ [推荐-已有新增改动-但未提交] 保留新增的改动,再执行Git merge --abort恢复 Head 状态

  4. ✅ [推荐-已有新增改动-且已提交] 可以通过Git reset --soft [commitID] 去重置 Head 的代码,然后保留新增的改动,再执行Git merge --abort

自动同步分支带来误操作

在使用每个release一个单独的分支的开发流程中,我们经常需要执行的一个动作就是把老分支的代码合并到新分支上。但是当这次MR出现冲突的时候,不正确的使用 Git lab 上的Merge Online按钮,会带来意想不到的副作用。这是由于Gitlab在网页上解决冲突的手法是使用老分支merge新分支。从而导致新分支上的新需求被同步到老分支上了。

image.png

错误做法

使用Gitlab网页的Merge Online,编辑解决冲突。此时我们可以看到图中,v2.8.2分支的指向已经移动了, 说明此时v2.8.2包含的内容与原本预期上线的v.2.8.2是不一样的。这种变化,可能对于feat或者dev分支来说是没有什么问题的。但是对于希望构建发版的代码分支来说则是完全不可接受的。

正确做法

在本地切换到新分支,直接使用 Git pull origin old_branch --merge 解决冲突之后,Git merge --continue. 然后push代码。

Git checkout origin/v2.9.1 -b merge_2.8.2
Git pull origin v2.8.2 --merge
# 解决冲突
Git merge --continue
Git push origin merge_2.8.2
# 在网页上发起 merge_2.8.2 -> v2.9.1 的Merge Request

错误操作之后的的恢复方法

对于不小心发分支合并到了某分支之后,虽然Gitlab也提供了revert的功能,但是不建议使用这个功能,因为这个功能实际上是类似于提交一个新的Merge Request来消除前面那个提交的代码。

而实际上。我们可以通过多种方式来恢复。首先我们需要认真记住的是:“Git的分支只是指向某一个commit的指针”。而我们的目标就是“让分支的指针重新指向原本的位置”,因此我们可以通过

  1. 直接修改这个指针并且push -f (不推荐
  2. 重命名远端已经错误的分支,在远端从想要的commit hash创建同名分支。

这两种解决方法最终造成的效果如图。我们可以看到虽然commit f 的parent仍然是e 和 c,但是这并不影响我们已经成功恢复了v2.9这个分支的指针到commit e了。而这个远比Gitlab默认提供的在生成一个MR,通过这个MR来消除v2.8的提交要来的简洁。

image.png

三、Git实战技巧

Git仓库迁移

该文章讲述了两个因为需要合并成一个monorepo的仓库的操作手法。利用Git merge 的允许不相干历史合并的选项,将两个仓库合并到同一个大的monorepo上,并且保留提交记录的操作方法。

image.png

代码合并技巧

Cherry-Pick

cherry-pick也可以作为一种合并代码的方式。当我们的分支A是从一个很老的revision checkout出来的,而我们的修改又只有一个一个commit。当此时的Merge request提示冲突,而解决冲突又很费时的时候,可以考虑从目标分支checkout一个分支作为新的source barnch,然后cherrypick想要合并的commit到新的source branch。这样可以尽量避免解决冲突或尽量少解决冲突。这在项目要求合并分支必须是Fast Forward的时候,能够帮助大大减少需要解决冲突的次数。

image.png

Patch文件

在一些交流或者想要把自己的一些修改同步给其他同学的时候,可以使用Patch的方法来传递。具体的操作如下

Git log -p {COMMIT_HASH} -1 > my_work.patch
# 将 my_work.patch 传送给对方
Git apply my_work.patch

Diff文件

没有commit的代码也可以通过一些办法移交给其他同学。可以如下处理

# 在仓库根目录下
Git diff > my_work.diff
# 把 my_work.diff 文件发送给对方
# 将 my_work.diff 移动到对方的仓库根目录下
Git apply my_work.diff

并行开发技巧

并行开发,我最常用的就是Git`` worktree 了。在这里推荐给大家。worktree可以帮助节省磁盘空间,在多个分支并行开发的时候,不需要clone多次,导致.``Git目录占用大量磁盘空间。只需要在想要制作worktree“分身”的仓库下,运行Git`` worktree add ${TARGET_ABSOLUTE_PATH} ${TARGET_BRANCH_NAME} 即可在TARGET_ABSOLUTE_PATH的位置新建一个worktree,用终端cd到这个目录下,就可以立刻开始开发。这个目录下的一些表现,与原本的目录一致。

image.png