前言
介绍了如何使用单一的 Git 命令同时推送多个分支,以及如何创建和使用 Git 别名来简化这一过程。今日前端早读课文章由 @Andrew Lock 分享,@飘飘编译。
适合:对 Git 有基本掌握的中高级开发者;经常在大型前端项目中做 PR、代码审查和分支管理的团队成员;对效率有追求、有 alias 使用经验的工程师。
译文从这开始~~
在这篇文章中,我将展示如何用一条命令推送整个分支栈。当你在使用堆叠分支(stacked branches)进行开发时,这种方法特别有用,可以一次性将所有分支推送出去。文中我会介绍我创建的一些 Git 别名,它们可以让这个操作变得简单快捷。
什么是堆叠分支?
我非常喜欢创建小而清晰的 Git 提交,尤其是在开发大型功能时。我倾向于通过多个小的提交来逐步构建一个完整的功能,就像讲故事一样,每个提交都是故事的一部分。这样做的目的是让其他人更容易逐步审查代码。
基于这个思路,我通常会为一个功能中的若干个提交分别创建独立的 PR(Pull Request)。这样再次方便他人逐步审查。GitHub 的 PR 页面在处理大型 PR 时体验不佳,即便你已经将提交做得 “渐进式”。为每个功能单元创建独立的分支和 PR,可以让审阅者更容易理解和跟进你的提交 “故事”。
当然,要写出小提交和拆分 PR,往往比最后提交一个大 PR 要麻烦一些。但我认为这是值得的。小 PR 更容易被审阅,也更有可能获得高质量的反馈。而且,从审阅者的角度来看,这种做法也更友好,节省了他们的时间。
这种开发方式,也就是多个独立的分支和 PR 相互依赖、层层构建起来的模式,被称为 “堆叠分支” 或 “堆叠 PR”。这个说法很好理解:从 Git 的提交图来看,每一个分支都像是 “堆叠” 在前一个分支之上。
举个例子,在下面这个项目中,我为某个功能(feature-xyz)创建了 6 个提交,并将它们拆分为 3 个逻辑单元,每个单元放在一个独立的分支里。这样我就可以为每个分支分别创建一个 PR:

功能中的堆叠分支示意图
对于第一个 PR,也就是分支 andrew/feature-xyz/part-1
,我会创建一个 PR 请求将其合并到 dev
(假设是开发分支)。对于第二个 PR,也就是分支 andrew/feature-xyz/part-2
,我会请求合并到 andrew/feature-xyz/part-1
,而第三个分支 part-3
则会请求合并到 part-2
:
功能中的堆叠分支示意图
每个 PR 只包含该分支特有的提交内容,这样可以大大提升代码审阅的体验。
我坚信,对于中大型功能开发来说,如果你希望优化代码审阅体验,并避免开发受阻,使用堆叠分支是一种非常高效的工作方式。不过,不可否认的是,这种方式相较于传统的单分支开发流程,需要更熟练地掌握 Git 的使用技巧。
使用一条 Git 命令推送整个分支栈
当我向同事推广使用堆叠分支(stacked branches)时,一个常见的痛点很快就浮现出来了:如果你修改了堆栈中的多个分支,想要一次性将这些分支推送到远程仓库,该怎么办?
遗憾的是,我直到最近才找到一个比较好的解决方案。之前我一直是这样做的:
git push origin --force-with-lease feature/part-1;
git push origin --force-with-lease feature/part-2;
git push origin --force-with-lease feature/part-3;
看起来很丑,对吧?但我也就一直这么忍着用了 😅
其实,我曾用一个别名 pof
来稍微简化一下这个过程,用命令:
git config --global alias.pof "push origin --force-with-lease"
这样我就可以用 git pof
来代替整句命令,至少能省点打字时间。
直到最近,我在处理一个特别大的分支栈时,终于受不了了,决定认真研究一下这个问题。
我们先用一个简单的例子说明一下:
初始分支栈示意图
现在这个仓库的状态如下:
- 默认分支是
main
,它跟踪远程仓库 origin
的默认分支; - 有一组名为
feature/
的分支构成了一个堆叠结构; feature/*
- 当前检出的分支是堆栈顶部的
feature/part-3
。
我们的目标是尽可能简单地将 feature/part-1
、feature/part-2
和 feature/part-3
都推送到远程仓库。我们先运行 git stack
,它会列出这个堆栈中将要推送的所有分支:
$ git stack
feature/part-3
feature/part-2
feature/part-1
这些就是将要推送的分支,看起来一切正常,于是我们运行:
$ git push-stack
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 20 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 1.14 KiB | 1.14 MiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0 (from 0)
To C:\repos\temp\temp69
* [new branch] feature/part-3 -> feature/part-3
...
这就把堆栈中的所有分支都推送到了远程仓库:
所有分支已成功推送
就这样,成功了!这个简单功能正是我一直以来想要实现的。当然,git push-stack
还有一些额外的 “功能”。
使用 git push-stack
推送部分分支
我们稍后会详细介绍 git push-stack
是如何实现的,但先来看一下它的几种使用方式。
你已经看到最基本的用法了:在你已经检出了堆栈顶端分支(如上例中的 feature/part-3
)的前提下,直接运行:
git push-stack
如果你只想推送堆栈的一部分,有两种方式可以实现:
1、检出你希望作为 “堆栈顶端” 的那个分支;
2、显式指定你希望作为 “堆栈顶端” 的那个分支。
比如,你只想推送 feature/part-1
和 feature/part-2
,而不包括 part-3
,你可以这样做:
# 方法一:检出 part-2 分支
git checkout feature/part-2
git stack
# 输出:
# feature/part-2
# feature/part-1
或者这样:
# 方法二:直接指定堆栈顶端分支
git stack feature/part-2
# 输出:
# feature/part-2
# feature/part-1
至于用哪种方式,取决于你当前的操作:如果你已经切换到了 feature/part-2
分支,用方法一更简单;否则就用方法二。
这里我们用 git stack
只是为了列出将被推送的分支。如果你直接使用 git push-stack
,这些分支就会被立刻推送。
git stack
的智能行为
还有一点要注意:git stack
会根据远程默认分支(比如 origin/main
)智能判断哪些分支需要被推送。
设想现在你的本地仓库是这样的状态:
feature/part-1 已被合并后的仓库状态图
此时的情况是:
feature/part-1
已经合并到了远程的 origin/main
,并且远程分支已被删除;- 本地的默认分支
main
还没有更新以跟踪远程的 origin/main
。
这时我们运行:
git stack feature/part-3
# 输出:
# feature/part-3
# feature/part-2
正如你所希望的,只有需要推送的分支会被列出来,而这个判断是基于远程默认分支 origin/main
,而不是本地分支。
目前这个功能已经足够满足我的需求了。当然,我相信这个工具还有很多可以扩展的地方。
接下来的部分,我会讲解如何创建 git push-stack
这个命令。
实现 git push-stack
命令
git push-stack
命令包含以下四个步骤:
为了便于测试和模块化,我为每一步都创建了一个 Git 别名(alias),我们接下来逐步介绍这些别名的实现方式。
前提假设
在开始之前,有几个默认假设需要说明:
- 你是从一个远程仓库克隆了本地仓库,或者你已经设置好了默认的远程分支。
现在,大多数项目的远程默认名称都是 origin
,但也可能是 upstream
或其他名称。这里我默认使用 origin
,当然你也可以根据需要修改这些别名。
我之所以没做参数动态切换,是因为 Git 别名不太方便支持带有命名参数的方式,我也不想用多个位置参数,避免像 git stack origin mybranch
还是 git stack mybranch origin
这样的困扰 🤔。如果你确实有这方面的需要,我也乐意分享适配后的脚本。
第二个假设通常不是问题。如果你是从 GitHub 克隆的仓库,基本上都没问题。如果你的仓库没有设置默认远程分支,可以手动设置:
git remote set-head origin --auto
让我们来看看创建 git push-stack
命令所需的各个步骤。
计算默认分支
创建 Git 仓库(无论是本地的还是 GitHub 上的)时,都需要设定默认分支。过去默认是 master
,现在大多改成了 main
,不过也可以是其他任何名称。
当克隆一个仓库时,Git 会自动检出 “默认” 分支(除非您明确指定其他分支)。Git 会在本地仓库的 refs/remotes/origin/HEAD
中创建一个 symbolic-ref
以指向远程仓库的默认分支。此引用会根据远程仓库定义的 HEAD 进行更新 —— 对于 GitHub 而言,这就是用户界面中显示为 “默认” 的分支。
可以使用 git symbolic-ref refs/remotes/origin/HEAD
读取此引用的值,它会打印出远程引用:
$ git symbolic-ref refs/remotes/origin/HEAD
refs/remotes/origin/main
我们只需要输出的最后 main
部分,所以我们创建一个别名来使用 sed
提取这部分内容。我将这个别名命名为 git default-branch
,并像这样定义它:
[alias]
default-branch = "!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"
示例输出:
$ git default-branch
main
如果你遇到下面这个错误:
fatal: ref refs/remotes/origin/HEAD is not a symbolic ref
这表明 origin
的默认分支尚未设置。如果是从 GitHub 克隆的仓库,通常不会出现这种情况,但如果只是在本地设置了仓库,或者某些配置下可能会出现。要解决此问题,请运行:
git remote set-head origin --auto
这会查询远程分支并更新本地符号链接以指向正确的分支。现在我们知道了默认的远程分支,就可以计算堆栈的 merge-base
了。
计算堆栈的计 merge-base
根据 Git 文档,merge-base
的作用是找出两个提交之间的 “最佳共同祖先”,也就是 Git 合并时使用的参考点。
在三方合并中用于两个提交之间的最佳公共祖先。如果一个公共祖先为另一个公共祖先的祖先,则前者优于后者。没有更好公共祖先的公共祖先即为最佳公共祖先,也就是合并基础。
例如,下面这个图:
o---o---o---B---o---o---C
/
---o---1---o---o---o---o---o---A
在计算我们的分支堆栈时,我们不想强制要求您已将分支堆栈重新基于 origin/main
进行基线调整,所以我们需要计算我们分支堆栈的最顶层分支与默认分支之间的 merge-base
。幸运的是,Git 已经有了 git merge-base
命令可以为我们完成此操作:
【第3404期】git bisect:基于二分法快速找到有问题的提交
git merge-base
我们定义了一个别名
merge-base-origin
,它使用 default-branch
并且要么使用 HEAD
(对于当前已检出的分支),要么使用调用者指定的参数来运行上述命令,从而打印出提交记录。例如,如果我们检出了分支 C 并且 A 是 origin/main
,那么 git merge-base-origin
就会打印出提交 1 的 SHA 值。
下面的 Git 配置展示了 merge-base-origin
命令,以及我们如何在其中嵌入对 git default-branch
别名的调用:
[alias]
merge-base-origin ="!f() { git merge-base ${1-HEAD} origin/$(git default-branch); };f "
这条命令中有趣的部分是 bash ${1-HEAD}
。它的意思是:
这意味着我们可以做这样的事情:
$ git merge-base-origin
7257e92c016e017fc95e763302ac31c32d78c2b8
$ git merge-base-origin feature/part-2
7257e92c016e017fc95e763302ac31c32d78c2b8
接下来的步骤比较棘手:列出合并基础与目标分支之间的所有分支。
【第3410期】如何将JavaScript单体代码库的Git大小缩小到原来的94%的?
列出堆栈中的所有分支
这一部分是最复杂的。我使用了 git log
和自定义格式来列出从 merge-base
到当前分支之间的所有本地分支。
我们首先运行 git log --pretty=%D
并传入我们想要比较的两个提交(目前为了简便手动设置为 HEAD 和 main )。我正在之前看到的那个仓库中运行这些命令:
%D
格式确保对于每个提交,我们仅打印出所指向的引用:
$ git log --pretty=%D main..HEAD
输出:
origin/feature/part-2, feature/part-2
origin/main, origin/HEAD, feature/part-1
好的,可以看到我们需要的内容概要在那里。有很多额外的噪音,比如远程分支( origin/feature/part-3
等)和 HEAD ,再加上空提交,但这是一个不错的开端。
我们先从删除空行开始。我们可以使用 --simplify-by-decoration
来实现这一点:
git log --pretty=%D --simplify-by-decoration main..HEAD
输出:
HEAD -> feature/part-3, origin/feature/part-3
origin/feature/part-2, feature/part-2
origin/main, origin/HEAD, feature/part-1
我们希望去除远程分支和 HEAD,只保留本地分支名。使用 --decorate-refs=refs/heads
参数:
git log --pretty=%D --simplify-by-decoration --decorate-refs=refs/heads main..HEAD
输出:
feature/part-3
feature/part-2
feature/part-1
太好了!这看起来几乎完美无缺,但有一个细微的问题不太明显。如果我们再创建一个分支就能看出来了:
# Just for clarity
git checkout feature/part-3
# Create a new branch on the same commit as feature/part-3
# but don't add any commits
git branch feature/part-4
如果我们再次运行上述命令,我们得到:
git log --pretty=%D --simplify-by-decoration --decorate-refs=refs/heads main..HEAD
输出:
feature/part-4, feature/part-3
feature/part-2
feature/part-1
这表明了问题所在:默认的漂亮格式将两个分支放在同一行,用逗号隔开。我们希望每个分支都单独占一行,所以我们定义了自己的漂亮格式,使用换行符作为分隔符,使每个分支都独占一行。
git log --pretty=format:"%(decorate:prefix=,suffix=,tag=,separator=%n)" --simplify-by-decoration --decorate-refs=refs/heads main..HEAD
输出:
feature/part-4
feature/part-3
feature/part-2
feature/part-1
就这样,成功了!接下来要做的就是更新硬编码的 main
和 HEAD
以支持提供特定分支,并动态计算 merge-base
,这样我们的别名就完成了。
[alias]
stack = "!f() { \
BRANCH=${1-HEAD}; \
MERGE_BASE=$(git merge-base-origin $BRANCH); \
git log --decorate-refs=refs/heads --simplify-by-decoration --pretty=format:\"%(decorate:prefix=,suffix=,tag=,separator=%n)\" $MERGE_BASE..$BRANCH; \
};f "
为简便起见,我将 BRANCH
和MERGE_BASE
变量单独列了出来。与merge-base-orgin
别名一样,BRANCH
被定义为HEAD
或用户提供的分支。MERGE_BASE
是运行git merge-base-origin
并传入计算出的$BRANCH
的输出结果,最后我们运行git log
命令。
别名设置完成后,我们现在可以运行了
$ git stack
feature/part-4
feature/part-3
feature/part-2
# or, for example
$ git stack feature/part-2
feature/part-2
剩下的就是实现 git push-stack 命令了。
推送所有分支
有了 git stack
之后,git push-stack
就只需要把输出的分支列表逐个执行 git push origin --force-with-lease
即可。
最简单的方法是使用 xargs
。我们只需将 git stack
的输出通过管道传递给 xargs
,它就会为提供的每个分支运行该命令:
git stack | xargs -I {} git push --force-with-lease origin {}
这部分的意思是 “将以下命令中的 {}
替换为实际参数”,其中参数是 git stack
返回的值,按行分割。因此,这会在 git stack
返回的每个分支上运行 git push
。
在 Git 中的定义几乎就是这样简单,我只是再次定义了 $BRANCH
变量,以便向下传递不同的值给 git stack
。
[alias]
push-stack = "!f() { \
BRANCH=${1-HEAD}; \
git stack $BRANCH | xargs -I {} git push --force-with-lease origin {}; \
};f "
这就是拼图的最后一块,所以现在我们可以把所有内容整合起来,看看最终的一组别名。
整合所有别名
以下是我在 .gitconfig
中配置的完整别名集合:
[alias]
default-branch = "!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"
merge-base-origin = "!f() { git merge-base ${1-HEAD} origin/$(git default-branch); };f "
stack = "!f() { \
BRANCH=${1-HEAD}; \
MERGE_BASE=$(git merge-base-origin $BRANCH); \
git log --decorate-refs=refs/heads --simplify-by-decoration --pretty=format:\"%(decorate:prefix=,suffix=,tag=,separator=%n)\" $MERGE_BASE..$BRANCH; \
};f "
push-stack = "!f() { \
BRANCH=${1-HEAD}; \
git stack $BRANCH | xargs -I {} git push --force-with-lease origin {}; \
};f "
你可以通过以下命令自动将它们添加到你的 Git 配置中:例如,通过运行 git config --global --edit
来打开您的编辑器
可能会好奇为什么文中到处都有 ${1-HEAD}
这样的重复内容。这主要是为了使每个别名都能独立调用。
或者,您也可以在命令行中运行以下命令来自动添加它们:
git config --global alias.default-branch "!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"
git config --global alias.merge-base-origin '!f() { git merge-base ${1-HEAD} origin/$(git default-branch); };f '
git config --global alias.stack '!f() { BRANCH=${1-HEAD}; MERGE_BASE=$(git merge-base-origin $BRANCH); git log --decorate-refs=refs/heads
--simplify-by-decoration --pretty=format:\"%(decorate:prefix=,suffix=,tag=,separator=%n)\" $MERGE_BASE..$BRANCH; };f '
git config --global alias.push-stack '!f() { BRANCH=${1-HEAD}; git stack $BRANCH | xargs -I {} git push --force-with-lease origin {}; };f '
就是这样:只需一条命令就能轻松推送整个 Git 分支栈。如果您像我一样多年来一直手动处理这个问题,那么希望这能帮到您!
总结
本文介绍了 Git 中的堆叠分支(stacked branches),以及它们如何帮助你更好地管理 PR 提交流程。虽然堆叠分支带来了更清晰的审阅流程,但每次推送多个分支确实有些麻烦。为此,我展示了如何通过 Git 别名创建一个 git push-stack
命令,用一条命令推送整个分支栈。
😀 每天只需花五分钟即可阅读到的技术资讯,加入【早阅】共学,可联系 vx:zhgb_f2er
5 分钟新知:了解外面世界的一种方式。
希望这个方法能让你更轻松地管理你的开发堆栈!
关于本文
译者:@飘飘
作者:@Andrew Lock
原文:https://andrewlock.net/pushing-a-whole-stack-of-branches-with-a-single-git-command/
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮”
在看” 一下 。