From e878e0a1eb1a98bc971edd375df2f3d4babac21a Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 10 Apr 2026 10:44:33 +0200 Subject: [PATCH 1/2] style: use compact Unicode symbols instead of emoji in CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AGENTS.md with CLI output style guideline: prefer compact Unicode symbols (✓ ✗ ● ○ —) over emoji for consistent terminal rendering. Replaces all emoji in the stack module (setup.py, pre-push.sh) with the compact symbols to match the queue module's existing style. Change-Id: Ie9a1cbe04ce1abdd5866e9f7de6d7000e95b9675 Claude-Session-Id: 2d9f4a52-4a1d-4a50-a459-3b57847dec9f --- AGENTS.md | 20 ++++++++++++++++++++ mergify_cli/stack/hooks/scripts/pre-push.sh | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..77483aa6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Agent Instructions + +## CLI Output Style + +Use compact Unicode symbols for terminal output, not emoji. This keeps new and +updated terminal output consistent and avoids rendering issues across terminals. + +**Preferred symbols:** +- `✓` success, approved, merged +- `✗` failure, conflict, changes requested +- `●` active, pending, in progress +- `○` inactive, skipped, none +- `—` unknown, not applicable + +**Avoid in terminal output:** `✅`, `❌`, `🟢`, `🔴`, `📝`, `⚠️`, `🔄`, `📦`, `🤖`, `🔗` +and other emoji. They can render at inconsistent widths across terminals and +break column alignment. + +**Exception:** Emoji are acceptable in markdown output destined for GitHub +(PR comments, CI summaries) where they render consistently. diff --git a/mergify_cli/stack/hooks/scripts/pre-push.sh b/mergify_cli/stack/hooks/scripts/pre-push.sh index 56c34fa4..9a0bbaeb 100755 --- a/mergify_cli/stack/hooks/scripts/pre-push.sh +++ b/mergify_cli/stack/hooks/scripts/pre-push.sh @@ -40,7 +40,7 @@ if test "$has_change_id" -eq 0; then fi echo "" -echo "⚠ This branch is managed by Mergify stacks." +echo "This branch is managed by Mergify stacks." echo " Use 'mergify stack push' instead of 'git push'." echo "" echo " 'mergify stack push' will:" From e1821bbaba1fc0718b9a8fcdb32d07b1e8e81c12 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 10 Apr 2026 10:51:52 +0200 Subject: [PATCH 2/2] feat: add smart_rebase algorithm for merged commit handling Adds smart_rebase() that detects merged PRs via the GitHub API and handles them during rebase. If merged commits are found, it does a single `git rebase -i` that both drops them and rebases onto trunk, avoiding conflicts from PRs modified on GitHub before merge. If no commits are merged, falls back to simple `git pull --rebase`. Integrated into `mergify stack push` during its rebase step. Change-Id: If517fa97f6ed588bf1f2782aa14a1411936c9379 Claude-Session-Id: 2d9f4a52-4a1d-4a50-a459-3b57847dec9f --- mergify_cli/stack/push.py | 21 +- mergify_cli/stack/sync.py | 266 ++++++++++ mergify_cli/tests/conftest.py | 10 +- mergify_cli/tests/stack/test_sync.py | 761 +++++++++++++++++++++++++++ 4 files changed, 1055 insertions(+), 3 deletions(-) create mode 100644 mergify_cli/stack/sync.py create mode 100644 mergify_cli/tests/stack/test_sync.py diff --git a/mergify_cli/stack/push.py b/mergify_cli/stack/push.py index cad0f874..a79228f2 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 00000000..473c5456 --- /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 0f2b358c..1f1694f6 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_sync.py b/mergify_cli/tests/stack/test_sync.py new file mode 100644 index 00000000..a0a2a70c --- /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")