本文希望教你如何成为一个 git cherry-pick 的 “master”!通过使用 git cherry-pick 来轻松地维护多个分支版本,再也不会让 multi-version maintaining 成为你心头上的那把令你屡次痛心的剑了!

本文所有内容都会基于以下(精心构造的)示例,该例子涵盖了大部分工程上容易出现的 Git log pattern(如有其他 corner-case ,欢迎联系我,一起努力让该文变得对大家更有帮助)。

贯穿本文的示例场景,这是该示例基于时间序的 Git 提交历史。

$ git --no-pager log --oneline --graph --date-order
* f2c1619 (HEAD -> red) R6
*   e6899ea R5 merge branch 'blue' into 'red'
|\
* \   0979d45 R4 merge branch 'green' into 'red'
|\ \
| | * 186da41 (blue) B3
| * | c950910 (green) G3
* | | 17e2629 R3
| | * 69edfc9 B2
| * | 059425a G2
| * | 05719c8 G1
| | * ebb218d B1
| |/
* / 8c6595b R2
|/
* 6581ff8 R1
* 2787f8f (master) init commit
快速创建该示例。
mkdir cherry-pick; cd cherry-pick/
git init
echo "init" >> init; git add -A; git commit -m "init commit"; sleep 1
git checkout -b red
echo "red" >> red; git add -A; git commit -m "R1"; sleep 1
git branch green
git branch blue
echo "red" >> red; git add -A; git commit -m "R2"; sleep 1
git checkout blue
echo "blue" >> blue; git add -A; git commit -m "B1"; sleep 1
git checkout green
echo "green" >> green; git add -A; git commit -m "G1"; sleep 1
echo "green" >> green; git add -A; git commit -m "G2"; sleep 1
git checkout blue
echo "blue" >> blue; git add -A; git commit -m "B2"; sleep 1
git checkout red
echo "red" >> red; git add -A; git commit -m "R3"; sleep 1
git checkout green
echo "green" >> green; git add -A; git commit -m "G3"; sleep 1
git checkout blue
echo "blue" >> blue; git add -A; git commit -m "B3"; sleep 1
git checkout red
git merge green -m "R4 merge branch 'green' into 'red'"; sleep 1
git merge blue -m "R5 merge branch 'blue' into 'red'"; sleep 1
echo "red" >> red; git add -A; git commit -m "R6"; sleep 1

git --no-pager log --oneline --graph --date-order

当前 Git 提交历史示意图如下。

提交记录示意图
提交记录示意图

Git cherry-pick 的命令的基本原理是根据用户所选择的提交,根据提交中的差异信息(diff)将这些提交移植至用户目标版本中。如将 hotfix 应用至其他 LTS 版本中是该功能的一个典型应用。

git cherry-pick 的大致用法为:

git cherry-pick [options] <commit>...

此处的 <commit>... 即为用户希望移植的提交(集合),这是本文讨论的要点。

<commit> 可以为单一提交(commit),也可以为一个版本区间(revision range)。若为 revision range,则该命令会将该 revision range 中的所有 commit 都解析出来,最终成为一连串的单一 commit 1cherry-pick 可以同时接受多个 <commit> ,此时表现类似于 git rev-list 中的 --no-walk 行为2

那我们依次来讨论 <commit>... 为单一 commit 以及 revision range 的情况。

Single commit

Normal commit

回到上文的例子,如果仅需要将 G2 选取出来,我们可以这样操作。

# 回到 master 新建一条分支用于测试
$ git checkout master
$ git checkout -b cp-single-normal-commit
# G2 的提交 SHA 值为 059425a
$ git cherry-pick 059425a

此时会出现合并冲突(merge conflict),输出如下所示。

CONFLICT (modify/delete): green deleted in HEAD and modified in 059425a (G2). Version 059425a (G2) of green left in tree.
error: could not apply 059425a... G2
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'

这段内容告知我们这些信息:green 文件在当前(暂存)版本 HEAD 中并不存在,但在选取的 G2 提交中存在。如果需要该文件,则使用 git add 将其提交至暂存区,若希望保留当前暂存版本的状态,即删除该文件,则使用 git rmgreen 文件舍弃。

我们希望在选取 G2 之后能够保留 green 文件,故采取如下操作。

# 将 green 提交至暂存区
$ git add green
# 已修复所有合并冲突,继续进行 cherry-pick
$ git cherry-pick --continue

此时 cherry-pick 操作已经完成,如果继续执行 git cherry-pick --continue ,则此时会显示 error: no cherry-pick or revert in progress ,即当前没有进行任何 cherry-pick 任务。

查看一下当前的提交记录,则会发现 G2 已经在我们当前的分支 cp-single-normal-commit 上了。

$ git --no-pager log --oneline --graph --date-order
* 0457362 (HEAD -> cp-single-normal-commit) G2
* 2787f8f (master) init commit

Merge commit

那如果我们想选取一个 merge commit 呢,比如将 R4 选取出来。

# 回到 master 新建一条分支用于测试
$ git checkout master
$ git checkout -b cp-single-merge-commit
# R4 的提交 SHA 值为 0979d45
$ git cherry-pick 0979d45

当执行完这条 cherry-pick 命令之后,你会得到以下输出。

error: commit 0979d45f1b46f72730188c5c01b3f2c7f41b18e6 is a merge but no -m option was given.
fatal: cherry-pick failed

默认情况下,cherry-pick 不处理 merge commit 并直接报错。因为在 merge commit 中,会有多个 parent 信息,但此时 Git 并不知道该使用哪个 parent 作为 mainline。在错误信息中,也同时提示了我们,如果要选取 merge commit ,则需要使用 -m (亦为 --mainline)选项来指定哪个 parent 是主线3

通过 git show 命令可以获得 merge commit 的多个 parent,且从 1 开始编号。由于该例中我们需要选取的 mainline parent 是 R3(17e2629) ,因此在 cherry-pick 中选择的是 -m 1

$ git --no-pager show 0979d45
commit 0979d45f1b46f72730188c5c01b3f2c7f41b18e6
Merge: 17e2629 c950910
Author: Triple-Z <me@triplez.cn>
Date:   Thu Mar 31 01:29:31 2022 +0800

    R4 merge branch 'green' into 'red'

让我们再来试一次。

$ git cherry-pick -m 1 0979d45

cherry-pick 圆满完成!此时再看一下当前的提交记录,则发现在 cp-single-merge-commit 分支上产生了一个新的 R4 提交。

$ git --no-pager log --oneline --graph --date-order
* 987aba7 (HEAD -> cp-single-merge-commit) R4 merge branch 'green' into 'red'
* 2787f8f (master) init commit

现在我们再来讲讲刚刚的 -m 1 发生了什么。如果现在去看 cp-single-merge-commit 这个测试分支上的文件,则会发现有 green ,而没有 red

$ ls -lh
total 16
-rw-r--r--  1 triplez  staff    18B  4  7 19:06 green
-rw-r--r--  1 triplez  staff     5B  3 31 01:29 init

这是因为我们在选取 merge commit 时,使用的是 mainline 1 ,即 red 分支。因此 cherry-pick 事实是以 red 为基础,寻找 mainline 2 green 分支与 red 的差异,选取的就是 green 分支上所做的修改了。

Revision range

Git 中可用多种方法来表示 revision (版本,或修订快照)4,这里我们主要讨论 revision range(版本区间)5

对于 revision range,有以下六种表示法:

  1. ^<rev> :(脱字符-表示法)表示排除 <rev> 以及它所有可到达的父辈 commit。

  2. <r1>..<r2>(两点-范围表示法):等同于 ^r1 r2 ,即 包含 <r2> 以及其可到达的父辈 commit ,并排除 <r1> 以及其可到达的父辈 commit。

    如果需要包括 <r1>,可使用这种写法:<r1>^..<r2>

  3. <r1>...<r2> (三点-对称差分表示法):包含所有 <r1> <r2> 及其可到达的父辈 commit,并排除 <r1> <r2> 两者可到达的共同父辈 commit。

  4. <rev>^@包含 <rev> 的所有父辈,但排除 <rev> 本身。

  5. <rev>^!包含 <rev> 本身,但排除 <rev> 所有父辈。即表示单个 <rev> commit。

    注意: <rev> (表示 <rev> 及其所有父辈)在 revision range 的语境中不同于 <rev>^! 。仅有指定 --no-walk 参数时,两者才可以认为是相同的(都仅表示 <rev> 本身)。

  6. <rev>^-[<n>]包含 <rev> 及其所有父辈,但排除 <rev> 的第 <n> 个 parent 及其可到达的所有父辈。 <n> 的缺省值为 1。

看起来很复杂,我们来用文中的场景来举两个范围表示法的例子。

首先,考虑 <r1><r2> 都在同一分支上的情况,如 G1 (05719c8)G3 (c950910)

# 回到 master 新建一条分支用于测试
$ git checkout master
$ git checkout -b cp-range-same-branch
# G1 的提交 SHA 值为 05719c8,G3 的 SHA 值为 c950910
$ git cherry-pick 05719c8^..c950910

05719c8(G1)^..c950910(G3) 的含义应当是:

  • 包含 G3 及其所有父辈。
  • 排除 G1 的所有父辈(不排除 G1)。

因此结果应当是选出从 G1G3 的所有提交,示意图如下,黄色为被包含的节点,灰色则代表被排除的节点。

git cheery-pick G1^..G3
git cheery-pick G1^..G3

让我们再看看当前的提交记录。Bingo! G1G2G3 这三个提交已经被选取出来了。

$ git --no-pager log --oneline --graph --date-order
* 32eac39 (HEAD -> cp-range-same-branch) G3
* d3b1130 G2
* c82c4c7 G1
* 2787f8f (master) init commit

<r1><r2> 在不同分支上,是什么情况呢?

我们以 G1 (05719c8)B2 (69edfc9) 作为用例。

# 回到 master 新建一条分支用于测试
$ git checkout master
$ git checkout -b cp-range-diff-branch
# G1 的提交 SHA 值为 05719c8,B2 的 SHA 值为 69edfc9
$ git cherry-pick 05719c8^..69edfc9

05719c8(G1)^..69edfc9(B2) 的含义应当是:

  • 包含 B2 及其所有父辈。
  • 排除 G1 的所有父辈(不排除 G1)。

由于 B2 及其所有父辈中,并不包括 G1。因此我们可以将 G1^..B2 理解为包含 B2 及其所有父辈,且排除 B2G1 的共同父辈后的结果。自然就只剩下 B1B2 两个 commit 了。示意图如下,黄色为被包含的节点,灰色则代表被排除的节点。

git cherry-pick G1^..B2
git cherry-pick G1^..B2

让我们再看看当前的提交记录,确实是只选择了 B1B2 两个提交。

$ git --no-pager log --oneline --graph --date-order
* e63f214 (HEAD -> cp-range-diff-branch) B2
* aed6717 B1
* 2787f8f (master) init commit

Rerere

Rerere 是“重用已记录的冲突解决方案(reuse recorded resolution)”,它是一种简化冲突解决的方法6 7

如果你经常进行大量的 merge, rebase 或 cherry-pick,或在维护一个长期不同于主干的分支8,那么非常建议开启 rerere 功能。

开启 rerere 非常简单,仅需要进行一次全局配置即可。

$ git config --global rerere.enabled true

在本地仓库中直接创建 .git/rr-cache 文件夹,也可以为该仓库开启 rerere

What’s next

在笔者撰写该文的过程中,也看到了 Microsoft 的 Raymond Chen 写的 Stop cherry-picking, start merging 系列文章,他在其中提及了许多工程实践中 cherry-pick 可能导致的 pitfall。接下来的时间里,笔者将会逐一阅读该系列文章,并根据文中案例去分析 cherry-pick 是否能够在常用软件开发工作流给我们带来足够的收益,以及,是否应该 stop cherry-picking, start merging。

只有在深入了解工具后,我们才能更好地运用工具,真正实现效率提升。


知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。