From c73eb41a2613f3b3a6d53ff43c0a85b9bf4edabc Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 9 Apr 2026 16:37:12 +0200 Subject: [PATCH] feat: add `mergify stack sync` CLI command Exposes the smart_rebase algorithm as `mergify stack sync`, which fetches trunk, detects merged PRs, drops them, and rebases in one step. Supports --dry-run to preview what would happen. Change-Id: I6d641d3caf7b64b451b7d348bc4c1d5e8177de0d Claude-Session-Id: 2d9f4a52-4a1d-4a50-a459-3b57847dec9f --- mergify_cli/stack/cli.py | 33 +++ mergify_cli/stack/skill.md | 3 + mergify_cli/stack/sync.py | 13 +- mergify_cli/tests/stack/test_sync.py | 327 +++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 8 deletions(-) diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index 32763280..18396844 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -22,6 +22,7 @@ from mergify_cli.stack import session as stack_session_mod from mergify_cli.stack import setup as stack_setup_mod from mergify_cli.stack import skill as stack_skill_mod +from mergify_cli.stack import sync as stack_sync_mod def trunk_type( @@ -504,6 +505,38 @@ def skill() -> None: click.echo(stack_skill_mod.get_skill_content(), nl=False) +@stack.command(help="Sync the stack: fetch trunk, remove merged commits, rebase") +@click.pass_context +@click.option( + "--dry-run", + "-n", + is_flag=True, + default=False, + help="Show what would happen without making changes", +) +@click.option( + "--trunk", + "-t", + type=click.UNPROCESSED, + default=lambda: asyncio.run(utils.get_trunk()), + callback=trunk_type, + help="Change the target branch of the stack.", +) +@utils.run_with_asyncio +async def sync( + ctx: click.Context, + *, + dry_run: bool, + trunk: tuple[str, str], +) -> None: + await stack_sync_mod.stack_sync( + github_server=ctx.obj["github_server"], + token=ctx.obj["token"], + trunk=trunk, + dry_run=dry_run, + ) + + @stack.command(name="list", help="List the stack's commits and their associated PRs") @click.pass_context @click.option( diff --git a/mergify_cli/stack/skill.md b/mergify_cli/stack/skill.md index f6f80441..1378e719 100644 --- a/mergify_cli/stack/skill.md +++ b/mergify_cli/stack/skill.md @@ -33,10 +33,13 @@ A branch is a stack. Keep stacks short and focused: ```bash mergify stack new NAME # Create a new stack/branch for new work mergify stack push # Push and create/update PRs +mergify stack sync # Fetch trunk, remove merged commits, rebase mergify stack list # Show commit <-> PR mapping for current stack mergify stack list --json # Same, but machine-readable JSON output ``` +Use `mergify stack sync` to bring your stack up to date. It fetches the latest trunk, detects which PRs have been merged, removes those commits from your local branch, and rebases the remaining commits. Run this before starting new work on an existing stack. + Use `mergify stack list` to see which commits have been pushed, which PRs they map to, and whether the stack is up to date with the remote. This is the go-to command to understand the current state of a stack. Use `--json` when you need to parse the output programmatically. ## CRITICAL: Check Branch Before ANY Commit diff --git a/mergify_cli/stack/sync.py b/mergify_cli/stack/sync.py index 281c0d09..52879eac 100644 --- a/mergify_cli/stack/sync.py +++ b/mergify_cli/stack/sync.py @@ -128,14 +128,11 @@ async def get_sync_status( stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch - try: - base_commit_sha = await utils.git( - "merge-base", - "--fork-point", - f"{remote}/{base_branch}", - ) - except utils.CommandError: - base_commit_sha = "" + base_commit_sha = await utils.git( + "merge-base", + "--fork-point", + f"{remote}/{base_branch}", + ) if not base_commit_sha: console.print( f"Common commit between `{remote}/{base_branch}` and `{dest_branch}` branches not found", diff --git a/mergify_cli/tests/stack/test_sync.py b/mergify_cli/tests/stack/test_sync.py index a0a2a70c..a4a490b2 100644 --- a/mergify_cli/tests/stack/test_sync.py +++ b/mergify_cli/tests/stack/test_sync.py @@ -759,3 +759,330 @@ async def test_smart_rebase_mid_stack_merged( assert status.remaining[0].title == "First open" assert status.remaining[1].title == "Last open" assert git_mock.has_been_called_with("rebase", "-i", "origin/main") + + +# --- stack_sync() integration tests --- + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_up_to_date_after_rebase( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test: no merged commits — rebase onto trunk, report up to date.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("fetch", "origin", "main", output="") + git_mock.mock("pull", "--rebase", "origin", "main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Open commit", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.finalize() + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/1", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "Open commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + await stack_sync_mod.stack_sync( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "up to date" in captured.out + assert git_mock.has_been_called_with("fetch", "origin", "main") + assert git_mock.has_been_called_with("pull", "--rebase", "origin", "main") + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_drops_merged_and_rebases_in_one_operation( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test: merged commit detected, dropped and rebased in single git rebase -i. + + When sync finds merged commits, it does a single `git rebase -i origin/main` + with a drop script — this both removes the merged commit and rebases onto + trunk in one operation, avoiding conflicts from trying to reapply a commit + whose content was modified on GitHub before merge. + """ + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("fetch", "origin", "main", output="") + # Single rebase onto origin/main (not fork-point) + git_mock.mock("rebase", "-i", "origin/main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Modified on GitHub then merged", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Open commit", + message="Message 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf51", + ), + ) + git_mock.finalize() + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/1", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/2", + }, + }, + ], + }, + ) + # commit1: merged (modified on GitHub — would conflict with git pull --rebase) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "Modified on GitHub then merged", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge_sha_1", + "draft": False, + "node_id": "", + }, + ) + # commit2: open + respx_mock.get("/repos/user/repo/pulls/2").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/2", + "number": "2", + "title": "Open commit", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + await stack_sync_mod.stack_sync( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "Modified on GitHub then merged" in captured.out + assert "Dropped 1 merged" in captured.out + # Should use single rebase -i onto origin/main (not git pull --rebase) + assert git_mock.has_been_called_with("rebase", "-i", "origin/main") + assert not git_mock.has_been_called_with("pull", "--rebase", "origin", "main") + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_all_merged_suggests_checkout( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test: all commits merged — suggests switching to main.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("fetch", "origin", "main", output="") + git_mock.mock("pull", "--rebase", "origin", "main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Only commit", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.finalize() + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/1", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "Only commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge_sha", + "draft": False, + "node_id": "", + }, + ) + + await stack_sync_mod.stack_sync( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "All commits" in captured.out + assert "git checkout main" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_dry_run( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test: dry-run shows what would happen without fetching or rebasing.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Merged commit", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf80", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Open commit", + message="Message 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf81", + ), + ) + git_mock.finalize() + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/1", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/2", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "Merged commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf80", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge_sha_1", + "draft": False, + "node_id": "", + }, + ) + respx_mock.get("/repos/user/repo/pulls/2").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/2", + "number": "2", + "title": "Open commit", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf81", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + await stack_sync_mod.stack_sync( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + dry_run=True, + ) + + captured = capsys.readouterr() + assert "Merged commit" in captured.out + assert "1 commit(s) would remain" in captured.out + # No fetch or rebase in dry-run + assert not git_mock.has_been_called_with("fetch", "origin", "main") + assert not git_mock.has_been_called_with("pull", "--rebase", "origin", "main")