Next.js 仓库实战:gh-stack 堆叠 PR 的故障排查与状态恢复(exit 3/6/8/10)
本文基于 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:记录本地哪些分支属于哪个栈,是checkout、unstack、导航命令(up/down/top/bottom)的依据; - 栈文件锁
.git/gh-stack.lock:保护.git/gh-stack的并发写,任何gh stack进程写入前都要拿到这个独占锁。
理解了这两个文件的位置,后面所有"锁"、"分叉"、"恢复"问题的表现就都能对上了。
Rebase 冲突(exit 3):sync 与 rebase 的恢复语义不同
rebase 和 sync 在冲突时都退出 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.md 对 rebase 的补充说明也印证了这一点:--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):
checkout <pr>如果撞上"本地已有一个覆盖这些分支但组成不同的栈",是无法强制的——必须先gh stack unstack --local再重试;- 远程 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 之上。操作纪律:移动任何分支之前,先记下旧的边界 SHA(git 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 7、unstack 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 不写本地状态,结果上本地导航命令(up、down、top、bottom)全部不可用。若之后想获得本地跟踪,用 gh stack checkout <stack-number> 把栈拉下来。
栈文件锁(exit 8)与中断的 modify 会话(exit 10)
exit 8:另一个 gh stack 进程持有 .git/gh-stack.lock 的独占锁。锁约 5 秒超时,所以常规做法是等待重试;如果持续出现 exit 8,说明有进程仍持有锁,需要先定位并停掉那个进程再重试。
exit 10:modify 是纯 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.md、SKILL.md、commands.md 与 stack-design.md 四个文件,可作为排障时的逐节对照手册。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00