From f0e7332766b8d9e553267497c94e775766a65cf8 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 10 Apr 2026 10:52:34 +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: I14bca20f08f8fcd5d265c86ebd5e1fd2eac688c6 Claude-Session-Id: 2d9f4a52-4a1d-4a50-a459-3b57847dec9f --- mergify_cli/stack/cli.py | 33 +++ mergify_cli/stack/sync.py | 79 +++++++ mergify_cli/tests/stack/test_sync.py | 327 +++++++++++++++++++++++++++ skills/mergify-stack/SKILL.md | 3 + 4 files changed, 442 insertions(+) diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index f4947ebc..a8605ebd 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -21,6 +21,7 @@ from mergify_cli.stack import push as stack_push_mod from mergify_cli.stack import reorder as stack_reorder_mod from mergify_cli.stack import setup as stack_setup_mod +from mergify_cli.stack import sync as stack_sync_mod def trunk_type( @@ -440,6 +441,38 @@ async def github_action_auto_rebase(ctx: click.Context) -> None: ) +@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/sync.py b/mergify_cli/stack/sync.py index 473c5456..52879eac 100644 --- a/mergify_cli/stack/sync.py +++ b/mergify_cli/stack/sync.py @@ -264,3 +264,82 @@ async def smart_rebase( script_path.unlink(missing_ok=True) return status + + +async def stack_sync( + github_server: str, + token: str, + *, + trunk: tuple[str, str], + dry_run: bool = False, + branch_prefix: str | None = None, + author: str | None = None, +) -> None: + """Sync the current stack by removing merged commits and rebasing. + + Args: + github_server: GitHub API server URL + token: GitHub personal access token + trunk: Tuple of (remote, branch) for the trunk + dry_run: If True, only report what would be done + branch_prefix: Optional branch prefix for stack branches + author: Optional author filter (defaults to token owner) + """ + remote, base_branch = trunk + + # Dry-run: just check status and report + if dry_run: + with console.status("Checking sync status\u2026"): + status = await get_sync_status( + github_server, + token, + trunk=trunk, + branch_prefix=branch_prefix, + author=author, + ) + + if status.all_merged: + console.print( + f"All commits in the stack have been merged into {base_branch}.\n" + f"You can switch to {base_branch} with: git checkout {base_branch}", + ) + elif status.up_to_date: + console.print("Stack is up to date.") + else: + console.print( + "[bold]Dry run:[/] the following merged commits would be removed:", + ) + for m in status.merged: + console.print(f" - {m.title} (#{m.pull_number}, merged)") + console.print( + f"\n{len(status.remaining)} commit(s) would remain in the stack.", + ) + return + + # Fetch and sync + with console.status(f"Fetching {remote}/{base_branch}\u2026"): + await utils.git("fetch", remote, base_branch) + + with console.status(f"Rebasing onto {remote}/{base_branch}\u2026"): + status = await smart_rebase( + github_server, + token, + trunk=trunk, + branch_prefix=branch_prefix, + author=author, + ) + + if status.all_merged: + console.print( + f"All commits in the stack have been merged into {base_branch}.\n" + f"You can switch to {base_branch} with: git checkout {base_branch}", + ) + elif status.up_to_date: + console.print("Stack is up to date.") + else: + for m in status.merged: + console.print(f" ✓ Dropped: {m.title} (#{m.pull_number})") + console.print( + f"Dropped {len(status.merged)} merged commit(s). " + f"{len(status.remaining)} commit(s) remaining in the stack.", + ) 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") diff --git a/skills/mergify-stack/SKILL.md b/skills/mergify-stack/SKILL.md index f263429b..e151ff1e 100644 --- a/skills/mergify-stack/SKILL.md +++ b/skills/mergify-stack/SKILL.md @@ -44,10 +44,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