【问题标题】:Merging a release branch that has a reverted merge from the main branch merges the revert into the main branch?合并从主分支恢复合并的发布分支会将恢复合并到主分支?
【发布时间】:2021-10-24 14:11:16
【问题描述】:

我们使用具有最新开发和发布分支的主分支进行开发,该分支经常从该主分支中分离出来并构成一个发布。 在这些发布分支上修复了错误,并且这些错误修复合并回主分支。我们所有的更改都经过 PR,您不能手动(强制)推送任何这些重要的分支。

现在,人为错误导致主分支被合并到发布分支(通过 PR)。这是通过包含错误合并提交的恢复提交的 PR 恢复的。因此,发布分支“很好”(除了这两个额外的提交)。 随后,这个发布分支并入了主分支。 接下来发生的事情出乎意料:从 main 到 release 的错误合并以某种方式被忽略(这是合乎逻辑的),但后续的 revert commit 撤消错误被合并,有效地删除了自发布分支以来主分支上的所有更改被分开了。

不幸的是,我不知道这究竟是如何发生的,但这可以以某种方式解释为“预期的”行为。我计划编写一个 git 命令的小脚本,尽快重复这种序列,并将在此处更新问题。

我的问题是:有没有一种方法(无需强制推送和消除错误提交)能够将发布分支合并到主分支中,而不会使恢复提交对主分支的文件产生影响?现在看来,这总是会导致还原提交更改不应该更改的内容。

【问题讨论】:

  • Here's an answer 提供了一些相关的上下文。这不是您的问题,因为它是关于尝试重新合并同一分支,而不是像您的情况那样将还原提交引入另一个分支,但我相信该答案中的解释和选项可能对您有用。 (在您的情况下,您几乎肯定想要#1 - 恢复还原。)

标签: git git-merge git-revert


【解决方案1】:

是的,这很正常。 TL;DR:您可能想要还原还原。但是您询问的是有关机制的更多信息,而不是快速解决方案,所以:

理解Git的merge的方法是理解:

  1. Git 使用(存储)快照;
  2. 提交历史:它们链接回旧提交;
  3. 首先提交“在一个分支上”意味着什么,并且提交通常在多个分支上;
  4. git merge 定位 合并基础,即 两个分支上的最佳共享提交
  5. 合并的工作原理,使用合并基础和两个提示提交

快照部分非常简单:每个提交都包含每个文件的完整副本,以您(或任何人)进行该提交时的状态为准。1 有一个怪癖,也就是说,Git 从其 index AKA staging area 中的任何内容进行提交,而不是某些工作树中的内容,但这主要解释了为什么您必须运行 @987654323 @这么多。

第 2 点和第 3 点相互关联:提交历史记录,因为每个提交都存储了一些较早提交的原始哈希 ID。这些向后指向的链接让 Git 在时间上向后移动:从提交到父级,然后从父级到祖父级,等等。像mainmaster 这样的分支名称 仅标识我们想要声明的任何提交是最后 提交“在”分支上。

这意味着您需要同时理解第 2 点和第 3 点。最初,这并不太难,因为我们可以像这样绘制提交:

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

这里H 代表last(最新)提交的哈希ID。我们可以看到H“指向”之前的提交G(提交H字面上包含提交G的原始哈希ID)。因此GH 的父级。同时提交G 包含仍然较早提交F 的原始哈希ID:FG 的父级,这使其成为H 的祖父级。

对于这张图,我们只是在末尾添加一个分支名称,例如,main 指向H

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

当我们向分支添加 new 提交时,Git:

  • 使用索引/暂存区域中的快照进行新提交;
  • 用元数据包装它,说明谁提交了提交,他们现在提交了,父提交是H(当前提交)等等;
  • 写出所有这些以获得一个新的随机哈希 ID,我们将其称为I;并且——这是棘手的一点——那么
  • I 的哈希ID 写入名称 main

最后一步更新分支,这样我们就有了:

...--F--G--H--I   <-- main

名称main 现在选择I,而不是H;我们使用I 来查找H,我们使用它来查找G,我们使用它来查找F,等等。

Git 知道要更新名称 main,因为(或者更确切地说,if)这是我们在进行新提交 I 时“打开”的分支。如果我们有多个分支名称,它们可能都指向同一个提交:

...--G--H   <-- develop, main, topic

这里所有三个分支名称都选择提交H。这意味着我们 git checkoutgit switch 到哪一个并不重要,就我们得到 签出的内容而言: 我们得到提交 H 在任何情况下签出。但是,如果我们选择 develop 作为我们在这里使用的名称,这会告诉 Git develop 也是 当前名称

...--G--H   <-- develop (HEAD), main, topic

请注意,通过并包括提交H 的所有提交都在所有三个分支上。

现在,当我们进行新的提交 I 时,Git 更新的 name 将是 develop:这是特殊名称 HEAD 附加到的名称。所以一旦我们制作了I,我们就有了:

          I   <-- develop (HEAD)
         /
...--G--H   <-- main, topic

如果我们再提交一次,我们会得到:

          I--J   <-- develop (HEAD)
         /
...--G--H   <-- main, topic

通过H 的提交在所有三个分支上仍然。提交 IJ 至少目前仅在 develop 上。

如果我们现在git switch topicgit checkout topic,我们返回提交H,同时将特殊名称附加到新选择的分支名称:

          I--J   <-- develop
         /
...--G--H   <-- main, topic (HEAD)

如果我们现在再提交两次,这次移动的是名称 topic

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

从这里开始,事情变得有点复杂和混乱,但我们现在已经准备好研究合并基础的概念。


1这些完整的副本被去重复,因此如果连续 3 次提交,则每次重复使用数百个文件,而只有一个文件反复更改同样在新的提交中,数百个文件中的每一个只有一个副本,在所有 3 个提交中共享;它是一个 changed 文件,具有三个副本,三个提交中的每个副本。重用在所有时间都有效:今天进行的新提交,将所有文件设置回去年的方式,重用去年的文件。 (Git 进行增量压缩,后来和不可见的方式与大多数 VCS 不同,但即时重用旧文件意味着这并不像看起来那么重要。)


合并有多种形式:现在让我们看看快进合并

运行git merge 总是影响当前分支,所以第一步通常是挑选出正确的分支。 (如果我们已经在正确的分支上,我们只能跳过这一步。)假设我们要检查 main 并合并 develop,所以我们运行 git checkout maingit switch main

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

接下来,我们将运行git merge develop。 Git 将定位合并基础:两个分支上的最佳提交main 上的提交是所有提交,包括(结束于)提交 H。那些在develop 上的都是通过J 的提交,沿着中间线和顶线。 Git 实际上发现这些是通过向后而不是向前工作,但重要的是它发现通过H 向上提交是共享

提交H最好的 共享提交,因为它在某种意义上是最新的。2 仅通过观察图表也很明显。但是:请注意,commit H,合并基础,与我们现在所坐的提交相同的提交。我们在main,它选择提交H。在git merge 中,这是一种特殊情况,Git 将其称为快进合并3

在快进合并中,不需要实际的合并。在这种情况下,Git 会跳过合并,除非你告诉它不要这样做。相反,Git 只会签出另一个分支名称选择的提交,然后拖动当前分支名称来满足它并保持HEAD 附加,如下所示:

          I--J   <-- develop, main (HEAD)
         /
...--G--H
         \
          K--L   <-- topic

注意没有 新的提交 发生。 Git 只是将名称 main "forward" 移动到了顶行的末尾,这与 Git 通常移动的方向相反(从提交向后退到父级)。这就是 快进 的作用。

您可以强制 Git 对这种特殊情况进行真正的合并,但为了便于说明,我们不会这样做(这对您自己的情况没有任何帮助)。相反,我们现在将继续进行另一个合并,其中 Git 无法 进行快进。我们现在将运行git merge topic


2最新 这里不是由 dates 定义的,而是由图中的位置定义的:H 是“更接近”@例如,987654403@ 比 G 是。从技术上讲,合并基础是通过解决Lowest Common Ancestor problem as extended for a Directed Acyclic Graph 来定义的,在某些情况下,可以有多个合并基础提交。我们会小心地忽略这个案例,希望它永远不会出现,因为它相当复杂。查找我的其他一些答案,看看 Git 在 确实 出现时会做什么。

3快进实际上是标签运动的一个属性(分支名称或远程跟踪名称),而不是合并,但是当你实现这个时使用 @987654405 @,Git 称之为快进合并。当您使用git fetchgit push 获得它时,Git 将其称为快进,但通常什么都不说;当 fetch 或 push 无法发生时,在某些情况下会出现 non-fast-forward 错误。不过,我会将这些排除在此答案之外。


真正的合并更难

如果我们现在运行git merge topic,Git 必须再次找到合并基础,即最佳共享提交。请记住,我们现在处于这种情况:

          I--J   <-- develop, main (HEAD)
         /
...--G--H
         \
          K--L   <-- topic

通过J 提交的内容位于我们当前的分支main。通过HK-L 向上提交,在topic 上。那么哪个提交是最好的 shared 提交?好吧,从J 向后工作:从J 开始,然后点击提交I,然后点击H,然后点击G,以此类推。现在从L 向后工作到KH:提交H 是共享的,它是“最右边”/最新可能的共享提交,因为G 出现在之前H。所以合并基础再次提交H

不过,这次提交 H 不是 当前 提交:当前提交是 J。所以Git不能使用快进作弊。相反,它必须进行真正的合并。 注意:这是您最初的问题所在。合并是关于合并更改。但提交本身不会保留更改。他们持有快照。我们如何找到改变的地方?

Git 可以比较提交H 和提交I,然后提交I 和提交J,一次一个,看看main 发生了什么变化。但这不是它所做的:它采用了一些不同的捷径,并将H 直接与J 进行比较。但是,如果它确实一次提交一次并不重要,因为它应该进行所有更改,即使其中一项更改是“撤消一些变化”(git revert)。

比较两个提交的 Git 命令是 git diff(如果你给它两个提交哈希 ID,无论如何)。所以这基本上相当于:4

git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed

在弄清楚自共同起点以来发生了什么变化之后,Git 现在需要弄清楚他们发生了什么变化,这当然只是另一个git diff

git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed

git merge 现在的工作是将这两组更改结合起来。如果您更改了 README 文件的第 17 行,Git 会将您的更新带到 README 的第 17 行。如果他们在 main.py 的第 40 行之后添加了一行,Git 会将他们添加到 main.py

Git 接受这些更改中的每一个(您的和他们的)并将这些更改应用于提交 H 中的快照,即合并基础。这样一来,Git 会保留您的工作并添加他们的工作——或者,根据相同的论点,Git 会保留他们的工作并添加您的工作。

请注意,如果您在提交H 之后 在某处进行了还原,而他们没有,则您的还原是自合并基础以来的更改,并且自合并基础以来他们没有更改任何内容.所以 Git 也选择了还原。

在某些情况下,您和他们可能更改了同一文件相同行,但方式不同。换句话说,您可能有冲突 的更改。5 对于这些情况,Git 会声明合并冲突并给您留下必须自己清理的混乱。但在数量惊人的情况下,Git 的合并只是自行工作。

如果 Git 能够自行成功合并所有内容——或者即使不能,但只要它认为它做到了——Git 通常会继续合并自己的新提交。这个新的提交有一个特别之处,但让我们先画出它:

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

注意名称 main 是如何向前拖动一跳的,这与任何新提交一样,因此它指向 Git 刚刚进行的新提交。提交M 有一个快照,就像任何其他提交一样。快照是由 Git 的索引/暂存区域中的文件创建的,就像任何其他提交一样。6

事实上,新的合并提交M唯一的特别之处在于它有两个父提交J,而不是只有一个父提交。对于通常的第一个父级,Git 添加了第二个父级,L。这就是我们在git merge 命令中命名的提交。请注意,其他分支名称也不会受到影响:名称main 已更新,因为它是当前分支。而且,由于通过从 last 提交向后工作可以找到“在”分支上的一组提交,所以现在 所有提交都在 main 上。我们从M 开始,然后我们返回一跳到both 提交JL。从这里,我们向后移动一跳到 both 提交 IK。从那里,我们向后移动一跳以提交H:向后移动一跳在分支较早分叉的点解决了这个“多路径”问题。


4--find-renames 部分处理您使用git mv 或等效项的情况。合并自动打开重命名查找; git diff最近 版本的 Git 中默认自动打开它,但在旧版本中,您需要明确的 --find-renames

5如果您更改的区域刚刚接触(邻接)他们更改的区域,Git 也会声明冲突。在某些情况下,可能存在排序限制;一般来说,从事合并软件工作的人发现这会产生最好的整体结果,并在适当的时候产生冲突。您可能偶尔会在不需要冲突时遇到冲突,或者在存在冲突时不会遇到冲突,但在实践中,这种简单的逐行规则对大多数 编程语言。 (对于像研究论文这样的文本内容,它往往效果不佳,除非您习惯将每个句子或独立从句放在单独的一行中。)

6这意味着如果您必须解决冲突,您实际上是在 Git 的索引/暂存区中执行此操作。您可以使用工作树文件来执行此操作(我通常这样做),或者您可以使用三个输入文件,Git 将它们留在 in 暂存区域以标记冲突。不过,我们不会在这里详细介绍这些内容,因为这只是一个概述。


真正的合并留下痕迹

现在我们有了这个:

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

我们可以git checkout topicgit switch topic 做更多的工作:

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

变成:

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

例如。如果我们现在git checkout maingit switch main,并再次运行git merge topic合并基数 提交是什么?

让我们找出来:从M,我们回到JL。从O,我们回到N,然后回到L啊哈! Commit L两个分支上

commit K 也在两个分支上,commit H 也是如此;但是提交I-J 不是因为我们必须遵循提交中的“向后箭头”,并且没有从LM 的链接,只有从M 向后到L。所以从L我们可以到达K然后H,但是我们不能通过这种方式到达M,并且没有通往JI的路径。提交K 明显低于LH 低于K,以此类推,所以提交L最佳 共享提交。

这意味着我们的下一个git merge topic 运行它的两个差异:

git diff --find-renames <hash-of-L> <hash-of-M>   # what we changed
git diff --find-renames <hash-of-L> <hash-of-O>   # what they changed

“我们改变了什么”部分基本上是重新发现我们从I-J 带来的东西,而“他们改变了什么”部分从字面上理解他们改变了什么。 Git 将这两组更改合并,将合并后的更改应用到来自L 的快照,并制作一个新快照:

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

请注意,这次无法进行快进,因为 main 确定了提交 M(合并),而不是提交 L(合并基础)。

如果我们稍后在topic 上进行更多开发并再次合并,未来 合并基础现在将提交O。除了传播从LM 的差异(现在保留为从OP 的差异)之外,我们不必重复旧的合并工作。

还有更多的合并变体

我们不会涉及git rebase——因为它是重复的挑选,是一种合并形式(每个挑选本身就是一个合并)——但让我们简要地看一下git merge --squash。让我们从这个开始:

          I--J   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

所以很明显合并基础是提交H,并且我们正在提交J。我们现在运行git merge --squash branch2。这像以前一样定位L,像以前一样做两个git diffs,并像以前一样组合工作。但是这一次,它不是进行合并提交M,而是进行常规提交,我将其称为S(用于壁球),我们绘制如下:

          I--J--S   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

注意S 是如何 连接回提交L。 Git 永远不会记得我们是如何获得SS 只是有一个快照,该快照是由 进行合并提交 M 的同一进程制作的。

如果我们现在向branch2 添加更多提交:

          I--J--S   <-- branch1
         /
...--G--H
         \
          K--L-----N--O   <-- branch2 (HEAD)

并运行git checkout branch1git switch branch1,然后再次运行git merge branch2,合并基础将再次提交H。当 Git 比较 HS 时,它会看到我们做了他们在 L 中所做的所有相同的更改,加上我们在 I-J 中所做的任何更改;当 Git 比较 HO 时,会看到他们在整个序列 K-L-N-O 中进行了所有更改; Git 现在必须将我们的更改(包含之前的一些更改)与他们的所有更改(同样包含之前的一些更改)结合起来。

确实工作,但合并冲突的风险上升。如果我们继续使用git merge --squash,在大多数情况下,合并冲突的风险会一路上升。作为一般规则,在这样的壁球之后唯一要做的就是丢弃 branch2 完全:

          I--J--S   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   ???

Commit S 包含与K-L 相同的所有更改,因此我们删除了branch2,忘记了如何找到提交K-L。我们从不回头寻找它们,最终——在很长一段时间后——Git 真的会把它们真正扔掉,它们将永远消失,前提是没有其他人提出任何让 Git 找到它们的名称(分支或标签名称)。历史似乎总是这样:

...--G--H--I--J--S--...   <-- somebranch

总结

  • 快进合并不会留下痕迹(也不会进行任何实际合并)。
  • 真正的合并留下痕迹:与两个父母的合并提交。合并操作——合并的动作,或 作为动词的合并——使用 merge base 来确定 merge commit 中的内容(作为形容词合并)。
  • Squash 合并不会留下任何痕迹,通常意味着您应该关闭被压扁的分支。
  • revert 只是普通的日常提交,因此合并revert 会合并reversion。您可以在合并之前或之后还原还原以撤消它。

【讨论】:

  • 那是你写的一篇博文:)。我希望这对许多人有用。 “请注意,如果您在提交 H 之后的某个地方进行了还原,而他们没有,那么您的还原是自合并基础以来的更改,并且自合并基础以来他们没有更改任何内容。所以 Git 也选择了还原。”证实了我的怀疑,即恢复被认为是要合并的更改。那么在主分支上恢复 revert 提交是否会一劳永逸地结束,并允许我们稍后将新的更改合并到发布分支而不会出现任何问题?
  • @rubenvb@rubenvb 是的,还原还原将修复它。
猜你喜欢
  • 2011-06-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-06-23
  • 2017-02-26
  • 1970-01-01
相关资源
最近更新 更多