Contents

Git多产品线代码同步 - 工具

在工作中,我们常常需要在不同分支间同步代码,经常用到 cherry‑pick、rebase 等命令。本文将讨论这些命令的基本原理,展示在多产品线环境下保持提交历史线性的意义,并介绍一些常用的保持提交原子性的命令

首先需要强调在Git中,每个提交存储的是完整的文件树快照,而不是仅仅记录与上一个提交之间的diff1。所以在说提交或分支时其实指的是对应的提交下的所有文件,而不是diff。

在每条产品线对应一个Git分支的情况下,不同分支会包含该产品线的特定功能。直接使用git merge会将其他产品线的所有特性合并到当前分支,这往往并非我们所需。为此,我们需要挑选特定的提交,将其应用到当前分支,实现精确的功能同步。下面将介绍mergecherry-pickrebase常用同步代码命令的各自特点和基本原理。

merge命令2可以将当前分支和目标分支进行合并,预期是将目标分支所有功能引入到当前分支。

合并时如果Git只是简单的将两个分支的文件进行两路合并,那么假如在目标分支的某个文件中增加了一行代码而当前分支没有该行代码,在两路合并时Git无法分辨是目标分支开发时增加了该行代码还是当前分支开发时删除了这行代码导致的区别,只能交给用户去处理。

但是如果引入基准分支进行比较,当前和目标分支都是基于基准分支开发,那么上述情况就可以分辨出来。

假如基准分支包含该行代码则表示是当前分支开发时删除了该行代码,该行代码就应该在结果中被删除;假如基准分支不包含该行代码则表示目标分支开发时增加了该行代码,该行代码就应该在结果中被增加。merge命令使用的基准分支就是当前分支和目标分支的公共祖先

根据上述的思想,三路合并冲突解决规则是:

  1. 如果目标或当前分支中的一方与另外两方有差异,则结果是保留差异。
  2. 如果目标和当前分支修改的内容一致与基准分支不同,则结果为修改后的内容。
  3. 如果目标、当前、基准分支三者内容都不一致,则出现冲突又用户处理。

三路合并规则例子如下图示例3。其中Base为基准分支,Ours为当前分支,Theirs为目标分支,Result为最终结果。

三路合并
三路合并例子

至于如何通过算法匹配到两方或者多方文件中相同部分和不同部分,涉及到最长子串匹配等算法,这里不做深入研究。

cherry-pick命令4可以用来将某个提交的修改应用到当前分支上,并且提交历史保持线性。通过将基准分支设置为目标提交的父提交来进行三路合并来实现期望功能。

下面通过例子来说明其工作的原理。

首先,在main分支中添加data.txt文件作为初始化init提交,然后在此基础上连续4次提交,每次在data.txt增加feat $i一行内容,并且用feat $i作为commit msgdata.txt文件内容如下:

txt

feat 1
feat 2
feat 3
feat 4

test分支在init提交基础上增加一个包含两行内容的提交feat 1 and feat 3data.txt文件内容如下:

txt

feat 1
feat 3

提交的历史图如下:

---

title: 提交历史(cherry-pick)
---

    gitGraph
       commit id: "init"
       branch test
       checkout test
       commit id: "feat 1 and feat 3"
       checkout main
       commit id: "feat 1"
       commit id: "feat 2"
       commit id: "feat 3"
       commit id: "feat 4"

此时,在test分支上可以使用git cherry-pickmain分支上的提交feat 4,就可以把其修改同步到test分支上。如下展示如何进行的三路合并。

---

title: cherry-pick三路合并
---

flowchart LR
  base["`
  **Base(feat4~)**
  feat 1
  feat 2
  feat 3
  `"]
  theirs["`
  **Theirs(feat4)**
  feat 1
  feat 2
  feat 3
  *feat 4*
  `"]
  ours["`
  **Ours(HEAD)**
  feat 1
  feat 3
  `"]
  results[["`
  **Results**
  feat 1
  feat 3
  *feat 4*
  `"]]
  base:::someclass --> theirs
  base --> ours
  ours ~~~ results
  theirs ~~~ results
  classDef someclass fill:#f96

通过上图可以观察到如下特点,由于Base和Theirs是父子提交:

  1. 需要pick提交的diff(即父子提交的diff)一定会引入到最终的结果或者带来冲突。上述例子的feat 4会引入到最终结果。
  2. 非pick提交的diff,一定不会引入到最终的结果。上述的父子提交都包含的feat 2一定不会被引入到最终的结果中。

其次,再看下面冲突的情况,在test分支上使用git cherry-pickmain分支上的提交feat 2,如下展示冲突是为何发生。

---

title: cherry-pick三路合并冲突
---

flowchart LR
  base["`
  **Base(feat2~)**
  feat 1
  `"]
  theirs["`
  **Theirs(feat2)**
  feat 1
  *feat 2*
  `"]
  ours["`
  **Ours(HEAD)**
  feat 1
  *feat 3*
  `"]
  results[["`
  **Results**
  feat 1
  <<<<<<< HEAD
  *feat 3*
  \=\=\=\=\=\=\=
  *feat 2*
  >>>>>>> feat 2
  `"]]
  base:::someclass --> theirs
  base --> ours
  ours ~~~ results
  theirs ~~~ results
  classDef someclass fill:#f96

上述就是在三方合并中遇到了三方都不同的情况导致冲突。同时也能说明pick某个提交时,只有某个提交和其父提交参与合并,而提交的未来提交不会参与。

解决好冲突就获取到基于test分支增加feat 2功能的提交。如下执行git show该提交获取到其对于上个提交的diff。

diff

commit 5985ed38e0da205ed3afbbf295ad1c44f9009fd9 (HEAD -> test)
Author: ImportMengjie <limengjie@hotmail.com>
Date:   Thu Feb 13 22:21:01 2025 +0800

    feat 2

diff --git a/data.txt b/data.txt
index bddfc51..7e4aaac 100644
--- a/data.txt
+++ b/data.txt
@@ -1,2 +1,3 @@
 feat 1
+feat 2
 feat 3

该提交有一个很好的特性,再将其pick到类似test的分支就不会产生冲突,也就是冲突在相似产品线分支解一次即可。例如,将其pick到类似test分支的test1分支,如下展示三方合并。

---

title: cherry-pick三路合并
---

flowchart LR
  base["`
  **Base(feat2'~)**
  feat 1
  feat 3
  `"]
  theirs["`
  **Theirs(feat2')**
  feat 1
  *feat 2*
  feat 3
  `"]
  ours["`
  **Ours(HEAD)**
  feat 1
  feat 3
  `"]
  results[["`
  **Results**
  feat 1
  *feat 2*
  feat 3
  `"]]
  base:::someclass --> theirs
  base --> ours
  ours ~~~ results
  theirs ~~~ results
  classDef someclass fill:#f96

rebase命令5可以从一个分支批量挑选提交pick到另一个分支。当然也可以人眼挑选提交手动执行cherry-pick,但是这样在提交多时容易遗漏。

其常用的使用格式如下。作用是以main分支为基底,将在当前分支但是不在main分支的提交挑选出来批量pick到main分支上。-i参数则是进入交互模式,在该模式下可以用编辑器编辑具体那些提交需要pick到main分支上。

shell

git rebase -i main

下边将举个具体的使用例子。假设产品在main分支开发到feat 5提交时切出test分支作为另一代产品的开发分支。两个分支分别都有专门针对对应产品的特别提交[main] feat only for main[test] feat only for test。开发主要在main分支上,test分支目前只是将main分支的两个修复提交[all] Fix One[all] Fix Two同步过来。当前的分支现状如下图。

---

title: 提交历史(rebase)
---

    gitGraph
       commit id: "init"
       commit id: "[all] feat 1"
       commit id: "[all] feat 2"
       commit id: "[all] feat 3"
       commit id: "[all] feat 4"
       commit id: "[all] feat 5"
       branch test
       checkout test
       commit id: "[test] feat only for test"
       checkout main
       commit id: "[main] feat only for main"
       commit id: "[all] Fix One"
       commit id: "[all] feat 6"
       commit id: "[all] Fix Two"
       checkout test
       commit id: "*[all] Fix One"
       commit id: "*[all] Fix Two"

在分支main上data.txt内容如下:

txt

[all] Fix One
[all] feat 1
[all] feat 2
[all] feat 3
[all] Fix Two
[all] feat 4
[main] feat only for main
[all] feat 5
[all] feat 6

在分支test上data.txt内容如下:

txt

[all] Fix One
[all] feat 1
[all] feat 2
[all] feat 3
[all] Fix Two
[all] feat 4
[test] feat only for test
[all] feat 5

需求是将main分支的针对所有产品线的提交pick到test分支上,在这例子中就是feat 6提交,但是不要[main] feat only for main,实际提交肯定是有多个,手动pick容易漏。现在使用rebase命令完成该需求。

首先将基于main分支新建test_pick分支,在该分支上执行git rebase -i test。此时会将在main分支上但是不在test分支上的提交挑选出来,并通过编辑界面展示。实际的结果如下,每一行代表一个提交将被pick。

txt

pick 53465ca [main] feat only for main
pick 9c59ef4 [all] feat 6
pick 342badc [all] Fix Two

对于在main分支上但是不在test分支的提交,Git给出的结果如上,需要解释下:

  • 对于结果中没有出现[all] feat 1~[all] feat 5提交很好理解,二者分支上这些提交的commit id是完全一样,可以确认这些提交都在两个分支中,不需要pick。

  • 前两个需要pick的提交也符合预期,确实属于在在main分支上但是不在test分支上的提交。

  • 对于[all] Fix One[all] Fix Two两个提交分别在两个分支的commit id是不同的,但是一个出现在结果另一个则没有,是因为Git还通过提交的patch id判断提交是否已经在另一个分支上。

    patch id是通过git show commit_id获得的结果去除不影响代码变更的信息,如提交用户、提交msg等,计算哈希值。查看某提交的patch id可以执行: git show commit id| git patch-id

    对于[all] Fix One对于两个分支git show内容是一样的,而[all] Fix Two则不同:

    • 分支main[all] Fix Twogit show结果:

      diff

      commit 342badc5b44093ef5fd999cc2beff2744a1ab924 (main)
      Author: ImportMengjie <limengjie@hotmail.com>
      Date:   Sun Feb 16 16:50:27 2025 +0800
      
          [all] Fix Two
      
      diff --git a/data.txt b/data.txt
      index ecfec0b..1089f5b 100644
      --- a/data.txt
      +++ b/data.txt
      @@ -2,6 +2,7 @@
      [all] feat 1
      [all] feat 2
      [all] feat 3
      +[all] Fix Two
      [all] feat 4
      [main] feat only for main
      [all] feat 5
      
    • 分支test[all] Fix Twogit show结果:

      diff

      commit 65830cfa90845a5d11281764fb61857f18542754 (HEAD -> test)
      Author: ImportMengjie <limengjie@hotmail.com>
      Date:   Sun Feb 16 16:50:27 2025 +0800
      
          [all] Fix Two
      
      diff --git a/data.txt b/data.txt
      index 609bbe7..b705a61 100644
      --- a/data.txt
      +++ b/data.txt
      @@ -2,6 +2,7 @@
      [all] feat 1
      [all] feat 2
      [all] feat 3
      +[all] Fix Two
      [all] feat 4
      [test] feat only for test
      [all] feat 5
      

    可以看到两个提交的git show稍有不同,导致Git不认为其是一个提交,所以会出现在结果中。

删除掉不需要的提交,结果如下。

txt

pick 9c59ef4 [all] feat 6
pick 342badc [all] Fix Two

然后保存文本,Git就会按照文本顺序进行pick。而Git在pick[all] Fix Two提交时三路合并发现修改已经在test分支上,会暂停pick,提示:

shell

The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:

    git commit --allow-empty

Otherwise, please use 'git cherry-pick --skip'
interactive rebase in progress; onto 65830cf
Last commands done (2 commands done):
   pick 9c59ef4 [all] feat 6
   pick 342badc [all] Fix Two
No commands remaining.
You are currently rebasing branch 'test_pick' on '65830cf'.

nothing to commit, working tree clean
Could not apply 342badc... [all] Fix Two

此时执行git cherry-pick --skip跳过即可。最终提交的历史如下:

---

title: rebase结果
---

    gitGraph
       commit id: "init"
       commit id: "[all] feat 1"
       commit id: "[all] feat 2"
       commit id: "[all] feat 3"
       commit id: "[all] feat 4"
       commit id: "[all] feat 5"
       branch test
       checkout test
       commit id: "[test] feat only for test"
       checkout main
       commit id: "[main] feat only for main"
       commit id: "[all] Fix One"
       commit id: "[all] Fix Two"
       commit id: "[all] feat 6"
       checkout test
       commit id: "*[all] Fix One"
       commit id: "*[all] Fix Two"
       branch test_pick
       commit id: "*[all] feat 6"

test_pick分支只在test分支的基础上增加了一个[all] feat 6提交,后续只要在test分支上执行git rebase test_pick即可完成将test_pick分支的修改同步到test分支上。

上述cherry-pickrebase命令在多产品线多分支的情况下很适合使用,但是在使用这些命令时,需要遵守一些规则。

首先,每个提交应保持原子性,即每次提交只包含一个功能。如果一个提交包含多个功能,那么在cherry‑pick时就会不可避免地将不相关的修改一起引入

下边是一些命令在保持提交性时可以使用。需要注意大部分命令都会改变提交历史,应仅在本地分支没有push到云端时使用,否则只能用git push -f来更新远程分支。

  • git commit --amend: 修改上个提交
  • git add -p: 挑选当前工作区修改到暂存区
  • git pull --rebase: 同步远程分支不产生merge提交
  • git reset: 多个提交压缩成一个提交
  • git rebase -i HEAD~3: 自由最近的三个提交内容,将rebase编辑文本中的pick修改为edit就可以在 pick 该提交时暂停,可以进行自由修改,然后commit --amend保存。

其次,要保持提交历史线性,即不可用merge产生合并提交。关于这条是本人在实际开发中思考的结论。

对于多产品线对应的分支之间不可能调用merge来合并,因为不同产品线之间各有各自的差异,没有一个产品需要合并另一个产品的所有功能

而对于一个产品分支内使用merge合并各种开发分支则看似可行,但是如果merge时有冲突就很难搞了。

例如,有两个分支基于feat 1提交开发,第一个在行尾增加了feat 2,第二个在行尾增加了feat 3,最终合并时会有如下冲突。

diff

feat 1
<<<<<<< HEAD
feat 2
=======
feat 3
>>>>>>> main_merge

解完冲突的data.txt如下:

txt

feat 1
feat 2
feat 3

此时提交的历史如下:

---

title: merge冲突
---

    gitGraph
       commit id: "init"
       commit id: "feat 1"
       branch main_merge
       commit id: "feat 3"
       checkout main
       commit id: "feat 2"
       merge main_merge id: "Merge branch 'main_merge'"

如果有另一个产品线分支test需要feat 2feat 3,使用cherry-pick这两个提交则会有冲突,因为两个提交的冲突并没有在提交内解决,而是在合并提交中解决。

对于合并提交本身和cherry-pickrebase命令天然犯冲:

  • 对于cherry-pick合并提交有多个父提交不能直接执行,而是需要使用-m参数指定那个父提交作为三路合并的基准提交。
  • 对于rebase默认是不会pick合并提交,也就是合并提交如果解决了冲突不会对rebase有任何帮助会导致重复解冲突,

在这种情况下使用cherry-pick如果不想再次解冲突,则需要执行如下命令:

shell

git cherry-pick "feat 2"
git cherry-pick -m 1 "Merge branch 'main_merge'" # 以编号为1的父节点即"feat 2"提交作为基准提交
# 或
git cherry-pick "feat 3"
git cherry-pick -m 2 "Merge branch 'main_merge'" # 以编号为2的父节点即"feat 3"提交作为基准提交

虽然不会有冲突,但是最终结果的历史是线性的,同时也把另一个commit msg变成Merge branch 'main_merge',丢失了信息。显然这么做不是聪明的做法。

以我目前的理解,提交历史如果不是线性的,在这种多产品线多分支的管理方式下会带来不必要的复杂度和可能的错误。

总结: 在多产品线管理中,为了保持各产品分支的稳定性和提交历史的清晰,直接合并可能会引入不必要的改动。通过cherry‑pickrebase,我们可以有选择性地同步代码。关键在于确保提交的原子性和历史的线性,避免因冲突而导致重复修改。