diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index bfe253b6..53009765 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -165,6 +165,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("stack", "note"), ("stack", "reorder"), ("stack", "reword"), + ("stack", "squash"), // Internal Python migration helpers. Listed so `looks_native` // routes `mergify _internal …` past the shim fallback when // clap rejects it, but they stay hidden from `--help` (see @@ -255,6 +256,10 @@ enum NativeCommand { /// `mergify stack move [] /// [--dry-run]` — move a single commit within the stack. StackMove(StackMoveOpts), + /// `mergify stack squash ... into [-m ] + /// [--dry-run]` — fold several commits into a target, + /// reordering them adjacent first. + StackSquash(StackSquashOpts), /// `_internal rebase-todo-rewrite --action /// --sha ` — self-invocation target set as /// `GIT_SEQUENCE_EDITOR` by the rebase-family stack @@ -310,17 +315,29 @@ enum StackMovePosition { After, } +struct StackSquashOpts { + src_prefixes: Vec, + target_prefix: String, + message: Option, + dry_run: bool, +} + struct InternalRebaseTodoRewriteOpts { /// Which transformation to apply. New variants land with the /// respective port slices (today: `edit`, `drop`, `fixup`, - /// `reword`, `exec-after`). + /// `reword`, `exec-after`, `reorder`, `squash`). action: InternalRebaseAction, - /// Target commit SHA — used by `edit`, `reword`, `exec-after`. + /// Target commit SHA — used by `edit`, `reword`, `exec-after`, + /// and (optionally) `squash` for the post-fixup exec. sha: Option, - /// Comma-separated commit SHAs — used by `drop`, `fixup`. + /// Comma-separated commit SHAs — used by `drop`, `fixup`, + /// `reorder`, `squash` (the full new order in this case). shas: Option, - /// Shell command to inject after the target — used by - /// `exec-after`. + /// Comma-separated SHAs that should fold as `fixup` — used + /// by `squash`. + fixup_shas: Option, + /// Shell command to inject as an `exec` line — used by + /// `exec-after`, `squash`. command: Option, /// Path to the rebase-todo file git wrote. todo_path: PathBuf, @@ -334,6 +351,7 @@ enum InternalRebaseAction { Reword, ExecAfter, Reorder, + Squash, } struct StackNoteOpts { @@ -683,6 +701,19 @@ fn dispatch_stack(debug: bool, args: Vec) -> Dispatch { }; Dispatch::Native(NativeCommand::StackMove(StackMoveOpts::from(parsed))) } + Some("squash") => { + let parsed = match StackSquashCli::try_parse_from(&args) { + Ok(p) => p, + Err(err) => err.exit(), + }; + match StackSquashOpts::try_from(parsed) { + Ok(opts) => Dispatch::Native(NativeCommand::StackSquash(opts)), + Err(msg) => { + eprintln!("error: {msg}"); + std::process::exit(2); + } + } + } _ => Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args))), } } @@ -777,6 +808,7 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { action, sha, shas, + fixup_shas, command, todo_path, }), @@ -785,6 +817,7 @@ fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { action, sha, shas, + fixup_shas, command, todo_path, }, @@ -1678,6 +1711,42 @@ fn run_native(cmd: NativeCommand) -> ExitCode { } Ok(mergify_core::ExitCode::Success) } + NativeCommand::StackSquash(opts) => { + let mergify_binary = std::env::current_exe().map_err(|e| { + mergify_core::CliError::Generic(format!( + "could not locate current binary path for GIT_SEQUENCE_EDITOR: {e}" + )) + })?; + let outcome = mergify_stack::commands::squash::run( + &mergify_stack::commands::squash::Options { + repo_dir: None, + src_prefixes: &opts.src_prefixes, + target_prefix: &opts.target_prefix, + message: opts.message.as_deref(), + dry_run: opts.dry_run, + mergify_binary: &mergify_binary, + }, + )?; + match outcome { + mergify_stack::commands::squash::Outcome::Squashed { plan } + | mergify_stack::commands::squash::Outcome::DryRun { plan } => { + println!("Squash plan:"); + for (i, c) in plan.iter().enumerate() { + let short = &c.sha[..c.sha.len().min(12)]; + println!(" {n}. {short} {subject}", n = i + 1, subject = c.subject); + } + if opts.dry_run { + println!("Dry run — no changes made"); + } else { + println!("Commits squashed successfully."); + } + } + mergify_stack::commands::squash::Outcome::EmptyStack => { + println!("No commits in the stack"); + } + } + Ok(mergify_core::ExitCode::Success) + } NativeCommand::InternalRebaseTodoRewrite(opts) => { let action = match opts.action { InternalRebaseAction::Edit => { @@ -1743,6 +1812,38 @@ fn run_native(cmd: NativeCommand) -> ExitCode { .collect(); mergify_stack::rebase_todo::Action::Reorder { ordered_shas } } + InternalRebaseAction::Squash => { + let raw_shas = opts.shas.ok_or_else(|| { + mergify_core::CliError::InvalidState( + "_internal rebase-todo-rewrite --action squash requires --shas" + .to_string(), + ) + })?; + let ordered_shas: Vec = raw_shas + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + let raw_fixup = opts.fixup_shas.ok_or_else(|| { + mergify_core::CliError::InvalidState( + "_internal rebase-todo-rewrite --action squash requires --fixup-shas" + .to_string(), + ) + })?; + let fixup_shas: Vec = raw_fixup + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + mergify_stack::rebase_todo::Action::Squash { + ordered_shas, + fixup_shas, + exec_after_sha: opts.sha, + exec_command: opts.command, + } + } InternalRebaseAction::ExecAfter => { let sha = opts.sha.ok_or_else(|| { mergify_core::CliError::InvalidState( @@ -1943,11 +2044,15 @@ struct InternalRebaseTodoRewriteArgs { /// Target SHA — required for `edit`, `reword`, `exec-after`. #[arg(long)] sha: Option, - /// Comma-separated SHAs — required for `drop`, `fixup`. + /// Comma-separated SHAs — required for `drop`, `fixup`, + /// `reorder`, `squash` (full new order). #[arg(long)] shas: Option, + /// Comma-separated SHAs to fold as fixup — used by `squash`. + #[arg(long = "fixup-shas")] + fixup_shas: Option, /// Shell command to inject as an `exec` line — required for - /// `exec-after`. + /// `exec-after`, optional for `squash`. #[arg(long)] command: Option, /// Path to the rebase-todo file git wrote; positional so it @@ -2147,6 +2252,63 @@ impl From for StackMoveOpts { } } +/// `mergify stack squash ... into [-m ] +/// [--dry-run]`. The `... into ` shape doesn't fit +/// clap's positional model directly, so we accept a flat +/// `Vec` and split on the literal `into` keyword inside +/// [`StackSquashOpts::try_from`]. +#[derive(Parser)] +#[command(name = "squash", about = "Squash commits into a target commit")] +struct StackSquashCli { + /// `SRC1 SRC2 ... into TARGET` — must contain exactly one + /// `into` token; everything before is a source, the single + /// token after is the target. + #[arg(required = true, num_args = 3..)] + tokens: Vec, + + /// Final commit message (required to rename; otherwise the + /// target's message is kept). + #[arg(short = 'm', long = "message")] + message: Option, + + /// Show the plan without rebasing. + #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] + dry_run: bool, +} + +impl TryFrom for StackSquashOpts { + type Error = String; + + fn try_from(cli: StackSquashCli) -> Result { + let into_positions: Vec = cli + .tokens + .iter() + .enumerate() + .filter_map(|(i, t)| (t == "into").then_some(i)) + .collect(); + if into_positions.len() != 1 { + return Err( + "squash requires exactly one 'into' keyword: SRC... into TARGET".to_string(), + ); + } + let idx = into_positions[0]; + let srcs: Vec = cli.tokens[..idx].to_vec(); + let after = &cli.tokens[idx + 1..]; + if srcs.is_empty() { + return Err("at least one source commit required before 'into'".to_string()); + } + if after.len() != 1 { + return Err("exactly one target commit required after 'into'".to_string()); + } + Ok(Self { + src_prefixes: srcs, + target_prefix: after[0].clone(), + message: cli.message, + dry_run: cli.dry_run, + }) + } +} + /// `mergify stack note []` — clap definition for the /// natively-ported `stack note` subcommand. Same secondary-parse /// pattern as `stack new`. diff --git a/crates/mergify-cli/tests/stack_squash.rs b/crates/mergify-cli/tests/stack_squash.rs new file mode 100644 index 00000000..362d1cb8 --- /dev/null +++ b/crates/mergify-cli/tests/stack_squash.rs @@ -0,0 +1,248 @@ +//! End-to-end tests for `mergify stack squash`. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn mergify_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_mergify")) +} + +fn isolated_git() -> Command { + let mut cmd = Command::new("git"); + cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); + cmd.env("GIT_CONFIG_NOSYSTEM", "1"); + cmd +} + +fn run_in(dir: &Path, args: &[&str]) { + let ok = isolated_git() + .arg("-C") + .arg(dir) + .args(args) + .status() + .unwrap() + .success(); + assert!(ok); +} + +fn capture(dir: &Path, args: &[&str]) -> String { + let out = isolated_git() + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn build_stack_repo() -> (tempfile::TempDir, Vec) { + let workdir = tempfile::tempdir().unwrap(); + let upstream = workdir.path().join("up.git"); + isolated_git() + .args([ + "init", + "-q", + "--bare", + "-b", + "main", + upstream.to_str().unwrap(), + ]) + .status() + .unwrap(); + let local = workdir.path().join("local"); + std::fs::create_dir(&local).unwrap(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "t@e.com"], + &["config", "user.name", "T"], + ] { + run_in(&local, args); + } + std::fs::write(local.join("root.txt"), "root").unwrap(); + run_in(&local, &["add", "root.txt"]); + run_in(&local, &["commit", "-q", "-m", "root"]); + run_in( + &local, + &["remote", "add", "origin", upstream.to_str().unwrap()], + ); + run_in(&local, &["push", "-q", "origin", "main"]); + run_in(&local, &["remote", "set-head", "origin", "main"]); + run_in(&local, &["checkout", "-q", "-b", "feature"]); + + let mut commits = Vec::new(); + for (label, cid) in [ + ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), + ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), + ("C", "Icccccccccccccccccccccccccccccccccccccc03"), + ] { + let fname = format!("{}.txt", label.to_lowercase()); + std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); + run_in(&local, &["add", &fname]); + let msg = format!("Commit {label}\n\nChange-Id: {cid}"); + run_in(&local, &["commit", "-q", "-m", &msg]); + commits.push(capture(&local, &["rev-parse", "HEAD"])); + } + (workdir, commits) +} + +fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { + Command::new(mergify_binary()) + .args(args) + .current_dir(local) + .output() + .unwrap() +} + +fn feature_subjects(local: &Path) -> Vec { + capture( + local, + &["log", "--reverse", "--format=%s", "origin/main..HEAD"], + ) + .lines() + .map(str::to_string) + .collect() +} + +#[test] +fn squash_src_into_target_keeps_target_message() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + + // squash C into A (no -m). Expected: A holds A+C; B unchanged. + let output = run_mergify( + &local, + &[ + "stack", + "squash", + &commits[2][..12], // C + "into", + &commits[0][..12], // A + ], + ); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let subjects = feature_subjects(&local); + // After squash: [A (now containing A+C), B] + assert_eq!(subjects, ["Commit A", "Commit B"]); + // C's content is preserved (folded into A) + assert!(local.join("a.txt").exists()); + assert!(local.join("c.txt").exists()); +} + +#[test] +fn squash_with_custom_message_replaces_subject() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + + let output = run_mergify( + &local, + &[ + "stack", + "squash", + &commits[2][..12], + "into", + &commits[0][..12], + "-m", + "feat: combined A+C\n\nbody paragraph", + ], + ); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let subjects = feature_subjects(&local); + assert!(subjects.contains(&"feat: combined A+C".to_string())); + assert!(!subjects.contains(&"Commit A".to_string())); + // Body survived the rebase-todo / tempfile indirection. + let head_parent_body = capture(&local, &["log", "-1", "--format=%b", "HEAD~1"]); + assert!(head_parent_body.contains("body paragraph")); +} + +#[test] +fn squash_dry_run_does_not_modify_head() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + let head_before = capture(&local, &["rev-parse", "HEAD"]); + + let output = run_mergify( + &local, + &[ + "stack", + "squash", + "--dry-run", + &commits[2][..12], + "into", + &commits[0][..12], + ], + ); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Squash plan:")); + assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); +} + +#[test] +fn squash_src_equals_target_errors() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + let output = run_mergify( + &local, + &[ + "stack", + "squash", + &commits[0][..12], + "into", + &commits[0][..12], + ], + ); + assert!(!output.status.success()); +} + +#[test] +fn squash_missing_into_keyword_errors() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + let output = run_mergify( + &local, + &["stack", "squash", &commits[0][..12], &commits[1][..12]], + ); + assert!(!output.status.success()); +} + +#[test] +fn squash_multiple_srcs_into_target() { + let (work, commits) = build_stack_repo(); + let local = work.path().join("local"); + + let output = run_mergify( + &local, + &[ + "stack", + "squash", + &commits[1][..12], // B + &commits[2][..12], // C + "into", + &commits[0][..12], // A + ], + ); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let subjects = feature_subjects(&local); + // After squashing B+C into A: stack is just [A] + assert_eq!(subjects, ["Commit A"]); + assert!(local.join("a.txt").exists()); + assert!(local.join("b.txt").exists()); + assert!(local.join("c.txt").exists()); +} diff --git a/crates/mergify-stack/src/commands/mod.rs b/crates/mergify-stack/src/commands/mod.rs index 0a68bde6..cb56c6d0 100644 --- a/crates/mergify-stack/src/commands/mod.rs +++ b/crates/mergify-stack/src/commands/mod.rs @@ -12,3 +12,4 @@ pub mod new; pub mod note; pub mod reorder; pub mod reword; +pub mod squash; diff --git a/crates/mergify-stack/src/commands/squash.rs b/crates/mergify-stack/src/commands/squash.rs new file mode 100644 index 00000000..62db0a32 --- /dev/null +++ b/crates/mergify-stack/src/commands/squash.rs @@ -0,0 +1,256 @@ +//! `mergify stack squash ... into [-m ] +//! [--dry-run]` — combine several commits into a target. +//! +//! Port of `mergify_cli/stack/squash.py::stack_squash`. Reorders +//! every SRC adjacent to TARGET and rewrites the SRC verbs as +//! `fixup` (so they fold without opening an editor and TARGET's +//! message survives). When `-m` is given, the new combined +//! message is applied via an `exec git commit --amend -F ` +//! line inserted right after the last fixed-up commit — same +//! tempfile-leak pattern as `stack reword -m`. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use mergify_core::CliError; + +use crate::change_id; +use crate::local_commits::{self, LocalCommit}; +use crate::trunk; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderedCommit { + pub sha: String, + pub subject: String, +} + +#[derive(Debug, Clone)] +pub enum Outcome { + Squashed { plan: Vec }, + DryRun { plan: Vec }, + EmptyStack, +} + +pub struct Options<'a> { + pub repo_dir: Option<&'a Path>, + pub src_prefixes: &'a [String], + pub target_prefix: &'a str, + pub message: Option<&'a str>, + pub dry_run: bool, + pub mergify_binary: &'a Path, +} + +pub fn run(opts: &Options<'_>) -> Result { + if opts.src_prefixes.is_empty() { + return Err(CliError::InvalidState( + "at least one source commit required".to_string(), + )); + } + let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; + let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { + CliError::StackNotFound(format!( + "could not determine trunk branch ({e}). Please set \ + upstream tracking or set a base manually." + )) + })?; + let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; + let commits = local_commits::read(&repo_dir, &base, "HEAD")?; + if commits.is_empty() { + return Ok(Outcome::EmptyStack); + } + + let target = match_commit(opts.target_prefix, &commits)?; + + let mut srcs: Vec = Vec::with_capacity(opts.src_prefixes.len()); + let mut seen_src = std::collections::HashSet::new(); + for prefix in opts.src_prefixes { + let matched = match_commit(prefix, &commits)?; + if matched.sha == target.sha { + return Err(CliError::InvalidState( + "a source commit cannot be the same as the target".to_string(), + )); + } + if !seen_src.insert(matched.sha.clone()) { + return Err(CliError::InvalidState(format!( + "duplicate — source prefix '{prefix}' resolves to the same commit as another" + ))); + } + srcs.push(matched); + } + + // Build new order: non-src commits in their original order, + // with the src list reinserted immediately after target. + let src_sha_set: std::collections::HashSet<&str> = + srcs.iter().map(|s| s.sha.as_str()).collect(); + let mut new_order: Vec = Vec::with_capacity(commits.len()); + for c in &commits { + if src_sha_set.contains(c.commit_sha.as_str()) { + continue; + } + new_order.push(OrderedCommit { + sha: c.commit_sha.clone(), + subject: c.title.clone(), + }); + if c.commit_sha == target.sha { + new_order.extend(srcs.iter().cloned()); + } + } + + if opts.dry_run { + return Ok(Outcome::DryRun { plan: new_order }); + } + + let ordered_shas: Vec = new_order.iter().map(|c| c.sha.clone()).collect(); + let fixup_shas: Vec = srcs.iter().map(|s| s.sha.clone()).collect(); + let (exec_after_sha, exec_command) = if let Some(msg) = opts.message { + let msg_path = write_temp_message(msg)?; + let command = format!( + "git commit --amend -F {}", + shell_quote(&msg_path.to_string_lossy()) + ); + let last_src = srcs.last().expect("non-empty validated above").sha.clone(); + (Some(last_src), Some(command)) + } else { + (None, None) + }; + + spawn_squash_rebase( + &repo_dir, + &base, + opts.mergify_binary, + &ordered_shas, + &fixup_shas, + exec_after_sha.as_deref(), + exec_command.as_deref(), + )?; + Ok(Outcome::Squashed { plan: new_order }) +} + +fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result { + let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { + ( + commits + .iter() + .filter(|c| c.change_id.starts_with(prefix)) + .collect(), + "Change-Id", + ) + } else { + ( + commits + .iter() + .filter(|c| c.commit_sha.starts_with(prefix)) + .collect(), + "SHA", + ) + }; + match matches.as_slice() { + [] => Err(CliError::StackNotFound(format!( + "no commit found matching {field} prefix '{prefix}'" + ))), + [only] => Ok(OrderedCommit { + sha: only.commit_sha.clone(), + subject: only.title.clone(), + }), + many => { + let listing = many + .iter() + .map(|c| format!("{} {}", &c.commit_sha[..7], c.title)) + .collect::>() + .join("\n "); + Err(CliError::InvalidState(format!( + "{field} prefix '{prefix}' matches multiple commits:\n {listing}" + ))) + } + } +} + +fn write_temp_message(message: &str) -> Result { + let mut tmp = tempfile::Builder::new() + .prefix("mergify_squash_msg_") + .suffix(".txt") + .tempfile() + .map_err(|e| CliError::Generic(format!("create squash tempfile: {e}")))?; + tmp.write_all(message.as_bytes()) + .map_err(|e| CliError::Generic(format!("write squash tempfile: {e}")))?; + tmp.flush() + .map_err(|e| CliError::Generic(format!("flush squash tempfile: {e}")))?; + let (_, path) = tmp + .keep() + .map_err(|e| CliError::Generic(format!("persist squash tempfile: {e}")))?; + Ok(path) +} + +fn spawn_squash_rebase( + repo_dir: &Path, + base: &str, + mergify_binary: &Path, + ordered_shas: &[String], + fixup_shas: &[String], + exec_after_sha: Option<&str>, + exec_command: Option<&str>, +) -> Result<(), CliError> { + let bin = shell_quote(&mergify_binary.to_string_lossy()); + let ordered = shell_quote(&ordered_shas.join(",")); + let fixup = shell_quote(&fixup_shas.join(",")); + let mut editor = format!( + "{bin} _internal rebase-todo-rewrite --action squash --shas {ordered} --fixup-shas {fixup}" + ); + if let (Some(after), Some(cmd)) = (exec_after_sha, exec_command) { + editor.push_str(" --sha "); + editor.push_str(&shell_quote(after)); + editor.push_str(" --command "); + editor.push_str(&shell_quote(cmd)); + } + spawn_rebase(repo_dir, base, &editor) +} + +fn shell_quote(value: &str) -> String { + let escaped = value.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result { + let raw = run_git_capture(repo_dir, &["rev-parse", "--show-toplevel"])?; + Ok(PathBuf::from(raw)) +} + +fn spawn_rebase(repo_dir: &Path, base: &str, sequence_editor: &str) -> Result<(), CliError> { + let status = Command::new("git") + .arg("-C") + .arg(repo_dir) + .args(["rebase", "-i", base]) + .env("GIT_SEQUENCE_EDITOR", sequence_editor) + .status() + .map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))?; + if !status.success() { + return Err(CliError::Generic(format!( + "`git rebase -i {base}` exited {status}" + ))); + } + Ok(()) +} + +fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result { + let mut cmd = Command::new("git"); + if let Some(dir) = repo_dir { + cmd.arg("-C").arg(dir); + } + cmd.args(args); + let output = cmd + .output() + .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(CliError::Generic(if stderr.is_empty() { + format!("`git {}` failed", args.join(" ")) + } else { + stderr + })); + } + let stdout = String::from_utf8(output.stdout).map_err(|e| { + CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" "))) + })?; + Ok(stdout.trim_end().to_string()) +} diff --git a/crates/mergify-stack/src/rebase_todo.rs b/crates/mergify-stack/src/rebase_todo.rs index 712601f3..db29a75c 100644 --- a/crates/mergify-stack/src/rebase_todo.rs +++ b/crates/mergify-stack/src/rebase_todo.rs @@ -52,6 +52,18 @@ pub enum Action { /// `pick` line and the count must equal the number of picks in /// the todo. Reorder { ordered_shas: Vec }, + /// Combined reorder + fixup + optional exec-after used by + /// `stack squash`. Each SHA in `ordered_shas` matches exactly + /// one pick line; those listed in `fixup_shas` get their verb + /// rewritten to `fixup`. If `exec_after_sha` and `exec_command` + /// are both set, an `exec ` line is inserted right + /// after the matching todo entry. + Squash { + ordered_shas: Vec, + fixup_shas: Vec, + exec_after_sha: Option, + exec_command: Option, + }, } /// Apply `action` to `todo` and return the rewritten contents. @@ -66,6 +78,18 @@ pub fn rewrite(todo: &str, action: &Action) -> Result { Action::Reword { sha } => rewrite_replace_verb(todo, std::slice::from_ref(sha), "reword"), Action::ExecAfter { sha, command } => rewrite_exec_after(todo, sha, command), Action::Reorder { ordered_shas } => rewrite_reorder(todo, ordered_shas), + Action::Squash { + ordered_shas, + fixup_shas, + exec_after_sha, + exec_command, + } => rewrite_squash( + todo, + ordered_shas, + fixup_shas, + exec_after_sha.as_deref(), + exec_command.as_deref(), + ), } } @@ -275,6 +299,83 @@ fn rewrite_reorder(todo: &str, ordered_shas: &[String]) -> Result, + exec_command: Option<&str>, +) -> Result { + // Split todo into picks (keyed by SHA) and other lines (kept + // verbatim at the end, same bucketing as Action::Reorder). + let mut pick_lines: Vec<(String, String)> = Vec::new(); + let mut other_lines = String::new(); + for line in todo.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if let Some(rest) = trimmed.strip_prefix("pick ") { + let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + pick_lines.push((sha.to_string(), line.to_string())); + } else { + other_lines.push_str(line); + } + } + if ordered_shas.len() != pick_lines.len() { + return Err(CliError::InvalidState(format!( + "rebase-todo squash: have {} pick lines but caller asked for {} ordered SHAs", + pick_lines.len(), + ordered_shas.len() + ))); + } + + let exec_set = exec_after_sha.zip(exec_command); + let mut consumed = vec![false; pick_lines.len()]; + let mut out = String::with_capacity(todo.len()); + for sha in ordered_shas { + let idx = pick_lines + .iter() + .enumerate() + .position(|(i, (todo_sha, _))| !consumed[i] && sha_matches(todo_sha, sha)); + let Some(idx) = idx else { + return Err(CliError::InvalidState(format!( + "rebase-todo squash: no remaining pick line matches {sha}" + ))); + }; + consumed[idx] = true; + let original_line = &pick_lines[idx].1; + let trimmed = original_line.trim_end_matches(['\n', '\r']); + let rest = trimmed.strip_prefix("pick ").unwrap_or(trimmed); + let terminator = &original_line[trimmed.len()..]; + let term_to_emit = if terminator.is_empty() { + "\n" + } else { + terminator + }; + + let verb = if fixup_shas.iter().any(|f| sha_matches(sha, f)) { + "fixup" + } else { + "pick" + }; + out.push_str(verb); + out.push(' '); + out.push_str(rest); + out.push_str(term_to_emit); + + if let Some((after_sha, command)) = exec_set { + if sha_matches(sha, after_sha) { + out.push_str("exec "); + out.push_str(command); + out.push_str(term_to_emit); + } + } + } + out.push_str(&other_lines); + Ok(out) +} + /// True when *either* `todo_sha` or `target` is a prefix of the /// other. Mirrors Python's /// `target.startswith(parts[1]) or parts[1].startswith(target)` @@ -631,6 +732,80 @@ exec cargo test } } + #[test] + fn squash_folds_srcs_into_target_and_keeps_target_verb() { + // ordered: target then src; src is fixup so it folds in. + let out = rewrite( + TODO, + &Action::Squash { + ordered_shas: vec![ + "1a2b3c4d".to_string(), // A + "cafe1234".to_string(), // C (will be fixed up into A) + "deadbeef".to_string(), // B (stays a pick) + ], + fixup_shas: vec!["cafe1234".to_string()], + exec_after_sha: None, + exec_command: None, + }, + ) + .unwrap(); + assert_eq!( + out, + "pick 1a2b3c4d feat: add foo\n\ + fixup cafe1234 fix: typo\n\ + pick deadbeef chore: bump deps\n\ + \n\ + # Rebase abc..def onto abc (3 commands)\n" + ); + } + + #[test] + fn squash_with_exec_after_injects_command() { + let out = rewrite( + TODO, + &Action::Squash { + ordered_shas: vec![ + "1a2b3c4d".to_string(), + "cafe1234".to_string(), + "deadbeef".to_string(), + ], + fixup_shas: vec!["cafe1234".to_string()], + exec_after_sha: Some("cafe1234".to_string()), + exec_command: Some("git commit --amend -F /tmp/msg.txt".to_string()), + }, + ) + .unwrap(); + assert_eq!( + out, + "pick 1a2b3c4d feat: add foo\n\ + fixup cafe1234 fix: typo\n\ + exec git commit --amend -F /tmp/msg.txt\n\ + pick deadbeef chore: bump deps\n\ + \n\ + # Rebase abc..def onto abc (3 commands)\n" + ); + } + + #[test] + fn squash_count_mismatch_errors() { + let err = rewrite( + TODO, + &Action::Squash { + ordered_shas: vec!["1a2b3c4d".to_string()], + fixup_shas: vec![], + exec_after_sha: None, + exec_command: None, + }, + ) + .unwrap_err(); + match err { + CliError::InvalidState(msg) => { + assert!(msg.contains("3 pick lines"), "got: {msg}"); + } + other => panic!("unexpected: {other:?}"), + } + } + #[test] fn reorder_unknown_sha_errors() { let err = rewrite( diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index fab57524..17a951ac 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -16,7 +16,6 @@ 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 setup as stack_setup_mod -from mergify_cli.stack import squash as stack_squash_mod from mergify_cli.stack import sync as stack_sync_mod @@ -507,54 +506,3 @@ async def open_cmd( token=ctx.obj["token"], commit=commit, ) - - -@stack.command(help="Squash commits into a target commit") -@click.argument("tokens", nargs=-1, required=True) -@click.option( - "-m", - "--message", - "message", - default=None, - help="Final commit message (required to rename; otherwise target's is kept)", -) -@click.option( - "--dry-run", - "-n", - is_flag=True, - default=False, - help="Show the plan without rebasing", -) -@utils.run_with_asyncio -async def squash( - *, - tokens: tuple[str, ...], - message: str | None, - dry_run: bool, -) -> None: - srcs, target = _parse_squash_tokens(tokens) - await stack_squash_mod.stack_squash( - src_prefixes=srcs, - target_prefix=target, - message=message, - dry_run=dry_run, - ) - - -def _parse_squash_tokens(tokens: tuple[str, ...]) -> tuple[list[str], str]: - """Parse ``SRC... into TARGET`` from a flat tuple of tokens. - - Raises :class:`click.BadParameter` on shape errors. - """ - into_positions = [i for i, t in enumerate(tokens) if t == "into"] - if len(into_positions) != 1: - msg = "squash requires exactly one 'into' keyword: SRC... into TARGET" - raise click.BadParameter(msg) - idx = into_positions[0] - srcs = list(tokens[:idx]) - after = tokens[idx + 1 :] - if not srcs: - raise click.BadParameter("at least one source commit required before 'into'") - if len(after) != 1: - raise click.BadParameter("exactly one target commit required after 'into'") - return srcs, after[0] diff --git a/mergify_cli/stack/reorder.py b/mergify_cli/stack/reorder.py deleted file mode 100644 index e4056928..00000000 --- a/mergify_cli/stack/reorder.py +++ /dev/null @@ -1,231 +0,0 @@ -# -# 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 console_error -from mergify_cli.exit_codes import ExitCode -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_error(f"no commit found matching {field_name} prefix '{prefix}'") - sys.exit(ExitCode.STACK_NOT_FOUND) - if len(matches) > 1: - console_error( - f"ambiguous {field_name} prefix '{prefix}' matches {len(matches)} commits:", - ) - for sha, subject, change_id in matches: - console.print(f" {sha[:12]} {subject} ({change_id[:12]})", style="red") - sys.exit(ExitCode.INVALID_STATE) - - return matches[0] - - -def run_scripted_rebase(base: str, script_content: str) -> None: - """Run ``git rebase -i`` with a custom sequence-editor script. - - Writes *script_content* to a temporary Python file, sets it as - ``GIT_SEQUENCE_EDITOR``, then executes the rebase. The temp file - is cleaned up regardless of outcome. - """ - tmp_fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="mergify_rebase_") - 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_error("rebase failed — there may be conflicts") - console.print( - "Resolve conflicts then run: git rebase --continue", - ) - console.print( - "Or abort the rebase with: git rebase --abort", - ) - sys.exit(ExitCode.CONFLICT) - finally: - tmp_file = pathlib.Path(tmp_path) - if tmp_file.exists(): - tmp_file.unlink() - - -def run_rebase(base: str, ordered_shas: list[str]) -> None: - """Run ``git rebase -i`` reordering picks to match *ordered_shas*.""" - run_action_rebase(base, ordered_shas, {}) - - -def run_action_rebase( - base: str, - ordered_shas: list[str], - actions: dict[str, str], - exec_after_sha: str | None = None, - exec_command: str | None = None, -) -> None: - """Run ``git rebase -i`` reordering picks and changing their action. - - *ordered_shas* is the desired full order (as in ``run_rebase``). - - *actions* maps sha -> action string (``"fixup"`` is the expected - value for stack_squash/stack_fixup). Each listed sha has its - ``pick`` replaced by the given action. Shas not in *actions* stay - as ``pick``. - - If *exec_after_sha* and *exec_command* are both provided, an - ``exec `` line is inserted right after the row for - *exec_after_sha*. Used by squash to amend the combined commit's - message while HEAD still points at it. - """ - script_content = ( - "#!/usr/bin/env python3\n" - "import sys\n" - "order = " + repr(ordered_shas) + "\n" - "actions = " + repr(actions) + "\n" - "exec_after_sha = " + repr(exec_after_sha) + "\n" - "exec_command = " + repr(exec_command) + "\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" - " matched = False\n" - " for key, line in pick_lines.items():\n" - " if sha.startswith(key) or key.startswith(sha):\n" - " matched = True\n" - " action = None\n" - " for act_sha, act in actions.items():\n" - " if sha.startswith(act_sha) or act_sha.startswith(sha):\n" - " action = act\n" - " break\n" - " if action is not None:\n" - " _parts = line.split(None, 1)\n" - " rest = _parts[1] if len(_parts) > 1 else ''\n" - " line = action + ' ' + rest\n" - " reordered.append(line)\n" - " if exec_after_sha is not None and exec_command is not None:\n" - " if sha.startswith(exec_after_sha) or exec_after_sha.startswith(sha):\n" - " reordered.append('exec ' + exec_command + '\\n')\n" - " break\n" - " if not matched:\n" - " raise SystemExit('no rebase-todo line matches sha ' + sha)\n" - "with open(todo_path, 'w') as f:\n" - " f.writelines(reordered + other_lines)\n" - ) - run_scripted_rebase(base, script_content) - - -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}") - - -def display_action_plan( - title: str, - commits: list[tuple[str, str, str]], - actions: dict[str, str], -) -> None: - """Print the planned commit order, tagging rows with their action.""" - console.log(title) - for idx, (sha, subject, change_id) in enumerate(commits, 1): - cid_display = f" ({change_id[:12]})" if change_id else "" - tag = "" - for act_sha, act in actions.items(): - if sha.startswith(act_sha) or act_sha.startswith(sha): - tag = f" [{act}]" - break - console.log(f" {idx}. {sha[:12]} {subject}{cid_display}{tag}") diff --git a/mergify_cli/stack/squash.py b/mergify_cli/stack/squash.py deleted file mode 100644 index e3de4ce8..00000000 --- a/mergify_cli/stack/squash.py +++ /dev/null @@ -1,119 +0,0 @@ -# -# 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 sys -import tempfile - -from mergify_cli import console -from mergify_cli import console_error -from mergify_cli import utils -from mergify_cli.exit_codes import ExitCode -from mergify_cli.stack.reorder import display_action_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_action_rebase - - -async def stack_squash( - src_prefixes: list[str], - target_prefix: str, - *, - message: 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 - - target = match_commit(target_prefix, commits) - srcs = [match_commit(p, commits) for p in src_prefixes] - - # Validate: TARGET not among SRCs - if any(s[0] == target[0] for s in srcs): - console_error("a source commit cannot be the same as the target") - sys.exit(ExitCode.INVALID_STATE) - - # Validate: no duplicate SRCs - src_shas = [s[0] for s in srcs] - if len(set(src_shas)) != len(src_shas): - seen: set[str] = set() - for prefix, sha in zip(src_prefixes, src_shas, strict=True): - if sha in seen: - console_error( - f"duplicate — source prefix '{prefix}' resolves to the same commit as another", - ) - sys.exit(ExitCode.INVALID_STATE) - seen.add(sha) - - # Build new order: keep non-SRCs in original order; re-insert SRCs - # immediately after TARGET in the listed order. - src_sha_set = set(src_shas) - new_order: list[tuple[str, str, str]] = [] - for commit in commits: - if commit[0] in src_sha_set: - continue - new_order.append(commit) - if commit[0] == target[0]: - new_order.extend(srcs) - - # Always use the fixup action: it folds without opening an editor and - # preserves TARGET's message and Change-Id. A custom message, if - # requested, is applied via an `exec git commit --amend -F ...` - # line inserted right after the last fixup — while HEAD still points - # at the combined target commit. The amend runs prepare-commit-msg so - # the Change-Id is re-attached. The message is passed via a temp - # file (not `-m "..."`) so multi-line messages survive embedding - # into a single rebase-todo line. - actions = dict.fromkeys(src_shas, "fixup") - - display_action_plan("Squash plan:", new_order, actions) - - if dry_run: - console.print("Dry run — no changes made", style="green") - return - - new_shas = [c[0] for c in new_order] - - if message is None: - run_action_rebase(base, new_shas, actions) - else: - msg_fd, msg_path = tempfile.mkstemp(suffix=".txt", prefix="mergify_squash_msg_") - with os.fdopen(msg_fd, "w") as f: - f.write(message) - # Intentionally NOT in a `finally`: if `run_action_rebase` raises - # SystemExit (conflicts), the rebase-todo still references this - # file, so `git rebase --continue` will need it to complete the - # `exec git commit --amend -F …` line. Leak the file rather than - # break --continue; the system temp directory is cleaned up by the OS. - run_action_rebase( - base, - new_shas, - actions, - exec_after_sha=src_shas[-1], - exec_command=f"git commit --amend -F {shlex.quote(msg_path)}", - ) - pathlib.Path(msg_path).unlink(missing_ok=True) - - console.print("Commits squashed successfully.", style="green") diff --git a/mergify_cli/tests/stack/test_squash.py b/mergify_cli/tests/stack/test_squash.py deleted file mode 100644 index 49a3f568..00000000 --- a/mergify_cli/tests/stack/test_squash.py +++ /dev/null @@ -1,421 +0,0 @@ -# -# 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 re -import shlex -import subprocess - -import pytest - -from mergify_cli.exit_codes import ExitCode -from mergify_cli.stack.squash import stack_squash - - -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]: - (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]: - raw = _run_git( - "log", - "--reverse", - f"-{n}", - "--format=%s", - cwd=repo, - ) - return [line for line in raw.splitlines() if line.strip()] - - -def _get_head_message(repo: pathlib.Path, sha: str = "HEAD") -> str: - return _run_git("log", "-1", "--format=%B", sha, cwd=repo).strip() - - -def _setup_tracking(repo: pathlib.Path) -> None: - 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 feature commits (A, B, C) on top of main.""" - repo = git_repo_with_hooks - - (repo / "init.txt").write_text("init") - _run_git("add", "init.txt", cwd=repo) - _run_git("commit", "-m", "Initial commit", cwd=repo) - - _setup_tracking(repo) - - _run_git("checkout", "-b", "feature", "main", cwd=repo) - _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) - - 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 TestStackSquash: - async def test_squash_single_into_target_no_message( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - """squash C into A (no -m): C folds into A keeping A's message.""" - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - sha_c = commits[2][0][:12] - - await stack_squash( - src_prefixes=[sha_c], - target_prefix=sha_a, - message=None, - dry_run=False, - ) - - feature = [s for s in _get_commit_subjects(repo) if s.startswith("Commit")] - # C was reordered adjacent to A, then folded in; B stays where it was. - assert feature == ["Commit A", "Commit B"] - # All content preserved - assert (repo / "a.txt").exists() - assert (repo / "b.txt").exists() - assert (repo / "c.txt").exists() - # Message at A's position is still "Commit A" - log = _run_git("log", "--format=%s", cwd=repo).splitlines() - assert "Commit A" in log - - async def test_squash_with_custom_message( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - """squash C into A -m 'combined': final commit message is 'combined'.""" - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - sha_c = commits[2][0][:12] - - await stack_squash( - src_prefixes=[sha_c], - target_prefix=sha_a, - message="feat: combined A+C", - dry_run=False, - ) - - feature = [ - s for s in _get_commit_subjects(repo) if s.startswith(("Commit", "feat")) - ] - assert "feat: combined A+C" in feature - # Original "Commit A" title is gone — replaced by the custom one - assert "Commit A" not in feature - - async def test_squash_with_multiline_message( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - """Multi-line message survives the rebase-todo / exec indirection.""" - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - sha_c = commits[2][0][:12] - - message = ( - "feat: combined with body\n" - "\n" - "A body paragraph explaining the combined change.\n" - "Second line of the body with a 'single quote' and \"double\".\n" - ) - - await stack_squash( - src_prefixes=[sha_c], - target_prefix=sha_a, - message=message, - dry_run=False, - ) - - # The commit's full message should contain both the subject and body. - subjects = _get_commit_subjects(repo) - assert "feat: combined with body" in subjects - full_msg = _get_head_message(repo, "HEAD~1") - assert "A body paragraph explaining the combined change." in full_msg - assert "Second line of the body" in full_msg - - async def test_squash_multiple_srcs_reordered( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - """squash B C into A: B and C are folded into A; stack becomes [A].""" - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - sha_b = commits[1][0][:12] - sha_c = commits[2][0][:12] - - await stack_squash( - src_prefixes=[sha_b, sha_c], - target_prefix=sha_a, - message=None, - dry_run=False, - ) - - feature = [s for s in _get_commit_subjects(repo) if s.startswith("Commit")] - assert feature == ["Commit A"] - assert (repo / "a.txt").exists() - assert (repo / "b.txt").exists() - assert (repo / "c.txt").exists() - - async def test_squash_reorder_preserves_non_srcs( - self, - git_repo_with_hooks: pathlib.Path, - ) -> None: - """A, B, C, D: squash D into A. Non-SRCs B, C keep original order.""" - repo = git_repo_with_hooks - (repo / "init.txt").write_text("init") - _run_git("add", "init.txt", cwd=repo) - _run_git("commit", "-m", "Initial commit", cwd=repo) - _setup_tracking(repo) - _run_git("checkout", "-b", "feature", "main", cwd=repo) - _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) - - commits = [] - for label, filename in [ - ("A", "a.txt"), - ("B", "b.txt"), - ("C", "c.txt"), - ("D", "d.txt"), - ]: - sha, _cid = _create_commit( - repo, - filename, - f"content {label}", - f"Commit {label}", - ) - commits.append(sha) - - os.chdir(repo) - await stack_squash( - src_prefixes=[commits[3][:12]], - target_prefix=commits[0][:12], - message=None, - dry_run=False, - ) - - feature = [s for s in _get_commit_subjects(repo) if s.startswith("Commit")] - # A remains first; D folded into A; B and C keep original order. - assert feature == ["Commit A", "Commit B", "Commit C"] - - async def test_squash_src_equals_target_errors( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - - with pytest.raises(SystemExit) as exc_info: - await stack_squash( - src_prefixes=[sha_a], - target_prefix=sha_a, - message=None, - dry_run=False, - ) - assert exc_info.value.code == ExitCode.INVALID_STATE - - async def test_squash_duplicate_srcs_errors( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - 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_squash( - src_prefixes=[sha_b, sha_b], - target_prefix=sha_a, - message=None, - dry_run=False, - ) - assert exc_info.value.code == ExitCode.INVALID_STATE - - async def test_squash_unknown_src_errors( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - repo, commits = stack_repo - os.chdir(repo) - - sha_a = commits[0][0][:12] - - with pytest.raises(SystemExit) as exc_info: - await stack_squash( - src_prefixes=["deadbeef"], - target_prefix=sha_a, - message=None, - dry_run=False, - ) - assert exc_info.value.code == ExitCode.STACK_NOT_FOUND - - async def test_squash_unknown_target_errors( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - repo, commits = stack_repo - os.chdir(repo) - - sha_b = commits[1][0][:12] - - with pytest.raises(SystemExit) as exc_info: - await stack_squash( - src_prefixes=[sha_b], - target_prefix="deadbeef", - message=None, - dry_run=False, - ) - assert exc_info.value.code == ExitCode.STACK_NOT_FOUND - - async def test_squash_dry_run( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - ) -> None: - repo, commits = stack_repo - os.chdir(repo) - - head_before = _run_git("rev-parse", "HEAD", cwd=repo) - - sha_a = commits[0][0][:12] - sha_c = commits[2][0][:12] - - await stack_squash( - src_prefixes=[sha_c], - target_prefix=sha_a, - message=None, - dry_run=True, - ) - - head_after = _run_git("rev-parse", "HEAD", cwd=repo) - assert head_before == head_after - - async def test_squash_empty_stack( - self, - git_repo_with_hooks: pathlib.Path, - ) -> None: - repo = git_repo_with_hooks - (repo / "init.txt").write_text("init") - _run_git("add", "init.txt", cwd=repo) - _run_git("commit", "-m", "Initial commit", cwd=repo) - _setup_tracking(repo) - _run_git("checkout", "-b", "feature", "main", cwd=repo) - _run_git("branch", "--set-upstream-to=origin/main", cwd=repo) - - os.chdir(repo) - - await stack_squash( - src_prefixes=["any"], - target_prefix="thing", - message=None, - dry_run=False, - ) - - async def test_squash_with_message_keeps_temp_file_on_conflict( - self, - stack_repo: tuple[pathlib.Path, list[tuple[str, str | None]]], - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """If the rebase exits via SystemExit (e.g. conflicts), the temp - message file MUST persist so `git rebase --continue` can read it - when replaying the `exec git commit --amend -F …` line.""" - repo, commits = stack_repo - os.chdir(repo) - - captured: list[str] = [] - - def fake_rebase( - _base: str, - _ordered_shas: list[str], - _actions: dict[str, str], - *, - exec_after_sha: str | None = None, - exec_command: str | None = None, - ) -> None: - assert exec_command is not None, "squash with -m must inject an exec line" - assert exec_after_sha is not None - # exec_command is `git commit --amend -F `; - # shlex.split strips the quoting added by `shlex.quote(msg_path)`. - captured.append(shlex.split(exec_command)[-1]) - raise SystemExit(ExitCode.CONFLICT) - - monkeypatch.setattr( - "mergify_cli.stack.squash.run_action_rebase", - fake_rebase, - ) - - sha_a = commits[0][0][:12] - sha_c = commits[2][0][:12] - - with pytest.raises(SystemExit) as exc_info: - await stack_squash( - src_prefixes=[sha_c], - target_prefix=sha_a, - message="Combined commit message", - dry_run=False, - ) - assert exc_info.value.code == ExitCode.CONFLICT - - assert captured, "fake_rebase was not invoked with exec_command" - msg_file = pathlib.Path(captured[0]) - assert msg_file.exists(), ( - f"temp message file {msg_file} was deleted before the rebase " - "could be continued — git rebase --continue would then fail " - "to replay the `exec git commit --amend -F …` line" - ) - # Don't pollute the temp dir between tests. - msg_file.unlink() diff --git a/mergify_cli/tests/stack/test_squash_cli.py b/mergify_cli/tests/stack/test_squash_cli.py deleted file mode 100644 index c0fde15a..00000000 --- a/mergify_cli/tests/stack/test_squash_cli.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# 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 click -import pytest - -from mergify_cli.stack.cli import _parse_squash_tokens - - -class TestParseSquashTokens: - def test_single_src(self) -> None: - srcs, target = _parse_squash_tokens(("A", "into", "X")) - assert srcs == ["A"] - assert target == "X" - - def test_multiple_srcs(self) -> None: - srcs, target = _parse_squash_tokens(("A", "B", "C", "into", "X")) - assert srcs == ["A", "B", "C"] - assert target == "X" - - def test_missing_into_errors(self) -> None: - with pytest.raises(click.BadParameter): - _parse_squash_tokens(("A", "B", "C", "X")) - - def test_two_intos_errors(self) -> None: - with pytest.raises(click.BadParameter): - _parse_squash_tokens(("A", "into", "B", "into", "X")) - - def test_no_srcs_errors(self) -> None: - with pytest.raises(click.BadParameter): - _parse_squash_tokens(("into", "X")) - - def test_no_target_errors(self) -> None: - with pytest.raises(click.BadParameter): - _parse_squash_tokens(("A", "into")) - - def test_multiple_targets_errors(self) -> None: - with pytest.raises(click.BadParameter): - _parse_squash_tokens(("A", "into", "X", "Y"))