Skip to content

Commit 3bbedfe

Browse files
remyduthuclaude
andauthored
fix(stack): Rebase branch in dry-run mode for accurate plan output (#974)
The dry-run plan was showing stale pre-rebase commit hashes, making its output inconsistent with actual push. Rebase is now always performed unless --skip-rebase is explicitly passed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 26b1946 commit 3bbedfe

File tree

3 files changed

+185
-0
lines changed

3 files changed

+185
-0
lines changed

mergify_cli/stack/changes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ class Changes:
203203
locals: list[LocalChange] = dataclasses.field(default_factory=list)
204204
orphans: list[OrphanChange] = dataclasses.field(default_factory=list)
205205

206+
def replace_local_action(self, old: ActionT, new: ActionT) -> None:
207+
for change in self.locals:
208+
if change.action == old:
209+
change.action = new
210+
206211

207212
def display_plan(
208213
changes: Changes,

mergify_cli/stack/push.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,20 @@ async def stack_push(
215215
await utils.git("pull", "--rebase", remote, base_branch)
216216
console.log(f"branch `{dest_branch}` rebased on `{remote}/{base_branch}`")
217217

218+
rebase_required = False
219+
if dry_run and not skip_rebase:
220+
commits_behind = int(
221+
await utils.git("rev-list", "--count", f"HEAD..{remote}/{base_branch}"),
222+
)
223+
rebase_required = commits_behind > 0
224+
225+
if rebase_required:
226+
console.log(
227+
f"[orange]branch `{dest_branch}` is behind `{remote}/{base_branch}` "
228+
f"by {commits_behind} {'commit' if commits_behind == 1 else 'commits'}, "
229+
f"commit SHAs will differ after rebase[/]",
230+
)
231+
218232
base_commit_sha = await utils.git(
219233
"merge-base",
220234
"--fork-point",
@@ -249,6 +263,12 @@ async def stack_push(
249263
next_only=next_only,
250264
)
251265

266+
if rebase_required:
267+
# If the branch is behind, we know for sure that all the existing
268+
# pull requests will need to be updated, so we can directly plan
269+
# them as "update" instead of "skip-up-to-date".
270+
planned_changes.replace_local_action(old="skip-up-to-date", new="update")
271+
252272
changes.display_plan(
253273
planned_changes,
254274
create_as_draft=create_as_draft,

mergify_cli/tests/stack/test_push.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import pytest
2121

22+
from mergify_cli.stack import changes
2223
from mergify_cli.stack import push
2324
from mergify_cli.tests import utils as test_utils
2425

@@ -491,6 +492,142 @@ async def test_stack_update_keep_title_and_body(
491492
}
492493

493494

495+
@pytest.mark.respx(base_url="https://api.github.com/")
496+
async def test_stack_dry_run_does_not_rebase(
497+
git_mock: test_utils.GitMock,
498+
respx_mock: respx.MockRouter,
499+
) -> None:
500+
git_mock.commit(
501+
test_utils.Commit(
502+
sha="commit1_sha",
503+
title="Title commit 1",
504+
message="Message commit 1",
505+
change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50",
506+
),
507+
)
508+
git_mock.finalize()
509+
git_mock.mock("rev-list", "--count", "HEAD..origin/main", output="0")
510+
511+
respx_mock.get("/user").respond(200, json={"login": "author"})
512+
respx_mock.get("/search/issues").respond(200, json={"items": []})
513+
514+
with pytest.raises(SystemExit, match="0"):
515+
await push.stack_push(
516+
github_server="https://api.github.com/",
517+
token="",
518+
skip_rebase=False,
519+
next_only=False,
520+
branch_prefix="",
521+
dry_run=True,
522+
trunk=("origin", "main"),
523+
)
524+
525+
# Dry-run never rebases.
526+
assert not git_mock.has_been_called_with("pull", "--rebase", "origin", "main")
527+
528+
# No branches are pushed.
529+
assert not git_mock.has_been_called_with(
530+
"push",
531+
"-f",
532+
"origin",
533+
"commit1_sha:refs/heads/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50",
534+
)
535+
536+
537+
@pytest.mark.respx(base_url="https://api.github.com/")
538+
async def test_stack_dry_run_behind_flips_up_to_date_to_update(
539+
git_mock: test_utils.GitMock,
540+
respx_mock: respx.MockRouter,
541+
) -> None:
542+
# PR exists with matching SHA — normally "skip-up-to-date".
543+
# But branch is behind base, so rebase would change SHAs → "update".
544+
git_mock.commit(
545+
test_utils.Commit(
546+
sha="commit1_sha",
547+
title="Title commit 1",
548+
message="Message commit 1",
549+
change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50",
550+
),
551+
)
552+
git_mock.finalize()
553+
git_mock.mock("rev-list", "--count", "HEAD..origin/main", output="3")
554+
555+
respx_mock.get("/user").respond(200, json={"login": "author"})
556+
respx_mock.get("/search/issues").respond(
557+
200,
558+
json={
559+
"items": [
560+
{
561+
"pull_request": {
562+
"url": "https://api.github.com/repos/user/repo/pulls/42",
563+
},
564+
},
565+
],
566+
},
567+
)
568+
respx_mock.get("/repos/user/repo/pulls/42").respond(
569+
200,
570+
json={
571+
"html_url": "",
572+
"head": {
573+
"sha": "commit1_sha",
574+
"ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50",
575+
},
576+
"state": "open",
577+
"merged_at": None,
578+
"draft": False,
579+
},
580+
)
581+
582+
with pytest.raises(SystemExit, match="0"):
583+
await push.stack_push(
584+
github_server="https://api.github.com/",
585+
token="",
586+
skip_rebase=False,
587+
next_only=False,
588+
branch_prefix="",
589+
dry_run=True,
590+
trunk=("origin", "main"),
591+
)
592+
593+
# Dry-run never rebases.
594+
assert not git_mock.has_been_called_with("pull", "--rebase", "origin", "main")
595+
596+
597+
@pytest.mark.respx(base_url="https://api.github.com/")
598+
async def test_stack_dry_run_skip_rebase(
599+
git_mock: test_utils.GitMock,
600+
respx_mock: respx.MockRouter,
601+
) -> None:
602+
git_mock.commit(
603+
test_utils.Commit(
604+
sha="commit1_sha",
605+
title="Title commit 1",
606+
message="Message commit 1",
607+
change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50",
608+
),
609+
)
610+
git_mock.finalize()
611+
612+
respx_mock.get("/user").respond(200, json={"login": "author"})
613+
respx_mock.get("/search/issues").respond(200, json={"items": []})
614+
615+
with pytest.raises(SystemExit, match="0"):
616+
await push.stack_push(
617+
github_server="https://api.github.com/",
618+
token="",
619+
skip_rebase=True,
620+
next_only=False,
621+
branch_prefix="",
622+
dry_run=True,
623+
trunk=("origin", "main"),
624+
)
625+
626+
# Rebase check is skipped when --skip-rebase is passed.
627+
assert not git_mock.has_been_called_with("rev-list", "--count", "HEAD..origin/main")
628+
assert not git_mock.has_been_called_with("pull", "--rebase", "origin", "main")
629+
630+
494631
@pytest.mark.respx(base_url="https://api.github.com/")
495632
async def test_stack_on_destination_branch_raises_an_error(
496633
git_mock: test_utils.GitMock,
@@ -535,3 +672,26 @@ async def test_stack_without_common_commit_raises_an_error(
535672
dry_run=False,
536673
trunk=("origin", "main"),
537674
)
675+
676+
677+
def test_replace_local_action_flips_up_to_date() -> None:
678+
def _make_local_change(action: changes.ActionT) -> changes.LocalChange:
679+
return changes.LocalChange(
680+
id=changes.ChangeId(""),
681+
pull=None,
682+
commit_sha="",
683+
title="",
684+
message="",
685+
base_branch="",
686+
dest_branch="",
687+
action=action,
688+
)
689+
690+
planned = changes.Changes(stack_prefix="")
691+
planned.locals.append(_make_local_change("skip-up-to-date"))
692+
planned.locals.append(_make_local_change("create"))
693+
694+
planned.replace_local_action(old="skip-up-to-date", new="update")
695+
696+
assert planned.locals[0].action == "update"
697+
assert planned.locals[1].action == "create"

0 commit comments

Comments
 (0)