Pro Git书中是正确的:一个提交是一个快照。但您也是正确的:git cherry-pick
应用了一个补丁。当您cherry-pick提交时,还会指定要考虑哪个父提交,并使用-m parent-number
参数生成相应的差异。然后,cherry-pick命令生成与该父项的差异,以便现在可以应用结果差异。如果您选择cherry-pick非合并提交,则只有一个父项,因此实际上不需要传递-m
,该命令将使用单个父项生成差异。但提交本身仍然是一个快照,正是cherry-pick
命令找到了commit^1
(第一个也是唯一的父项)与commit
的差异并应用它。
可选阅读:它不仅仅是一个补丁
技术上说,git cherry-pick
使用Git的合并机制进行全面三方合并。要理解这里的区别是什么以及它是什么,我们必须深入累积、补丁和合并。
两个文件之间或多个文件的两个快照之间的差异会产生一种配方。遵循这些说明不会烤出蛋糕(没有面粉、鸡蛋、黄油等)。相反,它将采用“before”或“left hand side”文件或文件集,并将其结果产生为“after”或“right hand side”文件或文件集。然后,说明包括诸如“在第30行后添加一行”或“删除第45行处的三行”之类的步骤。
由某个差异算法生成的明确的指令集取决于该算法。 Git最简单的diff仅使用两种: 删除一些现有行和在某个给定起点之后添加一些新的行。这对于new文件和deleted文件来说还不太够,因此我们可以添加delete file F1和create all-new-file F2。或者,在某些情况下,我们可能会用rename F1 to F2(可选地带有其他更改)替换delete-file-F1-create-F2-instead。Git最复杂的diff使用所有这些。1
这给我们提供了一组简单的定义,不仅适用于Git,也适用于许多其他系统。事实上,在Git之前有diff
和patch
。请参阅维基百科关于patch
的文章。虽然如此,两者的非常简要的摘要定义是:
- diff:比较两个或多个文件。
- patch:可机器读取且适合机器应用的diff。
它们在版本控制系统之外也很有用,这就是它们先于Git的原因(尽管从计算的角度来看,技术上并不属于版本控制,而版本控制可以追溯到20世纪50年代,从广义上来说甚至可能追溯到数千年前:我敢打赌,对于例如亚历山大灯塔或佐塞尔金字塔等建筑,已经存在多种不同的草图)。但是我们可能会遇到补丁问题。假设某人有某个程序的版本1,并对其问题制作了补丁。后来,我们发现版本5中也存在同样的问题。此时补丁可能无法应用,因为代码已经移动——可能甚至移到了不同的文件中,但肯定在文件中移动了。上下文也可能会改变。
Larry Wall的patch
程序使用所谓的偏移和fuzz来处理这一点。请参见为什么这个补丁用模糊度为1应用成功,而用模糊度为0失败?(这与现代软件测试中的“fuzzing”非常不同。)但是,在真正的版本控制系统中,我们可以做得更好,有时甚至可以做得更好。这就是三方合并的作用。
假设我们有一些软件,存储库R中有多个版本。每个版本Vi由一些文件组成。从Vi到Vj的差异会生成一个(机器可读的)补丁,用于将版本i转换为版本j。无论i和j的相对方向如何,都可以进行差异比较,即当j ≺ i时,我们可以“倒退”到旧版本(奇怪的花括号小于号是“之前”的符号,它允许使用类似Git哈希ID以及像SVN的简单数字版本)。
现在假设我们有一个补丁p,通过比较Vi与Vj来生成。我们希望将补丁p应用于第三个版本Vk。我们需要知道以下信息:
- 对于每个补丁的更改(并假设更改是“基于行”的,就像这里一样):
- Vk中哪个文件名与Vi与Vj中的文件对应于此更改?也就是说,也许我们正在修复某个函数
f()
,但在版本i和j中,函数f()
在文件file1.ext
中,在版本k中则在文件file2.ext
中。
- Vk中的哪些行对应于更改的行?也就是说,即使
f()
没有切换到其他文件,也可能由于大量删除或插入而被向上或向下移动。
有两种方法可以获得这些信息。我们可以将Vi与Vk进行比较,也可以将Vj与Vk进行比较。这两者都会得到我们所需的答案(尽管在某些情况下使用答案的具体细节略有不同)。如果我们像Git一样选择将Vi与Vk进行比较,则会得到两个差异。
1Git的diff命令也有一个“查找复制”选项,但它在合并和cherry-pick中不使用,我自己从来没有觉得它有用。 我认为它在内部有点不足,也就是说,这至少需要更多的工作。
常规合并
现在我们再做一个观察:在正常的Git合并中,我们的设置如下:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
每个大写字母代表一个提交。分支名称br1
和br2
分别选择提交J
和L
,并且从这两个分支末端提交向后的历史记录在提交H
处汇合-在两个分支上。
执行 git merge br2
,Git会找到所有三个提交。然后运行两个git diff
: 一个比较H
与J
,以查看我们在分支br1
中进行了哪些更改,另一个比较H
与L
,以查看他们在分支br2
中进行了哪些更改。然后,Git 组合这些更改,如果组合成功,则在H
文件的基础上创建新的合并提交M
,其中:
因此,它是正确的合并结果。在图形中,提交M
看起来像这样:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
但目前对我们更重要的是M
中的快照:在M
中的快照保留了我们的更改,即保存了我们在br1
中所做的所有更改,并添加了他们的更改,也就是获取了提交K
和L
中发生的任何功能或错误修复。
挑选代码(Cherry-picking)
我们的情况有点不同。
...--P--C--... <-- somebranch
我们还有以下内容:
...--K--L <-- ourbranch (HEAD)
在这里,...
部分可能会在 P-C
父/子提交对之前与 somebranch
合并,也可能会在 P-C
提交对之后与其合并,或者其他情况。也就是说,这两种情况都是有效的,尽管前者更为常见:
...--P--C--... <-- somebranch
\
...--K--L <-- ourbranch (HEAD)
并且:
...--P--C--... <-- somebranch
\
...--K--L <-- ourbranch (HEAD)
(在第二个示例中,在P
-vs-C
中进行的任何更改通常已经存在于K
和L
中,这就是为什么它不太常见。但是,有可能有人有意或甚至由于错误在其中一个...
部分中还原了提交C
。无论出于什么原因,我们现在想再次获取这些更改。)
运行git cherry-pick
不仅会比较P
-vs-C
。确实会做到这一点-这将产生我们想要的差异/补丁,但是它随后继续比较P
与L
。提交P
因此是git merge
样式比较的合并基。
从P
到L
的差异意味着保留我们所有的差异。与真正的合并中的H
-vs-K
示例一样,我们将在最终提交中保留所有的更改。因此,新的“合并”提交M
将具有我们的更改。但是Git将添加P
-vs-C
中的更改,因此我们还会获取补丁更改。
从P
到L
的差异提供了有关函数f()
已移动到哪个文件,如果它已移动,则需要的偏移量来修补函数f()
的必要信息。因此,通过使用合并机制,Git获得了将补丁应用于正确行的正确文件的能力。
但是,当Git制作最终的“合并”提交M
时,它不是将其链接到两个输入子节点,而是只将其链接回提交L
:
...--P--C--... <-- somebranch
\
...--K--L--M <-- ourbranch (HEAD)
也就是说,本次提交M
是一个普通的单亲(非合并)提交。 L
-vs-M
中的更改与P
-vs-C
中的更改相同,除了可能需要的行偏移和文件名更改。
现在,这里有一些警告。 特别是,git diff
无法识别某个合并基础中的多个派生文件。 如果P
-vs-C
中存在应用于file1.ext
的更改,但这些更改需要在修补提交L
时“拆分为两个文件”file2.ext
和file3.ext
,则Git不会注意到这一点。 它只是有点愚蠢。 此外,git diff
查找匹配的行:它不理解编程,并且如果存在错误匹配,例如许多关闭括号或括号或其他内容,则可以扰乱Git的diff,以便它找到错误匹配的行。
请注意,Git的存储系统在这里非常好。 没有足够聪明的是差异。 使git diff
变得更聪明,这些操作-合并和挑选-也变得更聪明。 2 然而,目前的diff操作以及合并和挑选是它们所拥有的:某人和/或某物应始终通过运行自动化测试,查看文件或任何您可以想到的其他方式(或所有这些方式的组合)来检查结果。
2他们将需要机器读取来自diff传递的任何更复杂的指令。 在内部,在diff中,这都是一个大型的C程序,差异引擎几乎像库一样运作,但原理是相同的。 这里有一个难题-适应新的diff输出-以及这个新diff的格式是否为文本格式,例如生成diff然后应用它的单独程序,或者是否为二进制格式,例如生成更改记录的内部类库函数,无论哪种方式,您在此执行的操作都是“移动 hard around”,如一位同事所说。