【问题标题】:What is a git conflict and in which conditions could you have a git conflict?什么是 git 冲突,在哪些情况下会出现 git 冲突?
【发布时间】:2019-08-04 12:14:54
【问题描述】:

在主分支上,我有一个文件(我们称之为 file.txt),它使用具有旧名称的结构的字段(我们称之为:old_field1、old_field2 和 old_field3)。在我的开发分支上,我有相同的文件,但它的另一个版本使用相同的字段,但名称不同(我们称它们为:new_field1、new_field2 和 new_field3)

我将主分支合并到我的开发分支中,由于这些字段的名称不同,我遇到了 git 冲突。我通过保留新名称来修复它。 后来我将开发分支合并到master分支中,我遇到了同样的git冲突。

我的问题是:

  1. 如果我修复了它,为什么会出现同样的冲突?我应该如何避免 将来呢?
  2. 什么是 git 冲突?
  3. “合并”过程如何工作? (例如:它以特定顺序应用补丁等)

【问题讨论】:

标签: git


【解决方案1】:

你的问题从一个相当糟糕的地方开始,或者可能选择了一个特别不走运的例子,如 #3 所示:

“合并”过程如何工作? (例如:它以特定顺序应用补丁等)

因为git merge 不(相当)应用补丁。但是你想知道是什么导致了冲突的发生,所以你需要了解很多关于 Git 的东西。

提交是快照,还有更多

首先,让我们尽可能简短地看一下commits。您已经知道要进行 new 提交,您需要在 git checkout 一个分支上做一些工作,git addgit commit。不过,您可能知道也可能不知道,每个提交都包含您的所有文件的完整且完整的快照

这可能看起来很奇怪,因为 git show 将提交显示为 patchchange-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。其余的行——parentauthorcommitter,以及 Junio Hamano 在提交此提交时输入的日志消息,构成了此提交的其余部分。

密切注意parent 行。这有另一个丑陋的哈希 ID。如果愿意,您可以直接查看该提交:克隆存储库和 git cat-file -p 该哈希 ID。你会看到这个有另一个tree 行——这是另一个提交的快照——还有更多的parentauthor 等等行。下一次提交——实际上是上一次提交——实际上有两个parent行,因为提交14fe4af084071803ab4f16e6841ff64ba7351071是一个合并提交

这些不同的parent 行字符串一起提交,按它们的哈希ID,以向后 顺序。每个提交都有一个真实名称的哈希 ID,每个提交都有一定数量的 parent 行。2 大多数提交都有这些行之一。这一行给出了提交的父提交的哈希 ID(真实名称)。

一旦提交,它就会被永久冻结。因此,当您稍后进行新的子提交时,不可能返回父项并添加子项的哈希 ID。这就是为什么每个提交只知道它的父母:它们在提交出生时就存在,并且一旦提交出生,它就会永远冻结。提交通过生成其哈希ID,哈希ID的唯一性部分由时间确定,直到第二个,您进行提交(编码到authorcommitter 行中——上面显示的日期和时间戳是1564776744 -0700,对于作者和提交者)。

请注意,提交及其快照将永远冻结。我们不能用冷冻的东西完成任何工作!因此,Git 为我们提供了一个工作区——Git 称之为 work-treeworking tree 或类似的任何东西——它扩展了冻结(和压缩)的地方来自提交的文件。还有一个非常重要的东西,称为 indexstaging 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,所以BC 的父级。同时,B 记住提交 A 的哈希 ID:AB 的父级。但是 commit A 是我们第一次提交,所以它有 no 父级。在 Git 术语中,它是一个 root 提交。它没有父级,因为它不能有任何父级:在A 存在之前没有提交。

让我们现在做一个新的提交。它将获得一些看起来随机的哈希 ID,但我们将其命名为 DDparent 必须是 D 之前的提交。但这当然只是提交C。所以D 的父级将是C。快照将是我们想要的任何内容。作者和提交者将是我们,以“现在”作为时间戳,我们可以编写日志消息。 Git 获取所有这些东西——树、parentC、我们的姓名、电子邮件和时间,以及我们的日志消息——并将它们写成一个新的提交,获取一些我们将假装的哈希 ID只是D,现在我们有了:

A <-B <-C <-D

git showgit log -p 使用图表比较快照

Git 可以比较C 中的快照和D 中的快照。如果我们这样做,我们会看到我们改变了什么。这就是git loggit 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 branchgit checkout 来完成。现在,就像master 一样,Git 必须将一些 hash ID 存储到这个新名称中。它应该使用哪个哈希 ID? Git 需要一些现有提交的哈希 ID。

我们的八个提交中的任何一个,AH,都可以。我们可以选择一个,但如果我们选择一个哈希 ID,Git 在我们的当前分支。所以现在我们有了这个:

...--F--G--H   <-- feature, master

一个非常有趣的事情是所有八个提交都在两个分支上

另一个有趣的事情是:假设我们现在添加一个新的提交。我们称之为IGit 更新了哪个分支名称

你的 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 Hmaster 表示 commit H

但是现在我们处于这种看起来有点奇怪的状态,让我们进行一两次新的提交。我们将它们称为I,然后称为J。 Git 会:

  • 将树写成快照;
  • 添加提交的其余内容:姓名、电子邮件等,当然还有最重要的parent 行;和
  • 更新当前分支名称

所以一旦我们做了两个新的提交,我们就有了:

             I--J   <-- master (HEAD)
            /
...--F--G--H   <-- feature

现在让我们git checkout feature 再做两个新的提交,JK。第一步——git checkout feature——结果如下:

             I--J   <-- master
            /
...--F--G--H   <-- feature (HEAD)

我们重新提交H。 Git 将更改我们工作树中的文件以匹配提交 H4 此外,HEAD 现在附加到 feature,而不是 master。所以现在让我们提交KL,这一次将更新名称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 匹配。当前分支现在是masterHEAD 附加到名称master。请注意,AJ 的提交都在 master 上。

现在你运行:

git merge feature

名称feature 标识提交L,但提交AH K L 都在@987654472 @。

一些提交——AH——在两个分支上。这些提交之一是最佳公共/共享提交。 Git 将这个最常见的提交称为 合并基础。在这种情况下,很明显哪个提交是最好的:那是提交H,就在两个分支分歧之前。我们可以走得更远,但为什么要麻烦呢?显然,提交 H 中的所有内容都与提交 H 中的所有内容相同。

让我们想一想git merge目标。目标是合并更改。当我们只有快照时,我们如何获得更改?但是等等——我们已经知道该怎么做了!我们使用git diff。我们可以在任意两个快照上运行git diff,以比较它们,看看有什么变化。

我们在这里有三个快照:HJL。我们需要运行两个 git diffs。让我们这样做:

  • git diff --find-renames <em>hash-of-H</em> <em>hash-of-J</em> 将比较HJ,并告诉我们master 自共同起点H 以来发生了什么变化。

  • git diff --find-renames <em>hash-of-H</em> <em>hash-of-L</em> 将比较 HL,并告诉我们自共同起点 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 父级是您合并的提交:Lfeature 的提示。 这是一个合并提交这一事实记录在提交图中。 提交M两个 父级,JLfuture git mergefuture feature 将找到一个不同的 合并基础。


5--all 用于特别复杂的图表,我们这里实际上没有。这意味着--all 在这种情况下不会做任何事情,但git merge 会使用它,以防我们 有一个复杂的图表。如果您从git merge-base 中获得两个哈希 ID,则合并过程会变得更加复杂,因此我们将跳过它。如果您省略--allgit merge-base 会选择可能(明显)随机的许多合并基之一。但无论如何,几乎总是只有一个合并基础。

6在内部,git merge 不会制作差异列表。它确实运行了两个差异,但以一种特殊的优化合并方式。在许多情况下,它可以完全跳过大多数逐个文件的差异,当它确实需要进行实际比较时,它使用一堆内部数据结构来查找各种更改的行,而不是文本 git diff输出。但是效果是一样的,只是效率更高。

7git merge --continue 所做的只是检查是否有完成的合并提交,然后运行git commit。但这有点像安全检查,有助于确保一切都如您所想,因此最好使用git merge --continue,即使您可以只运行git commit


合并为未来的合并做准备

我将在这里重复这一点,因为它是你所有痛苦的根源。正如我们在上面看到的,git merge

  1. 计算合并基数;
  2. 运行(实际上)两个git diffs;
  3. 组合更改,将这些更改应用到 merge base 提交;
  4. 如果一切顺利,则提交结果,否则让您清理并提交。

在步骤 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,它有父PV。名称feature 标识提交W。这里的 merge basebest common commit,但那是哪个提交呢?好吧,让我们从 W 开始并向后工作:我们得到一些匿名提交 o,然后是 V,然后是另一个匿名提交,依此类推。

如果我们从 M3 开始并向后工作,我们会得到 两个 提交:PV。这就是合并提交的魔力:通过将V 作为其第二个父级,它会自动将提交V 和所有较早的topic 提交作为mainline 分支的一部分。 这意味着提交 V 现在是合并基础,两个 git diff 命令将:

  • 比较 VM3,看看我们做了哪些改变,并且
  • 比较 VW,看看它们有什么变化。

这些是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 附加到的方式。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2018-07-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-08-13
    • 2018-09-17
    • 2019-02-24
    • 2014-06-06
    相关资源
    最近更新 更多