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 `jj` command **working-copy-agnostically** — with `--ignore-working-copy`
35/// prepended — returning lossy-decoded, trimmed stdout.
36///
37/// Use this for any operation that must not perturb the user's working copy: a
38/// read that should not snapshot their in-progress edits (and so should not
39/// create a spurious snapshot operation), or a fetch/push that never needs the
40/// working copy. It also stays readable when a concurrent writer has left the
41/// working copy stale — a plain [`run_jj_utf8`] errors "working copy is stale"
42/// in exactly that case, which is the moment op-log/divergence reads matter most.
43pub 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
50/// Run a `git` command, returning lossy-decoded, trimmed stdout as a `String`.
51pub 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
56/// Run a `jj` command with a timeout, returning trimmed stdout as a `String`.
57pub 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
66/// Run a `git` command with a timeout, returning trimmed stdout as a `String`.
67pub 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
76/// Run a `jj` command with retry, returning trimmed stdout as a `String`.
77pub 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
86/// Run a `git` command with retry, returning trimmed stdout as a `String`.
87pub 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
96/// Run a `jj` command with a timeout.
97pub 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
109/// Run a `git` command with a timeout.
110pub 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
122/// Run a `jj` command with retry on transient errors.
123pub 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
135/// Run a `git` command with retry on transient errors.
136pub 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
148/// Run a `jj` command with caller-driven cancellation.
149///
150/// When `cancel` is set to `true` the wrapper kills the child (SIGTERM →
151/// SIGKILL after the procpilot default grace) and returns
152/// [`RunError::Cancelled`]. If `cancel` is already set before spawn,
153/// returns `Cancelled` without starting the child.
154pub 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
166/// Run a `git` command with caller-driven cancellation. See
167/// [`run_jj_cancellable`] for semantics.
168pub 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
180/// Run a `jj` command with cancellation, returning trimmed stdout as a `String`.
181pub 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
190/// Run a `git` command with cancellation, returning trimmed stdout as a `String`.
191pub 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
200/// Run a `jj` command with retry on transient errors plus caller-driven cancellation.
201///
202/// Setting `cancel` short-circuits any pending backoff sleep and kills the
203/// in-flight child. The default retry predicate does not retry
204/// [`RunError::Cancelled`], so a cancelled attempt ends the loop.
205pub 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
219/// Run a `git` command with retry on transient errors plus caller-driven cancellation.
220/// See [`run_jj_with_retry_cancellable`] for semantics.
221pub 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
235/// Run a `jj` command with retry + cancellation, returning trimmed stdout as a `String`.
236pub 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
246/// Run a `git` command with retry + cancellation, returning trimmed stdout as a `String`.
247pub 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
257/// Find the merge base of two revisions in a jj repository.
258///
259/// Uses the revset `latest(::(a) & ::(b))` — the most recent common ancestor
260/// of the two revisions. Returns `Ok(None)` when the revisions have no common
261/// ancestor.
262pub 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
277// --- jj operation log ---
278//
279// A second jj process mutating the same working copy (e.g. a background watcher
280// racing a foreground command) forks the operation log; jj then silently
281// "reconciles divergent operations" by merging the two heads, which can corrupt
282// the working state. These helpers let a caller record a known-good operation
283// before mutating, detect a reconcile, and roll back to it. `jj-lib` exposes
284// these natively but churns pre-1.0; these shell out to a version-agnostic
285// `jj`, matching the rest of this crate.
286
287/// The id of the current (head) operation in the operation log.
288///
289/// Record this before a batch of mutations to get a point to [`jj_op_restore`]
290/// back to if a concurrent jj process forces jj to reconcile divergent
291/// operation logs mid-way.
292pub fn jj_current_operation_id(repo_path: &Path) -> Result<String, RunError> {
293    // `id` renders the full operation id, which `op restore` accepts directly.
294    // Working-copy-agnostic: reading the op head must not snapshot the working
295    // copy (that would create the very op we're trying to record).
296    run_jj_utf8_ignore_wc(repo_path, &["op", "log", "-n1", "--no-graph", "-T", "id"])
297}
298
299/// The recent operation-log entries, newest first, up to `limit` (`0` = all).
300///
301/// Used to spot a `"reconcile divergent operations"` entry (the signature of a
302/// concurrent writer) and to walk back to a clean operation for recovery.
303pub 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        // Insert `-n <limit>` right after the `log` subcommand.
312        args.splice(2..2, ["-n", limit_str.as_str()]);
313    }
314    // Working-copy-agnostic: reading the op log must not snapshot the working copy.
315    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
327/// Change ids that are divergent (one change id on multiple visible commits) —
328/// the persistent tell left by a concurrent op-log reconcile. Deduplicated: a
329/// change divergent across N commits is reported once. Empty means clean.
330pub fn jj_divergent_change_ids(repo_path: &Path) -> Result<Vec<String>, RunError> {
331    // Working-copy-agnostic: a concurrent writer can leave the working copy stale,
332    // and this signal — which exists to detect exactly that — must stay readable.
333    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
343/// Whether divergent changes exist as of a specific past operation. Lets a
344/// caller walk back to the most recent operation whose state predates a
345/// divergence, to [`jj_op_restore`] to.
346pub fn jj_is_divergent_at_operation(repo_path: &Path, op_id: &str) -> Result<bool, RunError> {
347    // `--at-operation` is a global flag and must precede the subcommand.
348    // Working-copy-agnostic: reading a past operation's state must not snapshot.
349    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
359/// Roll the repo back to `op_id` (`jj op restore`). The recovery primitive:
360/// pick a known-good operation instead of keeping jj's mangled auto-merge.
361///
362/// In a colocated repo this stays git-consistent — jj re-exports refs to git as
363/// part of the restore operation.
364pub 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
369/// Find the merge base of two revisions in a git repository.
370///
371/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
372/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
373pub 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
385/// Default transient-error check for jj/git.
386///
387/// Retries on `NonZeroExit` whose stderr contains `"stale"` or `".lock"`
388/// (jj working-copy staleness, git/jj lock-file contention). Spawn failures
389/// and timeouts are never treated as transient.
390pub 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        // CHANGELOG advertises this behavior — pin it so a future procpilot
533        // bump can't silently flip the default-predicate semantics.
534        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        // Without commits, git merge-base fails with non-zero; accept that shape.
555        let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
556    }
557
558    // --- jj operation-log helpers ---
559
560    /// A throwaway jj repo (optionally colocated) with helpers for driving jj
561    /// and git and for forcing an operation-log divergence the way a concurrent
562    /// writer would.
563    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        /// Two commits, A then B (@).
605        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        /// Fork the op log at an older op and reconcile — leaves a divergence.
611        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"]); // triggers the auto-reconcile
617        }
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        // Detection: divergence is present, and a reconcile op shows since good.
636        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        // Recovery clears it.
644        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        // One forked change spans two commits but is one change id.
686        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        // An uncommitted edit sitting in the working copy.
698        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        // These reads historically snapshotted `@` (folding the edit in and
711        // creating a spurious op); they must now leave the working copy untouched.
712        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    // --- colocation safety ---
720    //
721    // The risk is that op-log recovery leaves a colocated repo's git side (a
722    // branch ref, HEAD) pointing somewhere jj no longer agrees with. It does
723    // not: jj re-exports refs to git as part of the restore operation.
724
725    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", "@"]); // feat @ A
774        repo.jj(&["new", "-m", "B"]);
775        repo.jj(&["new", "-m", "C"]); // A <- B <- C=@
776        // MOVE feat from A to B (a non-working-copy commit).
777        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}