TL;DR
使用git reset --soft(正如你正在做的,但使用不同的目标,HEAD^2 或提交的原始哈希 ID C),然后使用git commit。您可能需要git commit 提供一两个额外选项。有关更多信息,请参阅长答案。
(同样请注意,您需要 git push --force 和 VonC's answer 一样。我怀疑他在您提到您在提交 A 中也有修复之前写了那个答案。)
长
让我们更正一些事实陈述......好吧,它们在微妙方面是错误的。就您看到的情况而言,它们是正确的。
我尝试reset --soft HEAD~1 删除 A 合并提交,但使用该命令其他提交、C 和 D 也已被删除。
实际情况并非如此。提交尚未删除。他们只是变得很难找到。原因很简单:Git 实际上向后工作。
让我水平地重新绘制你的提交序列,这是我更喜欢 StackOverflow 发布的方式,左边是较旧的提交,右边是较新的提交。这给了我这张图:
...--F--E---B--A <-- somebranch (HEAD)
\ /
D--C
其中,根据重置的结果,我们看到B 是A 的第一个父级。此时运行git log 将:
- 显示提交
A;那么
- 显示提交
B 因为A 链接回B;那么
- 显示提交
C 因为A 链接回C;那么
- 显示提交
D 因为C 链接回D;那么
- 显示提交
E 因为B 和D 都链接回E
等等。显示 B、C 和 D 的精确顺序取决于您为 git log 提供的任何提交排序选项:例如,--topo-order 强制使用合理的图形顺序,而 --author-date 顺序使用作者日期和时间戳。默认是使用提交者的日期和时间戳,最近的提交在最近的提交之前被看到。
当我们进行重置时,我们得到了这个。由于我绘制图表的方式,我需要将B 向上移动一行,但A 仍然链接回B 和C 两者:
B___ <-- somebranch (HEAD)
/ \
...--F--E A
\ /
D--C
也就是说,在git reset --soft HEAD~1 之后,name somebranch 现在选择提交B 而不是提交A。
由于 Git 向后工作,我们不再看到提交 A、C 和 D。 git log 操作以提交B 开头,并显示它; B 然后链接回E,所以git log 移动到E 并显示它;和E 链接回F,所以我们看到F,等等。我们永远没有机会向前移动到D、C 或A:这简直是不可能的,因为Git 向后工作。
我想拥有的最终历史:
E--D--C--B <-- somebranch (HEAD)
现在,事实上,commit B—B 代表一些丑陋的大哈希 ID — 连接回 commit E。情况总是如此:根本无法更改现有的提交。所以这个历史是不可能的。然而,我们可以new 提交B',这很像B,但又不同。
此外,我在要删除的最后一个合并提交中有一个新更改,我想在 B 提交中传输该修改...
当我们做出新的B' 提交时,就像-B-但不同,我们也可以这样做。
侧边栏:更多关于提交以及 Git 如何进行提交
Git 中的每个提交都包含两个部分:
-
每个提交都有一个 Git 知道的每个文件的完整快照,您(或任何人)在进行提交时。这些快照存储文件,但与您的计算机存储它们的方式不同。相反,它们的名称和内容存储为内部 Git 对象,并且这些对象被压缩和重复数据删除(并且一直冻结)。重复数据删除意味着如果您有一系列提交C1、C2、C3,每个都有数千个文件,但实际上只有一个文件更改在这些提交中,数以千计的文件全部共享。新的提交每个只有一个 new 文件。即便如此,新数据也会以各种方式被压缩和 Git 化,这可能会将一个大文件变成一个很小的增量(最终——这发生在游戏后期,在 Git 中,因为这样可以获得更好的增量)。
-
每个提交还存储一些元数据,或有关提交本身的信息。这包括作者和提交者信息:提交者和提交时间。它包含一条日志消息:如果您正在提交,您可以自己编写。而且——对于 Git 自己的目的来说这一切都很重要——提交包含原始哈希 ID,那些大而丑陋的字符串,如 225365fb5195e804274ab569ac3cc4919451dc7f,用于每个提交的父母。对于大多数提交,这只是较早的一次提交;对于像您的提交 A 这样的合并提交,这是两个提交哈希 ID 的列表(对于 B 和 C,按此顺序)。
新提交中的元数据来自您的 user.name 和 user.email 设置——因为这是你的姓名和电子邮件地址所在的位置——以及 Git 现在可以找到的信息,例如存储在你电脑的时钟。 (如果时钟错误,提交上的日期和时间戳也会出错。没什么大不了的,它们只是用来混淆人类。?)新的 父 commit 是 ... 当前提交,正如当前分支名称所指向的那样。
所以,如果我们希望新的提交 B' 指向现有的提交 C,我们需要提交 C——而不是提交 B,而不是提交 E——成为当前的提交。为了实现这一点,我们需要将 name somebranch 指向提交 C。
有很多方法可以在 Git 中移动分支名称,但我们将在这里使用的一种是 git reset。 git reset 命令又大又复杂,其中一个复杂之处是它可以重置 Git 的 index。所以让我们提一下索引。
索引——Git 也称它为 staging area,指的是你如何使用它,有时也称为 cache,尽管这些天主要是在像这样的标志中--cached,如git rm --cached 或git 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 的索引/暂存区域中的 Makefile现在 不同于HEAD 提交中的 Makefile现在 .
如果我现在运行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-commit 副本Makefile 踢出索引,并且而是放入Makefile 的现有工作树副本。这就是foo 的来源。
如果我使用git restore --staged Makefile,正如 Git 在这里建议的那样,将HEAD:Makefile 复制到:Makefile。此处的冒号前缀语法特定于某些 Git 操作(例如 git show),并允许您读取 Git 中的文件副本。 Makefile 在我的工作树 不在 中的副本在 Git 中,因此没有特殊的语法:它只是一个普通的普通文件。但是对于一些 Git 命令,有一个特殊的语法,带有这个冒号。例如,使用git show HEAD:Makefile 查看提交的 副本,使用git show :Makefile 查看索引 副本。
无论如何,我现在听从 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")
我运行的git restore --staged 将Makefile 的HEAD 副本复制到索引/暂存区域中。所以现在这两个相同,git status 没有说他们是staged for commit。但是现在索引Makefile 和我的工作树Makefile 不同,所以现在git status 表示这两个 不同。
关于git reset
我在这里使用的git restore 命令是新的,已在 Git 2.23 中引入。 git reset 命令要旧得多。这是一个大而复杂的命令,所以我们只看一下我们可以使用它的方法的一个子集。
当用作:
git reset --soft HEAD~1
例如,这种git reset 移动当前分支名称。也就是我们这样画图:
B___
/ \
...--F--E A <-- somebranch (HEAD)
\ /
D--C
并移动somebranch,使其指向B,如下所示:
B___ <-- somebranch (HEAD)
/ \
...--F--E A
\ /
D--C
没有提交更改。没有提交可以改变。
如果我们使用git reset --mixed,我们会让Git 移动分支名称并更改Git 索引中的所有文件副本。如果我们使用git reset --hard,我们会让 Git 移动分支名称,更改 Git 索引中的文件副本,并替换我们工作树中的文件副本。所以git reset 这种特殊的种类最多可以做三件事:
-
移动我们的HEAD。使用我们给出的论点,以及来自git rev-parse / gitrevisions 的规则,找到一些提交。无论我们使用什么分支名称——如果 git status 表示 on branch somebranch,那就是 somebranch——让该名称指向该提交的哈希 ID。
如果--soft,停止!否则,继续...
-
替换 Git 索引中的所有文件。替换文件来自我们在步骤 1 中选择的提交。
如果--mixed 或没有选项,请停止!否则 (--hard),继续...
-
以与第 2 步中替换索引文件相同的方式替换工作树文件。
如果你已经完成了所有这些,你可以看到 git reset --mixed 和 git reset --hard 可以,如果我们选择 current commit 作为 new commit ,只是重置索引,或重置索引并替换工作树文件。如果我们不给 git reset 一个特定的提交哈希 ID 或名称或相关指令,如 HEAD~1 或 HEAD^2,git reset 使用 HEAD。所以git reset --soft HEAD 或git reset --soft 只是一种什么都不做的方法,但git reset HEAD 或git reset 是一种清除Git 索引的方法,使其再次匹配HEAD。 (你不想这样做——我只是在这里记下它,这样你就可以对 git reset 所做的事情有一个正确的心理模型。)
关于git commit
当你运行git commit,Git:
- 收集任何必要的元数据,包括日志消息;
- 添加适当的父提交哈希 ID:通常仅用于
HEAD,但如果您要提交合并,HEAD 以及更多;
- 将 Git 索引中的任何内容打包为新快照;
- 将所有这些作为一个新的提交写出来,它会获得一个新的、唯一的哈希 ID;和
- 将新哈希ID写入分支名称。
最后一步是我们如何得到的:
...--F <-- somebranch (HEAD)
到:
...--F--E <-- somebranch (HEAD)
例如,回溯到什么时候。你做了一个git checkout somebranch 或git switch somebranch。那:
- 选择提交
F,因为somebranch指向提交F;
- 填写 Git 的索引 from 提交;
- 从提交中填写你的工作树(现在在 Git 的索引中表示);和
- 将名称
HEAD 附加到名称 somebranch,以便 Git 知道未来的提交应该写入 somebranch。
然后你修改了一些文件并运行了git add。这会将所有更新的文件复制到 Git 的索引中,准备好提交。索引继续保持提议的下一次提交(或快照部分),git add更改提议的快照,通过弹出一些 当前 索引文件并将新的 (更新)文件,而不是。实际上是 git add 步骤完成了所有文件的 Git 化,使它们准备好提交。
最后,你跑了git commit。这打包了所有文件的索引副本,以制作新的快照。它添加了正确的元数据。它进行了提交,这使 Git 获得了提交 E 的哈希 ID。 (这也将提交 E 放入 Git 的所有提交和其他对象的数据库中。)最后,它将 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
Commit B 在某种意义上是不好的。你不想提交B。它会持续很长一段时间——从你制作它起至少 30 天——即使我们设置了一些东西让你无法看到提交 B,但没关系,当它被闲置太久未使用时,Git最终会清除它。
这意味着提交A 也很糟糕,因为提交A 永久链接回提交B。 (A 也链接回C,但C 可以。)任何现有提交的任何部分都不能更改,所以要放弃B,我们也必须放弃A。
所以:让我们使用git reset 移动somebranch,以便somebranch 定位提交C。我们可以在这里使用三个重置选项中的任何一个,但是其中一个选项可以让事情变得简单:
让我们运行git reset --soft <em>hash-of-C</em>。或者,因为当前提交是提交A,我们可以使用HEAD^2。如果我们查看the gitrevisions documentation,我们会发现HEAD^2 表示当前提交的第二个父级。那将是提交C。 请注意,我们需要立即提交 A 以在 Git 的索引中包含正确的内容,所以如果我们不此时提交 A ,我们最好先检查一下。
最终结果是这样的:
B___
/ \
...--F--E A
\ /
D--C <-- somebranch (HEAD)
一旦我们有了这个,我们就可以运行git commit了。 Git 将使用 Git 索引中的任何内容——感谢--soft 和我们之前在A 的位置,是来自提交A 的文件集——来进行新的提交。我们将调用新的提交B';让我们把它画进去:
B___
/ \
...--F--E A
\ /
D--C--B' <-- somebranch (HEAD)
无法看到提交A。没有 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 中的日志消息很好并且您想重新使用它,您可以告诉 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> 表达式更容易。