特定提交的 Git 合并和 Git-cherry-pick 有什么区别



git merge <commit-id>git cherry-pick <commit-id>? 其中"提交 ID"是我想进入主分支的新分支提交的哈希。

除了琐碎的情况外,所有情况都存在巨大差异(即使在琐碎的情况下,仍然存在差异)。 正确理解这一点有点挑战,但是一旦你理解了,你就可以真正理解 Git 本身了。

TL;DR主要是ItayB已经说过的:挑选意味着复制一些现有的提交。 此复制的本质是将提交转换为更改集,然后将相同的更改集重新应用于其他一些现有提交以进行新提交。 新提交与您复制的提交"执行相同的更改",但将该更改应用于其他快照。

此描述既有用又实用,但并非 100% 准确 - 如果您在挑选樱桃期间遇到合并冲突,则无济于事。 正如这些所暗示的那样,樱桃采摘在内部是一种特殊的合并。 如果没有合并冲突,则无需知道这一点。 如果你是,那么最好从正确理解git merge风格合并开始。

合并(如git merge所做的)更复杂:它不会复制任何东西。 相反,它会进行合并类型的新提交,这...好吧,做一些复杂的事情。:-) 如果不首先描述 Git提交图,就无法充分解释它。 它还有两部分,我喜欢称之为,首先,合并为动词(组合更改的操作),其次,类型合并提交,或合并为名词或形容词:Git 称这些为合并或合并提交。

当 cherry-pick 进行合并时,它只做前半部分,合并作为动词动作,而且做得有点奇怪。 如果合并失败并发生冲突,则结果可能会非常令人费解。 它们只能通过了解 Git 如何作为动词过程进行合并来解释。

还有一种 Git 称之为快进操作,或者有时是快进合并,这根本不是合并。 不幸的是,这也令人困惑;让我们推迟一下。

下面的一切都是长答案:如果你想理解(更多)Git 时只读

关于提交需要了解的内容

首先要知道的是(您可能已经知道)Git 主要是关于提交,每个 Git 提交都会保存每个文件的完整快照。 也就是说,Git 的提交不是更改集。 如果您修改一个文件(例如README.md)并使用该文件进行新提交,则新提交将包含每个文件的完整文件,包括修改后的README.md(全文)。 当您使用git showgit log -p检查提交时,Git 将显示您更改的内容,但它通过首先提取上一个提交的已保存文件,然后提取提交的已保存文件,然后比较两个快照来实现。 由于只更改README.md,因此它仅显示README.md,即使这样,也仅显示差异 - 对一个文件的一组更改。

反过来,这意味着每个提交都知道其直接祖先或提交。 在 Git 中,提交有一个固定的、永久的"真实名称",它总是意味着特定的提交。 这个真实名称,或哈希 ID或有时是OID("O"代表对象),是 Git 在输出中打印git log的一大串丑陋的字母和数字。 例如,5d826e972970a784bd7a7bdf587512510097b8c7是 Git 的 Git 存储库中的提交。 这些东西看起来是随机的(尽管它们不是),并且通常对人类没有用,但它们是 Git找到每个提交的方式。 该特定提交有一个父级(其他一些丑陋的大哈希 ID),Git 将父级的哈希保存在提交中,以便 Git 可以使用提交向后查看其父级。

结果是,如果我们有一系列提交,它们会形成一个向后看的链。 我们(或 Git)将从此链的末尾开始,然后向后工作,以查找存储库中的历史记录。 假设我们有一个只有三个提交的小仓库。 而不是它们的实际哈希 ID,它们太大太丑陋而无法打扰,我们称它们为提交ABC,并将它们绘制在它们的父/子关系中:

A <-B <-C

提交C是最新的,所以它是B的孩子。 GitC记住B的哈希 ID,所以我们说C指向B。 当我们做B时,之前只有一个提交,A,所以AB的父级,B指向A。 提交A是一种特殊情况:当我们进行提交时,没有提交。 它没有父级,这就是允许 Git 停止向后追逐的原因。

提交也是完全的,完全的,100%只读的:一旦完成,任何提交都无法更改。 这是因为哈希 ID 实际上是提交完整内容的加密校验和。 在任何地方更改一位,您都会得到一个新的、不同的哈希 ID——一个新的、不同的提交。 因此,提交快照会永久保存文件的状态,或者至少在提交本身继续存在的情况下保存。 (你最初可以认为这是"永远"的;忘记替换提交的机制更先进,当它不是最新的提交时会变得非常棘手。

这种只读质量意味着我们可以更简单地绘制提交字符串:

A--B--C

请记住,链接只能向后进行。 父母不可能知道它的孩子,因为当父母出生时,孩子并不存在,一旦父母出生,它就会永远冻结。 然而,孩子可以知道它的父母,因为孩子是在父母存在并被冻结之后出生的。

关于分支名称的须知事项

在像上面这样的简化图中,很容易分辨出哪个提交是最新的。 毕竟,这封信CB之后,所以C是最新的。 但是 Git 哈希 ID 看起来完全随机,Git需要实际的哈希 ID。 因此,Git 在这里所做的是最新提交的哈希 ID 存储在分支名称中。

事实上,这就是分支名称的定义:像master这样的名称只是存储我们想要为该分支调用最新提交的哈希 ID。 所以给定A--B--C个提交字符串,我们只需添加名称master,指向提交C

A--B--C   <-- master

分支名称的特别之处在于,与提交不同,它们会发生变化。 它们不仅会改变,而且会自动改变。 在 Git 中,创建新提交的过程包括写出提交的内容(其父哈希 ID、作者/提交者信息、保存的快照、日志消息等),该内容计算新提交的新哈希 ID,然后更改分支名称以记录新提交的哈希 ID。 如果我们在master上创建一个新的提交D,Git 通过写出D指向C,然后更新master指向D来实现这一点:

A--B--C--D   <-- master

假设我们现在创建一个新的分支名称develop。 新名称还将指向提交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

在这里,我们在分支develop,因为那是HEAD所依附的那个。 (请注意,所有四个提交都在两个分支上。 如果我们现在做新的提交E,Git 知道要更新哪个名称:

A--B--C--D   <-- master

E   <-- develop (HEAD)

名称HEAD仍然附加到分支;分支名称本身会更改它记住的提交哈希 ID;并且提交E现在是当前提交。 如果我们现在进行新的提交,它的父级将是E,Git 将更新develop. (新的提交E仅在develop上,而提交A-B-C-D仍在两个分支上!

分离的 HEAD只是意味着 Git 已将名称HEAD直接指向某个提交,而不是将其附加到分支名称。 在这种情况下,HEAD仍命名当前提交。 你只是不在任何分支上。 进行新提交仍然像往常一样创建提交,但是 Git 不会将新提交的新哈希 ID 写入分支名称,而是直接将其写入名称HEAD

(分离的HEAD是正常的,但有点特殊;你不会在日常开发中使用它,除非在做一些git rebase操作时。 您主要使用它来检查历史提交 - 那些不在某个分支名称尖端的提交。 我们将在这里忽略它。

提交图和git merge

因此,现在我们知道了提交链接以及分支名称如何指向其分支上的最后一个提交,让我们看看git merge是如何工作的。

假设我们已经在masterdevelop上进行了一些提交,因此我们现在有一个如下所示的图形:

G--H   <-- master
/
...--D

E--F   <-- develop

我们将git checkout master,以便HEAD附加到指向Hmaster,然后运行git merge develop

此时,Git 将向后跟踪两条链。 也就是说,它将从H开始,然后向后工作到G,然后到D。 它也将从F开始,向后工作到E,然后到D。 此时,Git 已经找到了一个共享提交 - 一个位于两个分支上的提交。 所有早期的提交也是共享的,但这是最好的提交,因为它是最接近两个分支提示的提交。

最佳共享提交称为合并基。 所以在这种情况下,Dmaster(H)和develop(F)的合并基础。合并基础提交完全由提交图确定,从当前提交(HEAD=master= 提交H)开始,从您在命令行上命名的另一个提交开始(develop= 提交F)。 在此过程中,分支名称的唯一用途是定位提交 - 之后的所有内容都取决于图形。

找到合并基础后,git merge现在要做的就是合并更改。 但请记住,我们说过提交是快照,而不是更改集。 因此,要查找更改,Git 必须首先将合并基提交本身提取到临时区域中。

现在 Git 已经提取了合并库,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 可以向后工作到HF,从这些开始,分别到GE,然后从那里到Dmaster的名称现在指向Idevelop的名称保持不变:它继续指向F.

如果我们愿意,现在可以安全地删除名称develop,因为我们(和 Git)可以从提交I中找到提交F。 或者,我们可以继续开发它,进行更多新的提交:

G--H
/    
...--D      I   <-- master
    /
E--F--J--K--L   <-- develop

如果我们现在再次git checkout master再次运行git merge develop,Git 将执行与之前相同的操作:找到一个合并库,运行两个git diff,然后提交结果。 现在有趣的是,由于提交I,合并库不再D

你能说出合并基础吗? 尝试一下,作为一个练习:从L开始,然后向后工作,列出提交。 (记住只能倒退:F,你不能到达I,因为那是错误的方向。你可以E,这是正确的方法,倒过来。 然后从I开始,向后工作到FH。 您为develop制作的列表中是其中一个吗? 如果是这样,那就是新合并的合并基础(即F),因此 Git 将在其两个git diff命令中使用它。

最后,如果合并有效,我们将在master上获得一个新的合并提交M

G--H
/    
...--D      I--------M   <-- master (HEAD)
    /        /
E--F--J--K--L   <-- develop

未来的合并,如果我们向develop添加更多提交,将使用L作为合并基础。

樱桃采摘使用合并机制 - 两个差异 - 带有奇怪的基础

让我们回到这个状态,并将HEAD附加到master

G--H   <-- master (HEAD)
/
...--D

E--F   <-- develop

现在让我们看看 Git 实际上是如何实现git cherry-pick develop的。

首先,Git 将名称develop解析为提交哈希 ID。 由于develop指向F,这就是提交F

提交F是一个快照,必须转换为更改集。 Git 用git diff <hash-of-E> <hash-of-F>.

此时,Git可以将这些相同的更改应用于H中的快照。 这就是我们高层次的、不太准确的描述所声称的:我们只是采用这个差异并将其应用于H。 在大多数情况下,发生的事情看起来就像 Git 就是这样做的——而在非常旧的 Git 版本中(没有人再使用),Git确实做到了。 但是在某些情况下它不能正常工作,所以 Git 现在执行一种奇怪的合并。

在正常的合并中,Git 会找到合并基础并运行两个差异。 在樱桃拣选类型的合并中,Git 只是强制合并基成为被挑选的提交的父级。 也就是说,由于我们正在挑选F,Git 强制合并库提交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,事实上,Git 也从F复制日志消息,所以让我们调用这个新的提交F'来记住:

G--H--F'   <-- master (HEAD)
/
...--D

E--F   <-- develop

请注意,就像以前一样,develop没有移动。 但是,我们也没有进行合并提交:F'不会记录F本身。 图形合并;F'F合并基础仍然是 commitD

因此,这是完整而准确的答案

这就是 cherry-pick 和真正合并之间的全部区别:cherry-pick 使用 Git 的合并机制来执行更改组合,但保持图形不合并,只是复制一些现有的提交。 合并中使用的两个更改集基于精心挑选的提交的父级,而不是计算的合并基础。 新副本具有新的哈希 ID,与原始提交没有任何明显关系。 从分支名称开始发现的历史,masterdevelop在这里,仍然与过去相连。 对于真正的合并,新提交是双父合并,并且历史记录牢固连接 — 当然,git merge合并的两组更改是由计算的合并基础形成的,因此它们是不同的更改集。

合并失败并发生冲突时

Git 的合并机制,即组合两组不同更改的引擎,有时可以而且确实无法进行组合。 当两个更改集中都尝试更改同一文件的相同行时,会发生这种情况。

假设 Git 正在合并更改,并且更改集--ours表示文件A 的触摸行 17、文件 B 的第 30 行和文件 D 的第 3-6 行。 同时,更改集--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 --continuegit cherry-pick --continue,具体取决于导致问题的原因,让 Git 提交结果 - 或者,您可以运行git commit,这是执行相同操作的旧方法。 事实上,--continue操作主要只是为您运行git commit:提交代码检查是否存在应该完成的冲突,如果是,则进行常规(精选)提交或合并提交。

特例:合并为快进

当你运行git mergeothercommit时,Git 像往常一样定位合并基,但有时合并基是相当微不足道的。 例如,考虑这样的图表:

...--F--G--H   <-- develop (HEAD)

I--J   <-- feature-X

如果现在运行git merge feature-X,Git 会通过从提交JH开始,然后执行通常的向后行走以查找第一个共享提交来查找合并基。 但是第一个共享提交是提交本身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部分将为空。 合并的结果将与提交J中的快照相同,因此,如果我们强制进行真正的合并:

...--F--G--H------J'   <-- develop (HEAD)
    /
I--J   <-- feature-X

然后J'J也将匹配。 它们将是不同的提交 -J'将是一个合并提交,带有我们的名字和日期以及我们喜欢的任何日志消息 - 但它们的快照将是相同的。

如果我们强制进行真正的合并,Git 就会意识到J'J会像这样匹配,并且根本不会费心进行新的提交。 相反,它"向前滑动 HEAD 所附加到的名称",反对向后指向的内部箭头:

...--F--G--H

I--J   <-- develop (HEAD), feature-X

(之后,在图表中绘制扭结就没有意义了)。 这是一个快进操作,或者用 Git 相当奇特的术语来说,是一个快进合并(即使没有实际的合并!

cherry-pick只在当前分支中进行一次提交。merge采用整个分支(可能是多个提交)并将其合并到分支中。

如果您将其与<commit-id>合并,则相同 - 它不仅需要特定的提交,还需要下面的提交(如果有的话)。

正如Beco博士所说,合并过程本身对于合并和挑选似乎是相同的,尽管他指出基础和其他方面是不同的。我认为有一种观点认为,合并的执行方式,即合并的规则,对于合并和挑选应该不同,我们今年在XML布拉格上发表了一篇关于这个问题的论文"合并和嫁接:两个需要分开的双胞胎"http://www.xmlprague.cz/day2-2019/#merge 可能会感兴趣。

相关内容

  • 没有找到相关文章

最新更新