1use std::path::Path;
9use std::sync::Arc;
10use std::sync::atomic::AtomicBool;
11use std::time::Duration;
12
13use procpilot::{Cmd, RetryPolicy, RunError, RunOutput};
14
15pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
17 Cmd::new("jj").in_dir(repo_path).args(args).run()
18}
19
20pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
22 Cmd::new("git").in_dir(repo_path).args(args).run()
23}
24
25pub 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
34pub 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
40pub 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
50pub 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
60pub 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
70pub 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
80pub 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
93pub 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
106pub 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
119pub 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
132pub 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
150pub 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
164pub 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
174pub 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
184pub 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
203pub 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
219pub 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
230pub 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
241pub 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
261pub 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
277pub 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 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 let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
448 }
449}