diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index 07c9170..6799b29 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -380,13 +380,29 @@ async def checkout( ctx: click.Context, *, author: str | None, - repository: str, + repository: str | None, branch: str, branch_prefix: str | None, dry_run: bool, trunk: tuple[str, str], ) -> None: - user, repo = repository.split("/") + remote, _base_branch = trunk + if repository is not None: + repository_parts = repository.split("/", maxsplit=1) + if ( + len(repository_parts) != 2 + or not repository_parts[0] + or not repository_parts[1] + ): + raise click.BadParameter( + "Repository must be in the format 'owner/repo'", + param_hint="--repository", + ) + user, repo = repository_parts + else: + user, repo = utils.get_slug( + await utils.git("config", "--get", f"remote.{remote}.url"), + ) await stack_checkout_mod.stack_checkout( ctx.obj["github_server"], ctx.obj["token"], diff --git a/mergify_cli/stack/push.py b/mergify_cli/stack/push.py index cad0f87..a79228f 100644 --- a/mergify_cli/stack/push.py +++ b/mergify_cli/stack/push.py @@ -212,11 +212,28 @@ async def stack_push( if skip_rebase: console.log(f"branch `{dest_branch}` rebase skipped (--skip-rebase)") else: + from mergify_cli.stack import sync as stack_sync_mod + with console.status( f"Rebasing branch `{dest_branch}` on `{remote}/{base_branch}`...", ): - await utils.git("pull", "--rebase", remote, base_branch) - console.log(f"branch `{dest_branch}` rebased on `{remote}/{base_branch}`") + await utils.git("fetch", remote, base_branch) + sync_status = await stack_sync_mod.smart_rebase( + github_server, + token, + trunk=trunk, + branch_prefix=branch_prefix, + author=author, + ) + if sync_status.merged: + console.log( + f"branch `{dest_branch}` rebased on `{remote}/{base_branch}` " + f"(dropped {len(sync_status.merged)} merged commit(s))", + ) + else: + console.log( + f"branch `{dest_branch}` rebased on `{remote}/{base_branch}`", + ) rebase_required = False if dry_run and not skip_rebase: diff --git a/mergify_cli/stack/sync.py b/mergify_cli/stack/sync.py new file mode 100644 index 0000000..473c545 --- /dev/null +++ b/mergify_cli/stack/sync.py @@ -0,0 +1,266 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import dataclasses +import os +import pathlib +import sys +import tempfile +import typing + +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.stack import changes +from mergify_cli.stack.push import LocalBranchInvalidError +from mergify_cli.stack.push import check_local_branch + + +@dataclasses.dataclass +class MergedCommit: + """A commit whose PR has been merged.""" + + commit_sha: str + title: str + pull_number: int + pull_url: str + + +@dataclasses.dataclass +class RemainingCommit: + """A commit that still has an open PR or no PR yet.""" + + commit_sha: str + title: str + + +@dataclasses.dataclass +class SyncStatus: + """Result of a sync status check for the current stack.""" + + branch: str + trunk: str + merged: list[MergedCommit] + remaining: list[RemainingCommit] + + @property + def all_merged(self) -> bool: + """True when every commit in the stack has been merged.""" + return bool(self.merged) and not self.remaining + + @property + def up_to_date(self) -> bool: + """True when there are no merged commits (nothing to rebase away).""" + return not self.merged + + +async def get_sync_status( + github_server: str, + token: str, + *, + trunk: tuple[str, str], + branch_prefix: str | None = None, + author: str | None = None, +) -> SyncStatus: + """Compute the sync status for the current stack. + + Args: + github_server: GitHub API server URL + token: GitHub personal access token + trunk: Tuple of (remote, branch) for the trunk + branch_prefix: Optional branch prefix for stack branches + author: Optional author filter (defaults to token owner) + + Returns: + SyncStatus classifying each commit as merged or remaining + """ + dest_branch = await utils.git_get_branch_name() + + if author is None: + async with utils.get_github_http_client(github_server, token) as client: + r_author = await client.get("/user") + author = typing.cast("str", r_author.json()["login"]) + + if branch_prefix is None: + branch_prefix = await utils.get_default_branch_prefix(author) + + try: + check_local_branch(branch_name=dest_branch, branch_prefix=branch_prefix) + except LocalBranchInvalidError as e: + console.print(f"[red] {e.message} [/]") + console.print( + "You should run `mergify stack sync` on the branch you created in the first place", + ) + sys.exit(1) + + remote, base_branch = trunk + + user, repo = utils.get_slug( + await utils.git("config", "--get", f"remote.{remote}.url"), + ) + + if base_branch == dest_branch: + remote_url = await utils.git("remote", "get-url", remote) + console.print( + f"Your local branch `{dest_branch}` targets itself: " + f"`{remote}/{base_branch}` (at {remote_url}@{base_branch}).\n" + "You should either fix the target branch or rename your local branch.\n\n" + f"* To fix the target branch: " + f"`git branch {dest_branch} --set-upstream-to={remote}/{base_branch}`\n" + f"* To rename your local branch: " + f"`git branch -M {dest_branch} new-branch-name`", + style="red", + ) + sys.exit(1) + + stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch + + 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", + style="red", + ) + sys.exit(1) + + async with utils.get_github_http_client(github_server, token) as client: + remote_changes = await changes.get_remote_changes( + client, + user, + repo, + stack_prefix, + author, + ) + + stack_changes = await changes.get_changes( + base_commit_sha=base_commit_sha, + stack_prefix=stack_prefix, + base_branch=base_branch, + dest_branch=dest_branch, + remote_changes=remote_changes, + only_update_existing_pulls=False, + next_only=False, + ) + + merged: list[MergedCommit] = [] + remaining: list[RemainingCommit] = [] + + for local_change in stack_changes.locals: + if local_change.action == "skip-merged": + pull = local_change.pull + merged.append( + MergedCommit( + commit_sha=local_change.commit_sha, + title=local_change.title, + pull_number=int(pull["number"]) if pull else 0, + pull_url=pull["html_url"] if pull else "", + ), + ) + else: + remaining.append( + RemainingCommit( + commit_sha=local_change.commit_sha, + title=local_change.title, + ), + ) + + return SyncStatus( + branch=dest_branch, + trunk=f"{remote}/{base_branch}", + merged=merged, + remaining=remaining, + ) + + +def _write_drop_script(merged_shas: set[str]) -> pathlib.Path: + """Write a temporary bash script that acts as GIT_SEQUENCE_EDITOR to drop merged commits. + + The script reads the rebase todo file, replaces "pick " with + "drop " for each merged commit, and writes it back. + + Returns the path to the script. Caller is responsible for cleanup. + """ + short_shas = sorted(sha[:7] for sha in merged_shas) + + # Build sed expressions — one per merged SHA + sed_expressions = " ".join(f"-e 's/^pick {sha}/drop {sha}/'" for sha in short_shas) + + script = f'#!/bin/sh\nsed {sed_expressions} "$1" > "$1.tmp" && mv "$1.tmp" "$1"\n' + + fd, path = tempfile.mkstemp(suffix=".sh", prefix="mergify_drop_") + script_path = pathlib.Path(path) + os.close(fd) + script_path.write_text(script, encoding="utf-8") + script_path.chmod(0o755) + return script_path + + +async def smart_rebase( + github_server: str, + token: str, + *, + trunk: tuple[str, str], + branch_prefix: str | None = None, + author: str | None = None, +) -> SyncStatus: + """Rebase the stack onto trunk, dropping any merged commits. + + If merged commits are found, does a single `git rebase -i` that both + drops them and rebases onto the latest trunk. Otherwise, falls back + to a simple `git pull --rebase`. + + Callers are responsible for fetching the remote before calling this. + + Returns the SyncStatus so callers can inspect what happened. + """ + remote, base_branch = trunk + + status = await get_sync_status( + github_server, + token, + trunk=trunk, + branch_prefix=branch_prefix, + author=author, + ) + + if status.all_merged or status.up_to_date: + # Simple rebase — no merged commits to drop + await utils.git("pull", "--rebase", remote, base_branch) + return status + + # Merged commits found — drop them and rebase in one operation. + # Using git rebase -i onto trunk with a script that changes "pick" to "drop" + # for merged commits. This avoids conflicts from trying to reapply commits + # whose content was modified on GitHub before merge. + merged_shas = {m.commit_sha for m in status.merged} + script_path = _write_drop_script(merged_shas) + + env_backup = os.environ.get("GIT_SEQUENCE_EDITOR") + os.environ["GIT_SEQUENCE_EDITOR"] = str(script_path) + try: + await utils.git("rebase", "-i", f"{remote}/{base_branch}") + finally: + if env_backup is None: + os.environ.pop("GIT_SEQUENCE_EDITOR", None) + else: + os.environ["GIT_SEQUENCE_EDITOR"] = env_backup + script_path.unlink(missing_ok=True) + + return status diff --git a/mergify_cli/tests/conftest.py b/mergify_cli/tests/conftest.py index 0f2b358..1f1694f 100644 --- a/mergify_cli/tests/conftest.py +++ b/mergify_cli/tests/conftest.py @@ -80,8 +80,16 @@ def git_mock( "remote.origin.url", output="https://github.com/user/repo", ) - # Mock pull command + # Mock fetch and pull commands (used by smart_rebase) + git_mock_object.mock("fetch", "origin", "main", output="") git_mock_object.mock("pull", "--rebase", "origin", "main", output="") + # Mock branch prefix config (used by smart_rebase via get_sync_status) + git_mock_object.mock( + "config", + "--get", + "mergify-cli.stack-branch-prefix", + output="", + ) with mock.patch("mergify_cli.utils.git", git_mock_object): yield git_mock_object diff --git a/mergify_cli/tests/stack/test_checkout.py b/mergify_cli/tests/stack/test_checkout.py new file mode 100644 index 0000000..b65ae43 --- /dev/null +++ b/mergify_cli/tests/stack/test_checkout.py @@ -0,0 +1,158 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + +import pytest + +from mergify_cli.stack import checkout as stack_checkout_mod + + +if TYPE_CHECKING: + import respx + + from mergify_cli.tests import utils as test_utils + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_checkout_no_prs( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that checkout exits cleanly when no stacked PRs are found.""" + git_mock.mock( + "config", + "--get", + "mergify-cli.stack-branch-prefix", + output="", + ) + respx_mock.get("/search/issues").respond(200, json={"items": []}) + + with pytest.raises(SystemExit, match="0"): + await stack_checkout_mod.stack_checkout( + github_server="https://api.github.com/", + token="", + user="user", + repo="repo", + branch_prefix=None, + branch="my-branch", + author="author", + trunk=("origin", "main"), + dry_run=True, + ) + + +async def test_stack_checkout_repository_from_remote( + git_mock: test_utils.GitMock, +) -> None: + """Test that the CLI checkout function derives user/repo from git remote when --repository is not provided.""" + git_mock.mock( + "config", + "--get", + "remote.origin.url", + output="https://github.com/myorg/myrepo.git", + ) + + with mock.patch( + "mergify_cli.stack.cli.stack_checkout_mod.stack_checkout", + ) as mock_checkout: + mock_checkout.return_value = None + + from mergify_cli.stack.cli import checkout + + # Access the original async function through the decorator chain: + # click.pass_context -> run_with_asyncio -> async def + assert checkout.callback is not None + checkout_async = checkout.callback.__wrapped__.__wrapped__ # type: ignore[attr-defined] + + ctx = mock.MagicMock() + ctx.obj = { + "github_server": "https://api.github.com/", + "token": "test-token", + } + + await checkout_async( + ctx, + author="author", + repository=None, + branch="my-branch", + branch_prefix="prefix", + dry_run=True, + trunk=("origin", "main"), + ) + + mock_checkout.assert_called_once_with( + "https://api.github.com/", + "test-token", + user="myorg", + repo="myrepo", + branch_prefix="prefix", + branch="my-branch", + author="author", + trunk=("origin", "main"), + dry_run=True, + ) + + +async def test_stack_checkout_repository_explicit( + git_mock: test_utils.GitMock, +) -> None: + """Test that checkout uses the explicit --repository value when provided.""" + with mock.patch( + "mergify_cli.stack.cli.stack_checkout_mod.stack_checkout", + ) as mock_checkout: + mock_checkout.return_value = None + + from mergify_cli.stack.cli import checkout + + assert checkout.callback is not None + checkout_async = checkout.callback.__wrapped__.__wrapped__ # type: ignore[attr-defined] + + ctx = mock.MagicMock() + ctx.obj = { + "github_server": "https://api.github.com/", + "token": "test-token", + } + + await checkout_async( + ctx, + author="author", + repository="explicit-owner/explicit-repo", + branch="my-branch", + branch_prefix="prefix", + dry_run=True, + trunk=("origin", "main"), + ) + + mock_checkout.assert_called_once_with( + "https://api.github.com/", + "test-token", + user="explicit-owner", + repo="explicit-repo", + branch_prefix="prefix", + branch="my-branch", + author="author", + trunk=("origin", "main"), + dry_run=True, + ) + + # git remote URL should NOT have been queried + assert not git_mock.has_been_called_with( + "config", + "--get", + "remote.origin.url", + ) diff --git a/mergify_cli/tests/stack/test_sync.py b/mergify_cli/tests/stack/test_sync.py new file mode 100644 index 0000000..a0a2a70 --- /dev/null +++ b/mergify_cli/tests/stack/test_sync.py @@ -0,0 +1,761 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest + +from mergify_cli.stack import sync as stack_sync_mod +from mergify_cli.tests import utils as test_utils + + +if TYPE_CHECKING: + import pathlib + + import respx + + +@pytest.mark.skipif(sys.platform == "win32", reason="Bash not available on Windows") +def test_write_drop_script_produces_working_script(tmp_path: pathlib.Path) -> None: + """Test that the generated drop script correctly modifies a rebase todo file.""" + merged_shas = {"abc1234567890abcdef1234567890abcdef123456"} + + script_path = stack_sync_mod._write_drop_script(merged_shas) + try: + # Create a fake rebase todo file + todo = tmp_path / "git-rebase-todo" + todo.write_text( + "pick abc1234 First commit\n" + "pick def5678 Second commit\n" + "pick ghi9012 Third commit\n", + ) + + # Run the script + subprocess.run( + [str(script_path), str(todo)], + check=True, + ) + + result = todo.read_text() + assert "drop abc1234 First commit\n" in result + assert "pick def5678 Second commit\n" in result + assert "pick ghi9012 Third commit\n" in result + finally: + script_path.unlink(missing_ok=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Bash not available on Windows") +def test_write_drop_script_multiple_shas(tmp_path: pathlib.Path) -> None: + """Test drop script with multiple merged commits.""" + merged_shas = { + "abc1234567890abcdef1234567890abcdef123456", + "ghi9012567890abcdef1234567890abcdef123456", + } + + script_path = stack_sync_mod._write_drop_script(merged_shas) + try: + todo = tmp_path / "git-rebase-todo" + todo.write_text( + "pick abc1234 First commit\n" + "pick def5678 Second commit\n" + "pick ghi9012 Third commit\n", + ) + + subprocess.run( + [str(script_path), str(todo)], + check=True, + ) + + result = todo.read_text() + assert "drop abc1234 First commit\n" in result + assert "pick def5678 Second commit\n" in result + assert "drop ghi9012 Third commit\n" in result + finally: + script_path.unlink(missing_ok=True) + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_detects_merged_commits( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that get_sync_status correctly classifies merged vs remaining commits.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First commit", + message="Message commit 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Second commit", + message="Message commit 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 + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First commit", + "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": "Second commit", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + result = await stack_sync_mod.get_sync_status( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert len(result.merged) == 1 + assert result.merged[0].title == "First commit" + assert result.merged[0].commit_sha == "commit1_sha" + + assert len(result.remaining) == 1 + assert result.remaining[0].title == "Second commit" + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_up_to_date( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that get_sync_status reports up_to_date when no commits are merged.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First commit", + message="Message commit 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf60", + ), + ) + 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", + }, + }, + ], + }, + ) + # commit1: open (not merged) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf60", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + result = await stack_sync_mod.get_sync_status( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert result.up_to_date is True + assert len(result.merged) == 0 + assert len(result.remaining) == 1 + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_sync_all_merged( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that get_sync_status reports all_merged when every commit is merged.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First commit", + message="Message commit 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf70", + ), + ) + 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", + }, + }, + ], + }, + ) + # commit1: merged + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf70", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge_sha", + "draft": False, + "node_id": "", + }, + ) + + result = await stack_sync_mod.get_sync_status( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert result.all_merged is True + assert len(result.merged) == 1 + assert len(result.remaining) == 0 + + +# --- smart_rebase() direct tests --- + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_smart_rebase_no_merged_uses_pull_rebase( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """smart_rebase with no merged commits falls back to git pull --rebase.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", 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": "", + }, + ) + + status = await stack_sync_mod.smart_rebase( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert status.up_to_date + assert git_mock.has_been_called_with("pull", "--rebase", "origin", "main") + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_smart_rebase_with_merged_uses_rebase_i( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """smart_rebase with merged commits uses git rebase -i to drop them.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("rebase", "-i", "origin/main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Merged commit", + 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", + }, + }, + ], + }, + ) + 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/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "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/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + status = await stack_sync_mod.smart_rebase( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert len(status.merged) == 1 + assert len(status.remaining) == 1 + 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_smart_rebase_all_merged_uses_pull_rebase( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """smart_rebase with all commits merged falls back to git pull --rebase.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("pull", "--rebase", "origin", "main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Merged 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": "Merged 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": "", + }, + ) + + status = await stack_sync_mod.smart_rebase( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert status.all_merged + assert git_mock.has_been_called_with("pull", "--rebase", "origin", "main") + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_smart_rebase_multiple_merged( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """smart_rebase drops multiple merged commits in one rebase.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("rebase", "-i", "origin/main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First merged", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Second merged", + message="Message 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf51", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit3_sha", + title="Open commit", + message="Message 3", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf52", + ), + ) + 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", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/3", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First merged", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge1", + "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": "Second merged", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "closed", + "merged_at": "2024-01-02T00:00:00Z", + "merge_commit_sha": "merge2", + "draft": False, + "node_id": "", + }, + ) + respx_mock.get("/repos/user/repo/pulls/3").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/3", + "number": "3", + "title": "Open commit", + "head": { + "sha": "commit3_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf52", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + status = await stack_sync_mod.smart_rebase( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert len(status.merged) == 2 + assert len(status.remaining) == 1 + assert status.merged[0].title == "First merged" + assert status.merged[1].title == "Second merged" + assert git_mock.has_been_called_with("rebase", "-i", "origin/main") + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_smart_rebase_mid_stack_merged( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """smart_rebase handles interleaved merged/open commits (A open, B merged, C open).""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + git_mock.mock("rebase", "-i", "origin/main", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First open", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Mid-stack merged", + message="Message 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf51", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit3_sha", + title="Last open", + message="Message 3", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf52", + ), + ) + 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", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/3", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First open", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "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": "Mid-stack merged", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "merge_commit_sha": "merge_sha", + "draft": False, + "node_id": "", + }, + ) + respx_mock.get("/repos/user/repo/pulls/3").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/3", + "number": "3", + "title": "Last open", + "head": { + "sha": "commit3_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf52", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + status = await stack_sync_mod.smart_rebase( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + assert len(status.merged) == 1 + assert status.merged[0].title == "Mid-stack merged" + assert len(status.remaining) == 2 + 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")