为什么我的推送中突然有一个合并提交?

Why do I suddenly have a merge commit in my pushes?

好吧,我好像去把事情搞砸了。

直到最近,我曾经能够进行合并提交,然后在不显示单独提交的情况下推送到原点。现在它确实如此,合并提交是我在我的管道中所能看到的:

在此开始之前,只有手动提交被推送到原点(或至少如此显示):

这是行为更改后的团队资源管理器 (VS 2019 v16.6.5):

...这是我当地的分行历史:

看到变化了吗?

这一切都是在我恢复提交 a13adadf、修复它并重新发布后立即开始的。现在我遇到了某种奇怪的分支效应,我不知道如何让事情恢复到原来的状态。 (我尝试研究这个问题,但在搜索与 merge commit 相关的任何内容时,信噪比非常低。)

如何让我的 repo 到 'ignore'(即停止显示)合并提交?

(注意:我是唯一从事此回购的开发人员。)

您之前似乎进行过 fast-forward 操作。如果条件正确,git merge 命令将执行此操作 而不是 合并:

  1. A fast-forward 需要成为可能。
  2. 您需要避免 --no-ff 选项,这会禁用 fast-forward.

This all started right after I reverted commit a13adadf, fixed it and republished it.

这一定是创建了一个b运行ch。这个词“b运行ch”有问题,也就是说,它会让您误入歧途,但您在问题中显示的图表片段表明这实际上是发生了什么。

How can I get my repo to 'ignore' (i.e. stop displaying) the merge commits?

如果您只是想避免显示它们,您的查看器可能有一些选项可以做到这一点。

如果你想回到不制造它们——你之前的情况——你需要消除你制造的b运行ch。

Long:这是怎么回事(以及为什么“b运行ch”这个词有问题)

首先要记住的是 Git 是关于提交的。刚接触 Git 的人,甚至那些已经使用了一段时间的人,通常认为 Git 是关于文件的,或者 b运行ches。但它不是,真的:它是关于 提交 .

每个提交都有编号,但编号不是简单的计数。相反,每个提交都会得到一个 random-looking——但实际上根本不是 运行dom—— 哈希 ID。这些东西又大又丑,Git 有时会缩写它们(例如你的 a13adadf),但每一个都是一些 Git 对象的数字 ID——在这个案例,对于 Git 提交。

Git 有一个包含所有对象的大数据库,可以通过 ID 查找。如果您给 Git 一个提交编号,它会根据 ID 找到该提交的内容。

提交的内容分为两部分:

  • 首先,有一个 Git 知道的所有文件的快照。这往往是大多数提交的大部分,除了一件事:文件以特殊的 read-only、Git-only、压缩和 de-duplicated 格式存储。当您进行新提交时,其中大多数文件与一些 previous 提交基本相同,新提交实际上并没有存储文件 again.它只是 re-uses 现有文件。换句话说,一个特定文件的一个特定版本被分摊到许多提交 re-use 上。 re-use 是安全的 因为 文件是 read-only.

  • 除了保存的快照,每个提交存储一些关于提交本身的元数据:信息。这包括提交人的姓名和电子邮件地址,以及一些 date-and-time 信息,等等。值得注意的是,为了 Git 的使用,每个提交的元数据还存储了提交的提交编号(哈希 ID)或在 之前 此特定提交的提交. Git 称其为 parent 或者,对于合并提交,是提交的 parents

这样做是为了让 Git 可以 向后 工作。这就是 Git 向后工作的方式。如果我们有一长串提交,全部连续,就像这样:

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

其中H代表链中last提交的实际哈希ID,Git将从提交H开始,从对象数据库中读取它。在提交 H、Git 中将找到所有已保存的文件,以及较早提交 G 的哈希 ID。如果 Git 需要它,Git 将使用此哈希 ID 从对象数据库中读取提交 G。这给出了 Git 较早的快照,以及 even-earlier 提交的哈希 ID F

如果 Git 需要,Git 将使用散列 ID F(存储在 G 中)来读取 F,当然 F 还包含另一个父散列 ID。因此,以这种方式,Git 可以从 last 提交开始并向后工作。

这给 Git 留下了一个问题:它如何快速找到链中 last 提交的哈希 ID?这是 b运行ch names 进来的地方。

A b运行ch 名称只包含最后一次提交的哈希 ID

考虑到上述情况——并故意变得有点懒惰,并将提交与提交之间的连接画成一条线,而不是从子项到父项的箭头——我们现在可以绘制 master b运行像这样输入:

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

name master 仅包含现有提交的实际哈希 ID H

让我们添加另一个名称,develop也包含哈希 ID H,如下所示:

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

现在我们有一个小问题:我们要使用哪个name?在这里,Git使用特殊名称HEAD来记住使用哪个b运行ch名称,所以让我们稍微更新一下绘图

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

这表示 git checkout master 之后的结果:当前 b运行ch name 现在是 master,并且 master选择提交 H,这就是我们正在使用的提交(以及我们正在使用的 b运行ch 名称)。

如果我们现在 运行 git checkout develop,Git 将切换到那个 b运行ch。 name 仍然标识提交 H,因此没有其他要更改的内容,但现在我们有:

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

如果我们现在进行新的提交,Git 将:

  • 打包它知道的所有文件(这是Git的索引暂存区的用武之地, 但我们不会在这里介绍);
  • 添加适当的元数据,包括您作为作者和提交者的姓名以及作为时间戳的“现在”,但重要的是,使提交 H 成为新的 parent提交;
  • 使用所有这些来进行新的提交,我们称之为 I

还有一件事Git会做,但让我们现在画这部分。结果是:

...--F--G--H
            \
             I

那两个名字呢?还有一件事:Git 会将 I 的哈希 ID 写入 当前名称 。如果那是 develop,我们得到这个:

...--F--G--H   <-- master
            \
             I   <-- develop (HEAD)

请注意,master 保持不变,但名称 develop 已移至指向最新的提交。

当两个名称标识相同的提交时,任一名称都选择该提交

请注意,最初,当 masterdevelop 都选择了提交 H 时,从某种意义上说,与 git checkout 一起使用哪个并不重要.无论哪种方式,您都将提交 H 作为当前提交。但是当你提交 new 时,现在它很重要,因为 Git 只会更新一个 b运行ch name.没有人知道新提交的哈希 ID 是什么(因为它部分取决于您进行提交的确切秒数),但是一旦提交,develop 将保存该哈希 ID,如果 develop 是当前的 name.

请注意,如果您现在 git checkout master 并再次提交,名称 master 将是这次更新的名称:

...--F--G--H--J   <-- master (HEAD)
            \
             I   <-- develop

我们暂时假设您还没有这样做。

Fast-forward

考虑到之前的情况,现在让我们 运行 git checkout master,然后返回使用提交 H:

...--F--G--H   <-- master (HEAD)
            \
             I   <-- develop

在这种状态下,让我们现在运行git merge develop

Git 将执行它为 git merge 所做的事情——见下文——并发现 merge base 是提交 H,这也是当前的提交。另一个提交 I 领先于 提交 H。这些是 Git 可以执行 fast-forward 操作的条件。

A fast-forward 不是真正的合并。 Git 对自己说: 如果我进行了真正的合并,我会得到一个快照与提交 I 相匹配的提交。因此,我会走捷径,只是 签出 提交 I,同时将名称 master 拖到我身边。结果如下所示:

...--F--G--H
            \
             I   <-- develop, master (HEAD)

现在没有理由保留绘图中的扭结了——我们可以把它全部画成一行。

真正的合并

有时,上述那种 fast-forward-instead-of-merge 技巧是行不通的。假设您开始于:

...--G--H   <-- develop, master (HEAD)

并进行两次新提交 I-J:

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

现在你 git checkout develop 再做两次提交 K-L:

          I--J   <-- master
         /
...--G--H
         \
          K--L   <-- develop (HEAD)

此时无论你给git checkout哪个名字,如果你运行git merge在另一个名字上,就没有办法前进了JL,反之亦然。从 J,你必须备份到 I,然后下降到共享提交 H,然后才能前进到 K,然后是 L

这种合并,那么,不能是fast-forward操作。 Git 将改为进行真正的合并。

要执行合并,Git 使用:

  • 当前 (HEAD) 提交:让我们先 git checkout master 来实现 J
  • 您命名的另一个提交:让我们使用 git merge develop 选择提交 L;
  • 还有一个提交,Git 自己找到了。

最后一次——或者实际上是第一次——提交是合并基础,合并基础是根据称为最低公共祖先的图形操作定义的,但是短并且可以理解的版本是 Git 从 both 提交找到 最佳共享共同祖先 。在这种情况下,就是提交 H:两个 b运行 分支的点。虽然 G 和更早的提交也被共享,但它们不如提交 H.

所以 Git 现在将:

  • 将合并基础 H 快照与 HEAD/J 快照进行比较,以查看我们在 master;
  • 上所做的更改
  • 将合并基础 H 快照与其他快照进行比较L 快照,看看他们在 develop 上做了什么改变;和
  • 合并 两组更改,并将它们应用于合并基础快照。

这是合并的过程,或者合并作为动词。如果可以,Git 将自行完成所有这些工作。如果成功,Git 将进行新的提交,我们称之为 M:

          I--J
         /    \
...--G--H      M   <-- master (HEAD)
         \    /
          K--L   <-- develop

请注意,新提交 M 指向 both 提交 J L .这实际上是使这个新提交成为合并提交的原因。因为 fast-forward 实际上是不可能的,所以 Git 必须 进行此提交,以实现合并。

你最初在做 fast-forwards

您一开始遇到的是这种情况:

...--G--H   <-- master, develop (HEAD)

然后产生:

...--G--H   <-- master
         \
          I   <-- develop (HEAD)

您使用 git checkout master; git merge develop 或类似的方法得到:

...--G--H--I   <-- master (HEAD), develop

之后你可以重复这个过程,首先制作develop,然后develop master,命名新提交J:

...--G--H--I--J   <-- master (HEAD), develop

但是此时你做了一些不同的事情:你在 master.

上做了 git revert

git revert 命令进行了新的提交。新提交的快照就像之前一个提交的快照 backed-out,所以现在你有:

                K   <-- master (HEAD)
               /
...--G--H--I--J   <-- develop

K 中的快照可能与 I 中的快照匹配(因此它 re-uses 所有这些文件),但提交编号是 all-new.

从这里开始,你做了 git checkout develop 并写了比 J 更好的提交,我们可以称之为 L:

                K   <-- master
               /
...--G--H--I--J--L   <-- develop (HEAD)

然后你又回到了master和运行git merge develop。这一次,Git 不得不 进行新的 合并提交 。所以它就是这样做的:

                K--M   <-- master (HEAD)
               /  /
...--G--H--I--J--L   <-- develop

现在,当您返回 develop 并进行新提交时,您会得到相同的模式:

                K--M   <-- master
               /  /
...--G--H--I--J--L--N   <-- develop (HEAD)

当您切换回 mastergit merge develop 时,Git 必须再次进行新的合并提交。 Fast-forwarding 是不可能的,相反你得到:

                K--M--O   <-- master (HEAD)
               /  /  /
...--G--H--I--J--L--N   <-- develop

你能做些什么

假设你现在运行git checkout develop && git merge --ff-only master。第一步选择develop作为当前b运行ch。第二个要求与 master 合并。这个额外的标志,--ff-only,告诉Git:但只有当你可以作为 fast-forward.

时才这样做

(我们已经相信 Git 可以像 fast-forward 一样做到这一点,所以这个 --ff-only 标志只是一个安全检查。不过我认为这是个好主意。)

因为 fast-forward 可能的,你会得到这个:

                K--M--O   <-- master, develop (HEAD)
               /  /  /
...--G--H--I--J--L--N

注意名称 develop 是如何向前移动的,指向提交 O,而没有添加新的合并提交。这意味着您在 develop 上进行的下一次提交将以 O 作为其父级,如下所示:

                        P   <-- develop (HEAD)
                       /
                K--M--O   <-- master
               /  /  /
...--G--H--I--J--L--N

如果你现在git checkout master; git merge develop你会得到一个fast-forward,两个名字都标识新的提交P,你会回到那种情况下提交develop 允许 fast-forward.

请注意,这样做实际上是在声明您根本不需要 develop 这个名字

如果您的 work-pattern 是:

  • 进行新提交
  • 向前拖动master以匹配

那么您需要做的就是在 master 上进行新的提交。

在另一个名字上进行新的提交本身并没有什么错误,如果这只是有时你的工作模式,那可能是一个好习惯:使用大量的 b运行ch 名字对你以后有帮助,养成在开始工作前起一个新名字的习惯是好的。不过,您可能需要考虑使用比 develop 更有意义的名称。

无论如何,请注意 Git 这里关心的是 提交 b运行ch names 只是您可以让 Git 帮助您 find 特定提交的方式:由每个名称都是您使用该名称进行工作的时间点。实际的 b运行ching,如果有的话,是你所做的提交的函数。

换句话说:要将提交形式变成 b运行ches,您需要 b运行ch 名称,但要有 b运行ch 名称单独不会将提交形式变成 b运行ches. 即:

...--F--G--H   <-- master
            \
             I--J   <-- develop

给你两个“最后”提交,但是一个线性链在提交 J 结束。从某种意义上说,有两个 b运行ches,一个以 H 结束,一个以 J 结束,但在另一种意义上,只有一个 b运行ch,结束于 J。我们可以添加更多名称,指向现有提交:

...--F   <-- old
      \
       G--H   <-- master
           \
            I--J   <-- develop

现在有三个 names(和三个“最后”提交)但是存储库中的实际 commits 集还没有变了。我们只是把 F 单独画在一条线上,让名字 old 指向它。