Skip to main content

vcs_runner/
runner.rs

1//! VCS-specific subprocess helpers — thin wrappers over [`procpilot::Cmd`].
2//!
3//! The generic subprocess primitives (run_cmd, retry, timeout, etc.) live in
4//! [`procpilot`]. This module layers on jj/git-specific conveniences:
5//! merge-base helpers, the default transient-error predicate, and shorthand
6//! `run_jj` / `run_git` wrappers.
7
8use std::path::Path;
9use std::sync::Arc;
10use std::sync::atomic::AtomicBool;
11use std::time::Duration;
12
13use procpilot::{Cmd, RetryPolicy, RunError, RunOutput};
14
15/// Run a `jj` command in a repo directory, returning captured output.
16pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
17    Cmd::new("jj").in_dir(repo_path).args(args).run()
18}
19
20/// Run a `git` command in a repo directory, returning captured output.
21pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
22    Cmd::new("git").in_dir(repo_path).args(args).run()
23}
24
25/// Run a `jj` command, returning lossy-decoded, trimmed stdout as a `String`.
26///
27/// Shorthand for `run_jj(repo_path, args)?.stdout_lossy().trim().to_string()`
28/// — the most common pattern for callers that treat stdout as text.
29pub fn run_jj_utf8(repo_path: &Path, args: &[&str]) -> Result<String, RunError> {
30    let out = run_jj(repo_path, args)?;
31    Ok(out.stdout_lossy().trim().to_string())
32}
33
34/// Run a `git` command, returning lossy-decoded, trimmed stdout as a `String`.
35pub fn run_git_utf8(repo_path: &Path, args: &[&str]) -> Result<String, RunError> {
36    let out = run_git(repo_path, args)?;
37    Ok(out.stdout_lossy().trim().to_string())
38}
39
40/// Run a `jj` command with a timeout, returning trimmed stdout as a `String`.
41pub fn run_jj_utf8_with_timeout(
42    repo_path: &Path,
43    args: &[&str],
44    timeout: Duration,
45) -> Result<String, RunError> {
46    let out = run_jj_with_timeout(repo_path, args, timeout)?;
47    Ok(out.stdout_lossy().trim().to_string())
48}
49
50/// Run a `git` command with a timeout, returning trimmed stdout as a `String`.
51pub fn run_git_utf8_with_timeout(
52    repo_path: &Path,
53    args: &[&str],
54    timeout: Duration,
55) -> Result<String, RunError> {
56    let out = run_git_with_timeout(repo_path, args, timeout)?;
57    Ok(out.stdout_lossy().trim().to_string())
58}
59
60/// Run a `jj` command with retry, returning trimmed stdout as a `String`.
61pub fn run_jj_utf8_with_retry(
62    repo_path: &Path,
63    args: &[&str],
64    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
65) -> Result<String, RunError> {
66    let out = run_jj_with_retry(repo_path, args, is_transient)?;
67    Ok(out.stdout_lossy().trim().to_string())
68}
69
70/// Run a `git` command with retry, returning trimmed stdout as a `String`.
71pub fn run_git_utf8_with_retry(
72    repo_path: &Path,
73    args: &[&str],
74    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
75) -> Result<String, RunError> {
76    let out = run_git_with_retry(repo_path, args, is_transient)?;
77    Ok(out.stdout_lossy().trim().to_string())
78}
79
80/// Run a `jj` command with a timeout.
81pub fn run_jj_with_timeout(
82    repo_path: &Path,
83    args: &[&str],
84    timeout: Duration,
85) -> Result<RunOutput, RunError> {
86    Cmd::new("jj")
87        .in_dir(repo_path)
88        .args(args)
89        .timeout(timeout)
90        .run()
91}
92
93/// Run a `git` command with a timeout.
94pub fn run_git_with_timeout(
95    repo_path: &Path,
96    args: &[&str],
97    timeout: Duration,
98) -> Result<RunOutput, RunError> {
99    Cmd::new("git")
100        .in_dir(repo_path)
101        .args(args)
102        .timeout(timeout)
103        .run()
104}
105
106/// Run a `jj` command with retry on transient errors.
107pub fn run_jj_with_retry(
108    repo_path: &Path,
109    args: &[&str],
110    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
111) -> Result<RunOutput, RunError> {
112    Cmd::new("jj")
113        .in_dir(repo_path)
114        .args(args)
115        .retry(RetryPolicy::default().when(is_transient))
116        .run()
117}
118
119/// Run a `git` command with retry on transient errors.
120pub fn run_git_with_retry(
121    repo_path: &Path,
122    args: &[&str],
123    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
124) -> Result<RunOutput, RunError> {
125    Cmd::new("git")
126        .in_dir(repo_path)
127        .args(args)
128        .retry(RetryPolicy::default().when(is_transient))
129        .run()
130}
131
132/// Run a `jj` command with caller-driven cancellation.
133///
134/// When `cancel` is set to `true` the wrapper kills the child (SIGTERM →
135/// SIGKILL after the procpilot default grace) and returns
136/// [`RunError::Cancelled`]. If `cancel` is already set before spawn,
137/// returns `Cancelled` without starting the child.
138pub fn run_jj_cancellable(
139    repo_path: &Path,
140    args: &[&str],
141    cancel: Arc<AtomicBool>,
142) -> Result<RunOutput, RunError> {
143    Cmd::new("jj")
144        .in_dir(repo_path)
145        .args(args)
146        .cancel(cancel)
147        .run()
148}
149
150/// Run a `git` command with caller-driven cancellation. See
151/// [`run_jj_cancellable`] for semantics.
152pub fn run_git_cancellable(
153    repo_path: &Path,
154    args: &[&str],
155    cancel: Arc<AtomicBool>,
156) -> Result<RunOutput, RunError> {
157    Cmd::new("git")
158        .in_dir(repo_path)
159        .args(args)
160        .cancel(cancel)
161        .run()
162}
163
164/// Run a `jj` command with cancellation, returning trimmed stdout as a `String`.
165pub fn run_jj_utf8_cancellable(
166    repo_path: &Path,
167    args: &[&str],
168    cancel: Arc<AtomicBool>,
169) -> Result<String, RunError> {
170    let out = run_jj_cancellable(repo_path, args, cancel)?;
171    Ok(out.stdout_lossy().trim().to_string())
172}
173
174/// Run a `git` command with cancellation, returning trimmed stdout as a `String`.
175pub fn run_git_utf8_cancellable(
176    repo_path: &Path,
177    args: &[&str],
178    cancel: Arc<AtomicBool>,
179) -> Result<String, RunError> {
180    let out = run_git_cancellable(repo_path, args, cancel)?;
181    Ok(out.stdout_lossy().trim().to_string())
182}
183
184/// Run a `jj` command with retry on transient errors plus caller-driven cancellation.
185///
186/// Setting `cancel` short-circuits any pending backoff sleep and kills the
187/// in-flight child. The default retry predicate does not retry
188/// [`RunError::Cancelled`], so a cancelled attempt ends the loop.
189pub fn run_jj_with_retry_cancellable(
190    repo_path: &Path,
191    args: &[&str],
192    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
193    cancel: Arc<AtomicBool>,
194) -> Result<RunOutput, RunError> {
195    Cmd::new("jj")
196        .in_dir(repo_path)
197        .args(args)
198        .retry(RetryPolicy::default().when(is_transient))
199        .cancel(cancel)
200        .run()
201}
202
203/// Run a `git` command with retry on transient errors plus caller-driven cancellation.
204/// See [`run_jj_with_retry_cancellable`] for semantics.
205pub fn run_git_with_retry_cancellable(
206    repo_path: &Path,
207    args: &[&str],
208    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
209    cancel: Arc<AtomicBool>,
210) -> Result<RunOutput, RunError> {
211    Cmd::new("git")
212        .in_dir(repo_path)
213        .args(args)
214        .retry(RetryPolicy::default().when(is_transient))
215        .cancel(cancel)
216        .run()
217}
218
219/// Run a `jj` command with retry + cancellation, returning trimmed stdout as a `String`.
220pub fn run_jj_utf8_with_retry_cancellable(
221    repo_path: &Path,
222    args: &[&str],
223    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
224    cancel: Arc<AtomicBool>,
225) -> Result<String, RunError> {
226    let out = run_jj_with_retry_cancellable(repo_path, args, is_transient, cancel)?;
227    Ok(out.stdout_lossy().trim().to_string())
228}
229
230/// Run a `git` command with retry + cancellation, returning trimmed stdout as a `String`.
231pub fn run_git_utf8_with_retry_cancellable(
232    repo_path: &Path,
233    args: &[&str],
234    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
235    cancel: Arc<AtomicBool>,
236) -> Result<String, RunError> {
237    let out = run_git_with_retry_cancellable(repo_path, args, is_transient, cancel)?;
238    Ok(out.stdout_lossy().trim().to_string())
239}
240
241/// Find the merge base of two revisions in a jj repository.
242///
243/// Uses the revset `latest(::(a) & ::(b))` — the most recent common ancestor
244/// of the two revisions. Returns `Ok(None)` when the revisions have no common
245/// ancestor.
246pub fn jj_merge_base(
247    repo_path: &Path,
248    a: &str,
249    b: &str,
250) -> Result<Option<String>, RunError> {
251    let revset = format!("latest(::({a}) & ::({b}))");
252    let id = run_jj_utf8(
253        repo_path,
254        &[
255            "log", "-r", &revset, "--no-graph", "--limit", "1", "-T", "commit_id",
256        ],
257    )?;
258    Ok(if id.is_empty() { None } else { Some(id) })
259}
260
261/// Find the merge base of two revisions in a git repository.
262///
263/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
264/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
265pub fn git_merge_base(
266    repo_path: &Path,
267    a: &str,
268    b: &str,
269) -> Result<Option<String>, RunError> {
270    match run_git_utf8(repo_path, &["merge-base", a, b]) {
271        Ok(id) => Ok(if id.is_empty() { None } else { Some(id) }),
272        Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
273        Err(e) => Err(e),
274    }
275}
276
277/// Default transient-error check for jj/git.
278///
279/// Retries on `NonZeroExit` whose stderr contains `"stale"` or `".lock"`
280/// (jj working-copy staleness, git/jj lock-file contention). Spawn failures
281/// and timeouts are never treated as transient.
282pub fn is_transient_error(err: &RunError) -> bool {
283    procpilot::default_transient(err)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn jj_installed() -> bool {
291        procpilot::binary_available("jj")
292    }
293
294    fn git_installed() -> bool {
295        procpilot::binary_available("git")
296    }
297
298    #[test]
299    fn is_transient_matches_stale_and_lock() {
300        #[cfg(unix)]
301        let status = {
302            use std::os::unix::process::ExitStatusExt;
303            std::process::ExitStatus::from_raw(256)
304        };
305        #[cfg(windows)]
306        let status = {
307            use std::os::windows::process::ExitStatusExt;
308            std::process::ExitStatus::from_raw(1)
309        };
310        let err = RunError::NonZeroExit {
311            command: Cmd::new("jj").display(),
312            status,
313            stdout: vec![],
314            stderr: "The working copy is stale".into(),
315            attempts: 1,
316        };
317        assert!(is_transient_error(&err));
318    }
319
320    #[test]
321    fn run_jj_fails_gracefully_when_not_installed() {
322        if jj_installed() {
323            return;
324        }
325        let tmp = tempfile::tempdir().expect("tempdir");
326        let err = run_jj(tmp.path(), &["status"]).expect_err("jj not installed");
327        assert!(err.is_spawn_failure());
328    }
329
330    #[test]
331    fn run_jj_cancellable_short_circuits_on_preset_flag() {
332        let tmp = tempfile::tempdir().expect("tempdir");
333        let cancel = Arc::new(AtomicBool::new(true));
334        let err = run_jj_cancellable(tmp.path(), &["status"], cancel)
335            .expect_err("preset cancel flag must return error");
336        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
337    }
338
339    #[test]
340    fn run_git_cancellable_short_circuits_on_preset_flag() {
341        let tmp = tempfile::tempdir().expect("tempdir");
342        let cancel = Arc::new(AtomicBool::new(true));
343        let err = run_git_cancellable(tmp.path(), &["status"], cancel)
344            .expect_err("preset cancel flag must return error");
345        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
346    }
347
348    #[test]
349    fn run_jj_with_retry_cancellable_short_circuits_on_preset_flag() {
350        let tmp = tempfile::tempdir().expect("tempdir");
351        let cancel = Arc::new(AtomicBool::new(true));
352        let err = run_jj_with_retry_cancellable(
353            tmp.path(),
354            &["status"],
355            is_transient_error,
356            cancel,
357        )
358        .expect_err("preset cancel flag must return error before retry");
359        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
360    }
361
362    #[test]
363    fn run_jj_utf8_cancellable_short_circuits_on_preset_flag() {
364        let tmp = tempfile::tempdir().expect("tempdir");
365        let cancel = Arc::new(AtomicBool::new(true));
366        let err = run_jj_utf8_cancellable(tmp.path(), &["status"], cancel)
367            .expect_err("preset cancel flag must return error");
368        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
369    }
370
371    #[test]
372    fn run_git_utf8_cancellable_short_circuits_on_preset_flag() {
373        let tmp = tempfile::tempdir().expect("tempdir");
374        let cancel = Arc::new(AtomicBool::new(true));
375        let err = run_git_utf8_cancellable(tmp.path(), &["status"], cancel)
376            .expect_err("preset cancel flag must return error");
377        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
378    }
379
380    #[test]
381    fn run_git_with_retry_cancellable_short_circuits_on_preset_flag() {
382        let tmp = tempfile::tempdir().expect("tempdir");
383        let cancel = Arc::new(AtomicBool::new(true));
384        let err = run_git_with_retry_cancellable(
385            tmp.path(),
386            &["status"],
387            is_transient_error,
388            cancel,
389        )
390        .expect_err("preset cancel flag must return error before retry");
391        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
392    }
393
394    #[test]
395    fn run_jj_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
396        let tmp = tempfile::tempdir().expect("tempdir");
397        let cancel = Arc::new(AtomicBool::new(true));
398        let err = run_jj_utf8_with_retry_cancellable(
399            tmp.path(),
400            &["status"],
401            is_transient_error,
402            cancel,
403        )
404        .expect_err("preset cancel flag must return error before retry");
405        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
406    }
407
408    #[test]
409    fn run_git_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
410        let tmp = tempfile::tempdir().expect("tempdir");
411        let cancel = Arc::new(AtomicBool::new(true));
412        let err = run_git_utf8_with_retry_cancellable(
413            tmp.path(),
414            &["status"],
415            is_transient_error,
416            cancel,
417        )
418        .expect_err("preset cancel flag must return error before retry");
419        assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
420    }
421
422    #[test]
423    fn is_transient_does_not_retry_cancelled() {
424        // CHANGELOG advertises this behavior — pin it so a future procpilot
425        // bump can't silently flip the default-predicate semantics.
426        let err = RunError::Cancelled {
427            command: Cmd::new("jj").display(),
428            stdout: vec![],
429            stderr: String::new(),
430            attempts: 1,
431        };
432        assert!(!is_transient_error(&err));
433    }
434
435    #[test]
436    fn git_merge_base_returns_none_for_unrelated() {
437        if !git_installed() {
438            return;
439        }
440        let tmp = tempfile::tempdir().expect("tempdir");
441        std::process::Command::new("git")
442            .args(["init", "--quiet"])
443            .current_dir(tmp.path())
444            .status()
445            .expect("git init");
446        // Without commits, git merge-base fails with non-zero; accept that shape.
447        let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
448    }
449}