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_jj_utf8_ignore_wc(repo_path: &Path, args: &[&str]) -> Result<String, RunError> {
44 let mut full = Vec::with_capacity(args.len() + 1);
45 full.push("--ignore-working-copy");
46 full.extend_from_slice(args);
47 run_jj_utf8(repo_path, &full)
48}
49
50pub fn run_git_utf8(repo_path: &Path, args: &[&str]) -> Result<String, RunError> {
52 let out = run_git(repo_path, args)?;
53 Ok(out.stdout_lossy().trim().to_string())
54}
55
56pub fn run_jj_utf8_with_timeout(
58 repo_path: &Path,
59 args: &[&str],
60 timeout: Duration,
61) -> Result<String, RunError> {
62 let out = run_jj_with_timeout(repo_path, args, timeout)?;
63 Ok(out.stdout_lossy().trim().to_string())
64}
65
66pub fn run_git_utf8_with_timeout(
68 repo_path: &Path,
69 args: &[&str],
70 timeout: Duration,
71) -> Result<String, RunError> {
72 let out = run_git_with_timeout(repo_path, args, timeout)?;
73 Ok(out.stdout_lossy().trim().to_string())
74}
75
76pub fn run_jj_utf8_with_retry(
78 repo_path: &Path,
79 args: &[&str],
80 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
81) -> Result<String, RunError> {
82 let out = run_jj_with_retry(repo_path, args, is_transient)?;
83 Ok(out.stdout_lossy().trim().to_string())
84}
85
86pub fn run_git_utf8_with_retry(
88 repo_path: &Path,
89 args: &[&str],
90 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
91) -> Result<String, RunError> {
92 let out = run_git_with_retry(repo_path, args, is_transient)?;
93 Ok(out.stdout_lossy().trim().to_string())
94}
95
96pub fn run_jj_with_timeout(
98 repo_path: &Path,
99 args: &[&str],
100 timeout: Duration,
101) -> Result<RunOutput, RunError> {
102 Cmd::new("jj")
103 .in_dir(repo_path)
104 .args(args)
105 .timeout(timeout)
106 .run()
107}
108
109pub fn run_git_with_timeout(
111 repo_path: &Path,
112 args: &[&str],
113 timeout: Duration,
114) -> Result<RunOutput, RunError> {
115 Cmd::new("git")
116 .in_dir(repo_path)
117 .args(args)
118 .timeout(timeout)
119 .run()
120}
121
122pub fn run_jj_with_retry(
124 repo_path: &Path,
125 args: &[&str],
126 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
127) -> Result<RunOutput, RunError> {
128 Cmd::new("jj")
129 .in_dir(repo_path)
130 .args(args)
131 .retry(RetryPolicy::default().when(is_transient))
132 .run()
133}
134
135pub fn run_git_with_retry(
137 repo_path: &Path,
138 args: &[&str],
139 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
140) -> Result<RunOutput, RunError> {
141 Cmd::new("git")
142 .in_dir(repo_path)
143 .args(args)
144 .retry(RetryPolicy::default().when(is_transient))
145 .run()
146}
147
148pub fn run_jj_cancellable(
155 repo_path: &Path,
156 args: &[&str],
157 cancel: Arc<AtomicBool>,
158) -> Result<RunOutput, RunError> {
159 Cmd::new("jj")
160 .in_dir(repo_path)
161 .args(args)
162 .cancel(cancel)
163 .run()
164}
165
166pub fn run_git_cancellable(
169 repo_path: &Path,
170 args: &[&str],
171 cancel: Arc<AtomicBool>,
172) -> Result<RunOutput, RunError> {
173 Cmd::new("git")
174 .in_dir(repo_path)
175 .args(args)
176 .cancel(cancel)
177 .run()
178}
179
180pub fn run_jj_utf8_cancellable(
182 repo_path: &Path,
183 args: &[&str],
184 cancel: Arc<AtomicBool>,
185) -> Result<String, RunError> {
186 let out = run_jj_cancellable(repo_path, args, cancel)?;
187 Ok(out.stdout_lossy().trim().to_string())
188}
189
190pub fn run_git_utf8_cancellable(
192 repo_path: &Path,
193 args: &[&str],
194 cancel: Arc<AtomicBool>,
195) -> Result<String, RunError> {
196 let out = run_git_cancellable(repo_path, args, cancel)?;
197 Ok(out.stdout_lossy().trim().to_string())
198}
199
200pub fn run_jj_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("jj")
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_git_with_retry_cancellable(
222 repo_path: &Path,
223 args: &[&str],
224 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
225 cancel: Arc<AtomicBool>,
226) -> Result<RunOutput, RunError> {
227 Cmd::new("git")
228 .in_dir(repo_path)
229 .args(args)
230 .retry(RetryPolicy::default().when(is_transient))
231 .cancel(cancel)
232 .run()
233}
234
235pub fn run_jj_utf8_with_retry_cancellable(
237 repo_path: &Path,
238 args: &[&str],
239 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
240 cancel: Arc<AtomicBool>,
241) -> Result<String, RunError> {
242 let out = run_jj_with_retry_cancellable(repo_path, args, is_transient, cancel)?;
243 Ok(out.stdout_lossy().trim().to_string())
244}
245
246pub fn run_git_utf8_with_retry_cancellable(
248 repo_path: &Path,
249 args: &[&str],
250 is_transient: impl Fn(&RunError) -> bool + Send + Sync + 'static,
251 cancel: Arc<AtomicBool>,
252) -> Result<String, RunError> {
253 let out = run_git_with_retry_cancellable(repo_path, args, is_transient, cancel)?;
254 Ok(out.stdout_lossy().trim().to_string())
255}
256
257pub fn jj_merge_base(
263 repo_path: &Path,
264 a: &str,
265 b: &str,
266) -> Result<Option<String>, RunError> {
267 let revset = format!("latest(::({a}) & ::({b}))");
268 let id = run_jj_utf8(
269 repo_path,
270 &[
271 "log", "-r", &revset, "--no-graph", "--limit", "1", "-T", "commit_id",
272 ],
273 )?;
274 Ok(if id.is_empty() { None } else { Some(id) })
275}
276
277pub fn jj_current_operation_id(repo_path: &Path) -> Result<String, RunError> {
293 run_jj_utf8_ignore_wc(repo_path, &["op", "log", "-n1", "--no-graph", "-T", "id"])
297}
298
299pub fn jj_operation_log(
304 repo_path: &Path,
305 limit: usize,
306) -> Result<Vec<crate::JjOperation>, RunError> {
307 const TEMPLATE: &str = r#"id ++ "\t" ++ description.first_line() ++ "\n""#;
308 let limit_str = limit.to_string();
309 let mut args: Vec<&str> = vec!["op", "log", "--no-graph", "-T", TEMPLATE];
310 if limit > 0 {
311 args.splice(2..2, ["-n", limit_str.as_str()]);
313 }
314 let out = run_jj_utf8_ignore_wc(repo_path, &args)?;
316 Ok(out
317 .lines()
318 .filter_map(|line| {
319 line.split_once('\t').map(|(id, desc)| crate::JjOperation {
320 id: id.to_string(),
321 description: desc.to_string(),
322 })
323 })
324 .collect())
325}
326
327pub fn jj_divergent_change_ids(repo_path: &Path) -> Result<Vec<String>, RunError> {
331 let out = run_jj_utf8_ignore_wc(
334 repo_path,
335 &["log", "-r", "divergent()", "--no-graph", "-T", r#"change_id ++ "\n""#],
336 )?;
337 let mut ids: Vec<String> = out.lines().filter(|l| !l.is_empty()).map(str::to_string).collect();
338 ids.sort();
339 ids.dedup();
340 Ok(ids)
341}
342
343pub fn jj_is_divergent_at_operation(repo_path: &Path, op_id: &str) -> Result<bool, RunError> {
347 let out = run_jj_utf8_ignore_wc(
350 repo_path,
351 &[
352 "--at-operation", op_id,
353 "log", "-r", "divergent()", "--no-graph", "-T", r#"change_id ++ "\n""#,
354 ],
355 )?;
356 Ok(out.lines().any(|l| !l.is_empty()))
357}
358
359pub fn jj_op_restore(repo_path: &Path, op_id: &str) -> Result<(), RunError> {
365 run_jj(repo_path, &["op", "restore", op_id])?;
366 Ok(())
367}
368
369pub fn git_merge_base(
374 repo_path: &Path,
375 a: &str,
376 b: &str,
377) -> Result<Option<String>, RunError> {
378 match run_git_utf8(repo_path, &["merge-base", a, b]) {
379 Ok(id) => Ok(if id.is_empty() { None } else { Some(id) }),
380 Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
381 Err(e) => Err(e),
382 }
383}
384
385pub fn is_transient_error(err: &RunError) -> bool {
391 procpilot::default_transient(err)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn jj_installed() -> bool {
399 procpilot::binary_available("jj")
400 }
401
402 fn git_installed() -> bool {
403 procpilot::binary_available("git")
404 }
405
406 #[test]
407 fn is_transient_matches_stale_and_lock() {
408 #[cfg(unix)]
409 let status = {
410 use std::os::unix::process::ExitStatusExt;
411 std::process::ExitStatus::from_raw(256)
412 };
413 #[cfg(windows)]
414 let status = {
415 use std::os::windows::process::ExitStatusExt;
416 std::process::ExitStatus::from_raw(1)
417 };
418 let err = RunError::NonZeroExit {
419 command: Cmd::new("jj").display(),
420 status,
421 stdout: vec![],
422 stderr: "The working copy is stale".into(),
423 attempts: 1,
424 };
425 assert!(is_transient_error(&err));
426 }
427
428 #[test]
429 fn run_jj_fails_gracefully_when_not_installed() {
430 if jj_installed() {
431 return;
432 }
433 let tmp = tempfile::tempdir().expect("tempdir");
434 let err = run_jj(tmp.path(), &["status"]).expect_err("jj not installed");
435 assert!(err.is_spawn_failure());
436 }
437
438 #[test]
439 fn run_jj_cancellable_short_circuits_on_preset_flag() {
440 let tmp = tempfile::tempdir().expect("tempdir");
441 let cancel = Arc::new(AtomicBool::new(true));
442 let err = run_jj_cancellable(tmp.path(), &["status"], cancel)
443 .expect_err("preset cancel flag must return error");
444 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
445 }
446
447 #[test]
448 fn run_git_cancellable_short_circuits_on_preset_flag() {
449 let tmp = tempfile::tempdir().expect("tempdir");
450 let cancel = Arc::new(AtomicBool::new(true));
451 let err = run_git_cancellable(tmp.path(), &["status"], cancel)
452 .expect_err("preset cancel flag must return error");
453 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
454 }
455
456 #[test]
457 fn run_jj_with_retry_cancellable_short_circuits_on_preset_flag() {
458 let tmp = tempfile::tempdir().expect("tempdir");
459 let cancel = Arc::new(AtomicBool::new(true));
460 let err = run_jj_with_retry_cancellable(
461 tmp.path(),
462 &["status"],
463 is_transient_error,
464 cancel,
465 )
466 .expect_err("preset cancel flag must return error before retry");
467 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
468 }
469
470 #[test]
471 fn run_jj_utf8_cancellable_short_circuits_on_preset_flag() {
472 let tmp = tempfile::tempdir().expect("tempdir");
473 let cancel = Arc::new(AtomicBool::new(true));
474 let err = run_jj_utf8_cancellable(tmp.path(), &["status"], cancel)
475 .expect_err("preset cancel flag must return error");
476 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
477 }
478
479 #[test]
480 fn run_git_utf8_cancellable_short_circuits_on_preset_flag() {
481 let tmp = tempfile::tempdir().expect("tempdir");
482 let cancel = Arc::new(AtomicBool::new(true));
483 let err = run_git_utf8_cancellable(tmp.path(), &["status"], cancel)
484 .expect_err("preset cancel flag must return error");
485 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
486 }
487
488 #[test]
489 fn run_git_with_retry_cancellable_short_circuits_on_preset_flag() {
490 let tmp = tempfile::tempdir().expect("tempdir");
491 let cancel = Arc::new(AtomicBool::new(true));
492 let err = run_git_with_retry_cancellable(
493 tmp.path(),
494 &["status"],
495 is_transient_error,
496 cancel,
497 )
498 .expect_err("preset cancel flag must return error before retry");
499 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
500 }
501
502 #[test]
503 fn run_jj_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
504 let tmp = tempfile::tempdir().expect("tempdir");
505 let cancel = Arc::new(AtomicBool::new(true));
506 let err = run_jj_utf8_with_retry_cancellable(
507 tmp.path(),
508 &["status"],
509 is_transient_error,
510 cancel,
511 )
512 .expect_err("preset cancel flag must return error before retry");
513 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
514 }
515
516 #[test]
517 fn run_git_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
518 let tmp = tempfile::tempdir().expect("tempdir");
519 let cancel = Arc::new(AtomicBool::new(true));
520 let err = run_git_utf8_with_retry_cancellable(
521 tmp.path(),
522 &["status"],
523 is_transient_error,
524 cancel,
525 )
526 .expect_err("preset cancel flag must return error before retry");
527 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
528 }
529
530 #[test]
531 fn is_transient_does_not_retry_cancelled() {
532 let err = RunError::Cancelled {
535 command: Cmd::new("jj").display(),
536 stdout: vec![],
537 stderr: String::new(),
538 attempts: 1,
539 };
540 assert!(!is_transient_error(&err));
541 }
542
543 #[test]
544 fn git_merge_base_returns_none_for_unrelated() {
545 if !git_installed() {
546 return;
547 }
548 let tmp = tempfile::tempdir().expect("tempdir");
549 std::process::Command::new("git")
550 .args(["init", "--quiet"])
551 .current_dir(tmp.path())
552 .status()
553 .expect("git init");
554 let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
556 }
557
558 struct TestRepo {
564 dir: tempfile::TempDir,
565 }
566 impl TestRepo {
567 fn new(colocate: bool) -> Self {
568 let dir = tempfile::tempdir().expect("tempdir");
569 let mut args = vec!["git", "init"];
570 if colocate {
571 args.push("--colocate");
572 }
573 let ok = std::process::Command::new("jj")
574 .args(&args)
575 .current_dir(dir.path())
576 .output()
577 .expect("jj git init")
578 .status
579 .success();
580 assert!(ok, "jj git init failed");
581 Self { dir }
582 }
583 fn path(&self) -> &Path {
584 self.dir.path()
585 }
586 fn jj(&self, args: &[&str]) -> std::process::Output {
587 std::process::Command::new("jj")
588 .args(["--config=user.name=T", "--config=user.email=t@e.com"])
589 .args(args)
590 .current_dir(self.path())
591 .output()
592 .expect("jj command")
593 }
594 fn git(&self, args: &[&str]) -> std::process::Output {
595 std::process::Command::new("git")
596 .args(args)
597 .current_dir(self.path())
598 .output()
599 .expect("git command")
600 }
601 fn git_out(&self, args: &[&str]) -> String {
602 String::from_utf8_lossy(&self.git(args).stdout).trim().to_string()
603 }
604 fn seed_two_commits(&self) {
606 std::fs::write(self.path().join("f.txt"), "a\n").unwrap();
607 self.jj(&["describe", "-m", "A"]);
608 self.jj(&["new", "-m", "B"]);
609 }
610 fn force_divergence(&self) {
612 let out = self.jj(&["op", "log", "--no-graph", "-T", "id ++ \"\\n\""]);
613 let ops = String::from_utf8_lossy(&out.stdout);
614 let earlier = ops.lines().nth(2).expect("older op").to_string();
615 self.jj(&["--at-operation", &earlier, "describe", "-m", "FORKED"]);
616 self.jj(&["status"]); }
618 }
619
620 #[test]
621 fn operation_log_detection_and_op_restore_recovery() {
622 if !jj_installed() {
623 return;
624 }
625 let repo = TestRepo::new(false);
626 repo.seed_two_commits();
627 let path = repo.path();
628
629 let good = jj_current_operation_id(path).expect("current op id");
630 assert!(!good.is_empty());
631 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "clean to start");
632
633 repo.force_divergence();
634
635 assert!(!jj_divergent_change_ids(path).unwrap().is_empty(), "divergence detected");
637 let ops = jj_operation_log(path, 0).unwrap();
638 assert!(
639 ops.iter().any(|o| o.description.contains("reconcile divergent operations")),
640 "reconcile op should appear; got {ops:?}"
641 );
642
643 jj_op_restore(path, &good).unwrap();
645 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "restore should clear divergence");
646 }
647
648 #[test]
649 fn operation_log_respects_limit() {
650 if !jj_installed() {
651 return;
652 }
653 let repo = TestRepo::new(false);
654 repo.seed_two_commits();
655 let all = jj_operation_log(repo.path(), 0).unwrap();
656 let two = jj_operation_log(repo.path(), 2).unwrap();
657 assert!(all.len() > 2, "seeded repo should have several ops");
658 assert_eq!(two.len(), 2, "limit should cap the returned ops");
659 assert_eq!(two[0], all[0], "same newest-first ordering");
660 }
661
662 #[test]
663 fn is_divergent_at_operation_distinguishes_clean_from_divergent() {
664 if !jj_installed() {
665 return;
666 }
667 let repo = TestRepo::new(false);
668 repo.seed_two_commits();
669 let good = jj_current_operation_id(repo.path()).unwrap();
670 repo.force_divergence();
671
672 let head = &jj_operation_log(repo.path(), 1).unwrap()[0].id;
673 assert!(jj_is_divergent_at_operation(repo.path(), head).unwrap(), "head op is divergent");
674 assert!(!jj_is_divergent_at_operation(repo.path(), &good).unwrap(), "good op is clean");
675 }
676
677 #[test]
678 fn divergent_change_ids_dedups_to_distinct_changes() {
679 if !jj_installed() {
680 return;
681 }
682 let repo = TestRepo::new(false);
683 repo.seed_two_commits();
684 repo.force_divergence();
685 assert_eq!(jj_divergent_change_ids(repo.path()).unwrap().len(), 1);
687 }
688
689 #[test]
690 fn op_log_reads_do_not_snapshot_the_working_copy() {
691 if !jj_installed() {
692 return;
693 }
694 let repo = TestRepo::new(false);
695 repo.seed_two_commits();
696 let path = repo.path();
697 std::fs::write(path.join("f.txt"), "a\nwip\n").unwrap();
699 let wc_commit = |repo: &TestRepo| {
700 String::from_utf8_lossy(
701 &repo
702 .jj(&["--ignore-working-copy", "log", "-r", "@", "--no-graph", "-T", "commit_id"])
703 .stdout,
704 )
705 .trim()
706 .to_string()
707 };
708 let before = wc_commit(&repo);
709
710 let _ = jj_current_operation_id(path).unwrap();
713 let _ = jj_operation_log(path, 5).unwrap();
714 let _ = jj_divergent_change_ids(path).unwrap();
715
716 assert_eq!(before, wc_commit(&repo), "op-log/divergence reads must not snapshot the working copy");
717 }
718
719 fn assert_colocated_consistent(repo: &TestRepo, bookmark: &str) {
726 let jj_commit = String::from_utf8_lossy(
727 &repo.jj(&["log", "-r", bookmark, "--no-graph", "-T", "commit_id"]).stdout,
728 )
729 .trim()
730 .to_string();
731 assert_eq!(
732 jj_commit,
733 repo.git_out(&["rev-parse", bookmark]),
734 "git ref and jj bookmark for '{bookmark}' must agree"
735 );
736 }
737 fn assert_git_healthy(repo: &TestRepo) {
738 let fsck = repo.git(&["fsck", "--no-dangling"]);
739 assert!(fsck.status.success(), "git fsck: {}", String::from_utf8_lossy(&fsck.stderr));
740 assert!(repo.git(&["status"]).status.success(), "git status must work");
741 }
742
743 #[test]
744 fn colocation_consistent_for_a_plain_bookmark() {
745 if !jj_installed() || !git_installed() {
746 return;
747 }
748 let repo = TestRepo::new(true);
749 assert!(repo.path().join(".git").exists() && repo.path().join(".jj").exists());
750 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
751 repo.jj(&["describe", "-m", "A"]);
752 repo.jj(&["bookmark", "create", "feat", "-r", "@"]);
753 repo.jj(&["new", "-m", "B"]);
754
755 let good = jj_current_operation_id(repo.path()).unwrap();
756 assert_colocated_consistent(&repo, "feat");
757 repo.force_divergence();
758 jj_op_restore(repo.path(), &good).unwrap();
759
760 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
761 assert_colocated_consistent(&repo, "feat");
762 assert_git_healthy(&repo);
763 }
764
765 #[test]
766 fn colocation_consistent_when_bookmark_moved() {
767 if !jj_installed() || !git_installed() {
768 return;
769 }
770 let repo = TestRepo::new(true);
771 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
772 repo.jj(&["describe", "-m", "A"]);
773 repo.jj(&["bookmark", "create", "feat", "-r", "@"]); repo.jj(&["new", "-m", "B"]);
775 repo.jj(&["new", "-m", "C"]); repo.jj(&["bookmark", "set", "feat", "-r", "@-"]);
778 assert_colocated_consistent(&repo, "feat");
779
780 let good = jj_current_operation_id(repo.path()).unwrap();
781 repo.force_divergence();
782 jj_op_restore(repo.path(), &good).unwrap();
783
784 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
785 assert_colocated_consistent(&repo, "feat");
786 assert_git_healthy(&repo);
787 }
788
789 #[test]
790 fn colocation_consistent_for_a_stack() {
791 if !jj_installed() || !git_installed() {
792 return;
793 }
794 let repo = TestRepo::new(true);
795 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
796 repo.jj(&["describe", "-m", "A"]);
797 repo.jj(&["bookmark", "create", "bottom", "-r", "@"]);
798 repo.jj(&["new", "-m", "B"]);
799 repo.jj(&["bookmark", "create", "top", "-r", "@"]);
800 repo.jj(&["new", "-m", "C"]);
801
802 let good = jj_current_operation_id(repo.path()).unwrap();
803 assert_colocated_consistent(&repo, "bottom");
804 assert_colocated_consistent(&repo, "top");
805 repo.force_divergence();
806 jj_op_restore(repo.path(), &good).unwrap();
807
808 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
809 assert_colocated_consistent(&repo, "bottom");
810 assert_colocated_consistent(&repo, "top");
811 assert_git_healthy(&repo);
812 }
813}