Skip to main content

vcs_runner/
runner.rs

1//! VCS-specific subprocess helpers — thin wrappers over [`procpilot::Cmd`].
2//!
3//! The generic subprocess primitives (run_cmd, retry, timeout, etc.) live in
4//! [`procpilot`]. This module layers on jj/git-specific conveniences:
5//! merge-base helpers, the default transient-error predicate, and shorthand
6//! `run_jj` / `run_git` wrappers.
7
8use std::path::Path;
9use std::sync::Arc;
10use std::sync::atomic::AtomicBool;
11use std::time::Duration;
12
13use procpilot::{Cmd, RetryPolicy, RunError, RunOutput};
14
15/// Run a `jj` command in a repo directory, returning captured output.
16pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
17    Cmd::new("jj").in_dir(repo_path).args(args).run()
18}
19
20/// Run a `git` command in a repo directory, returning captured output.
21pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
22    Cmd::new("git").in_dir(repo_path).args(args).run()
23}
24
25/// Run a `jj` command, returning lossy-decoded, trimmed stdout as a `String`.
26///
27/// Shorthand for `run_jj(repo_path, args)?.stdout_lossy().trim().to_string()`
28/// — the most common pattern for callers that treat stdout as text.
29pub 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
34/// Run a `git` command, returning lossy-decoded, trimmed stdout as a `String`.
35pub 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
40/// Run a `jj` command with a timeout, returning trimmed stdout as a `String`.
41pub 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
50/// Run a `git` command with a timeout, returning trimmed stdout as a `String`.
51pub 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
60/// Run a `jj` command with retry, returning trimmed stdout as a `String`.
61pub 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
70/// Run a `git` command with retry, returning trimmed stdout as a `String`.
71pub 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
80/// Run a `jj` command with a timeout.
81pub 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
93/// Run a `git` command with a timeout.
94pub 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
106/// Run a `jj` command with retry on transient errors.
107pub 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
119/// Run a `git` command with retry on transient errors.
120pub 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
132/// Run a `jj` command with caller-driven cancellation.
133///
134/// When `cancel` is set to `true` the wrapper kills the child (SIGTERM →
135/// SIGKILL after the procpilot default grace) and returns
136/// [`RunError::Cancelled`]. If `cancel` is already set before spawn,
137/// returns `Cancelled` without starting the child.
138pub 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
150/// Run a `git` command with caller-driven cancellation. See
151/// [`run_jj_cancellable`] for semantics.
152pub 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
164/// Run a `jj` command with cancellation, returning trimmed stdout as a `String`.
165pub 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
174/// Run a `git` command with cancellation, returning trimmed stdout as a `String`.
175pub 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
184/// Run a `jj` command with retry on transient errors plus caller-driven cancellation.
185///
186/// Setting `cancel` short-circuits any pending backoff sleep and kills the
187/// in-flight child. The default retry predicate does not retry
188/// [`RunError::Cancelled`], so a cancelled attempt ends the loop.
189pub 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
203/// Run a `git` command with retry on transient errors plus caller-driven cancellation.
204/// See [`run_jj_with_retry_cancellable`] for semantics.
205pub 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
219/// Run a `jj` command with retry + cancellation, returning trimmed stdout as a `String`.
220pub 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
230/// Run a `git` command with retry + cancellation, returning trimmed stdout as a `String`.
231pub 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
241/// Find the merge base of two revisions in a jj repository.
242///
243/// Uses the revset `latest(::(a) & ::(b))` — the most recent common ancestor
244/// of the two revisions. Returns `Ok(None)` when the revisions have no common
245/// ancestor.
246pub 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
261// --- jj operation log ---
262//
263// A second jj process mutating the same working copy (e.g. a background watcher
264// racing a foreground command) forks the operation log; jj then silently
265// "reconciles divergent operations" by merging the two heads, which can corrupt
266// the working state. These helpers let a caller record a known-good operation
267// before mutating, detect a reconcile, and roll back to it. `jj-lib` exposes
268// these natively but churns pre-1.0; these shell out to a version-agnostic
269// `jj`, matching the rest of this crate.
270
271/// The id of the current (head) operation in the operation log.
272///
273/// Record this before a batch of mutations to get a point to [`jj_op_restore`]
274/// back to if a concurrent jj process forces jj to reconcile divergent
275/// operation logs mid-way.
276pub fn jj_current_operation_id(repo_path: &Path) -> Result<String, RunError> {
277    // `id` renders the full operation id, which `op restore` accepts directly.
278    run_jj_utf8(repo_path, &["op", "log", "-n1", "--no-graph", "-T", "id"])
279}
280
281/// The recent operation-log entries, newest first, up to `limit` (`0` = all).
282///
283/// Used to spot a `"reconcile divergent operations"` entry (the signature of a
284/// concurrent writer) and to walk back to a clean operation for recovery.
285pub 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        // Insert `-n <limit>` right after the `log` subcommand.
294        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
308/// Change ids that are divergent (one change id on multiple visible commits) —
309/// the persistent tell left by a concurrent op-log reconcile. Deduplicated: a
310/// change divergent across N commits is reported once. Empty means clean.
311pub 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
322/// Whether divergent changes exist as of a specific past operation. Lets a
323/// caller walk back to the most recent operation whose state predates a
324/// divergence, to [`jj_op_restore`] to.
325pub fn jj_is_divergent_at_operation(repo_path: &Path, op_id: &str) -> Result<bool, RunError> {
326    // `--at-operation` is a global flag and must precede the subcommand.
327    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
337/// Roll the repo back to `op_id` (`jj op restore`). The recovery primitive:
338/// pick a known-good operation instead of keeping jj's mangled auto-merge.
339///
340/// In a colocated repo this stays git-consistent — jj re-exports refs to git as
341/// part of the restore operation.
342pub 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
347/// Find the merge base of two revisions in a git repository.
348///
349/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
350/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
351pub 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
363/// Default transient-error check for jj/git.
364///
365/// Retries on `NonZeroExit` whose stderr contains `"stale"` or `".lock"`
366/// (jj working-copy staleness, git/jj lock-file contention). Spawn failures
367/// and timeouts are never treated as transient.
368pub 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        // CHANGELOG advertises this behavior — pin it so a future procpilot
511        // bump can't silently flip the default-predicate semantics.
512        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        // Without commits, git merge-base fails with non-zero; accept that shape.
533        let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
534    }
535
536    // --- jj operation-log helpers ---
537
538    /// A throwaway jj repo (optionally colocated) with helpers for driving jj
539    /// and git and for forcing an operation-log divergence the way a concurrent
540    /// writer would.
541    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        /// Two commits, A then B (@).
583        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        /// Fork the op log at an older op and reconcile — leaves a divergence.
589        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"]); // triggers the auto-reconcile
595        }
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        // Detection: divergence is present, and a reconcile op shows since good.
614        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        // Recovery clears it.
622        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        // One forked change spans two commits but is one change id.
664        assert_eq!(jj_divergent_change_ids(repo.path()).unwrap().len(), 1);
665    }
666
667    // --- colocation safety ---
668    //
669    // The risk is that op-log recovery leaves a colocated repo's git side (a
670    // branch ref, HEAD) pointing somewhere jj no longer agrees with. It does
671    // not: jj re-exports refs to git as part of the restore operation.
672
673    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", "@"]); // feat @ A
722        repo.jj(&["new", "-m", "B"]);
723        repo.jj(&["new", "-m", "C"]); // A <- B <- C=@
724        // MOVE feat from A to B (a non-working-copy commit).
725        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}