git-merge详解

git-merge 完全解析

Git 的 git-merge 是在 Git 中频繁使用的一个命令,很多人都觉得 git 合并是一个非常麻烦的事情,一不小心就会遇到丢失代码的问题,从而对 git 望而却步。本文基于 Git 2.8.2 对 git-merge 命令进行完整详细的介绍,特别是关于交叉合并所带来的代码遗失问题,在文末给出自己的建议,希望能够帮助到 git 的使用者。本文所介绍的内容基于 Git 2.8.2

git-merge 命令是用于将两个或两个以上的开发历史合并在一起的操作,通常也可写作:git merge。

转载:https://www.jianshu.com/p/58a166f24c81

1 git-merge 相关的选项参数

1.1 摘要

在 git-merge 命令中,有以下三种使用参数:

  • git merge [-n] [--stat] [--no-commit] [--squash] [--[no-]edit] [-s <strategy>] [-X <strategy-option>] [-S[<keyid>]] [--[no-]rerere-autoupdate] [-m <msg>] [<commit>...]
  • git merge <msg> HEAD <commit>...
  • git merge --abort

1.2 git-merge 简介

git-merge 命令是用于从指定的 commit(s) 合并到当前分支的操作。

注:这里的指定 commit(s) 是指从这些历史 commit 节点开始,一直到当前分开的时候。

git-merge 命令有以下两种用途:

  1. 用于 git-pull 中,来整合另一代码仓库中的变化(即:git pull = git fetch + git merge)
  2. 用于从一个分支到另一个分支的合并

假设下面的历史节点存在,并且当前所在的分支为 “master”:

7810BEAB-4124-48D7-A185-CD0A1626CFC8.png

那么

git merge topic

命令将会把在 master 分支上二者共同的节点(E 节点)之后分离的节点(即 topic 分支的 A B C 节点)重现在 master 分支上,直到 topic 分支当前的 commit 节点(C 节点),并位于 master 分支的顶部。并且沿着 master 分支和 topic 分支创建一个记录合并结果的新节点,该节点带有用户描述合并变化的信息。

即下图中的 H 节点,C 节点和 G 节点都是 H 节点的父节点。

C8CF6D76-B282-42E6-ABAF-E481223835FB.png

1.3git merge <msg> HEAD <commit>...命令

该命令的存在是由于历史原因,在新版本中不应该使用它,应该使用git merge -m <msg> <commit>....进行替代

1.4 git merge --abort命令

该命令仅仅在合并后导致冲突时才使用。git merge --abort将会抛弃合并过程并且尝试重建合并前的状态。但是,当合并开始时如果存在未 commit 的文件,git merge --abort在某些情况下将无法重现合并前的状态。(特别是这些未 commit 的文件在合并的过程中将会被修改时)

警告:运行git-merge时含有大量的未 commit 文件很容易让你陷入困境,这将使你在冲突中难以回退。因此非常不鼓励在使用git-merge时存在未 commit 的文件,建议使用git-stash命令将这些未 commit 文件暂存起来,并在解决冲突以后使用git stash pop把这些未 commit 文件还原出来。

2 参数

本部分用于介绍git-merge命令中使用的参数

2.1 --commit--no-commit

--commit参数使得合并后产生一个合并结果的 commit 节点。该参数可以覆盖--no-commit
--no-commit参数使得合并后,为了防止合并失败并不自动提交,能够给使用者一个机会在提交前审视和修改合并结果。

2.2 --edit-e以及--no-edit

--edit-e用于在成功合并、提交前调用编辑器来进一步编辑自动生成的合并信息。因此使用者能够进一步解释和判断合并的结果。
--no-edit参数能够用于接受自动合并的信息(通常情况下并不鼓励这样做)。

如果你在合并时已经给定了-m参数(下文介绍),使用 --edit(或-e)依然是有用的,这将在编辑器中进一步编辑-m所含的内容。

旧版本的节点可能并不允许用户去编辑合并日志信息。

2.3 --ff命令

--ff是指 fast-forward 命令。当使用 fast-forward 模式进行合并时,将不会创造一个新的 commit 节点。默认情况下,git-merge采用 fast-forward 模式。
关于 fast-forward 模式的详细解释,请看我的另一篇文章:一个成功的 Git 分支模型的 “关于 fast forward” 一节。

2.4 --no-ff命令

即使可以使用 fast-forward 模式,也要创建一个新的合并节点。这是当git merge在合并一个 tag 时的默认行为。

2.5 --ff-only命令

除非当前 HEAD 节点已经 up-to-date(更新指向到最新节点)或者能够使用 fast-forward 模式进行合并,否则的话将拒绝合并,并返回一个失败状态。

2.5 --log[=<n>]--no-log

--log[=<n>]将在合并提交时,除了含有分支名以外,还将含有最多 n 个被合并 commit 节点的日志信息。
--no-log并不会列出该信息。

2.6 --stat, -n, --no-stat命令

--stat参数将会在合并结果的末端显示文件差异的状态。文件差异的状态也可以在 git 配置文件中的 merge.stat 配置。
相反,-n, --no-stat参数将不会显示该信息。

2.7 --squash--no-squash

--squash 当一个合并发生时,从当前分支和对方分支的共同祖先节点之后的对方分支节点,一直到对方分支的顶部节点将会压缩在一起,使用者可以经过审视后进行提交,产生一个新的节点。

注意 1: 该参数和--no-ff冲突

注意 2: 该参数使用后的结果类似于在当前分支提交一个新节点。在某些情况下这个参数非常有用,例如使用 Git Flow 时(关于 Git Flow,请参考:一个成功的 Git 分支模型),功能分支在进行一个功能需求的研发时,开发者可能在本地提交了大量且无意义的节点,当需要合并到 develop 分支时,可能仅仅需要用一个新的节点来表示这一长串节点的修改内容,这时--squash命令将会发挥作用。此外,如果功能分支的多次提交并不是琐碎而都是有意义的,使用--no-ff命令更为合适。
--no-squash的作用正好相反。

2.8 -s <strategy>--strategy=<strategy>

-s <strategy>--strategy=<strategy>用于指定合并的策略。默认情况如果没有指定该参数,git 将按照下列情况采用默认的合并策略:

  1. 合并节点只含有单个父节点时(如采用 fast-forward 模式时),采用 recursive 策略(下文介绍)。
  2. 合并节点含有多个父节点时 (如采用 no-fast-forward 模式时),采用 octopus 策略(下文介绍)。

2.9 -X <option>--strategy-option=<option>

-s <strategy>时指定该策略的具体参数(下文介绍)。

2.10 --verify-signatures, --no-verify-signatures

用于验证被合并的节点是否带有 GPG 签名,并在合并中忽略那些不带有 GPG 签名验证的节点。
(以下引用摘自一篇转载的文章,由于我没有找到原作者,因此无法提供原作者信息和原文链接,如果有所侵权请私信或者评论告知,我将删除以下引用内容。)

GPG 是加密软件,可以使用 GPG 生成的公钥在网上安全的传播你的文件、代码。
为什么说安全的?以 Google 所开发的 repo 为例,repo 即采用 GPG 验证的方式,每个里程碑 tag 都带有 GPG 加密验证,假如在里程碑 v1.12.3 处你想要做修改,修改完后将这个 tag 删除,然后又创建同名 tag 指向你的修改点,这必然是可以的。但是,在你再次 clone 你修改后的项目时,你会发现,你对此里程碑 tag 的改变不被认可,验证失败,导致你的修改在这里无法正常实现。这就是 GPG 验证的作用,这样就能够保证项目作者(私钥持有者)所制定的里程碑别人将无法修改。那么,就可以说,作者的代码是安全传播的。
为什么会有这种需求?一个项目从开发到发布,再到后期的更新迭代,一定会存在若干的稳定版本与开发版本(存在不稳定因素)。作为项目发起者、持有者,有权定义他(们)所认可的稳定版本,这个稳定版本,将不允许其他开发者进行改动。还以 Google 的 repo 项目为例,项目所有者定义项目开发过程中的点 A 为稳定版 v1.12.3,那么用户在下载 v1.12.3 版本后,使用的肯定是 A 点所生成的项目、产品,就算其他开发者能够在本地对 v1.12.3 进行重新指定,指定到他们修改后的 B 点,但是最终修改后的版本给用户用的时候,会出现 GPG 签名验证不通过的问题,也就是说这样的修改是不生效的。

2.11 —summary,--no-summary

--stat--no-stat相似,并将在未来版本移除。

2.12 -q--quiet

静默操作,不显示合并进度信息。

2.13 -v--verbose

显示详细的合并结果信息。

2.14 --progress--no-progress

切换是否显示合并的进度信息。如果二者都没有指定,那么在标准错误发生时,将在连接的终端显示信息。请注意,并不是所有的合并策略都支持进度报告。

2.15 -S[<keyid>]--gpg-sign[=<keyid>]

GPG 签名。

2.16 -m <msg>

设置用于创建合并节点时的提交信息。
如果指定了--log参数,那么 commit 节点的短日志将会附加在提交信息里。

2.17 --[no-]rerere-autoupdate

rerere 即 reuse recorded resolution,重复使用已经记录的解决方案。它允许你让 Git 记住解决一个块冲突的方法,这样在下一次看到相同冲突时,Git 可以为你自动地解决它。

2.18 --abort

抛弃当前合并冲突的处理过程并尝试重建合并前的状态。

3 关于合并的其他概念

3.1 合并前的检测

在合并外部分支时,你应当保持自己分支的整洁,否则的话当存在合并冲突时将会带来很多麻烦。
为了避免在合并提交时记录不相关的文件,如果有任何在 index 所指向的 HEAD 节点中登记的未提交文件,git-pull 和 git-merge 命令将会停止。

3.2 fast-forward 合并

通常情况下分支合并都会产生一个合并节点,但是在某些特殊情况下例外。例如调用 git pull 命令更新远端代码时,如果本地的分支没有任何的提交,那么没有必要产生一个合并节点。这种情况下将不会产生一个合并节点,HEAD 直接指向更新后的顶端代码,这种合并的策略就是 fast-forward 合并。

3.3 合并细节

除了上文所提到的 fast-forward 合并模式以外,被合并的分支将会通过一个合并节点和当前分支绑在一起,该合并节点同时拥有合并前的当前分支顶部节点和对方分支顶部节点,共同作为父节点。
一个合并了的版本将会使所有相关分支的变化一致,包括提交节点,HEAD 节点和 index 指针以及节点树都会被更新。只要这些节点中的文件没有重叠的地方,那么这些文件的变化都会在节点树中改动并更新保存。
如果无法明显地合并这些变化,将会发生以下的情况:

  1. HEAD 指针所指向的节点保持不变
  2. MERGE_HEAD指针被置于其他分支的顶部
  3. 已经合并干净的路径在 index 文件和节点树中同时更新
  4. 对于冲突路径,index 文件记录了三个版本:版本 1 记录了二者共同的祖先节点,版本 2 记录了当前分支的顶部,即 HEAD,版本 3 记录了MERGE_HEAD。节点树中的文件包含了合并程序运行后的结果。例如三路合并算法会产生冲突。
  5. 其他方面没有任何变化。特别地,你之前进行的本地修改将继续保持原样。
    如果你尝试了一个导致非常复杂冲突的合并,并想重新开始,那么可以使用git merge --abort

关于三路合并算法:
三路合并算法是用于解决冲突的一种方式,当产生冲突时,三路合并算法会获取三个节点:本地冲突的 B 节点,对方分支的 C 节点,B,C 节点的共同最近祖先节点 A。三路合并算法会根据这三个节点进行合并。具体过程是,B,C 节点和 A 节点进行比较,如果 B,C 节点的某个文件和 A 节点中的相同,那么不产生冲突;如果 B 或 C 只有一个和 A 节点相比发生变化,那么该文件将会采用该变化了的版本;如果 B 和 C 和 A 相比都发生了变化,且变化不相同,那么则需要手动去合并; 如果 B,C 都发生了变化,且变化相同,那么并不产生冲突,会自动采用该变化的版本。最终合并后会产生 D 节点,D 节点有两个父节点,分别为 B 和 C。

3.4 合并 tag

当合并一个 tag 时,Git 总是创建一个合并的提交,即使这时能够使用 fast-forward 模式。该提交信息的模板预设为该 tag 的信息。额外地,如果该 tag 被签名,那么签名的检测信息将会附加在提交信息模板中。

3.5 冲突是如何表示的

当产生合并冲突时,该部分会以<<<<<<<, =======>>>>>>>表示。在=======之前的部分是当前分支这边的情况,在=======之后的部分是对方分支的情况。

3.6 如何解决冲突

在看到冲突以后,你可以选择以下两种方式:

  • 决定不合并。这时,唯一要做的就是重置 index 到 HEAD 节点。git merge --abort用于这种情况。
  • 解决冲突。Git 会标记冲突的地方,解决完冲突的地方后使用git add加入到 index 中,然后使用git commit产生合并节点。
    你可以用以下工具来解决冲突:
  • 使用合并工具。git mergetool将会调用一个可视化的合并工具来处理冲突合并。
  • 查看差异。git diff将会显示三路差异(三路合并中所采用的三路比较算法)。
  • 查看每个分支的差异。git log --merge -p <path>将会显示HEAD版本和MERGE_HEAD版本的差异。
  • 查看合并前的版本。git show :1:文件名显示共同祖先的版本,git show :2:文件名显示当前分支的 HEAD 版本,git show :3:文件名显示对方分支的MERGE_HEAD版本。

4 合并策略

Git 可以通过添加 - s 参数来指定合并的策略。一些合并策略甚至含有自己的参数选项,通过-X<option>设置这些合并策略的参数选项。(不要忘记,合并可以在 git merge 和 git pull 命令中发生,因此该合并策略同样适用于 git pull)。

4.1 resolve

仅仅使用三路合并算法合并两个分支的顶部节点(例如当前分支和你拉取下来的另一个分支)。这种合并策略遵循三路合并算法,由两个分支的 HEAD 节点以及共同子节点进行三路合并。
当然,真正会困扰我们的其实是交叉合并(criss-cross merge)这种情况。所谓的交叉合并,是指共同祖先节点有多个的情况,例如在两个分支合并时,很有可能出现共同祖先节点有两个的情况发生,这时候无法按照三路合并算法进行合并(因为共同祖先节点不唯一)。resolve 策略在解决交叉合并问题时是这样处理的,这里参考《Version Control with Git》:

In criss-cross merge situations, where there is more than one possible merge basis, the resolve strategy works like this: pick one of the possible merge bases, and hope for the best. This is actually not as bad as it sounds. It often turns out that the users have been working on different parts of the code. In that case, Git detects that it’s remerging some changes that are already in place and skips the duplicate changes, avoiding the conflict. Or, if these are slight changes that do cause conflict, at least the conflict should be easy for the developer to handle

这里简单翻译一下:在交叉合并的情况时有一个以上的合并基准点(共同祖先节点),resolve 策略是这样工作的:选择其中一个可能的合并基准点并期望这是合并最好的结果。实际上这并没有听起来的那么糟糕。通常情况下用户修改不同部分的代码,在这种情况下,很多的合并冲突其实是多余和重复的。而使用 resolve 进行合并时,产生的冲突也较易于处理,真正会遗失代码的情况很少。

4.2 recursive

仅仅使用三路合并算法合并两个分支。和 resolve 不同的是,在交叉合并的情况时,这种合并方式是递归调用的,从共同祖先节点之后两个分支的不同节点开始递归调用三路合并算法进行合并,如果产生冲突,那么该文件不再继续合并,直接抛出冲突;其他未产生冲突的文件将一直执行到顶部节点。额外地,这种方式也能够检测并处理涉及修改文件名的操作。这是 git 合并和拉取代码的默认合并操作。
recursive 合并策略有以下参数:

4.2.1 ours

该参数将强迫冲突发生时,自动使用当前分支的版本。这种合并方式不会产生任何困扰情况,甚至 git 都不会去检查其他分支版本所包含的冲突内容这种方式会抛弃对方分支任何冲突内容。

4.2.2 theirs

正好和 ours 相反。
theirs 和 ours 参数都适用于合并二进制文件冲突的情况。

4.2.2 patience

在这种参数下,git merge-recursive花费一些额外的时间来避免错过合并一些不重要的行(如函数的括号)。如果当前分支和对方分支的版本分支分离非常大时,建议采用这种合并方式。

4.2.3 diff-algorithm=[patience|minimal|histogram|myers]

告知git merge-recursive使用不同的比较算法。

4.2.4 ignore-space-change, ignore-all-space, ignore-space-at-eol

根据指定的参数来对待空格冲突。

  • 如果对方的版本仅仅添加了空格的变化,那么冲突合并时采用我们自己的版本
  • 如果我们的版本含有空格,但是对方的版本包含大量的变化,那么冲突合并时采用对方的版本
  • 采用正常的处理过程

4.2.5 no-renames

关闭重命名检测。

4.2.6subtree[=<path>]

该选项是 subtree 合并策略的高级形式,将会猜测两颗节点树在合并的过程中如何移动。不同的是,指定的路径将在合并开始时除去,以使得其他路径能够在寻找子树的时候进行匹配。(关于 subtree 合并策略详见下文)

4.3 octopus

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

4.4 ours

这种方式可以合并任意数量的分支,但是节点树的合并结果总是当前分支所冲突的部分。这种方式能够在替代旧版本时具有很高的效率。请注意,这种方式和 recursive 策略下的 ours 参数是不同的。

4.5 subtree

subtree 是修改版的 recursive 策略。当合并树 A 和树 B 时,如果 B 是 A 的子树,B 首先调整至匹配 A 的树结构,而不是读取相同的节点。

4.5 总结

在使用三路合并的策略时(指默认的 recursive 策略),如果一个文件(或一行代码)在当前分支和对方分支都产生变化,但是稍后又在其中一个分支回退,那么这种回退的变化将会在结果中体现。这一点可能会使一些人感到困惑。这是由于在合并的过程中,git 仅仅关注共同祖先节点以及两个分支的 HEAD 节点,而不是两个分支的所有节点。因此,合并算法将会把被回退的部分认为成没有变化,这样,合并后的结果就会变为另一个分支中变化的部分。

5 关于 Git 使用的一些个人看法

本人一直认为 Git 是一款非常优秀的版本控制工具,但是在公司中很多人觉得 Git 很难使用。这种情况很大一部分原因是之前使用 subversion 时带来的使用惯性对接受新技术造成了影响;另一方面,很多人仅仅通过 GUI 客户端去使用 Git。很久以来,大部分人认为使用 GUI 是一种较为便捷的入门方式,其实这是值得商榷的。依我个人的经验来说,使用 GUI 会形成惰性,往往点击几个按钮就能完成操作,使得很多人认为学习 Git 的命令是一种浪费时间和精力的行为。但是事实上,在没有理解清楚 Git 命令和思想的情况下,使用那些简单的按钮其实会带来很大的困扰:很多人根本不知道点击按钮后会发生什么,GUI 的过于智能让同一个按钮的点击事件可能对应着不同参数的命令。最后真正受到伤害的是可怜的使用者们,因为他们根本不知道问题出在哪里。
综合全文的内容,这里总结一些个人使用 Git 时所遵守的约定。所谓约定,即非强迫性的,自愿的行为。不遵守这些约定并不会带来什么缺陷,但是遵守这些约定可能会减轻在使用 Git 时带来的困难,提高效率。

  1. 多提交,少推送。多人协作时,推送会频繁地带来合并冲突的问题,影响效率。因此,尽量多使用提交命令,减少合并的使用,这样会节省很多时间。
  2. 使用 Git 流(Git Flow),详见我的另一篇文章:一个成功的 Git 分支模型
  3. 使用分支,保持主分支的整洁。这是我强烈推荐的一点,在分支进行提交,然后切到主分支更新 (git pull —rebase),再合并分支、推送。这样的流程会避免交叉合并的情况出现(不会出现共同祖先节点为多个的情况)。事实上,git 合并操作让很多人感到不知所措的原因就是各种原因所产生的交叉合并问题,从而造成在合并的过程中丢失某些代码。保持主分支的整洁能够避免交叉合并的情况出现。
  4. 禁用 fast-forward 模式。在拉取代码的时候使用 rebase 参数(前提是保持主分支的整洁)、合并的时候使用—no-ff 参数禁用 fast-forward 模式,这样做既能保证节点的清晰,又避免了交叉合并的情况出现。