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/// The commit ids `revset` resolves to **as of a specific operation** — `<ws>@`
360/// at op X, `main` at op X, or any revset. The building block for reconstructing
361/// a ref's history from the operation log without parsing `op log --op-diff`
362/// prose (jj exposes no structured op-diff, so text-scraping is the alternative,
363/// and it is exactly the "op log is a safety net, not a tool" anti-pattern).
364///
365/// The revset is wrapped in `present(…)`, so an operation older than the thing
366/// it names — before a workspace or bookmark existed — resolves to an empty vec
367/// rather than erroring, letting a caller distinguish "absent here" from a real
368/// failure. Working-copy-agnostic: inspecting a past operation must not snapshot.
369pub fn jj_revset_at_operation(
370    repo_path: &Path,
371    revset: &str,
372    op_id: &str,
373) -> Result<Vec<String>, RunError> {
374    // `--at-operation` is a global flag and must precede the subcommand.
375    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
386/// The distinct commit ids `revset` resolved to across the operation log, newest
387/// first — jj's analog of `git reflog <ref>`, for any revset. The first-class
388/// replacement for scraping `op log --op-diff` to answer "what did this ever
389/// point at" (working-copy recovery, pre-rebase-head recovery, drift detection).
390///
391/// Walks operations newest-first (via [`jj_operation_log`]) and evaluates
392/// [`jj_revset_at_operation`] at each, collecting distinct commit ids in the
393/// order first seen. The walk **stops at the first operation where `revset`
394/// resolves to nothing** — i.e. before the named ref existed — bounding it to
395/// the ref's own lifetime rather than the entire op log; `limit` (`0` = no cap)
396/// caps how many operations are examined as an extra guard on large logs.
397///
398/// That stop-at-absent bound suits *ref-like* revsets that come into being and
399/// persist (`<ws>@`, a bookmark). For a revset that is *transiently* empty —
400/// `divergent()`, `conflicts()` — it would end the walk immediately; evaluate
401/// [`jj_revset_at_operation`] over an explicit op list from [`jj_operation_log`]
402/// instead. Working-copy-agnostic. Cost is one `jj` invocation per operation
403/// walked: jj offers no batched or structured op-diff, so this is inherent.
404pub 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; // older than the ref — nothing more of its lifetime to find
416        }
417        for cid in at {
418            if seen.insert(cid.clone()) {
419                history.push(cid);
420            }
421        }
422    }
423    Ok(history)
424}
425
426/// Roll the repo back to `op_id` (`jj op restore`). The recovery primitive:
427/// pick a known-good operation instead of keeping jj's mangled auto-merge.
428///
429/// In a colocated repo this stays git-consistent — jj re-exports refs to git as
430/// part of the restore operation.
431pub 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
436/// Find the merge base of two revisions in a git repository.
437///
438/// Returns `Ok(None)` when git reports no common ancestor (exit code 1 with
439/// empty output), `Ok(Some(sha))` when found, `Err(_)` for actual failures.
440pub 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
452/// Default transient-error check for jj/git.
453///
454/// Retries on `NonZeroExit` whose stderr contains `"stale"` or `".lock"`
455/// (jj working-copy staleness, git/jj lock-file contention). Spawn failures
456/// and timeouts are never treated as transient.
457pub 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        // CHANGELOG advertises this behavior — pin it so a future procpilot
600        // bump can't silently flip the default-predicate semantics.
601        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        // Without commits, git merge-base fails with non-zero; accept that shape.
622        let _ = git_merge_base(tmp.path(), "HEAD", "HEAD");
623    }
624
625    // --- jj operation-log helpers ---
626
627    /// A throwaway jj repo (optionally colocated) with helpers for driving jj
628    /// and git and for forcing an operation-log divergence the way a concurrent
629    /// writer would.
630    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        /// Two commits, A then B (@).
672        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        /// Fork the op log at an older op and reconcile — leaves a divergence.
678        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"]); // triggers the auto-reconcile
684        }
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        // Detection: divergence is present, and a reconcile op shows since good.
703        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        // Recovery clears it.
711        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        // One forked change spans two commits but is one change id.
753        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        // An uncommitted edit sitting in the working copy.
765        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        // These reads historically snapshotted `@` (folding the edit in and
778        // creating a spurious op); they must now leave the working copy untouched.
779        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        // Before any `hist` bookmark exists, `present(hist)` resolves empty — not
796        // an error — so a caller can tell "absent at this op" from a real failure.
797        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        // And the read must not snapshot an uncommitted edit into `@`.
804        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("@-"); // commit A
837        let b = commit("@"); // working-copy commit B
838
839        // Point a bookmark at A, then move it to B: two distinct values in its life.
840        repo.jj(&["bookmark", "create", "hist", "-r", &a]);
841        repo.jj(&["bookmark", "set", "hist", "-r", &b]);
842
843        // Newest-first, distinct, and bounded at the bookmark's creation — the walk
844        // stops before `hist` existed rather than scanning the whole op log.
845        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    // --- colocation safety ---
853    //
854    // The risk is that op-log recovery leaves a colocated repo's git side (a
855    // branch ref, HEAD) pointing somewhere jj no longer agrees with. It does
856    // not: jj re-exports refs to git as part of the restore operation.
857
858    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", "@"]); // feat @ A
907        repo.jj(&["new", "-m", "B"]);
908        repo.jj(&["new", "-m", "C"]); // A <- B <- C=@
909        // MOVE feat from A to B (a non-working-copy commit).
910        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}