Skip to main content

vcs_runner/
runner.rs

1use std::borrow::Cow;
2use std::io::Read;
3use std::path::Path;
4use std::process::{Command, Output, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use backon::{BlockingRetryable, ExponentialBuilder};
9use wait_timeout::ChildExt;
10
11use crate::error::RunError;
12
13/// Captured output from a successful command.
14///
15/// Stdout is stored as raw bytes to support binary content (e.g., image
16/// diffs via `jj file show` or `git show`). Use [`stdout_lossy()`](RunOutput::stdout_lossy)
17/// for the common case of text output.
18#[derive(Debug, Clone)]
19pub struct RunOutput {
20    pub stdout: Vec<u8>,
21    pub stderr: String,
22}
23
24impl RunOutput {
25    /// Decode stdout as UTF-8, replacing invalid sequences with `�`.
26    ///
27    /// Returns a `Cow` — zero-copy when the bytes are valid UTF-8,
28    /// which they almost always are for git/jj text output.
29    pub fn stdout_lossy(&self) -> Cow<'_, str> {
30        String::from_utf8_lossy(&self.stdout)
31    }
32}
33
34/// Run an arbitrary command with inherited stdout/stderr (visible to user).
35///
36/// Fails if the command exits non-zero. For captured output, use
37/// [`run_cmd`] or the VCS-specific [`run_jj`] / [`run_git`].
38///
39/// Returns [`RunError`] on failure. Because stdout/stderr are inherited,
40/// the `NonZeroExit` variant carries empty `stdout` and `stderr`.
41pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<(), RunError> {
42    let status = Command::new(program).args(args).status().map_err(|source| {
43        RunError::Spawn {
44            program: program.to_string(),
45            source,
46        }
47    })?;
48
49    if status.success() {
50        Ok(())
51    } else {
52        Err(RunError::NonZeroExit {
53            program: program.to_string(),
54            args: args.iter().map(|s| s.to_string()).collect(),
55            status,
56            stdout: Vec::new(),
57            stderr: String::new(),
58        })
59    }
60}
61
62/// Run an arbitrary command, capturing stdout and stderr.
63pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
64    let output = Command::new(program).args(args).output().map_err(|source| {
65        RunError::Spawn {
66            program: program.to_string(),
67            source,
68        }
69    })?;
70
71    check_output(program, args, output)
72}
73
74/// Run an arbitrary command in a specific directory, capturing output.
75///
76/// The `dir` parameter is first to match `run_jj(dir, args)` / `run_git(dir, args)`.
77pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
78    run_cmd_in_with_env(dir, program, args, &[])
79}
80
81/// Run a command in a specific directory with extra environment variables.
82///
83/// Each `(key, value)` pair is added to the child process environment.
84/// The parent's environment is inherited; these vars are added on top.
85///
86/// ```no_run
87/// # use std::path::Path;
88/// # use vcs_runner::run_cmd_in_with_env;
89/// let repo = Path::new("/repo");
90/// let output = run_cmd_in_with_env(
91///     repo, "git", &["add", "-N", "--", "file.rs"],
92///     &[("GIT_INDEX_FILE", "/tmp/index.tmp")],
93/// )?;
94/// # Ok::<(), vcs_runner::RunError>(())
95/// ```
96pub fn run_cmd_in_with_env(
97    dir: &Path,
98    program: &str,
99    args: &[&str],
100    env: &[(&str, &str)],
101) -> Result<RunOutput, RunError> {
102    let mut cmd = Command::new(program);
103    cmd.args(args).current_dir(dir);
104    for &(key, val) in env {
105        cmd.env(key, val);
106    }
107    let output = cmd.output().map_err(|source| RunError::Spawn {
108        program: program.to_string(),
109        source,
110    })?;
111
112    check_output(program, args, output)
113}
114
115/// Run a command in a directory, killing it if it exceeds `timeout`.
116///
117/// Uses background threads to drain stdout and stderr so a chatty process
118/// can't block on pipe buffer overflow. On timeout, the child is killed
119/// and any output collected before the kill is included in the error.
120///
121/// Returns [`RunError::Timeout`] if the process was killed.
122/// Returns [`RunError::NonZeroExit`] if it completed with a non-zero status.
123/// Returns [`RunError::Spawn`] if the process couldn't start.
124///
125/// # Caveat: grandchildren
126///
127/// Only the direct child process receives the kill signal. Grandchildren
128/// (spawned by the child) become orphans and continue running, and they
129/// may hold the stdout/stderr pipes open, delaying this function's return
130/// until they exit naturally. This is rare for direct invocations of
131/// `git`/`jj` but can matter for shell wrappers — use `exec` in the shell
132/// command (e.g., `sh -c "exec git fetch"`) to replace the shell with the
133/// target process and avoid the grandchild case.
134///
135/// ```no_run
136/// # use std::path::Path;
137/// # use std::time::Duration;
138/// # use vcs_runner::{run_cmd_in_with_timeout, RunError};
139/// let repo = Path::new("/repo");
140/// match run_cmd_in_with_timeout(repo, "git", &["fetch"], Duration::from_secs(30)) {
141///     Ok(_) => println!("fetched"),
142///     Err(RunError::Timeout { elapsed, .. }) => {
143///         eprintln!("fetch hung, killed after {elapsed:?}");
144///     }
145///     Err(e) => return Err(e.into()),
146/// }
147/// # Ok::<(), anyhow::Error>(())
148/// ```
149pub fn run_cmd_in_with_timeout(
150    dir: &Path,
151    program: &str,
152    args: &[&str],
153    timeout: Duration,
154) -> Result<RunOutput, RunError> {
155    let mut child = Command::new(program)
156        .args(args)
157        .current_dir(dir)
158        .stdout(Stdio::piped())
159        .stderr(Stdio::piped())
160        .spawn()
161        .map_err(|source| RunError::Spawn {
162            program: program.to_string(),
163            source,
164        })?;
165
166    // Drain stdio in background threads so the child can't block on pipe buffers.
167    let stdout = child.stdout.take().expect("stdout piped");
168    let stderr = child.stderr.take().expect("stderr piped");
169    let stdout_handle = thread::spawn(move || read_to_end(stdout));
170    let stderr_handle = thread::spawn(move || read_to_end(stderr));
171
172    let start = Instant::now();
173    let wait_result = child.wait_timeout(timeout);
174
175    // If the child is still alive (timeout or wait error), kill it BEFORE joining
176    // the stdio threads — otherwise those threads block forever on read.
177    let outcome = match wait_result {
178        Ok(Some(status)) => Outcome::Exited(status),
179        Ok(None) => {
180            let _ = child.kill();
181            let _ = child.wait();
182            Outcome::TimedOut(start.elapsed())
183        }
184        Err(source) => {
185            let _ = child.kill();
186            let _ = child.wait();
187            Outcome::WaitFailed(source)
188        }
189    };
190
191    // Safe to join now: the child is dead, pipes are closed, threads will return EOF.
192    let stdout_bytes = stdout_handle.join().unwrap_or_default();
193    let stderr_bytes = stderr_handle.join().unwrap_or_default();
194    let stderr_str = String::from_utf8_lossy(&stderr_bytes).into_owned();
195
196    match outcome {
197        Outcome::Exited(status) => {
198            if status.success() {
199                Ok(RunOutput {
200                    stdout: stdout_bytes,
201                    stderr: stderr_str,
202                })
203            } else {
204                Err(RunError::NonZeroExit {
205                    program: program.to_string(),
206                    args: args.iter().map(|s| s.to_string()).collect(),
207                    status,
208                    stdout: stdout_bytes,
209                    stderr: stderr_str,
210                })
211            }
212        }
213        Outcome::TimedOut(elapsed) => Err(RunError::Timeout {
214            program: program.to_string(),
215            args: args.iter().map(|s| s.to_string()).collect(),
216            elapsed,
217            stdout: stdout_bytes,
218            stderr: stderr_str,
219        }),
220        Outcome::WaitFailed(source) => Err(RunError::Spawn {
221            program: program.to_string(),
222            source,
223        }),
224    }
225}
226
227enum Outcome {
228    Exited(std::process::ExitStatus),
229    TimedOut(Duration),
230    WaitFailed(std::io::Error),
231}
232
233/// Run a `jj` command in a repo directory, returning captured output.
234pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
235    run_cmd_in(repo_path, "jj", args)
236}
237
238/// Run a `git` command in a repo directory, returning captured output.
239pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
240    run_cmd_in(repo_path, "git", args)
241}
242
243/// Run a `jj` command with a timeout.
244///
245/// Shorthand for `run_cmd_in_with_timeout(repo_path, "jj", args, timeout)`.
246pub fn run_jj_with_timeout(
247    repo_path: &Path,
248    args: &[&str],
249    timeout: Duration,
250) -> Result<RunOutput, RunError> {
251    run_cmd_in_with_timeout(repo_path, "jj", args, timeout)
252}
253
254/// Run a `git` command with a timeout.
255///
256/// Shorthand for `run_cmd_in_with_timeout(repo_path, "git", args, timeout)`.
257pub fn run_git_with_timeout(
258    repo_path: &Path,
259    args: &[&str],
260    timeout: Duration,
261) -> Result<RunOutput, RunError> {
262    run_cmd_in_with_timeout(repo_path, "git", args, timeout)
263}
264
265/// Run a command in a directory with retry on transient errors.
266///
267/// Uses exponential backoff (100ms, 200ms, 400ms) with up to 3 retries.
268/// The `is_transient` callback receives a [`RunError`] and returns whether to retry.
269pub fn run_with_retry(
270    repo_path: &Path,
271    program: &str,
272    args: &[&str],
273    is_transient: impl Fn(&RunError) -> bool,
274) -> Result<RunOutput, RunError> {
275    let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
276
277    let op = || {
278        let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
279        run_cmd_in(repo_path, program, &str_args)
280    };
281
282    op.retry(
283        ExponentialBuilder::default()
284            .with_factor(2.0)
285            .with_min_delay(Duration::from_millis(100))
286            .with_max_times(3),
287    )
288    .when(is_transient)
289    .call()
290}
291
292/// Run a `jj` command with retry on transient errors.
293pub fn run_jj_with_retry(
294    repo_path: &Path,
295    args: &[&str],
296    is_transient: impl Fn(&RunError) -> bool,
297) -> Result<RunOutput, RunError> {
298    run_with_retry(repo_path, "jj", args, is_transient)
299}
300
301/// Run a `git` command with retry on transient errors.
302pub fn run_git_with_retry(
303    repo_path: &Path,
304    args: &[&str],
305    is_transient: impl Fn(&RunError) -> bool,
306) -> Result<RunOutput, RunError> {
307    run_with_retry(repo_path, "git", args, is_transient)
308}
309
310/// Default transient error check for jj/git.
311///
312/// Retries on:
313/// - `NonZeroExit` with `"stale"` in stderr — "The working copy is stale" (jj)
314/// - `NonZeroExit` with `".lock"` in stderr — Lock file contention (git/jj)
315///
316/// Spawn failures and timeouts are never treated as transient.
317pub fn is_transient_error(err: &RunError) -> bool {
318    match err {
319        RunError::NonZeroExit { stderr, .. } => {
320            stderr.contains("stale") || stderr.contains(".lock")
321        }
322        RunError::Spawn { .. } | RunError::Timeout { .. } => false,
323    }
324}
325
326/// Check whether a binary is available on PATH.
327pub fn binary_available(name: &str) -> bool {
328    Command::new(name)
329        .arg("--version")
330        .stdout(Stdio::null())
331        .stderr(Stdio::null())
332        .status()
333        .is_ok_and(|s| s.success())
334}
335
336/// Get a binary's version string, if available.
337pub fn binary_version(name: &str) -> Option<String> {
338    let output = Command::new(name).arg("--version").output().ok()?;
339    if !output.status.success() {
340        return None;
341    }
342    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
343}
344
345fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
346    if output.status.success() {
347        Ok(RunOutput {
348            stdout: output.stdout,
349            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
350        })
351    } else {
352        Err(RunError::NonZeroExit {
353            program: program.to_string(),
354            args: args.iter().map(|s| s.to_string()).collect(),
355            status: output.status,
356            stdout: output.stdout,
357            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
358        })
359    }
360}
361
362fn read_to_end<R: Read>(mut reader: R) -> Vec<u8> {
363    let mut buf = Vec::new();
364    let _ = reader.read_to_end(&mut buf);
365    buf
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // --- is_transient_error ---
373
374    fn fake_non_zero(stderr: &str) -> RunError {
375        let status = Command::new("false").status().expect("false");
376        RunError::NonZeroExit {
377            program: "jj".into(),
378            args: vec!["status".into()],
379            status,
380            stdout: Vec::new(),
381            stderr: stderr.to_string(),
382        }
383    }
384
385    fn fake_spawn() -> RunError {
386        RunError::Spawn {
387            program: "jj".into(),
388            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
389        }
390    }
391
392    fn fake_timeout() -> RunError {
393        RunError::Timeout {
394            program: "git".into(),
395            args: vec!["fetch".into()],
396            elapsed: Duration::from_secs(30),
397            stdout: Vec::new(),
398            stderr: String::new(),
399        }
400    }
401
402    #[test]
403    fn transient_detects_stale() {
404        assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
405    }
406
407    #[test]
408    fn transient_detects_lock() {
409        assert!(is_transient_error(&fake_non_zero(
410            "Unable to create .lock: File exists"
411        )));
412    }
413
414    #[test]
415    fn transient_rejects_config_error() {
416        assert!(!is_transient_error(&fake_non_zero(
417            "Config error: no such revision"
418        )));
419    }
420
421    #[test]
422    fn transient_never_retries_spawn_failure() {
423        assert!(!is_transient_error(&fake_spawn()));
424    }
425
426    #[test]
427    fn transient_never_retries_timeout() {
428        assert!(!is_transient_error(&fake_timeout()));
429    }
430
431    // --- run_cmd_inherited ---
432
433    #[test]
434    fn cmd_inherited_succeeds() {
435        run_cmd_inherited("true", &[]).expect("true should succeed");
436    }
437
438    #[test]
439    fn cmd_inherited_fails_on_nonzero() {
440        let err = run_cmd_inherited("false", &[]).expect_err("should fail");
441        assert!(err.is_non_zero_exit());
442        assert_eq!(err.program(), "false");
443    }
444
445    #[test]
446    fn cmd_inherited_fails_on_missing_binary() {
447        let err = run_cmd_inherited("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
448        assert!(err.is_spawn_failure());
449    }
450
451    // --- run_cmd ---
452
453    #[test]
454    fn cmd_captured_succeeds() {
455        let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
456        assert_eq!(output.stdout_lossy().trim(), "hello");
457    }
458
459    #[test]
460    fn cmd_captured_fails_on_nonzero() {
461        let err = run_cmd("false", &[]).expect_err("should fail");
462        assert!(err.is_non_zero_exit());
463        assert!(err.exit_status().is_some());
464    }
465
466    #[test]
467    fn cmd_captured_captures_stderr_on_failure() {
468        let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
469        assert_eq!(err.stderr(), Some("err\n"));
470    }
471
472    #[test]
473    fn cmd_captured_captures_stdout_on_failure() {
474        let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
475        match &err {
476            RunError::NonZeroExit { stdout, .. } => {
477                assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
478            }
479            _ => panic!("expected NonZeroExit"),
480        }
481    }
482
483    #[test]
484    fn cmd_fails_on_missing_binary() {
485        let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
486        assert!(err.is_spawn_failure());
487    }
488
489    // --- run_cmd_in ---
490
491    #[test]
492    fn cmd_in_runs_in_directory() {
493        let tmp = tempfile::tempdir().expect("tempdir");
494        let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
495        let pwd = output.stdout_lossy().trim().to_string();
496        let expected = tmp.path().canonicalize().expect("canonicalize");
497        let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
498        assert_eq!(actual, expected);
499    }
500
501    #[test]
502    fn cmd_in_fails_on_nonzero() {
503        let tmp = tempfile::tempdir().expect("tempdir");
504        let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
505        assert!(err.is_non_zero_exit());
506    }
507
508    #[test]
509    fn cmd_in_fails_on_nonexistent_dir() {
510        let err = run_cmd_in(
511            std::path::Path::new("/nonexistent_dir_xyz_42"),
512            "echo",
513            &["hi"],
514        )
515        .expect_err("should fail");
516        assert!(err.is_spawn_failure());
517    }
518
519    // --- run_cmd_in_with_env ---
520
521    #[test]
522    fn cmd_in_with_env_sets_variable() {
523        let tmp = tempfile::tempdir().expect("tempdir");
524        let output = run_cmd_in_with_env(
525            tmp.path(),
526            "sh",
527            &["-c", "echo $TEST_VAR_XYZ"],
528            &[("TEST_VAR_XYZ", "hello_from_env")],
529        )
530        .expect("should succeed");
531        assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
532    }
533
534    #[test]
535    fn cmd_in_with_env_multiple_vars() {
536        let tmp = tempfile::tempdir().expect("tempdir");
537        let output = run_cmd_in_with_env(
538            tmp.path(),
539            "sh",
540            &["-c", "echo ${A}_${B}"],
541            &[("A", "foo"), ("B", "bar")],
542        )
543        .expect("should succeed");
544        assert_eq!(output.stdout_lossy().trim(), "foo_bar");
545    }
546
547    #[test]
548    fn cmd_in_with_env_overrides_existing_var() {
549        let tmp = tempfile::tempdir().expect("tempdir");
550        let output = run_cmd_in_with_env(
551            tmp.path(),
552            "sh",
553            &["-c", "echo $HOME"],
554            &[("HOME", "/fake/home")],
555        )
556        .expect("should succeed");
557        assert_eq!(output.stdout_lossy().trim(), "/fake/home");
558    }
559
560    #[test]
561    fn cmd_in_with_env_fails_on_nonzero() {
562        let tmp = tempfile::tempdir().expect("tempdir");
563        let err = run_cmd_in_with_env(
564            tmp.path(),
565            "sh",
566            &["-c", "exit 1"],
567            &[("IRRELEVANT", "value")],
568        )
569        .expect_err("should fail");
570        assert!(err.is_non_zero_exit());
571    }
572
573    // --- run_cmd_in_with_timeout ---
574
575    #[test]
576    fn timeout_succeeds_for_fast_command() {
577        let tmp = tempfile::tempdir().expect("tempdir");
578        let output =
579            run_cmd_in_with_timeout(tmp.path(), "echo", &["hello"], Duration::from_secs(5))
580                .expect("should succeed");
581        assert_eq!(output.stdout_lossy().trim(), "hello");
582    }
583
584    #[test]
585    fn timeout_fires_for_slow_command() {
586        let tmp = tempfile::tempdir().expect("tempdir");
587        let wall_start = Instant::now();
588        let err = run_cmd_in_with_timeout(
589            tmp.path(),
590            "sleep",
591            &["10"],
592            Duration::from_millis(200),
593        )
594        .expect_err("should time out");
595        let wall_elapsed = wall_start.elapsed();
596
597        assert!(err.is_timeout());
598        // The sleep was for 10 seconds; we killed it. Total wall time must be far less.
599        assert!(
600            wall_elapsed < Duration::from_secs(5),
601            "expected quick kill, took {wall_elapsed:?}"
602        );
603    }
604
605    #[test]
606    fn timeout_captures_partial_stderr_before_kill() {
607        let tmp = tempfile::tempdir().expect("tempdir");
608        // `exec sleep 10` replaces the shell with sleep directly, so killing our
609        // direct child (sleep) closes its stderr pipe. Without exec, the shell
610        // would fork sleep as a grandchild that survives the kill and keeps the
611        // pipe open until its natural completion.
612        let err = run_cmd_in_with_timeout(
613            tmp.path(),
614            "sh",
615            &["-c", "echo partial >&2; exec sleep 10"],
616            Duration::from_millis(500),
617        )
618        .expect_err("should time out");
619        assert!(err.is_timeout());
620        let stderr = err.stderr().unwrap_or("");
621        assert!(
622            stderr.contains("partial"),
623            "expected partial stderr, got: {stderr:?}"
624        );
625    }
626
627    #[test]
628    fn timeout_reports_non_zero_exit_when_process_completes() {
629        let tmp = tempfile::tempdir().expect("tempdir");
630        let err = run_cmd_in_with_timeout(
631            tmp.path(),
632            "false",
633            &[],
634            Duration::from_secs(5),
635        )
636        .expect_err("should fail");
637        assert!(err.is_non_zero_exit());
638    }
639
640    #[test]
641    fn timeout_fails_on_missing_binary() {
642        let tmp = tempfile::tempdir().expect("tempdir");
643        let err = run_cmd_in_with_timeout(
644            tmp.path(),
645            "nonexistent_binary_xyz_42",
646            &[],
647            Duration::from_secs(5),
648        )
649        .expect_err("should fail");
650        assert!(err.is_spawn_failure());
651    }
652
653    #[test]
654    fn timeout_does_not_block_on_large_output() {
655        // Without background thread draining, a command that writes more than
656        // the pipe buffer (usually 64KB) would block waiting for us to read.
657        // The timeout would fire but the process would be hung on write.
658        let tmp = tempfile::tempdir().expect("tempdir");
659        let output = run_cmd_in_with_timeout(
660            tmp.path(),
661            "sh",
662            &["-c", "yes | head -c 200000"],
663            Duration::from_secs(5),
664        )
665        .expect("should succeed");
666        assert!(output.stdout.len() >= 200_000);
667    }
668
669    // --- RunOutput ---
670
671    #[test]
672    fn stdout_lossy_valid_utf8() {
673        let output = RunOutput {
674            stdout: b"hello world".to_vec(),
675            stderr: String::new(),
676        };
677        assert_eq!(output.stdout_lossy(), "hello world");
678    }
679
680    #[test]
681    fn stdout_lossy_invalid_utf8() {
682        let output = RunOutput {
683            stdout: vec![0xff, 0xfe, b'a', b'b'],
684            stderr: String::new(),
685        };
686        let s = output.stdout_lossy();
687        assert!(s.contains("ab"));
688        assert!(s.contains('�'));
689    }
690
691    #[test]
692    fn stdout_raw_bytes_preserved() {
693        let bytes: Vec<u8> = (0..=255).collect();
694        let output = RunOutput {
695            stdout: bytes.clone(),
696            stderr: String::new(),
697        };
698        assert_eq!(output.stdout, bytes);
699    }
700
701    #[test]
702    fn run_output_debug_impl() {
703        let output = RunOutput {
704            stdout: b"hello".to_vec(),
705            stderr: "warn".to_string(),
706        };
707        let debug = format!("{output:?}");
708        assert!(debug.contains("warn"));
709        assert!(debug.contains("stdout"));
710    }
711
712    // --- binary_available / binary_version ---
713
714    #[test]
715    fn binary_available_true_returns_true() {
716        assert!(binary_available("echo"));
717    }
718
719    #[test]
720    fn binary_available_missing_returns_false() {
721        assert!(!binary_available("nonexistent_binary_xyz_42"));
722    }
723
724    #[test]
725    fn binary_version_missing_returns_none() {
726        assert!(binary_version("nonexistent_binary_xyz_42").is_none());
727    }
728
729    // --- run_jj / run_git (only if binary available) ---
730
731    #[test]
732    fn run_jj_version_succeeds() {
733        if !binary_available("jj") {
734            return;
735        }
736        let tmp = tempfile::tempdir().expect("tempdir");
737        let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
738        assert!(output.stdout_lossy().contains("jj"));
739    }
740
741    #[test]
742    fn run_jj_fails_in_non_repo() {
743        if !binary_available("jj") {
744            return;
745        }
746        let tmp = tempfile::tempdir().expect("tempdir");
747        let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
748        assert!(err.is_non_zero_exit());
749    }
750
751    #[test]
752    fn run_git_version_succeeds() {
753        if !binary_available("git") {
754            return;
755        }
756        let tmp = tempfile::tempdir().expect("tempdir");
757        let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
758        assert!(output.stdout_lossy().contains("git"));
759    }
760
761    #[test]
762    fn run_git_fails_in_non_repo() {
763        if !binary_available("git") {
764            return;
765        }
766        let tmp = tempfile::tempdir().expect("tempdir");
767        let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
768        assert!(err.is_non_zero_exit());
769    }
770
771    #[test]
772    fn run_jj_with_timeout_succeeds() {
773        if !binary_available("jj") {
774            return;
775        }
776        let tmp = tempfile::tempdir().expect("tempdir");
777        let output =
778            run_jj_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
779                .expect("jj --version should work");
780        assert!(output.stdout_lossy().contains("jj"));
781    }
782
783    #[test]
784    fn run_git_with_timeout_succeeds() {
785        if !binary_available("git") {
786            return;
787        }
788        let tmp = tempfile::tempdir().expect("tempdir");
789        let output =
790            run_git_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
791                .expect("git --version should work");
792        assert!(output.stdout_lossy().contains("git"));
793    }
794
795    // --- check_output ---
796
797    #[test]
798    fn check_output_preserves_stderr_on_success() {
799        let output =
800            run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
801        assert_eq!(output.stdout_lossy().trim(), "ok");
802        assert_eq!(output.stderr.trim(), "warn");
803    }
804
805    // --- retry ---
806
807    #[test]
808    fn retry_accepts_closure_over_run_error() {
809        let captured = "special".to_string();
810        let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
811
812        assert!(!checker(&fake_non_zero("other")));
813        assert!(checker(&fake_non_zero("this has special text")));
814        assert!(!checker(&fake_spawn()));
815    }
816}