分支与手术

堆叠分支

一个堆叠就是一串分支,其中每一个都建立在下面那个之上:main → api → ui。评审三个小 PR,好过评审一个巨大的。

一个分支堆叠

Gitcito 把它画成一条路线:顶上是起点分支,往下每一层一个站点。每一站的 PR 指向它 上面那一站,第一站落在起点分支上。每一站会显示自己的提交数、要不要 restack,以及提交之后 的 PR 编号。

编辑路线

在你按下“应用”之前,什么都不会执行。 选分支、挪动站点、把它移出路线——这些只是在改 屏幕上的一份清单。真正的操作要 rebase 分支并切过去,那不该由一次试探性的点击来做。等路线看着 对了,应用路线会把它作为一次可撤销的操作执行;放弃则把草图退回仓库实际的样子。

路线按合并顺序画:上面的分支合并进下面那个,最下面是堆栈落脚的分支。

控件 作用
起点输入框 堆栈落到哪里。改掉它,整条链会重新连到新分支上并重放。
某一站的输入框 换掉占着这个位置的分支。离开路线的分支只是取消跟踪,不会被删除。
↑ / ↓ 把一站沿路线挪一格。
✕ 把这一站移出路线;它的前后两站接上。
添加一站 选一个你已有的分支,它会加到路线顶端;或者输入一个还不存在的名字,它会在最后一站的尖端被创建并切过去。
箭头按钮 切换到那一站。

每个输入框都支持边打边筛:输入即过滤,↑/↓ 和 Enter 选中,打的东西即使不在列表里也算数 ——所以 origin/main 这样的远端引用同样可以当起点分支。

底下这些编辑其实是同一个操作:把整条路线一次交回去。所以一个动作就是一次撤销 (⌘Z),而不是一串半途而废的链接改动。

改路线的代价

凡是改变顺序的操作——对调、挪动、换起点——都会重放这条链:每一站自己的提交会被 rebase 到新的基底上。所以它和 restack 一样会冲突。两个改动同一批行的站点,没有人参与就换不了位置。真遇上时,什么都不会发生: 整次编辑会被回滚,分支尖端、父链接和做了一半的 rebase 一并复原,并告诉你是哪两站撞上了。 随手碰到的下拉框,不该把你丢在 rebase 中间。

Restack 是这笔交易的另一半:那是你点名要的 rebase,所以它会停在冲突处并把冲突界面交给你 ——这也是拿到 Gitcito 拒绝的那个顺序的办法:在那里解决,然后再挪动站点。

撤销会重放上一条路线。它不会把旧提交复活——新提交是同样的工作,只是父提交不同。

全部推送

全部推送用 --force-with-lease 推送每一层,然后就停下——相当于什么都不打开的 gh stack push。下面的把堆栈提交为 PR会做同样的推送,再做 PR 那部分工作;当你 只想让分支上远端、还不打算送审时,用全部推送。

把堆叠提交为链式 PR

提交会先问清楚:要新开几个 PR、改几个的目标、在哪个远端,以及每个的 分支 → 基底 那一行 ——开 PR 是公开的,撤回也麻烦。写进每个描述里的 导航小节,才是让这条链在 GitHub 上看得见的东西;GitHub 本身没有“堆栈”这个概念。

提交在自己的界面里进行:先看计划,然后一步步的进度,最后是每个 PR 和堆栈本身的链接。

提交一个堆栈

将堆叠提交为 PR 一键完成那些堆叠工具收费才肯做的事:

  1. 用 --force-with-lease 推送每一层(新分支怎样都能接受,重新堆叠过的分支则少不了它)。
  2. 为每个还没有 PR 的层开一个 PR——各自以父分支为基底,而不是 main,因此每个评审只显示它自己的提交。标题和描述取自该层自己的提交。
  3. 为基底已经漂移的现有 PR 重定目标。
  4. 在每个 PR 正文里写入一段堆叠导航,让任何一层的评审者都能看到整条链,以及这个 PR 在链中的位置。

这个操作是幂等的:每次重新堆叠、新增一层或合并 PR 之后按它,结果都会收敛——什么都不会重复,只有漂移的部分会被更新。

最底部的 PR 合并之后,同一个按钮还会替它善后:已合并那层的子分支会改挂到主干上,那一层被取消跟踪,它的本地分支被删除(很安全——主干可证明地包含了它),整条链重新堆叠,剩下的每个 PR 都会重定目标。自下而上合并,按一下提交,如此往复。

在 GitHub 上它还会变成真正的堆栈

基底相扣是所有平台都懂的做法,在 GitLab、Bitbucket 和 Azure DevOps 上也只有这个。GitHub 还有更多:自从 stacked pull requests 进入预览,堆栈就是服务端的一个对象。PR 建好之后, Gitcito 会把它们从下到上注册成一个堆栈,于是你能拿到 PR 界面里的堆栈图、服务端跑的级联 rebase,以及在最上面那个 PR 上点合并、把下面所有未合并的层一起落地。

如果仓库没在这个预览里,或者令牌管不了堆栈,这次调用就悄悄跳过:链条和导航小节本身就够 用,和其他平台上完全一样。

重新堆叠

当下层的某个分支变了——比如你处理了 api 上的评审意见——它上面的每个分支现在都建立在错误的基底上。重新堆叠会用 rebase --onto 对整条链做级联变基,这样父分支的重写就不会把提交重复塞进它的子分支里。重新堆叠之后再按一次提交:它会强制推送被重写的层,PR 会就地更新。

限制

  • 提交目前仅支持 GitHub(创建在四个托管平台上都能用,但重定目标和正文更新需要 GitHub API)。
  • 底部合并后的清理通过祖先关系识别 merge 和 rebase 式合并,通过询问 GitHub 该分支的 PR 是否已落地来识别 squash 合并——所以只要有 GitHub 令牌,每种合并方式都能被清理。在其他托管平台上,或者没有令牌时,经 squash 合并的那层仍需手动取消跟踪。另外记得先抓取(fetch)——祖先关系检查读到的主干是你上次抓取时的状态。
  • PR 正文里的堆叠段落维护在隐藏标记之间——你写在它上方的描述会原样保留。
  • 重新排序和换主干会在它们碰到的每一层上改写历史。分支是你自己的,没推过的 层不花什么代价,但已经在评审中的层会在下一次提交时挨一次强制推送。
  • 一层一次只挪一格。两次对调就是两次 rebase,半路停下仍是个看得懂的状态;一拖就 落到三格开外则不是。
  • 站点是要被 rebase 的,所以堆栈落脚的那个分支绝不会同时是一站,受保护的分支也 不会(没改过名单的话就是 main 和 master)。两者都会被拒绝,而不是悄悄改写大家共用的 历史。
  • 提交在开任何 PR 之前,会先问远端哪些分支真的到了,并把没到的点名说出来。head 找不到时 GitHub 只回一句干巴巴的 “Validation Failed”,那对谁都没用。 堆栈落脚的分支也会检查:如果它只在本地,提交会提议推送它再继续。

这些链接存在哪儿

父级链接存在 git config 里,所以它们跟着仓库走,而且重新克隆之后依然健在。没有任何东西寄存在某个服务上。

另请参阅: 交互式变基 · 托管平台与拉取请求

在 GitHub 上改进这份翻译