Skip to content

Commit fa8c852

Browse files
feat(rebase): add --exec option to run commands after each patch
Add support for `stg rebase --exec <cmd>` 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: #469
1 parent d22cee4 commit fa8c852

File tree

5 files changed

+190
-1
lines changed

5 files changed

+190
-1
lines changed

src/cmd/rebase.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,25 @@ fn make() -> clap::Command {
8888
.action(clap::ArgAction::SetTrue),
8989
)
9090
.arg(argset::push_conflicts_arg())
91+
.arg(
92+
Arg::new("exec")
93+
.long("exec")
94+
.short('x')
95+
.help("Execute command after each patch is applied")
96+
.long_help(
97+
"Execute the given shell command after each patch is successfully \
98+
applied during the rebase operation. If the command fails (exits with \
99+
non-zero status), the rebase will halt.\n\
100+
\n\
101+
This option may be specified multiple times to run multiple commands \
102+
in sequence after each patch.\n\
103+
\n\
104+
This is similar to `git rebase --exec`.",
105+
)
106+
.action(clap::ArgAction::Append)
107+
.value_name("cmd")
108+
.conflicts_with_all(["nopush", "interactive"]),
109+
)
91110
}
92111

93112
fn run(matches: &ArgMatches) -> Result<()> {
@@ -237,13 +256,24 @@ fn run(matches: &ArgMatches) -> Result<()> {
237256
} else if !matches.get_flag("nopush") {
238257
stack.check_head_top_mismatch()?;
239258
let check_merged = matches.get_flag("merged");
259+
let exec_cmds: Vec<String> = matches
260+
.get_many::<String>("exec")
261+
.map(|vals| vals.cloned().collect())
262+
.unwrap_or_default();
263+
240264
stack
241265
.setup_transaction()
242266
.use_index_and_worktree(true)
243267
.allow_push_conflicts(allow_push_conflicts)
244268
.committer_date_is_author_date(committer_date_is_author_date)
245269
.with_output_stream(get_color_stdout(matches))
246-
.transact(|trans| trans.push_patches(&applied, check_merged))
270+
.transact(|trans| {
271+
if exec_cmds.is_empty() {
272+
trans.push_patches(&applied, check_merged)
273+
} else {
274+
trans.push_patches_with_exec(&applied, check_merged, &exec_cmds)
275+
}
276+
})
247277
.execute("rebase (reapply)")?;
248278
}
249279

src/stack/transaction/mod.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,59 @@ impl<'repo> StackTransaction<'repo> {
998998
})
999999
}
10001000

1001+
/// Push unapplied patches, running exec commands after each successful push.
1002+
///
1003+
/// This is similar to `push_patches`, but after each patch is pushed successfully,
1004+
/// all provided exec commands are run in sequence. If any exec command fails, the
1005+
/// entire transaction is rolled back (no patches remain applied).
1006+
///
1007+
/// This supports the `stg rebase --exec` functionality. Note that this differs from
1008+
/// `git rebase --exec` which leaves you at the failing point; stgit's behavior is
1009+
/// safer and consistent with how stgit transactions work.
1010+
pub(crate) fn push_patches_with_exec<P>(
1011+
&mut self,
1012+
patchnames: &[P],
1013+
check_merged: bool,
1014+
exec_cmds: &[String],
1015+
) -> Result<()>
1016+
where
1017+
P: AsRef<PatchName>,
1018+
{
1019+
let stupid = self.stack.repo.stupid();
1020+
stupid.with_temp_index(|stupid_temp| {
1021+
let mut temp_index_tree_id: Option<gix::ObjectId> = None;
1022+
1023+
let merged = if check_merged {
1024+
Some(self.check_merged(patchnames, stupid_temp, &mut temp_index_tree_id)?)
1025+
} else {
1026+
None
1027+
};
1028+
1029+
for (i, patchname) in patchnames.iter().enumerate() {
1030+
let patchname = patchname.as_ref();
1031+
let is_last = i + 1 == patchnames.len() && exec_cmds.is_empty();
1032+
let already_merged = merged
1033+
.as_ref()
1034+
.is_some_and(|merged| merged.contains(&patchname));
1035+
self.push_patch(
1036+
patchname,
1037+
already_merged,
1038+
is_last,
1039+
stupid_temp,
1040+
&mut temp_index_tree_id,
1041+
)?;
1042+
1043+
// Run exec commands after each successful push
1044+
for exec_cmd in exec_cmds {
1045+
self.ui.print_exec(exec_cmd)?;
1046+
stupid.exec_cmd(exec_cmd)?;
1047+
}
1048+
}
1049+
1050+
Ok(())
1051+
})
1052+
}
1053+
10011054
fn push_patch(
10021055
&mut self,
10031056
patchname: &PatchName,

src/stack/transaction/ui.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,16 @@ impl TransactionUserInterface {
243243
Ok(())
244244
}
245245

246+
pub(super) fn print_exec(&self, cmd: &str) -> Result<()> {
247+
let mut output = self.output.borrow_mut();
248+
let mut color_spec = termcolor::ColorSpec::new();
249+
output.set_color(color_spec.set_fg(Some(termcolor::Color::Yellow)))?;
250+
write!(output, "Executing: ")?;
251+
output.reset()?;
252+
writeln!(output, "{cmd}")?;
253+
Ok(())
254+
}
255+
246256
pub(super) fn print_updated(&self, patchname: &PatchName, applied: &[PatchName]) -> Result<()> {
247257
let mut output = self.output.borrow_mut();
248258
let (is_applied, is_top) = if let Some(pos) = applied.iter().position(|pn| pn == patchname)

src/stupid/context.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,35 @@ impl StupidContext<'_, '_> {
13171317
}
13181318
}
13191319

1320+
/// Run user-provided exec command in a shell.
1321+
///
1322+
/// This executes the command using the user's shell (from $SHELL env var, or
1323+
/// "sh" as fallback), similar to how `git rebase --exec` works.
1324+
pub(crate) fn exec_cmd(&self, cmd: &str) -> Result<()> {
1325+
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
1326+
let mut command = Command::new(&shell);
1327+
if let Some(work_dir) = self.work_dir {
1328+
command.current_dir(work_dir);
1329+
}
1330+
self.setup_git_env(&mut command);
1331+
let status = command
1332+
.arg("-c")
1333+
.arg(cmd)
1334+
.stdin(Stdio::inherit())
1335+
.stdout(Stdio::inherit())
1336+
.stderr(Stdio::inherit())
1337+
.status()
1338+
.with_context(|| format!("could not execute `{cmd}`"))?;
1339+
1340+
if status.success() {
1341+
Ok(())
1342+
} else if let Some(code) = status.code() {
1343+
Err(anyhow!("`{cmd}` exited with code {code}"))
1344+
} else {
1345+
Err(anyhow!("`{cmd}` failed"))
1346+
}
1347+
}
1348+
13201349
/// Run user-provided rebase command.
13211350
pub(crate) fn user_rebase(&self, user_cmd_str: &str, target: gix::ObjectId) -> Result<()> {
13221351
let mut args = user_cmd_str.split(|c: char| c.is_ascii_whitespace());

t/t2206-rebase-exec.sh

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/bin/sh
2+
3+
test_description='Test stg rebase --exec'
4+
5+
. ./test-lib.sh
6+
7+
test_expect_success 'Setup stack with multiple patches' '
8+
echo base >file &&
9+
stg add file &&
10+
git commit -m base &&
11+
echo update >file2 &&
12+
stg add file2 &&
13+
git commit -m "add file2" &&
14+
stg branch --create test-exec master~1 &&
15+
stg new p1 -m "patch 1" &&
16+
echo p1 >>file &&
17+
stg refresh &&
18+
stg new p2 -m "patch 2" &&
19+
echo p2 >>file &&
20+
stg refresh &&
21+
stg new p3 -m "patch 3" &&
22+
echo p3 >>file &&
23+
stg refresh
24+
'
25+
26+
test_expect_success 'Rebase with --exec runs command after each patch' '
27+
rm -f exec.log &&
28+
stg rebase --exec "echo EXEC >>exec.log" master &&
29+
test $(stg series --applied -c) = 3 &&
30+
test $(wc -l <exec.log) = 3
31+
'
32+
33+
test_expect_success 'Rebase with multiple --exec options' '
34+
rm -f exec.log exec2.log &&
35+
stg rebase --exec "echo EXEC1 >>exec.log" --exec "echo EXEC2 >>exec2.log" master~1 &&
36+
test $(stg series --applied -c) = 3 &&
37+
test $(wc -l <exec.log) = 3 &&
38+
test $(wc -l <exec2.log) = 3
39+
'
40+
41+
test_expect_success 'Rebase with --exec halts on command failure and rolls back' '
42+
rm -f exec.log out &&
43+
test_must_fail stg rebase --exec "echo EXEC >>exec.log && false" master >out 2>&1 &&
44+
grep -q "exited with code" out &&
45+
test_line_count = 1 exec.log &&
46+
test "$(stg series --applied -c)" = "0"
47+
'
48+
49+
test_expect_success 'Rebase --exec conflicts with --nopush' '
50+
test_must_fail stg rebase --exec "true" --nopush master 2>err &&
51+
grep -q "cannot be used with" err
52+
'
53+
54+
test_expect_success 'Rebase --exec conflicts with --interactive' '
55+
test_must_fail stg rebase --exec "true" --interactive master 2>err &&
56+
grep -q "cannot be used with" err
57+
'
58+
59+
test_expect_success 'Rebase --exec can run complex shell commands' '
60+
rm -f exec.log &&
61+
stg push -a &&
62+
stg rebase --exec "pwd && ls -la >>exec.log" master~1 &&
63+
test $(stg series --applied -c) = 3 &&
64+
test -f exec.log
65+
'
66+
67+
test_done

0 commit comments

Comments
 (0)