1use std::path::Path;
9use std::time::Duration;
10
11use procpilot::{Cmd, RetryPolicy, RunError, RunOutput};
12
13pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
15 Cmd::new("jj").in_dir(repo_path).args(args).run()
16}
17
18pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
20 Cmd::new("git").in_dir(repo_path).args(args).run()
21}
22
23pub 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
36pub 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
49pub 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
62pub 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
75pub 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
96pub 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
115pub 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 let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
180 }
181}