From fa8c8526c9832df444ea04f556adc575c46a8a1b Mon Sep 17 00:00:00 2001 From: Bobby Nathan Date: Thu, 11 Dec 2025 19:22:52 -0500 Subject: [PATCH 1/2] feat(rebase): add --exec option to run commands after each patch Add support for `stg rebase --exec ` which executes a shell command after each patch is successfully applied during the rebase operation. This is modeled after `git rebase --exec`. Key changes: - Add `exec_cmd()` method to StupidContext for running shell commands - Add `push_patches_with_exec()` method to StackTransaction for pushing patches with exec commands between each push - Add `print_exec()` method to TransactionUserInterface for output - Add `--exec` / `-x` argument to the rebase command The exec command is run via the user's shell ($SHELL or "sh" as fallback). Multiple --exec options can be specified to run multiple commands in sequence after each patch. If any command fails, the rebase halts. Note: When an exec command fails, the entire transaction is rolled back (no patches remain applied). This differs from git rebase --exec which leaves you at the failing point. The rollback behavior is safer and consistent with how stgit transactions work. Implements: stacked-git/stgit#469 --- src/cmd/rebase.rs | 32 ++++++++++++++++- src/stack/transaction/mod.rs | 53 ++++++++++++++++++++++++++++ src/stack/transaction/ui.rs | 10 ++++++ src/stupid/context.rs | 29 ++++++++++++++++ t/t2206-rebase-exec.sh | 67 ++++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100755 t/t2206-rebase-exec.sh diff --git a/src/cmd/rebase.rs b/src/cmd/rebase.rs index 82a3a213..74477664 100644 --- a/src/cmd/rebase.rs +++ b/src/cmd/rebase.rs @@ -88,6 +88,25 @@ 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 rebase will halt.\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`.", + ) + .action(clap::ArgAction::Append) + .value_name("cmd") + .conflicts_with_all(["nopush", "interactive"]), + ) } fn run(matches: &ArgMatches) -> Result<()> { @@ -237,13 +256,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 = matches + .get_many::("exec") + .map(|vals| vals.cloned().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..4433dce5 100644 --- a/src/stack/transaction/mod.rs +++ b/src/stack/transaction/mod.rs @@ -998,6 +998,59 @@ impl<'repo> StackTransaction<'repo> { }) } + /// Push unapplied patches, running exec commands after each successful push. + /// + /// This is similar to `push_patches`, but after each patch is pushed successfully, + /// 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 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: &[String], + ) -> Result<()> + where + P: AsRef, + { + let stupid = self.stack.repo.stupid(); + stupid.with_temp_index(|stupid_temp| { + let mut temp_index_tree_id: Option = None; + + let merged = if check_merged { + Some(self.check_merged(patchnames, stupid_temp, &mut temp_index_tree_id)?) + } else { + None + }; + + for (i, patchname) in patchnames.iter().enumerate() { + let patchname = patchname.as_ref(); + let is_last = 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, + stupid_temp, + &mut temp_index_tree_id, + )?; + + // Run exec commands after each successful push + for exec_cmd in exec_cmds { + self.ui.print_exec(exec_cmd)?; + stupid.exec_cmd(exec_cmd)?; + } + } + + Ok(()) + }) + } + fn push_patch( &mut self, patchname: &PatchName, 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..15c1338e 100644 --- a/src/stupid/context.rs +++ b/src/stupid/context.rs @@ -1317,6 +1317,35 @@ 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 `{cmd}`"))?; + + if status.success() { + Ok(()) + } else if let Some(code) = status.code() { + Err(anyhow!("`{cmd}` exited with code {code}")) + } else { + Err(anyhow!("`{cmd}` failed")) + } + } + /// 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..04d2f273 --- /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 "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 From 146cb08ff3d0adb3a42c63588f7feb085301d836 Mon Sep 17 00:00:00 2001 From: Bobby Nathan Date: Thu, 11 Dec 2025 21:40:24 -0500 Subject: [PATCH 2/2] chore: do rust more idiomatically, clean up language still referencing the non-rollback behaviour --- src/cmd/rebase.rs | 11 +++--- src/stack/transaction/mod.rs | 69 ++++++++++++++++-------------------- src/stupid/context.rs | 13 +++++-- t/t2206-rebase-exec.sh | 4 +-- 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/src/cmd/rebase.rs b/src/cmd/rebase.rs index 74477664..3c1faee7 100644 --- a/src/cmd/rebase.rs +++ b/src/cmd/rebase.rs @@ -96,12 +96,15 @@ fn make() -> clap::Command { .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 rebase will halt.\n\ + 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`.", + 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") @@ -256,9 +259,9 @@ 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 = matches + let exec_cmds: Vec<&str> = matches .get_many::("exec") - .map(|vals| vals.cloned().collect()) + .map(|vals| vals.map(String::as_str).collect()) .unwrap_or_default(); stack diff --git a/src/stack/transaction/mod.rs b/src/stack/transaction/mod.rs index 4433dce5..f64d9811 100644 --- a/src/stack/transaction/mod.rs +++ b/src/stack/transaction/mod.rs @@ -969,49 +969,36 @@ impl<'repo> StackTransaction<'repo> { where P: AsRef, { - let stupid = self.stack.repo.stupid(); - stupid.with_temp_index(|stupid_temp| { - let mut temp_index_tree_id: Option = None; - - let merged = if check_merged { - Some(self.check_merged(patchnames, stupid_temp, &mut temp_index_tree_id)?) - } else { - None - }; - - for (i, patchname) in patchnames.iter().enumerate() { - let patchname = patchname.as_ref(); - let is_last = i + 1 == patchnames.len(); - let already_merged = merged - .as_ref() - .is_some_and(|merged| merged.contains(&patchname)); - self.push_patch( - patchname, - already_merged, - is_last, - stupid_temp, - &mut temp_index_tree_id, - )?; - } - - Ok(()) - }) + self.push_patches_impl(patchnames, check_merged, &[]) } /// Push unapplied patches, running exec commands after each successful push. /// - /// This is similar to `push_patches`, but after each patch is pushed successfully, - /// all provided exec commands are run in sequence. If any exec command fails, the - /// entire transaction is rolled back (no patches remain applied). + /// 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 behavior is - /// safer and consistent with how stgit transactions work. + /// `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: &[String], + 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, @@ -1028,22 +1015,26 @@ impl<'repo> StackTransaction<'repo> { for (i, patchname) in patchnames.iter().enumerate() { let patchname = patchname.as_ref(); - let is_last = i + 1 == patchnames.len() && exec_cmds.is_empty(); + // 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, )?; - // Run exec commands after each successful push - for exec_cmd in exec_cmds { - self.ui.print_exec(exec_cmd)?; - stupid.exec_cmd(exec_cmd)?; + for cmd in exec_cmds { + self.ui.print_exec(cmd)?; + stupid.exec_cmd(cmd)?; } } diff --git a/src/stupid/context.rs b/src/stupid/context.rs index 15c1338e..eb3a1a3c 100644 --- a/src/stupid/context.rs +++ b/src/stupid/context.rs @@ -1335,14 +1335,21 @@ impl StupidContext<'_, '_> { .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() - .with_context(|| format!("could not execute `{cmd}`"))?; + .with_context(|| format!("could not execute command: {cmd}"))?; if status.success() { Ok(()) } else if let Some(code) = status.code() { - Err(anyhow!("`{cmd}` exited with code {code}")) + Err(anyhow!("command exited with code {code}: {cmd}")) } else { - Err(anyhow!("`{cmd}` failed")) + #[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}")) } } diff --git a/t/t2206-rebase-exec.sh b/t/t2206-rebase-exec.sh index 04d2f273..486668bf 100755 --- a/t/t2206-rebase-exec.sh +++ b/t/t2206-rebase-exec.sh @@ -38,10 +38,10 @@ test_expect_success 'Rebase with multiple --exec options' ' test $(wc -l >exec.log && false" master >out 2>&1 && - grep -q "exited with code" out && + grep -q "command exited with code" out && test_line_count = 1 exec.log && test "$(stg series --applied -c)" = "0" '