Git:如何将两个提交之间的所有提交压缩为一个提交?

13
我有一个分支,在过去几个月里,我一直在多台电脑上进行个人开发。结果是一个很长的历史记录链,在将其合并到主分支之前,我想清理它。最终的目标是消除我在编写服务器代码时经常制作的所有wip提交。
以下是gitk历史可视化的截图:

enter image description herehttp://imgur.com/a/I9feO

在这里的最底部是我从主分支分离的点。自从我开始这个分支以来,主分支已经发生了一些变化,但这些变化是不相关的,所以合并应该很容易。我通常的工作流程是rebase到主分支,然后压缩wip提交。
我试图执行一个简单的
git rebase -i master

我编辑了提交记录并进行了压缩。
起初看起来进展顺利,但后来失败了,要求我解决冲突。然而,通过查看差异,似乎没有好的方法来解决它。每个部分都使用了作用域中未定义的变量,所以我不确定该如何解决这些问题。
我还尝试使用git rebase -i -s recursive -X theirs master,虽然没有导致冲突,但它改变了HEAD的状态(我想以这样一种方式编辑历史记录,以使HEAD的最终结果不会改变)。
我认为这些冲突是在链的某些部分引起的,这些部分可以看到菱形图案(例如,在重新定义分类器和合并iccv分支之间)。
为了更好地表达我的问题,让A="合并分支 iccv",B="重新设计分类器",参考图像中的示例。在两者之间的提交将是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上。然后你就可以进行压缩操作了。 - njzk2
作为最新的更新,我已经制作了一个工具来帮助压缩线性提交链:https://github.com/Erotemic/git_well/blob/main/git_well/git_squash_streaks.py - Erotemic
2个回答

17

首先要使当前工作目录干净,然后运行以下命令:

#initial state

enter image description here

git branch backup thesis4
git checkout -b tmp thesis4

enter image description here

git reset A --hard

enter image description here

git reset B --soft

enter image description here

git commit

enter image description here

git cherry-pick A..thesis4

enter image description here

git checkout thesis4

enter image description here

git reset tmp --hard
git branch -D tmp

enter image description here

SX,Y,A的压缩。 M'等价于MN'等价于N。如果您想恢复初始状态,请运行

git checkout thesis4
git reset backup --hard

2
这个方法非常有效,增加了我对Git的理解。我成功地将所有钻石线性化,然后正常的变基操作也能够顺利进行。 - Erotemic

6
这是可以做到的,但通常机制下这是从有点繁琐到非常繁琐的过程。
根本问题在于,每当你想要改变东西时,你必须将提交复制到新的(稍微不同的)提交。原因是没有提交可以改变。原因在于提交的哈希 ID 在很大程度上就是该提交本身:Git 的哈希 ID 是 Git 找到底层对象的方法。改变对象内任意一位二进制位都会导致其获得一个新的、不同的哈希 ID。因此,当你想从以下状态转变时:
       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心理学家常说改变很难,但对于Git来说,这几乎是不可能的!:-)

2哈希碰撞在技术上是可能的,但如果发生了,这意味着您的存储库将停止添加新内容。也就是说,如果您设法创建一个与旧提交类似但具有所需更改和相同哈希ID的新提交,Git将禁止您添加它!


使用git rebase -i

注意:如果可能,请使用此方法;它更容易理解且更容易正确执行。

复制此类提交的标准命令是git rebase。然而,rebase对于像A这样的合并提交处理得非常糟糕。事实上,它通常完全忽略它们,而是优先线性化所有内容:

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

例如。
现在,如果合并提交 A 成功,即 X 不依赖于 Y 或反之,则简单的 git rebase -i <hash-of-B> 可能就足够了。你可以将 XY(实际上可能是多个提交)中除第一个 pick 以外的所有内容更改为 squash,一切都顺利进行,你就完成了:Git 将完全放弃 XY' ,取而代之的是具有与合并提交 A 相同树的单个组合 XY' 提交。结果是:
...--B--XY'-C'-D'-E'   <-- branch

如果我们把XY'叫做A',并且忘记它们的原始哈希ID,删除所有刻度标记,就可以得到你想要的东西。


使用git replace

如果合并很困难,但是您想保留合并的树,同时删除所有XY提交,则需要使用git replace是(或者)正确的解决方案。Git的替换有些复杂,但可以指示Git创建一个新的提交A',它“类似于A,但其单个父哈希ID为B”。现在,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 <hash of A>会让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在B提交之后立即以branch结束,它将收集现有的提交列表:
E, D, C, A'

然后将顺序反转(实际上,我们可以在A'处停止,因为我们会看到这一点)。接下来,filter-branch将A'复制到一个新的提交中。这个新的提交将以B作为其父提交,与A'相同的日志信息、树、作者和时间戳等等,简而言之,它将与A'完全相同。因此,它将获得与A'相同的哈希 ID,并且实际上成为提交A'。接下来,filter-branchC复制到一个新的提交中。这个新的提交将以A'作为其父提交,具有与C相同的日志信息、树等等。这与原始的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/名称以及filter-branch创建的refs/heads/branch备份副本来保存原始的E。这样做后,这些名称就会被移除掉,我们可以重新绘制图形:
...--B--A'-C'-D'-E'  <-- branch

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

filter-branch的机制

为了告诉git filter-branch在哪里停止,请使用^<hash-id>^<name>。否则,git filter-branch将继续列出要复制的提交,直到它用完所有提交为止:它将跟随提交B到其父提交,以及该父提交的父提交等,一直遍历整个历史记录。这些提交的副本将与原始提交的位模式完全相同,这意味着它们实际上将是原始提交,具有相同的哈希ID;但复制它们需要很长时间。

由于我们可以停在<hash-id-of-B>甚至<hash-id-of-A'>处,因此我们可以使用^refs/replace/<hash>来标识提交A。或者,我们可以只使用^<hash-id>,这可能更容易。

此外,我们可以编写^<hash> branch<hash>..branch。两者意思相同(有关详细信息,请参见gitrevisions文档)。所以:

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

只需过滤以将替换内容固定到位即可。
如果一切顺利,请按照 git filter-branch 文档末尾所示的方式删除 refs/original/ 引用,同时删除替换引用,这样就完成了。
使用 cherry-pick 作为 git replace 的替代方法,您也可以使用 git cherry-pick 复制提交。有关详细信息,请参见 ElpieKay 的回答。这基本上与之前的想法相同,但是使用“复制提交”工具而不是“基于rebase复制提交然后隐藏原始提交”的工具。它有一个棘手的步骤,即使用 git reset --soft 来设置索引以使提交 A 匹配以创建提交 A'。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接