如何 git 恢复通过 cherry-picking 完成的合并提交

How to git revert merge commits done by cherry-picking

我想还原这 3 个提交 295e8ce6e63e39b87548f2f52b3eb0d5139999effb690eaec7938abbd614b0b07a5364998c09f7a592ed5ce3d6b4cb70e56f74f17e80df960311b647。我能够还原最后一个 295e8ce6e63e39b87548f2f52b3eb0d5139999ef 但无法还原底部 2。我尝试了以下操作:

$git revert fb690eaec7938abbd614b0b07a5364998c09f7a5 -m 1 
$git revert 92ed5ce3d6b4cb70e56f74f17e80df960311b647 -m 1

然后我得到

Already up to date!
On branch feature
Your branch is up to date with 'origin/feature'.

nothing to commit, working tree clean

但是我的 git 日志没有恢复最后 2 次提交并且它保持不变:

commit d81838a9dc973acbe03865dce0533bd41e77860e (HEAD -> feature, origin/feature)
Author: abc
Date:   Thu Nov 5 14:08:59 2020 -0800

    Revert "merge PR #173"
    
    This reverts commit 295e8ce6e63e39b87548f2f52b3eb0d5139999ef.

commit 295e8ce6e63e39b87548f2f52b3eb0d5139999ef
Author: xyz
Date:   Fri Aug 28 22:30:12 2020 -0700

    merge PR #173

commit fb690eaec7938abbd614b0b07a5364998c09f7a5
Merge: 3cef115 92ed5ce
Author: abc
Date:   Thu Nov 5 12:29:19 2020 -0800

    Merging changes from e6a35ad0b2363932ac190ec602a7fd0c8bf9f04f

commit 92ed5ce3d6b4cb70e56f74f17e80df960311b647 (temp)
Author: xyz
Date:   Wed Sep 2 17:32:07 2020 -0700

    readjust null values for double data type from v5

您所看到的行为几乎可以肯定是正常的。 (如果没有访问相关存储库,我无法确定。)

我可以重现非常相似的东西,其中 git revert 什么都不做:

$ git revert 6af46d5b31c241a666c90fb77ae54baac842b251
On branch master
nothing to commit, working tree clean

请注意,我不会在此处还原合并提交。您恢复合并的事实与 revert-result 无关,但 确实 解释了 为什么您处于 ,其中你要求的恢复什么都不做。无事可做,所以 Git 什么都不做。

但那是什么解释呢?为什么不再需要恢复?那么,为此,我们必须了解什么是还原,以及它是如何工作的。它实际上与git cherry-pick直接相关:事实上,git cherry-pickgit revert都使用相同的代码

理解git revert

让我们先看看 git revert 的目标,然后看看 Git 如何完成还原。请记住,在 Git 中,动词 revert——字面意思是 turn back,具有法语和拉丁语词源,源自 revertere — 并不意味着“return 到”,而是意味着更多类似于“撤消”的内容。在英语中(包括特有的美式英语,其中 -ised 拼写为 -ized),动词 revert 通常与助动介词 ,如恢复到(以前的某个状态)。不过,这不是 Git 中 revert 的意思。 Git 的意思更像是 退出: 我们希望 Git 进行一些提交,并将其视为 更改 到一些文件集。找出哪些文件以何种方式更改后,我们希望 Git 现在 撤消 这些更改:即,采用我们拥有的状态 现在 并应用在另一个提交中发现的更改的逆转。

因此,我们希望 Git 假装提交是 更改 — 但提交实际上并不是更改!每个提交都包含每个文件的 完整快照 。幸运的是,每个提交还包含 上一个 提交的哈希 ID。每个提交都有一个唯一的编号,或 hash ID,并且每个提交都会记住上一个提交的哈希 ID。因此 Git 可以获取您在 git log 输出中看到的提交字符串:

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

其中 H 是某个提交的哈希 ID,不仅提取 H 的快照,还提取更早的提交 G 的快照。 Git 然后可以 比较 G-提交快照与 H- 提交快照。对于相同的文件,这些文件没有发生任何事情。对于两个提交中都存在但不同的文件,这些文件中的不同之处就是 更改 。如果 G 中有 H 中不存在的文件,则这些文件已 删除 ,如果 H 中存在未删除的文件t 在 G 中,这些文件是 新添加的

这始终是 Git 将提交作为更改呈现给您的方式:遍历 提交图 ,由每个提交与其 [=567= 的连接组成].当你有:

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

名称 somebranch 允许 Git 找到最新的提交 H,并且 H 允许 Git 找到较早的提交 G ,它允许 Git 找到更早的提交 F,等等。从这些快照中,Git 可以向您展示变化。 Git 简单地将 parent 提交(例如 G)与 child 进行比较提交如 H.

如果我们有一个像这样的长链:

...--C--D--E--F--G--H   <-- somebranch

和我们在E中所做的更改,即D-vs-E的区别,原来是坏主意,我们可以告诉 Git:恢复我们在 E 中所做的更改。 Git 现在将提取 DE,查看 更改的内容 ,并尝试撤消相同的更改。如果我们添加了一个新文件 newfile,Git 可以 删除 整个文件 newfile。如果我们向某个现有文件添加一些行,Git 可以从该文件中删除这些行。如果我们从某个文件中删除了一些行,Git 可以将这些行放回去。

但是有一个明显的问题。假设我们在提交 E 中添加了一个包含两个库例程的新文件 lib.py。假设通过提交 Hlib.py 有十几个库例程。 完全删除lib.py会破坏一切。因此,git revert 必须执行的 back-out-some-changes 操作必须以某种方式进行调整。这里有许多可能的“不知何故”; Git 选择的是使用其 三向合并 引擎。

在我们了解 git revert 如何使用 Git 的合并引擎之前,我们必须首先了解 git merge 本身。

合并:合并作为动词,合并作为名词

在Git中,我们通常使用git merge—或运行git pull来制作Git运行git merge—执行合并操作,这通常会导致合并提交。 merge这个词在a merge commit这个词中起到了形容词的作用,但是我们经常把merge commit简称为“a merge”。这里单词 merge 作为名词。

我们通过执行一些特定的操作来为合并(或合并提交,如果您愿意)生成快照。这些动作就是合并的过程。他们实现了合并的动词形式,合并(一些文件and/or一些提交)。确切的操作集当然很重要,但最重要的是,为了执行此合并操作,我们需要的不是两个而是 三个 输入。我们有一些原始的东西——一个提交,或者一个提交中的一个文件——以及那个原始东西的两个修改版本,我们称之为“我们的”和“他们的”。我们有 合并引擎 ,它是生成最终文件的代码,检查所有这三个输入并找出结果应该是什么。另见 three way merge in Merge (version control) in Wikipedia and/or Why is a 3-way merge advantageous over a 2-way merge?

合并单独的分支时,这一切都非常有意义。三个输入是三个提交。我们有一些看起来像这样的提交系列:

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

我们在分支 br1 上,提交 J。我们 运行 git merge br2 合并提交 L。此过程的共同起点 是提交H:最佳共享提交,在两个 分支上。合并引擎通过将 H 中的快照(我们都开始时使用的快照)与 J 中的快照进行比较来查看 我们 更改的内容,并比较 HL 看看 他们 改变了什么。合并引擎然后将我们的更改与其更改组合,将 合并的 更改应用到 H 中的快照,并生成最终快照。

当我们使用 git merge 调用合并引擎时,git merge 提交本身使结果合并 commit M 如下所示:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

现在我们有一个合并(名词),通过合并(动词)提交 JL 使用提交 H 作为共同起点。

git cherry-pick 如何使用合并引擎

想象一下我们有这样的 branch-y 情况:

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

也就是说,我们正在 br1 上做一些工作。在这一点上,我们意识到我们需要的正是我们或其他人在提交 L 中所做的,因为它修复了一些错误。但是我们还没有准备好得到commitK的效果,所以我们不想mergebr2完全。相反,我们想检查提交 L,查看提交 L 中的 更改,并获取 相同的更改 为了我们自己。

这就是 git cherry-pick 所做的:它比较 KL 中的快照,看看 L 中发生了什么变化,然后它使 same 更改我们在提交 J 时的位置,并进行新的提交。但是 方式 它这样做有点偷偷摸摸:它使用与 git merge.

相同的合并引擎

假设我们将提交 K 指定为(假的!)merge baseshared common commit,并使用 commit J 作为我们当前的提交,因为它是。同时我们使用 commit L 作为“他们的”提交。也就是说,这些是唯一“有趣”的提交:

             J   <-- br1 (HEAD)
   
          K--L   <-- br2

我们有 Git,通过它的合并引擎,比较 KL 看看他们改变了什么:这些是我们想要的改变 添加到我们的提交中。然后我们还有 Git 比较 KJ,看看“我们改变了”什么:这些是我们希望 保留的与 K 的差异.

就这两组更改不冲突而言,它是安全的——好吧,就像 Git 可以自动化一样安全,无论如何——采取从 L 变化而来。如果他们 冲突,Git 将声明合并冲突,暂停操作,并允许我们解决冲突。

如果一切顺利,Git 在最后做一个普通的 non-merge 提交:

          I--J--L'  <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

其中 L'L 的副本。如果不是,Git 因合并冲突而暂停。 我们 修复冲突,运行 git cherry-pick --continuegit commit 提交 L'.

还原只是 cherry-pick 向后完成

如果我们有:

             J   <-- br1 (HEAD)
   
          K--L   <-- br2

并比较 K-vs-L 看看他们改变了什么,比较 K-vs-J 看看我们改变了什么,我们得到一个 cherry-pick操作。但是如果我们 Git 选择提交 L 作为“基本”提交,K 作为“他们的”,并且 J作为“我们的”?那么我们想要保留的变化是从 LJ 的变化,以及从 LK 的变化——即变化的逆他们实际上做了——ar我们想要 添加 .

的更改

无论我们过去是否提交 KL,这都有效!如果它们是——如果我们有这样的东西:

...o--o--K--L--o--o--o--J   <-- branch (HEAD)

那么L-vs-J就是我们想要保留的一切,而L-vs-K就是我们要“加”的。它已经是 K-vs-L 的诗句,所以它将“撤消”或 退出 K 到 [=76] 的更改=].

所以git revert像以前一样简单地应用合并引擎,以child提交L)作为共同起点, parent 提交 reverse (K) 作为他们的更改,我们的提交 J 作为我们的提交。如果一切顺利,Git 自己进行新的提交:

...o--o--K--L--o--o--o--J--Γ   <-- branch (HEAD)

其中提交 Γ“撤消”L(gamma 看起来像 upside-down L)。

这什么时候什么都不做?

无论是 cherry-pick 还是还原,当我们要求 Git 到 add 的更改是 已经。例如,假设我们有:

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

与原始 cherry-pick 示例相同。进一步假设提交 L 修复了提交 K 中的错误,但是提交 I and/or J 已经修复了相同的错误(也许也做了更多的事情)。如果我们 运行 git cherry-pick br2 要求 Git 在此处复制提交 L,Git 将比较 K-vs-L看看他们改变了什么,然后比较 KJ 看看 我们 改变了什么。 我们改变的已经包含了一切,没有什么可以添加。 cherry-pick 只是停止。由于实施的一个怪癖——cherry-pick 以 运行 内部提交结束——你还会收到有关没有任何内容可提交的消息。

还原也是如此:如果我们要求还原 L,但在我们工作的某个地方,我们 已经实现了 ,没有什么可做的。 git revert 尝试提交,但没有任何内容可提交,您得到了您看到的消息。

为什么还原合并很特别?

在某些方面,恢复或 cherry-pick 合并 一点也不 特别。但有一种方法是。请记住,合并提交有 两个 以前的快照,并且 git cherry-pickgit revert 都需要找到 the [=您在 git 命令中命名的 child 提交的 567=]。合并没有 the parent:它有 two parents(或更多,在章鱼合并,但我们不会去那里。

因此,您必须命名 您希望 Git 使用的 parent 作为 merge-base (cherry-pick) 或“他们的”提交(恢复)。那就是你命令行中的-m 1

我们必须考虑的另一件事是合并快照中的内容。请记住,合并提交,例如本例中的 M

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

是在 H 的基础上加上 添加 all H-vs- JallH-vs-L.

的变化

为此,如果我们愿意,我们可能会添加更多提交:

          I--J
         /    \
...--G--H      M--N--O   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

现在假设我们要求 Git 恢复 M,使用 J 作为 parent。 Git 将:

  • M视为合并的starting-point;
  • O 视为“我们的”提交,因此找到 M-vs-O 作为 keep 的内容;和
  • J 视为“他们的”提交,因此找到 M-vs-J 作为 add.

如果我们不添加任何提交,这会简化一点:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

Git 现在将 M 视为基础提交和我们的提交:

  • 找到 M-vs-M 我们应该 keep (什么都没有);
  • 找到 M-vs-J 作为他们更改的应该添加到这里。

M-vs-J 添加到 nothing,当然会提供将快照转换为M 回到 J 中的那个。所以结果是:

          I--J
         /    \
...--G--H      M--J'  <-- br1 (HEAD)
         \    /
          K--L   <-- br2

其中 J' 表示新提交中的 快照 与提交 J 中的快照匹配。就 source snapshots 而言,我们刚刚撤消了整个合并。

现在,如果提交 L 本身是先前合并的结果怎么办?也就是说,如果不是上面的,我们开始于:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
        |     /
        : _--L   <-- br2
        ::  /
         :..

HL 之间有一些大的混乱历史,所以 L 是一个合并。 不管我们是如何到达那里的L 中的 快照 结合了从 H 到那一点的所有工作.如果我们现在使用 J 作为 parent 恢复 M,以获得新的提交 J',提交 J' 已撤消 一切 我们通过 L 引入,包括任何 mrges 到达 L。因此,恢复历史记录中从 HL 的所有合并现在无效。

我认为这就是您的情况。 Git 无事可做,因为无事可做。