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

Git: How to squash all commits between two commits into a single commit

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

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

http://imgur.com/a/I9feO

底部的方式是我从 master 分支出来的地方。自从我开始这个分支以来,Master 发生了一些变化,但是变化是不相交的,所以合并应该是小菜一碟。我通常的工作流程是 rebase 到 master,然后压缩 wip 提交。

我尝试执行一个简单的

git rebase -i master

并且我编辑了对 squash 的提交。

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

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

我相信这些冲突是由您可以看到菱形图案的链条部分引起的。 (例如,在重新设计的分类器之间...和 ​​Merge branch 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 处于这样的历史链的中间,我怎么能这样做呢?我想维护它之后的所有节点的历史记录。

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

#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

这是可以做到的,但是使用通常的机制从一点点痛苦到很多痛苦不等。

根本问题是,无论何时您想要更改内容,都必须复制 提交到新的(略有不同的)提交。原因是 no commit can ever change.1 原因是提交的 hash ID is 真正意义上的提交: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——那么我们必须 复制第一个提交 after 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 <hash-of-B> 可能就足够了。您可以将提交 XYpick 中除第一个以外的所有内容更改为 squash,这实际上可能是很多提交,一切都会顺利进行你完成了:Git 删除 XY' 完全支持单个合并的 XY' 提交,它与你的合并提交 A 具有相同的树。结果是:

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

如果我们调用 XY' A',然后通过忘记其原始哈希 ID 删除所有刻度线,我们将得到您想要的结果。


使用git replace

如果合并很困难,那么您想要的是保留合并中的 ,同时删除所有 XY提交。这里git replace is the (or a) right solution。 Git 的替换有些复杂,但您可以指示 Git 进行新提交 A' 即 "like A but has B as its single parent hash 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'Acopygit checkout <hash of A> 使 Git 查看 A' 并检查相同的内容树;并且 git log 在查看 A' 而不是 A.

时显示相同的日志消息

请注意,此时 AA' 都存在于存储库中。 它们是并排的,可以说是与Git 只是向您显示 A' 而不是 A 除非您使用特殊的 --no-replace-objects 标志。一旦 Git 向您显示(并使用)A' 而不是 A,它会跟随 link 从 A'B,向右跳过在整个 XY.

永久更换,完全去除 XY

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

当 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-branch 会将 E 复制到 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 为保存原始 E 而制作的 refs/heads/branch 的备份副本。当我们这样做时,名字就不会碍事了,我们可以重新绘制我们的图表:

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

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

过滤器分支的机制

要告诉 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。两者意思相同(详见 the gitrevisions documentation)。所以:

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

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

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


使用 cherry-pick

作为 git replace 的替代方法,您还可以使用 git cherry-pick 来复制提交。有关详细信息,请参阅 。这与以前的想法基本相同,但使用 "copy commits" 工具而不是 "rebase to copy commits and then hide the originals away" 工具。它有一个棘手的步骤,使用 git reset --soft 设置索引以匹配提交 A 以进行提交 A'.