vcs-runner
Subprocess runner for jj and git CLI tools, with automatic retry on transient errors, timeouts, repository detection, and structured output parsing for both VCS backends.
Why not std::process::Command?
- Typed errors — distinguishes "couldn't spawn the binary" from "command ran and exited non-zero" from "timed out," so callers can handle each as appropriate
- Retry with backoff on lock contention and stale working copy errors
- Timeout support that kills hung commands (e.g.,
git fetchagainst an unreachable remote) and captures any partial output - Binary-safe output (
Vec<u8>) with convenient.stdout_lossy()for text - Repo detection that walks parent directories and distinguishes git, jj, and colocated repos
- Structured output parsing (optional) for both backends — jj log, jj bookmarks, jj diff summary, git diff name-status
- Merge-base helpers for both backends with consistent
Option<String>semantics
Usage
[]
= "0.10"
Cargo features
jj-parse(default): enables jj output parsers (log, bookmarks, diff summary) — pulls inserdeandserde_jsongit-parse(default): enables git output parsers (diff name-status) — no extra deps
Git-only consumers can skip jj parsing:
[]
= { = "0.10", = false, = ["git-parse"] }
Running commands
use ;
// Run a jj command, get captured output
let output = run_jj?;
let log_text = output.stdout_lossy;
// Binary content: access raw bytes directly (e.g., for image diffs)
let output = run_jj?;
let image_bytes: = output.stdout;
// With retry on lock contention / stale working copy
let output = run_jj_with_retry?;
// Custom retry predicate receives a typed RunError
let output = run_jj_with_retry?;
// Git works the same way
let output = run_git?;
Handling "command ran and said no"
run_jj and run_git return Result<RunOutput, RunError>. The RunError enum distinguishes infrastructure failure (binary missing, fork failed) from non-zero exits (the command ran and reported failure via exit code) from timeouts:
use ;
match run_git
RunError implements std::error::Error, so ? into anyhow::Result works when you don't care about the distinction.
Inspection methods on RunError:
err.is_non_zero_exit()/err.is_spawn_failure()/err.is_timeout()/err.is_cancelled()— check the varianterr.stderr()— captured stderr onNonZeroExit/Timeout/Cancelled,NoneonSpawnerr.exit_status()— exit status onNonZeroExit,Noneon otherserr.attempts()— 1-based attempt count (relevant after*_with_retry*)err.program()— the program name that failed
RunError is marked #[non_exhaustive], so new variants can be added in future versions without breaking your match arms (add a wildcard fallback).
Timeouts
For commands that might hang (network ops, unreachable remotes, user-supplied revsets), use the timeout variants:
use Duration;
use ;
match run_git_with_timeout
The timeout implementation drains stdout/stderr in background threads, so a chatty process can't block on pipe-buffer overflow. Any output collected before the kill is returned in the Timeout error variant.
Caveat on grandchildren: the kill signal reaches only the direct child. A shell wrapper like sh -c "git fetch" forks git as a grandchild that survives the shell's kill. Use exec in the shell (sh -c "exec git fetch") or invoke git directly to avoid this.
Cancellation
When a wall-clock timeout doesn't fit — e.g. a TUI event loop where the user
pressing q should immediately abort an in-flight jj/git call — thread an
Arc<AtomicBool> flag through the cancellable variants:
use Arc;
use ;
use ;
let cancel = new;
let cancel_clone = clone;
// UI thread: fire the flag the moment the operation is obsolete.
spawn;
match run_jj_cancellable
If the flag is already set when the helper is called, the child is never
spawned. If it fires mid-flight, the child receives SIGTERM (then SIGKILL
after a short grace) and the helper returns RunError::Cancelled with any
output captured before the kill.
Cancellation composes with retry — run_jj_with_retry_cancellable /
run_git_with_retry_cancellable short-circuit any pending backoff sleep
when the flag fires, and the default transient-error predicate does not
retry Cancelled (the caller asked to stop, so we stop). Each variant has
a _utf8 counterpart that returns trimmed stdout as String.
Commands other than jj/git
For any non-VCS subprocess, use Cmd — re-exported from procpilot, so one vcs-runner dep covers both.
use Duration;
use ;
// Captured output with env, cwd, timeout — all composable.
let output = new
.args
.in_dir
.env
.timeout
.run?;
// Pipe stdin into a child (kubectl apply -f -, docker build -, etc.)
new.args.stdin.run?;
// Let stderr stream to the user (live progress)
new.args.stderr.run?;
Repository detection
use ;
let = detect_vcs?;
if backend.is_jj
if backend.has_git
Detection walks parent directories automatically (e.g., /repo/src/lib/ finds /repo/.jj).
Merge base
Find the common ancestor of two revisions. Returns Ok(None) when there is no common ancestor (unrelated histories); Err(_) for actual failures like invalid refs.
use ;
if let Some = jj_merge_base?
if let Some = git_merge_base?
Operation log
Helpers for jj's operation log — enough to detect and recover from a concurrent op-log reconcile. When a second jj process mutates the same working copy (say a background watcher racing a foreground command), the operation log forks and jj silently "reconciles divergent operations" by merging the two heads, which can corrupt the working state. The pattern: record a known-good operation before mutating, and if a reconcile happened, roll back to it.
use ;
// Record a known-good operation before a batch of mutations.
let good = jj_current_operation_id?;
// ... run mutations (fetch, rebase, push) ...
// A concurrent writer forced a reconcile if a "reconcile divergent operations"
// op appeared, or divergent change ids now exist.
let reconciled = jj_operation_log?
.iter
.any;
if reconciled || !jj_divergent_change_ids?.is_empty
For a repo that is already divergent (no known-good op held), walk back to the most recent clean operation and restore to that:
use ;
if let Some = jj_operation_log?
.into_iter
.find
jj_op_restore is colocation-safe: in a colocated repo jj re-exports refs to
git as part of the restore, so the git side (branch refs, HEAD) stays in
lockstep with jj. Deciding when to snapshot and what counts as a good
operation is application policy — this crate provides the primitives.
Parsing jj output
Requires the jj-parse feature (on by default). Pre-built templates produce line-delimited JSON; parse functions handle malformed output gracefully.
use ;
use ;
// Log entries with structured fields
let output = run_jj?;
let result = parse_log_output;
for entry in &result.entries
// Bookmarks with sync status
let output = run_jj?;
let result = parse_bookmark_output;
for bookmark in &result.bookmarks
// Diff summary — file changes between revisions
let output = run_jj?;
for change in parse_diff_summary
Parsing git output
Requires the git-parse feature (on by default). No extra dependencies.
use ;
let output = run_git?;
for change in parse_git_diff_name_status
Both parse_diff_summary (jj) and parse_git_diff_name_status (git) return the same Vec<FileChange>, so tools that support both backends can share downstream logic.
Binary availability
use ;
if jj_available
// Generic: works with any binary that supports --version
if binary_available
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.