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