特定提交的 Git-merge 和 Git-cherry-pick 之间有什么区别?

what is the difference between a Git-merge and Git-cherry-pick for a specific commit?

a 和 : 之间有区别吗? git merge <commit-id>git cherry-pick <commit-id> ? 其中''commit-id''是我想进入主分支的新分支提交的哈希。

cherry-pick 只对当前分支进行一次提交。 merge 获取整个分支(可能是多个提交)并将其合并到您的分支。

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

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

TL;DR 主要是 : cherry-pick 意味着 copy 一些现有的提交。这种复制的本质是将提交变成 change-set,然后 re-apply 将相同的 change-set 转换为其他现有提交以进行新提交。新提交 "does the same change" 作为您复制的提交,但将该更改应用于不同的快照。

此描述很有用且实用,但并非 100% 准确 — 如果您在 cherry-pick 期间遇到 合并冲突,则无济于事。正如那些暗示的那样,cherry-pick 在内部实现为一种特殊的合并。如果没有合并冲突,则不需要知道这一点。如果您是,那么最好从正确理解 git merge 样式合并开始。

合并(由 git merge 完成)更复杂:它不会复制任何东西。相反,它会进行类型为 merge 的新提交,这……好吧,做了一些复杂的事情。 :-) 如果不先描述 Git 提交图 ,就无法对其进行充分解释。它也有两个部分,我喜欢称之为,第一,merge as a verb(组合变化的动作),第二,commit-of-type-merge,或者 merge 作为名词或形容词: Git 称这些 a mergea merge commit

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

还有一些东西 Git 调用 fast-forward 操作,或者有时 fast-forward merge,这根本不是合并。不幸的是,这也令人困惑;让我们暂且搁置。

下面的所有内容都是长答案:只有想了解(更多)才能阅读Git

关于提交的知识

首先要知道的是——您可能已经知道了——Git 主要是关于提交,每个 Git 提交都会保存 every[=477= 的完整快照] 文件。也就是说,Git 的提交是 而不是 change-set。如果你修改一个文件——比如说,README.md——并用它进行新的提交,新的提交有每个文件,完整的,包括(的全文)修改 README.md。当您 检查 提交时,使用 git showgit log -p,Git 将显示您更改的内容,但它通过提取 previous先commit保存的文件,然后commit保存的文件,然后比较两个快照。由于只有 README.md 改变了 ,它只会向您显示 README.md,即便如此,也只会向您显示差异 - 对一个文件的一组更改。

反过来,这意味着每个提交都知道其直接祖先,或 parent 提交。 Git 中的提交有一个固定的、永久的 "true name",它始终表示 那个特定的提交 。这个真名,或者 hash ID 或者有时 OID ("O" 代表 Object),就是大丑Git 在 git log 输出中打印的一串字母和 digits。例如,5d826e972970a784bd7a7bdf587512510097b8c7 是 Git 存储库中 Git 的提交。这些东西看起来 运行dom(尽管它们不是),并且通常对人类没有用,但它们是 Git 查找 每次提交的方式。该特定提交有一个 parent——其他一些又大又丑的哈希 ID——并且 Git 将 parent 的哈希保存在提交中,以便 Git 可以使用提交来回头看看它的parent.

结果是,如果我们有一系列提交,它们会形成一个 backwards-looking 链。我们——或 Git——将从这条链的 开始,并向后工作,以在存储库中找到历史记录。假设我们有一个只有三个提交的小型存储库。我们将它们称为提交 ABC,并将它们绘制在它们的 parent/child 中,而不是它们的实际哈希 ID,它们又大又难看。关系:

A <-B <-C

提交 C 是最新的,因此它是 B 的 child。 Git 有 C 记住 B 的哈希 ID,所以我们说 C 指向 B。当我们做 B 时,只有一个先前的提交,A,所以 AB 的 parent 而 B 指向 A.提交 A 是一种特殊情况:当我们提交时,有 没有 提交。它没有parent,这就是让Git停止向后追逐的原因。

提交也完全、完全、100%read-only:一旦提交,任何提交都不能更改。这是因为哈希 ID 实际上是提交完整内容的加密校验和。在任何地方改变一点,你就会得到一个新的、不同的哈希 ID——一个新的、不同的提交。因此,提交快照会永远保存文件的状态——或者至少,只要提交本身继续存在。 (您最初可以将其视为 "forever";忘记替换 提交的机制更高级,并且变得非常棘手当它不是 最新的 提交时。)

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

A--B--C

请记住,link时代只有一个方向,倒退。 parent无法知道它的child人,因为parent出生时child人不存在,parent一旦出生,它一直被冻结。 child可以知道它的parent,因为child是在parent存在并被冻结之后诞生的。

关于 b运行ch 名称的知识

在像上面这样的简化图中,很容易分辨出哪个提交是最新的。字母 C 毕竟在 B 之后,所以 C 是最新的。但是 Git 哈希 ID 看起来完全是 运行dom,而 Git 需要 实际的哈希 ID。所以 Git 在这里做的是 store latest 提交的哈希 ID 在 b运行通道名称.

事实上,这正是 b运行ch 名称的定义:像 master 这样的名称只是存储了我们要调用 latest 的提交的哈希 ID对于那个 b运行ch。因此,给定 A--B--C 提交字符串,我们只需添加名称 master,指向提交 C:

A--B--C   <-- master

b运行ch 名称的特别之处在于,与提交不同,它们更改。它们不仅会改变,而且会自动改变。在 Git 中进行 new 提交的过程包括写出提交的内容——它的 parent 哈希 ID,author/committer 信息,保存快照、日志消息等——计算新提交的新哈希 ID,然后 更改 b运行ch 名称 以记录新提交的哈希 ID。如果我们在 master 上创建一个新的提交 D,Git 通过写出 D 指向 C,然后将 master 更新为指向 D:

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

假设我们现在创建一个newb运行ch名称,develop。新名称将 指向提交 D:

A--B--C--D   <-- develop, master

现在让我们做一个新的提交 E,其 parent 将是 D:

A--B--C--D
          \
           E

哪个 b运行ch name 应该 Git 更新?我们是希望 master 指向 E,还是希望 develop 指向 E?这个问题的答案在于特殊名称HEAD.

Git 的 HEAD 记住了 b运行ch,因此当前的提交

记住我们希望Git更新哪个b运行ch,以及我们有哪个提交checked-out right now, Git 有一个特殊的名字 HEAD, 全部用大写字母拼写成这样。 (由于一个怪癖,小写在 Windows 和 MacOS 上有效,但在不具有此怪癖的 Linux/Unix 系统上不起作用,因此最好使用 all-uppercase 拼写。如果你不喜欢打字,可以用符号@,这是同义词。)通常,Git 附加名字HEAD到一个 b运行ch name:

A--B--C--D   <-- develop (HEAD), master

在这里,我们在 b运行ch develop 上,因为那是 HEAD 所附加的。 (请注意,所有四个提交都在 both b运行ches 上。)如果我们现在进行新提交 E,Git 知道要更新哪个名称:

A--B--C--D   <-- master
          \
           E   <-- develop (HEAD)

名称 HEAD 仍然附加到 b运行ch; b运行ch 名称本身会更改它记住的提交哈希 ID;并且提交 E 现在是 当前提交 。如果我们现在进行新的提交,它的 parent 将是 E,并且 Git 将更新 develop。 (新提交 E 仅在 develop,而提交 A-B-C-D 仍在 both b运行切!)

A detached HEAD 只是意味着 Git 已经将名称 HEAD 指向 直接指向某个提交 而不是将其附加到 b运行ch 名称。在这种情况下,HEAD 仍然命名当前提交。你只是不在 any b运行ch。进行新提交仍会像往常一样创建提交,但随后 Git 不会将新提交的新哈希 ID 写入 b运行ch 名称,而是直接将其写入名称 HEAD

(Detached HEAD 是正常的,但有点 special-case;你不会在日常开发中使用它,除非在做一些 git rebase 操作时。你主要用它来检查历史提交—那些不在某个 b运行ch 名字的顶端。我们将此处忽略。)

提交图,以及git merge

现在我们知道如何提交 link 以及 b运行ch 名称如何指向其 b运行 上的 last 提交ch,让我们看看git merge是如何工作的。

假设我们在 masterdevelop 上都做了一些提交,所以我们现在有一个看起来像这样的图表:

       G--H   <-- master
      /
...--D
      \
       E--F   <-- develop

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

此时,

Git 将向后跟随 both 链。也就是说,它将从 H 开始并向后工作到 G,然后到 D。它还将从 F 开始,向后工作到 E,然后到 D。此时,Git 找到了一个 共享提交 ——一个在 both b运行 上的提交。所有较早的提交也被共享,但这是 最好的,因为它是最接近 b运行ch 提示的提交。

最佳共享提交 称为合并基础。所以在这种情况下,Dmaster (H) 和 develop (F) 的合并基础。 合并基础提交完全由提交图决定, 从当前提交(HEAD = master = 提交 H)和您在命令行上命名的其他提交(develop = 提交 F)。 b运行ch names 在此过程中的唯一用途是定位提交——之后的一切都取决于图表。

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

现在 Git 已经提取了合并基础,git diff 将找到 我们 更改的内容,在 master 上: D 中的快照和 HEAD 中的快照 (H)。那是第一个change-set.

Git 现在必须 运行 一秒钟 git diff,才能找到 他们 更改的内容,在 develop 上: D 中的快照与 F 中的快照之间的差异。那是第二个change-set.

因此,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 认为它有效。一会儿回来合并冲突

提交应用于合并基础的组合更改的结果是一个新的提交。这个新的提交有一个特殊的功能:除了像往常一样保存完整快照外,它没有一个而是 两个 parent 提交。这两个 parent 的 first 是您在 运行 git merge 时所在的提交,第二个是另一个提交。也就是说,新的提交 I 是一个 merge commit:

       G--H
      /    \
...--D      I   <-- master (HEAD)
      \    /
       E--F   <-- develop

因为 Git 存储库 中的历史记录是 提交集,这使得一个新提交的历史记录是 both b运行切。从 I、Git 可以倒推到 HF,再从这些倒推到 GE,然后从那里到 D。名称 master 现在指向 I。名称 develop 没有改变:它继续指向 F.

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

       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 制作的清单中的其中之一吗?如果是这样,那就是新合并的 merge base(即 F),因此 Git 将把它用于它的两个 git diff 命令。

最后,如果合并有效,我们将在 master:

上获得新的合并提交 M
       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,因此提交 F

提交 F 是快照,必须变成 change-set。 Git 用 git diff <hash-of-E> <hash-of-F> 做这个。

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

在正常的合并中,Git 会找到合并基础和 运行 两个差异。在 cherry-pick 类型的合并中,Git 只是 强制 合并基础成为被 cherry-pick 提交的 parent。也就是说,由于我们 cherry-picking 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 添加 change-set。

如果一切顺利,Git 会进行新的提交,但这个新提交是一个普通的 single-parent 提交 master。它很像 F,事实上,Git 也从 F 复制日志消息,所以让我们将这个新提交称为 F' 以记住:

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

请注意,与之前一样,develop 没有移动。但是,我们还没有进行 merge 提交: 新的 F' 不会记录 F 本身。该图合并; F'Fmerge base 仍然是提交 D.

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

这是 cherry-pick 和真正合并之间的全部区别:cherry-pick 使用 Git 的合并机制来执行 change-combining,但留下graph 未合并,只是复制一些现有的提交。合并中使用的两个 change-set 是基于 cherry-picked 提交的 parent,而不是计算的合并基础。新副本有一个新的哈希 ID,与原始提交没有任何明显的关联。从 b运行ch 名称、masterdevelop 开始找到的历史,仍然很好地连接到过去。对于真正的合并,新的提交是 two-parent 合并,并且历史被牢固地连接在一起——当然,git merge 合并的两组更改是从计算的合并基础中形成的,所以它们不同 change-sets.

当合并因冲突而失败时

Git 的合并机制,即结合两组不同更改的引擎,有时可以而且确实无法进行合并。当两个 change-set 都试图更改 相同文件 .[=219= 的 相同 行时,就会发生这种情况]

假设Git正在组合变化,change-set--ours触摸文件A的第17行,文件B的第30行,以及第3-6行文件 D。同时 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的index(这里我一直没有讲)就变得至关重要了。我将继续不谈论它,只是说 Git 将 冲突文件的所有三个版本留在其中 。同时,还有文件 B 的 work-tree 副本,在 work-tree 文件中,Git 尽最大努力合并更改,使用冲突标记来显示问题所在。

作为人类 运行宁 Git,您的工作是以您喜欢的任何方式解决每个冲突。解决所有冲突后,您可以使用 git add 更新 Git 的新提交的索引。然后您可以 运行 git merge --continuegit cherry-pick --continue,根据问题的原因,让 Git 提交结果——或者,您可以 运行 git commit,这是做同样事情的老方法。事实上,--continue 操作主要只是 运行 git commit 对你来说:提交代码检查是否存在应该完成的冲突,如果是,则生成一个常规 ( cherry-pick) 提交或合并提交。

特例:合并为fast-forward

当您 运行 git 合并 <em>othercommit</em> 时,Git 将合并基础定位为通常,但有时合并基础非常微不足道。例如,考虑这样的图表:

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

如果您现在 运行 git merge feature-X,Git 通过从提交 JH 开始并执行通常的 [=637] 来找到合并基础=] 找到第一个 shared 提交。但是第一个共享提交是提交 H 本身,就在 develop 点的位置。

Git可以进行真正的合并,运行ning:

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将像这样匹配,并且根本懒得做新的提交。相反,它 "slides the name to which HEAD is attached forwards",反对 backwards-pointing 内部箭头:

...--F--G--H
            \
             I--J   <-- develop (HEAD), feature-X

(之后就没有必要在图中绘制扭结)。那是一个 fast-forward 操作 或者,在 Git 相当奇特的术语中,一个 fast-forward 合并 (即使没有真正的合并!)。

正如 Beco 博士所说,合并过程本身对于合并和 cherry-pick 来说是相同的,尽管他指出基础和其他方面是不同的。我认为有一种观点认为合并的执行方式,即合并的规则,对于合并和 cherry-pick 应该不同,我们今年在 XML 布拉格提交了一篇关于此的论文 "Merge and Graft: Two Twins That Need To Grow Apart" http://www.xmlprague.cz/day2-2019/#merge 这可能是你感兴趣的。