Skip to content

Commit 0ac743c

Browse files
jdclaude
andauthored
feat(stack): add optional commit argument to stack edit (#1145)
Allow `mergify stack edit <commit>` to directly edit a specific commit in the stack by SHA or Change-Id prefix, without opening the full interactive rebase editor. Uses GIT_SEQUENCE_EDITOR to mark the matched commit as `edit` automatically. When called without arguments, the existing behavior (full interactive rebase) is preserved. Extract shared `run_scripted_rebase` helper from `reorder.py` to avoid duplicating the temp-script + GIT_SEQUENCE_EDITOR plumbing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6725a4d commit 0ac743c

4 files changed

Lines changed: 285 additions & 38 deletions

File tree

mergify_cli/stack/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,10 @@ async def setup(*, force: bool, check: bool) -> None:
198198

199199

200200
@stack.command(help="Edit the stack history")
201+
@click.argument("commit", required=False, default=None)
201202
@utils.run_with_asyncio
202-
async def edit() -> None:
203-
await stack_edit_mod.stack_edit()
203+
async def edit(*, commit: str | None) -> None:
204+
await stack_edit_mod.stack_edit(commit_prefix=commit)
204205

205206

206207
@stack.command(help="Reorder the stack's commits")

mergify_cli/stack/edit.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,50 @@
22

33
import os
44

5+
from mergify_cli import console
56
from mergify_cli import utils
7+
from mergify_cli.stack.reorder import get_stack_commits
8+
from mergify_cli.stack.reorder import match_commit
9+
from mergify_cli.stack.reorder import run_scripted_rebase
610

711

8-
async def stack_edit() -> None:
12+
async def stack_edit(commit_prefix: str | None = None) -> None:
913
os.chdir(await utils.git("rev-parse", "--show-toplevel"))
1014
trunk = await utils.get_trunk()
1115
base = await utils.git("merge-base", trunk, "HEAD")
12-
os.execvp("git", ("git", "rebase", "-i", f"{base}^")) # noqa: S606
16+
17+
if commit_prefix is None:
18+
os.execvp("git", ("git", "rebase", "-i", base)) # noqa: S606
19+
else:
20+
commits = get_stack_commits(base)
21+
if not commits:
22+
console.print("No commits in the stack", style="green")
23+
return
24+
25+
sha, subject, _ = match_commit(commit_prefix, commits)
26+
console.print(f"Editing commit: {sha[:12]} {subject}")
27+
_run_edit_rebase(base, sha)
28+
console.print(
29+
"Amend the commit, then run: git rebase --continue",
30+
)
31+
32+
33+
def _run_edit_rebase(base: str, target_sha: str) -> None:
34+
"""Run ``git rebase -i`` marking *target_sha* as ``edit``."""
35+
script_content = (
36+
"import sys\n"
37+
"target = " + repr(target_sha) + "\n"
38+
"todo_path = sys.argv[1]\n"
39+
"with open(todo_path) as f:\n"
40+
" lines = f.readlines()\n"
41+
"result = []\n"
42+
"for line in lines:\n"
43+
" parts = line.split(None, 2)\n"
44+
" if len(parts) >= 2 and parts[0] == 'pick':\n"
45+
" if target.startswith(parts[1]) or parts[1].startswith(target):\n"
46+
" line = 'edit' + line[4:]\n"
47+
" result.append(line)\n"
48+
"with open(todo_path, 'w') as f:\n"
49+
" f.writelines(result)\n"
50+
)
51+
run_scripted_rebase(base, script_content)

mergify_cli/stack/reorder.py

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -92,42 +92,14 @@ def match_commit(
9292
return matches[0]
9393

9494

95-
def run_rebase(base: str, ordered_shas: list[str]) -> None:
96-
"""Run ``git rebase -i`` with a generated sequence editor script.
95+
def run_scripted_rebase(base: str, script_content: str) -> None:
96+
"""Run ``git rebase -i`` with a custom sequence-editor script.
9797
98-
The temporary Python script rewrites the rebase todo list so that
99-
the pick lines appear in the order given by *ordered_shas*.
98+
Writes *script_content* to a temporary Python file, sets it as
99+
``GIT_SEQUENCE_EDITOR``, then executes the rebase. The temp file
100+
is cleaned up afterwards regardless of outcome.
100101
"""
101-
script_content = (
102-
"#!/usr/bin/env python3\n"
103-
"import sys\n"
104-
"order = " + repr(ordered_shas) + "\n"
105-
"todo_path = sys.argv[1]\n"
106-
"with open(todo_path) as f:\n"
107-
" lines = f.readlines()\n"
108-
"pick_lines = {}\n"
109-
"other_lines = []\n"
110-
"for line in lines:\n"
111-
" stripped = line.strip()\n"
112-
" if stripped and not stripped.startswith('#'):\n"
113-
" parts = stripped.split(None, 2)\n"
114-
" if len(parts) >= 2:\n"
115-
" pick_lines[parts[1]] = line\n"
116-
" else:\n"
117-
" other_lines.append(line)\n"
118-
" else:\n"
119-
" other_lines.append(line)\n"
120-
"reordered = []\n"
121-
"for sha in order:\n"
122-
" for key in pick_lines:\n"
123-
" if sha.startswith(key) or key.startswith(sha):\n"
124-
" reordered.append(pick_lines[key])\n"
125-
" break\n"
126-
"with open(todo_path, 'w') as f:\n"
127-
" f.writelines(reordered + other_lines)\n"
128-
)
129-
130-
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="mergify_reorder_")
102+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="mergify_rebase_")
131103
try:
132104
with os.fdopen(tmp_fd, "w") as f:
133105
f.write(script_content)
@@ -161,6 +133,39 @@ def run_rebase(base: str, ordered_shas: list[str]) -> None:
161133
tmp_file.unlink()
162134

163135

136+
def run_rebase(base: str, ordered_shas: list[str]) -> None:
137+
"""Run ``git rebase -i`` reordering picks to match *ordered_shas*."""
138+
script_content = (
139+
"#!/usr/bin/env python3\n"
140+
"import sys\n"
141+
"order = " + repr(ordered_shas) + "\n"
142+
"todo_path = sys.argv[1]\n"
143+
"with open(todo_path) as f:\n"
144+
" lines = f.readlines()\n"
145+
"pick_lines = {}\n"
146+
"other_lines = []\n"
147+
"for line in lines:\n"
148+
" stripped = line.strip()\n"
149+
" if stripped and not stripped.startswith('#'):\n"
150+
" parts = stripped.split(None, 2)\n"
151+
" if len(parts) >= 2:\n"
152+
" pick_lines[parts[1]] = line\n"
153+
" else:\n"
154+
" other_lines.append(line)\n"
155+
" else:\n"
156+
" other_lines.append(line)\n"
157+
"reordered = []\n"
158+
"for sha in order:\n"
159+
" for key in pick_lines:\n"
160+
" if sha.startswith(key) or key.startswith(sha):\n"
161+
" reordered.append(pick_lines[key])\n"
162+
" break\n"
163+
"with open(todo_path, 'w') as f:\n"
164+
" f.writelines(reordered + other_lines)\n"
165+
)
166+
run_scripted_rebase(base, script_content)
167+
168+
164169
def display_plan(
165170
title: str,
166171
commits: list[tuple[str, str, str]],
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#
2+
# Copyright © 2021-2026 Mergify SAS
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
5+
# not use this file except in compliance with the License. You may obtain
6+
# a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations
14+
# under the License.
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import re
20+
import subprocess
21+
from typing import TYPE_CHECKING
22+
23+
import pytest
24+
25+
from mergify_cli.stack.edit import stack_edit
26+
27+
28+
if TYPE_CHECKING:
29+
import pathlib
30+
31+
32+
def _run_git(*args: str, cwd: pathlib.Path | None = None) -> str:
33+
return subprocess.check_output(
34+
["git", *args],
35+
text=True,
36+
cwd=cwd,
37+
).strip()
38+
39+
40+
def _create_commit(
41+
repo: pathlib.Path,
42+
filename: str,
43+
content: str,
44+
message: str,
45+
) -> tuple[str, str | None]:
46+
"""Create a commit and return (sha, change_id)."""
47+
(repo / filename).write_text(content)
48+
_run_git("add", filename, cwd=repo)
49+
_run_git("commit", "-m", message, cwd=repo)
50+
sha = _run_git("rev-parse", "HEAD", cwd=repo)
51+
body = _run_git("log", "-1", "--format=%b", "HEAD", cwd=repo)
52+
change_id_match = re.search(r"Change-Id: (I[0-9a-z]{40})", body)
53+
return sha, change_id_match.group(1) if change_id_match else None
54+
55+
56+
def _setup_tracking(repo: pathlib.Path) -> None:
57+
"""Create a bare origin and set up tracking for the current branch."""
58+
origin_path = repo.parent / f"{repo.name}_origin.git"
59+
_run_git("init", "--bare", str(origin_path))
60+
_run_git("remote", "add", "origin", str(origin_path), cwd=repo)
61+
_run_git("push", "origin", "main", cwd=repo)
62+
_run_git("branch", "--set-upstream-to=origin/main", cwd=repo)
63+
64+
65+
@pytest.fixture
66+
def stack_repo(
67+
git_repo_with_hooks: pathlib.Path,
68+
) -> tuple[pathlib.Path, list[tuple[str, str | None]]]:
69+
"""Create a repo with 3 commits (A, B, C) on a feature branch."""
70+
repo = git_repo_with_hooks
71+
72+
# Create an initial commit on main
73+
(repo / "init.txt").write_text("init")
74+
_run_git("add", "init.txt", cwd=repo)
75+
_run_git("commit", "-m", "Initial commit", cwd=repo)
76+
77+
_setup_tracking(repo)
78+
79+
# Create a feature branch
80+
_run_git("checkout", "-b", "feature", "main", cwd=repo)
81+
_run_git("branch", "--set-upstream-to=origin/main", cwd=repo)
82+
83+
# Create 3 commits
84+
commits = []
85+
for label, filename in [("A", "a.txt"), ("B", "b.txt"), ("C", "c.txt")]:
86+
sha, cid = _create_commit(repo, filename, f"content {label}", f"Commit {label}")
87+
commits.append((sha, cid))
88+
89+
return repo, commits
90+
91+
92+
class TestStackEdit:
93+
async def test_edit_stops_at_target_commit(
94+
self,
95+
stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]],
96+
) -> None:
97+
"""Editing a mid-stack commit stops the rebase at that commit."""
98+
repo, commits = stack_repo
99+
os.chdir(repo)
100+
101+
sha_b = commits[1][0][:12]
102+
await stack_edit(commit_prefix=sha_b)
103+
104+
# Rebase should have stopped — HEAD is now the target commit
105+
head_subject = _run_git("log", "-1", "--format=%s", cwd=repo)
106+
assert head_subject == "Commit B"
107+
108+
# Verify we're mid-rebase
109+
rebase_dir = repo / ".git" / "rebase-merge"
110+
assert rebase_dir.exists()
111+
112+
# Clean up the rebase
113+
_run_git("rebase", "--abort", cwd=repo)
114+
115+
async def test_edit_stops_at_first_commit(
116+
self,
117+
stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]],
118+
) -> None:
119+
"""Editing the first commit in the stack works."""
120+
repo, commits = stack_repo
121+
os.chdir(repo)
122+
123+
sha_a = commits[0][0][:12]
124+
await stack_edit(commit_prefix=sha_a)
125+
126+
head_subject = _run_git("log", "-1", "--format=%s", cwd=repo)
127+
assert head_subject == "Commit A"
128+
129+
rebase_dir = repo / ".git" / "rebase-merge"
130+
assert rebase_dir.exists()
131+
132+
_run_git("rebase", "--abort", cwd=repo)
133+
134+
async def test_edit_stops_at_last_commit(
135+
self,
136+
stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]],
137+
) -> None:
138+
"""Editing the last (HEAD) commit in the stack works."""
139+
repo, commits = stack_repo
140+
os.chdir(repo)
141+
142+
sha_c = commits[2][0][:12]
143+
await stack_edit(commit_prefix=sha_c)
144+
145+
head_subject = _run_git("log", "-1", "--format=%s", cwd=repo)
146+
assert head_subject == "Commit C"
147+
148+
rebase_dir = repo / ".git" / "rebase-merge"
149+
assert rebase_dir.exists()
150+
151+
_run_git("rebase", "--abort", cwd=repo)
152+
153+
async def test_edit_by_change_id(
154+
self,
155+
stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]],
156+
) -> None:
157+
"""Editing by Change-Id prefix works."""
158+
repo, commits = stack_repo
159+
os.chdir(repo)
160+
161+
cid_b = commits[1][1]
162+
assert cid_b is not None
163+
164+
await stack_edit(commit_prefix=cid_b[:8])
165+
166+
head_subject = _run_git("log", "-1", "--format=%s", cwd=repo)
167+
assert head_subject == "Commit B"
168+
169+
_run_git("rebase", "--abort", cwd=repo)
170+
171+
async def test_edit_unknown_prefix_exits(
172+
self,
173+
stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]],
174+
) -> None:
175+
"""Unknown commit prefix causes exit."""
176+
repo, _commits = stack_repo
177+
os.chdir(repo)
178+
179+
with pytest.raises(SystemExit) as exc_info:
180+
await stack_edit(commit_prefix="deadbeef1234")
181+
assert exc_info.value.code == 1
182+
183+
async def test_edit_empty_stack(
184+
self,
185+
git_repo_with_hooks: pathlib.Path,
186+
) -> None:
187+
"""Empty stack prints message and returns."""
188+
repo = git_repo_with_hooks
189+
190+
(repo / "init.txt").write_text("init")
191+
_run_git("add", "init.txt", cwd=repo)
192+
_run_git("commit", "-m", "Initial commit", cwd=repo)
193+
194+
_setup_tracking(repo)
195+
196+
_run_git("checkout", "-b", "feature", "main", cwd=repo)
197+
_run_git("branch", "--set-upstream-to=origin/main", cwd=repo)
198+
199+
os.chdir(repo)
200+
201+
# Should return without error — no commits to edit
202+
await stack_edit(commit_prefix="abc")

0 commit comments

Comments
 (0)