深入理解 git cherry-pick 操作

在前面几篇讲解 Git 进阶用法的文章中,我们已经了解了 Git 的工作原理,以及 rebasemergecheckoutreset 等多种操作的使用场景和用法,基本上在使用 Git 时你已经可以无所畏惧了,你可以自如的修改提交历史,并保证不会丢失任何更改。今天我们来补上最后一环,了解使用场景不多但却能达到奇效的 cherry-pick 命令。

如何使用 git cherry-pick

Git 命令文档的描述不一定直观易懂,但绝对准确,文档对 git cherry-pick 描述是: Apply the changes introduced by some existing commits,即应用某些已有提交所引入的更改。通常我们会说 cherry-pick 是将某个(些)提交从一个分支移动到另一个分支,这种说法更加容易理解,但后面我们会解释为何文档的描述才是最准确的。

假设我们有如下提交:

1
2
3
a - b - c - d   master
         \
           e - f - g   feature

现在我们想把 ef 两个提交移动到 master 分支,首先需要切换到 master :

1
$ git checkout master

cherry-pick 命令的用法简单明了,对需要移动的一个或多个提交执行 cherry-pick 即可,注意这里我们用字母指代实际的提交 SHA-1 ID:

1
$ git cherry-pick f g

执行后的提交历史如下:

1
2
3
a - b - c - d - f' - g'   master
         \
           e - f - g   feature

实际的结果是在 master 分支创建了 f'g' 两个新的提交,它们拥有和 fg 不同的 ID 。

使用场景

从上面的命令解释来看,cherry-pick 实现的效果比较简单,而且和 mergerebase 看上去有所重合,下面我们来看看 cherry-pick 的实际使用场景。

紧急 bug 修复

通常在一个产品的 Git 工作流中,会有至少一个发布分支和开发主分支。当发现一个 bug 时,我们需要尽快向已发布的产品提供修复补丁,同时也要将补丁整合到开发主分支中。

举个例子,比如我们发布了一个版本并已经开始开发一些新的功能,在新功能开发过程中,又发现了一个已经存在的 bug。我们创建了一个紧急修复提交对这个错误进行修复,并在开发主分支进行集成测试。这个新的补丁提交在合入开发主分支后可以直接 cherry-pick 到发布分支,在影响更多用户之前修复这个 bug。过程示意如下:

Jan-26-2021-git-cherry-pick

在上面的动图中,我们在开发主分支 master 添加了一些新的功能提交,修复了一些 bug 并合入了两个 bugfix 分支,然后又将 bugfix 分支中的所有提交 cherry-pick 到了 release 分支。在有些 Git 工作流中情况会有所不同,可能会基于 release 分支创建 bugfix 分支,并在合入 releasecherry-pick 这些提交到 master

从放弃的分支中挑出个别提交

有时因为需求的变化一个功能分支可能会过时,而不会被合并到主分支中。有时,一个 Pull Request 可能会在没有合并的情况下被关闭。我们可以通过 git loggit reflog 等命令,从中找出一些有用的提交,并把它们 cherry-pick 到主分支。

其他场景

还有一些其他的使用场景,比如你在没有意识到的情况下在一个错误的分支上创建了一个提交,你可以使用 cherry-pick 将其移动到正确的分支上去;或者出于某些原因你想将团队成员在另一个分支开发的某个提交拿到你自己的分支,诸如此类。

从以上有限的场景来看,我们使用 rebase 或者 merge 配合 reset 等命令也能实现同样的效果,但是 cherry-pick 的优势在于它足够地简单直接,一条命令就能实现原本需要一系列命令来实现的操作。但我们依然需要谨慎的使用 cherry-pick ,并意识到它的一些危险之处。

深入理解 cherry-pick

假设我们有一个刚刚通过提交 A 添加了 main.py 的代码仓库,main.py 的文件内容如下:

1
2
if __name__ == '__main__':
    print('Hello world')

现在我们创建一个新的 new-feature 分支进行后续的修改:

1
$ git checkout -b new-feature

首先创建一个提交 B,加入一个新的文件 setup.py 并对 main.py 做如下修改:

1
2
3
if __name__ == '__main__':
    print('Hello world')
    print('Git is easy')

接着我们又创建了一个提交 C,加入一个新的文件 README.md 并继续对 main.py 添加一行代码:

1
2
3
4
if __name__ == '__main__':
    print('Hello world')
    print('Git is easy')
    print('But sometimes it can be difficult')

最后我们再切换回一开始的 master ,并对 new-feature 的最新提交 C 执行 cherry-pick

1
2
$ git checkout master
$ git cherry-pick new-feature

执行过程很简单,如下示意:

Jan-26-2021-git-cherry-pick2

但现在我们来猜一猜,现在的 master 有几个文件? main.py 会有几行 print 语句?

正确的答案是:我们会遭遇合并冲突 😝。在解决冲突后,我们会拥有一个修改过的 main.py 文件,以及在提交 C 中新添加的 README.md 文件。

为什么结果会是这样?借此示例,我们来深入探究一番 cherry-pick 实际的执行过程。

应用哪些更改

Git 撤销操作浅析中我们了解到每一个提交都是一份完整的文件快照,但从示例来看,cherry-pick 的过程显然并没有应用目标提交中的所有文件内容(否则当前 master 将包含在提交 B 中加入的 setup.py 文件),而是仅仅影响了目标提交中更改过的文件( README.mdmain.py )。由此可见,「将某个提交从一个分支移动到另一个分支」这一说法并不准确, cherry-pick 只会应用在目标提交中引入的更改,即在该提交中更改过的文件。

如何应用

在确定了 cherry-pick 只会应用目标提交中更改过的文件后,我们来看下应用更改的具体过程。「应用」在内部其实就是像 merge 一样执行了一次三路合并。关于 Git 的三路合并算法我们在 Git 合并操作浅析中已有详细介绍。这次我们换一种方式来表示三路合并中的各方,当我们执行 git cherry-pick <commit C> 时:

  1. LOCAL:在该提交基础上执行合并(即当前所在分支的 HEAD)。
  2. REMOTE:你正在 cherry-pick 的目标提交(即 <commit C>)。
  3. BASE:你要 cherry-pick 的提交的父提交(即 C^,C 的上一次提交),通常为 LOCAL 和 REMOTE 的共同祖先提交(但也可能不是,比如在本示例中)。

执行 cherry-pick 时,就是以 BASE 作为基础,以 LOCAL 和 REMOTE 作为要合并的内容进行三路合并,并将合并的结果作为一个新的提交添加到 LOCAL 之后(算法执行的具体过程不再赘述)。我们可以通过如下方式进行验证:

  1. 首先将示例仓库 Git 的 merge.conflictstyle 更改为 diff3

    1
    
    $ git config merge.conflictstyle diff3
    
  2. 然后重新执行上面的示例步骤,并查看发生合并冲突的 main.py 的文件内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    if __name__ == '__main__':
        print('Hello world')
    <<<<<<< HEAD
    ||||||| parent of 77b3860 (C)
        print('Git is easy')
    =======
        print('Git is easy')
        print('But sometimes it can be difficult')
    >>>>>>> 77b3860 (C)
    

    相比常规的 diff 展示的 LOCAL 和 REMOTE 两方对比,diff3 会通过 ||||||| 多展示一方来自 BASE 的内容。从结果中我们可以确认,BASE 正是提交 C 的父提交(即提交 B)。

处理冲突

cherry-pick 出现冲突时的处理方式与 rebasemerge 一致,我们通过 git status 查看发生冲突的文件,修改这些文件并删除其中的特殊标记,通过 git add 将其标记为冲突已解决,最后 git commit 提交更改。

在解决冲突过程中,我们还可以在解决所有冲突后执行 git cherry-pick --continue 提交所有内容,使用 git cherry-pick --skip 在处理多个提交时跳过此提交,或使用 git cherry-pick --abort 取消 cherry-pick 操作,恢复到执行操作之前的状态。

总结

git cherry-pick 的用途并不广泛,在一些特定场景会很有用,但由于其合并机制有引入意想不到的文件更改的风险,在使用时我们应该谨慎考虑可能发生的结果。

cherry-pick 还有两个容易产生的误解需要澄清:

  • cherry-pick 并不会应用提交所代表的整个文件快照,而是只会影响该在提交中新增、删除或更改的文件。
  • cherry-pick 并不是简单的应用目标提交与其父提交的 diff 内容,而是会在内部以该父提交作为基础在当前分支指向提交和目标提交之间进行一次三路合并,因此有可能发生合并冲突。

参考链接

updatedupdated2022-08-032022-08-03