diff --git a/src/cmd/rebase.rs b/src/cmd/rebase.rs index 82a3a213..3c1faee7 100644 --- a/src/cmd/rebase.rs +++ b/src/cmd/rebase.rs @@ -88,6 +88,28 @@ fn make() -> clap::Command { .action(clap::ArgAction::SetTrue), ) .arg(argset::push_conflicts_arg()) + .arg( + Arg::new("exec") + .long("exec") + .short('x') + .help("Execute command after each patch is applied") + .long_help( + "Execute the given shell command after each patch is successfully \ + applied during the rebase operation. If the command fails (exits with \ + non-zero status), the entire transaction is rolled back and no patches \ + remain applied.\n\ + \n\ + This option may be specified multiple times to run multiple commands \ + in sequence after each patch.\n\ + \n\ + This is similar to `git rebase --exec`, but note that stgit rolls back \ + the entire operation on failure rather than leaving you at the failing \ + point.", + ) + .action(clap::ArgAction::Append) + .value_name("cmd") + .conflicts_with_all(["nopush", "interactive"]), + ) } fn run(matches: &ArgMatches) -> Result<()> { @@ -237,13 +259,24 @@ fn run(matches: &ArgMatches) -> Result<()> { } else if !matches.get_flag("nopush") { stack.check_head_top_mismatch()?; let check_merged = matches.get_flag("merged"); + let exec_cmds: Vec<&str> = matches + .get_many::("exec") + .map(|vals| vals.map(String::as_str).collect()) + .unwrap_or_default(); + stack .setup_transaction() .use_index_and_worktree(true) .allow_push_conflicts(allow_push_conflicts) .committer_date_is_author_date(committer_date_is_author_date) .with_output_stream(get_color_stdout(matches)) - .transact(|trans| trans.push_patches(&applied, check_merged)) + .transact(|trans| { + if exec_cmds.is_empty() { + trans.push_patches(&applied, check_merged) + } else { + trans.push_patches_with_exec(&applied, check_merged, &exec_cmds) + } + }) .execute("rebase (reapply)")?; } diff --git a/src/stack/transaction/mod.rs b/src/stack/transaction/mod.rs index 44698947..f64d9811 100644 --- a/src/stack/transaction/mod.rs +++ b/src/stack/transaction/mod.rs @@ -966,6 +966,40 @@ impl<'repo> StackTransaction<'repo> { /// tree. Patches that are determined to have already been merged will still be /// pushed successfully, but their diff will be empty. pub(crate) fn push_patches

(&mut self, patchnames: &[P], check_merged: bool) -> Result<()> + where + P: AsRef, + { + self.push_patches_impl(patchnames, check_merged, &[]) + } + + /// Push unapplied patches, running exec commands after each successful push. + /// + /// After each patch is successfully pushed, all provided exec commands are run + /// in sequence. If any exec command fails, the entire transaction is rolled back + /// (no patches remain applied). + /// + /// This supports the `stg rebase --exec` functionality. Note that this differs from + /// `git rebase --exec` which leaves you at the failing point; stgit's rollback + /// behavior is safer and consistent with how stgit transactions work. + pub(crate) fn push_patches_with_exec

( + &mut self, + patchnames: &[P], + check_merged: bool, + exec_cmds: &[&str], + ) -> Result<()> + where + P: AsRef, + { + self.push_patches_impl(patchnames, check_merged, exec_cmds) + } + + /// Core implementation for pushing patches with optional exec commands. + fn push_patches_impl

( + &mut self, + patchnames: &[P], + check_merged: bool, + exec_cmds: &[&str], + ) -> Result<()> where P: AsRef, { @@ -981,17 +1015,27 @@ impl<'repo> StackTransaction<'repo> { for (i, patchname) in patchnames.iter().enumerate() { let patchname = patchname.as_ref(); - let is_last = i + 1 == patchnames.len(); + // When exec commands are provided, we can't optimize the final checkout + // because the commands may modify the working tree. Only treat the last + // patch as "last" (enabling checkout optimization) when there are no + // exec commands. + let should_optimize_final_checkout = + i + 1 == patchnames.len() && exec_cmds.is_empty(); let already_merged = merged .as_ref() .is_some_and(|merged| merged.contains(&patchname)); self.push_patch( patchname, already_merged, - is_last, + should_optimize_final_checkout, stupid_temp, &mut temp_index_tree_id, )?; + + for cmd in exec_cmds { + self.ui.print_exec(cmd)?; + stupid.exec_cmd(cmd)?; + } } Ok(()) diff --git a/src/stack/transaction/ui.rs b/src/stack/transaction/ui.rs index e8300a0f..39e289ed 100644 --- a/src/stack/transaction/ui.rs +++ b/src/stack/transaction/ui.rs @@ -243,6 +243,16 @@ impl TransactionUserInterface { Ok(()) } + pub(super) fn print_exec(&self, cmd: &str) -> Result<()> { + let mut output = self.output.borrow_mut(); + let mut color_spec = termcolor::ColorSpec::new(); + output.set_color(color_spec.set_fg(Some(termcolor::Color::Yellow)))?; + write!(output, "Executing: ")?; + output.reset()?; + writeln!(output, "{cmd}")?; + Ok(()) + } + pub(super) fn print_updated(&self, patchname: &PatchName, applied: &[PatchName]) -> Result<()> { let mut output = self.output.borrow_mut(); let (is_applied, is_top) = if let Some(pos) = applied.iter().position(|pn| pn == patchname) diff --git a/src/stupid/context.rs b/src/stupid/context.rs index 3f626b39..eb3a1a3c 100644 --- a/src/stupid/context.rs +++ b/src/stupid/context.rs @@ -1317,6 +1317,42 @@ impl StupidContext<'_, '_> { } } + /// Run user-provided exec command in a shell. + /// + /// This executes the command using the user's shell (from $SHELL env var, or + /// "sh" as fallback), similar to how `git rebase --exec` works. + pub(crate) fn exec_cmd(&self, cmd: &str) -> Result<()> { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()); + let mut command = Command::new(&shell); + if let Some(work_dir) = self.work_dir { + command.current_dir(work_dir); + } + self.setup_git_env(&mut command); + let status = command + .arg("-c") + .arg(cmd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("could not execute command: {cmd}"))?; + + if status.success() { + Ok(()) + } else if let Some(code) = status.code() { + Err(anyhow!("command exited with code {code}: {cmd}")) + } else { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return Err(anyhow!("command killed by signal {signal}: {cmd}")); + } + } + Err(anyhow!("command failed: {cmd}")) + } + } + /// Run user-provided rebase command. pub(crate) fn user_rebase(&self, user_cmd_str: &str, target: gix::ObjectId) -> Result<()> { let mut args = user_cmd_str.split(|c: char| c.is_ascii_whitespace()); diff --git a/t/t2206-rebase-exec.sh b/t/t2206-rebase-exec.sh new file mode 100755 index 00000000..486668bf --- /dev/null +++ b/t/t2206-rebase-exec.sh @@ -0,0 +1,67 @@ +#!/bin/sh + +test_description='Test stg rebase --exec' + +. ./test-lib.sh + +test_expect_success 'Setup stack with multiple patches' ' + echo base >file && + stg add file && + git commit -m base && + echo update >file2 && + stg add file2 && + git commit -m "add file2" && + stg branch --create test-exec master~1 && + stg new p1 -m "patch 1" && + echo p1 >>file && + stg refresh && + stg new p2 -m "patch 2" && + echo p2 >>file && + stg refresh && + stg new p3 -m "patch 3" && + echo p3 >>file && + stg refresh +' + +test_expect_success 'Rebase with --exec runs command after each patch' ' + rm -f exec.log && + stg rebase --exec "echo EXEC >>exec.log" master && + test $(stg series --applied -c) = 3 && + test $(wc -l >exec.log" --exec "echo EXEC2 >>exec2.log" master~1 && + test $(stg series --applied -c) = 3 && + test $(wc -l >exec.log && false" master >out 2>&1 && + grep -q "command exited with code" out && + test_line_count = 1 exec.log && + test "$(stg series --applied -c)" = "0" +' + +test_expect_success 'Rebase --exec conflicts with --nopush' ' + test_must_fail stg rebase --exec "true" --nopush master 2>err && + grep -q "cannot be used with" err +' + +test_expect_success 'Rebase --exec conflicts with --interactive' ' + test_must_fail stg rebase --exec "true" --interactive master 2>err && + grep -q "cannot be used with" err +' + +test_expect_success 'Rebase --exec can run complex shell commands' ' + rm -f exec.log && + stg push -a && + stg rebase --exec "pwd && ls -la >>exec.log" master~1 && + test $(stg series --applied -c) = 3 && + test -f exec.log +' + +test_done