gwm/exec.rs
1//! `gwm exec` (issue #313): run a command across worktrees and roll up the
2//! results.
3//!
4//! The CLI handler in `cli.rs` resolves which worktrees to target and prints
5//! the output; everything testable lives here: the spawn primitive
6//! ([`exec_in_dir`]), the aggregate exit code ([`rollup_exit_code`]), and the
7//! per-worktree line formatter ([`format_outcome`]). Execution is sequential
8//! — deterministic, readable output for the MVP; parallel fan-out is a
9//! deliberate follow-up.
10
11use crate::config::ExecConfig;
12use crate::error::{GwmError, Result};
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::Mutex;
17
18/// Outcome of running the command inside one worktree.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ExecStatus {
21 /// The command exited 0.
22 Ok,
23 /// The command exited with a non-zero code.
24 Failed(i32),
25 /// The command was terminated by a signal (no exit code available).
26 Signal,
27 /// The program could not be spawned at all (e.g. not found on `PATH`).
28 SpawnError(String),
29}
30
31/// A worktree's display name paired with its command outcome.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ExecOutcome {
34 pub name: String,
35 pub status: ExecStatus,
36}
37
38/// One parallel worktree run: its [`ExecOutcome`] plus the captured
39/// stdout+stderr bytes printed as a block by the caller.
40pub type CapturedRun = (ExecOutcome, Vec<u8>);
41
42/// Resolve the argv `gwm exec` should run, from exactly one source: an
43/// inline `-- <cmd>` or a `--profile <name>` (issue #324).
44///
45/// The two are mutually exclusive and exactly one is required:
46/// - both given → error (the profile *carries* the command);
47/// - a `--profile` naming an entry absent from `[exec.profiles]` → error;
48/// - a profile whose `command` is empty → error (degenerate config);
49/// - neither given → error (nothing to run).
50///
51/// Every error path is a user-facing [`GwmError`] (exit 1), never a panic.
52pub fn resolve_exec_command(profile: Option<&str>, inline: &[String], cfg: &ExecConfig) -> Result<Vec<String>> {
53 match (profile, inline.is_empty()) {
54 (Some(_), false) => Err(GwmError::Other(
55 "exec: --profile and an inline `-- <cmd>` are mutually exclusive — the profile carries the command".into(),
56 )),
57 (Some(name), true) => {
58 let p = cfg
59 .profiles
60 .get(name)
61 .ok_or_else(|| GwmError::Config(format!("exec: no profile named `{name}` in [exec.profiles]")))?;
62 validate_exec_profile_command(name, &p.command)?;
63 Ok(p.command.clone())
64 }
65 (None, false) => Ok(inline.to_vec()),
66 (None, true) => Err(GwmError::Other(
67 "exec: provide a command after `--` (e.g. `gwm exec -- cargo test`) or pass `--profile <name>`".into(),
68 )),
69 }
70}
71
72/// Validate a `[exec.profiles.<name>]` entry's `command`: it must be a
73/// non-empty argv array. Surfaced for the config validation path so
74/// `gwm config validate` / `gwm doctor` reject what `gwm exec --profile`
75/// would (issue #324 review).
76pub fn validate_exec_profile_command(profile: &str, command: &[String]) -> Result<()> {
77 if command.is_empty() {
78 return Err(GwmError::Config(format!(
79 "exec: profile `{profile}` has an empty `command` — give it an argv array like `command = [\"cargo\", \"test\"]`"
80 )));
81 }
82 Ok(())
83}
84
85/// Run `program args…` with the working directory set to `dir`.
86///
87/// The child inherits the parent's stdio so its output streams to the user
88/// live (sequential execution keeps the streams from interleaving). Only the
89/// resolved exit status is captured and returned — a spawn failure (missing
90/// binary, permission denied) maps to [`ExecStatus::SpawnError`] rather than
91/// aborting the whole fan-out.
92pub fn exec_in_dir(dir: &Path, program: &str, args: &[String]) -> ExecStatus {
93 let resolved = resolve_program(dir, program);
94 match Command::new(&resolved).args(args).current_dir(dir).status() {
95 Ok(status) => match status.code() {
96 Some(0) => ExecStatus::Ok,
97 Some(code) => ExecStatus::Failed(code),
98 None => ExecStatus::Signal,
99 },
100 Err(e) => ExecStatus::SpawnError(e.to_string()),
101 }
102}
103
104/// Resolve the effective parallelism for `gwm exec` (issue #324): the `--jobs`
105/// flag wins, then the selected profile's `jobs`, then the global `[exec]
106/// jobs`, else `1`. A resolved `0` (or absent) means sequential. Always
107/// returns a worker count `>= 1`.
108pub fn resolve_jobs(flag: Option<u32>, profile: Option<&str>, cfg: &ExecConfig) -> usize {
109 let n = flag
110 .or_else(|| profile.and_then(|p| cfg.profiles.get(p)).and_then(|p| p.jobs))
111 .or(cfg.jobs)
112 .unwrap_or(1);
113 n.max(1) as usize
114}
115
116/// Like [`exec_in_dir`], but CAPTURE stdout+stderr (stdout then stderr)
117/// instead of inheriting the parent's stdio. Used by [`run_in_dirs_parallel`]
118/// so concurrent worktrees don't interleave their output — each block is
119/// printed whole, in worktree order, after the fan-out completes.
120pub fn exec_capture_in_dir(dir: &Path, program: &str, args: &[String]) -> (ExecStatus, Vec<u8>) {
121 let resolved = resolve_program(dir, program);
122 match Command::new(&resolved).args(args).current_dir(dir).output() {
123 Ok(out) => {
124 let mut buf = out.stdout;
125 buf.extend_from_slice(&out.stderr);
126 let status = match out.status.code() {
127 Some(0) => ExecStatus::Ok,
128 Some(code) => ExecStatus::Failed(code),
129 None => ExecStatus::Signal,
130 };
131 (status, buf)
132 }
133 Err(e) => (ExecStatus::SpawnError(e.to_string()), Vec::new()),
134 }
135}
136
137/// Run `program args…` in each `(name, dir)` of `items` with up to `jobs`
138/// concurrent workers, capturing each one's output. Returns one
139/// `(ExecOutcome, captured_output)` per item **in input order** (not
140/// completion order), so the caller prints deterministic per-worktree blocks
141/// regardless of which finished first. `jobs` is clamped to `[1, items.len()]`.
142pub fn run_in_dirs_parallel(
143 jobs: usize,
144 items: &[(String, PathBuf)],
145 program: &str,
146 args: &[String],
147) -> Vec<CapturedRun> {
148 if items.is_empty() {
149 return Vec::new();
150 }
151 let workers = jobs.clamp(1, items.len());
152 let next = AtomicUsize::new(0);
153 let slots: Vec<Mutex<Option<CapturedRun>>> = (0..items.len()).map(|_| Mutex::new(None)).collect();
154 std::thread::scope(|s| {
155 for _ in 0..workers {
156 s.spawn(|| loop {
157 let i = next.fetch_add(1, Ordering::Relaxed);
158 if i >= items.len() {
159 break;
160 }
161 let (name, path) = &items[i];
162 let (status, output) = exec_capture_in_dir(path, program, args);
163 // `.lock()` never poisons: the worker body cannot panic (the spawn
164 // primitive returns `SpawnError` instead of unwinding).
165 *slots[i].lock().expect("exec worker mutex never poisoned") = Some((
166 ExecOutcome {
167 name: name.clone(),
168 status,
169 },
170 output,
171 ));
172 });
173 }
174 });
175 slots
176 .into_iter()
177 .map(|m| m.into_inner().expect("exec worker mutex never poisoned"))
178 .map(|slot| slot.expect("every worktree slot filled by a worker"))
179 .collect()
180}
181
182/// Resolve `program` for execution inside `dir`.
183///
184/// A relative program that contains a path separator (e.g. `./build.sh`,
185/// `scripts/run`) is a *path*, and the command's contract is "run in each
186/// worktree" — so it is joined onto `dir`. This pins the resolution to the
187/// target worktree regardless of whether the platform resolves a relative
188/// executable against the parent's or the child's cwd (the order differs
189/// across OSes for `std::process::Command` + `current_dir`). Bare names
190/// (no separator) stay `PATH` lookups, and absolute paths are left as-is.
191pub fn resolve_program(dir: &Path, program: &str) -> PathBuf {
192 let p = Path::new(program);
193 if p.is_relative() && has_path_separator(program) {
194 dir.join(p)
195 } else {
196 p.to_path_buf()
197 }
198}
199
200/// Whether `program` contains a path separator — `/` everywhere, plus `\` on
201/// Windows. Such a token is a path, not a `PATH`-resolved command name.
202fn has_path_separator(program: &str) -> bool {
203 program.contains('/') || (cfg!(windows) && program.contains('\\'))
204}
205
206/// Aggregate exit code for the whole fan-out: `0` only when every worktree
207/// succeeded, else `1`. Mirrors the repo's doctor/CI convention of a single
208/// non-zero "something failed" code rather than trying to reconcile multiple
209/// distinct child codes into one.
210pub fn rollup_exit_code(outcomes: &[ExecOutcome]) -> i32 {
211 if outcomes.iter().all(|o| o.status == ExecStatus::Ok) {
212 0
213 } else {
214 1
215 }
216}
217
218/// Render one rollup line for a worktree using the repo's ✓ / ✗ sigils,
219/// e.g. `✓ feat-1` or `✗ fix-2 (exit 2)`.
220pub fn format_outcome(o: &ExecOutcome) -> String {
221 match &o.status {
222 ExecStatus::Ok => format!("✓ {}", o.name),
223 ExecStatus::Failed(code) => format!("✗ {} (exit {})", o.name, code),
224 ExecStatus::Signal => format!("✗ {} (killed by signal)", o.name),
225 ExecStatus::SpawnError(msg) => format!("✗ {} (spawn error: {})", o.name, msg),
226 }
227}