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_revset_at_operation(
370 repo_path: &Path,
371 revset: &str,
372 op_id: &str,
373) -> Result<Vec<String>, RunError> {
374 let wrapped = format!("present({revset})");
376 let out = run_jj_utf8_ignore_wc(
377 repo_path,
378 &[
379 "--at-operation", op_id,
380 "log", "-r", &wrapped, "--no-graph", "-T", r#"commit_id ++ "\n""#,
381 ],
382 )?;
383 Ok(out.lines().filter(|l| !l.is_empty()).map(str::to_string).collect())
384}
385
386pub fn jj_revset_history(
405 repo_path: &Path,
406 revset: &str,
407 limit: usize,
408) -> Result<Vec<String>, RunError> {
409 let ops = jj_operation_log(repo_path, limit)?;
410 let mut seen = std::collections::HashSet::new();
411 let mut history = Vec::new();
412 for op in &ops {
413 let at = jj_revset_at_operation(repo_path, revset, &op.id)?;
414 if at.is_empty() {
415 break; }
417 for cid in at {
418 if seen.insert(cid.clone()) {
419 history.push(cid);
420 }
421 }
422 }
423 Ok(history)
424}
425
426pub fn jj_op_restore(repo_path: &Path, op_id: &str) -> Result<(), RunError> {
432 run_jj(repo_path, &["op", "restore", op_id])?;
433 Ok(())
434}
435
436pub fn git_merge_base(
441 repo_path: &Path,
442 a: &str,
443 b: &str,
444) -> Result<Option<String>, RunError> {
445 match run_git_utf8(repo_path, &["merge-base", a, b]) {
446 Ok(id) => Ok(if id.is_empty() { None } else { Some(id) }),
447 Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
448 Err(e) => Err(e),
449 }
450}
451
452pub fn is_transient_error(err: &RunError) -> bool {
458 procpilot::default_transient(err)
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 fn jj_installed() -> bool {
466 procpilot::binary_available("jj")
467 }
468
469 fn git_installed() -> bool {
470 procpilot::binary_available("git")
471 }
472
473 #[test]
474 fn is_transient_matches_stale_and_lock() {
475 #[cfg(unix)]
476 let status = {
477 use std::os::unix::process::ExitStatusExt;
478 std::process::ExitStatus::from_raw(256)
479 };
480 #[cfg(windows)]
481 let status = {
482 use std::os::windows::process::ExitStatusExt;
483 std::process::ExitStatus::from_raw(1)
484 };
485 let err = RunError::NonZeroExit {
486 command: Cmd::new("jj").display(),
487 status,
488 stdout: vec![],
489 stderr: "The working copy is stale".into(),
490 attempts: 1,
491 };
492 assert!(is_transient_error(&err));
493 }
494
495 #[test]
496 fn run_jj_fails_gracefully_when_not_installed() {
497 if jj_installed() {
498 return;
499 }
500 let tmp = tempfile::tempdir().expect("tempdir");
501 let err = run_jj(tmp.path(), &["status"]).expect_err("jj not installed");
502 assert!(err.is_spawn_failure());
503 }
504
505 #[test]
506 fn run_jj_cancellable_short_circuits_on_preset_flag() {
507 let tmp = tempfile::tempdir().expect("tempdir");
508 let cancel = Arc::new(AtomicBool::new(true));
509 let err = run_jj_cancellable(tmp.path(), &["status"], cancel)
510 .expect_err("preset cancel flag must return error");
511 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
512 }
513
514 #[test]
515 fn run_git_cancellable_short_circuits_on_preset_flag() {
516 let tmp = tempfile::tempdir().expect("tempdir");
517 let cancel = Arc::new(AtomicBool::new(true));
518 let err = run_git_cancellable(tmp.path(), &["status"], cancel)
519 .expect_err("preset cancel flag must return error");
520 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
521 }
522
523 #[test]
524 fn run_jj_with_retry_cancellable_short_circuits_on_preset_flag() {
525 let tmp = tempfile::tempdir().expect("tempdir");
526 let cancel = Arc::new(AtomicBool::new(true));
527 let err = run_jj_with_retry_cancellable(
528 tmp.path(),
529 &["status"],
530 is_transient_error,
531 cancel,
532 )
533 .expect_err("preset cancel flag must return error before retry");
534 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
535 }
536
537 #[test]
538 fn run_jj_utf8_cancellable_short_circuits_on_preset_flag() {
539 let tmp = tempfile::tempdir().expect("tempdir");
540 let cancel = Arc::new(AtomicBool::new(true));
541 let err = run_jj_utf8_cancellable(tmp.path(), &["status"], cancel)
542 .expect_err("preset cancel flag must return error");
543 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
544 }
545
546 #[test]
547 fn run_git_utf8_cancellable_short_circuits_on_preset_flag() {
548 let tmp = tempfile::tempdir().expect("tempdir");
549 let cancel = Arc::new(AtomicBool::new(true));
550 let err = run_git_utf8_cancellable(tmp.path(), &["status"], cancel)
551 .expect_err("preset cancel flag must return error");
552 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
553 }
554
555 #[test]
556 fn run_git_with_retry_cancellable_short_circuits_on_preset_flag() {
557 let tmp = tempfile::tempdir().expect("tempdir");
558 let cancel = Arc::new(AtomicBool::new(true));
559 let err = run_git_with_retry_cancellable(
560 tmp.path(),
561 &["status"],
562 is_transient_error,
563 cancel,
564 )
565 .expect_err("preset cancel flag must return error before retry");
566 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
567 }
568
569 #[test]
570 fn run_jj_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
571 let tmp = tempfile::tempdir().expect("tempdir");
572 let cancel = Arc::new(AtomicBool::new(true));
573 let err = run_jj_utf8_with_retry_cancellable(
574 tmp.path(),
575 &["status"],
576 is_transient_error,
577 cancel,
578 )
579 .expect_err("preset cancel flag must return error before retry");
580 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
581 }
582
583 #[test]
584 fn run_git_utf8_with_retry_cancellable_short_circuits_on_preset_flag() {
585 let tmp = tempfile::tempdir().expect("tempdir");
586 let cancel = Arc::new(AtomicBool::new(true));
587 let err = run_git_utf8_with_retry_cancellable(
588 tmp.path(),
589 &["status"],
590 is_transient_error,
591 cancel,
592 )
593 .expect_err("preset cancel flag must return error before retry");
594 assert!(err.is_cancelled(), "expected Cancelled, got: {err:?}");
595 }
596
597 #[test]
598 fn is_transient_does_not_retry_cancelled() {
599 let err = RunError::Cancelled {
602 command: Cmd::new("jj").display(),
603 stdout: vec![],
604 stderr: String::new(),
605 attempts: 1,
606 };
607 assert!(!is_transient_error(&err));
608 }
609
610 #[test]
611 fn git_merge_base_returns_none_for_unrelated() {
612 if !git_installed() {
613 return;
614 }
615 let tmp = tempfile::tempdir().expect("tempdir");
616 std::process::Command::new("git")
617 .args(["init", "--quiet"])
618 .current_dir(tmp.path())
619 .status()
620 .expect("git init");
621 let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
623 }
624
625 struct TestRepo {
631 dir: tempfile::TempDir,
632 }
633 impl TestRepo {
634 fn new(colocate: bool) -> Self {
635 let dir = tempfile::tempdir().expect("tempdir");
636 let mut args = vec!["git", "init"];
637 if colocate {
638 args.push("--colocate");
639 }
640 let ok = std::process::Command::new("jj")
641 .args(&args)
642 .current_dir(dir.path())
643 .output()
644 .expect("jj git init")
645 .status
646 .success();
647 assert!(ok, "jj git init failed");
648 Self { dir }
649 }
650 fn path(&self) -> &Path {
651 self.dir.path()
652 }
653 fn jj(&self, args: &[&str]) -> std::process::Output {
654 std::process::Command::new("jj")
655 .args(["--config=user.name=T", "--config=user.email=t@e.com"])
656 .args(args)
657 .current_dir(self.path())
658 .output()
659 .expect("jj command")
660 }
661 fn git(&self, args: &[&str]) -> std::process::Output {
662 std::process::Command::new("git")
663 .args(args)
664 .current_dir(self.path())
665 .output()
666 .expect("git command")
667 }
668 fn git_out(&self, args: &[&str]) -> String {
669 String::from_utf8_lossy(&self.git(args).stdout).trim().to_string()
670 }
671 fn seed_two_commits(&self) {
673 std::fs::write(self.path().join("f.txt"), "a\n").unwrap();
674 self.jj(&["describe", "-m", "A"]);
675 self.jj(&["new", "-m", "B"]);
676 }
677 fn force_divergence(&self) {
679 let out = self.jj(&["op", "log", "--no-graph", "-T", "id ++ \"\\n\""]);
680 let ops = String::from_utf8_lossy(&out.stdout);
681 let earlier = ops.lines().nth(2).expect("older op").to_string();
682 self.jj(&["--at-operation", &earlier, "describe", "-m", "FORKED"]);
683 self.jj(&["status"]); }
685 }
686
687 #[test]
688 fn operation_log_detection_and_op_restore_recovery() {
689 if !jj_installed() {
690 return;
691 }
692 let repo = TestRepo::new(false);
693 repo.seed_two_commits();
694 let path = repo.path();
695
696 let good = jj_current_operation_id(path).expect("current op id");
697 assert!(!good.is_empty());
698 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "clean to start");
699
700 repo.force_divergence();
701
702 assert!(!jj_divergent_change_ids(path).unwrap().is_empty(), "divergence detected");
704 let ops = jj_operation_log(path, 0).unwrap();
705 assert!(
706 ops.iter().any(|o| o.description.contains("reconcile divergent operations")),
707 "reconcile op should appear; got {ops:?}"
708 );
709
710 jj_op_restore(path, &good).unwrap();
712 assert!(jj_divergent_change_ids(path).unwrap().is_empty(), "restore should clear divergence");
713 }
714
715 #[test]
716 fn operation_log_respects_limit() {
717 if !jj_installed() {
718 return;
719 }
720 let repo = TestRepo::new(false);
721 repo.seed_two_commits();
722 let all = jj_operation_log(repo.path(), 0).unwrap();
723 let two = jj_operation_log(repo.path(), 2).unwrap();
724 assert!(all.len() > 2, "seeded repo should have several ops");
725 assert_eq!(two.len(), 2, "limit should cap the returned ops");
726 assert_eq!(two[0], all[0], "same newest-first ordering");
727 }
728
729 #[test]
730 fn is_divergent_at_operation_distinguishes_clean_from_divergent() {
731 if !jj_installed() {
732 return;
733 }
734 let repo = TestRepo::new(false);
735 repo.seed_two_commits();
736 let good = jj_current_operation_id(repo.path()).unwrap();
737 repo.force_divergence();
738
739 let head = &jj_operation_log(repo.path(), 1).unwrap()[0].id;
740 assert!(jj_is_divergent_at_operation(repo.path(), head).unwrap(), "head op is divergent");
741 assert!(!jj_is_divergent_at_operation(repo.path(), &good).unwrap(), "good op is clean");
742 }
743
744 #[test]
745 fn divergent_change_ids_dedups_to_distinct_changes() {
746 if !jj_installed() {
747 return;
748 }
749 let repo = TestRepo::new(false);
750 repo.seed_two_commits();
751 repo.force_divergence();
752 assert_eq!(jj_divergent_change_ids(repo.path()).unwrap().len(), 1);
754 }
755
756 #[test]
757 fn op_log_reads_do_not_snapshot_the_working_copy() {
758 if !jj_installed() {
759 return;
760 }
761 let repo = TestRepo::new(false);
762 repo.seed_two_commits();
763 let path = repo.path();
764 std::fs::write(path.join("f.txt"), "a\nwip\n").unwrap();
766 let wc_commit = |repo: &TestRepo| {
767 String::from_utf8_lossy(
768 &repo
769 .jj(&["--ignore-working-copy", "log", "-r", "@", "--no-graph", "-T", "commit_id"])
770 .stdout,
771 )
772 .trim()
773 .to_string()
774 };
775 let before = wc_commit(&repo);
776
777 let _ = jj_current_operation_id(path).unwrap();
780 let _ = jj_operation_log(path, 5).unwrap();
781 let _ = jj_divergent_change_ids(path).unwrap();
782
783 assert_eq!(before, wc_commit(&repo), "op-log/divergence reads must not snapshot the working copy");
784 }
785
786 #[test]
787 fn revset_at_operation_wraps_absence_and_stays_wc_agnostic() {
788 if !jj_installed() {
789 return;
790 }
791 let repo = TestRepo::new(false);
792 repo.seed_two_commits();
793 let path = repo.path();
794
795 let before = jj_current_operation_id(path).unwrap();
798 assert!(
799 jj_revset_at_operation(path, "hist", &before).unwrap().is_empty(),
800 "a ref absent at an operation resolves to empty, not an error"
801 );
802
803 std::fs::write(path.join("f.txt"), "a\nwip\n").unwrap();
805 let wc = || {
806 String::from_utf8_lossy(
807 &repo
808 .jj(&["--ignore-working-copy", "log", "-r", "@", "--no-graph", "-T", "commit_id"])
809 .stdout,
810 )
811 .trim()
812 .to_string()
813 };
814 let wc_before = wc();
815 let _ = jj_revset_at_operation(path, "@", &before).unwrap();
816 assert_eq!(wc_before, wc(), "reading a revset at an operation must not snapshot the working copy");
817 }
818
819 #[test]
820 fn revset_history_reflogs_a_bookmarks_moves_and_bounds_at_creation() {
821 if !jj_installed() {
822 return;
823 }
824 let repo = TestRepo::new(false);
825 repo.seed_two_commits();
826 let path = repo.path();
827 let commit = |rev: &str| {
828 String::from_utf8_lossy(
829 &repo
830 .jj(&["--ignore-working-copy", "log", "-r", rev, "--no-graph", "-T", "commit_id", "--limit", "1"])
831 .stdout,
832 )
833 .trim()
834 .to_string()
835 };
836 let a = commit("@-"); let b = commit("@"); repo.jj(&["bookmark", "create", "hist", "-r", &a]);
841 repo.jj(&["bookmark", "set", "hist", "-r", &b]);
842
843 assert_eq!(
846 jj_revset_history(path, "hist", 0).unwrap(),
847 vec![b.clone(), a.clone()],
848 "reflog-order history of the moving bookmark"
849 );
850 }
851
852 fn assert_colocated_consistent(repo: &TestRepo, bookmark: &str) {
859 let jj_commit = String::from_utf8_lossy(
860 &repo.jj(&["log", "-r", bookmark, "--no-graph", "-T", "commit_id"]).stdout,
861 )
862 .trim()
863 .to_string();
864 assert_eq!(
865 jj_commit,
866 repo.git_out(&["rev-parse", bookmark]),
867 "git ref and jj bookmark for '{bookmark}' must agree"
868 );
869 }
870 fn assert_git_healthy(repo: &TestRepo) {
871 let fsck = repo.git(&["fsck", "--no-dangling"]);
872 assert!(fsck.status.success(), "git fsck: {}", String::from_utf8_lossy(&fsck.stderr));
873 assert!(repo.git(&["status"]).status.success(), "git status must work");
874 }
875
876 #[test]
877 fn colocation_consistent_for_a_plain_bookmark() {
878 if !jj_installed() || !git_installed() {
879 return;
880 }
881 let repo = TestRepo::new(true);
882 assert!(repo.path().join(".git").exists() && repo.path().join(".jj").exists());
883 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
884 repo.jj(&["describe", "-m", "A"]);
885 repo.jj(&["bookmark", "create", "feat", "-r", "@"]);
886 repo.jj(&["new", "-m", "B"]);
887
888 let good = jj_current_operation_id(repo.path()).unwrap();
889 assert_colocated_consistent(&repo, "feat");
890 repo.force_divergence();
891 jj_op_restore(repo.path(), &good).unwrap();
892
893 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
894 assert_colocated_consistent(&repo, "feat");
895 assert_git_healthy(&repo);
896 }
897
898 #[test]
899 fn colocation_consistent_when_bookmark_moved() {
900 if !jj_installed() || !git_installed() {
901 return;
902 }
903 let repo = TestRepo::new(true);
904 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
905 repo.jj(&["describe", "-m", "A"]);
906 repo.jj(&["bookmark", "create", "feat", "-r", "@"]); repo.jj(&["new", "-m", "B"]);
908 repo.jj(&["new", "-m", "C"]); repo.jj(&["bookmark", "set", "feat", "-r", "@-"]);
911 assert_colocated_consistent(&repo, "feat");
912
913 let good = jj_current_operation_id(repo.path()).unwrap();
914 repo.force_divergence();
915 jj_op_restore(repo.path(), &good).unwrap();
916
917 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
918 assert_colocated_consistent(&repo, "feat");
919 assert_git_healthy(&repo);
920 }
921
922 #[test]
923 fn colocation_consistent_for_a_stack() {
924 if !jj_installed() || !git_installed() {
925 return;
926 }
927 let repo = TestRepo::new(true);
928 std::fs::write(repo.path().join("f.txt"), "a\n").unwrap();
929 repo.jj(&["describe", "-m", "A"]);
930 repo.jj(&["bookmark", "create", "bottom", "-r", "@"]);
931 repo.jj(&["new", "-m", "B"]);
932 repo.jj(&["bookmark", "create", "top", "-r", "@"]);
933 repo.jj(&["new", "-m", "C"]);
934
935 let good = jj_current_operation_id(repo.path()).unwrap();
936 assert_colocated_consistent(&repo, "bottom");
937 assert_colocated_consistent(&repo, "top");
938 repo.force_divergence();
939 jj_op_restore(repo.path(), &good).unwrap();
940
941 assert!(jj_divergent_change_ids(repo.path()).unwrap().is_empty());
942 assert_colocated_consistent(&repo, "bottom");
943 assert_colocated_consistent(&repo, "top");
944 assert_git_healthy(&repo);
945 }
946}