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 jj_current_operation_id(repo_path: &Path) -> Result<String, RunError> {
277 run_jj_utf8(repo_path, &["op", "log", "-n1", "--no-graph", "-T", "id"])
279}
280
281pub fn jj_operation_log(
286 repo_path: &Path,
287 limit: usize,
288) -> Result<Vec<crate::JjOperation>, RunError> {
289 const TEMPLATE: &str = r#"id ++ "\t" ++ description.first_line() ++ "\n""#;
290 let limit_str = limit.to_string();
291 let mut args: Vec<&str> = vec!["op", "log", "--no-graph", "-T", TEMPLATE];
292 if limit > 0 {
293 args.splice(2..2, ["-n", limit_str.as_str()]);
295 }
296 let out = run_jj_utf8(repo_path, &args)?;
297 Ok(out
298 .lines()
299 .filter_map(|line| {
300 line.split_once('\t').map(|(id, desc)| crate::JjOperation {
301 id: id.to_string(),
302 description: desc.to_string(),
303 })
304 })
305 .collect())
306}
307
308pub fn jj_divergent_change_ids(repo_path: &Path) -> Result<Vec<String>, RunError> {
312 let out = run_jj_utf8(
313 repo_path,
314 &["log", "-r", "divergent()", "--no-graph", "-T", r#"change_id ++ "\n""#],
315 )?;
316 let mut ids: Vec<String> = out.lines().filter(|l| !l.is_empty()).map(str::to_string).collect();
317 ids.sort();
318 ids.dedup();
319 Ok(ids)
320}
321
322pub fn jj_is_divergent_at_operation(repo_path: &Path, op_id: &str) -> Result<bool, RunError> {
326 let out = run_jj_utf8(
328 repo_path,
329 &[
330 "--at-operation", op_id,
331 "log", "-r", "divergent()", "--no-graph", "-T", r#"change_id ++ "\n""#,
332 ],
333 )?;
334 Ok(out.lines().any(|l| !l.is_empty()))
335}
336
337pub fn jj_op_restore(repo_path: &Path, op_id: &str) -> Result<(), RunError> {
343 run_jj(repo_path, &["op", "restore", op_id])?;
344 Ok(())
345}
346
347pub fn git_merge_base(
352 repo_path: &Path,
353 a: &str,
354 b: &str,
355) -> Result<Option<String>, RunError> {
356 match run_git_utf8(repo_path, &["merge-base", a, b]) {
357 Ok(id) => Ok(if id.is_empty() { None } else { Some(id) }),
358 Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
359 Err(e) => Err(e),
360 }
361}
362
363pub fn is_transient_error(err: &RunError) -> bool {
369 procpilot::default_transient(err)
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn jj_installed() -> bool {
377 procpilot::binary_available("jj")
378 }
379
380 fn git_installed() -> bool {
381 procpilot::binary_available("git")
382 }
383
384 #[test]
385 fn is_transient_matches_stale_and_lock() {
386 #[cfg(unix)]
387 let status = {
388 use std::os::unix::process::ExitStatusExt;
389 std::process::ExitStatus::from_raw(256)
390 };
391 #[cfg(windows)]
392 let status = {
393 use std::os::windows::process::ExitStatusExt;
394 std::process::ExitStatus::from_raw(1)
395 };
396 let err = RunError::NonZeroExit {
397 command: Cmd::new("jj").display(),
398 status,
399 stdout: vec![],
400 stderr: "The working copy is stale".into(),
401 attempts: 1,
402 };
403 assert!(is_transient_error(&err));
404 }
405
406 #[test]
407 fn run_jj_fails_gracefully_when_not_installed() {
408 if jj_installed() {
409 return;
410 }
411 let tmp = tempfile::tempdir().expect("tempdir");
412 let err = run_jj(tmp.path(), &["status"]).expect_err("jj not installed");
413 assert!(err.is_spawn_failure());
414 }
415
416 #[test]
417 fn run_jj_cancellable_short_circuits_on_preset_flag() {
418 let tmp = tempfile::tempdir().expect("tempdir");
419 let cancel = Arc::new(AtomicBool::new(true));
420 let err = run_jj_cancellable(tmp.path(), &["status"], cancel)
421 .expect_err("preset cancel flag must return error");
422 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
423 }
424
425 #[test]
426 fn run_git_cancellable_short_circuits_on_preset_flag() {
427 let tmp = tempfile::tempdir().expect("tempdir");
428 let cancel = Arc::new(AtomicBool::new(true));
429 let err = run_git_cancellable(tmp.path(), &["status"], cancel)
430 .expect_err("preset cancel flag must return error");
431 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
432 }
433
434 #[test]
435 fn run_jj_with_retry_cancellable_short_circuits_on_preset_flag() {
436 let tmp = tempfile::tempdir().expect("tempdir");
437 let cancel = Arc::new(AtomicBool::new(true));
438 let err = run_jj_with_retry_cancellable(
439 tmp.path(),
440 &["status"],
441 is_transient_error,
442 cancel,
443 )
444 .expect_err("preset cancel flag must return error before retry");
445 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
446 }
447
448 #[test]
449 fn run_jj_utf8_cancellable_short_circuits_on_preset_flag() {
450 let tmp = tempfile::tempdir().expect("tempdir");
451 let cancel = Arc::new(AtomicBool::new(true));
452 let err = run_jj_utf8_cancellable(tmp.path(), &["status"], cancel)
453 .expect_err("preset cancel flag must return error");
454 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
455 }
456
457 #[test]
458 fn run_git_utf8_cancellable_short_circuits_on_preset_flag() {
459 let tmp = tempfile::tempdir().expect("tempdir");
460 let cancel = Arc::new(AtomicBool::new(true));
461 let err = run_git_utf8_cancellable(tmp.path(), &["status"], cancel)
462 .expect_err("preset cancel flag must return error");
463 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
464 }
465
466 #[test]
467 fn run_git_with_retry_cancellable_short_circuits_on_preset_flag() {
468 let tmp = tempfile::tempdir().expect("tempdir");
469 let cancel = Arc::new(AtomicBool::new(true));
470 let err = run_git_with_retry_cancellable(
471 tmp.path(),
472 &["status"],
473 is_transient_error,
474 cancel,
475 )
476 .expect_err("preset cancel flag must return error before retry");
477 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
478 }
479
480 #[test]
481 fn run_jj_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
482 let tmp = tempfile::tempdir().expect("tempdir");
483 let cancel = Arc::new(AtomicBool::new(true));
484 let err = run_jj_utf8_with_retry_cancellable(
485 tmp.path(),
486 &["status"],
487 is_transient_error,
488 cancel,
489 )
490 .expect_err("preset cancel flag must return error before retry");
491 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
492 }
493
494 #[test]
495 fn run_git_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
496 let tmp = tempfile::tempdir().expect("tempdir");
497 let cancel = Arc::new(AtomicBool::new(true));
498 let err = run_git_utf8_with_retry_cancellable(
499 tmp.path(),
500 &["status"],
501 is_transient_error,
502 cancel,
503 )
504 .expect_err("preset cancel flag must return error before retry");
505 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
506 }
507
508 #[test]
509 fn is_transient_does_not_retry_cancelled() {
510 let err = RunError::Cancelled {
513 command: Cmd::new("jj").display(),
514 stdout: vec![],
515 stderr: String::new(),
516 attempts: 1,
517 };
518 assert!(!is_transient_error(&err));
519 }
520
521 #[test]
522 fn git_merge_base_returns_none_for_unrelated() {
523 if !git_installed() {
524 return;
525 }
526 let tmp = tempfile::tempdir().expect("tempdir");
527 std::process::Command::new("git")
528 .args(["init", "--quiet"])
529 .current_dir(tmp.path())
530 .status()
531 .expect("git init");
532 let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
534 }
535
536 struct TestRepo {
542 dir: tempfile::TempDir,
543 }
544 impl TestRepo {
545 fn new(colocate: bool) -> Self {
546 let dir = tempfile::tempdir().expect("tempdir");
547 let mut args = vec!["git", "init"];
548 if colocate {
549 args.push("--colocate");
550 }
551 let ok = std::process::Command::new("jj")
552 .args(&args)
553 .current_dir(dir.path())
554 .output()
555 .expect("jj git init")
556 .status
557 .success();
558 assert!(ok, "jj git init failed");
559 Self { dir }
560 }
561 fn path(&self) -> &Path {
562 self.dir.path()
563 }
564 fn jj(&self, args: &[&str]) -> std::process::Output {
565 std::process::Command::new("jj")
566 .args(["--config=user.name=T", "--config=user.email=t@e.com"])
567 .args(args)
568 .current_dir(self.path())
569 .output()
570 .expect("jj command")
571 }
572 fn git(&self, args: &[&str]) -> std::process::Output {
573 std::process::Command::new("git")
574 .args(args)
575 .current_dir(self.path())
576 .output()
577 .expect("git command")
578 }
579 fn git_out(&self, args: &[&str]) -> String {
580 String::from_utf8_lossy(&self.git(args).stdout).trim().to_string()
581 }
582 fn seed_two_commits(&self) {
584 std::fs::write(self.path().join("f.txt"), "a\n").unwrap();
585 self.jj(&["describe", "-m", "A"]);
586 self.jj(&["new", "-m", "B"]);
587 }
588 fn force_divergence(&self) {
590 let out = self.jj(&["op", "log", "--no-graph", "-T", "id ++ \"\\n\""]);
591 let ops = String::from_utf8_lossy(&out.stdout);
592 let earlier = ops.lines().nth(2).expect("older op").to_string();
593 self.jj(&["--at-operation", &earlier, "describe", "-m", "FORKED"]);
594 self.jj(&["status"]); }
596 }
597
598 #[test]
599 fn operation_log_detection_and_op_restore_recovery() {
600 if !jj_installed() {
601 return;
602 }
603 let repo = TestRepo::new(false);
604 repo.seed_two_commits();
605 let path = repo.path();
606
607 let good = jj_current_operation_id(path).expect("current op id");
608 assert!(!good.is_empty());
609 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "clean to start");
610
611 repo.force_divergence();
612
613 assert!(!jj_divergent_change_ids(path).unwrap().is_empty(), "divergence detected");
615 let ops = jj_operation_log(path, 0).unwrap();
616 assert!(
617 ops.iter().any(|o| o.description.contains("reconcile divergent operations")),
618 "reconcile op should appear; got {ops:?}"
619 );
620
621 jj_op_restore(path, &good).unwrap();
623 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "restore should clear divergence");
624 }
625
626 #[test]
627 fn operation_log_respects_limit() {
628 if !jj_installed() {
629 return;
630 }
631 let repo = TestRepo::new(false);
632 repo.seed_two_commits();
633 let all = jj_operation_log(repo.path(), 0).unwrap();
634 let two = jj_operation_log(repo.path(), 2).unwrap();
635 assert!(all.len() > 2, "seeded repo should have several ops");
636 assert_eq!(two.len(), 2, "limit should cap the returned ops");
637 assert_eq!(two[0], all[0], "same newest-first ordering");
638 }
639
640 #[test]
641 fn is_divergent_at_operation_distinguishes_clean_from_divergent() {
642 if !jj_installed() {
643 return;
644 }
645 let repo = TestRepo::new(false);
646 repo.seed_two_commits();
647 let good = jj_current_operation_id(repo.path()).unwrap();
648 repo.force_divergence();
649
650 let head = &jj_operation_log(repo.path(), 1).unwrap()[0].id;
651 assert!(jj_is_divergent_at_operation(repo.path(), head).unwrap(), "head op is divergent");
652 assert!(!jj_is_divergent_at_operation(repo.path(), &good).unwrap(), "good op is clean");
653 }
654
655 #[test]
656 fn divergent_change_ids_dedups_to_distinct_changes() {
657 if !jj_installed() {
658 return;
659 }
660 let repo = TestRepo::new(false);
661 repo.seed_two_commits();
662 repo.force_divergence();
663 assert_eq!(jj_divergent_change_ids(repo.path()).unwrap().len(), 1);
665 }
666
667 fn assert_colocated_consistent(repo: &TestRepo, bookmark: &str) {
674 let jj_commit = String::from_utf8_lossy(
675 &repo.jj(&["log", "-r", bookmark, "--no-graph", "-T", "commit_id"]).stdout,
676 )
677 .trim()
678 .to_string();
679 assert_eq!(
680 jj_commit,
681 repo.git_out(&["rev-parse", bookmark]),
682 "git ref and jj bookmark for '{bookmark}' must agree"
683 );
684 }
685 fn assert_git_healthy(repo: &TestRepo) {
686 let fsck = repo.git(&["fsck", "--no-dangling"]);
687 assert!(fsck.status.success(), "git fsck: {}", String::from_utf8_lossy(&fsck.stderr));
688 assert!(repo.git(&["status"]).status.success(), "git status must work");
689 }
690
691 #[test]
692 fn colocation_consistent_for_a_plain_bookmark() {
693 if !jj_installed() || !git_installed() {
694 return;
695 }
696 let repo = TestRepo::new(true);
697 assert!(repo.path().join(".git").exists() && repo.path().join(".jj").exists());
698 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
699 repo.jj(&["describe", "-m", "A"]);
700 repo.jj(&["bookmark", "create", "feat", "-r", "@"]);
701 repo.jj(&["new", "-m", "B"]);
702
703 let good = jj_current_operation_id(repo.path()).unwrap();
704 assert_colocated_consistent(&repo, "feat");
705 repo.force_divergence();
706 jj_op_restore(repo.path(), &good).unwrap();
707
708 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
709 assert_colocated_consistent(&repo, "feat");
710 assert_git_healthy(&repo);
711 }
712
713 #[test]
714 fn colocation_consistent_when_bookmark_moved() {
715 if !jj_installed() || !git_installed() {
716 return;
717 }
718 let repo = TestRepo::new(true);
719 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
720 repo.jj(&["describe", "-m", "A"]);
721 repo.jj(&["bookmark", "create", "feat", "-r", "@"]); repo.jj(&["new", "-m", "B"]);
723 repo.jj(&["new", "-m", "C"]); repo.jj(&["bookmark", "set", "feat", "-r", "@-"]);
726 assert_colocated_consistent(&repo, "feat");
727
728 let good = jj_current_operation_id(repo.path()).unwrap();
729 repo.force_divergence();
730 jj_op_restore(repo.path(), &good).unwrap();
731
732 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
733 assert_colocated_consistent(&repo, "feat");
734 assert_git_healthy(&repo);
735 }
736
737 #[test]
738 fn colocation_consistent_for_a_stack() {
739 if !jj_installed() || !git_installed() {
740 return;
741 }
742 let repo = TestRepo::new(true);
743 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
744 repo.jj(&["describe", "-m", "A"]);
745 repo.jj(&["bookmark", "create", "bottom", "-r", "@"]);
746 repo.jj(&["new", "-m", "B"]);
747 repo.jj(&["bookmark", "create", "top", "-r", "@"]);
748 repo.jj(&["new", "-m", "C"]);
749
750 let good = jj_current_operation_id(repo.path()).unwrap();
751 assert_colocated_consistent(&repo, "bottom");
752 assert_colocated_consistent(&repo, "top");
753 repo.force_divergence();
754 jj_op_restore(repo.path(), &good).unwrap();
755
756 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
757 assert_colocated_consistent(&repo, "bottom");
758 assert_colocated_consistent(&repo, "top");
759 assert_git_healthy(&repo);
760 }
761}