挑选提交(cherry-picking commit) - 提交(commit)是快照还是补丁?

3
我有一个关于挑选提交和冲突的问题。
《Pro Git》书中解释,提交是快照而不是补丁/差异。
但是挑选提交可能会像补丁一样运作。

以下是简短的示例:

  1. 创建3个提交,每次编辑文件的第一行(唯一的一行)

  2. 将分支重置为第一个提交

  3. 测试1: 尝试挑选第三个提交(冲突)

  4. 测试2: 尝试挑选第二个提交(成功)


mkdir gitlearn
cd gitlearn

touch file
git init
Initialized empty Git repository in /root/gitlearn/.git/

git add file

#fill file by single 'A'
echo A > file && cat file
A

git commit file -m A
[master (root-commit) 9d5dd4d] A
 1 file changed, 1 insertion(+)
 create mode 100644 file

#fill file by single 'B'
echo B > file && cat file
B

git commit file -m B
[master 28ad28f] B
 1 file changed, 1 insertion(+), 1 deletion(-)

#fill file by single 'C'
echo C > file && cat file
C

git commit file -m C
[master c90c5c8] C
 1 file changed, 1 insertion(+), 1 deletion(-)

git log --oneline
c90c5c8 C
28ad28f B
9d5dd4d A

测试1

#reset the branch to 9d5dd4d ('A' version)
git reset --hard HEAD~2
HEAD is now at 9d5dd4d A

git log --oneline
9d5dd4d A

#cherry-pick 'C' version over 'A'
git cherry-pick c90c5c8
error: could not apply c90c5c8... C
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'

#the conflict:
cat file
<<<<<<< HEAD
A
=======
C
>>>>>>> c90c5c8... C

测试2

#same for 'B' - succeeds
git reset --hard HEAD
HEAD is now at 9d5dd4d A

git cherry-pick 28ad28f
[master eb27a49] B
 1 file changed, 1 insertion(+), 1 deletion(-)

请解释为什么测试1失败了(如果提交是补丁,我可以想象答案,但快照呢?)

第一个测试中出现了合并冲突。由于您在第二个测试中进行了重置,因此它成功了。 - Mr. Polywhirl
Polywhirl先生,感谢您的回答,但是: 1)我不确定您为什么打破了原帖的格式 2)在进行这两个测试之前进行了硬重置 - MrCricket
Stack Overflow在检测代码块时可能会有些敏感。正如您可以通过点击并排的Markdown查看修订历史记录中所示,@Mr.Polywhirl实际上只是将其变成了一个编号列表,而没有尝试更改任何其他内容。虽然在编辑预览中可能会发现格式问题,但我从经验中知道很容易忽略它们。 - user743382
1个回答

10

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 F1create all-new-file F2。或者,在某些情况下,我们可能会用rename F1 to F2(可选地带有其他更改)替换delete-file-F1-create-F2-instead。Git最复杂的diff使用所有这些。1

这给我们提供了一组简单的定义,不仅适用于Git,也适用于许多其他系统。事实上,在Git之前有diffpatch。请参阅维基百科关于patch的文章。虽然如此,两者的非常简要的摘要定义是:

  • diff:比较两个或多个文件。
  • patch:可机器读取且适合机器应用的diff。

它们在版本控制系统之外也很有用,这就是它们先于Git的原因(尽管从计算的角度来看,技术上并不属于版本控制,而版本控制可以追溯到20世纪50年代,从广义上来说甚至可能追溯到数千年前:我敢打赌,对于例如亚历山大灯塔或佐塞尔金字塔等建筑,已经存在多种不同的草图)。但是我们可能会遇到补丁问题。假设某人有某个程序的版本1,并对其问题制作了补丁。后来,我们发现版本5中也存在同样的问题。此时补丁可能无法应用,因为代码已经移动——可能甚至移到了不同的文件中,但肯定在文件中移动了。上下文也可能会改变。

Larry Wall的patch程序使用所谓的偏移和fuzz来处理这一点。请参见为什么这个补丁用模糊度为1应用成功,而用模糊度为0失败?(这与现代软件测试中的“fuzzing”非常不同。)但是,在真正的版本控制系统中,我们可以做得更好,有时甚至可以做得更好。这就是三方合并的作用。

假设我们有一些软件,存储库R中有多个版本。每个版本Vi由一些文件组成。从ViVj的差异会生成一个(机器可读的)补丁,用于将版本i转换为版本j。无论ij的相对方向如何,都可以进行差异比较,即当j ≺ i时,我们可以“倒退”到旧版本(奇怪的花括号小于号是“之前”的符号,它允许使用类似Git哈希ID以及像SVN的简单数字版本)。

现在假设我们有一个补丁p,通过比较ViVj来生成。我们希望将补丁p应用于第三个版本Vk。我们需要知道以下信息:

  • 对于每个补丁的更改(并假设更改是“基于行”的,就像这里一样):
    • Vk中哪个文件名与ViVj中的文件对应于此更改?也就是说,也许我们正在修复某个函数f(),但在版本ij中,函数f()在文件file1.ext中,在版本k中则在文件file2.ext中。
    • Vk中的哪些行对应于更改的行?也就是说,即使f()没有切换到其他文件,也可能由于大量删除或插入而被向上或向下移动。

有两种方法可以获得这些信息。我们可以将ViVk进行比较,也可以将VjVk进行比较。这两者都会得到我们所需的答案(尽管在某些情况下使用答案的具体细节略有不同)。如果我们像Git一样选择将ViVk进行比较,则会得到两个差异。


1Git的diff命令也有一个“查找复制”选项,但它在合并和cherry-pick中不使用,我自己从来没有觉得它有用。 我认为它在内部有点不足,也就是说,这至少需要更多的工作。


常规合并

现在我们再做一个观察:在正常的Git合并中,我们的设置如下:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

每个大写字母代表一个提交。分支名称br1br2分别选择提交JL,并且从这两个分支末端提交向后的历史记录在提交H处汇合-在两个分支上。

执行 git merge br2,Git会找到所有三个提交。然后运行两个git diff: 一个比较HJ,以查看我们在分支br1中进行了哪些更改,另一个比较HL,以查看他们在分支br2中进行了哪些更改。然后,Git 组合这些更改,如果组合成功,则在H文件的基础上创建新的合并提交M,其中:

  • 保留我们的更改,但同时
  • 添加他们的更改

因此,它是正确的合并结果。在图形中,提交M看起来像这样:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

但目前对我们更重要的是M中的快照:在M中的快照保留了我们的更改,即保存了我们在br1中所做的所有更改,并添加了他们的更改,也就是获取了提交KL中发生的任何功能或错误修复。

挑选代码(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中进行的任何更改通常已经存在于KL中,这就是为什么它不太常见。但是,有可能有人有意或甚至由于错误在其中一个...部分中还原了提交C。无论出于什么原因,我们现在想再次获取这些更改。)

运行git cherry-pick不仅会比较P-vs-C。确实会做到这一点-这将产生我们想要的差异/补丁,但是它随后继续比较PL。提交P因此是git merge样式比较的合并基

PL的差异意味着保留我们所有的差异。与真正的合并中的H-vs-K示例一样,我们将在最终提交中保留所有的更改。因此,新的“合并”提交M将具有我们的更改。但是Git将添加P-vs-C中的更改,因此我们还会获取补丁更改。

PL的差异提供了有关函数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.extfile3.ext,则Git不会注意到这一点。 它只是有点愚蠢。 此外,git diff查找匹配的行:它不理解编程,并且如果存在错误匹配,例如许多关闭括号或括号或其他内容,则可以扰乱Git的diff,以便它找到错误匹配的行。

请注意,Git的存储系统在这里非常好。 没有足够聪明的是差异。 使git diff变得更聪明,这些操作-合并和挑选-也变得更聪明。 2 然而,目前的diff操作以及合并和挑选是它们所拥有的:某人和/或某物应始终通过运行自动化测试,查看文件或任何您可以想到的其他方式(或所有这些方式的组合)来检查结果。


2他们将需要机器读取来自diff传递的任何更复杂的指令。 在内部,在diff中,这都是一个大型的C程序,差异引擎几乎像库一样运作,但原理是相同的。 这里有一个难题-适应新的diff输出-以及这个新diff的格式是否为文本格式,例如生成diff然后应用它的单独程序,或者是否为二进制格式,例如生成更改记录的内部类库函数,无论哪种方式,您在此执行的操作都是“移动 hard around”,如一位同事所说。


抱歉,我仍然不明白。从您解释的最后一段中 - 我的test1和test2中的*commit^1commit*是什么? - MrCricket
啊,不,当你要求挑选c90c5c8时,git会找到它的父提交,即28ad28f。试一下:git rev-parse c90c5c8^git diff c90c5c8^ c90c5c8。一旦提交存在,就永远不会改变。git reset不会更改任何现有的提交,它只是更改当前分支名称所解析的提交(同样,使用git rev-parse来演示这一点:git rev-parse master将显示其当前的原始SHA-1)。 - torek
是的,每个快照都“知道”它的父级是谁。但为什么cherry-pick要使用这些信息呢?从技术上讲,它只应该比较提交“A”中文件的内容与“C”的内容,而与“B”无关。我们还可以在test1块的最后几行看到这一点:冲突中没有“B”的内容。 - MrCricket
1
Cherry-pick 之所以比较提交与其父级,是因为定义了这种操作。您向 Git 提供一个或多个提交 ID,Git 查找每个已命名提交的父级(如果是合并,则查找第 -m 个父级),然后执行差异并尝试应用该差异。在这种情况下,差异是 "将 B 更改为 C",而没有 B,因此您会看到补丁应用错误。 (如果您想执行其他操作,则不需要 cherry-pick 命令。例如,如果您只想获取某个文件的特定快照版本,请使用 git checkout <revspec> -- <path>git show <revspec>:<path>。) - torek
“Cherry-pick将一个提交与其父提交进行比较,因为它是这样定义的。” 这就是我的问题 :) 提交本身是一份“快照”,但cherry-pick使用“补丁”。谢谢,torek。 - MrCricket
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接