更新的回答(请参见问题中的更新)
我认为这里发生的事情与选择要复制的提交有关。
让我们注意一下,然后搁置一下这个事实:git rebase
可能会使用 git cherry-pick
或 git format-patch
和 git am
来复制一些提交。在大多数情况下,git cherry-pick
和 git am
应该可以实现相同的结果。(git rebase
文档 特别指出了上游文件重命名作为使用 cherry-pick 方法的问题,而默认的基于 git am
的方法用于非交互式 rebase。另请参见下面原始答案中的各种括号内备注和评论。)
这里需要考虑的主要问题是
要复制哪些提交。在手动方法中,您首先手动将提交
D
和
E
复制到
D'
和
E'
,然后手动将
F
和
G
复制到
F'
和
G'
。这是我们想要的最少工作量,唯一的缺点是我们必须进行所有手动提交标识。
当您使用以下命令:
git checkout <branch> && git rebase <upstream>
您可以让Git自动化查找要复制的提交过程。这很好,当Git做对的时候,但如果Git做错了,就不是那么好了。
那么Git是如何选择这些提交的呢?简单但有些错误的答案在这句话中(来自相同的文档):
“当前分支中提交所做的所有更改但不在中的提交都保存在临时区域中。这是与git log ..HEAD或git log 'fork_point'..HEAD显示的提交集相同的提交集,如果--fork-point已激活(请参见下面关于--fork-point的描述),则为激活状态;或者通过git log HEAD,如果指定了--root选项。”
--fork-point的复杂性是比较新的,自git 2.x以来,但在这种情况下它并没有被“激活”,因为您指定了参数并且没有指定--fork-point。实际上的在两次操作中都是master。
现在,如果您实际运行每个
git log
(使用
--oneline
使其更好看):
git checkout next && git log --oneline master..HEAD
并且:
git checkout other-next && git log --oneline master..HEAD
你会发现第一个列表列出了提交
D
和
E
,很好!但是第二个列表列出了
D
、
E
、
F
和
G
。哦,
D
和
E
出现了两次!
问题是,这种方法有时候有效。嗯,我上面说的“有点不对”,就在前面引用的两段话中解释了:
请注意,任何在HEAD中引入与HEAD..<upstream>中提交相同文本更改的提交都将被省略(即已使用不同提交消息或时间戳接受上游的补丁将被跳过)。
请注意,这里的
HEAD..<upstream>
是
git log
命令中我们刚刚运行的
<upstream>..HEAD
的反向,其中我们看到了
D
到
G
。
对于第一次rebase,
git log HEAD..master
中没有提交,因此不会跳过任何提交。这很好,因为我们没有要跳过的提交:我们只是将
E
和
F
复制到
E'
和
F'
中,这正是我们想要的。
对于第二次rebase,在第一次rebase完成后,
git log HEAD..master
会显示
E'
和
F'
两个提交的副本。这些提交被认为是潜在要跳过的提交:它们是要考虑跳过的候选提交。
“潜在要跳过的”并不等同于“真正要跳过的”。
那么 Git 如何决定哪些提交真正应该被跳过呢?答案在于
git patch-id
,尽管它实际上是直接在
git rev-list
中实现的,这是一个非常花哨和复杂的命令。但是,这两个命令都没有很好地描述它,部分原因是因为它很难描述。不过,这里是我的尝试。 :-)
Git在这里所做的是查看差异,去掉标识行号,以防补丁在不同位置(由于先前的补丁将行上下移动)而略有不同。它使用与文件相同的技巧 - 将文件内容转换为唯一哈希 - 将每个提交转换为“补丁ID”。
提交ID是一个唯一的哈希,用于标识一个特定的提交,并始终标识相同的提交。
补丁ID是另一个(但仍然是唯一的某些内容)哈希ID,它始终标识“相同”的补丁,即删除和添加相同的差异块,即使从不同位置删除和添加它们。
Git为每个提交计算了补丁ID,然后可以说:“啊哈,提交D和提交D'有相同的补丁ID!我应该跳过复制D,因为D'可能是复制D的结果。”它也可以对E与E'进行同样的操作。这通常有效,但在从D到D'的复制需要手动干预(修复合并冲突)时,它会失败,并且在从E到E'的复制需要手动干预时也会失败。
需要的是一种“智能变基”,它可以预先查看一系列分支并计算要复制哪些提交,一次性为所有待变基的分支完成复制。然后,在完成所有复制之后,“智能变基”将调整所有分支名称。
在这种特定情况下-复制D到G-实际上很容易,您可以手动执行此操作:
$ git checkout -q other-next && git rebase master
[here rebase copies D, E, F, and G, perhaps with your assistance]
接着是:
$ git checkout next
[here git checks out "next", so that HEAD is ref: refs/heads/next
and refs/heads/next points to original commit E]
$ git reset --hard other-next~2
这能够起作用是因为
other-next
指向了提交
G'
,它的父提交是
F'
,而
F'
的父提交则是
E'
,这正是我们希望
next
指向的位置。由于
HEAD
引用了分支
next
,所以
git reset
会调整
refs/heads/next
来指向提交
E'
,然后我们就完成了。
在更复杂的情况下,需要仅复制一次的提交并不都是线性的:
A1-A2-A3 <-- featureA
/
...--o--o--o--o--o--o--o <-- master
\
*--*--B3-B4-B5 <-- featureB
\
C3-C4 <-- featureC
如果我们想要对这三个特性进行“多次变基”,我们可以独立于其他两个功能重新定义
featureA
- 除了早期的
A
提交之外,这三个
A
提交都不依赖于任何“非主”内容。但是,为了复制这五个
B
提交和这四个
C
提交,我们必须复制那两个
*
提交,它们既是
B
又是
C
,但只复制一次,然后将其余的三个和两个提交(分别)复制到已复制提交的顶部。 (实现这样一个“智能变基”是可能的,但将其整合到Git中并使得
git status
真正理解它则相当困难。)
原始回答
我希望看到一个可重现的例子。在大多数情况下,你的“头脑中”模型应该是有效的。但有一种特殊情况。
交互式变基(interactive rebase)或在普通的git rebase
命令中添加-m
或--merge
选项实际上使用了git cherry-pick
,而默认的非交互式变基则使用git format-patch
和git am
。后者对于重命名检测不够好。特别是,如果在上游进行了文件重命名,那么可以预期交互式或--merge
变基将表现得不同(通常更好)。
(另外,请注意两种重新设置基础的方式——基于补丁和基于 cherry-pick 的版本——都会跳过与已经存在于上游的提交相同的提交,通过使用
git rev-list --left-only --cherry-pick HEAD...<upstream>
或等效命令。请参阅
git rev-list
的文档,特别是关于
--cherry-mark
和
--left-right
的部分,我认为这使得这一点更加易于理解。不过,对于两种重新设置基础的方式来说,这应该是相同的;如果您手动进行 cherry-pick,那么您需要自己决定是否要这样做。)
更精确地说,
git diff --find-renames
需要“相信”那里有一个重命名。通常情况下,如果有一个重命名,它会相信这一点,但由于它是通过比较树来“检测”它们的,所以这并不完美。
git-rebase
在底层使用git-cherry-pick
。 (无论是显式地还是在补丁应用失败时作为后备方案。) - Edward Thomson