你的问题从一个相当糟糕的地方开始,或者可能选择了一个特别不走运的例子,如 #3 所示:
“合并”过程如何工作? (例如:它以特定顺序应用补丁等)
因为git merge 不(相当)应用补丁。但是你想知道是什么导致了冲突的发生,所以你需要了解很多关于 Git 的东西。
提交是快照,还有更多
首先,让我们尽可能简短地看一下commits。您已经知道要进行 new 提交,您需要在 git checkout 一个分支上做一些工作,git add 和 git commit。不过,您可能知道也可能不知道,每个提交都包含您的所有文件的完整且完整的快照。
这可能看起来很奇怪,因为 git show 将提交显示为 patch 或 change-set,而 git log -p 显示每个提交的补丁。 1 但这是因为提交不只是存储快照。如果您运行git log,无论有无-p,您都会获得每次提交的更多信息。例如:
$ git log | head -7 | sed 's/@/ /'
commit 7c20df84bd21ec0215358381844274fa10515017
Author: Junio C Hamano <gitster pobox.com>
Date: Fri Aug 2 13:12:24 2019 -0700
Git 2.23-rc1
Signed-off-by: Junio C Hamano <gitster pobox.com>
因此,提交不仅存储快照,还存储一些姓名和电子邮件地址等内容。
注意大而丑陋的哈希 ID,7c20df84bd21ec0215358381844274fa10515017。这实际上是提交的真实名称。这一特定的提交将始终为7c20df84bd21ec0215358381844274fa10515017。在此 Git 存储库的 my 克隆中,在同一存储库 (https://github.com/git/git) 的 your 克隆中,在 GitHub 克隆中,以及在Git 人民的克隆,等等。我们也可以直接查看这个提交的原始内容:
$ git cat-file -p 7c20df84bd21ec0215358381844274fa10515017 | sed 's/@/ /'
tree 8858576e734aa4f1cd9b45e207e7ee2937488d13
parent 14fe4af084071803ab4f16e6841ff64ba7351071
author Junio C Hamano <gitster pobox.com> 1564776744 -0700
committer Junio C Hamano <gitster pobox.com> 1564776744 -0700
Git 2.23-rc1
Signed-off-by: Junio C Hamano <gitster pobox.com>
这实际上是整个内部 Git 提交对象,就在那儿:快照是间接保存的,通过第一行写着 tree 并有另一个丑陋的大哈希 ID。其余的行——parent、author、committer,以及 Junio Hamano 在提交此提交时输入的日志消息,构成了此提交的其余部分。
密切注意parent 行。这有另一个丑陋的哈希 ID。如果愿意,您可以直接查看该提交:克隆存储库和 git cat-file -p 该哈希 ID。你会看到这个有另一个tree 行——这是另一个提交的快照——还有更多的parent 和author 等等行。下一次提交——实际上是上一次提交——实际上有两个parent行,因为提交14fe4af084071803ab4f16e6841ff64ba7351071是一个合并提交。
这些不同的parent 行字符串一起提交,按它们的哈希ID,以向后 顺序。每个提交都有一个真实名称的哈希 ID,每个提交都有一定数量的 parent 行。2 大多数提交都有这些行之一。这一行给出了提交的父提交的哈希 ID(真实名称)。
一旦提交,它就会被永久冻结。因此,当您稍后进行新的子提交时,不可能返回父项并添加子项的哈希 ID。这就是为什么每个提交只知道它的父母:它们在提交出生时就存在,并且一旦提交出生,它就会永远冻结。提交通过生成其哈希ID,哈希ID的唯一性部分由时间确定,直到第二个,您进行提交(编码到author 和committer 行中——上面显示的日期和时间戳是1564776744 -0700,对于作者和提交者)。
请注意,提交及其快照将永远冻结。我们不能用冷冻的东西完成任何工作!因此,Git 为我们提供了一个工作区——Git 称之为 work-tree 或 working tree 或类似的任何东西——它扩展了冻结(和压缩)的地方来自提交的文件。还有一个非常重要的东西,称为 index 或 staging area(同一事物的两个名称),我不会在这里介绍,它位于你提交的“之间”签出,工作树。
1请注意,git log -p 不显示合并提交的补丁,但 git show 显示。这里还有很多要了解的内容,但为简洁起见,我们将跳过所有这些内容。
2至少一个提交有 no 父级,我们稍后会看到。大多数都有一个。一些——合并提交——有两个或更多;根据定义,任何具有至少两个父级的提交都是合并提交。超过两个是罕见的,从某种意义上说,从来没有必要,但如果你查看 Git 的 Git 存储库,你会发现一些。例如,89e4fcb0dd01b42e82b8f27f9a575111a26844df 就是其中之一。
提交因此形成了一种特殊的图
在数学上,图由一对集合定义:G = (V, E)(参见Wikipedia Article)。在这种情况下,V - 图中的顶点集 - 是您的所有提交,由它们的哈希 ID 找到,而 E - 边集 - 来自parent 行。不过,对于最简单的情况,我们可以绘制图表,我认为这更容易理解。让我们为提交使用一个字母的名称,以代替大而丑陋的哈希 ID,并想象我们有一个只有三个提交的小存储库,全部连续:
A <-B <-C
Commit C 是我们制作的最后一个。它记住了提交B 的哈希ID,所以B 是C 的父级。同时,B 记住提交 A 的哈希 ID:A 是 B 的父级。但是 commit A 是我们第一次提交,所以它有 no 父级。在 Git 术语中,它是一个 root 提交。它没有父级,因为它不能有任何父级:在A 存在之前没有提交。
让我们现在做一个新的提交。它将获得一些看起来随机的哈希 ID,但我们将其命名为 D。 D 的 parent 必须是 D 之前的提交。但这当然只是提交C。所以D 的父级将是C。快照将是我们想要的任何内容。作者和提交者将是我们,以“现在”作为时间戳,我们可以编写日志消息。 Git 获取所有这些东西——树、parent 和 C、我们的姓名、电子邮件和时间,以及我们的日志消息——并将它们写成一个新的提交,获取一些我们将假装的哈希 ID只是D,现在我们有了:
A <-B <-C <-D
git show 和 git log -p 使用图表比较快照
Git 可以比较C 中的快照和D 中的快照。如果我们这样做,我们会看到我们改变了什么。这就是git log 和git show 所做的事情:给定一些提交,他们会查看那个提交的父 以及那个提交。无论是不同,它们都会显示为您的补丁。
您还可以使用git diff 来比较任意两个 提交。例如,您可以使用 git diff <em>hash-of-A hash-of-D</em> 将有史以来的第一个提交 A 与此处的最后一个提交 D 进行比较。 Git 提取两个快照,比较它们,然后告诉你有什么不同不同。
分支名称查找提交
到目前为止,这一切都不难。每个新的提交都会获得一些看起来很丑的随机哈希 ID。每个提交都指向其父级。没问题吧?但是等等:我们如何记住 last 提交的实际又大又丑的哈希 ID? 我们需要一个地方来存放那个哈希 ID,因为在一个大存储库中我们不会'不能只看一眼每个提交和所有的parent行等等并弄清楚。所以 Git 所做的就是:它保存 last 提交的哈希 ID——C,然后是 D——在一个 name 中。让我们使用名称master:
A--B--C--D <-- master
name master,在这种情况下,只是保存了提交 D 的实际原始哈希 ID——我们刚刚创建的那个。从D,Git 可以使用其父行找到C,然后使用C 的父行找到B,以此类推。当 Git 找到没有父级的 A 时,该操作停止。
所以 branch name 只是标识分支中的 last 提交。如果我们进行新的提交 E,Git 会通过将 E 的实际哈希 ID 写入名称 master 来更新 master:
A--B--C--D--E <-- master
现在我们在master 上有五个提交。我们可以继续前进,最终我们有 8 个提交,全部在 master,如下所示:
...--F--G--H <-- master
这仍然很容易,不是吗?让我们让它更难一点。 :-) 让我们创建一个 new 分支名称,feature。我们究竟是如何做到的?好吧,我们要求 Git 使用 git branch 或 git checkout 来完成。现在,就像master 一样,Git 必须将一些 hash ID 存储到这个新名称中。它应该使用哪个哈希 ID? Git 需要一些现有提交的哈希 ID。
我们的八个提交中的任何一个,A 到 H,都可以。我们可以选择一个,但如果我们不选择一个哈希 ID,Git 在我们的当前分支。所以现在我们有了这个:
...--F--G--H <-- feature, master
一个非常有趣的事情是所有八个提交都在两个分支上。
另一个有趣的事情是:假设我们现在添加一个新的提交。我们称之为I。 Git 更新了哪个分支名称?
你的 HEAD 告诉你的 Git 要更新哪个分支名称
上一节末尾的问题的答案是,其中很多东西真正开始融合在一起。 Git 有一个非常特殊的名称,HEAD,用全大写字母写成这样。3 通常,Git 会将 HEAD 附加到您的分支名称之一:
...--F--G--H <-- feature (HEAD), master
这表明我们已经签出了分支feature。如果我们运行git status,它将显示on branch feature。如果我们git checkout master,我们会将其转换为:
...--F--G--H <-- feature, master (HEAD)
在这两种情况下,当前的 commit 都将是 commit H。 但是当前的 branch 会改变。对于同一个提交,我们有两个不同的名称:feature 表示 commit H 和 master 表示 commit H。
但是现在我们处于这种看起来有点奇怪的状态,让我们进行一两次新的提交。我们将它们称为I,然后称为J。 Git 会:
- 将树写成快照;
- 添加提交的其余内容:姓名、电子邮件等,当然还有最重要的
parent 行;和
- 更新当前分支名称。
所以一旦我们做了两个新的提交,我们就有了:
I--J <-- master (HEAD)
/
...--F--G--H <-- feature
现在让我们git checkout feature 再做两个新的提交,J 和 K。第一步——git checkout feature——结果如下:
I--J <-- master
/
...--F--G--H <-- feature (HEAD)
我们重新提交H。 Git 将更改我们工作树中的文件以匹配提交 H。4 此外,HEAD 现在附加到 feature,而不是 master。所以现在让我们提交K 和L,这一次将更新名称feature:
I--J <-- master
/
...--F--G--H
\
K--L <-- feature (HEAD)
我们现在处于可以git merge 并且(您关心的部分)合并冲突的状态。
3在 Windows 和 MacOS 上(从技术上讲,在大小写折叠文件系统上),您通常可以将其拼写为小写并使其正常工作。但是,如果您开始使用git worktree add,这将开始中断,因此这是一个坏习惯。如果您不喜欢输入四个大写字母,请考虑使用 @ 的同义词 HEAD。
4同样,索引/暂存区也非常重要,在某些特殊情况下,Git 不更新(部分)索引和工作树,但现在让我们忽略所有这些。
git merge 的工作原理
git merge 命令看起来很神奇,但实际上它根本不是魔法。你输入:
git checkout master
这会改变你的看法:
I--J <-- master (HEAD)
/
...--F--G--H
\
K--L <-- feature
当前提交现在是J,因此您在文件中看到的内容与冻结的J 匹配。当前分支现在是master:HEAD 附加到名称master。请注意,A 到 J 的提交都在 master 上。
现在你运行:
git merge feature
名称feature 标识提交L,但提交A 到H 和 K 和 L 都在@987654472 @。
一些提交——A 到H——在两个分支上。这些提交之一是最佳公共/共享提交。 Git 将这个最常见的提交称为 合并基础。在这种情况下,很明显哪个提交是最好的:那是提交H,就在两个分支分歧之前。我们可以走得更远,但为什么要麻烦呢?显然,提交 H 中的所有内容都与提交 H 中的所有内容相同。
让我们想一想git merge 的目标。目标是合并更改。当我们只有快照时,我们如何获得更改?但是等等——我们已经知道该怎么做了!我们使用git diff。我们可以在任意两个快照上运行git diff,以比较它们,看看有什么变化。
我们在这里有三个快照:H、J 和 L。我们需要运行两个 git diffs。让我们这样做:
git diff --find-renames <em>hash-of-H</em> <em>hash-of-J</em> 将比较H 和J,并告诉我们master 自共同起点H 以来发生了什么变化。
git diff --find-renames <em>hash-of-H</em> <em>hash-of-L</em> 将比较 H 和 L,并告诉我们自共同起点 H 以来 feature 发生了什么变化。
我们不必自己输入甚至查找这些哈希值。 Git 为我们做到了这一点。它知道J 的哈希ID,因为这是我们当前的提交并且在名称master 中,它知道L 的哈希ID,因为它在名称feature 中。 Git finds H 自己,使用提交图——它不再只是一个简单的反向链,但仍然不太复杂。如果需要,我们可以使用以下命令查看 Git 找到的合并基础提交:5
git merge-base --all master feature
但我们不必费心; git merge 在这里完成了所有艰苦的工作。
无论如何,在列出了两个差异列表后,6git merge 现在可以查看它们并弄清楚该怎么做:
- 如果您在
H 之后更改了文件,但他们没有更改,请使用您的文件。
- 如果他们更改了自
H 以来的文件,而您没有更改,请使用他们的文件。
- 如果你们都没有更改文件,请使用文件的任何副本:所有三个都匹配。
只有双方都修改了某个文件,git merge 才需要努力工作。现在git merge 必须实际结合您的两组更改。假设你们都触摸了文件README.md:
- 如果您触摸了第 3 行而他们没有触摸,Git 可以在此处使用您的更改。
- 如果他们触及了第 25 行而您没有触及,Git 可以在此处使用他们的更改。
- 但如果你们都将第 42 行更改为两个不同的东西,Git 不知道哪个更改是正确的。结果是冲突!
当没有冲突时,对 Git 来说一切都很容易:它只是将所有更改组合成相当于(但不完全是)一个大的组合补丁,并将其应用于合并库中的文件副本。其效果是保留您的更改,同时添加他们的更改。这一切都结合在一起,一切都很好。至少 Git 是这么想的:如果您在第 3 行的更改中断他们在第 25 行的更改怎么办?
但是,如果存在 冲突,Git 会给您留下一些混乱。它将所有三个输入文件版本写入索引/暂存区域(我们不打算在这里讨论)并将一堆冲突标记写入README.md 的工作树副本。你的工作变成:修复混乱并将正确合并到位。合并有点暂停:Git 记录了 合并,git status 会告诉你你正处于合并的中间。但是git merge 命令已经退出。稍后您将启动一个新命令以真正完成这项工作。
你也可以得到我所说的高级冲突。请注意我们的示例git diff 命令中的--find-renames。如果您在更改中重命名了某些文件,或者添加或删除了文件——master 上的 H-vs-J 部分——并且他们还在更改中重命名、添加或删除了文件——@987654516 @ 与L 部分在feature 上——这些整个文件的更改可能相互冲突。在这种情况下,git merge 会一团糟,将文件留在索引中,但通常在文件的工作树副本中带有 no 合并冲突标记。幸运的是,这些高级别的冲突很少见,因为解决它们可能要困难得多。
一旦你解决了所有问题,你的工作就变成了:运行git merge --continue(如果你的 Git 不是太旧)或git commit(如果是)。7Git 会创建一个新的照常快照,照常收集日志消息,并写出新的提交。这个新提交将有 两个 父级。
如果合并中一切顺利,Git 将自己进行新的提交(照常收集日志消息):您不必运行git merge --continue,因为合并从未停止。无论哪种方式——冲突与否,手动解决与否——这是合并完成的地方,这是最后一点魔法,因为这个新的合并提交将有两个父节点:
I--J
/ \
...--F--G--H M <-- master (HEAD)
\ /
K--L <-- feature
first 父级一切照旧:M 的第一个父级是 J,即您刚才所做的提交。 second 父级是您合并的提交:L,feature 的提示。 这是一个合并提交这一事实记录在提交图中。 提交M 有两个 父级,J 和L。 future git merge 与 future feature 将找到一个不同的 合并基础。
5--all 用于特别复杂的图表,我们这里实际上没有。这意味着--all 在这种情况下不会做任何事情,但git merge 会使用它,以防我们做 有一个复杂的图表。如果您从git merge-base 中获得两个哈希 ID,则合并过程会变得更加复杂,因此我们将跳过它。如果您省略--all,git merge-base 会选择可能(明显)随机的许多合并基之一。但无论如何,几乎总是只有一个合并基础。
6在内部,git merge 不会制作差异列表。它确实运行了两个差异,但以一种特殊的优化合并方式。在许多情况下,它可以完全跳过大多数逐个文件的差异,当它确实需要进行实际比较时,它使用一堆内部数据结构来查找各种更改的行,而不是文本 git diff输出。但是效果是一样的,只是效率更高。
7git merge --continue 所做的只是检查是否有完成的合并提交,然后运行git commit。但这有点像安全检查,有助于确保一切都如您所想,因此最好使用git merge --continue,即使您可以只运行git commit。
合并为未来的合并做准备
我将在这里重复这一点,因为它是你所有痛苦的根源。正如我们在上面看到的,git merge:
- 计算合并基数;
- 运行(实际上)两个
git diffs;
- 组合更改,将这些更改应用到 merge base 提交;
- 如果一切顺利,则提交结果,否则让您清理并提交。
在步骤 1 中找到的合并基础提交基于 提交图。第 4 步中的 提交图 是您对 next 合并的输入——下一个“第 1 步”。
当你反复将一个分支合并到另一个分支时,你会得到一种缝纫针迹图案:
...--o--o--o---M <-- mainline
\ /
o--o--o <-- topic
变成:
...--o--o--o---M1----M2--P--M3 <-- mainline
\ / / /
o--o--T--o--U---o--V--o--W <-- feature
每个M 有两个父级,一个是之前的主线提交(甚至可能是之前的合并),另一个是曾经的提交之一,当时,特性或主题分支的提示提交。
考虑一下如果我们先git checkout mainline 然后git merge feature 会发生什么。名称mainline 标识提交M3,它有父P 和V。名称feature 标识提交W。这里的 merge base 是 best common commit,但那是哪个提交呢?好吧,让我们从 W 开始并向后工作:我们得到一些匿名提交 o,然后是 V,然后是另一个匿名提交,依此类推。
如果我们从 M3 开始并向后工作,我们会得到 两个 提交:P 和 V。这就是合并提交的魔力:通过将V 作为其第二个父级,它会自动将提交V 和所有较早的topic 提交作为mainline 分支的一部分。 这意味着提交 V 现在是合并基础,两个 git diff 命令将:
- 比较
V 和 M3,看看我们做了哪些改变,并且
- 比较
V 与 W,看看它们有什么变化。
这些是git merge 将尝试合并的变更集。由于两个变更集中的重叠变更而发生冲突(如果有的话)。
合并提交的内容由您决定。在您运行git merge 时,该图暗示了合并提交的图表。 git merge 的关键输入之一是 merge base,Git 会使用图表自动找到它。要查看图表,请参阅Pretty git branch graphs。
要点
- 提交是快照加上元数据。
- 一些元数据构成了提交图。这些是 parent 链接,它们都向后指向:Git 必须向后工作。
- 分支名称标识一个特定的提交。这个特定的提交是分支上 / in / 的 last 提交。 Git 将此称为提示提交。
- 进行新提交会使当前分支名称前进以指向新提交。该分支现在有了新的提示。
-
HEAD 告诉你哪个 name 是当前名称,然后那个名称告诉你哪个 commit 是当前提交——所以HEAD 给了 Git 两条不同的信息同时。
- “分支”可以表示整个系列的提交,从分支名称给出的最后一个提交开始并向后工作。
- 许多提交通常同时在 许多 分支上。 (见Think Like (a) Git。)
- 通过合并提交向后工作意味着跟随两个父母。
-
git diff 可以比较任何给定的两个提交的快照。
-
git show 将提交与其父级进行比较; git log -p 也是如此。
-
git merge 尽可能多地遍历图形,以找到 最佳 合并基础。然后它会产生 两个 差异并将它们组合起来,以实现真正的合并。
不属于上述内容,但很重要:
- Git 从存储在 index 中的文件而不是工作树中的文件进行新的提交。
- 由于合并提交至少有 两个 父级,
git show 必须在这里做一些特别的事情(确实如此),但 git log -p 很懒惰,只是懒得做 anything 显示补丁。无论哪种方式,这两个命令都故意遗漏了很多内容:合并的“补丁”本质上是一种错误。
-
git merge 有时会故意偷懒:如果不需要真正的合并,它会改为执行 快进。当git merge 进行快进时,它不会进行新的提交。
- 在“分离 HEAD”模式下,
HEAD 直接指向提交,而不是附加到分支名称。其他一切都一样,除了问 Git 问题:哪个分支名称是当前分支返回 错误:不计算。
-
git checkout(或在 Git 2.23 中,新的 git switch)是您更改 哪个分支名称 HEAD 附加到的方式。