Commit 91f00d4
committed
feat(runway): move a change's head branch before pushing the target
## 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.1 parent ec27110 commit 91f00d4
6 files changed
Lines changed: 730 additions & 58 deletions
File tree
- runway/extension/merger/git
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
| |||
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
28 | | - | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
29 | 33 | | |
30 | 34 | | |
31 | 35 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
82 | | - | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
83 | 99 | | |
84 | 100 | | |
85 | 101 | | |
| |||
0 commit comments