社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Git

【第3519期】使用一条 Git 命令推送整个分支栈

前端早读课 • 3 天前 • 18 次点击  

前言

介绍了如何使用单一的 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-1feature/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,并且远程分支已被删除;
  • 本地分支 feature/part-1 还在;
  • 本地的默认分支 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 命令包含以下四个步骤:

  • 1、计算默认分支(如 origin/main);
  • 2、计算堆栈的 “合并基点”,即堆栈的底部;
  • 3、找出从合并基点到堆栈顶部之间的所有分支;
  • 4、将上一步中列出的所有分支推送到远程。

为了便于测试和模块化,我为每一步都创建了一个 Git 别名(alias),我们接下来逐步介绍这些别名的实现方式。

前提假设

在开始之前,有几个默认假设需要说明:

  • 远程仓库的名称是 origin
  • 你是从一个远程仓库克隆了本地仓库,或者你已经设置好了默认的远程分支。

现在,大多数项目的远程默认名称都是 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
  • A 和 B 的合并基点是 1;
  • A 和 C 的合并基点也是 1;
  • B 和 C 的合并基点是 B。

在计算我们的分支堆栈时,我们不想强制要求您已将分支堆栈重新基于 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}。它的意思是:

  • 如果有用户提供的参数,请将其放在此处。
  • 如果不是,则使用 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 "

为简便起见,我将 BRANCHMERGE_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/

图片
这期前端早读课
对你有帮助,帮” 
 “一下,
期待下一期,帮”
 在看” 一下 。

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/182646
 
18 次点击