首页
/ Next.js 仓库实战:gh-stack 堆叠 PR 的故障排查与状态恢复(exit 3/6/8/10)

Next.js 仓库实战:gh-stack 堆叠 PR 的故障排查与状态恢复(exit 3/6/8/10)

2026-09-05 17:01:42作者:俞予舒Fleming

本文基于 Next.js 仓库内置的 gh-stack 技能文档 troubleshooting.md,系统讲解堆叠分支/堆叠 PR(stacked PRs)工作流中最常见的七类故障:rebase 冲突(exit 3)、squash 合并后的重放、本地与远程栈分叉、栈重构、分支归属歧义(exit 6)、跨工具/跨 worktree 驱动栈、栈文件锁(exit 8)以及中断的 modify 会话(exit 10)。读完后你可以:在任意 gh stack 命令失败时按退出码定位状态、选择正确的恢复路径,并在不丢失任何 PR 和分支的前提下重建栈结构。

背景:stack 是什么,本地状态在哪里

gh stack 是 GitHub CLI 的一个扩展,用于管理堆叠分支:一条以 trunk(如 main)为根、按顺序排列的分支链,每个分支基于它下面的分支开一个 PR,这样审阅者只看得到该层的 diff。gh stack 打印栈时 trunk 在左、顶部在右:

(main) <- auth <- api <- frontend

左侧是底部(最先合入),右侧是顶部(最后合入)。该仓库在 SKILL.md 中给出了完整的命令约定,其中有两个与故障排查直接相关的基础设施:

  • 本地跟踪文件 .git/gh-stack:记录本地哪些分支属于哪个栈,是 checkoutunstack、导航命令(up/down/top/bottom)的依据;
  • 栈文件锁 .git/gh-stack.lock:保护 .git/gh-stack 的并发写,任何 gh stack 进程写入前都要拿到这个独占锁。

理解了这两个文件的位置,后面所有"锁"、"分叉"、"恢复"问题的表现就都能对上了。

Rebase 冲突(exit 3):sync 与 rebase 的恢复语义不同

rebasesync 在冲突时都退出 3,但两者的失败语义完全不同,这是恢复路径差异的根源:

  • sync 先恢复所有分支到 rebase 之前的状态,因此一次失败的 sync 不会留下"半吊子"的中间态;
  • rebase 则是停在冲突现场,等你解决后继续。

rebase 冲突的标准恢复流程:

gh stack rebase
# exit 3 — 冲突文件路径打印在 stderr
git add <resolved paths>
gh stack rebase --continue     # 如果下一个分支也冲突,重复此过程

关键细节:gh stack rebase --abort 恢复的是整条栈里的每一个分支,而不仅仅是当前正在 rebase 的那一层。反过来,如果 sync 已经替你恢复了所有分支,你可以重跑 gh stack rebase 重新制造同一个冲突,再走"解决 → --continue"的完整循环。

另一个重要机制是 git rerere(reuse recorded revisions):init 会启用它,因此同一个冲突只要你解决过一次,下次再出现时会被自动重放。这一点在堆叠工作流里格外有价值——因为栈底部的一个改动会被 rebase 穿透它上方的每一层分支,同一处冲突可能在不同层反复出现。没有 rerere 的话,这些重复冲突需要在每个受影响的层里手工解决一遍。

commands.mdrebase 的补充说明也印证了这一点:--upstack 从当前分支向顶部重放(编辑了底层之后用它),--downstack 从 trunk 向当前分支重放,--no-trunk 则跳过 trunk 的 fetch 与 rebase,只对栈内分支互相拉齐。另外,如果在已有 rebase 进行中再启动一次 rebase,命令会退出 7(而不是 3),此时应使用 --continue--abort

Squash 合并之后:为什么普通 rebase 会"重放幽灵提交"

Squash merge 会用一个新提交替换分支上的原始提交,于是原提交在 trunk 历史中不再存在;如果此时做一次普通的 rebase,Git 会试图把这些"原提交"再重放一遍。

gh stack sync 能检测到这种情形:它用 git rebase --onto 针对正确的目标重放,自动跳过已合并的分支

gh stack sync
gh stack view --json    # 已合并分支上报 "isMerged": true, "state": "MERGED"

正常情况下无需任何手工干预。只有当重放真的产生冲突时,sync 才会把所有分支恢复原状并退出 3;此时按上文流程重跑 gh stack rebase,停在冲突处解决后再 --continue 直到完成。如果合并完成后还想清理本地分支,用 gh stack sync --prune 删除已合并 PR 对应的本地分支——非交互模式下,不带 --prune 永远不会剪枝

本地与远程栈分叉:exit 0 不等于同步成功

"分叉"指本地栈和 GitHub 上的栈以不同的方式发生了变化——例如本地新增了分支,同时有人在 github.com 往这个栈里加了一个 PR。

这里有一个反直觉但极易踩坑的行为:非交互模式下,sync 检测到分叉时会打印两条链、不改动任何东西、并以退出码 0 结束,并附上 Sync aborted 信息。也就是说,成功退出码在这里并不表示同步发生了。如果你(或脚本)只判断退出码,就会误以为栈已经同步。正确做法是检查 stderr 中是否有该消息,或重跑 gh stack view --json 对比状态。SKILL.md 也强调了同样的原则:状态消息走 stderr、不应被解析,应当分支判断退出码——但分叉这一种情况是例外,需要额外看 Sync aborted 文本。

两条恢复路径(都不会删除 PR 或分支):

  • 保留远程版本:丢弃本地跟踪,把栈从 GitHub 重新拉下来。

    gh stack unstack --local          # 保留 GitHub 上的栈
    gh stack checkout <stack-number>  # 或 PR 编号
    
  • 保留本地版本:移除 GitHub 上的分组,再从本地状态重建。

    gh stack unstack                  # 移除分组;PR 和分支都保留
    gh stack submit --auto            # 重新在 GitHub 上链接
    

两个补充事实(来自 commands.md):

  1. checkout <pr> 如果撞上"本地已有一个覆盖这些分支但组成不同的栈",是无法强制的——必须先 gh stack unstack --local 再重试;
  2. 远程 unstack 之后,处于 merging 状态(已开 auto-merge)或排队中(在 merge queue 里)的 PR 会保持 stacked 状态,必要时先清掉这些状态再重试。

重构栈结构:先改 Git 祖先关系,再重建栈

这是排查文档中最"重"的一节。核心结论是:gh-stack 没有非交互式的重排、改名或移除add 在错误的分支上运行时会建议 gh stack modify,但 modify 是纯 TUI,没有非交互路径。正确的做法是拆掉重建:

gh stack unstack                       # 移除本地跟踪和 GitHub 分组
# 按需改名/删除分支,重写祖先关系。
gh stack init --base main branch-1 branch-2 branch-3
gh stack submit --auto                 # 重新在 GitHub 上链接

两个关键保证:init收养已存在的分支,所以重建是复用旧分支而不是新建;已有 PR 全部幸存。只要 Git 祖先关系正确,submit 会更新它们的 base 分支并在 GitHub 上重新链接栈。

注意区分两个概念:改元数据不改变 Git 祖先关系。要调整层序,必须先把提交重排,再重建栈。文档给出的例子是把 main <- models <- migration <- ui 重排为 main <- migration <- models <- ui

old_models=$(git rev-parse models)
old_migration=$(git rev-parse migration)
git rebase --onto main "$old_models" migration
git rebase --onto migration main models
git rebase --onto models "$old_migration" ui
gh stack unstack
gh stack init --base main migration models ui

三次 rebase 的语义:第一次把 migration 层的独有提交移到 trunk 之上;第二次把 models 层的提交重放到 migration 之上;第三次把 ui 层的独有提交重放到 models 之上。操作纪律:移动任何分支之前,先记下旧的边界 SHAgit rev-parse 的结果)。要对其他顺序做重排,先用 git log <old-parent>..<branch> 识别每一层的提交范围,然后自底向上重放各范围。

stack-design.md 从预防角度呼应了这一成本:因为"没有非交互的原地重排",层序错误只能靠 unstack + init 修复,所以在写代码之前先规划层序远比事后重构便宜——"任务大到值得用栈,就应该在开始时创建栈"。

分支归属多个栈(exit 6):没有消歧参数

当命令无法从当前分支确定唯一栈时(通常是该分支同时是多个栈的 trunk),命令退出 6。没有可以传入的消歧 flag。恢复方式只有两种:

gh stack checkout <a-branch-unique-to-the-intended-stack>

切到一个只属于目标栈的分支上,然后重跑原命令;或者改用显式栈编号的命令(如 merge 7unstack 7),这类命令不依赖当前分支推断栈,直接绕开问题。

从其他工具或 worktree 驱动栈:link 命令

当分支不是由 gh stack 的本地状态管理时——例如由 jj、Sapling、git-town 管理,分支在另一个 worktree 里,或任何让本地 .git/gh-stack 文件"错误或缺失"的工作流——应使用 gh stack link。它纯粹通过 API 创建和更新栈,不写任何本地跟踪状态:

gh stack link branch-a branch-b branch-c        # 从底到顶
gh stack link --base develop --open a b c       # 非默认 trunk,直接就绪评审
gh stack link 10 20 30                          # 按 PR 编号
gh stack link 7 feature-d                       # 追加到已有栈 #7 的顶部

参数规则(结合 commands.md):

  • 参数从底到顶给出,可以是分支名或 PR 编号;数字参数先尝试当作 PR 编号,失败再回退为分支名;
  • 首位数字参数仅当存在该编号的栈时才被视为栈编号,其余参数直接追加到该栈顶部,无需重列现有 PR;
  • 分支参数会被自动推送(非强制、原子);缺失的 PR 用自动生成的标题和正确的链式 base 创建;base 错误的已有 PR 会被修正;
  • 栈成员是纯增量的——link 从不把 PR 从栈中移除。

代价是:由于 link 不写本地状态,结果上本地导航命令(updowntopbottom)全部不可用。若之后想获得本地跟踪,用 gh stack checkout <stack-number> 把栈拉下来。

栈文件锁(exit 8)与中断的 modify 会话(exit 10)

exit 8:另一个 gh stack 进程持有 .git/gh-stack.lock 的独占锁。锁约 5 秒超时,所以常规做法是等待重试;如果持续出现 exit 8,说明有进程仍持有锁,需要先定位并停掉那个进程再重试。

exit 10modify 是纯 TUI 命令,不应被 agent 调用。如果仓库被人留下的中断 modify 会话污染了,恢复方式是:

gh stack modify --abort

一个关联细节:submit 也会检测"pending modify"状态,在 TTY 下会询问是否用本地状态覆盖 GitHub 上的栈——脚本环境遇到该状态时应先执行上面的 --abort

附录:退出码速查表

排查文档中只覆盖了 3、6、8、10 四个退出码的完整恢复过程;完整退出码表(来自 SKILL.md)汇总如下,可用作排障入口索引:

退出码 含义 恢复
0 成功 —(注意分叉场景的 Sync aborted 例外)
1 通用错误 读 stderr
2 不在任何栈中 gh stack init,或 gh stack checkout <target>
3 Rebase 冲突 上文"Rebase 冲突"一节
4 GitHub API 失败 检查 gh auth status,重试
5 参数无效 修正调用;看 <command> --help
6 需要消歧 分支属于多个栈;切到非共享分支
7 Rebase 已在进行中 gh stack rebase --continue--abort
8 栈文件被锁 另一个 gh stack 进程在写;约 5 秒后重试
9 仓库未启用 stacked PRs 告知用户,不要自行降级为普通 PR
10 需要 modify 恢复 gh stack modify --abort

最后提醒两条适用前提:其一,gh stack 的所有非交互行为建立在 stdout 是否为 TTY 的检测之上,被管道化时多数命令会干净地报错或打印静态文本,而在 PTY 下同样命令会打开交互提示或全屏 TUI 并阻塞——因此脚本/agent 应始终显式传 --json--auto--yes 等标志,而不依赖该检测;其二,gh stack <command> --help 才是各命令参数与标志的权威来源,gh stack help <command> 并不存在,只会打印顶层帮助。

以上所有行为的原始出处集中在 troubleshooting.mdSKILL.mdcommands.mdstack-design.md 四个文件,可作为排障时的逐节对照手册。

登录后查看全文
热门项目推荐
相关项目推荐