git 两个分支之间的差异与合并期间的更改不匹配

git diff between two branches not matching the changes during a merge

我有两个分支,master 和 feature。 如果我这样做:

git diff --name-only master..feature

我得到一长串文件,其中一些是源代码,所以没有被 .gitignore

排除

但是,当我尝试将功能合并到 master 中时:

git checkout master
git merge feature

在合并过程中,我在 master 中只更改了一个文件。

为什么会这样?

另一个有趣的事情是,如果我尝试反向将 master 合并到 feature 中,在 feature 分支中创建的文件将被删除。

我该如何解决这个问题并避免以后再出现这个问题?

这不是错误。

考虑以下简单示例。假设有一个名为 example.txt 的文件。在分支 X 中,它显示为:

This is
quite
a file.

在分支 Y 中,它显示为:

This is
not
a file.

合并分支 X 和 Y 的结果应该是什么?具体来说,您希望在名为 example.txt?

的文件中显示什么 内容

我没有给你什么信息,如果有的话?在 回答 这个问题之前,您还需要了解什么?

(在继续阅读之前尝试想出一个答案。)

Git 是关于提交,而不是文件

在继续之前,请注意您在 Git 中处理的存储单元是 commit,而不是文件。确实提交 包含 文件,但这里的一般想法是它是一揽子交易:提交有 完整快照 of all 个文件。如果我们开始提交:

git checkout somebranch

并将一个大文件 bigfile.py 拆分为两个较小的文件 small1.pysmall2.py 以及 删除 bigfile.py完全然后提交,新提交 lacks bigfile.py and adds 这两个较小的文件,与旧提交相比。当我们检出旧提交时,我们只有三个文件中的一个——大文件——而当我们检出新提交时,我们只有三个文件中的两个。这是一揽子交易:您可以选择一个文件的提交,或者两个文件的提交,但是您永远不会同时获得大文件和 一个 小文件,或者所有三个文件,或其他一些组合。

仍然,提交 包含 文件,这在我们开始合并时很重要。但除了包含文件——这是它们的主要数据:每个 文件的快照(按照你提交时它出现的方式)——每个提交包含一些元数据,或有关提交的信息。这包括您在 git log 输出中看到的内容:提交人的姓名和电子邮件地址,以及 date-and-time-stamp,例如。1

在所有这些元数据中,Git 在每次提交中存储一些早期提交的原始 哈希 ID。大多数提交只存储一个较早的提交哈希 ID。这些哈希 ID 也是提交的“真实名称”:它们是 Git 实际上 找到 每个提交的方式。提交存储在一个大key-value database中,以提交的哈希ID为键,提交的内容为值。

随着每个提交存储 前一个 提交的哈希 ID,我们最终得到一个很好的简单线性提交链。如果我们用大写字母代表每个哈希 ID,我们得到的图如下所示:

... <-F <-G <-H

其中 H 是链中 last 提交的哈希 ID。在提交 H 中,Git 存储了早期提交 G 的实际哈希 ID。在提交 G、Git 中存储了 still-earlier 提交 F 的哈希 ID,依此类推。

这些链允许 Git 向后工作,从 最新的 提交回到更早的提交。这些 Git 存储库中的历史,因此这些链对于使用 Git 至关重要。而且,由于每个提交都存储了一个 完整快照 ,我们必须 Git 比较 两个提交以查看发生了什么变化。例如,如果我们 Git 将 G 中的快照与 H 中的快照进行比较,这会告诉我们在 制作 时我们更改了什么 H 来自 G.

所以,这就是 git log 所做的:它从最近的提交(例如 H)开始,打印出哈希 ID 和元数据,如果我们使用 -p 获取补丁,提取 GH (到临时内存区域)并比较两个提交的快照以找出更改的内容,并向我们展示。然后,显示提交 H,Git 向后移动一步提交 G:它打印出哈希 ID 和元数据,如果我们使用 -p,比较 F-对-G。打印出 Ggit log 后退一步到 F,依此类推。

(换句话说,Git 工作 向后 。我不会在这里多强调这一点,但它解释了很多关于 Git,一旦你意识到这一点。)


1如果你使用git log --pretty=fuller,你会看到每个提交实际上有两个:author和一个 提交者 。每个都由三元组组成:姓名、电子邮件、时间戳。这些天通常两者都是相同的,除了 cherry-picked 提交,其中保留原始提交的作者,提交者是执行 cherry-pick 的人,提交者时间戳是时间cherry-pick 动作。


分支名称只是帮助我们 查找 提交

为了完成上述工作,我们有以某种方式知道链中 last 提交的哈希 ID。我们需要将该哈希 ID 提供给 Git,因为 Git 最终只能 找到 通过其哈希 ID 提交。我们可以写下这些哈希 ID,将它们记在纸上、白板上或其他东西上。但它们又大又丑,而且很难正确输入。另外,我们有一台 计算机 。为什么不让 computer 为我们记住哈希 ID?我们可以将第二个数据库添加到我们的 Git 存储库:它将包含 names,例如 masterdevelopfeature,以及使用这些名称,记住 last(最近的,最有用的,无论如何)提交的哈希 ID。

这就是分支名称:它是名称数据库中的一个条目。实际名称扩展了一点:master 实际上是 refs/heads/masterfeature 实际上是 refs/heads/feature。这为其他类型的名称留出了空间,例如标签名称:v2.1 实际上是 refs/tags/v2.1。但特别是对于分支名称,它们都持有 提交哈希 ID——每个一个——并且该哈希 ID 是我们提交的 last 的 ID会考虑“在分支上”。

如果我们只有一个分支,一切都很简单:

...--F--G--H   <-- master

在这里,分支名称 master 是唯一的名称,它包含我们最近提交的哈希 ID,提交 H。所以 name master 指向 链末端的提交。这让我们(和 Git)可以访问提交 H。提交 H 向后指向提交 G,这让我们(和 Git)可以访问它; commit G points backwards again, 依此类推。

如果我们现在创建一个 new 分支名称,例如 feature,我们可以选择任何现有的提交来使用这个新名称 point-to .不过,大多数情况下,我们会选择我们正在使用的提交:H,通过 master。所以我们会得到:

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

现在我们有问题了。我们使用的是哪个分支名称?为了记住,我们将添加一个特殊名称 HEAD,并将其附加到这两个分支名称之一。让我们将 HEAD 附加到 feature——如有必要,通过 运行ning git checkout feature——然后绘制:

...--F--G--H   <-- feature (HEAD), master

我们仍在使用提交 H,但现在我们使用它是因为名称 feature

现在让我们以通常的方式创建一个新的提交:修改一些文件,甚至可能创建新的 and/or 删除现有的,然后使用 git add and/or git rm 根据需要更新它们,然后 git commit 结果。无需过多担心所有细节,这 Git 保存了一个新快照,添加了一些元数据,并将集合作为新提交写出。新提交获得一个新的、唯一的哈希 ID——某种东西 random-looking,并且不可预测,因为它取决于我们进行提交的 确切时间 ——但我们只需调用它提交 I。新提交将向后指向现有提交 H:

             I
            /
...--F--G--H

一旦新的提交 存在 ,甚至在我们恢复能够 运行 更多命令之前,Git 现在执行它的最后一个特殊技巧:它将新提交的哈希 ID 写入 当前分支名称 ,即 HEAD 是 attached-to。因为那是 feature,我们得到:

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

提交 H 就在提交 I 之前,但它仍然是 master 分支上的 last 提交。提交 Ifeature 上的 最后一个 提交,但 H 之前的提交也在 feature 上。

现在让我们继续在 feature 上再提交一次:

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

然后是运行git checkout master。这将使我们的 HEAD 远离 feature 并将其附加到 master。它还将更新我们的工作区,以便我们使用提交 H 的内容,而不是提交 J 的内容:我们所有的文件现在匹配 H,而不是 J.我们对 IJ 所做的任何更新和快照都安全地存储在那里,在 IJ 中,但它们从我们的 视图中消失了 现在,因为我们已经提交 H out:

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

我们现在可以创建另一个新的分支名称,例如 feature2,并将 HEAD 附加到该名称:

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

然后在 feature2 上进行两个新提交:

             I--J   <-- feature
            /
...--F--G--H   <-- master
            \
             K--L   <-- feature2 (HEAD)

或者,我们可以直接在 master:

上进行这些提交
             I--J   <-- feature
            /
...--F--G--H
            \
             K--L   <-- master (HEAD)

图本身 而言——提交集与它们之间的 backwards-pointing 箭头(此处绘制为线,因为文本中可用的箭头图形是差)—没关系:我们不能 更改 任何 现有 提交(永远),但我们总是可以添加新的提交,并且无论哪种方式,我们最终都会得到这组提交。这只是 names find 这些提交的问题。但是 Git 允许我们随时创建、销毁或移动 分支名称 。提交不会改变;只是我们用来 findnames 可能不同

正在合并

是时候回答上面的问题了:缺少什么?

当我们合并 Git 中的一些提交时,这就是合并工作。这个想法是,某人在某些提交系列(I-J 中)做了一些工作,而另一个人(可能是其他人)在其他一些提交系列中(K-L)做了一些工作。这给了我们这个:

          I--J   <-- br1
         /
...--G--H
         \
          K--L   <-- br2

由于提交的性质——它们永远不会改变——我们可以从这张图中看出,这两行工作共同开始,即提交H。从视觉上很容易看出 J 中的所有内容都是从 H 继承而来的,L 也是如此。它们也是 G 的后代,但是 H “更好”,因为它“更接近” end-point 提交。

现在,我们已经知道 Git 可以比较两个快照,如 GH,或 IJ。如果 Git 可以轻松地将 H 直接与 J 进行比较会怎么样?好吧,它 可以; 如果我们 Git 这样做,我们会发现 H 有什么不同J。这是 某人在第一行所做的工作。所以这些是 br1.

中的 变化

同样,如果我们 Git 将 H 中的内容与 L 中的内容进行比较,我们将在底线上找出某人所做的工作。无论文件有何不同,无论我们使用什么规则将 H 中的文件内容 更改为 L 中的文件内容,这就是有人在 br2 上所做的].

这也告诉我们缺少了什么。为了合并 example.txt,我们不仅需要两个 end-point 文件——例如,一个在第 2 行说 quite,另一个在第 2 行说 not——还有文件的 基本副本 example.txt 的基础副本是提交 H 中文件的副本。提交 H 是两个提示提交的 合并基础 ,每个文件的副本是我们找出 更改的内容 的方式。

如果基本副本说:

This is
quite
a file.

然后我们知道 没有任何变化 在仍然说 quite 的那一行,并且在说 not.[=170 的那一行改变了一行=]

如果基本副本说:

This is
not
a file.

然后我们知道 没有任何变化 在仍然说 not 的那一行,并且在说 quite.[=170 的那一行改变了一行=]

如果基本副本没有第 2 行——如果它完整地读取:

This is
a file.

然后我们有一个合并冲突,因为两个人都做了一个改变:都添加了一个line-2,但是他们添加了不同的第 2 行。

这对您的情况意味着什么

如果两个分支提示提交 - 一个由名称 master 找到的,一个由名称 feature 找到的 - 不同,这只是告诉我们它们是不同的。 Git 提出的配方,将更改一个提交以使其匹配另一个提交,只是告诉我们如何将一个提示提交更改为另一个提示提交。

如果这两个 branch-tip 提交之间的 merge base 提交是一些 third 提交,2 我们需要知道第三次提交中的内容,因为 那是 git merge 如何弄清楚 master 中发生了什么变化以及 feature。然后,合并命令将尝试合并那些两组更改,将合并的更改应用于合并基础中的任何内容。

作为phd commented,你可以在git diff命令中使用triple-dot表示法:

git diff master...feature

例如。这有 Git:

  • 找到两个 tip 提交之间的合并基础(我们称之为 $B);然后
  • 运行相当于git diff $B feature

它告诉您 feature 上关于此合并基础的更改。如果您然后 运行 相同的命令,但两个名称互换:

git diff feature...master

Git 将找到相同的两个 tip 提交的合并基础,3 然后 diff $B vs master:这表明你在 master.

发生了什么变化

同样,git merge 对这些情况的作用是:4

  • 运行 两者 diffs,将输出保存在临时区;
  • 获取每个文件的merge base版本;
  • 如果可能的话,合并差异;和
  • combined 差异应用于文件的合并基础版本。

如果一切顺利,git merge 将从结果中进行合并提交。合并提交与常规 non-merge 提交没有太大区别:它仍然具有所有文件的快照(由上面的合并过程构建)和一些元数据。合并提交的特殊之处在于它列出了 both branch-tip 提交作为其父项,因此 Git 可以 g返回两个分支(现在通过合并提交合并为一个“分支”:这暴露了“分支”一词的缺陷;参见What exactly do we mean by "branch"?)。


2这里有一些退化的情况。特别是,如果合并基础是两个分支提示提交之一,我们要么有一个简单的“fast-forward-able”案例,要么没有要合并的东西。不过,鉴于您发布的内容,您一定没有这些情况之一。

3如果只有一个合并基础提交——通常是这种情况——两个分支尖端提交的列出顺序无关紧要。对于一些复杂的提交图,但是,可能有两个或更多合并基础提交。在这里,画面变得相当模糊。 git diff 命令直到最近才处理得很好; git merge 处理得更好,但仍然很棘手。

4这个描述对你如何进行合并、图形的形状等做出了很多假设,并且在其他方​​面大大简化了 git merge 内部确实如此。这个想法是为了抓住总体目标,而不是进入一些更棘手的机制。例如,这忽略了合并如何处理重命名文件的情况。