Skip to main content

vcs_runner/
runner.rs

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