到处我都看到这个:"...樱桃采摘应用提交引入的更改...">
我这样做了:在 master 中创建此文件:
** File 1 **
Content
** Footer **
然后分支到 branch2 并提交更改:
** File 1 **
Content
Edit 1
** Footer **
然后是另一个:
** File 1 **
Content
Edit 2
Edit 1
** Footer **
现在我回到了 master 并尝试从 branch2 中挑选最新的提交。我预计只有"Edit2"会被导入,因为与上一次相比,这不是该提交引入的更改吗?
相反,我得到的是以下合并冲突:
** File 1 **
Content
<<<<<<< HEAD
=======
Edit 2
Edit 1
>>>>>>> b634e53...
** Footer **
现在我明显的问题是我对樱桃挑选的工作原理有什么误解,具体来说为什么这里存在合并冲突,这将是 git 合并的快进?
重要提示:这不是一个关于合并冲突的问题,我感兴趣的是樱桃采摘在这里实际做什么。而且我不是出于好奇/什么原因而问,而是因为我在工作中使用 git 遇到了麻烦。
正如一些人在评论中指出的那样(并链接到其他问题),git cherry-pick
实际上进行了三向合并。 樱桃采摘和还原如何工作?描述了这一点,但更多的是内容而不是机制。
我在为什么我使用 git rebase 交互式获得此合并冲突中描述了一组特定合并冲突的来源?,以及挑选和还原的一般概述,但我认为退后一步并提出您所做的机制问题是个好主意。 不过,我会重新构建一下,因为这三个问题:
- 提交真的是快照吗?
- 如果提交是快照,
git show
或git log -p
如何将其显示为更改? - 如果提交是快照,
git cherry-pick
或git revert
如何工作?
回答最后一个问题需要首先回答另一个问题:
- Git 如何执行
git merge
?
那么,让我们按照正确的顺序回答这四个问题。 这将相当长,如果你愿意,你可以直接跳到最后一部分 - 但请注意,它建立在第三部分的基础上,第三部分建立在第二部分的基础上,第二部分建立在第一部分的基础上。
提交真的是快照吗?
是的,但从技术上讲,提交是指快照,而不是快照。这是非常简单明了的。 要使用 Git,我们通常从运行开始git clone
,这为我们提供了一个新的存储库。 有时,我们首先创建一个空目录并使用git init
创建一个空存储库。 但是,无论哪种方式,我们现在有三个实体:
存储库本身,其中有一个大型对象数据库,加上一个较小的数据库名称到哈希ID映射(例如,分支名称),以及许多其他作为单个文件实现的迷你数据库(例如,每个reflog一个)。
Git 称之为索引、暂存区,有时也称为缓存。 它被称为什么取决于谁在召唤。 索引本质上是你让 Git 构建下一次提交的地方,尽管它在合并期间扮演了扩展的角色。
工作树,您可以实际查看文件并处理/处理它们。
对象数据库包含四种类型的对象,Git 调用提交、树、blob和带注释的标记。树和 blob 大多是实现细节,我们可以在这里忽略带注释的标记:就我们的目的而言,这个大数据库的主要功能是保存我们所有的提交。 然后,这些提交引用保存文件的树和 Blob。 最后,实际上是树加斑点的组合才是快照。 尽管如此,每个提交都只有一棵树,而这棵树是让我们到达快照的其余部分,所以除了许多恶魔般的实现细节之外,提交本身也可能是一个快照。
如何使用索引创建新快照
我们还不会太深入地研究杂草,但我们会说索引通过保存每个文件的压缩、Git 化、大部分冻结的副本来工作。 从技术上讲,它包含对实际冻结副本的引用,存储为blob。 也就是说,如果你从做git cloneurl
开始,Git 已经运行git checkoutbranch
作为克隆的最后一步。 这checkout
从分支尖端的提交中填充索引,以便索引具有该提交中每个文件的副本。
实际上,大多数1git checkout
操作都会从提交中填充索引和工作树。 这使您可以查看和使用工作树中的所有文件,但工作树副本不是实际在提交中的文件。 提交的内容是所有这些文件的冻结、压缩、Git 化、永不更改的 blob 快照。 这会永久保留这些文件的这些版本(或者只要提交本身存在),并且非常适合存档,但对于执行任何实际工作都无用。 这就是为什么 Git 将文件解 Git 到工作树中的原因。
Git可以停在这里,只有提交和工作树。 Mercurial——在很多方面都像 Git——确实止步于此:你的工作树是你提议的下一个提交。 您只需更改工作树中的内容,然后运行hg commit
,它就会从您的工作树中进行新的提交。 这有一个明显的优势,那就是没有讨厌的索引制造麻烦。 但它也有一些缺点,包括本质上比 Git 的方法慢。 无论如何,Git 所做的是从保存在索引中的上一个提交信息开始,准备再次提交。
然后,每次运行git add
时,Git 都会压缩并 Git 化您添加的文件,并立即更新索引。 如果只更改几个文件,然后只git add
这几个文件,则 Git 只需更新几个索引条目。 因此,这意味着索引始终包含下一个快照,以特殊的仅 Git 压缩和准备冻结的形式。
这反过来意味着git commit
只需要冻结索引内容。 从技术上讲,它将索引转换为新树,为新提交做好准备。 在少数情况下,例如在某些还原之后,或者对于git commit --allow-empty
,新树实际上将与以前的一些提交是同一棵树,但您不需要知道或关心这一点。
此时,Git 会收集您的日志消息以及进入每次提交的其他元数据。 它将当前时间添加为时间戳 - 这有助于确保每个提交都是完全唯一的,并且通常很有用。 它使用当前提交作为新提交的父哈希 ID,使用通过保存索引生成的树哈希 ID,并写出新的提交对象,该对象获得新的唯一提交哈希 ID。 因此,新提交包含您之前签出的任何提交的实际哈希 ID。
最后,Git 将新提交的哈希 ID 写入当前分支名称,以便分支名称现在引用新提交,而不是像以前那样引用新提交的父级。 也就是说,无论提交是什么,都是分支的尖端,现在提交比分支的尖端落后一步。 新提示是您刚刚进行的提交。
1您可以使用git checkoutcommit--path
从一个特定提交中提取一个特定文件。 这仍然会先将文件复制到索引中,因此这并不是一个真正的例外。 但是,您也可以使用git checkout
将文件仅从索引复制到工作树,例如,您可以使用git checkout -p
有选择地交互式修补文件。 这些变体中的每一个都有自己一套特殊的规则,用于处理索引和/或工作树。
由于 Git从索引构建新的提交,因此经常重新检查文档可能是明智的(尽管很痛苦)。 幸运的是,git status
告诉你很多关于索引中的内容 - 通过比较当前提交与索引,然后比较索引与工作树,对于每个这样的比较,告诉你有什么不同。 所以很多时候,你不必在脑海中随身携带每个 Git 命令对索引和/或工作树的影响的所有变化很大的细节:你可以运行该命令,稍后使用git status
。
git show
或git log -p
如何将提交显示为更改?
每个提交都包含其父提交的原始哈希 ID,这反过来意味着我们总是可以从一些提交字符串的最后一次提交开始,并向后工作以查找所有以前的提交:
... <-F <-G <-H <--master
我们只需要有一种方法来找到最后一个提交。 这种方式是:分支名称(例如此处的master
)标识最后一个提交。 如果最后一次提交的哈希 ID 是H
,Git 会在对象数据库中查找提交H
。H
存储G
的哈希ID,Git从中查找G
,存储F
的哈希ID,Git从中查找F
,等等。
这也是将提交显示为补丁的指导原则。 我们让 Git 查看提交本身,找到它的父级,并提取该提交的快照。 然后我们也让 Git 提取提交的快照。 现在我们有两个快照,现在我们可以比较它们 - 从后面的快照中减去前面的快照。 无论有什么不同,那一定是该快照中更改的内容。
请注意,这仅适用于非合并提交。 当我们让 Git 构建合并提交时,Git 存储的不是一个,而是两个父哈希 ID。 例如,在master
上运行git merge feature
后,我们可能有:
G--H--I
/
...--F M <-- master (HEAD)
/
J--K--L <-- feature
提交M
有两个父项:它的第一个父级是I
,这是刚才master
上的提示提交。 它的第二个父级是L
,这仍然是feature
上的提示提交。 很难 - 好吧,真的不可能 - 将提交M
作为I
或L
的简单更改呈现,默认情况下,git log
根本不愿意在这里显示任何更改!
(您可以告诉git log
和git show
实际上拆分合并:显示从I
到M
的差异,然后显示从L
到M
的第二个单独的差异,使用git log -m -p
或git show -m
。 默认情况下,git show
命令会产生 Git 所谓的组合差异,这有点奇怪和特殊:它实际上是通过运行两个 diff 作为-m
,然后忽略他们所说的大部分内容,只向你显示来自两次提交的一些更改。 这与合并的工作方式密切相关:这个想法是显示可能存在合并冲突的部分。
这就引出了我们的嵌入式问题,在我们开始挑选和还原之前,我们需要涵盖这些问题。 我们需要讨论git merge
的机制,即我们最初是如何获得提交M
的快照的。
Git 如何执行git merge
?
让我们首先注意合并(好吧,无论如何,大多数合并)的要点是合并工作。 当我们做git checkout master
然后git merge feature
时,我们的意思是:我在master
上做了一些工作。 其他人在feature
上做了一些工作. 我想把他们所做的工作和我所做的工作结合起来。有一个过程可以进行此组合,然后有一个更简单的过程来保存结果。
因此,真正的合并有两个部分,会导致如上所述M
提交。 第一部分是我喜欢称之为动词部分,合并。 这部分实际上结合了我们不同的变化。 第二部分是进行合并或合并提交:这里我们使用"合并"一词作为名词或形容词。
这里还值得一提的是,git merge
并不总是进行合并。 命令本身很复杂,并且有很多有趣的标志参数以各种方式控制它。 在这里,我们只考虑它确实进行了实际合并的情况,因为我们正在研究合并,以便理解挑选和还原。
合并为名词或形容词
真正合并的第二部分是更容易的部分。 一旦我们完成了合并过程,合并为动词,我们就让 Git 以通常的方式进行新的提交,使用索引中的任何内容。 这意味着索引需要以合并的内容结束。 Git 将像往常一样构建树,并像往常一样收集日志消息——如果我们感觉特别勤奋,我们可以使用不太好的默认值,merge branchB
,或者构造一个好的默认值。 Git 将像往常一样添加我们的姓名、电子邮件地址和时间戳。 然后 Git 会写出一个提交——但在这个新提交中,Git 不会存储一个父级,而只是存储一个父级,而是存储一个额外的第二个父级,这是我们在运行git merge
时选择的提交的哈希 ID。
例如,对于我们在master
上的git merge feature
,第一个父级将是提交I
- 我们通过运行git checkout master
签出的提交。 第二个父项将是提交L
,feature
指向的那个。 这就是合并的全部内容:合并提交只是至少有两个父级的提交,标准合并的标准两个父项是第一个与任何提交相同,第二个是我们通过运行git mergesomething
选择的。
合并为动词
合并为动词是更难的部分。 我们在上面指出,Git 将从索引中的任何内容进行新的提交。 因此,我们需要将组合工作的结果放入索引中,或者将 Git 放入索引中。
我们在上面声明我们对master
进行了一些更改,他们 - 无论他们是谁 - 对feature
进行了一些更改。 但我们已经看到 Git 不存储更改。 Git存储快照。 我们如何从快照到更改?
我们已经知道这个问题的答案!当我们看git show
时,我们看到了它。 Git比较了两个快照。 所以对于git merge
,我们只需要选择正确的快照。 但是哪些是正确的快照?
这个问题的答案就在提交图中。 在我们运行git merge
之前,图形如下所示:
G--H--I <-- master (HEAD)
/
...--F
J--K--L <-- feature
我们坐在提交I
,master
的尖端。 他们的提交是提交L
,feature
的提示。 从I
,我们可以向后工作到H
然后G
,然后F
,然后大概是E
等等。 同时,从L
,我们可以向后工作到K
,然后J
,然后F
,大概是E
等等。
当我们实际执行此向后工作技巧时,我们会收敛在提交F
。 显然,无论我们做了什么更改,我们都是从F
年的快照开始的......无论他们做了什么更改,他们也是从F
中的快照开始的! 因此,为了将两组更改结合起来,我们所要做的就是:
F
与I
进行比较:这就是我们改变的- 将
F
与L
进行比较:这就是他们改变的
从本质上讲,我们只会让 Git 运行两个git diff
。 一个人会弄清楚我们改变了什么,一个人会弄清楚他们改变了什么。 提交F
是我们的共同起点,或者用版本控制的话来说,是合并基础。
现在,为了实际完成合并,Git 扩展了索引。 Git 现在将让索引保存每个文件的三个副本,而不是每个文件的一个副本。 一个副本将来自合并基础F
。 第二个副本将来自我们的提交I
。 最后一个,第三个副本来自他们的提交L
。
同时,Git 还会逐个文件查看两个差异的结果。 只要提交F
、I
和L
都有相同的文件,2只有以下五种可能性:
- 没有人碰过文件。 只需使用任何版本:它们都是一样的。
- 我们更改了文件,但他们没有。 只需使用我们的版本。
- 他们更改了文件,而我们没有。 只需使用他们的版本。
- 我们和他们都更改了文件,但我们进行了相同的更改。 使用我们的或他们的 - 两者都是相同的,所以哪个都无关紧要。
- 我们和他们都更改了同一个文件,但我们进行了不同的更改。
案例5是唯一艰难的案例。 对于所有其他情况,Git 知道(或者至少假设它知道)正确的结果是什么,因此对于所有其他情况,Git 将相关文件的索引槽缩小到仅保存正确结果的一个槽(编号为零)。
但是,对于情况 5,Git 将三个输入文件的所有三个副本填充到索引中的三个编号槽中。 如果文件名为file.txt
,:1:file.txt
保存来自F
的合并基础副本,:2:file.txt
保存来自提交I
的副本,:3:file.txt
保存来自L
的副本。 然后 Git 运行一个低级合并驱动程序——我们可以在.gitattributes
中设置一个,或者使用默认的。
默认的低级合并采用两个差异,从基础到我们的,从基础到他们的,并尝试通过进行两组更改来组合它们。 每当我们触摸文件中的不同行时,Git 都会接受我们或他们的更改。 当我们触及相同的行时,Git 会声明合并冲突。3Git 将生成的文件作为file.txt
写入工作树,如果存在冲突,则带有冲突标记。 如果将merge.conflictStyle
设置为diff3
,则冲突标记包括插槽 1 中的基本文件,以及插槽 2 和 3 中文件中的行。 我比默认的更喜欢这种冲突风格,它省略了插槽 1 上下文,只显示插槽 2 与插槽 3 的冲突。
当然,如果存在冲突,Git 会声明合并冲突。 在这种情况下,它(最终,在处理所有其他文件之后)在合并过程中停止,将冲突标记混乱留在工作树中,并将file.txt
的所有三个副本留在索引中,位于插槽 1、2 和 3 中。 但是,如果 Git 能够自行解析两个不同的更改集,它将继续擦除插槽 1-3,将成功合并的文件写入工作树,4将工作树文件复制到正常插槽零处的索引中,然后照常处理其余文件。
如果合并确实停止,您的工作就是解决混乱。 许多人通过编辑冲突的工作树文件、找出正确的结果、写出工作树文件并运行git add
将该文件复制到索引中来做到这一点。5复制到索引步骤删除阶段 1-3 条目并写入正常的阶段零条目,以便解决冲突并准备好提交。 然后你告诉合并继续,或者直接运行git commit
git merge --continue
因为无论如何都会运行git commit
。
这个合并过程虽然有点复杂,但最终非常简单:
- 选取合并基础。
- 将合并基础与当前提交进行比较,我们已经检查出我们将通过合并进行修改,以查看我们更改了什么。 将合并
- 基础与另一个提交(我们选择要合并的提交)进行比较,以查看它们更改了哪些内容。 合并更改,将合并
- 的更改应用于合并库中的快照。 这就是结果,它进入索引。 我们可以从合并基础版本开始,因为组合的更改包括我们的更改:除非我们说只获取他们的文件版本,否则我们不会丢失它们。
这要合并或合并为动词过程,然后是合并为名词步骤,进行合并提交,合并完成。
2如果三个输入提交没有所有相同的文件,事情就会变得棘手。 我们可以有添加/添加冲突、修改/重命名冲突、修改/删除冲突等等,所有这些都是我所说的高级冲突。 这些还会停止中间的合并,留下索引的插槽 1-3 根据需要填充。-X
标志、-X ours
和-X theirs
不会影响高级别冲突。
3您可以使用-X ours
或-X theirs
让 Git 选择"我们的更改"或"他们的更改",而不是以冲突停止。 请注意,您将其指定为git merge
的参数,因此它适用于所有有冲突的文件。 在冲突发生后,可以使用git merge-file
以更智能和更有选择性的方式一次执行一个文件,但 Git 并没有使这变得如此简单。
4至少,Git认为文件已成功合并。 Git 基于此无非是合并的两边接触了同一文件的不同行,这必须没问题,而实际上这不一定是可以的。 不过,它在实践中效果很好。
5有些人更喜欢合并工具,它通常向您显示所有三个输入文件,并允许您以某种方式构建正确的合并结果,其方式取决于工具。 合并工具可以简单地从索引中提取这三个输入,因为它们就在三个插槽中。
git cherry-pick
和git revert
如何运作?
这些也是三向合并操作。 他们使用提交图的方式类似于git show
使用它的方式。 它们不像git merge
那么花哨,即使它们使用合并作为合并代码的动词部分。
相反,我们从您可能拥有的任何提交图开始,例如:
...---o--P--C---o--...
. .
. .
. .
...--o---o---H <-- branch (HEAD)
H
和P
之间以及H
和C
之间的实际关系(如果有的话)并不重要。 这里唯一重要的是当前(HEAD)提交是H
,并且有一些提交C
(子)具有(一个,单个)父提交P
。 也就是说,P
和C
直接是我们想要选取或还原的提交的父提交。
由于我们在提交H
,这就是我们的索引和工作树中的内容。 我们的 HEAD 附加到名为branch
的分支,并branch
点提交H
。6现在,Git 为git cherry-pickhash-of-C
所做的很简单:
- 选择提交
P
作为合并基础。 - 执行标准的三向合并,将合并作为动词部分,使用当前的提交
H
作为我们的,将提交C
作为他们的。
这种合并为动词的过程发生在索引中,就像git merge
一样。 当它都成功完成时——或者你清理了混乱,如果它不成功,并且你已经运行了git cherry-pick --continue
——Git 继续进行一个普通的、非合并的提交。
如果你回顾一下合并为动词的过程,你会发现这意味着:
- 差异提交
P
与C
:这就是他们改变的 - diff commit
P
vsH
:这就是我们改变的 - 将这些差异结合起来,将它们应用于
P
所以git cherry-pick
是三向合并。 只是他们改变的是git show
要展示的东西! 与此同时,我们改变的是我们将P
变成H
所需的一切——我们确实需要它,因为我们希望H
作为我们的起点,并且只添加他们的变化。
但这也是樱桃采摘有时看到一些奇怪的——我们认为——冲突的方式和原因。 它必须将整套P
-vs-H
更改与P
-vs-C
更改相结合。 如果P
和H
相距甚远,这些变化可能是巨大的。
git revert
命令就像git cherry-pick
一样简单,实际上,它是由 Git 中的相同源文件实现的。 它所做的只是使用 commitC
作为合并基础,并将 commitP
作为他们的提交(同时像往常一样使用H
作为我们的)。 也就是说,Git 将C
diff ,提交还原,与H
,看看我们做了什么。 然后它会C
不同,承诺恢复,与P
看看他们做了什么——当然,这与他们实际做了相反。 然后,合并引擎(将合并实现为动词的部分)将组合这两组更改,将组合的更改应用于C
并将结果放入索引和我们的工作树中。 组合结果保留我们的更改(C
vsH
)并撤消其更改(C
vsP
是反向差异)。
如果一切顺利,我们最终会得到一个非常普通的新提交:
...---o--P--C---o--...
. .
. .
. .
...--o---o---H--I <-- branch (HEAD)
从H
到I
的区别,也就是我们将在git show
中看到的,要么是P
到C
变化的副本(樱桃采摘),要么是P
到C
变化的逆转(还原)。
6除非索引和工作树与当前提交匹配,否则 cherry-pick 和 revert 都拒绝运行,尽管它们确实具有允许它们不同的模式。 "允许与众不同"只是调整期望的问题。事实上,如果拾取或还原失败,则可能无法干净地恢复。 如果工作树和索引与提交匹配,则很容易从失败的操作中恢复,因此这就是存在此要求的原因。