Git:如何将两次提交之间的所有提交压缩为一次提交
Git: How to squash all commits between two commits into a single commit
我有一个分支,过去几个月我一直在多台计算机上亲自工作。结果是一个很长的历史链,我想在将它合并到 master 分支之前清理它。最终目标是摆脱我在处理服务器代码时经常进行的所有那些 wip 提交。
这是 gitk 历史可视化的屏幕截图:
底部的方式是我从 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" 参考图中的示例。中间的提交将是 X
和 Y
。
...
|
|
A
/ \
| X
Y |
\ /
B
|
|
...
我想重写历史,使A
的状态完全保持原样,并有效地破坏中间表示X
和Y
,所以生成的历史看起来像这样
...
|
|
A
|
|
B
|
|
...
有没有办法像这样将 A
、X
和 Y
的已解决状态压缩到历史链中间的单个提交中?
如果 A
和 B
是提交的 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
S
是X,Y,A
的南瓜。 M'
等同于 M
,N'
等同于 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>
可能就足够了。您可以将提交 X
和 Y
的 pick
中除第一个以外的所有内容更改为 squash
,这实际上可能是很多提交,一切都会顺利进行你完成了:Git 删除 X
和 Y'
完全支持单个合并的 XY'
提交,它与你的合并提交 A
具有相同的树。结果是:
...--B--XY'-C'-D'-E' <-- branch
如果我们调用 XY'
A'
,然后通过忘记其原始哈希 ID 删除所有刻度线,我们将得到您想要的结果。
使用git replace
如果合并很困难,那么您想要的是保留合并中的 树 ,同时删除所有 X
和 Y
提交。这里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'
是 A
的 copy,git checkout <hash of A>
使 Git 查看 A'
并检查相同的内容树;并且 git log
在查看 A'
而不是 A
.
时显示相同的日志消息
请注意,此时 A
和 A'
都存在于存储库中。 它们是并排的,可以说是与Git 只是向您显示 A'
而不是 A
除非您使用特殊的 --no-replace-objects
标志。一旦 Git 向您显示(并使用)A'
而不是 A
,它会跟随 link 从 A'
到 B
,向右跳过在整个 X
和 Y
.
永久更换,完全去除 X
和 Y
一旦您对替换感到满意,您可能希望将其永久化。您可以使用 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'
.
我有一个分支,过去几个月我一直在多台计算机上亲自工作。结果是一个很长的历史链,我想在将它合并到 master 分支之前清理它。最终目标是摆脱我在处理服务器代码时经常进行的所有那些 wip 提交。
这是 gitk 历史可视化的屏幕截图:
底部的方式是我从 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" 参考图中的示例。中间的提交将是 X
和 Y
。
...
|
|
A
/ \
| X
Y |
\ /
B
|
|
...
我想重写历史,使A
的状态完全保持原样,并有效地破坏中间表示X
和Y
,所以生成的历史看起来像这样
...
|
|
A
|
|
B
|
|
...
有没有办法像这样将 A
、X
和 Y
的已解决状态压缩到历史链中间的单个提交中?
如果 A
和 B
是提交的 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
S
是X,Y,A
的南瓜。 M'
等同于 M
,N'
等同于 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>
可能就足够了。您可以将提交 X
和 Y
的 pick
中除第一个以外的所有内容更改为 squash
,这实际上可能是很多提交,一切都会顺利进行你完成了:Git 删除 X
和 Y'
完全支持单个合并的 XY'
提交,它与你的合并提交 A
具有相同的树。结果是:
...--B--XY'-C'-D'-E' <-- branch
如果我们调用 XY'
A'
,然后通过忘记其原始哈希 ID 删除所有刻度线,我们将得到您想要的结果。
使用git replace
如果合并很困难,那么您想要的是保留合并中的 树 ,同时删除所有 X
和 Y
提交。这里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'
是 A
的 copy,git checkout <hash of A>
使 Git 查看 A'
并检查相同的内容树;并且 git log
在查看 A'
而不是 A
.
请注意,此时 A
和 A'
都存在于存储库中。 它们是并排的,可以说是与Git 只是向您显示 A'
而不是 A
除非您使用特殊的 --no-replace-objects
标志。一旦 Git 向您显示(并使用)A'
而不是 A
,它会跟随 link 从 A'
到 B
,向右跳过在整个 X
和 Y
.
永久更换,完全去除 X
和 Y
一旦您对替换感到满意,您可能希望将其永久化。您可以使用 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
来复制提交。有关详细信息,请参阅 git reset --soft
设置索引以匹配提交 A
以进行提交 A'
.