Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions mergify_cli/stack/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@


CHANGEID_RE = re.compile(r"Change-Id: (I[0-9a-z]{40})")


def is_change_id_prefix(prefix: str) -> bool:
"""Return True if *prefix* looks like a Change-Id prefix."""
return (
len(prefix) >= 2
and prefix[0] == "I"
and all(c in "0123456789abcdef" for c in prefix[1:])
)


ChangeId = typing.NewType("ChangeId", str)
RemoteChanges = typing.NewType(
"RemoteChanges",
Expand Down
15 changes: 15 additions & 0 deletions mergify_cli/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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
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 skill as stack_skill_mod

Expand Down Expand Up @@ -201,6 +202,20 @@ async def edit() -> None:
await stack_edit_mod.stack_edit()


@stack.command(help="Reorder the stack's commits")
@click.argument("commits", nargs=-1, required=True)
@click.option(
"--dry-run",
"-n",
is_flag=True,
default=False,
help="Show the plan without reordering",
)
@utils.run_with_asyncio
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="Create a new stack branch")
@click.argument("name")
@click.option(
Expand Down
228 changes: 228 additions & 0 deletions mergify_cli/stack/reorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#
# 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 pathlib
import shlex
import subprocess
import sys
import tempfile

from mergify_cli import console
from mergify_cli import utils
from mergify_cli.stack.changes import CHANGEID_RE
from mergify_cli.stack.changes import is_change_id_prefix


def get_stack_commits(base: str) -> list[tuple[str, str, str]]:
"""Return (full_sha, subject, change_id) tuples from base to HEAD.

Uses ``git log --reverse`` so the list is in commit order
(oldest first).
"""
raw = subprocess.check_output( # noqa: S603
["git", "log", "--reverse", "--format=%H%x00%s%x00%b%x1e", f"{base}..HEAD"],
text=True,
)
commits: list[tuple[str, str, str]] = []
for record in raw.split("\x1e"):
stripped = record.strip()
if not stripped:
continue
parts = stripped.split("\x00", 2)
if len(parts) != 3:
continue
sha = parts[0].strip()
subject = parts[1].strip()
body = parts[2].strip()
match = CHANGEID_RE.search(body)
change_id = match.group(1) if match else ""
commits.append((sha, subject, change_id))
return commits


def match_commit(
prefix: str,
commits: list[tuple[str, str, str]],
) -> tuple[str, str, str]:
"""Match a SHA or Change-Id prefix to exactly one commit.

Auto-detect: if prefix starts with ``I`` and the rest is hex, match
against the change_id field; otherwise match against the sha field.

Calls ``sys.exit(1)`` with an error message on no match or ambiguous
match.
"""
if is_change_id_prefix(prefix):
matches = [c for c in commits if c[2].startswith(prefix)]
field_name = "Change-Id"
else:
matches = [c for c in commits if c[0].startswith(prefix)]
field_name = "SHA"

if len(matches) == 0:
console.print(
f"error: no commit found matching {field_name} prefix '{prefix}'",
style="red",
)
sys.exit(1)
if len(matches) > 1:
console.print(
f"error: ambiguous {field_name} prefix '{prefix}' matches {len(matches)} commits:",
style="red",
)
for sha, subject, change_id in matches:
console.print(f" {sha[:12]} {subject} ({change_id[:12]})", style="red")
sys.exit(1)

return matches[0]


def run_rebase(base: str, ordered_shas: list[str]) -> None:
"""Run ``git rebase -i`` with a generated sequence editor script.

The temporary Python script rewrites the rebase todo list so that
the pick lines appear in the order given by *ordered_shas*.
"""
script_content = (
"#!/usr/bin/env python3\n"
"import sys\n"
"order = " + repr(ordered_shas) + "\n"
"todo_path = sys.argv[1]\n"
"with open(todo_path) as f:\n"
" lines = f.readlines()\n"
"pick_lines = {}\n"
"other_lines = []\n"
"for line in lines:\n"
" stripped = line.strip()\n"
" if stripped and not stripped.startswith('#'):\n"
" parts = stripped.split(None, 2)\n"
" if len(parts) >= 2:\n"
" pick_lines[parts[1]] = line\n"
" else:\n"
" other_lines.append(line)\n"
" else:\n"
" other_lines.append(line)\n"
"reordered = []\n"
"for sha in order:\n"
" for key in pick_lines:\n"
" if sha.startswith(key) or key.startswith(sha):\n"
" reordered.append(pick_lines[key])\n"
" break\n"
"with open(todo_path, 'w') as f:\n"
" f.writelines(reordered + other_lines)\n"
)

tmp_fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="mergify_reorder_")
try:
with os.fdopen(tmp_fd, "w") as f:
f.write(script_content)
pathlib.Path(tmp_path).chmod(0o755)

env = os.environ.copy()
python = shlex.quote(sys.executable)
script = shlex.quote(tmp_path)
env["GIT_SEQUENCE_EDITOR"] = f"{python} {script}"

result = subprocess.run( # noqa: S603
["git", "rebase", "-i", base],
env=env,
)

if result.returncode != 0:
console.print(
"error: rebase failed — there may be conflicts",
style="red",
)
console.print(
"Resolve conflicts then run: git rebase --continue",
)
console.print(
"Or abort the rebase with: git rebase --abort",
)
sys.exit(1)
finally:
tmp_file = pathlib.Path(tmp_path)
if tmp_file.exists():
tmp_file.unlink()


def display_plan(
title: str,
commits: list[tuple[str, str, str]],
) -> None:
"""Print the planned commit order."""
console.log(title)
for idx, (sha, subject, change_id) in enumerate(commits, 1):
cid_display = f" ({change_id[:12]})" if change_id else ""
console.log(f" {idx}. {sha[:12]} {subject}{cid_display}")


async def stack_reorder(
commit_prefixes: list[str],
*,
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

if len(commit_prefixes) != len(commits):
console.print(
f"error: expected {len(commits)} commits but got {len(commit_prefixes)} prefixes",
style="red",
)
sys.exit(1)

# Match each prefix to a commit
matched = [match_commit(p, commits) for p in commit_prefixes]

# Check for duplicates
matched_shas = [c[0] for c in matched]
if len(set(matched_shas)) != len(matched_shas):
seen: set[str] = set()
for prefix, sha in zip(commit_prefixes, matched_shas, strict=True):
if sha in seen:
console.print(
f"error: duplicate — prefix '{prefix}' resolves to the same commit as another prefix",
style="red",
)
sys.exit(1)
seen.add(sha)

# Check if already in order
current_shas = [c[0] for c in commits]
if matched_shas == current_shas:
console.print(
"Stack is already in the requested order",
style="green",
)
return

display_plan("Reorder plan:", matched)

if dry_run:
console.print("Dry run — no changes made", style="green")
return

run_rebase(base, matched_shas)
console.print("Stack reordered successfully", style="green")
2 changes: 2 additions & 0 deletions mergify_cli/stack/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 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.
Expand All @@ -46,6 +47,7 @@ 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)
```

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.
Expand Down
Loading
Loading