AI

下层折扣 PR 被 squash 进 main 之后,上层结账分支对 main 做普通 rebase,停在已经合过的那条提交上(中英切换)

This post is currently available in Chinese only. The original text follows.Open the Chinese page →

三人、七台电脑。1 号机上的代理刚写完折扣接口,PR #41 还在排队:feat/discount 指向 main,两条提交,A1 是 apply_discount,A2 把它接到购物车。3 号机上的代理要写结账页,结账必须调用这个还没进 main 的函数。它没有等,从 feat/discount 的 tip 拉出 feat/checkout,加上 B1、B2,开了 PR #42,底部分支写的是 feat/discount。

下午 4 号机过了 #41。7 号机按仓库默认点了 Squash and merge。main 上多了一个新的 squash 提交,feat/discount 随后被删。#42 如果跟着把底部分支改到 main,页面上看起来就像一张对着 main 的普通 PR。3 号机上的代理——或者接手数的下一个会话——按惯例跑了句 git rebase main,想把下层已经进去的改动「同步」过来。

如果此时 main 上除了这个 squash 什么都没发生,这件事有时看起来像成功了。我在玩具仓库里碰到的就是这种情况。可是 4 号机随后又往 main 上打了一笔热修:True 不能再被当成 1% 的优惠。这时候再对 main 做普通 rebase,停下来的不是 B1,是已经合进去的 A1。冲突文件是 pricing/discount.py。代理如果在冲突里取「正在打的那一边」,热修会从代码和测试里一起消失,pytest 仍然是 7 通过。

我在本机搭了一个玩具仓库,把 squash、普通 rebase、rebase --onto,以及几种冲突处理都跑了一遍。git 提交、合并、rebase、pytest,以及门禁之后的快进,都是真跑的。GitHub 官方的 Stacked pull requests 界面、gh stack、以及网页上点 Squash and merge 之后平台会不会自动 rebase 上层分支,这次没有在线验证。下面先把 git 本身会发生的事情说清楚,再说明门禁怎么补。

下层 squash 进 main 之后,上层对 main 做普通 rebase

实验室怎么搭

玩具仓库 shop 的最初主干是 681ad59:购物车只做加法。1 号机这条线有两笔提交。d5646bc(A1)加入 pricing/discount.py,apply_discount 只接受 0 到 100 的整数百分比,超出就抛 ValueError。bb3a354(A2)把购物车接到这个函数上。pytest 在这条分支上是 4 通过。

3 号机从 A2 拉出 feat/checkout。18c1db4(B1)加上 checkout.py,结账调用 apply_discount。a6bd344(B2)把 discount.py 整文件重写,在同一份文件里加上 apply_stackable,并补了测试。代理常这样改:不是在文件末尾补一个小补丁,而是把整份文件再写一遍。这条分支上 pytest 是 7 通过。

7 号机在本机用 git merge --squash feat/discount 模拟仓库默认的 squash 合并,得到 8f0b81f。git diff bb3a354 8f0b81f 是空的,两边的树哈希都是 5549880。内容一样,历史不一样。squash 提交不是 A1、A2 的子孙。这时 git merge-base feat/checkout main 回到 681ad59,也就是更早的那次 main,而不是 A2。

之后我在 main 上又加了一笔热修 588e2e7:apply_discount 拒绝 bool(在 Python 里 True == 1),并补了 test_bool_is_not_a_percent。相对 A2,改动是 pricing/discount.py 和 tests/test_discount.py 两个文件,共 9 行新增。这笔热修很短,也很常见——下层合进去之后,主干还会继续往前走。

直接合并会在新文件上变成 add/add

下层刚 squash 完、热修还没打的时候,我先试了把 feat/checkout 直接合并进 main。冲突发生在 pricing/discount.py 和 tests/test_discount.py,状态是 AA。checkout.py 和它的测试是干净的新增。

原因不在内容是否「其实已经合过」,而在合并基线。基线是 681ad59,那时候还没有 discount.py。main 这一侧在 squash 里「新增」了 A 的版本,上层这一侧「新增」了带 apply_stackable 的版本。两边都是从无到有,blob 又不同,Git 只能报 add/add。

冲突里取 ours(保留 main 上尚未包含 stackable 的那一版),提交能做成,pytest 是 1 error:checkout.py 已经进树,它要 import 的 apply_stackable 却不在。取 theirs(上层整文件重写的那一版),7 通过,功能是齐的。这只是因为当时还没有热修。热修一旦打在同一份文件上,再取 theirs,就会把后来写在 main 上的那几行一起丢掉。

主干没再动时,普通 rebase 会自己跳过已合提交

有人会说:不要合并,rebase 就行。在 squash 之后、热修之前,我对 feat/checkout 跑了 git rebase main。它成功了,日志里只剩下重放后的 B1、B2,pytest 7 通过。同样的条件下,git rebase --onto main bb3a354 feat/checkout 得到同一个 HEAD 4451022。门禁是 ALLOW。merge_if_restacked.sh 把 main 从 8f0b81f 快进到 4451022,然后我把 main 复位,继续后面的比较。

这不是文档写错了,是前提刚好成立。squash 的树和 A2 完全一样,重放 A1、A2 不会再改出新内容,Git 把它们当成已经在上游的补丁跳过。所以「先 squash 再对 main 做普通 rebase」在这一刻是通的。它依赖的是:主干在 squash 之后没有再改那些文件。七台机器的仓库很少停在这一刻。

主干上多了热修,普通 rebase 会停在 A1

热修进 main 之后,同一句 git rebase main 停在 d5646bc,也就是 A1。冲突还是 discount.py 和对应测试,状态同样是 add/add。冲突标记的一边是热修里的 TypeError,另一边是 A1 里那份还没有类型检查的原文件。已经合进 main 的提交,又被拿出来打了一遍。

这时候代理很容易做错。rebase 里「theirs」是正在打的那条提交,也就是 A1。取 theirs,rebase 会继续走完,pytest 是 7 通过,has_hotfix 变成 false:类型检查和那则测试都不在了。日志里还多了一条被重放的 A1。门禁的判定是 REFUSE,理由是主干在下层 tip 之后新增的行,在 head 里找不到。

同一处冲突取 ours(保留 main 上已有的热修),rebase 也能完成,日志里只剩 B1、B2,热修还在,门禁是 ALLOW。pytest 仍是 7 通过,少的是上层自己加的那则 test_stackable_order;apply_stackable 还在,结账测试也还在覆盖它。取 ours 至少没有把主干后来加的东西撤掉。但它不是你可以写进脚本里、让代理每次都选的规则:下一处冲突的 ours 可能刚好是丢掉上层功能的那一边。

我还试过对停在 A1 的 rebase 执行 --skip。跳过 A1 是对的,那条提交已经在 squash 里了。Git 接着把 A2 也丢掉,理由是补丁内容已经在上游,然后重放 B1,再在 B2 上停住。如果这时再把 B2 也 skip 掉——脚本里这一步是我故意接着跑的,也是代理可能干出来的事——rebase 会显示成功,apply_stackable 却没了,pytest 变成 1 error。--skip 只适用于已经合过的下层提交,不能用在上层自己的提交上。

应该按记下的下层 tip 做 rebase --onto

正确的范围是:只重放 A2 之后的提交。

git rebase --onto main <A2 的 SHA> feat/checkout

主干停在 squash、还没有热修时,这样做干净地重放 B1、B2,7 通过,门禁 ALLOW。主干已经有热修时,B1 仍然干净;B2 停在测试文件上,是内容冲突,不是 add/add。pricing/discount.py 被自动合并了:热修的 TypeError 检查和 apply_stackable 都在,文件里没有冲突标记。Git 并没有因为 B2 是整文件重写,就把热修整段盖掉。真正要人看的是测试文件:一边是 test_bool_is_not_a_percent,一边是 test_stackable_order。

这里如果取 theirs(上层测试),rebase 完成,pytest 又是 7 通过。热修代码还在,热修测试没了。门禁 REFUSE,缺的正是 test_bool_is_not_a_percent 那几行。这是比「代码被盖掉」更不容易被看见的洞:CI 是绿的,回归检查却丢了。取 ours 会留下热修测试、丢掉上层的单元测试,门禁 ALLOW,因为主干新增的行还在;上层功能靠结账测试勉强还在。两边都留的时候,pytest 是 8 通过,门禁 ALLOW。merge_if_restacked.sh 把 main 从 588e2e7 快进到 7af3c29,8 通过。随后我把 main 复位。

对照实验:下层不用 squash,而用 git merge --no-ff 做成 merge commit 9873756。这时 A2 已经在 main 的历史里,对这条 main 做普通 rebase,只会重放 B1、B2,7 通过。squash 带来的不是「内容没进去」,而是「原提交的身份丢了」。普通 rebase 按提交身份和合并基线来决定从哪里开始打;身份一丢,基线就会退回更早的 main。

同一套仓库、几种接法的实测结果

门禁看的是历史和主干后来加的行,不只是测试绿

stack_gate.py 做三件具体的事,不信任「rebase 已经跑过」或「pytest 是绿的」这种口头状态。

第一,如果你把下层 tip(这里是 A2)告诉它,而这个 SHA 还是 head 的祖先、却不是主干的祖先,就判定为 REFUSE。这正是 squash 之后、上层还没 restack 时的形状:内容也许已经在 squash 里,提交身份却还挂在上层历史上。未 restack 的 feat/checkout 对着带热修的 main,门禁同时给出四条理由:parent_commits_still_on_head,git merge-tree --write-tree 报 discount.py 和测试文件的 add/add 冲突,主干新增行在 head 里缺失,以及主干不是 head 的祖先。

第二,用 git merge-tree --write-tree 看当前主干和 head 能不能干净合并。这是 git 2.47 里的现代用法,退出码 0 表示干净,1 表示有冲突。我第一版门禁误读了旧格式的输出,把一次其实有 add/add 的合并树写成了 clean;修过之后,未 restack 的分支会正确地被拦住。

第三,检查主干在下层 tip 之后新增的每一行,是不是还出现在 head 对应文件里。C2 取 theirs 时,代码和测试里的热修行都不在了,尽管 7 个测试是绿的。C4 取 theirs 时,代码里的热修还在,测试里的热修行没了,仍然是 7 绿。两处都是 REFUSE。门禁不负责证明上层功能的测试是否完整,它只拦住「把主干后来加的东西弄丢了还说可以合」。

开上层分支的时候,就要把下层 tip 写进 .stack/parent-tip。分支被删之后,还可以从已经合进去的那张 PR 的最后一次提交找回。没有这个 SHA,--onto 就没有边界,门禁的第一项和第三项也跑不了。restack_after_squash.sh 会先对一下旧 tip 和当前主干的树;两边不一致时它不会替你停下来不跑,但会明确告诉你主干已经往前走了,整文件重写可能在重放时把后来的改动盖掉。

先按记下的下层 tip 做 rebase --onto,再用门禁核对主干后来加的行

这件事和官方堆叠 PR 不是同一条路

GitHub 的 Stacked pull requests 已在 2026 年 10 月 6 日正式可用。文档写过:下层合并之后,剩下的分支会被自动 rebase,让下一张 PR 指向默认底部分支;squash、merge commit、rebase 合并都支持。如果你们已经在用官方堆叠,并且合并下层之后上层确实被平台接好了,上面这种手搓的 git rebase main 本来就不该再出现。

这次实验没有打开官方堆叠,也没有在 github.com 上点过合并。三台开发机上的代理更常做的,是从一条还没合的功能分支直接拉出下一条,或者把底部分支设成下层分支,并不经过 gh stack。仓库默认又是 squash。这种手叠在 squash 之后,不会自动获得「只重放上层自己那几笔提交」的边界,代理按平时的习惯对 main rebase,就会从已经合过的地方开始打。官方自动 rebase 如果在热修之后停在冲突上,最后要做的决定和这次实验室是同一类:不要用单边解决把主干后来加的行丢掉,也不要只看测试是不是绿的。

什么时候不必上这套门禁

如果下层从不 squash,一律用 merge commit 合进主干,普通 rebase 会像对照实验那样只重放上层自己的提交。代价是 main 的历史不再是一条直线。有的团队认为这比教代理写 --onto 更省事。

如果从来没有人在未合的分支上再叠一层,这篇文章说的问题不会出现。等下层进 main 再开上层,是更简单的规矩,前提是 3 号机愿意等。

如果上层完全不改下层新增的那些文件,只往仓库里加新文件,squash 之后直接合并有可能是干净的,普通 rebase 在主干没动时也可能直接跳过。实验室里的冲突,来自 B2 改写了 A 引入的 discount.py。这正是堆叠常见的形状:下层加接口,上层在同一份文件里往下写。

门禁里「主干新增行还在不在」这一条,用的是字面包含,不是补丁语义。改过格式、换过引号的热修行,可能被误报缺失;一行碰巧出现在注释里,也可能被当成还在。它是给这次这种短热修用的闸,不是通用的合并策略。

落地顺序

  1. 开上层分支时运行 record_parent_tip.sh,把下层 tip 写进 .stack/parent-tip,并在 PR 说明里写明依赖哪张 PR。
  2. 下层被 squash 进 main 之后,用 restack_after_squash.sh 按这个 tip 做 rebase --onto。不要对已经往前走的 main 做普通 rebase。
  3. 冲突停在已经合过的下层提交上,就 --skip;停在上层自己的提交上,就把主干后来加的测试和上层自己的测试都留下。不要用单边的 ours/theirs 结束。
  4. 把 stack_gate.py 设为合并前的必需检查。7 号机用 merge_if_restacked.sh,门禁不是 ALLOW 就不要快进。

我在本机跑过 merge_if_restacked.sh:未 restack 的上层分支退出码是 1,main 仍停在 8f0b81f;两边测试都留下的分支,main 从 588e2e7 快进到 7af3c29,pytest 8 通过。随后复位。

你可以明天抄走的

脚本、workflow 和 AGENTS.md 片段在 stack-restack-kit.tar.gz。把门禁放到 tools/stack_gate.py,合并前这样跑:

python3 tools/stack_gate.py --repo . --base origin/<base> --head HEAD --parent-tip $(cat .stack/parent-tip)

必需检查的名字要和规则集里的一致。workflow 只在本机看过结构,没有在 GitHub Actions 上跑过。

今天最小的一步:找一张已经合进去的、底部分支曾经是另一张未合 PR 的单。把下层最后一次提交的 SHA 记下来,对当时的上层 HEAD 跑一遍门禁。如果判定是 ALLOW,而你记得合进去之前并没有做过 --onto,值得再看一眼那次合入有没有把主干后来加的行盖掉。

这次实际跑了什么

跑过的是:本机 git 2.47.3,玩具仓库里的提交、git merge --squash、树哈希比较、git merge-base、直接合并时的 add/add、ours/theirs 解决、热修前后的 git rebase main、git rebase --onto、--skip、git merge --no-ff 对照、git merge-tree --write-tree、各 HEAD 上的 pytest,以及 merge_if_restacked.sh 的拒绝与快进(随后复位)。

没跑的是:GitHub Actions;没有在 github.com 上开官方堆叠、没有跑 gh stack,也没有观察网页上 squash 合并下层之后平台会不会自动 rebase 上层;没有 merge queue;没有第二套 GitHub 账号去点合并。这次实验里没有 Windows 任务。文中的短 SHA 来自这一次重建;仓库重做之后短 SHA 会变,关系不会变。

Three people, seven machines. The agent on machine 1 has just finished a discount helper. PR #41 is still in the queue: feat/discount points at main, with two commits. A1 adds apply_discount, A2 wires it into the cart. The agent on machine 3 is supposed to write checkout, and checkout has to call a function that is not on main yet. It does not wait. It branches feat/checkout from the tip of feat/discount, adds B1 and B2, and opens PR #42 with feat/discount as its base.

In the afternoon, machine 4 approves #41. Machine 7 squash-merges it, because that is the repository default. main gets a new squash commit, and feat/discount is deleted. If #42 is retargeted at main, it looks like an ordinary pull request against main. The agent on machine 3 — or the next session that picks up the branch — does what it usually does: git rebase main, to "sync" the work that has already landed.

If nothing else has happened on main besides that squash, this can look like a success. That is what I saw in the toy repo. Then machine 4 lands a hotfix: True must no longer count as a 1% coupon. A plain rebase onto main now stops, not on B1, but on A1, which has already been merged. The conflict is in pricing/discount.py. If the agent resolves it by taking the incoming side, the hotfix disappears from both the code and the tests, and pytest still reports 7 passed.

I built a toy repository on this machine and ran the squash, a plain rebase, rebase --onto, and several ways of resolving the conflict. The git commits, the merges, the rebases, pytest, and the fast-forward after the gate were all real. I did not open GitHub's stacked pull requests UI, did not run gh stack, and did not watch what the platform does after a Squash and merge on github.com. The rest of this piece is about what git itself does, and about a merge gate that checks the result.

After the lower PR is squash-merged, a plain rebase of the upper branch starts at A1

How the lab was set up

The toy shop repo starts at 681ad59 on main: the cart only adds integers. Machine 1's branch has two commits. d5646bc (A1) adds pricing/discount.py. apply_discount accepts an integer percent between 0 and 100 and raises ValueError otherwise. bb3a354 (A2) wires the cart to that helper. pytest on this branch is 4 passed.

Machine 3 branches feat/checkout from A2. 18c1db4 (B1) adds checkout.py, which calls apply_discount. a6bd344 (B2) rewrites discount.py as a whole file, adds apply_stackable in the same file, and updates the tests. Agents often edit this way: not a small hunk at the bottom, but a fresh write of the whole file. pytest on this branch is 7 passed.

Machine 7's squash is done locally with git merge --squash feat/discount, producing 8f0b81f. git diff bb3a354 8f0b81f is empty; both trees are 5549880. Same content, different history. The squash commit is not a descendant of A1 or A2. git merge-base feat/checkout main is 681ad59, the earlier main, not A2.

I then add a hotfix 588e2e7 on main: apply_discount rejects a bool (True == 1 in Python) and gains test_bool_is_not_a_percent. Against A2, that is two files, pricing/discount.py and tests/test_discount.py, and nine new lines. The hotfix is short, and it is the usual case: after the lower PR lands, main keeps moving.

A direct merge becomes an add/add on the new file

Right after the squash, before the hotfix, I merged feat/checkout into main. The conflict was in pricing/discount.py and tests/test_discount.py, status AA. checkout.py and its test were clean adds.

The reason is the merge base, not whether the content "has already landed." The base is 681ad59, which has no discount.py. Main "added" A's version in the squash; the upper branch "added" the version with apply_stackable. Both sides created the file from nothing, the blobs differ, and git reports add/add.

Taking ours (main's copy, without stackable) produces a commit, and pytest is 1 error: checkout.py is in the tree and imports apply_stackable, which is not there. Taking theirs (the upper rewrite) is 7 passed, and the feature is complete. That is only true because the hotfix does not exist yet. Once the hotfix is in the same file, taking theirs drops the lines that landed on main later.

When trunk has not moved, a plain rebase skips the already-landed commits

The objection is: do not merge, rebase. After the squash and before the hotfix, git rebase main on feat/checkout succeeded. The log contained only the replayed B1 and B2, and pytest was 7 passed. Under the same conditions, git rebase --onto main bb3a354 feat/checkout landed on the same HEAD, 4451022. The gate said ALLOW. merge_if_restacked.sh fast-forwarded main from 8f0b81f to 4451022. I then reset main and continued.

This is not a mistake in the usual advice. The premise just happened to hold. The squash tree is identical to A2, so replaying A1 and A2 produces no new content, and git skips them as already upstream. "Squash, then rebase onto main" works at that moment. It depends on main not touching those files again after the squash. A seven-machine repo rarely stays in that moment.

After a hotfix, a plain rebase stops on A1

Once the hotfix is on main, the same git rebase main stops on d5646bc, which is A1. The conflicts are again discount.py and its tests, still add/add. One side of the markers is the hotfix TypeError; the other is A1's original file, without the type check. A commit that has already landed on main is being applied again.

This is an easy place for an agent to go wrong. During a rebase, "theirs" is the commit being applied, which is A1. Take theirs, and the rebase finishes, pytest is 7 passed, and has_hotfix is false: the type check and its test are both gone. The log still contains a replayed A1. The gate says REFUSE, because the lines trunk added after the parent tip are missing from head.

Taking ours at the same conflict (keep the hotfix already on main) also lets the rebase finish. The log is only B1 and B2, the hotfix is still there, and the gate says ALLOW. pytest is still 7 passed; what is missing is the upper branch's own test_stackable_order. apply_stackable is still in the file, and the checkout test still covers it. ours at least does not revert what trunk added later. It is not a rule you can put in a script and let an agent choose every time: the next ours may be the side that drops the upper-branch feature.

I also ran --skip on the rebase that had stopped at A1. Skipping A1 is right; that commit is already in the squash. Git then dropped A2 as well, because the patch was already upstream, replayed B1, and stopped on B2. If you then skip B2 too — the script did this on purpose, and an agent might — the rebase reports success, apply_stackable is missing, and pytest is 1 error. --skip is for already-landed lower commits, not for the upper branch's own work.

Restack with rebase --onto from the recorded parent tip

The right range is: replay only the commits after A2.

git rebase --onto main <A2 SHA> feat/checkout

With trunk at the squash and no hotfix, this replays B1 and B2 cleanly, 7 passed, gate ALLOW. With the hotfix already on main, B1 is still clean; B2 stops on the test file, a content conflict, not add/add. pricing/discount.py was auto-merged: the hotfix TypeError checks and apply_stackable are both present, with no conflict markers. Git did not let the whole-file rewrite in B2 wipe the hotfix. What still needs a person is the test file: one side is test_bool_is_not_a_percent, the other is test_stackable_order.

Take theirs (the upper tests) and the rebase finishes, with pytest at 7 passed again. The hotfix code is still there; the hotfix test is gone. The gate says REFUSE, for exactly those missing test_bool_is_not_a_percent lines. This is a quieter hole than "the code was overwritten": CI is green, and the regression test is not. Take ours and you keep the hotfix test and drop the upper unit test; the gate says ALLOW, because trunk's new lines are still there, and the checkout test still covers stackable. Keep both tests, and pytest is 8 passed, gate ALLOW. merge_if_restacked.sh fast-forwarded main from 588e2e7 to 7af3c29, 8 passed. I then reset main.

A control: merge the lower branch with git merge --no-ff, producing merge commit 9873756. A2 is now in main's history, and a plain rebase onto that main replays only B1 and B2, 7 passed. What squash costs you is not the content. It is the identity of the original commits. A plain rebase decides where to start from commit identity and the merge base. Once the identity is gone, the base falls back to an earlier main.

Same toy repo, results for each way of landing the upper branch

The gate looks at history and at lines trunk added later, not at a green test run

stack_gate.py does three concrete checks. It does not trust a claim that "the rebase already ran" or "pytest is green."

First, if you give it the parent tip (A2 here) and that SHA is still an ancestor of head but not of trunk, the decision is REFUSE. That is the shape of a squash-merged parent with an unrestacked child: the content may already be in the squash, but the original commits are still on the upper history. Against main with the hotfix, the unrestacked feat/checkout is refused for four reasons at once: parent_commits_still_on_head, git merge-tree --write-tree reports add/add on discount.py and the tests, trunk's new lines are missing from head, and trunk is not an ancestor of head.

Second, it runs git merge-tree --write-tree on current trunk and head. This is the modern form in git 2.47: exit 0 is a clean tree, exit 1 is a conflict. The first version of the gate misread the older output and called a merge tree clean when it had add/add conflicts. After the fix, the unrestacked branch is refused correctly.

Third, it checks that every line trunk added after the parent tip still appears in the corresponding file on head. When C2 takes theirs, the hotfix lines are gone from both the code and the tests, even though 7 tests are green. When C4 takes theirs, the hotfix is still in the code and gone from the tests, still 7 green. Both are REFUSE. The gate is not trying to prove that the upper-branch feature is fully tested. It is there to stop a merge that dropped what trunk added later and still looks mergeable.

When you open the upper branch, write the parent tip to .stack/parent-tip. If the lower branch is later deleted, you can recover the SHA from the last commit on the merged pull request. Without that SHA, --onto has no boundary, and the first and third checks cannot run. restack_after_squash.sh diffs the old tip against current trunk first. A non-empty diff does not make it refuse to run, but it will tell you that trunk has moved, and that a whole-file rewrite may drop those later changes when it is replayed.

Restack with rebase --onto from the recorded parent tip, then gate on trunk's later lines

This is not the same path as official stacked pull requests

GitHub's stacked pull requests became generally available on 6 October 2026. The docs say that after a lower pull request is merged, the remaining branches are automatically rebased so the next pull request targets the default base branch, and that squash, merge-commit, and rebase merges are all supported. If you are already using official stacks, and the upper layers really are restacked for you after the lower merge, the handmade git rebase main above should not be in the path at all.

This run did not turn official stacks on, and it did not merge anything on github.com. The agents on the three development machines more often branch the next piece of work directly off an unmerged feature branch, or set that branch as the pull request base, without going through gh stack. The repository default is still squash. A handmade stack does not automatically get a boundary that means "replay only the upper commits" after that squash. An agent that rebases onto main the way it usually does will start at work that has already landed. If official auto-rebase stops on a conflict after a later hotfix, the decision you still have to make is the same one as in this lab: do not resolve it from one side and drop lines trunk added later, and do not take a green test run as proof.

When this gate is the wrong tool

If the lower branch is never squashed, and always lands with a merge commit, a plain rebase behaves like the control and replays only the upper commits. The cost is that main is no longer a straight line. Some teams will take that cost over teaching an agent to write --onto.

If nobody ever stacks on an unmerged branch, the problem in this piece does not appear. Waiting for the lower pull request to land before opening the next one is a simpler rule, if machine 3 is willing to wait.

If the upper branch never edits the files the lower branch introduced, and only adds new files, a merge after the squash can be clean, and a plain rebase may skip the lower commits when trunk has not moved. The conflicts in the lab come from B2 rewriting discount.py, which A introduced. That is the usual shape of a stack: the lower layer adds an API, the upper layer keeps writing in the same file.

The "are trunk's later lines still present" check is a literal substring test, not a patch-semantic one. A hotfix line that was later reformatted or re-quoted can be reported missing; a line that happens to appear in a comment can be reported present. It is a gate for a short hotfix like this one, not a general merge strategy.

Rollout order

  1. When you open the upper branch, run record_parent_tip.sh so .stack/parent-tip holds the lower tip, and name the parent pull request in the description.
  2. After the lower pull request is squash-merged into main, restack with restack_after_squash.sh and that tip. Do not run a plain rebase onto a main that has already moved.
  3. If the rebase stops on an already-landed lower commit, --skip it. If it stops on one of the upper branch's own commits, keep both the tests trunk added later and the upper branch's own tests. Do not finish with a one-sided ours or theirs.
  4. Make stack_gate.py a required check before merge. Machine 7 can run merge_if_restacked.sh and refuse a fast-forward until the gate says ALLOW.

I ran merge_if_restacked.sh on this machine: against the unrestacked upper branch it exited 1 and left main at 8f0b81f; against the branch that kept both tests, main fast-forwarded from 588e2e7 to 7af3c29, and pytest was 8 passed. I then reset.

What you can copy

The scripts, workflow, and AGENTS.md snippet are in stack-restack-kit.tar.gz. Put the gate at tools/stack_gate.py and call it before merge:

python3 tools/stack_gate.py --repo . --base origin/<base> --head HEAD --parent-tip $(cat .stack/parent-tip)

The required check name must match what your ruleset expects. The workflow was inspected locally for structure; it was not run on GitHub Actions.

The smallest step today: take a pull request you already merged whose base was once another open pull request. Record the lower branch's last SHA, and run the gate on the upper HEAD from that time. If the output is ALLOW, and you know no --onto was done before the merge, it is worth checking whether that merge dropped lines trunk had added later.

What was actually run

Run: git 2.47.3 on this machine, commits in the toy repo, git merge --squash, tree-hash comparison, git merge-base, an add/add merge, ours/theirs resolutions, git rebase main before and after the hotfix, git rebase --onto, --skip, a git merge --no-ff control, git merge-tree --write-tree, pytest on each HEAD, and merge_if_restacked.sh refusing and fast-forwarding (then reset).

Not run: GitHub Actions; no official stacked pull requests on github.com, no gh stack, and no observation of whether the platform auto-rebases the upper branch after a squash merge in the UI; no merge queue; no second GitHub account to click merge. No Windows job appears in this experiment. The short SHAs come from this run. They will change if the repo is rebuilt; the relationships will not.