这是可以做到的,但使用通常的机制,可能会有点痛苦,也可能会很痛苦。
根本的问题是你必须复制 提交到新的(略有不同的)提交,无论何时你想改变事物。原因是没有提交可以改变。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 <hash-of-B> 可能就足够了。您可以将除第一个 picks 之外的所有提交 X 和 Y(实际上可能是许多提交)更改为 squash,一切顺利,您完成了:Git 删除 @987654352 @ 和 Y' 完全赞成单个组合的 XY' 提交,它具有与合并提交 A 相同的树。结果是:
...--B--XY'-C'-D'-E' <-- branch
如果我们调用XY'A',然后通过忘记它们的原始哈希 ID 来删除所有刻度线,我们就会得到你想要的。
使用git replace
但是,如果合并很困难,您想要的是保留合并中的 tree,同时删除所有 X 和 Y 提交。这里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 <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 以 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/ 名称和过滤器分支为保存原始 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
足以进行过滤以将替换物固定到位。
如果一切顺利,请删除 refs/original/ 引用,如 the git filter-branch documentation 末尾附近所示,并删除替换引用,然后您就完成了。
使用樱桃挑选
作为git replace 的替代方案,您还可以使用git cherry-pick 复制提交。有关详细信息,请参阅ElpieKay's answer。这与以前的想法基本相同,但使用“复制提交”工具而不是“rebase 复制提交然后隐藏原件”工具。它有一个棘手的步骤,使用git reset --soft 设置索引以匹配提交A 以进行提交A'。