Skip to main content

vcs_runner/
runner.rs

1use std::borrow::Cow;
2use std::path::Path;
3use std::process::{Command, Output, Stdio};
4
5use backon::{BlockingRetryable, ExponentialBuilder};
6
7use crate::error::RunError;
8
9/// Captured output from a successful command.
10///
11/// Stdout is stored as raw bytes to support binary content (e.g., image
12/// diffs via `jj file show` or `git show`). Use [`stdout_lossy()`](RunOutput::stdout_lossy)
13/// for the common case of text output.
14#[derive(Debug, Clone)]
15pub struct RunOutput {
16    pub stdout: Vec<u8>,
17    pub stderr: String,
18}
19
20impl RunOutput {
21    /// Decode stdout as UTF-8, replacing invalid sequences with `�`.
22    ///
23    /// Returns a `Cow` — zero-copy when the bytes are valid UTF-8,
24    /// which they almost always are for git/jj text output.
25    pub fn stdout_lossy(&self) -> Cow<'_, str> {
26        String::from_utf8_lossy(&self.stdout)
27    }
28}
29
30/// Run an arbitrary command with inherited stdout/stderr (visible to user).
31///
32/// Fails if the command exits non-zero. For captured output, use
33/// [`run_cmd`] or the VCS-specific [`run_jj`] / [`run_git`].
34///
35/// Returns [`RunError`] on failure. Because stdout/stderr are inherited,
36/// the `NonZeroExit` variant carries empty `stdout` and `stderr`.
37pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<(), RunError> {
38    let status = Command::new(program).args(args).status().map_err(|source| {
39        RunError::Spawn {
40            program: program.to_string(),
41            source,
42        }
43    })?;
44
45    if status.success() {
46        Ok(())
47    } else {
48        Err(RunError::NonZeroExit {
49            program: program.to_string(),
50            args: args.iter().map(|s| s.to_string()).collect(),
51            status,
52            stdout: Vec::new(),
53            stderr: String::new(),
54        })
55    }
56}
57
58/// Run an arbitrary command, capturing stdout and stderr.
59///
60/// Fails with [`RunError::NonZeroExit`] (carrying captured output) on non-zero exit,
61/// or [`RunError::Spawn`] if the process couldn't start.
62pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
63    let output = Command::new(program).args(args).output().map_err(|source| {
64        RunError::Spawn {
65            program: program.to_string(),
66            source,
67        }
68    })?;
69
70    check_output(program, args, output)
71}
72
73/// Run an arbitrary command in a specific directory, capturing output.
74///
75/// The `dir` parameter is first to match `run_jj(dir, args)` / `run_git(dir, args)`.
76pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
77    run_cmd_in_with_env(dir, program, args, &[])
78}
79
80/// Run a command in a specific directory with extra environment variables.
81///
82/// Each `(key, value)` pair is added to the child process environment.
83/// The parent's environment is inherited; these vars are added on top.
84///
85/// ```no_run
86/// # use std::path::Path;
87/// # use vcs_runner::run_cmd_in_with_env;
88/// let repo = Path::new("/repo");
89/// let output = run_cmd_in_with_env(
90///     repo, "git", &["add", "-N", "--", "file.rs"],
91///     &[("GIT_INDEX_FILE", "/tmp/index.tmp")],
92/// )?;
93/// # Ok::<(), vcs_runner::RunError>(())
94/// ```
95pub fn run_cmd_in_with_env(
96    dir: &Path,
97    program: &str,
98    args: &[&str],
99    env: &[(&str, &str)],
100) -> Result<RunOutput, RunError> {
101    let mut cmd = Command::new(program);
102    cmd.args(args).current_dir(dir);
103    for &(key, val) in env {
104        cmd.env(key, val);
105    }
106    let output = cmd.output().map_err(|source| RunError::Spawn {
107        program: program.to_string(),
108        source,
109    })?;
110
111    check_output(program, args, output)
112}
113
114/// Run a `jj` command in a repo directory, returning captured output.
115pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
116    run_cmd_in(repo_path, "jj", args)
117}
118
119/// Run a `git` command in a repo directory, returning captured output.
120pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
121    run_cmd_in(repo_path, "git", args)
122}
123
124/// Run a command in a directory with retry on transient errors.
125///
126/// Uses exponential backoff (100ms, 200ms, 400ms) with up to 3 retries.
127/// The `is_transient` callback receives a [`RunError`] and returns whether to retry.
128///
129/// For convenience, [`run_jj_with_retry`] and [`run_git_with_retry`]
130/// pre-fill the program name.
131pub fn run_with_retry(
132    repo_path: &Path,
133    program: &str,
134    args: &[&str],
135    is_transient: impl Fn(&RunError) -> bool,
136) -> Result<RunOutput, RunError> {
137    let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
138
139    let op = || {
140        let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
141        run_cmd_in(repo_path, program, &str_args)
142    };
143
144    op.retry(
145        ExponentialBuilder::default()
146            .with_factor(2.0)
147            .with_min_delay(std::time::Duration::from_millis(100))
148            .with_max_times(3),
149    )
150    .when(is_transient)
151    .call()
152}
153
154/// Run a `jj` command with retry on transient errors.
155///
156/// Shorthand for `run_with_retry(repo_path, "jj", args, is_transient)`.
157pub fn run_jj_with_retry(
158    repo_path: &Path,
159    args: &[&str],
160    is_transient: impl Fn(&RunError) -> bool,
161) -> Result<RunOutput, RunError> {
162    run_with_retry(repo_path, "jj", args, is_transient)
163}
164
165/// Run a `git` command with retry on transient errors.
166///
167/// Shorthand for `run_with_retry(repo_path, "git", args, is_transient)`.
168pub fn run_git_with_retry(
169    repo_path: &Path,
170    args: &[&str],
171    is_transient: impl Fn(&RunError) -> bool,
172) -> Result<RunOutput, RunError> {
173    run_with_retry(repo_path, "git", args, is_transient)
174}
175
176/// Default transient error check for jj/git.
177///
178/// Retries on:
179/// - `NonZeroExit` with `"stale"` in stderr — "The working copy is stale" (jj)
180/// - `NonZeroExit` with `".lock"` in stderr — Lock file contention (git/jj)
181///
182/// Spawn failures are never treated as transient.
183pub fn is_transient_error(err: &RunError) -> bool {
184    match err {
185        RunError::NonZeroExit { stderr, .. } => {
186            stderr.contains("stale") || stderr.contains(".lock")
187        }
188        RunError::Spawn { .. } => false,
189    }
190}
191
192/// Check whether a binary is available on PATH.
193pub fn binary_available(name: &str) -> bool {
194    Command::new(name)
195        .arg("--version")
196        .stdout(Stdio::null())
197        .stderr(Stdio::null())
198        .status()
199        .is_ok_and(|s| s.success())
200}
201
202/// Get a binary's version string, if available.
203pub fn binary_version(name: &str) -> Option<String> {
204    let output = Command::new(name).arg("--version").output().ok()?;
205    if !output.status.success() {
206        return None;
207    }
208    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
209}
210
211fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
212    if output.status.success() {
213        Ok(RunOutput {
214            stdout: output.stdout,
215            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
216        })
217    } else {
218        Err(RunError::NonZeroExit {
219            program: program.to_string(),
220            args: args.iter().map(|s| s.to_string()).collect(),
221            status: output.status,
222            stdout: output.stdout,
223            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
224        })
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    // --- is_transient_error ---
233
234    fn fake_non_zero(stderr: &str) -> RunError {
235        let status = Command::new("false").status().expect("false");
236        RunError::NonZeroExit {
237            program: "jj".into(),
238            args: vec!["status".into()],
239            status,
240            stdout: Vec::new(),
241            stderr: stderr.to_string(),
242        }
243    }
244
245    fn fake_spawn() -> RunError {
246        RunError::Spawn {
247            program: "jj".into(),
248            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
249        }
250    }
251
252    #[test]
253    fn transient_detects_stale() {
254        assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
255    }
256
257    #[test]
258    fn transient_detects_stale_in_context() {
259        assert!(is_transient_error(&fake_non_zero(
260            "Error: The working copy is stale (not updated since op abc)"
261        )));
262    }
263
264    #[test]
265    fn transient_detects_lock() {
266        assert!(is_transient_error(&fake_non_zero(
267            "Unable to create .lock: File exists"
268        )));
269    }
270
271    #[test]
272    fn transient_detects_git_index_lock() {
273        assert!(is_transient_error(&fake_non_zero(
274            "fatal: Unable to create '/repo/.git/index.lock'"
275        )));
276    }
277
278    #[test]
279    fn transient_rejects_config_error() {
280        assert!(!is_transient_error(&fake_non_zero(
281            "Config error: no such revision"
282        )));
283    }
284
285    #[test]
286    fn transient_rejects_empty() {
287        assert!(!is_transient_error(&fake_non_zero("")));
288    }
289
290    #[test]
291    fn transient_never_retries_spawn_failure() {
292        assert!(!is_transient_error(&fake_spawn()));
293    }
294
295    // --- run_cmd_inherited ---
296
297    #[test]
298    fn cmd_inherited_succeeds() {
299        run_cmd_inherited("true", &[]).expect("true should succeed");
300    }
301
302    #[test]
303    fn cmd_inherited_fails_on_nonzero() {
304        let result = run_cmd_inherited("false", &[]);
305        let err = result.expect_err("should fail");
306        assert!(err.is_non_zero_exit());
307        assert_eq!(err.program(), "false");
308    }
309
310    #[test]
311    fn cmd_inherited_fails_on_missing_binary() {
312        let result = run_cmd_inherited("nonexistent_binary_xyz_42", &[]);
313        let err = result.expect_err("should fail");
314        assert!(err.is_spawn_failure());
315    }
316
317    // --- run_cmd ---
318
319    #[test]
320    fn cmd_captured_succeeds() {
321        let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
322        assert_eq!(output.stdout_lossy().trim(), "hello");
323    }
324
325    #[test]
326    fn cmd_captured_fails_on_nonzero() {
327        let err = run_cmd("false", &[]).expect_err("should fail");
328        assert!(err.is_non_zero_exit());
329        assert!(err.exit_status().is_some());
330    }
331
332    #[test]
333    fn cmd_captured_captures_stderr_on_failure() {
334        let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
335        assert_eq!(err.stderr(), Some("err\n"));
336    }
337
338    #[test]
339    fn cmd_captured_captures_stdout_on_failure() {
340        let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
341        match &err {
342            RunError::NonZeroExit { stdout, .. } => {
343                assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
344            }
345            _ => panic!("expected NonZeroExit"),
346        }
347    }
348
349    #[test]
350    fn cmd_fails_on_missing_binary() {
351        let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
352        assert!(err.is_spawn_failure());
353    }
354
355    // --- run_cmd_in ---
356
357    #[test]
358    fn cmd_in_runs_in_directory() {
359        let tmp = tempfile::tempdir().expect("tempdir");
360        let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
361        let pwd = output.stdout_lossy().trim().to_string();
362        let expected = tmp.path().canonicalize().expect("canonicalize");
363        let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
364        assert_eq!(actual, expected);
365    }
366
367    #[test]
368    fn cmd_in_fails_on_nonzero() {
369        let tmp = tempfile::tempdir().expect("tempdir");
370        let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
371        assert!(err.is_non_zero_exit());
372    }
373
374    #[test]
375    fn cmd_in_fails_on_nonexistent_dir() {
376        let err = run_cmd_in(
377            std::path::Path::new("/nonexistent_dir_xyz_42"),
378            "echo",
379            &["hi"],
380        )
381        .expect_err("should fail");
382        assert!(err.is_spawn_failure());
383    }
384
385    // --- run_cmd_in_with_env ---
386
387    #[test]
388    fn cmd_in_with_env_sets_variable() {
389        let tmp = tempfile::tempdir().expect("tempdir");
390        let output = run_cmd_in_with_env(
391            tmp.path(),
392            "sh",
393            &["-c", "echo $TEST_VAR_XYZ"],
394            &[("TEST_VAR_XYZ", "hello_from_env")],
395        )
396        .expect("should succeed");
397        assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
398    }
399
400    #[test]
401    fn cmd_in_with_env_multiple_vars() {
402        let tmp = tempfile::tempdir().expect("tempdir");
403        let output = run_cmd_in_with_env(
404            tmp.path(),
405            "sh",
406            &["-c", "echo ${A}_${B}"],
407            &[("A", "foo"), ("B", "bar")],
408        )
409        .expect("should succeed");
410        assert_eq!(output.stdout_lossy().trim(), "foo_bar");
411    }
412
413    #[test]
414    fn cmd_in_with_env_empty_env_same_as_cmd_in() {
415        let tmp = tempfile::tempdir().expect("tempdir");
416        let output =
417            run_cmd_in_with_env(tmp.path(), "pwd", &[], &[]).expect("should succeed");
418        let pwd = output.stdout_lossy().trim().to_string();
419        let expected = tmp.path().canonicalize().expect("canonicalize");
420        let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
421        assert_eq!(actual, expected);
422    }
423
424    #[test]
425    fn cmd_in_with_env_overrides_existing_var() {
426        let tmp = tempfile::tempdir().expect("tempdir");
427        let output = run_cmd_in_with_env(
428            tmp.path(),
429            "sh",
430            &["-c", "echo $HOME"],
431            &[("HOME", "/fake/home")],
432        )
433        .expect("should succeed");
434        assert_eq!(output.stdout_lossy().trim(), "/fake/home");
435    }
436
437    #[test]
438    fn cmd_in_with_env_fails_on_nonzero() {
439        let tmp = tempfile::tempdir().expect("tempdir");
440        let err = run_cmd_in_with_env(
441            tmp.path(),
442            "sh",
443            &["-c", "exit 1"],
444            &[("IRRELEVANT", "value")],
445        )
446        .expect_err("should fail");
447        assert!(err.is_non_zero_exit());
448    }
449
450    // --- RunOutput ---
451
452    #[test]
453    fn stdout_lossy_valid_utf8() {
454        let output = RunOutput {
455            stdout: b"hello world".to_vec(),
456            stderr: String::new(),
457        };
458        assert_eq!(output.stdout_lossy(), "hello world");
459    }
460
461    #[test]
462    fn stdout_lossy_invalid_utf8() {
463        let output = RunOutput {
464            stdout: vec![0xff, 0xfe, b'a', b'b'],
465            stderr: String::new(),
466        };
467        let s = output.stdout_lossy();
468        assert!(s.contains("ab"));
469        assert!(s.contains('�'));
470    }
471
472    #[test]
473    fn stdout_raw_bytes_preserved() {
474        let bytes: Vec<u8> = (0..=255).collect();
475        let output = RunOutput {
476            stdout: bytes.clone(),
477            stderr: String::new(),
478        };
479        assert_eq!(output.stdout, bytes);
480    }
481
482    #[test]
483    fn run_output_debug_impl() {
484        let output = RunOutput {
485            stdout: b"hello".to_vec(),
486            stderr: "warn".to_string(),
487        };
488        let debug = format!("{output:?}");
489        assert!(debug.contains("warn"));
490        assert!(debug.contains("stdout"));
491    }
492
493    // --- binary_available / binary_version ---
494
495    #[test]
496    fn binary_available_true_returns_true() {
497        assert!(binary_available("echo"));
498    }
499
500    #[test]
501    fn binary_available_missing_returns_false() {
502        assert!(!binary_available("nonexistent_binary_xyz_42"));
503    }
504
505    #[test]
506    fn binary_version_missing_returns_none() {
507        assert!(binary_version("nonexistent_binary_xyz_42").is_none());
508    }
509
510    // --- run_jj / run_git (only if binary available) ---
511
512    #[test]
513    fn run_jj_version_succeeds() {
514        if !binary_available("jj") {
515            return;
516        }
517        let tmp = tempfile::tempdir().expect("tempdir");
518        let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
519        assert!(output.stdout_lossy().contains("jj"));
520    }
521
522    #[test]
523    fn run_jj_fails_in_non_repo() {
524        if !binary_available("jj") {
525            return;
526        }
527        let tmp = tempfile::tempdir().expect("tempdir");
528        let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
529        assert!(err.is_non_zero_exit());
530    }
531
532    #[test]
533    fn run_git_version_succeeds() {
534        if !binary_available("git") {
535            return;
536        }
537        let tmp = tempfile::tempdir().expect("tempdir");
538        let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
539        assert!(output.stdout_lossy().contains("git"));
540    }
541
542    #[test]
543    fn run_git_fails_in_non_repo() {
544        if !binary_available("git") {
545            return;
546        }
547        let tmp = tempfile::tempdir().expect("tempdir");
548        let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
549        assert!(err.is_non_zero_exit());
550    }
551
552    // --- check_output ---
553
554    #[test]
555    fn check_output_preserves_stderr_on_success() {
556        let output =
557            run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
558        assert_eq!(output.stdout_lossy().trim(), "ok");
559        assert_eq!(output.stderr.trim(), "warn");
560    }
561
562    // --- retry ---
563
564    #[test]
565    fn retry_accepts_closure_over_run_error() {
566        let captured = "special".to_string();
567        let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
568
569        assert!(!checker(&fake_non_zero("other")));
570        assert!(checker(&fake_non_zero("this has special text")));
571        assert!(!checker(&fake_spawn()));
572    }
573}