diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index f4947eb..c3fdec0 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -16,6 +16,7 @@ github_action_auto_rebase as stack_github_action_auto_rebase_mod, ) from mergify_cli.stack import list as stack_list_mod +from mergify_cli.stack import move as stack_move_mod from mergify_cli.stack import new as stack_new_mod from mergify_cli.stack import open as stack_open_mod from mergify_cli.stack import push as stack_push_mod @@ -215,6 +216,33 @@ async def reorder(*, commits: tuple[str, ...], dry_run: bool) -> None: await stack_reorder_mod.stack_reorder(list(commits), dry_run=dry_run) +@stack.command(help="Move a commit within the stack") +@click.argument("commit") +@click.argument("position", type=click.Choice(["before", "after", "first", "last"])) +@click.argument("target", required=False, default=None) +@click.option( + "--dry-run", + "-n", + is_flag=True, + default=False, + help="Show the plan without moving", +) +@utils.run_with_asyncio +async def move( + *, + commit: str, + position: str, + target: str | None, + dry_run: bool, +) -> None: + await stack_move_mod.stack_move( + commit_prefix=commit, + position=position, + target_prefix=target, + dry_run=dry_run, + ) + + @stack.command(help="Create a new stack branch") @click.argument("name") @click.option( diff --git a/mergify_cli/stack/move.py b/mergify_cli/stack/move.py new file mode 100644 index 0000000..64cf4e7 --- /dev/null +++ b/mergify_cli/stack/move.py @@ -0,0 +1,104 @@ +# +# 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 os +import sys + +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.stack.reorder import display_plan +from mergify_cli.stack.reorder import get_stack_commits +from mergify_cli.stack.reorder import match_commit +from mergify_cli.stack.reorder import run_rebase + + +async def stack_move( + commit_prefix: str, + position: str, + target_prefix: str | None, + *, + dry_run: bool, +) -> None: + os.chdir(await utils.git("rev-parse", "--show-toplevel")) + trunk = await utils.get_trunk() + base = await utils.git("merge-base", trunk, "HEAD") + commits = get_stack_commits(base) + + if not commits: + console.print("No commits in the stack", style="green") + return + + commit = match_commit(commit_prefix, commits) + + if position in {"before", "after"}: + if target_prefix is None: + console.print( + f"error: '{position}' requires a target commit", + style="red", + ) + sys.exit(1) + target = match_commit(target_prefix, commits) + if commit[0] == target[0]: + console.print( + "error: commit and target are the same", + style="red", + ) + sys.exit(1) + elif position in {"first", "last"}: + if target_prefix is not None: + console.print( + f"error: '{position}' does not accept a target commit", + style="red", + ) + sys.exit(1) + + # Compute new order + remaining = [c for c in commits if c[0] != commit[0]] + + if position == "first": + new_order = [commit, *remaining] + elif position == "last": + new_order = [*remaining, commit] + elif position == "before": + target_idx = next(i for i, c in enumerate(remaining) if c[0] == target[0]) + new_order = [*remaining[:target_idx], commit, *remaining[target_idx:]] + elif position == "after": + target_idx = next(i for i, c in enumerate(remaining) if c[0] == target[0]) + new_order = [ + *remaining[: target_idx + 1], + commit, + *remaining[target_idx + 1 :], + ] + + # Check if order changed + current_shas = [c[0] for c in commits] + new_shas = [c[0] for c in new_order] + if current_shas == new_shas: + console.print( + "Commit is already in the requested position", + style="green", + ) + return + + display_plan("Move plan:", new_order) + + if dry_run: + console.print("Dry run — no changes made", style="green") + return + + run_rebase(base, new_shas) + console.print("Commit moved successfully", style="green") diff --git a/mergify_cli/tests/stack/test_move.py b/mergify_cli/tests/stack/test_move.py new file mode 100644 index 0000000..a029381 --- /dev/null +++ b/mergify_cli/tests/stack/test_move.py @@ -0,0 +1,332 @@ +# +# 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 os +import re +import subprocess +from typing import TYPE_CHECKING + +import pytest + +from mergify_cli.stack.move import stack_move + + +if TYPE_CHECKING: + import pathlib + + +def _run_git(*args: str, cwd: pathlib.Path | None = None) -> str: + return subprocess.check_output( + ["git", *args], + text=True, + cwd=cwd, + ).strip() + + +def _create_commit( + repo: pathlib.Path, + filename: str, + content: str, + message: str, +) -> tuple[str, str | None]: + """Create a commit and return (sha, change_id).""" + (repo / filename).write_text(content) + _run_git("add", filename, cwd=repo) + _run_git("commit", "-m", message, cwd=repo) + sha = _run_git("rev-parse", "HEAD", cwd=repo) + body = _run_git("log", "-1", "--format=%b", "HEAD", cwd=repo) + change_id_match = re.search(r"Change-Id: (I[0-9a-z]{40})", body) + return sha, change_id_match.group(1) if change_id_match else None + + +def _get_commit_subjects(repo: pathlib.Path, n: int = 10) -> list[str]: + """Return the last n commit subjects, oldest first.""" + raw = _run_git( + "log", + "--reverse", + f"-{n}", + "--format=%s", + cwd=repo, + ) + return [line for line in raw.splitlines() if line.strip()] + + +def _setup_tracking(repo: pathlib.Path) -> None: + """Create a bare origin and set up tracking for the current branch.""" + origin_path = repo.parent / f"{repo.name}_origin.git" + _run_git("init", "--bare", str(origin_path)) + _run_git("remote", "add", "origin", str(origin_path), cwd=repo) + _run_git("push", "origin", "main", cwd=repo) + _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) + + +@pytest.fixture +def stack_repo( + git_repo_with_hooks: pathlib.Path, +) -> tuple[pathlib.Path, list[tuple[str, str | None]]]: + """Create a repo with 3 commits (A, B, C) on a feature branch.""" + repo = git_repo_with_hooks + + # Create an initial commit on main + (repo / "init.txt").write_text("init") + _run_git("add", "init.txt", cwd=repo) + _run_git("commit", "-m", "Initial commit", cwd=repo) + + _setup_tracking(repo) + + # Create a feature branch + _run_git("checkout", "-b", "feature", "main", cwd=repo) + _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) + + # Create 3 commits + commits = [] + for label, filename in [("A", "a.txt"), ("B", "b.txt"), ("C", "c.txt")]: + sha, cid = _create_commit(repo, filename, f"content {label}", f"Commit {label}") + commits.append((sha, cid)) + + return repo, commits + + +class TestStackMove: + async def test_move_before( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move C before A, verify order: C, A, B.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + sha_c = commits[2][0][:12] + + await stack_move(sha_c, "before", sha_a, dry_run=False) + + subjects = _get_commit_subjects(repo) + feature_subjects = [s for s in subjects if s.startswith("Commit")] + assert feature_subjects == ["Commit C", "Commit A", "Commit B"] + + async def test_move_after( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move A after B, verify order: B, A, C.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + sha_b = commits[1][0][:12] + + await stack_move(sha_a, "after", sha_b, dry_run=False) + + subjects = _get_commit_subjects(repo) + feature_subjects = [s for s in subjects if s.startswith("Commit")] + assert feature_subjects == ["Commit B", "Commit A", "Commit C"] + + async def test_move_first( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move C first, verify order: C, A, B.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_c = commits[2][0][:12] + + await stack_move(sha_c, "first", None, dry_run=False) + + subjects = _get_commit_subjects(repo) + feature_subjects = [s for s in subjects if s.startswith("Commit")] + assert feature_subjects == ["Commit C", "Commit A", "Commit B"] + + async def test_move_last( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move A last, verify order: B, C, A.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + + await stack_move(sha_a, "last", None, dry_run=False) + + subjects = _get_commit_subjects(repo) + feature_subjects = [s for s in subjects if s.startswith("Commit")] + assert feature_subjects == ["Commit B", "Commit C", "Commit A"] + + async def test_move_dry_run( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Verify dry-run doesn't change anything.""" + repo, commits = stack_repo + os.chdir(repo) + + head_before = _run_git("rev-parse", "HEAD", cwd=repo) + + sha_c = commits[2][0][:12] + + await stack_move(sha_c, "first", None, dry_run=True) + + head_after = _run_git("rev-parse", "HEAD", cwd=repo) + assert head_before == head_after + + async def test_move_already_in_position( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move A first when A is already first, verify no-op.""" + repo, commits = stack_repo + os.chdir(repo) + + head_before = _run_git("rev-parse", "HEAD", cwd=repo) + + sha_a = commits[0][0][:12] + + await stack_move(sha_a, "first", None, dry_run=False) + + head_after = _run_git("rev-parse", "HEAD", cwd=repo) + assert head_before == head_after + + async def test_move_already_last( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Move C last when C is already last, verify no-op.""" + repo, commits = stack_repo + os.chdir(repo) + + head_before = _run_git("rev-parse", "HEAD", cwd=repo) + + sha_c = commits[2][0][:12] + + await stack_move(sha_c, "last", None, dry_run=False) + + head_after = _run_git("rev-parse", "HEAD", cwd=repo) + assert head_before == head_after + + async def test_move_commit_equals_target( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """move X before X should fail.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + + with pytest.raises(SystemExit) as exc_info: + await stack_move(sha_a, "before", sha_a, dry_run=False) + assert exc_info.value.code == 1 + + async def test_move_target_missing_for_before( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Call with position='before' but target=None should fail.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + + with pytest.raises(SystemExit) as exc_info: + await stack_move(sha_a, "before", None, dry_run=False) + assert exc_info.value.code == 1 + + async def test_move_target_missing_for_after( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Call with position='after' but target=None should fail.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + + with pytest.raises(SystemExit) as exc_info: + await stack_move(sha_a, "after", None, dry_run=False) + assert exc_info.value.code == 1 + + async def test_move_target_provided_for_first( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Call with position='first' and a target should fail.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + sha_b = commits[1][0][:12] + + with pytest.raises(SystemExit) as exc_info: + await stack_move(sha_a, "first", sha_b, dry_run=False) + assert exc_info.value.code == 1 + + async def test_move_target_provided_for_last( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Call with position='last' and a target should fail.""" + repo, commits = stack_repo + os.chdir(repo) + + sha_a = commits[0][0][:12] + sha_b = commits[1][0][:12] + + with pytest.raises(SystemExit) as exc_info: + await stack_move(sha_a, "last", sha_b, dry_run=False) + assert exc_info.value.code == 1 + + async def test_move_with_change_id( + self, + stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], + ) -> None: + """Use Change-Id prefix, verify it works.""" + repo, commits = stack_repo + os.chdir(repo) + + cid_c = commits[2][1] + assert cid_c is not None + + await stack_move(cid_c[:8], "first", None, dry_run=False) + + subjects = _get_commit_subjects(repo) + feature_subjects = [s for s in subjects if s.startswith("Commit")] + assert feature_subjects == ["Commit C", "Commit A", "Commit B"] + + async def test_move_empty_stack( + self, + git_repo_with_hooks: pathlib.Path, + ) -> None: + """No commits between base and HEAD.""" + repo = git_repo_with_hooks + + # Create just an initial commit on main + (repo / "init.txt").write_text("init") + _run_git("add", "init.txt", cwd=repo) + _run_git("commit", "-m", "Initial commit", cwd=repo) + + _setup_tracking(repo) + + # Create feature branch with no new commits + _run_git("checkout", "-b", "feature", "main", cwd=repo) + _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) + + os.chdir(repo) + + # Should just print no-op and return without error + await stack_move("anything", "first", None, dry_run=False) diff --git a/skills/mergify-stack/SKILL.md b/skills/mergify-stack/SKILL.md index f263429..a33ea88 100644 --- a/skills/mergify-stack/SKILL.md +++ b/skills/mergify-stack/SKILL.md @@ -21,6 +21,7 @@ A branch is a stack. Keep stacks short and focused: - **Push**: Use `mergify stack push` (never `git push`) - **Fixes**: Use `git commit --amend` (never create new commits to fix issues) - **Mid-stack fixes**: Use `git rebase -i` to edit the specific commit, amend it, continue rebase, then `mergify stack push` +- **Reordering**: Use `mergify stack reorder` (list all commits in desired order) or `mergify stack move` (move a single commit) instead of manual `git rebase -i` — non-interactive and avoids `GIT_SEQUENCE_EDITOR` quoting issues - **Commit titles**: Follow [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`) - **PR title & body**: `mergify stack` copies the commit message title to the PR title and the commit message body to the PR body — so write commit messages as if they were PR descriptions. **Everything that should appear in the PR (ticket references, context, test plans) MUST go in the commit message.** - **Ticket references**: Include ticket/issue references (e.g., `MRGFY-1234`, `Fixes #123`) in the commit message body, not added separately to the PR. @@ -46,6 +47,11 @@ mergify stack new NAME # Create a new stack/branch for new work mergify stack push # Push and create/update PRs mergify stack list # Show commit <-> PR mapping for current stack mergify stack list --json # Same, but machine-readable JSON output +mergify stack reorder C A B # Reorder all commits (pass SHA or Change-Id prefixes) +mergify stack move X first # Move commit X to the top of the stack +mergify stack move X last # Move commit X to the bottom of the stack +mergify stack move X before Y # Move commit X before commit Y +mergify stack move X after Y # Move commit X after commit Y ``` 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.