【问题标题】:Git: How to squash all commits between two commits into a single commitGit:如何将两个提交之间的所有提交压缩为一个提交
【发布时间】:2025-12-07 14:20:03
【问题描述】:

在过去的几个月里,我有一个分支,我一直在多台计算机上亲自工作。结果是一个很长的历史链,我想在将它合并到主分支之前清理它。最终目标是摆脱我在处理服务器代码时经常进行的所有那些 wip 提交。

这是gitk历史可视化的截图:

http://imgur.com/a/I9feO

这是我从 master 分支出来的地方。自从我开始这个分支以来,Master 发生了一些变化,但是这些变化是脱节的,所以合并应该是小菜一碟。我通常的工作流程是重新定位到 master 上,然后压缩 wip 提交。

我尝试执行一个简单的

git rebase -i master

然后我将提交编辑为 sqush。

一开始似乎很好,但后来失败了,要我解决冲突。但是,似乎没有通过查看差异来解决它的好方法。每个部分都使用范围内未定义的变量,所以我不知道如何解决它们。

我也尝试使用git rebase -i -s recursive -X theirs master,这并没有导致冲突,但它改变了修改后分支的 HEAD 状态(我想以这样的方式编辑历史记录,使 HEAD 中的最终结果不会改变)。

我相信这些冲突是由链条中可以看到菱形图案的部分引起的。 (例如,在重新设计的分类器之间......和合并分支 iccv)。


为了更好地表达我的问题,让A="Merge branch iccv" 和B="reworked classifiers" 参考图片中的示例。两者之间的提交将是XY

      ...
       |
       |
       A 
     /  \
    |   X
    Y   |
     \ /
      B
      |
      |
     ...

我想重写历史,所以A 的状态完全一样,并有效地破坏中间表示XY,所以生成的历史看起来像这样

      ...
       |
       |
       A 
       |
       |
       B
       |
       | 
      ...

有没有办法将AXY 的已解决状态压缩到这样的历史链中间的单个提交中?

如果AB 是提交的SHAID,我可以运行一个简单的命令(或者可能是一个脚本)来实现我想要的结果吗?

如果A 是 HEAD,我相信我可以做到

git reset B
git commit -am "recreating the A state"

创建一个新的头部,但是如果A 位于这样的历史链的中间,我该怎么做。我想维护它之后的所有节点的历史。

【问题讨论】:

  • 我认为你需要从一个分支的基础开始。例如,X-A-HEAD 到 B-Y。然后你就可以压扁了。

标签: git rebase


【解决方案1】:

首先清理当前工作树,然后运行这些命令:

#initial state

git branch backup thesis4
git checkout -b tmp thesis4

git reset A --hard

git reset B --soft

git commit

git cherry-pick A..thesis4

git checkout thesis4

git reset tmp --hard
git branch -D tmp

SX,Y,A 的壁球。 M' 相当于 MN' 相当于 N。如果要恢复初始状态,请运行

git checkout thesis4
git reset backup --hard

【讨论】:

  • 这就像一个魅力,增加了我对我可以用 git 做什么的理解。我能够线性化所有的菱形,然后正常的 rebase 工作。
【解决方案2】:

这是可以做到的,但使用通常的机制,可能会有点痛苦,也可能会很痛苦。

根本的问题是你必须复制 提交到新的(略有不同的)提交,无论何时你想改变事物。原因是没有提交可以改变1原因是提交的哈希ID提交,在一个非常真实的意义:Git 的哈希 ID 是 Git 查找底层对象的方式。更改对象中的任何位,它都会获得一个新的、不同的哈希 ID。2 因此,当您想要从:

       X
      / \
...--B   A--C--D--E   <-- branch
      \ /
       Y

看起来像:

...--B--A--C--D--E   <-- branch

B 之后的东西 不能A,它必须是一个不同的提交,只是闻起来像 A。我们可以将此提交称为A' 来区分它们:

...--B--A'-...

但是,如果我们将A 复制到一个新的、气味更新鲜(但相同的树)A',它的历史中不再有中间的东西——也就是说,A' 直接连接到B——那么我们必须复制第一个提交之后 A'。一旦我们这样做了,我们必须在那个之后复制提交,依此类推。结果是:

...--B--A'-C'-D'-E'  <-- branch

1心理学家喜欢说change is hard,但对于 Git,这简直是不可能的! :-)

2Hash collisions are technically possible,但如果它们发生,则意味着您的存储库停止添加新内容。也就是说,如果你设法提出了一个与旧提交类似的新提交,但有你想要的更改,具有相同的哈希 ID,Git 将禁止你添加它!


使用git rebase -i

注意:尽可能使用此方法;它更容易理解和正确。

像这样复制提交的标准命令是git rebase。但是,rebase 对合并提交(如A)的处理效果很差。事实上,它通常会完全抛弃它们,而是倾向于线性化所有内容:

...--B--X--Y'-C'-D'-E'   <-- branch

例如。

现在,如果合并提交 A 顺利进行,即 X 中的任何内容都不依赖于 Y,反之亦然,一个简单的 git rebase -i &lt;hash-of-B&gt; 可能就足够了。您可以将除第一个 picks 之外的所有提交 XY(实际上可能是许多提交)更改为 squash,一切顺利,您完成了:Git 删除 @987654352 @ 和 Y' 完全赞成单个组合的 XY' 提交,它具有与合并提交 A 相同的树。结果是:

...--B--XY'-C'-D'-E'   <-- branch

如果我们调用XY'A',然后通过忘记它们的原始哈希 ID 来删除所有刻度线,我们就会得到你想要的。


使用git replace

但是,如果合并很困难,您想要的是保留合并中的 tree,同时删除所有 XY 提交。这里git replace is the (or a) right solution。 Git 的替换有些复杂,但您可以指示 Git 进行新的提交 A',即“类似于 A,但将 B 作为其单一父哈希 ID”。 Git 现在将具有此提交图结构:

       X
      / \
...--B   A--C--D--E   <-- branch
     |\ /
     | Y
     \
      A'  <-- refs/replace/<complicated-thing>

这个特殊的refs/replace 名称告诉Git,当它执行git log 和其他使用提交ID 的命令时,Git 应该将其隐喻的目光从提交A 转移到提交A' .因为A'A副本,所以git checkout &lt;hash of A&gt; 让Git 查看A' 并检查同一棵树;而git log 在查看A' 而不是A 时会显示相同的日志消息。

请注意,此时 AA' 都存在于存储库中。 它们是并排的,Git 只是向您显示 A' 而不是A 除非您使用特殊的 --no-replace-objects 标志。一旦 Git 向您展示(并使用)A' 而不是 A,它会沿着从 A'B 的反向链接,直接跳过所有 XY

使替换永久化,完全摆脱 XY

一旦您对替换感到满意,您可能希望将其永久化。您可以使用git filter-branch 来执行此操作,它只是复制提交。它从某个起点开始复制并在历史中向前移动,这与 Git 的正常向后“从今天开始并在历史中向后工作”的方式相反。

当 filter-branch 制作它的副本时——以及它的复制内容列表——它通常会做与 Git 的其余部分相同的让人眼花缭乱的事情。因此,如果我们有上面显示的历史记录,并且我们告诉 filter-branch 以 branch 结束并在提交 B 之后开始,它将收集现有的提交列表:

E, D, C, A'

然后颠倒顺序。 (事实上​​,如果我们愿意,我们可以在A' 停下来,我们会看到。)

接下来,filter-branch 会将A' 复制到新的提交中。这个新的提交将有 B 作为它的父级,与 A' 相同的日志消息,相同的树,相同的作者和日期戳等等 - 简而言之,它将真正成为A' 相同。所以它将获得与A' 相同的哈希ID,实际上是提交A'

接下来,filter-branch 会将C 复制到新的提交中。这个新的提交将有A' 作为它的父级,与C 相同的日志消息,以及相同的树等等。这与原始的C 略有不同,其父级是A,而不是A'。所以这个新的提交获得了一个不同的哈希ID:它变成了提交C'

接下来,filter-branch 将复制 D。这将变为D',就像C 的副本是C'

最后,filter-branchE 复制到E' 并让branch 指向E',给我们这个:

       X
      / \
...--B   A--C--D--E   <-- refs/original/refs/heads/branch
     |\ /
     | Y
     \
      A'  <-- refs/replace/<complicated-thing>
       \
        C'-D'-E'  <-- branch

我们现在可以删除 refs/replace/ 名称和过滤器分支为保存原始 E 而创建的 refs/heads/branch 的备份副本。当我们这样做时,名称就会消失,我们可以重新绘制我们的图表:

...--B--A'-C'-D'-E'  <-- branch

这正是我们想要(并得到)使用 git rebase -i 的结果,但无需重新进行合并。

过滤器分支的机制

要告诉git filter-branch 在哪里停止,请使用^&lt;hash-id&gt;^&lt;name&gt;。否则git filter-branch 不会停止列出要复制的提交,直到它用完提交:它将跟随提交B 到其父级,以及该父级的父级,依此类推,一直追溯到历史。这些提交的副本将与原件逐位相同,这意味着它们实际上将成为原件、相同的哈希 ID 等等;但它们需要很长时间才能完成。

由于我们可以停在&lt;hash-id-of-B&gt; 甚至&lt;hash-id-of-A'&gt;,我们可以使用^refs/replace/&lt;hash&gt; 来识别提交A。或者我们可以直接使用^&lt;hash-id&gt;,这实际上可能更简单。

此外,我们可以写^&lt;hash&gt; branch&lt;hash&gt;..branch。两者的含义相同(有关详细信息,请参阅the gitrevisions documentation)。所以:

git filter-branch -- <hash>..branchname

足以进行过滤以将替换物固定到位。

如果一切顺利,请删除 refs/original/ 引用,如 the git filter-branch documentation 末尾附近所示,并删除替换引用,然后您就完成了。


使用樱桃挑选

作为git replace 的替代方案,您还可以使用git cherry-pick 复制提交。有关详细信息,请参阅ElpieKay's answer。这与以前的想法基本相同,但使用“复制提交”工具而不是“rebase 复制提交然后隐藏原件”工具。它有一个棘手的步骤,使用git reset --soft 设置索引以匹配提交A 以进行提交A'

【讨论】: