Skip to content

Commit a8a9050

Browse files
jdclaude
andauthored
fix(stack): repoint moving PR bases to trunk before force-push (#1559)
Reordering the commits of a stack and re-pushing could make GitHub spuriously mark a pull request as merged and auto-close it (and its dependents). The merge is irreversible, so the stack's PR <-> commit mapping is destroyed: `mergify stack list` then shows the reordered commit as a phantom "merged" PR and its sibling as "no PR". Root cause: `stack push` force-pushes every head branch in one atomic `git push`, then updates the PR bases afterwards. When commits are reordered, the branches hold the new order while the PRs still carry their pre-reorder bases. During that window a PR's head branch becomes an ancestor of its own (stale) base branch — e.g. the new-bottom PR's head ends up contained in the branch that now holds the commit stacked on top of it. GitHub reads "head is an ancestor of base" as "merged". This was confirmed empirically: force-pushing only the reordered heads, with no base change at all, is enough to trigger the auto-merge. Fix: before the force-push, repoint any PR whose base is moving onto the trunk. A head branch is never an ancestor of the trunk, so the push can no longer trigger the spurious merge; the real new base is set afterwards by `create_or_update_pr`, once every branch holds its final content and each head is strictly ahead of its parent again. PRs already based on the trunk or whose base is unchanged are left alone, so the common non-reorder push issues no extra API calls. Verified end-to-end against a sandbox repo: a 2-commit stack reordered and re-pushed now keeps both PRs open with correctly flipped bases, and the PR timeline shows the base repoint landing before the head force-push, with no `merged`/`closed` event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e18dc2a commit a8a9050

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

mergify_cli/stack/push.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,53 @@ async def push_branches(
210210
os.environ.pop("MERGIFY_STACK_PUSH", None)
211211

212212

213+
async def neutralize_stale_bases(
214+
client: httpx.AsyncClient,
215+
user: str,
216+
repo: str,
217+
local_changes: list[changes.LocalChange],
218+
trunk_base: str,
219+
) -> None:
220+
"""Repoint at-risk PR bases onto the trunk before the force-push.
221+
222+
When commits are reordered, a PR's branch can move *below* a commit that
223+
used to sit beneath it. The atomic force-push then rewrites every head
224+
branch at once, while the PRs on GitHub still carry their pre-reorder
225+
bases. During that window a PR's head branch can become an ancestor of
226+
its own (stale) base branch — for example the new-bottom PR's head ends
227+
up contained in the branch that now holds the commit stacked on top of
228+
it. GitHub interprets "head is an ancestor of base" as "merged" and
229+
irreversibly auto-closes the PR (and closes its dependents).
230+
231+
To avoid that, any PR whose base is moving is first repointed to the
232+
trunk: a head branch is never an ancestor of the trunk, so the upcoming
233+
force-push cannot trigger the spurious merge. The real new base is set
234+
afterwards by ``create_or_update_pr`` once every branch holds its final
235+
content (where each head is strictly ahead of its parent again).
236+
237+
PRs already based on the trunk, or whose base is unchanged, are left
238+
alone — the common non-reorder push issues no extra API calls.
239+
"""
240+
sem = asyncio.Semaphore(MAX_CONCURRENT_API_CALLS)
241+
242+
async def _neutralize(pull: github_types.PullRequest) -> None:
243+
async with sem:
244+
await client.patch(
245+
f"/repos/{user}/{repo}/pulls/{pull['number']}",
246+
json={"base": trunk_base},
247+
)
248+
249+
tasks = [
250+
_neutralize(change.pull)
251+
for change in local_changes
252+
if change.action == "update"
253+
and change.pull is not None
254+
and change.pull["base"]["ref"] != trunk_base
255+
and change.pull["base"]["ref"] != change.base_branch
256+
]
257+
await asyncio.gather(*tasks)
258+
259+
213260
async def _git_patch_id(sha: str) -> str:
214261
"""Get the patch-id of a commit, stable across rebases."""
215262
diff = await utils.git("show", sha)
@@ -626,6 +673,20 @@ async def stack_push(
626673
change.commit_sha,
627674
)
628675

676+
# Before force-pushing, repoint any PR whose base is moving onto the
677+
# trunk. Reordering can make a PR's head branch momentarily an ancestor
678+
# of its stale base branch once the atomic force-push lands, which
679+
# GitHub would irreversibly auto-close as "merged". See
680+
# neutralize_stale_bases for the full rationale.
681+
with console.status("Neutralizing reordered pull request bases..."):
682+
await neutralize_stale_bases(
683+
client,
684+
user,
685+
repo,
686+
planned_changes.locals,
687+
base_branch,
688+
)
689+
629690
with console.status("Pushing stacked branches..."):
630691
await push_branches(
631692
remote,

0 commit comments

Comments
 (0)