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/// Find the merge base of two revisions in a jj repository.
311///
312/// Uses the revset `latest(::(a) & ::(b))` — the most recent common ancestor
313/// of the two revisions. Returns `Ok(None)` when the revisions have no common
314/// ancestor (unrelated histories).
315///
316/// ```no_run
317/// # use std::path::Path;
318/// # use vcs_runner::jj_merge_base;
319/// let repo = Path::new("/repo");
320/// let base = jj_merge_base(repo, "trunk()", "@")?;
321/// # Ok::<(), vcs_runner::RunError>(())
322/// ```
323pub fn jj_merge_base(
324    repo_path: &Path,
325    a: &str,
326    b: &str,
327) -> Result<Option<String>, RunError> {
328    let revset = format!("latest(::({a}) & ::({b}))");
329    let output = run_jj(
330        repo_path,
331        &[
332            "log", "-r", &revset, "--no-graph", "--limit", "1", "-T", "commit_id",
333        ],
334    )?;
335    let id = output.stdout_lossy().trim().to_string();
336    Ok(if id.is_empty() { None } else { Some(id) })
337}
338
339/// Find the merge base of two revisions in a git repository.
340///
341/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
342/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
343///
344/// ```no_run
345/// # use std::path::Path;
346/// # use vcs_runner::git_merge_base;
347/// let repo = Path::new("/repo");
348/// let base = git_merge_base(repo, "origin/main", "HEAD")?;
349/// # Ok::<(), vcs_runner::RunError>(())
350/// ```
351pub fn git_merge_base(
352    repo_path: &Path,
353    a: &str,
354    b: &str,
355) -> Result<Option<String>, RunError> {
356    match run_git(repo_path, &["merge-base", a, b]) {
357        Ok(output) => {
358            let id = output.stdout_lossy().trim().to_string();
359            Ok(if id.is_empty() { None } else { Some(id) })
360        }
361        // git merge-base exits 1 when no common ancestor exists.
362        Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
363        Err(e) => Err(e),
364    }
365}
366
367/// Default transient error check for jj/git.
368///
369/// Retries on:
370/// - `NonZeroExit` with `"stale"` in stderr — "The working copy is stale" (jj)
371/// - `NonZeroExit` with `".lock"` in stderr — Lock file contention (git/jj)
372///
373/// Spawn failures and timeouts are never treated as transient.
374pub fn is_transient_error(err: &RunError) -> bool {
375    match err {
376        RunError::NonZeroExit { stderr, .. } => {
377            stderr.contains("stale") || stderr.contains(".lock")
378        }
379        RunError::Spawn { .. } | RunError::Timeout { .. } => false,
380    }
381}
382
383/// Check whether a binary is available on PATH.
384pub fn binary_available(name: &str) -> bool {
385    Command::new(name)
386        .arg("--version")
387        .stdout(Stdio::null())
388        .stderr(Stdio::null())
389        .status()
390        .is_ok_and(|s| s.success())
391}
392
393/// Get a binary's version string, if available.
394pub fn binary_version(name: &str) -> Option<String> {
395    let output = Command::new(name).arg("--version").output().ok()?;
396    if !output.status.success() {
397        return None;
398    }
399    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
400}
401
402fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
403    if output.status.success() {
404        Ok(RunOutput {
405            stdout: output.stdout,
406            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
407        })
408    } else {
409        Err(RunError::NonZeroExit {
410            program: program.to_string(),
411            args: args.iter().map(|s| s.to_string()).collect(),
412            status: output.status,
413            stdout: output.stdout,
414            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
415        })
416    }
417}
418
419fn read_to_end<R: Read>(mut reader: R) -> Vec<u8> {
420    let mut buf = Vec::new();
421    let _ = reader.read_to_end(&mut buf);
422    buf
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    // --- is_transient_error ---
430
431    fn fake_non_zero(stderr: &str) -> RunError {
432        let status = Command::new("false").status().expect("false");
433        RunError::NonZeroExit {
434            program: "jj".into(),
435            args: vec!["status".into()],
436            status,
437            stdout: Vec::new(),
438            stderr: stderr.to_string(),
439        }
440    }
441
442    fn fake_spawn() -> RunError {
443        RunError::Spawn {
444            program: "jj".into(),
445            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
446        }
447    }
448
449    fn fake_timeout() -> RunError {
450        RunError::Timeout {
451            program: "git".into(),
452            args: vec!["fetch".into()],
453            elapsed: Duration::from_secs(30),
454            stdout: Vec::new(),
455            stderr: String::new(),
456        }
457    }
458
459    #[test]
460    fn transient_detects_stale() {
461        assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
462    }
463
464    #[test]
465    fn transient_detects_lock() {
466        assert!(is_transient_error(&fake_non_zero(
467            "Unable to create .lock: File exists"
468        )));
469    }
470
471    #[test]
472    fn transient_rejects_config_error() {
473        assert!(!is_transient_error(&fake_non_zero(
474            "Config error: no such revision"
475        )));
476    }
477
478    #[test]
479    fn transient_never_retries_spawn_failure() {
480        assert!(!is_transient_error(&fake_spawn()));
481    }
482
483    #[test]
484    fn transient_never_retries_timeout() {
485        assert!(!is_transient_error(&fake_timeout()));
486    }
487
488    // --- run_cmd_inherited ---
489
490    #[test]
491    fn cmd_inherited_succeeds() {
492        run_cmd_inherited("true", &[]).expect("true should succeed");
493    }
494
495    #[test]
496    fn cmd_inherited_fails_on_nonzero() {
497        let err = run_cmd_inherited("false", &[]).expect_err("should fail");
498        assert!(err.is_non_zero_exit());
499        assert_eq!(err.program(), "false");
500    }
501
502    #[test]
503    fn cmd_inherited_fails_on_missing_binary() {
504        let err = run_cmd_inherited("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
505        assert!(err.is_spawn_failure());
506    }
507
508    // --- run_cmd ---
509
510    #[test]
511    fn cmd_captured_succeeds() {
512        let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
513        assert_eq!(output.stdout_lossy().trim(), "hello");
514    }
515
516    #[test]
517    fn cmd_captured_fails_on_nonzero() {
518        let err = run_cmd("false", &[]).expect_err("should fail");
519        assert!(err.is_non_zero_exit());
520        assert!(err.exit_status().is_some());
521    }
522
523    #[test]
524    fn cmd_captured_captures_stderr_on_failure() {
525        let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
526        assert_eq!(err.stderr(), Some("err\n"));
527    }
528
529    #[test]
530    fn cmd_captured_captures_stdout_on_failure() {
531        let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
532        match &err {
533            RunError::NonZeroExit { stdout, .. } => {
534                assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
535            }
536            _ => panic!("expected NonZeroExit"),
537        }
538    }
539
540    #[test]
541    fn cmd_fails_on_missing_binary() {
542        let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
543        assert!(err.is_spawn_failure());
544    }
545
546    // --- run_cmd_in ---
547
548    #[test]
549    fn cmd_in_runs_in_directory() {
550        let tmp = tempfile::tempdir().expect("tempdir");
551        let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
552        let pwd = output.stdout_lossy().trim().to_string();
553        let expected = tmp.path().canonicalize().expect("canonicalize");
554        let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
555        assert_eq!(actual, expected);
556    }
557
558    #[test]
559    fn cmd_in_fails_on_nonzero() {
560        let tmp = tempfile::tempdir().expect("tempdir");
561        let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
562        assert!(err.is_non_zero_exit());
563    }
564
565    #[test]
566    fn cmd_in_fails_on_nonexistent_dir() {
567        let err = run_cmd_in(
568            std::path::Path::new("/nonexistent_dir_xyz_42"),
569            "echo",
570            &["hi"],
571        )
572        .expect_err("should fail");
573        assert!(err.is_spawn_failure());
574    }
575
576    // --- run_cmd_in_with_env ---
577
578    #[test]
579    fn cmd_in_with_env_sets_variable() {
580        let tmp = tempfile::tempdir().expect("tempdir");
581        let output = run_cmd_in_with_env(
582            tmp.path(),
583            "sh",
584            &["-c", "echo $TEST_VAR_XYZ"],
585            &[("TEST_VAR_XYZ", "hello_from_env")],
586        )
587        .expect("should succeed");
588        assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
589    }
590
591    #[test]
592    fn cmd_in_with_env_multiple_vars() {
593        let tmp = tempfile::tempdir().expect("tempdir");
594        let output = run_cmd_in_with_env(
595            tmp.path(),
596            "sh",
597            &["-c", "echo ${A}_${B}"],
598            &[("A", "foo"), ("B", "bar")],
599        )
600        .expect("should succeed");
601        assert_eq!(output.stdout_lossy().trim(), "foo_bar");
602    }
603
604    #[test]
605    fn cmd_in_with_env_overrides_existing_var() {
606        let tmp = tempfile::tempdir().expect("tempdir");
607        let output = run_cmd_in_with_env(
608            tmp.path(),
609            "sh",
610            &["-c", "echo $HOME"],
611            &[("HOME", "/fake/home")],
612        )
613        .expect("should succeed");
614        assert_eq!(output.stdout_lossy().trim(), "/fake/home");
615    }
616
617    #[test]
618    fn cmd_in_with_env_fails_on_nonzero() {
619        let tmp = tempfile::tempdir().expect("tempdir");
620        let err = run_cmd_in_with_env(
621            tmp.path(),
622            "sh",
623            &["-c", "exit 1"],
624            &[("IRRELEVANT", "value")],
625        )
626        .expect_err("should fail");
627        assert!(err.is_non_zero_exit());
628    }
629
630    // --- run_cmd_in_with_timeout ---
631
632    #[test]
633    fn timeout_succeeds_for_fast_command() {
634        let tmp = tempfile::tempdir().expect("tempdir");
635        let output =
636            run_cmd_in_with_timeout(tmp.path(), "echo", &["hello"], Duration::from_secs(5))
637                .expect("should succeed");
638        assert_eq!(output.stdout_lossy().trim(), "hello");
639    }
640
641    #[test]
642    fn timeout_fires_for_slow_command() {
643        let tmp = tempfile::tempdir().expect("tempdir");
644        let wall_start = Instant::now();
645        let err = run_cmd_in_with_timeout(
646            tmp.path(),
647            "sleep",
648            &["10"],
649            Duration::from_millis(200),
650        )
651        .expect_err("should time out");
652        let wall_elapsed = wall_start.elapsed();
653
654        assert!(err.is_timeout());
655        // The sleep was for 10 seconds; we killed it. Total wall time must be far less.
656        assert!(
657            wall_elapsed < Duration::from_secs(5),
658            "expected quick kill, took {wall_elapsed:?}"
659        );
660    }
661
662    #[test]
663    fn timeout_captures_partial_stderr_before_kill() {
664        let tmp = tempfile::tempdir().expect("tempdir");
665        // `exec sleep 10` replaces the shell with sleep directly, so killing our
666        // direct child (sleep) closes its stderr pipe. Without exec, the shell
667        // would fork sleep as a grandchild that survives the kill and keeps the
668        // pipe open until its natural completion.
669        let err = run_cmd_in_with_timeout(
670            tmp.path(),
671            "sh",
672            &["-c", "echo partial >&2; exec sleep 10"],
673            Duration::from_millis(500),
674        )
675        .expect_err("should time out");
676        assert!(err.is_timeout());
677        let stderr = err.stderr().unwrap_or("");
678        assert!(
679            stderr.contains("partial"),
680            "expected partial stderr, got: {stderr:?}"
681        );
682    }
683
684    #[test]
685    fn timeout_reports_non_zero_exit_when_process_completes() {
686        let tmp = tempfile::tempdir().expect("tempdir");
687        let err = run_cmd_in_with_timeout(
688            tmp.path(),
689            "false",
690            &[],
691            Duration::from_secs(5),
692        )
693        .expect_err("should fail");
694        assert!(err.is_non_zero_exit());
695    }
696
697    #[test]
698    fn timeout_fails_on_missing_binary() {
699        let tmp = tempfile::tempdir().expect("tempdir");
700        let err = run_cmd_in_with_timeout(
701            tmp.path(),
702            "nonexistent_binary_xyz_42",
703            &[],
704            Duration::from_secs(5),
705        )
706        .expect_err("should fail");
707        assert!(err.is_spawn_failure());
708    }
709
710    #[test]
711    fn timeout_does_not_block_on_large_output() {
712        // Without background thread draining, a command that writes more than
713        // the pipe buffer (usually 64KB) would block waiting for us to read.
714        // The timeout would fire but the process would be hung on write.
715        let tmp = tempfile::tempdir().expect("tempdir");
716        let output = run_cmd_in_with_timeout(
717            tmp.path(),
718            "sh",
719            &["-c", "yes | head -c 200000"],
720            Duration::from_secs(5),
721        )
722        .expect("should succeed");
723        assert!(output.stdout.len() >= 200_000);
724    }
725
726    // --- RunOutput ---
727
728    #[test]
729    fn stdout_lossy_valid_utf8() {
730        let output = RunOutput {
731            stdout: b"hello world".to_vec(),
732            stderr: String::new(),
733        };
734        assert_eq!(output.stdout_lossy(), "hello world");
735    }
736
737    #[test]
738    fn stdout_lossy_invalid_utf8() {
739        let output = RunOutput {
740            stdout: vec![0xff, 0xfe, b'a', b'b'],
741            stderr: String::new(),
742        };
743        let s = output.stdout_lossy();
744        assert!(s.contains("ab"));
745        assert!(s.contains('�'));
746    }
747
748    #[test]
749    fn stdout_raw_bytes_preserved() {
750        let bytes: Vec<u8> = (0..=255).collect();
751        let output = RunOutput {
752            stdout: bytes.clone(),
753            stderr: String::new(),
754        };
755        assert_eq!(output.stdout, bytes);
756    }
757
758    #[test]
759    fn run_output_debug_impl() {
760        let output = RunOutput {
761            stdout: b"hello".to_vec(),
762            stderr: "warn".to_string(),
763        };
764        let debug = format!("{output:?}");
765        assert!(debug.contains("warn"));
766        assert!(debug.contains("stdout"));
767    }
768
769    // --- binary_available / binary_version ---
770
771    #[test]
772    fn binary_available_true_returns_true() {
773        assert!(binary_available("echo"));
774    }
775
776    #[test]
777    fn binary_available_missing_returns_false() {
778        assert!(!binary_available("nonexistent_binary_xyz_42"));
779    }
780
781    #[test]
782    fn binary_version_missing_returns_none() {
783        assert!(binary_version("nonexistent_binary_xyz_42").is_none());
784    }
785
786    // --- run_jj / run_git (only if binary available) ---
787
788    #[test]
789    fn run_jj_version_succeeds() {
790        if !binary_available("jj") {
791            return;
792        }
793        let tmp = tempfile::tempdir().expect("tempdir");
794        let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
795        assert!(output.stdout_lossy().contains("jj"));
796    }
797
798    #[test]
799    fn run_jj_fails_in_non_repo() {
800        if !binary_available("jj") {
801            return;
802        }
803        let tmp = tempfile::tempdir().expect("tempdir");
804        let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
805        assert!(err.is_non_zero_exit());
806    }
807
808    #[test]
809    fn run_git_version_succeeds() {
810        if !binary_available("git") {
811            return;
812        }
813        let tmp = tempfile::tempdir().expect("tempdir");
814        let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
815        assert!(output.stdout_lossy().contains("git"));
816    }
817
818    #[test]
819    fn run_git_fails_in_non_repo() {
820        if !binary_available("git") {
821            return;
822        }
823        let tmp = tempfile::tempdir().expect("tempdir");
824        let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
825        assert!(err.is_non_zero_exit());
826    }
827
828    #[test]
829    fn run_jj_with_timeout_succeeds() {
830        if !binary_available("jj") {
831            return;
832        }
833        let tmp = tempfile::tempdir().expect("tempdir");
834        let output =
835            run_jj_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
836                .expect("jj --version should work");
837        assert!(output.stdout_lossy().contains("jj"));
838    }
839
840    #[test]
841    fn run_git_with_timeout_succeeds() {
842        if !binary_available("git") {
843            return;
844        }
845        let tmp = tempfile::tempdir().expect("tempdir");
846        let output =
847            run_git_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
848                .expect("git --version should work");
849        assert!(output.stdout_lossy().contains("git"));
850    }
851
852    // --- check_output ---
853
854    #[test]
855    fn check_output_preserves_stderr_on_success() {
856        let output =
857            run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
858        assert_eq!(output.stdout_lossy().trim(), "ok");
859        assert_eq!(output.stderr.trim(), "warn");
860    }
861
862    // --- retry ---
863
864    #[test]
865    fn retry_accepts_closure_over_run_error() {
866        let captured = "special".to_string();
867        let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
868
869        assert!(!checker(&fake_non_zero("other")));
870        assert!(checker(&fake_non_zero("this has special text")));
871        assert!(!checker(&fake_spawn()));
872    }
873
874    // --- merge_base helpers ---
875
876    #[test]
877    fn git_merge_base_finds_common_ancestor() {
878        if !binary_available("git") {
879            return;
880        }
881        let tmp = tempfile::tempdir().expect("tempdir");
882        let repo = tmp.path();
883
884        // Init repo with one commit
885        let _ = Command::new("git")
886            .args(["init", "-b", "main"])
887            .current_dir(repo)
888            .output();
889        let _ = Command::new("git")
890            .args(["config", "user.email", "t@t"])
891            .current_dir(repo)
892            .output();
893        let _ = Command::new("git")
894            .args(["config", "user.name", "t"])
895            .current_dir(repo)
896            .output();
897        std::fs::write(repo.join("a.txt"), "a").expect("write test file");
898        let _ = Command::new("git")
899            .args(["add", "."])
900            .current_dir(repo)
901            .output();
902        let _ = Command::new("git")
903            .args(["commit", "-m", "initial"])
904            .current_dir(repo)
905            .output();
906
907        // HEAD is merge base with itself
908        let base = git_merge_base(repo, "HEAD", "HEAD").expect("should succeed");
909        assert!(base.is_some());
910        assert_eq!(base.as_deref().map(str::len), Some(40));
911    }
912
913    #[test]
914    fn git_merge_base_unrelated_histories_returns_none() {
915        if !binary_available("git") {
916            return;
917        }
918        let tmp = tempfile::tempdir().expect("tempdir");
919        let repo = tmp.path();
920
921        // Init with one commit on main, then create an orphan branch.
922        // Use -b main explicitly since CI's git may default to master.
923        let _ = Command::new("git")
924            .args(["init", "-b", "main"])
925            .current_dir(repo)
926            .output();
927        let _ = Command::new("git")
928            .args(["config", "user.email", "t@t"])
929            .current_dir(repo)
930            .output();
931        let _ = Command::new("git")
932            .args(["config", "user.name", "t"])
933            .current_dir(repo)
934            .output();
935        std::fs::write(repo.join("a.txt"), "a").expect("write test file");
936        let _ = Command::new("git")
937            .args(["add", "."])
938            .current_dir(repo)
939            .output();
940        let _ = Command::new("git")
941            .args(["commit", "-m", "main-1"])
942            .current_dir(repo)
943            .output();
944        let _ = Command::new("git")
945            .args(["checkout", "--orphan", "alt"])
946            .current_dir(repo)
947            .output();
948        let _ = Command::new("git")
949            .args(["rm", "-rf", "."])
950            .current_dir(repo)
951            .output();
952        std::fs::write(repo.join("b.txt"), "b").expect("write test file");
953        let _ = Command::new("git")
954            .args(["add", "."])
955            .current_dir(repo)
956            .output();
957        let _ = Command::new("git")
958            .args(["commit", "-m", "alt-1"])
959            .current_dir(repo)
960            .output();
961
962        // alt and main have no common ancestor
963        let result = git_merge_base(repo, "alt", "main");
964        assert!(matches!(result, Ok(None)));
965    }
966
967    #[test]
968    fn git_merge_base_invalid_ref_returns_err() {
969        if !binary_available("git") {
970            return;
971        }
972        let tmp = tempfile::tempdir().expect("tempdir");
973        let _ = Command::new("git")
974            .args(["init"])
975            .current_dir(tmp.path())
976            .output();
977        let result = git_merge_base(tmp.path(), "nonexistent-ref-xyz", "HEAD");
978        // Invalid refs produce exit code 128, which is a real error (not None).
979        assert!(result.is_err());
980    }
981
982    #[test]
983    fn jj_merge_base_same_rev_returns_self() {
984        if !binary_available("jj") {
985            return;
986        }
987        let tmp = tempfile::tempdir().expect("tempdir");
988        let repo = tmp.path();
989        let _ = Command::new("jj")
990            .args(["git", "init"])
991            .current_dir(repo)
992            .output();
993
994        // @ with @ should be @ itself
995        let base = jj_merge_base(repo, "@", "@");
996        // Empty fresh repo — @ exists but may or may not have a commit_id
997        // depending on jj version. Just check the call succeeds.
998        assert!(base.is_ok());
999    }
1000}