这是可以做到的,但通常机制下这是从有点繁琐到非常繁琐的过程。
根本问题在于,每当你想要改变东西时,你必须将提交复制到新的(稍微不同的)提交。原因是没有提交可以改变。原因在于提交的哈希 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>
可能就足够了。你可以将
X
和
Y
(实际上可能是多个提交)中除第一个
pick
以外的所有内容更改为
squash
,一切都顺利进行,你就完成了:Git 将完全放弃
X
和
Y'
,取而代之的是具有与合并提交
A
相同树的单个组合
XY'
提交。结果是:
...--B--XY'-C'-D'-E' <-- branch
如果我们把XY'
叫做A'
,并且忘记它们的原始哈希ID,删除所有刻度标记,就可以得到你想要的东西。
使用git replace
如果合并很困难,但是您想保留合并的树,同时删除所有X
和Y
提交,则需要使用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
时显示相同的日志消息。
请注意,此时存储库中同时存在A
和A'
。它们并排存在,Git只会向您显示
A'
而不是
A
,除非您使用特殊的
--no-replace-objects
标志。一旦Git向您展示(并使用)
A'
而不是
A
,它就会跟随从
A'
到
B
的反向链接,直接跳过所有的
X
和
Y
。
使替换永久化,完全删除X
和Y
一旦您对替换感到满意,您可能希望使其永久化。您可以使用
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-branch
将
C
复制到一个新的提交中。这个新的提交将以
A'
作为其父提交,具有与
C
相同的日志信息、树等等。这与原始的
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创建的
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'。