Git 从历史记录中删除合并提交,但保留与其关联的提交

Git remove merge commit from history, but retain the commits with which it has been connected

我有历史上最后 6 次提交的以下结构,都在同一个分支中:

A(merge commit)
|
|\
B \
|  |
|  C
|  |
|  D
|  |
| /
|/
E
|
F

我想删除 A 合并提交,但我想在 B 提交之前将 C 和 D 提交保持在线性历史记录中。我提到所有提交都被推送到远程。我尝试 reset --soft HEAD~1 删除 A 合并提交,但使用该命令其他提交 C 和 D 也已被删除。我还想删除最后一个合并提交中的新更改,我想在 B 提交中传输该修改,因为它将添加到与 B 提交中相同的文件中。

我想要的最终历史:

B
|
|
C
|
|
D
|
|
E

如果您没有任何正在进行的工作,我会简单地做:

git switch mybranch # commit A
git reset --hard C
git cherry-pick B

这样,您将在新的 'mybranch' HEAD C.
之上重新创建 B 之后将需要一个 git push --force(如果之前已推送该分支),因此,如果您不是一个人在该分支上工作,请务必通知您的同事。

TL;DR

使用 git reset --soft(正如您所做的那样,但使​​用不同的目标,HEAD^2 或提交的原始哈希 ID C),然后使用 git commit。您的 git commit 可能需要一两个额外的选项。有关详细信息,请参阅详细答案。

(还要注意,你需要 git push --force,就像 中那样。我怀疑他在你提到你在提交 A 中也有修复之前写了那个答案.)

让我们更正一些 statements-of-fact 是......好吧,它们在 微妙的 方面是错误的。就您 所看到的 .

而言,它们是正确的

I try to reset --soft HEAD~1 to delete the A merge commit but with that command the other commits, C and D have been also deleted.

实际情况并非如此。提交尚未删除。他们只是变得很难找到。原因很简单:Git 实际上可以 向后.

让我 re-draw 你的水平提交顺序,我更喜欢 Whosebug 帖子的方式,左边是旧的提交,右边是新的提交。这给了我这张图:

...--F--E---B--A   <-- somebranch (HEAD)
         \    /
          D--C

其中,根据重置的结果,我们看到 BA 的第一个父级。 运行 git log 此时会:

  • 显示提交A;然后
  • 显示提交 B 因为 A link 回到 B;然后
  • 显示提交 C 因为 A link 回到 C;然后
  • 显示提交 D 因为 C link 回到 D;然后
  • show commit E 因为 BD link 回到 E

等等。显示 BCD 的精确顺序取决于您为 git log 提供的任何 commit-sorting 选项:--topo-order 强制生成一个合理的图表order,例如,while --author-date order 使用作者日期和时间戳。默认是使用提交者日期和时间戳,在 less-recent 提交之前看到最近的提交。

当我们进行重置时,我们得到了这个。由于我绘制图表的方式,我需要将 B 向上移动一条线,但 A 仍然 link 回到 BC 两者:

          B___ <-- somebranch (HEAD)
         /    \
...--F--E      A
         \    /
          D--C

也就是说,在 git reset --soft HEAD~1 之后,name somebranch 现在选择提交 B 而不是提交 A

因为Git 向后工作,我们不再看到 提交ACDgit log 操作以 commit B 开始,并显示它; B 然后 link 回到 E,所以 git log 移动到 E 并显示它; E link 回到 F 所以我们看到 F,等等。我们从来没有机会将 forward 移动到 DCA:这根本不可能,因为 Git 有效 向后.

The final history I want to have:

E--D--C--B   <-- somebranch (HEAD)

现在,事实上,提交 B——B 代表一些丑陋的大哈希 ID——连接回提交 E。情况总是如此:根本无法更改现有的提交。所以这段历史是不可能的。但是,我们可以创建一个 new 提交 B',它很像 B,但又不同。

Also I have a new change in the last merge commit I want to delete and I want to transfer that modification in the B commit ...

当我们进行新的 B' 提交时 -B-but-different,我们也可以这样做。

侧边栏:更多关于提交以及 Git 如何进行的信息

Git 中的每个提交都包含两个部分:

  • 每个提交都有一个 每个文件的完整快照,Git 在您(或任何人)进行提交时知道 。这些快照存储文件,但与您的计算机存储它们的方式不同。相反,它们的名称和内容存储为内部 Git 对象,并且这些对象被压缩和 de-duplicated(并且一直冻结)。 de-duplication 意味着如果您有一系列提交 C1C2C3,每个提交都有数千个文件,但只有 一个 文件实际上 更改 在这些提交中,数千个文件都是 共享的 。每个新提交只有一个 new 文件。即使那样,新数据也会以各种方式进行压缩和 Git-ified,这可能会将一个大文件变成一个很小的增量(最终——这发生在游戏的后期,在 Git,因为你会得到更好的增量那样)。

  • 每个提交还存储一些元数据,或有关提交本身的信息。这包括作者和提交者信息:提交者和提交时间。它包含一条日志消息:如果您要提交,则可以自己编写。而且——对于 Git 自己的目的来说,所有这些都很重要——提交包括原始哈希 ID,那些像 225365fb5195e804274ab569ac3cc4919451dc7f 这样的丑陋的大字符串,对于每个提交的 parents .对于大多数提交,这只是一个较早的提交;对于像您的提交 A 这样的合并提交,这是两个提交哈希 ID 的列表(对于 BC,按此顺序)。

新提交中的元数据来自您的 user.nameuser.email 设置——因为那是您的姓名和电子邮件地址所在的位置——以及 frm 信息 Git 可以立即找到,例如,存储在您计算机时钟中的当前日期和时间。 (如果时钟不对,提交的 date-and-time-stamps 也会出错。没什么大不了的,它们只是用来混淆人类。)新的 parent提交是...当前提交,作为pointed-to当前b运行ch名称。

因此,如果我们希望新提交 B' 指向现有提交 C,我们需要提交 C——而不是提交 B,而不是提交 E——成为当前提交。为了实现这一点,我们需要使 name somebranch 指向提交 C.

有很多方法可以在 Git 中移动 b运行ch 名称,但我们在这里使用的是 git resetgit reset 命令又大又复杂,其中一个复杂之处在于它可以重置 Git 的 index。所以让我们提一下指数。

索引——Git也称为暂存区,指的是你如何使用它,有时也称为缓存,虽然现在主要是在 --cached 之类的标志中,如 git rm --cachedgit diff --cached——是 Git 获取 文件的地方 放入一个新的提交。换句话说,索引保存了新提交的 建议快照 。当您进行新提交时,该新提交将同时具有元数据和快照,而快照来自 Git 的索引。

当我们将索引描述为暂存区时,我们谈论的是我们如何更改工作树文件,然后使用git add复制它们进入 集结区。这没有错,但这张图片并不完整:它表明停靠区一开始是空的,然后逐渐填满。但实际上,它 开始时满是文件 。只是它充满的文件与提交和工作树中的 相同 文件。

当你 运行 git status 并且它说,例如:

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   Makefile

这并不意味着 只有 Makefile 会进入下一个快照。事实上,每个文件 都将进入下一个快照。但是 Git 的索引 / staging-area 中的 Makefile 现在 不同于 MakefileHEAD 提交 现在 .

如果我现在 运行 git diff --cached(或 git diff --staged,完全一样),我得到:

diff --git a/Makefile b/Makefile
index 9b1bde2e0e..5d0b1b5f31 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,4 @@
+foo
 # The default target of this Makefile is...
 all::
 

我在 Makefile 和 运行 git add Makefile 的前面放了一些伪造的东西才能到达这里,这意味着我 Git 踢掉了现有的 HEAD-从索引中提交 Makefile 的副本,并放入 Makefile 的现有 working-tree 副本。这就是行 foo 的来源。

如果我使用 git restore --staged Makefile,正如 Git 此处建议的那样, 会将 HEAD:Makefile 复制到 :Makefile。这里的 colon-prefix 语法特定于某些 Git 操作(例如 git show)并允许您读取 Git 中的文件副本。我的 工作树 Makefile 的副本不在 Git 中 ,因此没有特殊的语法: 这只是一个普通的普通文件。但是对于一些 Git 命令,有一个特殊的语法,带有这个冒号的东西。例如,使用 git show HEAD:Makefile 查看 committed 副本,使用 git show :Makefile 查看 index 副本。

无论如何,我现在听从Git的建议:

$ git restore --staged Makefile
$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   Makefile

no changes added to commit (use "git add" and/or "git commit -a")

我运行将MakefileHEAD副本复制到索引/staging-area中的git restore --staged。所以现在这两个 相同 git status 并没有说他们是 staged for commit。但是现在索引Makefile和我的工作树Makefile不一样了,所以现在git status这两个不一样

关于git reset

我在这里使用的git restore命令是new-ish,已在Git 2.23中引入。 git reset 命令要老得多。这是一个大而复杂的命令,所以我们只会看看我们可以使用它的一部分方法。

当用作:

git reset --soft HEAD~1

例如,这种git reset移动当前b运行ch名称。也就是我们这样取图:

          B___
         /    \
...--F--E      A   <-- somebranch (HEAD)
         \    /
          D--C

并移动 somebranch 使其指向 B,像这样:

          B___ <-- somebranch (HEAD)
         /    \
...--F--E      A
         \    /
          D--C

没有提交 更改。没有提交 可以 更改。

如果我们使用git reset --mixed,我们会Git移动b运行ch名称改变所有的Git 索引中的文件副本。如果我们要使用 git reset --hard,我们会 Git 移动 b运行ch 名称,更改 Git 索引中的文件副本,替换我们工作树中文件的副本。所以这个 kind of git reset 最多做三件事:

  1. 移动我们的HEAD。使用我们给出的参数和 git rev-parse / gitrevisions 中的规则,找到一些提交。无论我们使用什么 b运行ch 名称——如果 git statuson branch somebranch,那就是somebranch—使该名称指向该提交的哈希 ID。

    如果--soft,停止!否则,继续...

  2. 替换 Git 索引中的所有文件。替换文件来自我们在步骤 1 中选择的提交。

    如果--mixed或没有选项,停止!否则(--hard),继续...

  3. 按照步骤 2 中替换索引文件的方式替换工作树文件。

如果你已经完成了所有这些,你可以看到 git reset --mixedgit reset --hard 可以,如果我们选择 当前提交 作为new commit, just reset the index, or reset the index and replace the working-tree文件。如果我们不给 git reset 一个特定的提交哈希 ID 或名称或相关指令,如 HEAD~1HEAD^2git reset 使用 HEAD。所以 git reset --soft HEADgit reset --soft 只是一种什么都不做的方法,但是 git reset HEADgit reset 是一种清除 Git 索引的方法,使得它再次匹配 HEAD。 (你不想这样做——我只是在这里注明,以便你可以对 git reset 的行为有一个正确的心理模型。)

关于git commit

当你 运行 git commit, Git:

  • 收集任何必要的元数据,包括日志消息;
  • 添加适当的父提交哈希 ID:通常只是 HEAD,但如果您要提交合并,HEAD 加上更多;
  • 将Git索引中的任何内容打包为新快照;
  • 将所有这些作为新提交写出,它获得一个新的、唯一的哈希 ID;和
  • new 哈希 ID 写入 b运行ch name.

最后一步是我们如何从:

...--F   <-- somebranch (HEAD)

至:

...--F--E   <-- somebranch (HEAD)

例如,很久以前。你做了 git checkout somebranchgit switch somebranch。那:

  • 选择提交 F,因为 somebranch 指向提交 F
  • 填写Git的索引来自提交;
  • 从提交中填写你的工作树(现在在 Git 的索引中表示);和
  • 将名称 HEAD 附加到名称 somebranch,以便 Git 知道将来的提交应该写入 somebranch.

然后你修改了一些文件和运行 git add。这将任何更新的文件 复制到 Git 的索引中,准备提交。索引继续持有提议的下一次提交(或快照部分),git add 改变提议的快照,通过弹出一些current 索引文件并放入新的(更新的)文件。它实际上是执行所有 Git-ifying 文件的 git add 步骤,使它们准备好提交。

最后,你运行git commit。这打包了所有文件的索引副本,以制作新的快照。它添加了正确的元数据。它进行了提交,获得了 Git 提交 E 的哈希 ID。 (这也将提交 E 放入 Git 的 all-the-commits-and-other-objects 数据库。)最后,它将 E 的哈希 ID 写入 name somebranch,现在你有:

...--F--E   <-- somebranch (HEAD)

与当前提交和 Git 的索引再次匹配。如果您 git add-ed all 您更新的文件、提交、索引和您的工作树都匹配。如果你只 git add-ed selected 文件,你仍然有一些与提交不匹配的工作树文件,你可以 git add 它们并制作另一个提交。

你现在在哪里

与此同时,我们现在处于这种状态:

          B___
         /    \
...--F--E      A   <-- somebranch (HEAD)
         \    /
          D--C

提交 B 在某种意义上是 不好的 。你不想提交 B。它会保留很长一段时间——从你制作它起至少 30 天——即使在我们设置好之后你不能 see commit B , 但没关系,Git 会 最终 在它闲置太久未使用时清除它。

这意味着提交 A 也很糟糕,因为提交 A 永久 link 回到提交 B。 (A link 也回到 C,但是 C 没问题。)任何现有提交的任何部分都不能更改,因此放弃 B ,我们也不得不放弃A

所以:让我们使用 git reset 移动 somebranch,以便 somebranch 找到提交 C。我们可以在这里使用三个重置选项中的任何一个,但其中一个选项使事情变得简单:

  • 如果我们使用git reset --soft索引保持不变。 Git 的索引当前与合并提交 A 中的快照匹配。 这是您说要保留的快照。

  • 如果我们使用--mixed--hard,Git将清空其索引并从提交C填充它。这并不可怕——我们想要的文件仍在提交中 A——但它显然没有那么有用。

所以让我们运行 git reset --soft <em>hash-of-C[=512=。或者,因为 current 提交是提交 A,我们可以使用 HEAD^2。如果我们查看 the gitrevisions documentation,我们会发现 HEAD^2 表示 当前提交的第二个父级 。那将是提交 C请注意,我们需要立即提交 A 才能在 Git 的索引中包含正确的内容,所以如果我们 not on commit A 此时,我们最好先检查一下。

最终结果是这样的:

          B___
         /    \
...--F--E      A
         \    /
          D--C   <-- somebranch (HEAD)

一旦我们有了这个,我们就可以 运行 git commit。 Git 将使用 Git 索引中的任何内容——感谢 --soft 和我们之前在 A 的位置,这是来自提交 [=37= 的文件集]——进行新的提交。我们将调用新提交 B';让我们把它画进去:

          B___
         /    \
...--F--E      A
         \    /
          D--C--B'  <-- somebranch (HEAD)

无法看到提交 A。没有 name (b运行ch name) 可以找到它。我们可以 运行 git log 并给它 A 的原始哈希 ID,然后 会找到提交 A,但我们可以否则就会看到它。所以让我们更新我们的绘图,就好像没有提交 A。由于 A 是找到 B 的唯一方法,因此我们也将 B 排除在外:

...--F--E--D--C--B'  <-- somebranch (HEAD)

所以我们最后的命令序列是:

git checkout somebranch                # if necessary
git log --decorate --oneline --graph   # make sure everything is as expected


git reset --soft HEAD^2
git commit

关于 HEAD^2 的注释:当心 DOS/Windows CLI 吃掉 ^ 个字符。您可能必须使用 HEAD^^2、引号或其他东西来保护 ^.

最后一次优化

当您 运行 git commit 时,Git 将需要一条日志消息。如果现有提交 B 中的日志消息是好的并且你想要 re-use 它,你可以告诉 Git 这样做。 git commit 命令有一个 -c-C 选项。 运行:

git commit -C <hash-of-B>

将从提交 B 中获取提交消息并使用它。您不会被扔进编辑器来提出提交消息。

如果 B 中的提交消息可以 改进 ,您可能 想要 被扔到您的编辑器中。为此,请添加 --edit,或将大写 -C 更改为小写 -c:

git commit --edit -C <hash-of-B>

或:

git commit -c <hash-of-B>

请注意,在 git reset 之后,很难找到 B 的哈希值,因此您可能需要保存它。 Git 的 reflogs 有一个技巧来获取它,不过:somebranch@{1} 是重置前 somebranch 的旧值,因此:

git commit -c somebranch@{1}~1

会起作用。不过,我通常发现使用 git log 然后用鼠标剪切和粘贴原始哈希 ID 比输入复杂的 <em>name</em>@ 更容易{<em>number</em>}~<em>number</em>^<em>number</em>个表达式.