git merge <commit-id>
a :和之间有区别
git cherry-pick <commit-id>
吗?其中 ''commit-id'' 是我想要进入主分支的新分支的提交的哈希值。
3 回答
除了微不足道的情况外,所有情况都存在巨大差异(即使在微不足道的情况下,仍然存在差异)。要正确理解这一点有点挑战,但一旦你做到了,你就可以真正理解 Git 本身。
TL;DR 主要是ItayB 已经说过的:cherry-pick 意味着复制一些现有的提交。这种复制的本质是将提交转换为更改集,然后将相同的更改集重新应用到其他现有提交以进行新提交。新提交与您复制的提交“进行相同的更改”,但将该更改应用于不同的快照。
此描述有用且实用,但并非 100% 准确——如果您在挑选樱桃时遇到合并冲突,这将无济于事。正如那些暗示的那样,cherry-pick 在内部被实现为一种特殊的合并。如果没有合并冲突,您不需要知道这一点。如果你是,那么,最好从对git merge
样式合并的正确理解开始。
合并(由 完成git merge
)更复杂:它不会复制任何东西。取而代之的是,它进行了类型为merge的新提交,这……嗯,做了一些复杂的事情。:-) 如果不先描述 Git提交图,就无法充分解释。它也有两部分,我喜欢将其称为,第一,作为动词合并(合并更改的动作),第二,commit-of-type-merge,或作为名词或形容词合并: Git 调用这些是合并或合并提交。
当cherry-pick 进行合并时,它只进行前半部分,将合并作为动词动作,而且这样做有点奇怪。如果合并因冲突而失败,结果可能非常令人费解。它们只能通过了解 Git 如何将合并作为动词过程来解释。
还有一种 Git 称为快进操作,或者有时是快进合并,这根本不是合并。不幸的是,这也令人困惑。让我们推迟一下。
以下所有内容都是长答案:仅当您想了解(更多)Git时才阅读
关于提交的知识
首先要知道的——你可能已经知道了——Git 主要是关于提交的,每个 Git 提交都会保存每个文件的完整快照。也就是说,Git 的提交不是变更集。如果你修改一个文件——比如说README.md
——并用它进行新的提交,那么新的提交将包含每个文件的完整内容,包括修改后的README.md
. 当您检查提交时,使用git show
或git log -p
,Git 会显示您所做的更改,但它会先提取上一个提交的保存文件,然后是提交的保存文件,然后比较两个快照。由于只README.md
改变了,它只显示你README.md
,即使那样,也只会向您展示差异 - 对一个文件的一组更改。
反过来,这意味着每个提交都知道它的直接祖先或父提交。在 Git 中,提交有一个固定的、永久的“真实名称”,它始终表示特定的提交。这个真实的名称,或哈希 ID或有时是OID(“O”代表对象),是 Git 在git log
输出中打印的大而丑陋的字母和数字字符串。例如,5d826e972970a784bd7a7bdf587512510097b8c7
是 Git 存储库中用于 Git 的提交。这些东西看起来是随机的(尽管它们不是),并且通常对人类没有用,但它们是 Git发现的方式每次提交。那个特定的提交有一个父级——一些其他大而丑陋的哈希 ID——并且 Git 将父级的哈希保存在提交中,以便 Git 可以使用提交来回溯到它的父级。
结果是,如果我们有一系列提交,它们就会形成一个向后看的链。我们——或 Git——将从这条链的末端开始并向后工作,以查找存储库中的历史记录。假设我们有一个只有三个提交的小型存储库。与其实际的散列 ID(它们太大且难看),不如将它们称为 commits A
、B
和C
,并将它们绘制在它们的父/子关系中:
A <-B <-C
CommitC
是最新的,所以它是B
. Git 有C
记住B
的哈希 ID,所以我们说它C
指向 B
. 当我们做出B
时,只有一个先前的提交,A
,所以A
的B
父级和B
指向A
。提交A
是一种特殊情况:当我们提交时,没有提交。它没有父母,这就是让 Git 停止向后追赶的原因。
提交也是完全、完全、100% 只读的:一旦提交,任何提交都无法更改。这是因为哈希 ID 实际上是提交的完整内容的加密校验和。即使在任何地方更改一点,您都会得到一个新的、不同的哈希 ID——一个新的、不同的提交。因此,提交快照会永久保存文件的状态——或者至少,只要提交本身继续存在。(您最初可以将其视为“永远”;忘记或替换提交的机制更先进,当它不是最新的提交时会变得相当棘手。)
这种只读质量意味着我们可以更简单地绘制提交字符串:
A--B--C
请记住,链接只有一种方式,向后。父母不能知道它的孩子,因为孩子在父母出生时不存在,而一旦父母出生,它就永远冻结了。但是,孩子可以知道它的父母,因为孩子是在父母存在并被冻结之后出生的。
关于分支名称的知识
在上面的简化图中,很容易判断哪个提交是最新的. 毕竟,这封信C
紧随其后,也是最新的。但是 Git 哈希 ID 看起来完全是随机的,而 Git需要实际的哈希 ID。所以 Git 在这里所做的就是将最新提交的哈希 ID存储在一个分支名称中。B
C
事实上,这正是分支名称的定义:一个类似的名称master
只是存储了我们想要为该分支调用最新的提交的哈希 ID 。所以给定A--B--C
提交字符串,我们只需添加名称master
,指向提交C
:
A--B--C <-- master
分支名称的特别之处在于,与提交不同,它们更改. 它们不仅会改变,而且会自动进行。在 Git 中进行新提交的过程包括写出提交的内容——其父哈希 ID、作者/提交者信息、保存的快照、日志消息等——计算新提交的新哈希 ID,然后更改分支名称以记录新提交的哈希 ID。D
如果我们在 上创建一个新的提交master
,Git 会通过写出D
指向C
,然后更新master
为指向D
:
A--B--C--D <-- master
假设我们现在创建一个新的分支名称develop
. 新名称也将指向 commit D
:
A--B--C--D <-- develop, master
现在让我们进行一个新的提交E
,其父级将是D
:
A--B--C--D
\
E
Git 应该更新哪个分支名称?我们要master
指向E
,还是要develop
指向E
?这个问题的答案在于特殊的名称HEAD
。
Git 的 HEAD 会记住分支,从而记住当前的提交
为了记住我们希望 Git 更新哪个分支,以及我们现在检查了哪个提交,Git 有一个特殊的名称HEAD
,用大写字母拼写,就像这样。(由于一个怪癖,小写在 Windows 和 MacOS 上可以工作,但在没有这个怪癖的 Linux/Unix 系统上不起作用,所以最好使用全大写拼写。如果你不喜欢输入这个词,您可以使用符号@
,它是同义词。)通常,Git将名称附加HEAD
到一个分支名称:
A--B--C--D <-- develop (HEAD), master
在这里,我们在 branch develop
,因为那HEAD
是附属的。(请注意,所有四个提交都在两个分支上。)如果我们现在进行新的提交E
,Git 知道要更新哪个名称:
A--B--C--D <-- master
\
E <-- develop (HEAD)
该名称HEAD
仍附在分支机构上;分支名称本身会更改它记住的提交哈希 ID;并且 commitE
现在是当前的 commit。如果我们现在做一个新的提交,它的父级将是E
,并且 Git 将更新develop
。(新提交E
仅on ,而develop
提交A-B-C-D
仍在两个分支上!)
分离的HEAD仅意味着 Git 已将名称直接HEAD
指向某个提交,而不是将其附加到分支名称。在这种情况下,HEAD
仍然命名当前提交。你只是不在任何分支上。进行新的提交仍然会像往常一样创建提交,但是 Git 没有将新提交的新哈希 ID 写入分支名称,而是直接将其写入名称HEAD
中。
(分离的 HEAD 是正常的,但有点特殊;你不会将它用于日常开发,除非在执行一些git rebase
操作时。你主要使用它来检查历史提交——那些不在某些分支名称的尖端。我们会在这里忽略它。)
提交图,以及git merge
所以现在我们知道提交如何链接以及分支名称如何指向其分支上的最后一个提交,让我们看看它是如何git merge
工作的。
假设我们已经在两者上都做了一些提交master
,develop
所以我们现在有一个看起来像这样的图表:
G--H <-- master
/
...--D
\
E--F <-- develop
我们将连接到git checkout master
指向,然后运行。HEAD
master
H
git merge develop
在这一点上,Git 将向后跟随两条链。也就是说,它将开始H
并向后工作到G
,然后到D
。它也将从 开始F
并向后工作E
,然后到D
。此时,Git 找到了一个共享提交——一个位于两个分支上的提交。所有早期的提交也是共享的,但这是最好的一个,因为它是最接近两个分支提示的提交。
这个最佳共享提交称为合并基础。所以在这种情况下,D
是master
( H
) 和develop
( F
) 的合并基础。 合并基础提交完全由提交图确定,从当前提交 ( HEAD
= master
= commit H
) 和您在命令行上命名的另一个提交 ( develop
= commit F
) 开始。在这个过程中,分支名称的唯一用途是定位提交——之后的一切都取决于图表。
找到合并基础后,git merge
现在要做的是合并更改。但是请记住,我们说过提交是快照,而不是变更集。因此,要查找更改,Git 必须首先将合并基础提交本身提取到一个临时区域中。
现在 Git 已经提取了合并基础,a会在: 中的快照和( )中的快照之间git diff
找到我们更改的内容。这是第一个变更集。master
D
HEAD
H
Git 现在必须运行第二次git diff
,以查找它们更改的内容: 中的快照和 中的快照develop
之间的区别。这是第二个变更集。D
F
因此,git merge
在找到合并基础后,运行以下两个git diff
命令:
git diff --find-renames <hash-of-D> <hash-of-H> # what we changed
git diff --find-renames <hash-of-D> <hash-of-F> # what they changed
然后 Git 组合这两组更改,将组合的更改应用于D
(合并基础)中的快照中的内容,并根据结果进行新的提交。或者更确切地说,只要组合有效,它就会完成所有这些工作——或者更准确地说,只要Git 认为组合有效。
现在,让我们假设 Git 认为它有效。我们稍后会回来合并冲突。
提交应用到合并基础的组合更改的结果是一个新的提交。这个新的提交有一个特殊的功能:除了像往常一样保存一个完整的快照,它有两个父提交而不是一个。这两个父母中的第一个是您运行时正在进行的提交,git merge
第二个是另一个提交。也就是说,新的提交I
是一个合并提交:
G--H
/ \
...--D I <-- master (HEAD)
\ /
E--F <-- develop
因为 Git 存储库中的历史是一组提交,所以这会生成一个新的提交,其历史都是两个分支。从I
,Git 可以向后工作到H
和到F
,以及从那些,到G
和E
分别,从那里,到D
。名称master
现在指向I
。名称develop
不变:它继续指向F
。
如果我们愿意,现在可以安全地删除name develop
,因为我们(和 Git)可以F
从 commit 中找到 commit I
。或者,我们可以继续开发,做出更多新的提交:
G--H
/ \
...--D I <-- master
\ /
E--F--J--K--L <-- develop
如果我们现在git checkout master
再次运行git merge develop
, Git 将执行与之前相同的操作:找到一个合并基,运行两个git diff
s,然后提交结果。现在有趣的是,由于提交I
,合并基础不再D
。
你能命名合并基地吗?尝试一下,作为练习:从开始L
并向后工作,列出提交。(记住只能向后走:从F
,你不能到达I
,因为那是错误的方向。你可以向后到达E
,这是正确的方式。)然后从 和 开始并向I
后工作。是您制作的清单中的其中之一吗?如果是这样,那就是新合并的合并基础(即),所以 Git 将在它的两个命令中使用它。F
H
develop
F
git diff
最后,如果合并有效,我们将获得一个新的合并M
提交master
:
G--H
/ \
...--D I--------M <-- master (HEAD)
\ / /
E--F--J--K--L <-- develop
如果我们向 中添加更多提交,未来的合并develop
将L
用作合并基础。
Cherry-picking 使用合并机制——两个差异——有一个奇怪的基础
让我们回到这个状态,并附HEAD
加到master
:
G--H <-- master (HEAD)
/
...--D
\
E--F <-- develop
现在让我们看看 Git 是如何实际实现git cherry-pick develop
.
首先,Git 将名称解析为develop
提交哈希 ID。既然develop
指向F
,那就是 commit F
。
提交F
是一个快照,必须变成一个变更集。Git 使用git diff <hash-of-E> <hash-of-F>
.
此时, Git可以将这些相同的更改应用于H
. 这就是我们的高级、不太准确的描述所声称的:我们只是将这个差异应用到H
. 在大多数情况下,看起来Git 就是这样做的——而且在非常旧的 Git 版本中(没有人再使用),Git 确实做到了。但是在某些情况下它不能正常工作,所以 Git 现在执行了一种奇怪的合并。
在正常的合并中,Git 会找到合并基础并运行两个差异。在樱桃挑选类型的合并中,Git 只是强制合并基础成为被挑选的提交的父级。也就是说,由于我们在挑选樱桃F
,Git 会强制合并基础为 commit E
。
Git 现在确实git diff --find-renames <hash-of-E> <hash-of-H>
可以查看我们更改了什么,并git diff --find-renames <hash-of-E> <hash-of-F>
查看他们(提交F
)更改了什么。然后它结合两组更改并将结果应用到 中的快照E
。这可以保留您的工作(因为无论您更改了什么,您仍然已经更改),同时也添加了更改集F
。
如果一切顺利(通常情况下),Git 会进行一个新的提交,但这个新的提交是一个普通的单父提交,它会继续进行master
。这很像F
,事实上,GitF
也会从其中复制日志消息,所以让我们称之为新提交F'
以记住这一点:
G--H--F' <-- master (HEAD)
/
...--D
\
E--F <-- develop
注意,和以前一样,develop
没有移动。但是,我们也没有进行合并提交:新的F'
不会记录F
自己。该图未合并;和的合并基础仍然是commit 。F'
F
D
因此,这是完整而准确的答案
这是cherry-pick 和真正的merge 之间的全部区别:cherry-pick 使用Git 的合并机制来进行更改组合,但不合并图表,只是复制一些现有的提交。组合中使用的两个变更集基于精选提交的父级,而不是计算的合并基础。新副本有一个新的哈希 ID,与原始提交没有任何关系。从分支名称master
或develop
此处开始找到的历史仍然可以很好地融入过去。对于真正的合并,新的提交是一个双父合并,并且历史被牢固地连接在一起——当然,组合的两组更改git merge
是从计算的合并基础中形成的,因此它们是不同的更改集。
当合并因冲突而失败时
Git 的合并机制,即组合两组不同更改的引擎,有时可以并且确实无法进行合并。当在两个变更集中都尝试更改同一文件的相同行时,就会发生这种情况。
假设 Git 正在合并更改,并且 change-set--ours
说触摸文件 A 的第 17 行、文件 B 的第 30 行和文件 D 的第 3-6 行。同时,change-set--theirs
没有说明文件 A,但确实更改了文件 B 的第 30 行、文件 C 的第 12 行和文件 D 的第 10-15 行。
由于只有我们的文件A,只有他们的文件C,Git可以只使用我们的A版本和他们的C版本。我们都触摸文件D,但我们的文件是3-6行,他们的文件是10-15行,所以Git 可以对文件 D 进行这两种更改。文件 B 是真正的问题:我们都触及了第 30 行。
如果我们对第 30 行进行相同的更改,Git 可以解决这个问题:它只需要一份更改副本。但是如果我们对第 30 行进行不同的更改,Git 将因合并冲突而停止。
此时,Git 的索引(这里我没有讲)就变得至关重要。我将继续不谈论它,只是说 Git 将所有三个版本的冲突文件都保留在其中。同时,还有文件 B 的工作树副本,在工作树文件中,Git 尽最大努力组合更改,使用冲突标记来显示问题所在。
作为运行 Git 的人类,你的工作是以任何你喜欢的方式解决每个冲突。修复了所有冲突后,您可以使用git add
为新提交更新 Git 的索引。然后,您可以运行git merge --continue
或git cherry-pick --continue
,根据导致问题的原因,让 Git 提交结果 - 或者,您可以运行git commit
,这是做同样事情的旧方法。实际上,这些--continue
操作主要只是git commit
为您运行:提交代码检查是否存在应该完成的冲突,如果是,则进行常规(cherry-pick)提交或合并提交。
一种特殊情况:合并为快进
当你运行时,Git 会像往常一样定位合并基础,但有时合并基础非常简单。例如,考虑如下图:git merge othercommit
...--F--G--H <-- develop (HEAD)
\
I--J <-- feature-X
如果您git merge feature-X
现在运行,Git 会通过从提交开始J
并H
执行通常的向后遍历来查找第一个共享提交来找到合并基础。但是第一个共享提交就是提交H
本身,就在develop
指向的地方。
Git 可以进行真正的合并,运行:
git diff --find-renames <hash-of-H> <hash-of-H> # what we changed
git diff --find-renames <hash-of-H> <hash-of-J> # what they changed
你可以强制 Git 这样做,使用git merge --no-ff
. 但显然,将提交与自身进行比较将不会显示任何更改。--ours
两组更改的部分将为空。合并的结果将与 commit 中的快照相同J
,因此如果我们强制进行真正的合并:
...--F--G--H------J' <-- develop (HEAD)
\ /
I--J <-- feature-X
然后J'
和J
也将匹配。它们将是不同的提交——<code>J' 将是一个合并提交,带有我们的名称和日期以及我们喜欢的任何日志消息——但它们的快照将是相同的。
如果我们不强制进行真正的合并,Git 会意识到这一点J'
并且J
会像这样匹配,并且根本不会费心进行新的提交。相反,它“向前滑动 HEAD 所附加的名称”,反对向后指向的内部箭头:
...--F--G--H
\
I--J <-- develop (HEAD), feature-X
(之后在图中绘制扭结没有意义)。这是一个快进操作,或者,在 Git 相当特殊的术语中,一个快进合并(即使没有实际的合并!)。
cherry-pick
只需要一次提交到您当前的分支。
merge
获取整个分支(可能是多个提交)并将其合并到您的分支。
如果将它与它合并,则相同<commit-id>
- 它不仅需要特定的提交,还需要以下提交(如果有的话)。
正如 Beco 博士所说,似乎合并过程本身对于合并和挑选樱桃来说是相同的,尽管他指出基础和其他方面是不同的。我认为有一个论点是合并的执行方式,即合并的规则,对于合并和cherry-pick应该是不同的,我们今年在 XML 布拉格发表了一篇关于此的论文“Merge and Graft: Two Twins That需要成长” http://www.xmlprague.cz/day2-2019/#merge这可能很有趣。