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::time::Duration;
10
11use procpilot::{Cmd, RetryPolicy, RunError, RunOutput};
12
13/// Run a `jj` command in a repo directory, returning captured output.
14pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
15    Cmd::new("jj").in_dir(repo_path).args(args).run()
16}
17
18/// Run a `git` command in a repo directory, returning captured output.
19pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
20    Cmd::new("git").in_dir(repo_path).args(args).run()
21}
22
23/// Run a `jj` command with a timeout.
24pub fn run_jj_with_timeout(
25    repo_path: &Path,
26    args: &[&str],
27    timeout: Duration,
28) -> Result<RunOutput, RunError> {
29    Cmd::new("jj")
30        .in_dir(repo_path)
31        .args(args)
32        .timeout(timeout)
33        .run()
34}
35
36/// Run a `git` command with a timeout.
37pub fn run_git_with_timeout(
38    repo_path: &Path,
39    args: &[&str],
40    timeout: Duration,
41) -> Result<RunOutput, RunError> {
42    Cmd::new("git")
43        .in_dir(repo_path)
44        .args(args)
45        .timeout(timeout)
46        .run()
47}
48
49/// Run a `jj` command with retry on transient errors.
50pub fn run_jj_with_retry(
51    repo_path: &Path,
52    args: &[&str],
53    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
54) -> Result<RunOutput, RunError> {
55    Cmd::new("jj")
56        .in_dir(repo_path)
57        .args(args)
58        .retry(RetryPolicy::default().when(is_transient))
59        .run()
60}
61
62/// Run a `git` command with retry on transient errors.
63pub fn run_git_with_retry(
64    repo_path: &Path,
65    args: &[&str],
66    is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
67) -> Result<RunOutput, RunError> {
68    Cmd::new("git")
69        .in_dir(repo_path)
70        .args(args)
71        .retry(RetryPolicy::default().when(is_transient))
72        .run()
73}
74
75/// Find the merge base of two revisions in a jj repository.
76///
77/// Uses the revset `latest(::(a) & ::(b))` — the most recent common ancestor
78/// of the two revisions. Returns `Ok(None)` when the revisions have no common
79/// ancestor.
80pub fn jj_merge_base(
81    repo_path: &Path,
82    a: &str,
83    b: &str,
84) -> Result<Option<String>, RunError> {
85    let revset = format!("latest(::({a}) & ::({b}))");
86    let output = run_jj(
87        repo_path,
88        &[
89            "log", "-r", &revset, "--no-graph", "--limit", "1", "-T", "commit_id",
90        ],
91    )?;
92    let id = output.stdout_lossy().trim().to_string();
93    Ok(if id.is_empty() { None } else { Some(id) })
94}
95
96/// Find the merge base of two revisions in a git repository.
97///
98/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
99/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
100pub fn git_merge_base(
101    repo_path: &Path,
102    a: &str,
103    b: &str,
104) -> Result<Option<String>, RunError> {
105    match run_git(repo_path, &["merge-base", a, b]) {
106        Ok(output) => {
107            let id = output.stdout_lossy().trim().to_string();
108            Ok(if id.is_empty() { None } else { Some(id) })
109        }
110        Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
111        Err(e) => Err(e),
112    }
113}
114
115/// Default transient-error check for jj/git.
116///
117/// Retries on `NonZeroExit` whose stderr contains `"stale"` or `".lock"`
118/// (jj working-copy staleness, git/jj lock-file contention). Spawn failures
119/// and timeouts are never treated as transient.
120pub fn is_transient_error(err: &RunError) -> bool {
121    procpilot::default_transient(err)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn jj_installed() -> bool {
129        procpilot::binary_available("jj")
130    }
131
132    fn git_installed() -> bool {
133        procpilot::binary_available("git")
134    }
135
136    #[test]
137    fn is_transient_matches_stale_and_lock() {
138        #[cfg(unix)]
139        let status = {
140            use std::os::unix::process::ExitStatusExt;
141            std::process::ExitStatus::from_raw(256)
142        };
143        #[cfg(windows)]
144        let status = {
145            use std::os::windows::process::ExitStatusExt;
146            std::process::ExitStatus::from_raw(1)
147        };
148        let err = RunError::NonZeroExit {
149            command: Cmd::new("jj").display(),
150            status,
151            stdout: vec![],
152            stderr: "The working copy is stale".into(),
153        };
154        assert!(is_transient_error(&err));
155    }
156
157    #[test]
158    fn run_jj_fails_gracefully_when_not_installed() {
159        if jj_installed() {
160            return;
161        }
162        let tmp = tempfile::tempdir().expect("tempdir");
163        let err = run_jj(tmp.path(), &["status"]).expect_err("jj not installed");
164        assert!(err.is_spawn_failure());
165    }
166
167    #[test]
168    fn git_merge_base_returns_none_for_unrelated() {
169        if !git_installed() {
170            return;
171        }
172        let tmp = tempfile::tempdir().expect("tempdir");
173        std::process::Command::new("git")
174            .args(["init", "--quiet"])
175            .current_dir(tmp.path())
176            .status()
177            .expect("git init");
178        // Without commits, git merge-base fails with non-zero; accept that shape.
179        let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
180    }
181}