Published on

Git 分支迁移

Authors
  • avatar
    Name
    皓月之明
    Twitter

多数情况下把一个分支的提交迁移到另一个分支,通常用 git merge 或者 git rebase 就可以达到目的,但有时候实际情况会比这复杂的多。下文会基于一个真实的项目场景,说明各种情况下分支迁移的处理方式。

假设我们正在一个项目组工作,我们的仓库分支管理模型是 GitFlow(本文会忽略 master 分支,因为它在我们的场景中没有发挥作用)。项目即将引来第一个版本发布,所以基于 develop checkout release/1 分支。在此期间,下个迭代的功能也在同步进行,不幸的是,项目组临时决定本属于下个迭代的需求,将会在 release/1 紧急发布。

现在有 3 个 feature 分支正在开发中,分别是 feautre/a, feature/b, feature/cfeature/d。 这 3 个分支想要合并到 release/1 上正好对应了 3 种情况。现在的项目进度可以在下图中清晰的了解到:

如果你想在本地实践一下,可以展开下列的命令,粘贴到终端,它将为你创建上图的 Git 提交历史。

下列命令会在当前目录创建 demo

mkdir demo && cd demo
git init
git branch -M develop

touch a && git add . && git commit -m 'C0: commit to develop 1'

git checkout -b release/1
git checkout -b feature/a
git checkout -b feature/c

git checkout develop
touch b && git add . && git commit -m 'C1: commit to develop 1'

git checkout release/1
touch c && git add . && git commit -m 'C2: commit to release/1'

git checkout feature/a
touch d && git add . && git commit -m 'C3: start feature/a'
touch e && git add . && git commit -m 'C4: finish feature/a'
git checkout develop

git checkout -b feature/b
touch f && git add . && git commit -m 'C5: start feature/b'
touch g && git add . && git commit -m 'C6: finish feature/b'

git checkout feature/c
touch h && git add . && git commit -am 'C7: start feature/c'
git merge feature/b
touch i && git add . && git commit -am 'C8: finish feature/c'

分别来看下这 3 个分支迁移到 release/1 会碰到什么问题。

迁移 feature/a

下图仅展示了我们关注的分支,隐藏了无关分支。

这种情况最简单,feature/arelease/1 来自相同的父分支,提交记录比较清晰干净,从图中也能直观地看出,这种情况,可以通过 merge 或者 rebase 任意一种方式完成迁移。

# merge
git checkout release/1
git merge feature/a
# rebase
git checkout feature/a
git rebase release/1
git checkout release/1
git merge feature/a

这两种方式都能够让 release/1 分支拥有 feature/a 分支的 foo 提交。以 rebase 为例,此时的 git log 如下:

Updating 8b6f5db..9d50199
Fast-forward
a51ff94 (HEAD -> release/1, feature/a) C4: finish feature/a
c05dc81 C3: start feature/a
586a845 C2: commit to release/1
5005f0c (develop) C0: commit to develop 1

此时的提交历史图为:

结果是一个线性的提交历史,使用 git merge 将会得到一样的提交内容,不过提交历史会稍有不同。

迁移 feature/b

注意这个用例,feature/brelease/1 并非来自相同的父节点。下图仅展示了我们关注的分支,隐藏了无关分支。

假设我们采用上面说的那种方式来合并会发生什么事?由于 feature/b 包含 C1 提交,如果直接 merge 或 rebase 会导致 C1 也被包含在了 release/1。 但是 C1 不属于 feature/b,它是 develop 的日常事务提交。也就是说,我们需要排除 C1,仅让 C5C6 被迁移至 release/1

git log origin..HEAD 是个简写后的指令,它的原指令更清晰地表达了它的意图: git log HEAD ^origin。通过对比 developfeature/b 两个分支,可以得到 feature/b 所拥有的,develop 没有的 commits。

$ git log develop..feature/b --oneline
0bbb1f9 (HEAD -> feature/b) C6: finish feature/b
dcd6f2b C5: start feature/b

从结果可以看出,我们只拿到了 C5C6 这两个提交。我们需要把这两个提交 cherry-pickrelease/1 分支,由于 cherry-pick 要按时间进行,所以我们需要拿到由先到后的 commit hash。使用上面的参数,通过 git rev-list 可以得到:

$ git rev-list --reverse develop..feature/b
dcd6f2bd33d1e7dbfc612befb596f0c6c27174dc
0bbb1f987dc3e186c9a8c45ab3e4bf2bee7c2905

由于 cherry-pick 可以接收多个 commit hash,以空格分隔,所以还需将上述输出结果改为以空格分隔的字符串:

$ git rev-list --reverse develop..feature/b | xargs
dcd6f2bd33d1e7dbfc612befb596f0c6c27174dc 0bbb1f987dc3e186c9a8c45ab3e4bf2bee7c2905

接着就是让 release/1 cherry-pick 这些 commits 了,最终的命令是:

# 确保处于 release/1 分支
$ git checkout release/1
$ git rev-list --reverse \develop..feature/b \
| xargs \
| xargs git cherry-pick

现在使用 git-log 查看 release/1 的 commits 可以看到只有 C0C2C5C6

246a5eb (HEAD -> release/1) C6: finish feature/b
f61a851 C5: start feature/b
4009214 C2: commit to release/1
4bfed4c C0: commit to develop 1

此时的提示历史图为:

迁移 feature/c

下图仅展示了我们关注的分支,隐藏了无关分支。

feature/c 的情况更复杂了,它曾经合并过其他分支,属于 feature/c 的直属提交只有 C5C6

如果实际上 feautre/bfeature/c 的前置特性,想要正常使用 feature/c 必须带上 feature/b,那 release/1 就同时需要 feautre/bfeature/c,这样的话,用上一节的方式就可以了。

这里我们关注另外一种情况,继续阅读。

在开发 feature/c 的过程中,开发人员出于某种原因,合并了 feature/b,但现在要紧急发布此功能,就必须剔除 feature/b 的 commits,我们只希望迁移 C7C8

分支被 merge 指令合并时,会产生一次新的合并提交,就是上图 C7C8 中间的那个 commit,它会记录自身的父分支,通常第一父分支是执行合并时所处的分支,我们要获取到它的第一父分支(feature/c)上的提交可以用这种方式:

$ git log develop..feature/c --first-parent --oneline
774b57d (feature/c) C8: finish feature/c
f37b176 Merge branch 'feature/b' into feature/c
e66ee72 C7: start feature/c

结果我们得到了三个提交,除了 C7C8,还包含一个合并提交,去掉这个提交可以通过 --no-merges 选项:

$ git log develop..feature/c --first-parent --oneline --no-merges
774b57d (feature/c) C8: finish feature/c
e66ee72 C7: start feature/c

现在的结果是期望的了,像上一节一样,把 git-log 换成 git rev-list 获取倒序的 commit hash,并转换格式后执行 chery-pick,最终的指令是:

# 确保正处于 release/1 分支
git checkout release/1
git rev-list \
	--reverse \
	--first-parent \
	--no-merges \
	develop..feature/c  \
| xargs \
| xargs git cherry-pick

此时,使用 git log 查看提交记录,可以看到当前有 C0C2C7C8 这些提交。

现在的提交历史图是这样的:

跳过等效提交

上面我们用来过滤出需要被迁移的 commits 的命令,在上例的情况下,还有一个等效的命令:

git log develop..feature/c --first-parent --oneline --no-merges
# 等效于
git log develop...feature/c --first-parent --oneline --no-merges

区别仅是,两个分支名之前的符号变成了三个点。它也是缩写,原始命令是:

git log A B --not $(git merge-base --all A B)

git merge-base 能够输出 A B 两个分支点的合并基,在本文的提交历史中,developfeature/c 的共同祖先有 C0C1merge-base 会取出最近的 C1。整行命令就是列出 A 和 B 分支的对称差异,即仅属于 A 和 B 的提交。

接着在 feature/c 这个例子的基础上做一个小小的改动,如果你用最上面的命令在本地重现了实践环境,那么需要删除之前创建的 demo 文件夹,重来一次,并在之前的基础上多执行一行命令:

git checkout release/1
git rev-list feature/c --no-merges -n 1 --skip=1 | xargs git cherry-pick

现在提交历史如下图所示:

release/1 已经拥有了 feature/cC7 提交,对应到现实中的场景,这可以是一个 hot-fix。现在 feature/c 中需要被迁移到 release/1 的提交只剩下 C8 了,如果用本节上面介绍的迁移迁移方式,也不会出问题,它会 cherry-pick C7 和 C8,其中 C7 是重复的,不过 Git 能够识别出来,并会提示:

On branch release/1
You are currently cherry-picking commit fafd483.
  (all conflicts fixed: run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

nothing to commit, working tree clean
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'

这种情况可以直接执行 git cherry-pick --skip 跳过。不过我们依然有办法能够过滤掉重复的 C7。之前我们一直是对比 developfeature 分支,但现在 feature/* 是基于 develop 的,重复的 C7 是在 release/1 确是上的,所以需要换个方式。

$ git log release/1...feature/c --first-parent --oneline --no-merges --cherry-mark
= 1141483 (HEAD -> release/1) C7: start feature/c
+ 873023c (feature/c) C8: finish feature/c
+ 5e2c875 C2: commit to release/1
= fafd483 C7: start feature/c

根据刚刚的介绍,我们得出的意料之内的属于 release/1feature/c 的提交。这里多了一个参数 --cherry-mark ,它会使输出结果前面加上一些标记,= 表示已经有等效提交了,即可以忽略的,+ 表示非等效提交。另一个类似的参数是 --cherry-pick,它与 --cherry-mark 的区别是会忽略掉等效提交。

$ git log release/1...feature/c --first-parent --oneline --no-merges --cherry-pick
873023c (feature/c) C8: finish feature/c
5e2c875 C2: commit to release/1

最后还需要过滤掉 C2 这样的属于 release/1 的提交:

$ git log release/1...feature/c --first-parent --oneline --no-merges --cherry-pick --right-only
873023c (feature/c) C8: finish feature/c

通过 --right-only 可以仅列出 ... 右侧分支的提交,与之类似的还有 --left-only--left-right,顾名思义,不再赘述。

完整的指令是:

git rev-list --reverse release/1...feature/c --first-parent --no-merges --cherry-pick --right-only \
| xargs \
| xargs git cherry-pick

小结

概括一下要点:

  • 排除合并进来的分支使用 --first-parent
  • 排除合并产生的提交使用 --no-merges
  • 将从 A checkout 出来的 B 分支合并至 C,可以使用 ... 标记版本范围
    • 使用 --cherry-pick 检查对称差异性
    • 使用 --right-only 挑选右侧分支的提交

如果继续寻找特例肯定还能找到上述未涉及的情况,不过理解了这些指令后,也能够自己得出答案了。文中的示例都是基于我们一开始给出的 Git 提交历史图,如果实际案例情况更复杂,加上提交历史混乱,那么 Git 管理员将会看到一团乱麻,无从下手。所以平时开发者就需要注意 Pull Request 的提交历史,避免此类情况。