feat(runway): move a change's head branch to the commit it landed as - #542
Open
behinddwalls wants to merge 1 commit into
Open
feat(runway): move a change's head branch to the commit it landed as#542behinddwalls wants to merge 1 commit into
behinddwalls wants to merge 1 commit into
Conversation
behinddwalls
marked this pull request as ready for review
August 7, 2026 18:16
This was referenced Aug 7, 2026
behinddwalls
force-pushed
the
sq/merger-headbranch
branch
7 times, most recently
from
August 11, 2026 21:06
2d6fb43 to
5f4026e
Compare
behinddwalls
force-pushed
the
sq/merger-headbranch
branch
from
August 11, 2026 22:39
5f4026e to
051dbda
Compare
behinddwalls
force-pushed
the
sq/merger-headbranch
branch
from
August 11, 2026 22:52
051dbda to
91f00d4
Compare
behinddwalls
force-pushed
the
sq/merger-headbranch
branch
from
August 12, 2026 01:57
91f00d4 to
3be8b3d
Compare
## Summary ### Why? A provider decides whether a change merged while it processes the push to the target branch, comparing the change's recorded head against what that push makes reachable. `MERGE` and `PROMOTE` satisfy that by construction, but `REBASE` and `SQUASH_REBASE` produce new commit objects — so the change's original head appears nowhere in the target's history and the change is recorded as closed after it has, in every meaningful sense, landed. Reachability alone is not enough, and neither is atomicity. The comparison happens at one instant — while the target update is being processed — against the head the provider has recorded *at that moment*. Three orderings were measured against a live GitHub repository, each landing a rebased change whose new commit is provably an ancestor of the target afterwards: | Ordering | Result | | --- | --- | | target pushed, then head branch | closed, not merged | | both refs in one `git push --atomic` | closed, not merged | | head branch pushed, then target | **merged** | Only a separate, earlier push works. The atomic case fails for the same reason as the post-push case: the provider still evaluates the target update against the head it had recorded before the transaction. ### What? `Params.UpdateHeadBranch` moves each change's head branch to the commit that change became — its last replayed commit under `REBASE`, its single squashed commit under `SQUASH_REBASE` — as its own push, immediately before the target is pushed. It is provider-neutral. The branch is found by matching the change's pinned head SHA against the remote's branch tips, never by parsing a change number or calling an API, so the same mechanism serves a GitHub pull request, a GitLab merge request, or a bare branch. Three cases are declined rather than guessed: no matching branch (the ordinary case for a fork, whose branch lives in another repository), several matching branches, and the target branch itself. None of the three is a failure; those changes land normally. A branch that cannot be moved fails the merge, before the target is pushed. Landing a change while knowing its head could not be moved produces exactly the half-merged state the option exists to prevent, so the merge stops rather than completing into it. Each push carries a lease against the SHA the URI pinned, so an author pushing in the window between reading the remote and updating it fails the lease instead of losing work — and now fails the merge with it. Because the head branch now moves before the point of no return, an attempt that moves it and then loses the target push leaves it on a commit that never landed. Such a branch no longer answers to the pinned SHA, so a retry could not find it by matching tips and would strand it there. A tracker carries the resolved branch and the value the next lease must name across attempts, so the retry moves it on to the commit that did land. Off by default: moving a branch the merger was not asked to move is a surprise unless a deployment opted in. ## Test Plan ✅ `bazel test //runway/extension/merger/git:go_default_test` — 14 cases against a real bare repository covering both rewriting strategies, stacks, fork changes, ambiguous matches, the stale lease, and that `MERGE`/`PROMOTE`/dry-run leave branches untouched. Three are new to this ordering: the head branch has moved even when the target push is then rejected, a branch already moved by a failed attempt is moved on by the retry, and a head branch that cannot be pushed fails the land with the target untouched. Each was confirmed to fail against a deliberately reverted implementation, not merely to pass. ✅ The pre-receive race hook now contends only on the target ref, so it simulates target contention rather than rejecting the head-branch pushes that precede it. Existing retry tests are unaffected — they run with the flag off. ✅ Ordering verified end-to-end against a live GitHub repository, per the table above.
behinddwalls
force-pushed
the
sq/merger-headbranch
branch
from
August 12, 2026 05:47
3be8b3d to
0311792
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why?
A provider marks a change merged once its head commit is reachable from the target branch.
MERGEandPROMOTEsatisfy that by construction, butREBASEandSQUASH_REBASEproduce new commit objects — so the change's original head appears nowhere in the target's history and the change stays open after it has, in every meaningful sense, landed.What?
Params.UpdateHeadBranchadds a post-push step to the committing merge: each change's head branch is moved to the commit that change became — its last replayed commit underREBASE, its single squashed commit underSQUASH_REBASE.It is provider-neutral. The branch is found by matching the change's pinned head SHA against the remote's branch tips, never by parsing a change number or calling an API, so the same mechanism serves a GitHub pull request, a GitLab merge request, or a bare branch. Three cases are declined rather than guessed: no matching branch (the ordinary case for a fork, whose branch lives in another repository), several matching branches, and the target branch itself.
Each push carries a lease against the SHA the URI pinned, so an author pushing in the window between reading the remote and updating it aborts the update instead of losing work. Failures are reported and swallowed — the target is already pushed and cannot be unpushed, so a change left open with a stale head is recoverable, where failing the merge would ask the pipeline to retry completed work.
Off by default: moving a branch the merger was not asked to move is a surprise unless a deployment opted in.
Test Plan
✅
bazel test //runway/extension/merger/git:go_default_test— 11 new cases against a real bare repository covering both rewriting strategies, stacks, fork changes, ambiguous matches, the stale lease, and thatMERGE/PROMOTE/dry-run leave branches untouched. Existing cases unaffected with the flag off.Issues