stack: restack the rest of the stack after a squash land #237

Merged
stephen merged 3 commits from fix/stack-ship-restack-after-land into main 2026-07-30 22:54:23 +00:00
Owner

fj stack ship --squash left the stack broken. A squash merge replaces the item's commits with one new commit, so every branch above the landed item stayed parented on history the trunk no longer has: the PR above shows the landed diff a second time, and you had to rebase and force-push the remainder by hand. Ship now finishes the job.

Ship is still a client sequence of merge calls, not an atomic land. That is now stated plainly in the help and in docs/stacks.md rather than left to be inferred.

What changed

  • After a history-rewriting land (--squash), replay the branches above onto the new trunk tip with one git rebase --onto <new trunk> <landed tip> <top branch> --update-refs (the plumbing stack absorb already uses), force-push each with an explicit --force-with-lease=<ref>:<pre-replay sha>, and re-sync the PR bases. A conflict rolls back: nothing pushed, no PR retargeted, ship stops with the commands to finish by hand.
  • Pre-flight the stack's shape in git before the first merge: each unmerged branch descends from the one below it, the lowest contains the trunk's fetched tip, and each local branch tip matches its PR head. A stale or non-linear stack is refused up front instead of failing mid-merge with the bottom already landed.
  • Re-validate between merges: after an item lands, the next is re-read (open, mergeable, approved, at a head fj put there) before it is merged, and ship stops cleanly if the ground shifted. Checks that ship itself re-armed by force-pushing a restacked branch are not re-gated; an independent push still is.
  • --no-restack opts out of the replay and the local checks it depends on (clone without the branches, or git < 2.38). Merge-commit lands are unchanged: the original commits stay reachable, so only the next PR's base moves.
  • New git plumbing sits on a git::Repo handle addressed by path, so the replay runs at the repo root and is testable against a throwaway repo.
  • Additive JSON: stack ship --json gains restacked per item, stack review --json gains head_sha.

Testing

cargo build, make lint, cargo test green (719 unit tests, +25). New tests build real git repos with a bare "origin" in a temp dir and assert the branches above a simulated squash land come out reparented, one commit each, force-pushed, and with their PR bases re-pointed against a mock forge; the conflict path leaves every ref and PR untouched. Pure-function tests cover the pre-flight refusals (stale trunk, broken link, missing branch, branch/PR head mismatch) and the re-validation verdicts (conflicts, closed, someone else pushed, review or checks regressed, restacked head accepted with its re-armed CI). Real-git tests skip themselves when git is older than 2.38.

Follow-ups, deliberately not here

--upto <n> for a partial land, and --auto (merge_when_checks_succeed, already plumbed at src/api/pull_core.rs:378) to arm a stack to land as checks go green, which is also the answer for waiting on the CI a restack re-arms.

`fj stack ship --squash` left the stack broken. A squash merge replaces the item's commits with one new commit, so every branch above the landed item stayed parented on history the trunk no longer has: the PR above shows the landed diff a second time, and you had to rebase and force-push the remainder by hand. Ship now finishes the job. Ship is still a client sequence of merge calls, not an atomic land. That is now stated plainly in the help and in docs/stacks.md rather than left to be inferred. **What changed** - After a history-rewriting land (`--squash`), replay the branches above onto the new trunk tip with one `git rebase --onto <new trunk> <landed tip> <top branch> --update-refs` (the plumbing `stack absorb` already uses), force-push each with an explicit `--force-with-lease=<ref>:<pre-replay sha>`, and re-sync the PR bases. A conflict rolls back: nothing pushed, no PR retargeted, ship stops with the commands to finish by hand. - Pre-flight the stack's shape in git before the first merge: each unmerged branch descends from the one below it, the lowest contains the trunk's *fetched* tip, and each local branch tip matches its PR head. A stale or non-linear stack is refused up front instead of failing mid-merge with the bottom already landed. - Re-validate between merges: after an item lands, the next is re-read (open, mergeable, approved, at a head fj put there) before it is merged, and ship stops cleanly if the ground shifted. Checks that ship itself re-armed by force-pushing a restacked branch are not re-gated; an independent push still is. - `--no-restack` opts out of the replay and the local checks it depends on (clone without the branches, or git < 2.38). Merge-commit lands are unchanged: the original commits stay reachable, so only the next PR's base moves. - New git plumbing sits on a `git::Repo` handle addressed by path, so the replay runs at the repo root and is testable against a throwaway repo. - Additive JSON: `stack ship --json` gains `restacked` per item, `stack review --json` gains `head_sha`. **Testing** `cargo build`, `make lint`, `cargo test` green (719 unit tests, +25). New tests build real git repos with a bare "origin" in a temp dir and assert the branches above a simulated squash land come out reparented, one commit each, force-pushed, and with their PR bases re-pointed against a mock forge; the conflict path leaves every ref and PR untouched. Pure-function tests cover the pre-flight refusals (stale trunk, broken link, missing branch, branch/PR head mismatch) and the re-validation verdicts (conflicts, closed, someone else pushed, review or checks regressed, restacked head accepted with its re-armed CI). Real-git tests skip themselves when git is older than 2.38. **Follow-ups, deliberately not here** `--upto <n>` for a partial land, and `--auto` (`merge_when_checks_succeed`, already plumbed at `src/api/pull_core.rs:378`) to arm a stack to land as checks go green, which is also the answer for waiting on the CI a restack re-arms.
stack: restack the rest of the stack after a squash land
All checks were successful
Forseti review / forseti review (advisory) (pull_request_target) Successful in 4m31s
ci / check (pull_request) Successful in 10m37s
ci / live-e2e (pull_request) Successful in 2m3s
ci / coverage (pull_request) Successful in 2m19s
226c63719f
`fj stack ship --squash` merged bottom-up, re-pointed the next PR at the
trunk, and stopped there. A squash merge replaces the item's commits with
one new commit, so every branch above the landed item was left parented on
history the trunk no longer has. The PR above then shows the landed diff a
second time, and the only fix was to rebase and force-push the remainder by
hand. Ship, the command whose whole point is landing the stack for you, was
reliably leaving a stack that needed hand repair.

Three changes, all client-side. Ship stays a sequence of merge calls, not an
atomic land; the module doc and docs/stacks.md now say so plainly instead of
leaving it to be inferred.

Restack after a history-rewriting land. Once an item merges, read the trunk's
new tip, replay the branches above it with one `git rebase --onto <new trunk>
<landed tip> <top branch> --update-refs`, force-push each with an explicit
`--force-with-lease=<ref>:<pre-replay sha>`, and re-sync the PR bases: the
item directly above moves to the trunk, the rest keep targeting the branch
below them. One rebase moves every intermediate ref, which is the plumbing
`stack absorb` already relies on, so either the whole remainder moves or the
rebase aborts and none of it does. A conflict is rolled back, nothing is
pushed, no PR is retargeted, and ship stops with the commands to finish by
hand. Merge-commit lands leave the original commits reachable and need no
replay, so only the next PR's base moves, as before.

Pre-flight the stack's shape in git. Before the first merge, check that each
unmerged branch descends from the one below it, that the lowest one contains
the trunk's fetched tip (not the local copy, which can be stale), and that
each local branch tip matches the head of the PR that would merge. A stale or
non-linear stack is refused up front with a restack-then-sync message, rather
than surfacing as a failed merge with the bottom item already landed.

Re-validate between merges. The old gate ran once and then acted blind. After
an item lands, the next one is re-read before it is merged: still open, still
mergeable, still approved, and still at a head fj put there. If the ground
shifted, ship stops cleanly, names what already landed, and leaves the rest
for a re-run. Checks that ship itself re-armed by force-pushing a restacked
branch are not re-gated, since that tree was already approved and green and
the pending state is ship's own doing; the sha the branch was pushed to is
tracked so an independent push is still caught. Forgejo updates a PR's head
asynchronously, so a just-pushed branch is re-read a few times before that
counts as a shift.

`--no-restack` opts out of the replay and of the local checks it depends on,
for a clone without the stack's branches or a git older than 2.38.

New git plumbing lives on a `git::Repo` handle addressed by path rather than
by the process directory: the replay has to run at the repo root, and it makes
the rebase, the lease, and the fetch testable against a throwaway repository.
The tests build real repos (with a bare "origin") in a temp dir and assert the
branches above a simulated squash land come out reparented, one commit each,
force-pushed, and with their PR bases re-pointed against a mock forge, plus
the conflict path leaving every ref and PR untouched.

`stack ship --json` gains a `restacked` list per item and `stack review
--json` gains each PR's `head_sha`; both are additive.

Follow-ups, deliberately not here: `--upto <n>` for a partial land, and
`--auto` to arm a stack to land as checks go green.

Superseded by Forseti review for 1e2880c0140b.

Forseti review

2 blocking finding(s) from the lead reviewer (advisory: not gating merges yet).

Findings

  • P1 src/cli/stack.rs:1887 — The new docs and recovery text promise that a conflicting replay rolls back with no PR retargeted, but the Err(e) path after restack_onto_trunk unconditionally edits the PR directly above the landed item to target trunk and updates the local stack base. If the replay failed before moving the remaining branches, this leaves the forge/base metadata changed while the branches are still based on the pre-squash commits, recreating the broken-diff state the PR is intended to avoid and contradicting the documented rollback behavior.

  • P1 src/cli/stack.rs:1840 — After a restack, ExpectedHead contains both the preflight SHA and the newly pushed SHA. revalidate_blocker therefore returns None when the forge still reports the old pre-restack head, and this loop immediately breaks on blocker.is_none(). That means a lagging PR read can allow the next merge call to run before the forge has observed the force-pushed restacked head, defeating the purpose of replaying the branch onto the new trunk tip and potentially merging the stale stack history.

  • PR: rasterstate/fj#237

  • Head SHA: 226c63719fbd

  • Review job: sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 2

  • Inline findings: 2

  • Token source: GITHUB_TOKEN

  • Runner: de13e5c632e6

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/414

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"226c63719fbd3a4092962a9465cc5181c16221ce","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:0b3f02a935722451","review_job_key":"sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"superseded"} --> > Superseded by Forseti review for `1e2880c0140b`. ## Forseti review 2 blocking finding(s) from the lead reviewer (advisory: not gating merges yet). ### Findings - **P1** `src/cli/stack.rs:1887` — The new docs and recovery text promise that a conflicting replay rolls back with no PR retargeted, but the `Err(e)` path after `restack_onto_trunk` unconditionally edits the PR directly above the landed item to target `trunk` and updates the local stack base. If the replay failed before moving the remaining branches, this leaves the forge/base metadata changed while the branches are still based on the pre-squash commits, recreating the broken-diff state the PR is intended to avoid and contradicting the documented rollback behavior. - **P1** `src/cli/stack.rs:1840` — After a restack, `ExpectedHead` contains both the preflight SHA and the newly pushed SHA. `revalidate_blocker` therefore returns `None` when the forge still reports the old pre-restack head, and this loop immediately breaks on `blocker.is_none()`. That means a lagging PR read can allow the next merge call to run before the forge has observed the force-pushed restacked head, defeating the purpose of replaying the branch onto the new trunk tip and potentially merging the stale stack history. - PR: `rasterstate/fj#237` - Head SHA: `226c63719fbd` - Review job: `sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `2` - Inline findings: `2` - Token source: `GITHUB_TOKEN` - Runner: `de13e5c632e6` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/414
forgejo-actions requested changes 2026-07-30 21:58:02 +00:00
Dismissed
forgejo-actions left a comment

Forseti review

2 blocking finding(s) from the lead reviewer (advisory: not gating merges yet).

Findings

  • P1 src/cli/stack.rs:1887 — The new docs and recovery text promise that a conflicting replay rolls back with no PR retargeted, but the Err(e) path after restack_onto_trunk unconditionally edits the PR directly above the landed item to target trunk and updates the local stack base. If the replay failed before moving the remaining branches, this leaves the forge/base metadata changed while the branches are still based on the pre-squash commits, recreating the broken-diff state the PR is intended to avoid and contradicting the documented rollback behavior.

  • P1 src/cli/stack.rs:1840 — After a restack, ExpectedHead contains both the preflight SHA and the newly pushed SHA. revalidate_blocker therefore returns None when the forge still reports the old pre-restack head, and this loop immediately breaks on blocker.is_none(). That means a lagging PR read can allow the next merge call to run before the forge has observed the force-pushed restacked head, defeating the purpose of replaying the branch onto the new trunk tip and potentially merging the stale stack history.

  • PR: rasterstate/fj#237

  • Head SHA: 226c63719fbd

  • Review job: sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 2

  • Inline findings: 2

  • Token source: GITHUB_TOKEN

  • Runner: de13e5c632e6

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/414

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"226c63719fbd3a4092962a9465cc5181c16221ce","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:0b3f02a935722451","review_job_key":"sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"current"} --> ## Forseti review 2 blocking finding(s) from the lead reviewer (advisory: not gating merges yet). ### Findings - **P1** `src/cli/stack.rs:1887` — The new docs and recovery text promise that a conflicting replay rolls back with no PR retargeted, but the `Err(e)` path after `restack_onto_trunk` unconditionally edits the PR directly above the landed item to target `trunk` and updates the local stack base. If the replay failed before moving the remaining branches, this leaves the forge/base metadata changed while the branches are still based on the pre-squash commits, recreating the broken-diff state the PR is intended to avoid and contradicting the documented rollback behavior. - **P1** `src/cli/stack.rs:1840` — After a restack, `ExpectedHead` contains both the preflight SHA and the newly pushed SHA. `revalidate_blocker` therefore returns `None` when the forge still reports the old pre-restack head, and this loop immediately breaks on `blocker.is_none()`. That means a lagging PR read can allow the next merge call to run before the forge has observed the force-pushed restacked head, defeating the purpose of replaying the branch onto the new trunk tip and potentially merging the stale stack history. - PR: `rasterstate/fj#237` - Head SHA: `226c63719fbd` - Review job: `sha256:e434dd25bbaa875d042303631d41494b03f1741163ee1690f84a3b365137cc7d` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `2` - Inline findings: `2` - Token source: `GITHUB_TOKEN` - Runner: `de13e5c632e6` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/414
stack: close the ship pre-flight's unread-PR-head hole
Some checks failed
Forseti review / forseti review (advisory) (pull_request_target) Successful in 1m47s
ci / coverage (pull_request) Has been cancelled
ci / live-e2e (pull_request) Has been cancelled
ci / check (pull_request) Has been cancelled
1e2880c014
The linear-history pre-flight was meant to refuse a stale or non-linear
stack up front rather than fail mid-merge with the bottom item already
landed. It did not, whenever a PR head could not be read.

`ship` takes its heads from one unpaginated page of open PRs (limit 30,
no sort), so any stack whose PRs sit outside that page had no head to
compare against. Both places that consume the map treated "not read" as
"nothing to report":

  - the pre-flight's comparison fell through a `_ => None` arm, so
    `linear_history_blocker` had nothing to refuse on and the check
    passed;
  - under --force, which skips the per-PR gate entirely, the expected
    head fell back to `unwrap_or_default()`, producing an empty sha list
    that made `revalidate_blocker`'s head-moved guard vacuous.

Together those let a stale bottom item squash-land, after which the
replay's force-push was rejected on a stale lease, leaving the stack
half-repaired. The correlation ran the wrong way too: a stack whose PRs
are not recent enough to sit on page 1 is the stack most likely to be
stale locally.

Fill the gaps with a per-PR read before the pre-flight (free on repos
whose open PRs fit one page), and make both consumers fail safe if a
head is still unknown: `HeadCheck::Unknown` refuses in the pre-flight,
and `forced_expected_heads` refuses instead of merging on an
expectation it cannot check. The pre-flight observation moves into
`observe_stack` so the refusal is directly testable.

Also distinguish a rejected lease from a transport failure when
publishing a replayed branch. Both used to print "The local refs are
already correct; push the remaining branches with --force-with-lease",
which is wrong for a stale lease: the local refs are precisely what is
missing the concurrent push, and escalating to --force from there
destroys it. `git::StaleLease` is now a typed error so the caller can
tell the two apart.

Superseded by Forseti review for 1ca2a264f5c9.

Forseti review

No blocking findings from the lead reviewer.

No inline findings.

  • PR: rasterstate/fj#237
  • Head SHA: 1e2880c0140b
  • Review job: sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131
  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001
  • Blocking findings: 0
  • Inline findings: 0
  • Token source: GITHUB_TOKEN
  • Runner: 34f1a8310153
  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/416
<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"1e2880c0140b41e79d2501cb88fd29f84cbd9b92","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:f645865840647353","review_job_key":"sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"superseded"} --> > Superseded by Forseti review for `1ca2a264f5c9`. ## Forseti review No blocking findings from the lead reviewer. _No inline findings._ - PR: `rasterstate/fj#237` - Head SHA: `1e2880c0140b` - Review job: `sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `0` - Token source: `GITHUB_TOKEN` - Runner: `34f1a8310153` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/416
forgejo-actions approved these changes 2026-07-30 22:31:39 +00:00
Dismissed
forgejo-actions left a comment

Forseti review

No blocking findings from the lead reviewer.

No inline findings.

  • PR: rasterstate/fj#237
  • Head SHA: 1e2880c0140b
  • Review job: sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131
  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001
  • Blocking findings: 0
  • Inline findings: 0
  • Token source: GITHUB_TOKEN
  • Runner: 34f1a8310153
  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/416
<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"1e2880c0140b41e79d2501cb88fd29f84cbd9b92","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:f645865840647353","review_job_key":"sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"current"} --> ## Forseti review No blocking findings from the lead reviewer. _No inline findings._ - PR: `rasterstate/fj#237` - Head SHA: `1e2880c0140b` - Review job: `sha256:596eb9a1472b81484ee7b47fda1c945d76f2fddc41ab17625a8952c7f4140131` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `0` - Token source: `GITHUB_TOKEN` - Runner: `34f1a8310153` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/416
stack: treat a superseded head as a lagging forge, not a pass
All checks were successful
Forseti review / forseti review (advisory) (pull_request_target) Successful in 2m18s
ci / check (pull_request) Successful in 10m36s
ci / coverage (pull_request) Successful in 2m2s
ci / live-e2e (pull_request) Successful in 2m4s
1ca2a264f5
`ExpectedHead.shas` accumulates: `validated()` seeds it with the pre-flight
sha and `pushed()` appends each force-pushed one. `revalidate_blocker` only
objected when the reported head was in neither, so a forge still serving the
pre-restack head returned no blocker, and the re-validation loop broke on
`blocker.is_none()` before the `entry.restacked` retry condition was reached.
The retry budget was dead code for the exact race the comment above
REVALIDATE_ATTEMPTS describes.

The acceptance set was answering two different questions with one membership
test. "Did somebody else move this head" wants the whole set; "has the forge
observed my force-push yet" wants only the newest. Split them: a sha fj
superseded is Stale (re-read), a sha fj never pushed is Blocked (stop now),
and a Stale that outlives the retry budget becomes a blocker rather than a
pass.

This matters beyond the head field. Everything else revalidate reads off a
pre-restack record is stale too, and `mergeable` doubly so, since it was
computed for the old head against the base branch that has just merged away.
Returning early on Stale means no gate is judged on a snapshot of a shape the
restack already replaced.

Docs: both CHANGELOG.md and docs/stacks.md claimed a conflicting replay
retargets no PR. The code retargets the item directly above the landed one,
and is right to: that item's base branch has merged away regardless of the
replay, so leaving it aimed at a deleted branch is worse than the no-restack
end state. Scope the rollback claim to the rebase and say what does move.

Forseti review

No blocking findings from the lead reviewer.

No inline findings.

  • PR: rasterstate/fj#237
  • Head SHA: 1ca2a264f5c9
  • Review job: sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5
  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001
  • Blocking findings: 0
  • Inline findings: 0
  • Token source: GITHUB_TOKEN
  • Runner: 33b352ff6ee0
  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/418
<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"1ca2a264f5c9ab156618a1f74cba4c28daa06d52","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:08704143b363607e","review_job_key":"sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"current"} --> ## Forseti review No blocking findings from the lead reviewer. _No inline findings._ - PR: `rasterstate/fj#237` - Head SHA: `1ca2a264f5c9` - Review job: `sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `0` - Token source: `GITHUB_TOKEN` - Runner: `33b352ff6ee0` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/418
forgejo-actions left a comment

Forseti review

No blocking findings from the lead reviewer.

No inline findings.

  • PR: rasterstate/fj#237
  • Head SHA: 1ca2a264f5c9
  • Review job: sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5
  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001
  • Blocking findings: 0
  • Inline findings: 0
  • Token source: GITHUB_TOKEN
  • Runner: 33b352ff6ee0
  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/418
<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":237,"head_sha":"1ca2a264f5c9ab156618a1f74cba4c28daa06d52","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:08704143b363607e","review_job_key":"sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5","base_sha":"8363fc58aa1060f0c2414b8bc7d1c6c701d036c8","role":"summary","status":"current"} --> ## Forseti review No blocking findings from the lead reviewer. _No inline findings._ - PR: `rasterstate/fj#237` - Head SHA: `1ca2a264f5c9` - Review job: `sha256:55dd14da0f34e40bd54ee6ee8a6a50104de35b1d9e78f509efcefe755fe2baf5` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `0` - Token source: `GITHUB_TOKEN` - Runner: `33b352ff6ee0` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/418
Author
Owner

Both P1s from the review at 226c63719fbd are addressed in 1ca2a26. One was a real logic defect and is fixed in code. The other identified a real contradiction but pointed at the wrong half, so the fix is in the prose.

Finding 2, src/cli/stack.rs:1840, the re-validation loop: confirmed, fixed in code

The mechanism is exactly as described. ExpectedHead.shas accumulates: validated() seeds it with the pre-flight sha and pushed() appends each force-pushed one. revalidate_blocker only objected when the reported head was in neither, so a forge still serving the pre-restack head returned None, and the loop broke on blocker.is_none() before entry.restacked was ever consulted. The retry budget was dead code for precisely the race the comment above REVALIDATE_ATTEMPTS describes.

Worth recording how it got there, because it was not an oversight. The acceptance was deliberate and there was a test asserting it, in revalidate_accepts_the_head_fj_force_pushed_and_its_re_armed_checks:

// The pre-restack head still counts as fj's own: Forgejo can lag a
// moment behind the force-push.
assert_eq!(revalidate_blocker(&lagging, &expected, true), None);

That is a correct observation with the wrong remedy. Two mechanisms were built for the same lag, and the lenient one silently disabled the careful one. That assertion is now inverted.

On the consequence, where I do not follow the finding's framing

The review says this risks "merging the stale stack history". I do not think that is what happens, and it is worth being precise because the real hazard is a different one.

By the time revalidate runs for the next item, restack_onto_trunk has already returned Ok. That means the force-push completed and api::pull::edit retargeted the base, both awaited. The remote ref is already at the new sha. api::pull::merge sends no head_commit_id (MergeOptions has no such field), so the merge is by ref, and the ref has moved. The content that merges is the replayed content, not the pre-squash history.

What is actually stale is the PR record, and that is the problem, because revalidate is a gate that reads its whole verdict off that record. state, mergeable, and the checks are all taken from a snapshot computed for the pre-restack head against the pre-retarget base. mergeable is the dangerous one, and it is worse than "computed for the old head": fj invalidated both of its inputs milliseconds earlier, the head by force-pushing and the base by retargeting it to the trunk. A mergeable: true from that snapshot carries no information about whether the replayed head merges into the trunk. The gate whose entire job is to catch "this stopped merging cleanly after something below it landed" is reading stale data in exactly the situation a restack creates.

The failure mode that follows is a degraded one rather than corruption: fj proceeds, the forge refuses the conflicting merge, api::pull::merge errors, and the user gets recovery_message ("a merge failed partway, repair by hand") when the design intends stopped_message ("stopped before merging, here is what landed, nothing else was touched"). A clean stop turns into a mid-run failure with the bottom already merged. Real, but I would not describe it as merging the wrong tree.

One amplification neither the finding nor I spotted at first. Every land restacks again and calls pushed() again, so an entry deep in the stack accumulates several shas fj pushed. Under the old membership test the lag tolerance compounded with depth: by the third item, three different superseded heads all read as ready. revalidate_lag_tolerance_does_not_compound_down_the_stack covers this.

The fix

The acceptance set was answering two questions with one membership test. "Did somebody else move this head" wants the whole set; "has the forge observed my force-push yet" wants only the newest. Those are now separate:

  • a sha fj superseded, and only when fj actually force-pushed, is Stale: re-read, do not judge anything else off that snapshot
  • a sha fj never pushed is Blocked: stop now, no retry, unchanged from today
  • a Stale that outlives REVALIDATE_ATTEMPTS becomes a blocker rather than a pass, because a forge that never caught up is itself a reason not to merge

Returning early on Stale is the part that closes the mergeable hole: no gate is evaluated on a record describing a shape the restack already replaced. Once the forge catches up, the same gates run against a snapshot of the post-restack, post-retarget state, which is what they were always meant to see.

Since Stale requires expected.restacked, the non-restacked path cannot reach it, so the common case takes no new latency.

Tests

Four new cases plus the inverted one, and I mutation-checked rather than trusting green. Restoring the old contains() acceptance fails 4 of the new tests and leaves all 8 pre-existing ones passing, which is the check that the fix did not simply widen acceptance. Separately, mutating Revalidation::blocker so an exhausted retry budget returns None fails the test that asserts a stale read is terminal.

Finding 1, src/cli/stack.rs:1887, retarget on replay failure: contradiction confirmed, direction refuted, fixed in the docs

The review is right that the code and the prose disagree, and right about which lines. CHANGELOG.md and docs/stacks.md both said a conflicting replay means no PR is retargeted, while the Err(e) arm calls api::pull::edit(numbers[i + 1], base: Some(&trunk)) and writes that base into the stack state.

The code is the correct half, so acting on the implied direction (roll the retarget back too) would be a regression.

The item below has already merged at that point. Its branch is going away whatever happened to the replay, and Forgejo deletes or orphans it. A PR left pointing at a deleted base branch is a worse end state than the one --no-restack produces, and it is not recoverable by re-running ship, since ship's own pre-flight would then refuse the stack it created. The retarget is the same thing the _ => arm does for merge-commit lands, where no replay happens at all; the Err arm is converging on that known-good state, not inventing one. The comment above the block already says this.

So the prose is what was wrong. Both files now scope the rollback claim to the rebase, which is what is actually rolled back, and state separately that the PR directly above the landed item still moves to the trunk, with the reason.

While correcting it I found the claim was overstated in a second way the finding did not reach. The sentence is conditioned on "if the replay conflicts", but the Err arm catches every failure from restack_onto_trunk, including a force-push or a re-point that fails partway through the remainder. In those cases "nothing is force-pushed" is also false: the branches already pushed stay pushed. docs/stacks.md now says so.

I checked stopped_message and recovery_message for the same overclaim. Neither carries it; both are generic about what stopped and what already landed, and make no claim about retargeting. No change needed there. I did add a scope note to restack_onto_trunk_rolls_back_and_retargets_nothing_on_conflict, whose name reads like the general claim but which only exercises restack_onto_trunk in isolation, where retargeting nothing is correct.

Not changed

I checked whether numbers[i + 1] in the Err arm can panic on the last item. It cannot: plan_restack returns None when above.is_empty(), so the (true, Some(plan)) arm guarantees an item above. No defensive get() added.

State

cargo test 726 passing, cargo clippy --all-targets -D warnings clean, cargo fmt --check clean.

One caveat on the Forseti signal, since it would be easy to over-read. Both P1s were already reported as cleared at 1e2880c, before this fix landed, and that commit changed neither the acceptance set nor the docs sentence. So the clean run at 1ca2a26 is consistent with the fix but is not by itself evidence for it. The mutation checks above are the load-bearing part.

Both P1s from the review at `226c63719fbd` are addressed in `1ca2a26`. One was a real logic defect and is fixed in code. The other identified a real contradiction but pointed at the wrong half, so the fix is in the prose. ## Finding 2, `src/cli/stack.rs:1840`, the re-validation loop: confirmed, fixed in code The mechanism is exactly as described. `ExpectedHead.shas` accumulates: `validated()` seeds it with the pre-flight sha and `pushed()` appends each force-pushed one. `revalidate_blocker` only objected when the reported head was in neither, so a forge still serving the pre-restack head returned `None`, and the loop broke on `blocker.is_none()` before `entry.restacked` was ever consulted. The retry budget was dead code for precisely the race the comment above `REVALIDATE_ATTEMPTS` describes. Worth recording how it got there, because it was not an oversight. The acceptance was deliberate and there was a test asserting it, in `revalidate_accepts_the_head_fj_force_pushed_and_its_re_armed_checks`: ```rust // The pre-restack head still counts as fj's own: Forgejo can lag a // moment behind the force-push. assert_eq!(revalidate_blocker(&lagging, &expected, true), None); ``` That is a correct observation with the wrong remedy. Two mechanisms were built for the same lag, and the lenient one silently disabled the careful one. That assertion is now inverted. ### On the consequence, where I do not follow the finding's framing The review says this risks "merging the stale stack history". I do not think that is what happens, and it is worth being precise because the real hazard is a different one. By the time `revalidate` runs for the next item, `restack_onto_trunk` has already returned `Ok`. That means the force-push completed and `api::pull::edit` retargeted the base, both awaited. The remote ref is already at the new sha. `api::pull::merge` sends no `head_commit_id` (`MergeOptions` has no such field), so the merge is by ref, and the ref has moved. The content that merges is the replayed content, not the pre-squash history. What is actually stale is the PR *record*, and that is the problem, because `revalidate` is a gate that reads its whole verdict off that record. `state`, `mergeable`, and the checks are all taken from a snapshot computed for the pre-restack head against the pre-retarget base. `mergeable` is the dangerous one, and it is worse than "computed for the old head": fj invalidated *both* of its inputs milliseconds earlier, the head by force-pushing and the base by retargeting it to the trunk. A `mergeable: true` from that snapshot carries no information about whether the replayed head merges into the trunk. The gate whose entire job is to catch "this stopped merging cleanly after something below it landed" is reading stale data in exactly the situation a restack creates. The failure mode that follows is a degraded one rather than corruption: fj proceeds, the forge refuses the conflicting merge, `api::pull::merge` errors, and the user gets `recovery_message` ("a merge failed partway, repair by hand") when the design intends `stopped_message` ("stopped before merging, here is what landed, nothing else was touched"). A clean stop turns into a mid-run failure with the bottom already merged. Real, but I would not describe it as merging the wrong tree. One amplification neither the finding nor I spotted at first. Every land restacks again and calls `pushed()` again, so an entry deep in the stack accumulates several shas fj pushed. Under the old membership test the lag tolerance compounded with depth: by the third item, three different superseded heads all read as ready. `revalidate_lag_tolerance_does_not_compound_down_the_stack` covers this. ### The fix The acceptance set was answering two questions with one membership test. "Did somebody else move this head" wants the whole set; "has the forge observed my force-push yet" wants only the newest. Those are now separate: - a sha fj superseded, and only when fj actually force-pushed, is `Stale`: re-read, do not judge anything else off that snapshot - a sha fj never pushed is `Blocked`: stop now, no retry, unchanged from today - a `Stale` that outlives `REVALIDATE_ATTEMPTS` becomes a blocker rather than a pass, because a forge that never caught up is itself a reason not to merge Returning early on `Stale` is the part that closes the `mergeable` hole: no gate is evaluated on a record describing a shape the restack already replaced. Once the forge catches up, the same gates run against a snapshot of the post-restack, post-retarget state, which is what they were always meant to see. Since `Stale` requires `expected.restacked`, the non-restacked path cannot reach it, so the common case takes no new latency. ### Tests Four new cases plus the inverted one, and I mutation-checked rather than trusting green. Restoring the old `contains()` acceptance fails 4 of the new tests and leaves all 8 pre-existing ones passing, which is the check that the fix did not simply widen acceptance. Separately, mutating `Revalidation::blocker` so an exhausted retry budget returns `None` fails the test that asserts a stale read is terminal. ## Finding 1, `src/cli/stack.rs:1887`, retarget on replay failure: contradiction confirmed, direction refuted, fixed in the docs The review is right that the code and the prose disagree, and right about which lines. `CHANGELOG.md` and `docs/stacks.md` both said a conflicting replay means no PR is retargeted, while the `Err(e)` arm calls `api::pull::edit(numbers[i + 1], base: Some(&trunk))` and writes that base into the stack state. The code is the correct half, so acting on the implied direction (roll the retarget back too) would be a regression. The item below has already merged at that point. Its branch is going away whatever happened to the replay, and Forgejo deletes or orphans it. A PR left pointing at a deleted base branch is a worse end state than the one `--no-restack` produces, and it is not recoverable by re-running ship, since ship's own pre-flight would then refuse the stack it created. The retarget is the same thing the `_ =>` arm does for merge-commit lands, where no replay happens at all; the `Err` arm is converging on that known-good state, not inventing one. The comment above the block already says this. So the prose is what was wrong. Both files now scope the rollback claim to the rebase, which is what is actually rolled back, and state separately that the PR directly above the landed item still moves to the trunk, with the reason. While correcting it I found the claim was overstated in a second way the finding did not reach. The sentence is conditioned on "if the replay conflicts", but the `Err` arm catches every failure from `restack_onto_trunk`, including a force-push or a re-point that fails partway through the remainder. In those cases "nothing is force-pushed" is also false: the branches already pushed stay pushed. `docs/stacks.md` now says so. I checked `stopped_message` and `recovery_message` for the same overclaim. Neither carries it; both are generic about what stopped and what already landed, and make no claim about retargeting. No change needed there. I did add a scope note to `restack_onto_trunk_rolls_back_and_retargets_nothing_on_conflict`, whose name reads like the general claim but which only exercises `restack_onto_trunk` in isolation, where retargeting nothing is correct. ## Not changed I checked whether `numbers[i + 1]` in the `Err` arm can panic on the last item. It cannot: `plan_restack` returns `None` when `above.is_empty()`, so the `(true, Some(plan))` arm guarantees an item above. No defensive `get()` added. ## State `cargo test` 726 passing, `cargo clippy --all-targets -D warnings` clean, `cargo fmt --check` clean. One caveat on the Forseti signal, since it would be easy to over-read. Both P1s were already reported as cleared at `1e2880c`, before this fix landed, and that commit changed neither the acceptance set nor the docs sentence. So the clean run at `1ca2a26` is consistent with the fix but is not by itself evidence for it. The mutation checks above are the load-bearing part.
stephen deleted branch fix/stack-ship-restack-after-land 2026-07-30 22:54:23 +00:00
Sign in to join this conversation.
No description provided.