Skip to main content

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}