Skip to content

Commit db54760

Browse files
jdclaude
andauthored
feat(rust): port stack squash to native Rust (#1521)
The Rust binary now serves ``mergify stack squash <SRC>... into <TARGET> [-m <msg>] [--dry-run]`` natively. With this slice, the entire ``stack`` rebase family is native: ``edit``, ``drop``, ``fixup``, ``reword``, ``reorder``, ``move``, ``squash``. Final cleanup: ``mergify_cli/stack/squash.py``, ``mergify_cli/stack/reorder.py`` (the helpers ``squash.py`` was still using), and their tests (``test_squash.py``, ``test_squash_cli.py``) are removed. ``mergify_cli/stack/cli.py`` loses its ``squash`` click registration and its ``_parse_squash_tokens`` helper. The rebase-todo machinery grows one new variant — ``Action::Squash`` — that combines what ``Reorder`` and ``Fixup`` did separately plus an optional ``ExecAfter``: - ``ordered_shas`` — the full new pick order (target's neighbours rearranged so all sources sit directly after it). - ``fixup_shas`` — the subset whose verb flips from ``pick`` to ``fixup``. - ``exec_after_sha`` + ``exec_command`` — when ``-m`` is given, an ``exec git commit --amend -F <file>`` is injected right after the last fixed-up source. The amend runs while HEAD still points at the combined target commit so ``prepare-commit-msg`` re-attaches the Change-Id. ``mergify stack squash``: 1. Resolves the trunk and walks the stack. 2. Parses ``SRC... into TARGET`` — the ``into`` keyword splits the positional list. Mirrors the Python parser: exactly one ``into``, at least one source before, exactly one target after. 3. Validates: target is not among the sources; no source SHA appears twice. 4. Builds the new order: keep non-source commits in their original positions, insert all sources directly after target in the order they were listed. 5. ``--dry-run`` short-circuits with the plan; otherwise spawns ``git rebase -i <base>`` with the squash sequence editor. 6. With ``-m``: writes the message to a leaked tempfile and passes it through ``--sha``/``--command`` on the ``rebase-todo-rewrite`` self-invocation. End-to-end coverage in ``crates/mergify-cli/tests/stack_squash.rs`` (6 cases): single source into target keeps target's message, custom ``-m`` replaces subject + body, dry-run is a no-op, source-equals- target errors, missing ``into`` errors, multi-source folds all into target. The pure transformer adds 3 new ``rebase_todo`` unit tests (happy path, exec-after injection, count mismatch). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a8a9050 commit db54760

10 files changed

Lines changed: 849 additions & 883 deletions

File tree

crates/mergify-cli/src/main.rs

Lines changed: 169 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[
165165
("stack", "note"),
166166
("stack", "reorder"),
167167
("stack", "reword"),
168+
("stack", "squash"),
168169
// Internal Python migration helpers. Listed so `looks_native`
169170
// routes `mergify _internal …` past the shim fallback when
170171
// clap rejects it, but they stay hidden from `--help` (see
@@ -255,6 +256,10 @@ enum NativeCommand {
255256
/// `mergify stack move <COMMIT> <POSITION> [<TARGET>]
256257
/// [--dry-run]` — move a single commit within the stack.
257258
StackMove(StackMoveOpts),
259+
/// `mergify stack squash <SRC>... into <TARGET> [-m <msg>]
260+
/// [--dry-run]` — fold several commits into a target,
261+
/// reordering them adjacent first.
262+
StackSquash(StackSquashOpts),
258263
/// `_internal rebase-todo-rewrite --action <ACTION>
259264
/// --sha <SHA> <TODO_PATH>` — self-invocation target set as
260265
/// `GIT_SEQUENCE_EDITOR` by the rebase-family stack
@@ -310,17 +315,29 @@ enum StackMovePosition {
310315
After,
311316
}
312317

318+
struct StackSquashOpts {
319+
src_prefixes: Vec<String>,
320+
target_prefix: String,
321+
message: Option<String>,
322+
dry_run: bool,
323+
}
324+
313325
struct InternalRebaseTodoRewriteOpts {
314326
/// Which transformation to apply. New variants land with the
315327
/// respective port slices (today: `edit`, `drop`, `fixup`,
316-
/// `reword`, `exec-after`).
328+
/// `reword`, `exec-after`, `reorder`, `squash`).
317329
action: InternalRebaseAction,
318-
/// Target commit SHA — used by `edit`, `reword`, `exec-after`.
330+
/// Target commit SHA — used by `edit`, `reword`, `exec-after`,
331+
/// and (optionally) `squash` for the post-fixup exec.
319332
sha: Option<String>,
320-
/// Comma-separated commit SHAs — used by `drop`, `fixup`.
333+
/// Comma-separated commit SHAs — used by `drop`, `fixup`,
334+
/// `reorder`, `squash` (the full new order in this case).
321335
shas: Option<String>,
322-
/// Shell command to inject after the target — used by
323-
/// `exec-after`.
336+
/// Comma-separated SHAs that should fold as `fixup` — used
337+
/// by `squash`.
338+
fixup_shas: Option<String>,
339+
/// Shell command to inject as an `exec` line — used by
340+
/// `exec-after`, `squash`.
324341
command: Option<String>,
325342
/// Path to the rebase-todo file git wrote.
326343
todo_path: PathBuf,
@@ -334,6 +351,7 @@ enum InternalRebaseAction {
334351
Reword,
335352
ExecAfter,
336353
Reorder,
354+
Squash,
337355
}
338356

339357
struct StackNoteOpts {
@@ -683,6 +701,19 @@ fn dispatch_stack(debug: bool, args: Vec<String>) -> Dispatch {
683701
};
684702
Dispatch::Native(NativeCommand::StackMove(StackMoveOpts::from(parsed)))
685703
}
704+
Some("squash") => {
705+
let parsed = match StackSquashCli::try_parse_from(&args) {
706+
Ok(p) => p,
707+
Err(err) => err.exit(),
708+
};
709+
match StackSquashOpts::try_from(parsed) {
710+
Ok(opts) => Dispatch::Native(NativeCommand::StackSquash(opts)),
711+
Err(msg) => {
712+
eprintln!("error: {msg}");
713+
std::process::exit(2);
714+
}
715+
}
716+
}
686717
_ => Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args))),
687718
}
688719
}
@@ -777,6 +808,7 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch {
777808
action,
778809
sha,
779810
shas,
811+
fixup_shas,
780812
command,
781813
todo_path,
782814
}),
@@ -785,6 +817,7 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch {
785817
action,
786818
sha,
787819
shas,
820+
fixup_shas,
788821
command,
789822
todo_path,
790823
},
@@ -1678,6 +1711,42 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
16781711
}
16791712
Ok(mergify_core::ExitCode::Success)
16801713
}
1714+
NativeCommand::StackSquash(opts) => {
1715+
let mergify_binary = std::env::current_exe().map_err(|e| {
1716+
mergify_core::CliError::Generic(format!(
1717+
"could not locate current binary path for GIT_SEQUENCE_EDITOR: {e}"
1718+
))
1719+
})?;
1720+
let outcome = mergify_stack::commands::squash::run(
1721+
&mergify_stack::commands::squash::Options {
1722+
repo_dir: None,
1723+
src_prefixes: &opts.src_prefixes,
1724+
target_prefix: &opts.target_prefix,
1725+
message: opts.message.as_deref(),
1726+
dry_run: opts.dry_run,
1727+
mergify_binary: &mergify_binary,
1728+
},
1729+
)?;
1730+
match outcome {
1731+
mergify_stack::commands::squash::Outcome::Squashed { plan }
1732+
| mergify_stack::commands::squash::Outcome::DryRun { plan } => {
1733+
println!("Squash plan:");
1734+
for (i, c) in plan.iter().enumerate() {
1735+
let short = &c.sha[..c.sha.len().min(12)];
1736+
println!(" {n}. {short} {subject}", n = i + 1, subject = c.subject);
1737+
}
1738+
if opts.dry_run {
1739+
println!("Dry run — no changes made");
1740+
} else {
1741+
println!("Commits squashed successfully.");
1742+
}
1743+
}
1744+
mergify_stack::commands::squash::Outcome::EmptyStack => {
1745+
println!("No commits in the stack");
1746+
}
1747+
}
1748+
Ok(mergify_core::ExitCode::Success)
1749+
}
16811750
NativeCommand::InternalRebaseTodoRewrite(opts) => {
16821751
let action = match opts.action {
16831752
InternalRebaseAction::Edit => {
@@ -1743,6 +1812,38 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
17431812
.collect();
17441813
mergify_stack::rebase_todo::Action::Reorder { ordered_shas }
17451814
}
1815+
InternalRebaseAction::Squash => {
1816+
let raw_shas = opts.shas.ok_or_else(|| {
1817+
mergify_core::CliError::InvalidState(
1818+
"_internal rebase-todo-rewrite --action squash requires --shas"
1819+
.to_string(),
1820+
)
1821+
})?;
1822+
let ordered_shas: Vec<String> = raw_shas
1823+
.split(',')
1824+
.map(str::trim)
1825+
.filter(|s| !s.is_empty())
1826+
.map(str::to_string)
1827+
.collect();
1828+
let raw_fixup = opts.fixup_shas.ok_or_else(|| {
1829+
mergify_core::CliError::InvalidState(
1830+
"_internal rebase-todo-rewrite --action squash requires --fixup-shas"
1831+
.to_string(),
1832+
)
1833+
})?;
1834+
let fixup_shas: Vec<String> = raw_fixup
1835+
.split(',')
1836+
.map(str::trim)
1837+
.filter(|s| !s.is_empty())
1838+
.map(str::to_string)
1839+
.collect();
1840+
mergify_stack::rebase_todo::Action::Squash {
1841+
ordered_shas,
1842+
fixup_shas,
1843+
exec_after_sha: opts.sha,
1844+
exec_command: opts.command,
1845+
}
1846+
}
17461847
InternalRebaseAction::ExecAfter => {
17471848
let sha = opts.sha.ok_or_else(|| {
17481849
mergify_core::CliError::InvalidState(
@@ -1943,11 +2044,15 @@ struct InternalRebaseTodoRewriteArgs {
19432044
/// Target SHA — required for `edit`, `reword`, `exec-after`.
19442045
#[arg(long)]
19452046
sha: Option<String>,
1946-
/// Comma-separated SHAs — required for `drop`, `fixup`.
2047+
/// Comma-separated SHAs — required for `drop`, `fixup`,
2048+
/// `reorder`, `squash` (full new order).
19472049
#[arg(long)]
19482050
shas: Option<String>,
2051+
/// Comma-separated SHAs to fold as fixup — used by `squash`.
2052+
#[arg(long = "fixup-shas")]
2053+
fixup_shas: Option<String>,
19492054
/// Shell command to inject as an `exec` line — required for
1950-
/// `exec-after`.
2055+
/// `exec-after`, optional for `squash`.
19512056
#[arg(long)]
19522057
command: Option<String>,
19532058
/// Path to the rebase-todo file git wrote; positional so it
@@ -2147,6 +2252,63 @@ impl From<StackMoveCli> for StackMoveOpts {
21472252
}
21482253
}
21492254

2255+
/// `mergify stack squash <SRC>... into <TARGET> [-m <msg>]
2256+
/// [--dry-run]`. The `<SRC>... into <TARGET>` shape doesn't fit
2257+
/// clap's positional model directly, so we accept a flat
2258+
/// `Vec<String>` and split on the literal `into` keyword inside
2259+
/// [`StackSquashOpts::try_from`].
2260+
#[derive(Parser)]
2261+
#[command(name = "squash", about = "Squash commits into a target commit")]
2262+
struct StackSquashCli {
2263+
/// `SRC1 SRC2 ... into TARGET` — must contain exactly one
2264+
/// `into` token; everything before is a source, the single
2265+
/// token after is the target.
2266+
#[arg(required = true, num_args = 3..)]
2267+
tokens: Vec<String>,
2268+
2269+
/// Final commit message (required to rename; otherwise the
2270+
/// target's message is kept).
2271+
#[arg(short = 'm', long = "message")]
2272+
message: Option<String>,
2273+
2274+
/// Show the plan without rebasing.
2275+
#[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)]
2276+
dry_run: bool,
2277+
}
2278+
2279+
impl TryFrom<StackSquashCli> for StackSquashOpts {
2280+
type Error = String;
2281+
2282+
fn try_from(cli: StackSquashCli) -> Result<Self, Self::Error> {
2283+
let into_positions: Vec<usize> = cli
2284+
.tokens
2285+
.iter()
2286+
.enumerate()
2287+
.filter_map(|(i, t)| (t == "into").then_some(i))
2288+
.collect();
2289+
if into_positions.len() != 1 {
2290+
return Err(
2291+
"squash requires exactly one 'into' keyword: SRC... into TARGET".to_string(),
2292+
);
2293+
}
2294+
let idx = into_positions[0];
2295+
let srcs: Vec<String> = cli.tokens[..idx].to_vec();
2296+
let after = &cli.tokens[idx + 1..];
2297+
if srcs.is_empty() {
2298+
return Err("at least one source commit required before 'into'".to_string());
2299+
}
2300+
if after.len() != 1 {
2301+
return Err("exactly one target commit required after 'into'".to_string());
2302+
}
2303+
Ok(Self {
2304+
src_prefixes: srcs,
2305+
target_prefix: after[0].clone(),
2306+
message: cli.message,
2307+
dry_run: cli.dry_run,
2308+
})
2309+
}
2310+
}
2311+
21502312
/// `mergify stack note [<commit>]` — clap definition for the
21512313
/// natively-ported `stack note` subcommand. Same secondary-parse
21522314
/// pattern as `stack new`.

0 commit comments

Comments
 (0)