正如一些人在评论中指出的那样(并链接到其他问题),git cherry-pick
实际上进行了三路合并。挑选和恢复如何工作? https://stackoverflow.com/q/34572096/1256452描述了这一点,但更多的是内容而不是机制。
我描述了特定的来源set的合并冲突为什么我会遇到与 git rebase Interactive 的合并冲突? https://stackoverflow.com/q/54829847/1256452,以及樱桃挑选和恢复的总体轮廓,但我认为退一步问一下是个好主意机制你做的问题。不过,我会稍微重新构建一下它,因为这三个问题:
- 提交真的是快照吗?
- 如果提交是快照,那么如何
git show
or git log -p
将其显示为change?
- 如果提交是快照,如何
git cherry-pick
or git revert
work?
回答最后一个问题需要首先回答一个问题:
那么,让我们按照正确的顺序回答这四个问题。这将相当长,如果您愿意,可以直接跳到最后一部分,但请注意,它建立在第三部分的基础上,第三部分建立在第二部分的基础上,第三部分建立在第一部分的基础上。
提交真的是快照吗?
是的——不过,从技术上讲,这是一个承诺指的是快照,而不是being一。这非常简单明了。要使用 Git,我们通常首先运行git clone
,这为我们提供了一个新的存储库。有时,我们首先创建一个空目录并使用git init
创建一个empty存储库。但无论如何,我们现在拥有三个实体:
存储库本身是一个大数据库objects,加上一个较小的数据库名称到哈希 ID 的映射(例如,分支名称),加上许多其他作为单个文件实现的小型数据库(例如,每个引用日志一个)。
Git 称之为index, 或者暂存区,或者有时cache。它被称为什么取决于调用者。索引本质上是 Git 构建的地方next您将进行的提交,尽管它在合并期间发挥了更大的作用。
The 工作树,您可以在其中实际查看文件并处理/使用它们。
对象数据库保存四种类型的对象,Git 称之为commits, trees, blobs, and 带注释的标签。树和 blob 主要是实现细节,我们可以在这里忽略带注释的标签:对于我们的目的来说,这个大数据库的主要功能是保存我们的所有提交。然后,这些提交引用保存文件的树和 blob。最后,它实际上是树加斑点的组合,这就是快照。尽管如此,每个提交都只有一棵树,而这棵树就是我们到达快照的其余部分的原因,因此除了许多可怕的实现细节之外,提交本身也可能是一个快照。
我们如何使用索引来制作new快照
我们还不会深入讨论,但我们会说索引的工作原理是保存每个文件的压缩的、Git 化的、大部分冻结的副本。从技术上来说,它拥有参考实际冻结的副本,存储为blob。也就是说,如果你开始做git clone url
,Git已经运行git checkout branch
作为克隆的最后一步。这checkout
填写了顶部提交的索引branch,以便索引拥有该提交中每个文件的副本。
Indeed, most1 git checkout
operations fill in both the index and the work-tree from a commit. This lets you see, and use, all of your files in the work-tree, but the work-tree copies aren't the ones that are actually in the commit. What's in the commit is (are?) frozen, compressed, Git-ified, can-never-be-changed blob snapshots of all of those files. This keeps those versions of those files forever—or for as long as the commit itself exists—and is great for archival, but useless for doing any actual work. That's why Git de-Git-ifies the files into the work-tree.
Git could停在这里,只有提交和工作树。 Mercurial——在很多方面与 Git 很相似——does停在这里:您的工作树是您建议的下一次提交。您只需更改工作树中的内容然后运行hg commit
它会从您的工作树中进行新的提交。这样做有一个明显的优点,那就是没有烦人的索引带来麻烦。但它也有一些缺点,包括本质上比 Git 的方法慢。无论如何,Git 所做的都是从previous提交的信息saved in索引,准备再次提交。
然后,每次跑步时git add
、Git 压缩并 Git 化您添加的文件,并且立即更新索引。如果您只更改几个文件,然后git add
就这几个文件而言,Git 只需要更新几个索引条目。所以这意味着每时每刻该指数有里面的下一个快照,采用特殊的仅 Git 压缩且可立即冻结的形式。
这反过来意味着git commit
只需要冻结索引内容。从技术上讲,它将索引变成一棵新树,为新的提交做好准备。在少数情况下,例如在一些恢复之后,或者对于git commit --allow-empty
,新树实际上是same树作为一些先前的提交,但您不需要知道或关心这一点。
此时,Git 会收集您的日志消息以及每次提交中的其他元数据。它将当前时间添加为时间戳 - 这有助于确保每次提交都是完全唯一的,并且通常有用。它使用current提交为新提交parent哈希 ID,使用tree通过保存索引产生的哈希ID,并写出新的提交对象,从而获得新的且唯一的提交哈希ID。因此,新提交包含您之前签出的任何提交的实际哈希 ID。
最后,Git 将新提交的哈希 ID 写入当前分支名称,以便分支名称现在引用new提交,而不是像以前那样提交到新提交的父级。也就是说,无论提交什么was分支的尖端,现在提交是一步behind树枝的尖端。新提示是您刚刚做出的承诺。
1You can use git checkout commit -- path
to extract one particular file from one particular commit. This still copies the file into the index first, so that's not really an exception. However, you can also use git checkout
to copy files just from the index, to the work-tree, and you can use git checkout -p
to selectively, interactively patch files, for instance. Each of these variants has its own special set of rules as to what it does with index and/or work-tree.
由于 Git 构建了新的提交from索引,经常重新检查文档可能是明智的(尽管痛苦)。幸运的是,git status
告诉您很多有关索引中现在内容的信息 - 通过比较当前提交与索引,然后比较索引与工作树,对于每个这样的比较,告诉您什么不同的。因此,很多时候,您不必在头脑中记住每个 Git 命令对索引和/或工作树的影响的所有差异很大的细节:您只需运行该命令,然后使用git status
later.
如何git show
or git log -p
将提交显示为更改?
每个提交都包含其父提交的原始哈希 ID,这意味着我们始终可以从last提交一些提交字符串,然后工作向后查找所有以前的提交:
... <-F <-G <-H <--master
我们只需要有一种方法可以找到last犯罪。那种方式就是:分店名称, 例如master
在这里,标识last犯罪。如果最后一次提交的哈希 ID 是H
, Git 找到提交H
在对象数据库中。H
stores G
的哈希 ID,Git 从中找到G
,其中存储F
的哈希 ID,Git 从中找到F
, 等等。
这也是将提交显示为补丁的指导原则。我们让 Git 查看提交本身,找到其父提交,并提取该提交的快照。然后我们也让 Git 提取提交的快照。现在我们有两张快照,现在我们可以比较它们——可以说,从后一张快照中减去前一张快照。无论是不同的,那一定是这样的changed在那张快照中。
请注意,这仅适用于不合并承诺。当我们让 Git 构建一个merge提交,我们有 Git 存储,不是一个,而是two父哈希 ID。例如,运行后git merge feature
开启时master
,我们可能有:
G--H--I
/ \
...--F M <-- master (HEAD)
\ /
J--K--L <-- feature
Commit M
has two父母:它的第一个父母是I
, which was提示提交于master
刚才。它的第二个父母是L
,这仍然是提示提交feature
。很难——嗯,不可能,真的——做出承诺M
作为一个简单的改变I
or L
,并且默认情况下,git log
simply 不打扰在这里显示任何更改!
(你可以告诉两个git log
and git show
实际上,split合并:显示差异I
to M
,然后显示第二个单独的差异L
to M
, using git log -m -p
or git show -m
. The git show
默认情况下,命令生成 Git 所说的组合差异,这有点奇怪和特殊:它实际上是通过运行两个差异来制作的-m
, then 忽略他们所说的大部分内容并只向您展示其中的一些变化both承诺。这与合并的工作方式密切相关:其想法是显示可能存在合并冲突的部分。)
这引出了我们嵌入的问题,在我们进行挑选和恢复之前,我们需要先解决这个问题。我们需要谈谈机制git merge
,即我们如何得到snapshot用于提交M
首先。
Git 的表现如何git merge
?
让我们首先注意到point合并——好吧,无论如何,大多数合并——就是结合工作。当我们这样做的时候git checkout master
进而git merge feature
,我们的意思是:我做了一些工作master
。其他人做了一些工作feature
。我想将他们所做的工作与我所做的工作结合起来。有一个执行此组合的过程,然后有一个更简单的过程来保存结果。
因此,真正的合并有两个部分,导致像这样的提交M
多于。第一部分我喜欢称之为verb part, to merge。这部分其实结合了我们不同的改变。第二部分正在制作a merge, or 合并提交:在这里,我们使用“合并”一词作为名词或形容词。
这里还值得一提的是git merge
并不总是进行合并。该命令本身很复杂,并且有很多有趣的标志参数来以各种方式控制它。在这里,我们只考虑它确实进行实际合并的情况,因为我们正在研究合并是为了理解樱桃选择和恢复。
合并为名词或形容词
真正合并的第二部分是更容易的部分。一旦我们完成了to merge过程,合并作为动词,我们让 Git 以通常的方式使用索引中的任何内容进行新的提交。这意味着索引最终需要包含合并的内容。 Git 将像往常一样构建树并收集日志消息 - 我们可以使用不太好的默认值,merge branch B
,或者如果我们感觉特别勤奋,就构建一个好的。 Git 将照常添加我们的姓名、电子邮件地址和时间戳。然后 Git 将写出一个提交,但在这个新的提交中,只存储one父级,Git 将存储一个额外的,secondParent,这是我们运行时选择的提交的哈希 IDgit merge
.
For our git merge feature
开启时master
,例如,第一个父级将被提交I
——我们通过运行签出的提交git checkout master
。第二个父级将被提交L
,其中一个feature
点。这就是真正要做的一切a合并:合并提交只是具有至少两个父级的提交,标准合并的标准两个父级是第一个与any提交,第二个是我们通过运行选择的git merge something
.
合并为动词
动词合并是更难的部分。我们在上面指出,Git 将使new从索引中的任何内容提交。所以,我们需要把into索引,或者将 Git 放入其中,结果为结合工作.
我们在上面声明我们做了一些更改master
,他们——无论他们是谁——做了一些改变feature
。但我们已经看到 Git 没有store变化。 gitstores快照。我们如何从snapshot to change?
我们已经知道这个问题的答案了!我们看的时候就看到了git show
. Git compares两张快照。因此对于git merge
,我们只需要选择正确的快照。但哪些是正确的快照呢?
答案是this问题在于提交图。在我们跑步之前git merge
,图表如下所示:
G--H--I <-- master (HEAD)
/
...--F
\
J--K--L <-- feature
我们正在等待提交I
,尖端master
。他们的承诺是承诺L
,尖端feature
. From I
,我们可以向后推算H
进而G
进而F
然后大概E
等等。与此同时,从L
,我们可以向后推算K
进而J
进而F
大概E
等等。
When we do实际上do这个倒推的技巧,我们converge在提交时F
。显然,无论我们做了什么改变,我们都是从快照开始的F
...无论发生什么变化they做了,他们also从快照开始F
!因此,为了结合两组更改,我们所要做的就是:
- compare
F
to I
: 这就是我们改变的
- compare
F
to L
: 这就是他们改变的
本质上,我们只需让 Git 运行两个git diff
s。人们会弄清楚什么we改变了,人们就会明白是什么they改变了。犯罪F
是我们共同的起点,或者用版本控制的话说,合并基地.
现在,为了真正完成合并,Git 扩展了索引。而不是持有one每个文件的副本,Git 现在将保留索引three每个文件的副本。一份副本将来自合并基地F
。第二个副本将来自我们的提交I
。最后一个、第三个副本来自他们的提交L
.
Meanwhile, Git also looks at the result of the two diffs, file-by-file. As long as commits F
, I
, and L
all have all the same files,2 there are only these five possibilities:
- 没有人碰过这个文件。只需使用任何版本:它们都是一样的。
- 我们更改了文件,但他们没有。只需使用我们的版本即可。
- 他们更改了文件,而我们没有。只需使用他们的版本即可。
- 我们和他们都改变了文件,但我们做了same变化。使用我们的或他们的——两者都是相同的,所以使用哪个并不重要。
- 我们和他们都改变了same文件,但我们做了不同的变化。
案例5是唯一棘手的案例。对于所有其他情况,Git 知道(或者至少假设它知道)正确的结果是什么,因此对于所有其他情况,Git 将相关文件的索引槽缩小到只有一个槽(编号为零),该槽保存正确的结果。
不过,对于情况 5,Git 将三个输入文件的所有三个副本填充到索引中的三个编号槽中。如果文件名为file.txt
, :1:file.txt
保存合并基础副本F
, :2:file.txt
保存我们提交的副本I
, and :3:file.txt
持有他们的副本L
。然后 Git 运行一个低级合并驱动程序——我们可以在其中设置一个.gitattributes
,或使用默认值。
The default low-level merge takes the two diffs, from base to ours and from base to theirs, and tries to combine them by taking both sets of changes. Whenever we touch different lines in the file, Git takes our or their change. When we touch the same lines, Git declares a merge conflict.3 Git writes the resulting file to the work-tree as file.txt
, with conflict markers if there were conflicts. If you set merge.conflictStyle
to diff3
, the conflict markers include the base file from slot 1, as well as the lines from the files in slots 2 and 3. I like this conflict style much better than the default, which omits the slot-1 context and shows just the slot-2 vs slot-3 conflict.
Of course, if there are conflicts, Git declares the merge conflicted. In this case, it (eventually, after processing all the other files) stops in the middle of the merge, leaving the conflict-marker mess in the work-tree and all three copies of file.txt
in the index, in slots 1, 2, and 3. But if Git is able to resolve the two different change-sets on its own, it goes ahead and erases slots 1-3, writes the successfully-merged file to the work-tree,4 copies the work-tree file into the index at the normal slot zero, and proceeds with the rest of the files as usual.
If the merge does stop, it is your job to fix the mess. Many people do this by editing the conflicted work-tree file, figuring out what the right result is, writing out the work-tree file, and running git add
to copy that file into the index.5 The copy-into-index step removes the stage 1-3 entries and writes the normal stage-zero entry, so that the conflict is resolved and we're ready to commit. Then you tell the merge to continue, or run git commit
directly since git merge --continue
just runs git commit
anyway.
This to merge过程虽然有点复杂,但最终非常简单:
- 选择一个合并基地。
- 将合并基础与当前提交(我们已经检查出我们将通过合并进行修改的提交)进行比较,看看有什么we改变了。
- 将合并基础与other提交,我们选择合并的那个,看看会发生什么they改变了。
- 结合更改,应用combined对快照的更改合并基地。这就是结果,它会出现在索引中。我们可以从合并基础版本开始,因为combined变化include我们的改变:我们不会失去它们unless we say 只获取他们的文件版本.
This to merge or 合并作为动词然后是该过程合并为名词步骤,进行合并提交,合并完成。
2If the three input commits don't have all the same files, things get tricky. We can have add/add conflicts, modify/rename conflicts, modify/delete conflicts, and so on, all of which are what I call high level conflicts. These also stop the merge in the middle, leaving slots 1-3 of the index populated as appropriate. The -X
flags, -X ours
and -X theirs
, do not affect high level conflicts.
3You can use -X ours
or -X theirs
to make Git choose "our change" or "their change" instead of stopping with a conflict. Note that you specify this as an argument to git merge
, so it applies to all files that have conflicts. It's possible to do this one file at a time, after the conflict happens, in a more intelligent and selective way, using git merge-file
, but Git does not make this as easy as it should.
4At least, Git thinks the file is successfully merged. Git is basing this on nothing more than the two sides of the merge touched different lines of the same file and that must be OK, when that's not necessarily actually OK at all. It works pretty well in practice, though.
5Some people prefer merge tools, which generally show you all three of the input files and allow you to construct the correct merge result somehow, with the how depending on the tool. A merge tool can simply extract those three inputs from the index, since they are right there in the three slots.
How do git cherry-pick
and git revert
work?
这些也是三向合并操作。他们使用提交图,其方式类似于git show
使用它。它们并不像git merge
,即使他们使用合并作为动词合并代码的一部分。
相反,我们从您可能拥有的任何提交图开始,例如:
...---o--P--C---o--...
. .
. .
. .
...--o---o---H <-- branch (HEAD)
实际关系,if any, 之间H
and P
,和之间H
and C
, 并不重要。这里唯一重要的是current(HEAD) 提交是H
,并且有一些提交C
(子)有(一个,单个)父提交P
。那是,P
and C
直接是我们想要选择或恢复的提交的父提交。
Since we're on commit H
, that's what is in our index and work-tree. Our HEAD is attached to the branch named branch
, and branch
points to commit H
.6 Now, what Git does for git cherry-pick hash-of-C
is simple:
-
Choose commit
P
作为合并基础。
- 进行标准的三向合并,合并作为动词部分,使用当前提交
H
作为我们的并承诺C
作为他们的。
这个合并为动词的过程发生在索引中,就像git merge
。当一切都成功完成时,或者你已经清理了混乱,如果wasn't成功了,你已经跑了git cherry-pick --continue
——Git 继续做了一个普通,非合并 commit.
如果您回顾一下合并作为动词的过程,您会发现这意味着:
- 差异提交
P
vs C
: 这就是他们改变的
- 差异提交
P
vs H
: 这就是我们改变的
- 结合这些差异,将它们应用到其中
P
So git cherry-pick
is三路合并。只是这样他们改变了什么是同一件事git show
会显示!同时,我们改变了什么是我们需要转动的一切P
into H
—and we do需要这个,因为我们想要keep H
as our起点,仅添加their对此进行更改。
但这也是选择有时会出现一些奇怪的(我们认为)冲突的方式和原因。它必须combine整套P
-vs-H
变化与P
-vs-C
变化。如果P
and H
相距很远,这些变化可能是巨大的。
The git revert
命令就像这样简单git cherry-pick
,实际上是由Git中相同的源文件实现的。它所做的只是使用提交C
作为合并基础和提交P
as their提交(当使用H
和我们平常一样)。也就是说,Git 会 diffC
,恢复的提交,vsH
,看看我们做了什么。然后就会有差异C
,恢复的提交,vsP
看看他们做了什么——当然,这与他们实际所做的相反。然后是合并引擎,实现的部分合并作为动词,将组合这两组更改,将组合的更改应用于C
并将结果放入索引和我们的工作树中。合并后的结果保留了我们的更改(C
vs H
) and undoes他们的改变(C
vs P
是一个反向差异)。
如果一切顺利,我们最终会得到一个完全普通的新提交:
...---o--P--C---o--...
. .
. .
. .
...--o---o---H--I <-- branch (HEAD)
区别于H
to I
,这就是我们将看到的git show
, 是一个copy of the P
-to-C
更改(择优挑选)或reversal of the P
-to-C
更改(恢复)。
6Both cherry-pick and revert refuse to run unless the index and work-tree match the current commit, though they do have modes that allow them to be different. The "allowed to be different" is just a matter of tweaking expectations. and the fact that if the pick or revert fails, it may be impossible to recover cleanly. If the work-tree and index match the commit, it's easy to recover from a failed operation, so that's why this requirement exists.