合并具有来自主分支的恢复合并的发布分支会将恢复合并到主分支中吗?

rub*_*nvb 1 git git-merge git-revert

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

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

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

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

tor*_*rek 8

是的,这是正常的。TL;DR:您可能想恢复恢复。但您询问的是有关机制的更多信息,而不是快速解决方案,因此:

\n

长的

\n

理解Git的合并的方法是理解:

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

快照部分非常简单:每次提交都保存每个文件的完整副本,截至您(或任何人)进行该提交时的状态。1 有一个怪癖,那就是 Git 从其索引(又名临时区域)中的任何内容(而不是某个工作树中的内容)进行提交,但这主要解释了为什么您必须运行git add这么多。

\n

第 2 点和第 3 点相互关联:提交历史记录,因为每个提交都存储一些早期提交的原始哈希 ID。这些向后指向的链接让 Git 可以随着时间向后移动:从提交到父级,然后从父级到祖父级,等等。类似或 的分支名称只是标识我们想要声明为该分支“上”的最后一次提交。mainmaster

\n

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

\n
... <-F <-G <-H\n
Run Code Online (Sandbox Code Playgroud)\n

这里H代表最后(最新)提交的哈希 ID。我们可以看到H“指向”之前的提交G(提交H实际上包含提交的原始哈希 ID G)。因此GH\ 的父级。同时 commitG包含更早提交的原始哈希 ID FFG其父级,这使其成为H祖父级。

\n

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

\n
...--F--G--H   <-- main\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n
    \n
  • 使用索引/暂存区域中的快照进行新的提交;
  • \n
  • 用元数据包装它,说明谁进行了提交,他们现在进行了提交,父级已提交H(当前提交)等等;
  • \n
  • 将所有这些写出来以获得我们将调用的新的随机哈希 ID I;和\xe2\x80\x94这是棘手的一点\xe2\x80\x94然后
  • \n
  • I\的哈希ID写入name main
  • \n
\n

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

\n
...--F--G--H--I   <-- main\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

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

\n
...--G--H   <-- develop, main, topic\n
Run Code Online (Sandbox Code Playgroud)\n

这里所有三个分支名称都选择 commit H。这意味着就我们检查的内容而言,我们git checkout或去哪一个并不重要在任何情况下我们都会检查提交。但是如果我们选择这里使用的名称,这也会告诉 Git 这也是当前的名称git switchHdevelopdevelop

\n
...--G--H   <-- develop (HEAD), main, topic\n
Run Code Online (Sandbox Code Playgroud)\n

请注意,所有提交(包括提交)都H位于所有三个分支上。

\n

现在,当我们进行新的提交时I, Git 更新的名称将是:即附加develop特殊名称的名称。HEAD所以一旦我们做出来,I我们就有了:

\n
          I   <-- develop (HEAD)\n         /\n...--G--H   <-- main, topic\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n
          I--J   <-- develop (HEAD)\n         /\n...--G--H   <-- main, topic\n
Run Code Online (Sandbox Code Playgroud)\n

向上的提交仍然H在所有三个分支上。提交并且至少目前\xe2\x80\x94仅在.IJdevelop

\n

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

\n
          I--J   <-- develop\n         /\n...--G--H   <-- main, topic (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n

topic如果我们现在再进行两次提交,那么这次移动的就是名称:

\n
          I--J   <-- develop\n         /\n...--G--H   <-- main\n         \\\n          K--L   <-- topic (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n
\n

1这些完整副本经过重复数据删除,因此,如果连续 3 次提交每次重复使用数百个文件,并且只有一个文件在新提交中一遍又一遍地更改,则数百个文件中的每一个都只有一个副本文件数量,在所有 3 次提交之间共享;这是改变的那个文件,具有三个副本,三个提交中每个副本都有一个。重用始终有效:今天进行的新提交,将所有文件设置回去年的状态,重新使用去年的文件。(Git进行了增量压缩,但它是在稍后以不可见的方式进行的,其方式与大多数 VCS 不同,但旧文件的即时重用意味着这并不像看起来那么重要。)

\n
\n

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

\n

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

\n
          I--J   <-- develop\n         /\n...--G--H   <-- main (HEAD)\n         \\\n          K--L   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

接下来,我们将运行git merge develop. Git 将找到合并基础:两个分支上的最佳提交。正在进行的提交main是直到 \xe2\x80\x94 为止的所有提交(包括以 \xe2\x80\x94commit 结尾)H。那些打开的develop都是J沿着中线和顶线向上提交的。Git 实际上是通过向后而不是向前工作来找到这些的,但重要的是它发现向上提交H共享的

\n

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

\n

在快进合并中,不需要实际合并。在这种情况下,Git 将跳过 merge,除非你告诉它不要这样做。相反,Git 将仅检查由其他分支名称选择的提交,然后拖动当前分支名称以满足该提交并保持HEAD附加,如下所示:

\n
          I--J   <-- develop, main (HEAD)\n         /\n...--G--H\n         \\\n          K--L   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

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

\n
\n

2这里的最新不是由日期定义的,而是由图表中的位置定义的:例如,比现在H“更接近” 。从技术上讲,合并基础是通过解决有向无环图扩展的最低公共祖先问题来定义的,并且在某些情况下,可以有多个合并基础提交。我们会小心地忽略这个案例,希望它永远不会出现,因为它相当复杂。查找我的其他一些答案,看看 Git 出现时会做什么JG

\n

3快进实际上是标签运动(分支名称或远程跟踪名称)的属性,而不是合并,但是当您使用 实现此目的时 git merge Git 将其称为快进合并。当你用git fetch或得到它时git push,Git 将其称为快进,但通常什么也不说;当获取或推送无法发生这种情况时,在某些情况下您会收到非快进错误。不过,我将把这些排除在这个答案之外。

\n
\n

真正的合并更难

\n

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

\n
          I--J   <-- develop, main (HEAD)\n         /\n...--G--H\n         \\\n          K--L   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

向上的提交J是在main我们当前的分支上。向上提交H,加上K-L,正在进行topic。那么哪个提交是最好的共享提交呢?好吧,从 向后工作J:从 开始J,然后点击 commit I,然后H,然后G,依此类推。L现在从到K向后工作H:提交H是共享的,并且它是“最右边”/最新可能的共享提交,因为它G出现在 之前 H。所以合并基础再次 commit H

\n

不过,这一次,提交H不是当前提交:当前提交是J。所以 Git 不能使用快进作弊。相反,它必须进行真正的合并。 注意:这就是您最初的问题所在。 合并是关于合并更改。但提交本身并不保留更改。他们拿着快照。我们如何发现发生了什么变化?

\n

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

\n

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

\n
git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n
git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

Git 获取这些更改中的每一个\xe2\x80\x94 你的和他们的\xe2\x80\x94 并将这些更改应用到H合并基础 commit 中的快照。这样,Git 会保留您的工作并添加他们的\xe2\x80\x94,或者,通过相同的论点,Git 保留他们的工作并添加您的工作。

\n

请注意,如果您在commit之后在某处进行了恢复H,而他们没有,那么您的恢复是自合并基础以来的更改,并且自合并基础以来他们没有更改任何内容。所以 Git 也会进行恢复。

\n

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

\n

如果 Git能够自己成功地合并所有内容\xe2\x80\x94,或者即使不能,但只要它认为它做到了\xe2\x80\x94,Git 通常会继续进行自己的新提交。这个新的提交在一个方面是特别的,但让我们先画一下它:

\n
          I--J   <-- develop\n         /    \\\n...--G--H      M   <-- main (HEAD)\n         \\    /\n          K--L   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

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

\n
\n

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

\n

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

\n

6这意味着如果您必须解决冲突,您实际上是在 Git 的索引/暂存区域中执行此操作。您可以使用工作树文件来执行此操作\xe2\x80\x94,这就是我通常所做的\xe2\x80\x94,或者您可以使用三个输入文件,Git在暂存区域中留下这些文件来标记冲突。不过,我们不会在这里详细讨论其中的任何细节,因为这只是一个概述。

\n
\n

真正的合并留下痕迹

\n

现在我们有了这个:

\n
          I--J   <-- develop\n         /    \\\n...--G--H      M   <-- main (HEAD)\n         \\    /\n          K--L   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

我们可以git checkout topic或者git switch topic并且做更多的工作:

\n
          I--J   <-- develop\n         /    \\\n...--G--H      M   <-- main\n         \\    /\n          K--L   <-- topic (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n

变成:

\n
          I--J   <-- develop\n         /    \\\n...--G--H      M   <-- main\n         \\    /\n          K--L---N--O   <-- topic (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

让我们找出答案:从M,我们回到JL。从O,我们回到N,然后到L啊哈! 提交L是在两个分支上。

\n

CommitK也在两个分支上,commit 也是如此H;但提交I-J不是,因为我们必须遵循提交中的“向后箭头”,并且没有从L到 的链接M,只有从M向后到 的链接L。因此,从L可以到达K,然后H,但我们无法到达M那条路,并且没有通往J或 的路径I。CommitK显然不如L、 、HK,所以 commitL最好的共享提交。

\n

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

\n
git diff --find-renames <hash-of-L> <hash-of-M>   # what we changed\ngit diff --find-renames <hash-of-L> <hash-of-O>   # what they changed\n
Run Code Online (Sandbox Code Playgroud)\n

“我们改变了什么”部分基本上是重新发现我们从中引入了什么I-J,而“他们改变了什么”部分从字面上弄清楚了他们改变了什么。Git 组合这两组更改,将组合的更改应用到快照L,并创建一个新快照:

\n
          I--J   <-- develop\n         /    \\\n...--G--H      M------P   <-- main (HEAD)\n         \\    /      /\n          K--L---N--O   <-- topic\n
Run Code Online (Sandbox Code Playgroud)\n

请注意,这次快进是不可能的,因为main已确定提交M(合并),而不是提交L(合并基础)。

\n

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

\n

还有更多合并变体

\n

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

\n
          I--J   <-- branch1 (HEAD)\n         /\n...--G--H\n         \\\n          K--L   <-- branch2\n
Run Code Online (Sandbox Code Playgroud)\n

这样很明显合并基础是 commitH并且我们正在 commit J。我们现在跑git merge --squash branch2。它像以前一样定位Lgit diff像以前一样执行两次操作,并像以前一样组合工作。但这一次,M它没有进行合并提交,而是进行了常规提交,我将其称为S(用于挤压),我们绘制如下:

\n
          I--J--S   <-- branch1 (HEAD)\n         /\n...--G--H\n         \\\n          K--L   <-- branch2\n
Run Code Online (Sandbox Code Playgroud)\n

请注意如何根本S 连接回提交。LGit 从来不记得我们是如何得到的SS只有一个快照,该快照是由进行合并提交的同一进程创建的M

\n

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

\n
          I--J--S   <-- branch1\n         /\n...--G--H\n         \\\n          K--L-----N--O   <-- branch2 (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

确实有效,但合并冲突的风险会增加。如果我们继续使用git merge --squash,在大多数情况下,合并冲突的风险会大大增加。一般来说,在这样的挤压之后唯一要做的就是完全放下 branch2

\n
          I--J--S   <-- branch1 (HEAD)\n         /\n...--G--H\n         \\\n          K--L   ???\n
Run Code Online (Sandbox Code Playgroud)\n

提交S保留了所有相同的更改,因此K-L我们放弃branch2,忘记了如何查找提交K-L。我们永远不会回头寻找它们,最终 \xe2\x80\x94 经过很长一段时间 \xe2\x80\x94Git 真的会把它们扔掉,它们将永远消失,只要没有其他人命名(分支或标签名称)让 Git 找到它们。历史似乎总是这样:

\n
...--G--H--I--J--S--...   <-- somebranch\n
Run Code Online (Sandbox Code Playgroud)\n

概括

\n
    \n
  • 快进合并不会留下痕迹(并且不进行任何实际合并)。
  • \n
  • 真正的合并会留下痕迹:与两个父级的合并提交。合并操作\xe2\x80\x94合并的操作,或作为动词合并\xe2\x80\x94使用合并基础来确定合并提交中的内容(作为形容词合并)。
  • \n
  • 挤压合并不会留下任何痕迹,通常意味着您应该杀死被挤压的分支。
  • \n
  • 还原只是正常的日常提交,因此合并还原就是合并还原。您可以在合并之前或之后恢复恢复,以撤消它。
  • \n
\n