Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/cmd/rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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::<String>("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)")?;
}

Expand Down
48 changes: 46 additions & 2 deletions src/stack/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<P>(&mut self, patchnames: &[P], check_merged: bool) -> Result<()>
where
P: AsRef<PatchName>,
{
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<P>(
&mut self,
patchnames: &[P],
check_merged: bool,
exec_cmds: &[&str],
) -> Result<()>
where
P: AsRef<PatchName>,
{
self.push_patches_impl(patchnames, check_merged, exec_cmds)
}

/// Core implementation for pushing patches with optional exec commands.
fn push_patches_impl<P>(
&mut self,
patchnames: &[P],
check_merged: bool,
exec_cmds: &[&str],
) -> Result<()>
where
P: AsRef<PatchName>,
{
Expand All @@ -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(())
Expand Down
10 changes: 10 additions & 0 deletions src/stack/transaction/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions src/stupid/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
67 changes: 67 additions & 0 deletions t/t2206-rebase-exec.sh
Original file line number Diff line number Diff line change
@@ -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) = 3
'

test_expect_success 'Rebase with multiple --exec options' '
rm -f exec.log exec2.log &&
stg rebase --exec "echo EXEC1 >>exec.log" --exec "echo EXEC2 >>exec2.log" master~1 &&
test $(stg series --applied -c) = 3 &&
test $(wc -l <exec.log) = 3 &&
test $(wc -l <exec2.log) = 3
'

test_expect_success 'Rebase with --exec rolls back on command failure' '
rm -f exec.log out &&
test_must_fail stg rebase --exec "echo EXEC >>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