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    let output = Command::new(program)
61        .args(args)
62        .current_dir(dir)
63        .output()
64        .with_context(|| format!("failed to run {program} in {}", dir.display()))?;
65
66    check_output(program, args, output)
67}
68
69/// Run a `jj` command in a repo directory, returning captured output.
70pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
71    run_cmd_in(repo_path, "jj", args)
72}
73
74/// Run a `git` command in a repo directory, returning captured output.
75pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
76    run_cmd_in(repo_path, "git", args)
77}
78
79/// Run a command in a directory with retry on transient errors.
80///
81/// Uses exponential backoff (100ms, 200ms, 400ms) with up to 3 retries.
82/// The `is_transient` callback receives the full stringified error
83/// (including stderr content) and returns whether to retry.
84///
85/// For convenience, [`run_jj_with_retry`] and [`run_git_with_retry`]
86/// pre-fill the program name.
87pub fn run_with_retry(
88    repo_path: &Path,
89    program: &str,
90    args: &[&str],
91    is_transient: impl Fn(&str) -> bool,
92) -> Result<RunOutput> {
93    let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
94
95    let op = || {
96        let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
97        run_cmd_in(repo_path, program, &str_args)
98    };
99
100    op.retry(
101        ExponentialBuilder::default()
102            .with_factor(2.0)
103            .with_min_delay(std::time::Duration::from_millis(100))
104            .with_max_times(3),
105    )
106    .when(|e| is_transient(&e.to_string()))
107    .call()
108}
109
110/// Run a `jj` command with retry on transient errors.
111///
112/// Shorthand for `run_with_retry(repo_path, "jj", args, is_transient)`.
113pub fn run_jj_with_retry(
114    repo_path: &Path,
115    args: &[&str],
116    is_transient: impl Fn(&str) -> bool,
117) -> Result<RunOutput> {
118    run_with_retry(repo_path, "jj", args, is_transient)
119}
120
121/// Run a `git` command with retry on transient errors.
122///
123/// Shorthand for `run_with_retry(repo_path, "git", args, is_transient)`.
124pub fn run_git_with_retry(
125    repo_path: &Path,
126    args: &[&str],
127    is_transient: impl Fn(&str) -> bool,
128) -> Result<RunOutput> {
129    run_with_retry(repo_path, "git", args, is_transient)
130}
131
132/// Check whether a jj/git error indicates a transient condition.
133///
134/// Matches:
135/// - "stale" — "The working copy is stale" (jj, resolves after op completion)
136/// - ".lock" — Lock file contention (git/jj)
137pub fn is_transient_error(error_msg: &str) -> bool {
138    error_msg.contains("stale") || error_msg.contains(".lock")
139}
140
141/// Check whether a binary is available on PATH.
142pub fn binary_available(name: &str) -> bool {
143    Command::new(name)
144        .arg("--version")
145        .stdout(Stdio::null())
146        .stderr(Stdio::null())
147        .status()
148        .is_ok_and(|s| s.success())
149}
150
151/// Get a binary's version string, if available.
152pub fn binary_version(name: &str) -> Option<String> {
153    let output = Command::new(name)
154        .arg("--version")
155        .output()
156        .ok()?;
157    if !output.status.success() {
158        return None;
159    }
160    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
161}
162
163fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput> {
164    if output.status.success() {
165        Ok(RunOutput {
166            stdout: output.stdout,
167            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
168        })
169    } else {
170        let stderr = String::from_utf8_lossy(&output.stderr);
171        bail!("{program} {} failed: {}", args.join(" "), stderr.trim())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // --- is_transient_error ---
180
181    #[test]
182    fn transient_detects_stale() {
183        assert!(is_transient_error("The working copy is stale"));
184    }
185
186    #[test]
187    fn transient_detects_stale_in_context() {
188        assert!(is_transient_error(
189            "jj diff failed: Error: The working copy is stale (not updated since op abc)"
190        ));
191    }
192
193    #[test]
194    fn transient_detects_lock() {
195        assert!(is_transient_error("Unable to create .lock: File exists"));
196    }
197
198    #[test]
199    fn transient_detects_git_index_lock() {
200        assert!(is_transient_error("fatal: Unable to create '/repo/.git/index.lock'"));
201    }
202
203    #[test]
204    fn transient_rejects_config_error() {
205        assert!(!is_transient_error("Config error: no such revision"));
206    }
207
208    #[test]
209    fn transient_rejects_empty() {
210        assert!(!is_transient_error(""));
211    }
212
213    #[test]
214    fn transient_rejects_not_found() {
215        assert!(!is_transient_error("jj not found"));
216    }
217
218    // --- run_cmd_inherited ---
219
220    #[test]
221    fn cmd_inherited_succeeds() {
222        run_cmd_inherited("true", &[]).expect("true should succeed");
223    }
224
225    #[test]
226    fn cmd_inherited_fails_on_nonzero() {
227        let result = run_cmd_inherited("false", &[]);
228        assert!(result.is_err());
229        let msg = result.expect_err("should fail").to_string();
230        assert!(msg.contains("false"), "error should name the program");
231    }
232
233    #[test]
234    fn cmd_inherited_fails_on_missing_binary() {
235        let result = run_cmd_inherited("nonexistent_binary_xyz_42", &[]);
236        assert!(result.is_err());
237    }
238
239    // --- run_cmd ---
240
241    #[test]
242    fn cmd_captured_succeeds() {
243        let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
244        assert_eq!(output.stdout_lossy().trim(), "hello");
245    }
246
247    #[test]
248    fn cmd_captured_fails_on_nonzero() {
249        let result = run_cmd("false", &[]);
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn cmd_captured_captures_stderr() {
255        let result = run_cmd("sh", &["-c", "echo err >&2; exit 1"]);
256        let msg = result.expect_err("should fail").to_string();
257        assert!(msg.contains("err"), "error should include stderr content");
258    }
259
260    // --- run_cmd_in ---
261
262    #[test]
263    fn cmd_in_runs_in_directory() {
264        let tmp = tempfile::tempdir().expect("tempdir");
265        let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
266        let pwd = output.stdout_lossy().trim().to_string();
267        let expected = tmp.path().canonicalize().expect("canonicalize");
268        let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
269        assert_eq!(actual, expected);
270    }
271
272    #[test]
273    fn cmd_in_fails_on_nonzero() {
274        let tmp = tempfile::tempdir().expect("tempdir");
275        let result = run_cmd_in(tmp.path(), "false", &[]);
276        assert!(result.is_err());
277    }
278
279    #[test]
280    fn cmd_in_fails_on_nonexistent_dir() {
281        let result = run_cmd_in(std::path::Path::new("/nonexistent_dir_xyz_42"), "echo", &["hi"]);
282        assert!(result.is_err());
283    }
284
285    // --- RunOutput ---
286
287    #[test]
288    fn stdout_lossy_valid_utf8() {
289        let output = RunOutput {
290            stdout: b"hello world".to_vec(),
291            stderr: String::new(),
292        };
293        assert_eq!(output.stdout_lossy(), "hello world");
294    }
295
296    #[test]
297    fn stdout_lossy_invalid_utf8() {
298        let output = RunOutput {
299            stdout: vec![0xff, 0xfe, b'a', b'b'],
300            stderr: String::new(),
301        };
302        let s = output.stdout_lossy();
303        assert!(s.contains("ab"), "valid bytes should be preserved");
304        assert!(s.contains('�'), "invalid bytes should become replacement char");
305    }
306
307    #[test]
308    fn stdout_raw_bytes_preserved() {
309        let bytes: Vec<u8> = (0..=255).collect();
310        let output = RunOutput {
311            stdout: bytes.clone(),
312            stderr: String::new(),
313        };
314        assert_eq!(output.stdout, bytes);
315    }
316
317    #[test]
318    fn run_output_debug_impl() {
319        let output = RunOutput {
320            stdout: b"hello".to_vec(),
321            stderr: "warn".to_string(),
322        };
323        let debug = format!("{output:?}");
324        assert!(debug.contains("warn"));
325        assert!(debug.contains("stdout"));
326    }
327
328    // --- binary_available / binary_version ---
329
330    #[test]
331    fn binary_available_true_returns_true() {
332        assert!(binary_available("echo"));
333    }
334
335    #[test]
336    fn binary_available_missing_returns_false() {
337        assert!(!binary_available("nonexistent_binary_xyz_42"));
338    }
339
340    #[test]
341    fn binary_version_missing_returns_none() {
342        assert!(binary_version("nonexistent_binary_xyz_42").is_none());
343    }
344
345    // --- run_jj (only if jj available) ---
346
347    #[test]
348    fn run_jj_version_succeeds() {
349        if !binary_available("jj") {
350            return;
351        }
352        let tmp = tempfile::tempdir().expect("tempdir");
353        let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
354        assert!(output.stdout_lossy().contains("jj"));
355    }
356
357    #[test]
358    fn run_jj_fails_in_non_repo() {
359        if !binary_available("jj") {
360            return;
361        }
362        let tmp = tempfile::tempdir().expect("tempdir");
363        assert!(run_jj(tmp.path(), &["status"]).is_err());
364    }
365
366    // --- run_git (only if git available) ---
367
368    #[test]
369    fn run_git_version_succeeds() {
370        if !binary_available("git") {
371            return;
372        }
373        let tmp = tempfile::tempdir().expect("tempdir");
374        let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
375        assert!(output.stdout_lossy().contains("git"));
376    }
377
378    #[test]
379    fn run_git_fails_in_non_repo() {
380        if !binary_available("git") {
381            return;
382        }
383        let tmp = tempfile::tempdir().expect("tempdir");
384        assert!(run_git(tmp.path(), &["status"]).is_err());
385    }
386
387    // --- check_output ---
388
389    #[test]
390    fn check_output_preserves_stderr_on_success() {
391        let output = run_cmd("sh", &["-c", "echo ok; echo warn >&2"])
392            .expect("should succeed");
393        assert_eq!(output.stdout_lossy().trim(), "ok");
394        assert_eq!(output.stderr.trim(), "warn");
395    }
396
397    // --- retry ---
398
399    #[test]
400    fn retry_accepts_closure() {
401        let custom_keyword = "custom_transient".to_string();
402        let checker = |err: &str| err.contains(custom_keyword.as_str());
403        assert!(!checker("some other error"));
404        assert!(checker("this is custom_transient error"));
405    }
406}