【问题标题】:Git remove merge commit from history, but retain the commits with which it has been connectedGit 从历史记录中删除合并提交,但保留已连接的提交
【发布时间】:2021-06-12 04:16:04
【问题描述】:

我在历史记录中的最后 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

【问题讨论】:

  • 查看C。樱桃采摘B。根据需要移动分支指针。
  • @pkamb 嗨,我在 1 分钟前编辑了帖子,并添加了我说的事实,即我在最后一次合并提交中也对文件进行了修改在删除 A 之前转移到 B 提交。在这种情况下要采取什么步骤?
  • 你提到你的提交被推送了,相关的问题是,有没有其他人已经在使用它们了?这是导致问题的部分。你从来没有提到为什么你需要改变提交?
  • 就我个人而言,我只是在挑选樱桃后手动重新进行更改,而不是尝试以某种方式从合并提交中提取它。然后你可以将它和B 一起压缩。或者使用混合重置。
  • @ian 实际上只是我使用分支来添加新文件或修改现有文件。我需要更改提交,因为我只想拥有一个线性历史,正如我在帖子中所说,我首先尝试删除最后一次合并提交,但是通过此重置 --soft 更改 C 和 D 也被删除,之后唯一的我可以做的是添加新的提交,其中包含来自 A、C、D 的所有更改的文件,但我希望像过去一样提交 C 和 D,并使用来自 A 的更改更新 B。

标签: git github bitbucket rebase git-reset


【解决方案1】:

TL;DR

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

(同样请注意,您需要 git push --forceVonC's answer 一样。我怀疑他在您提到您在提交 A 中也有修复之前写了那个答案。)

让我们更正一些事实陈述......好吧,它们在微妙方面是错误的。就您看到的情况而言,它们是正确的。

我尝试reset --soft HEAD~1 删除 A 合并提交,但使用该命令其他提交、C 和 D 也已被删除。

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

让我水平地重新绘制你的提交序列,这是我更喜欢 StackOverflow 发布的方式,左边是较旧的提交,右边是较新的提交。这给了我这张图:

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

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

  • 显示提交A;那么
  • 显示提交B 因为A 链接回B;那么
  • 显示提交C 因为A 链接回C;那么
  • 显示提交D 因为C 链接回D;那么
  • 显示提交E 因为BD 都链接回E

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

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

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

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

由于 Git 向后工作,我们不再看到提交 ACDgit log 操作以提交B 开头,并显示它; B 然后链接回E,所以git log 移动到E 并显示它;和E 链接回F,所以我们看到F,等等。我们永远没有机会向前移动到DCA:这简直是不可能的,因为Git 向后工作

我想拥有的最终历史:

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

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

此外,我在要删除的最后一个合并提交中有一个新更改,我想在 B 提交中传输该修改...

当我们做出新的B' 提交时,就像-B-但不同,我们也可以这样做。

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

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

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

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

新提交中的元数据来自您的 user.nameuser.email 设置——因为这是你的姓名和电子邮件地址所在的位置——以及 Git 现在可以找到的信息,例如存储在你电脑的时钟。 (如果时钟错误,提交上的日期和时间戳也会出错。没什么大不了的,它们只是用来混淆人类。?)新的 commit 是 ... 当前提交,正如当前分支名称所指向的那样。

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

有很多方法可以在 Git 中移动分支名称,但我们将在这里使用的一种是 git resetgit reset 命令又大又复杂,其中一个复杂之处是它可以重置 Git 的 index。所以让我们提一下索引。

索引——Git 也称它为 staging area,指的是你如何使用它,有时也称为 cache,尽管这些天主要是在像这样的标志中--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 的索引/暂存区域中的 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 --stagedMakefileHEAD 副本复制到索引/暂存区域中。所以现在这两个相同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 这种特殊的种类最多可以做三件事:

  1. 移动我们的HEAD。使用我们给出的论点,以及来自git rev-parse / gitrevisions 的规则,找到一些提交。无论我们使用什么分支名称——如果 git status 表示 on branch somebranch,那就是 somebranch——让该名称指向该提交的哈希 ID。

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

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

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

  3. 以与第 2 步中替换索引文件相同的方式替换工作树文件。

如果你已经完成了所有这些,你可以看到 git reset --mixedgit reset --hard 可以,如果我们选择 current commit 作为 new commit ,只是重置索引,或重置索引替换工作树文件。如果我们不给 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;和
  • 哈希ID写入分支名称

最后一步是我们如何得到的:

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

到:

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

例如,回溯到什么时候。你做了一个git checkout somebranchgit 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 --softindex 保持不变。 Git 的索引当前与合并提交A 中的快照匹配。 这是您说要保留的快照。

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

让我们运行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> 表达式更容易。

【讨论】:

  • 非常感谢!这个非常好的教程可以帮助我解决我的问题。
【解决方案2】:

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

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

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

【讨论】:

    猜你喜欢
    • 2015-04-17
    • 2015-10-19
    • 2021-12-26
    • 2014-11-20
    • 2013-09-20
    • 1970-01-01
    • 2015-09-02
    • 2016-03-13
    • 1970-01-01
    相关资源
    最近更新 更多