Skip to main content

vcs_cli_support/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-cli-support` — the [`processkit`]-coupled plumbing the CLI wrappers reuse.
4//!
5//! `vcs-git` / `vcs-jj` / `vcs-github` all drive a CLI through [`processkit`], so
6//! they share three concerns that *touch* [`processkit::Error`]: an argv injection
7//! guard, a fetch-retry policy, and a set of [`Error`] classifiers. Extracting them
8//! here keeps the std-only `vcs-diff` clean of the `processkit` dependency, and —
9//! more to the point — keeps the marker lists and classifier logic from drifting
10//! between backends. The wrapper crates re-export these items (so you reach them
11//! as `vcs_git::is_merge_conflict`, not via this crate's name) and rarely name
12//! `vcs-cli-support` directly.
13//!
14//! # The surface
15//!
16//! - **[`reject_flag_like`]** — the injection guard for bare positional argv slots.
17//!   A caller value that is empty/whitespace, or starts with `-`, is refused before
18//!   spawning (the CLI would parse it as a flag); flag-*value* slots (`-m <msg>`)
19//!   are consumed verbatim and skip the check. Wrappers call it with their own
20//!   binary name so the surfaced [`Error::Spawn`] names the right `program`.
21//! - **[`FETCH_ATTEMPTS`] / [`FETCH_BACKOFF`]** — the shared transient-retry policy
22//!   for `fetch` (one try plus two retries, fixed backoff between them).
23//! - **[`is_merge_conflict`] / [`is_nothing_to_commit`] / [`is_transient_fetch_error`]
24//!   / [`is_lock_contention`]** — classify a returned [`Error`] so callers branch on
25//!   *intent* ("conflict, resolve it"; "nothing to commit, no-op"; "transient,
26//!   retry"; "another process holds the lock, retry") instead of matching on error
27//!   internals. They inspect captured [`Error::Exit`] output against fixed marker
28//!   lists; a [`processkit`] [`Error::Timeout`] is **not** treated as a transient
29//!   fetch error (it already spent the full deadline — see
30//!   [`is_transient_fetch_error`]); any unfamiliar `#[non_exhaustive]` variant falls
31//!   through to "no".
32//! - **[`RetryPolicy`] / [`retry_async`] / [`ManagedClient`]** — an opt-in retry
33//!   strategy (attempts + exponential, jittered backoff) for **lock-contention**
34//!   failures. `ManagedClient` wraps a [`processkit`] `CliClient` and applies the
35//!   policy to every command, so the `vcs-git`/`vcs-jj` clients gain retry via
36//!   `with_retry(...)` without changing a call site. Lock-acquisition failures are
37//!   pre-execution, so retrying is safe even for mutating commands.
38//! - **[`CredentialProvider`] / [`Credential`] / [`Secret`]** — an opt-in seam for
39//!   supplying a secret *per operation* (a CI token, a vault lookup) instead of
40//!   relying on ambient CLI auth. `ManagedClient` injects the resolved token into
41//!   each command (the forge `GH_TOKEN`/`GITLAB_TOKEN` env); git uses
42//!   [`git_credential_helper`] to keep the secret out of `argv`. Default is no
43//!   provider → ambient auth, unchanged. See the [`credentials`](mod@credentials)
44//!   module for the full picture.
45//!
46//! # Recipes
47//!
48//! Classify a failed `fetch` to drive a retry decision — branch on intent, not on
49//! the error's internals:
50//!
51//! ```no_run
52//! use vcs_cli_support::{is_transient_fetch_error, FETCH_ATTEMPTS, FETCH_BACKOFF};
53//! # fn run() -> Result<(), processkit::Error> { todo!() }
54//! # fn demo() -> Result<(), processkit::Error> {
55//! for attempt in 1..=FETCH_ATTEMPTS {
56//!     match run() {
57//!         Ok(()) => break,
58//!         Err(e) if is_transient_fetch_error(&e) && attempt < FETCH_ATTEMPTS => {
59//!             std::thread::sleep(FETCH_BACKOFF); // DNS / dropped connection — worth a retry
60//!         }
61//!         Err(e) => return Err(e),               // anything else: give up
62//!     }
63//! }
64//! # Ok(()) }
65//! ```
66
67use std::ffi::OsStr;
68use std::fmt;
69use std::future::Future;
70use std::path::Path;
71use std::sync::Arc;
72use std::time::Duration;
73
74use processkit::{
75    CliClient, Command, Error, IntoCommand, JobRunner, ProcessResult, ProcessRunner, Result,
76};
77
78pub mod credentials;
79pub use credentials::{
80    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
81    GitCredentialHelper, Secret, StaticCredential, git_credential_helper, https_host, provider_fn,
82};
83
84/// JSON helpers shared by the forge wrappers, behind the `serde` feature — so the
85/// three forge parsers share one `null -> ""` and parse-error convention.
86#[cfg(feature = "serde")]
87#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
88pub mod json {
89    use processkit::{Error, Result};
90    use serde::Deserialize;
91    use serde::de::DeserializeOwned;
92
93    /// Deserialize a `String` a forge CLI may send as JSON `null` for an empty
94    /// optional value: `null` -> empty string, same as an absent key. `#[serde(default)]`
95    /// alone covers only an absent key; a present `null` would fail the whole-object
96    /// parse. Use as `#[serde(deserialize_with = "vcs_cli_support::json::null_to_empty")]`.
97    pub fn null_to_empty<'de, D>(deserializer: D) -> ::core::result::Result<String, D::Error>
98    where
99        D: serde::Deserializer<'de>,
100    {
101        Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
102    }
103
104    /// Deserialize a forge CLI's `--json` output into `T`, mapping a parse failure to
105    /// [`Error::Parse`] tagged with `program` (the CLI's binary name).
106    pub fn from_json<T: DeserializeOwned>(program: &str, json: &str) -> Result<T> {
107        serde_json::from_str(json).map_err(|e| Error::Parse {
108            program: program.to_string(),
109            message: e.to_string(),
110        })
111    }
112}
113
114/// Generate the cwd-bound forwarders for a CLI wrapper's `…At` view.
115///
116/// Each CLI wrapper (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`, `vcs-gitea`)
117/// exposes a cwd-bound view — `GitAt`, `JjAt`, `GitHubAt`, `GitLabAt`, `GiteaAt` —
118/// that holds a reference to the client plus a pre-bound `dir`, and re-exposes the
119/// client's methods with `dir` already supplied. The forwarder bodies are
120/// byte-identical across the five backends but for three things, so they live here
121/// once instead of as a copied `macro_rules!` per crate:
122///
123/// - `$view` — the bound view type (e.g. `GitAt`). It must be generic over
124///   `<'a, R: ProcessRunner>` and have a field named `$field` holding the client
125///   plus a `dir: &'a Path` field.
126/// - `$field` — the inner field naming the client (e.g. `git`, `gh`, `glab`,
127///   `tea`).
128/// - `$client` — a **string literal** naming the client type, used in the
129///   generated doc strings and rendered as an intra-doc link (e.g. `"Git"` →
130///   ``[`Git`]``).
131/// - `bare { … }` — methods forwarded verbatim to `self.$field`.
132/// - `dir  { … }` — methods that take `self.dir` as their first argument.
133///
134/// The argument and return types in the method lists resolve in the **calling**
135/// crate, so they are written exactly as that wrapper's own methods are. The
136/// `ProcessRunner` bound is fully qualified (`::processkit::ProcessRunner`) so the
137/// expansion compiles regardless of which items the caller has imported.
138///
139/// ```ignore
140/// vcs_cli_support::at_forwarders! {
141///     GitAt, git, "Git",
142///     bare { fn version() -> Result<String>; }
143///     dir  { fn status() -> Result<Vec<StatusEntry>>; }
144/// }
145/// ```
146#[macro_export]
147macro_rules! at_forwarders {
148    (
149        $view:ident, $field:ident, $client:literal,
150        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
151        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
152    ) => {
153        impl<'a, R: ::processkit::ProcessRunner> $view<'a, R> {
154            $(
155                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($bn), "`.")]
156                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
157                    self.$field.$bn($($ba),*).await
158                }
159            )*
160            $(
161                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
162                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
163                    self.$field.$dn(self.dir, $($da),*).await
164                }
165            )*
166        }
167    };
168}
169
170/// Emit the common client scaffold every CLI wrapper hand-writes around a
171/// [`ManagedClient`].
172///
173/// `vcs-git`, `vcs-jj`, `vcs-github`, and `vcs-gitlab` each wrap a
174/// [`ManagedClient`] in a thin newtype that re-exposes the same handful of
175/// constructors and default-applying builders — `new` / `Default` /
176/// `with_runner` / `default_timeout` / `default_env` / `default_env_remove` /
177/// `default_cancel_on` — with byte-identical bodies and doc strings. This macro
178/// generates that shared part so it can't drift between backends; each wrapper
179/// keeps its *capability* builders (`with_retry`, `with_credentials`, every verb,
180/// the `…At` view, …) hand-written in a separate `impl` block.
181///
182/// The generated newtype is `struct $name<R: ProcessRunner = JobRunner>` with a
183/// single private `core: ManagedClient<R>` field — accessible to the rest of the
184/// wrapper crate (same module). All paths are fully qualified, so the expansion
185/// compiles regardless of what the caller has imported.
186///
187/// - `$name` — the wrapper type (e.g. `Git`). The struct-level doc comment (and
188///   any other attributes) written before `struct` are attached to it verbatim.
189/// - `$binary` — the program the client drives (an expression, typically the
190///   crate's `BINARY` const).
191/// - `token_env = ($svc, $var)` — *optional*. When given, `new`/`with_runner`
192///   chain [`ManagedClient::with_token_env`] so a resolved credential is injected
193///   into the `$var` environment variable for service `$svc` (the forge case:
194///   `GH_TOKEN`, `GITLAB_TOKEN`). Omit it for the ambient-auth backends (git, jj).
195/// - `scrub_env = [ $var, … ]` — *optional*. When given, `new`/`with_runner`
196///   chain [`ManagedClient::default_env_remove`] for each var, so **every** client
197///   the macro generates drops those inherited environment variables by default
198///   (`vcs-git` uses it to scrub the repo-redirector vars — `GIT_DIR`, … — so a
199///   value leaking from the parent process can't retarget commands). Must come
200///   *after* `token_env` when both are present.
201///
202/// ```ignore
203/// vcs_cli_support::managed_client! {
204///     /// The real GitHub client.
205///     pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
206/// }
207/// vcs_cli_support::managed_client! {
208///     /// The real Git client — scrubs the repo-redirector env vars by default.
209///     pub struct Git => BINARY, scrub_env = ["GIT_DIR", "GIT_WORK_TREE"]
210/// }
211/// ```
212#[macro_export]
213macro_rules! managed_client {
214    (
215        $(#[$meta:meta])*
216        $vis:vis struct $name:ident => $binary:expr
217        $(, token_env = ($svc:expr, $var:expr) )?
218        $(, scrub_env = [ $($scrub:expr),* $(,)? ] )?
219        $(,)?
220    ) => {
221        $(#[$meta])*
222        $vis struct $name<R: ::processkit::ProcessRunner = ::processkit::JobRunner> {
223            core: $crate::ManagedClient<R>,
224        }
225
226        // Manual Debug: no `R: Debug` bound (matches `ManagedClient`'s own impl),
227        // delegating straight to `core` — `ManagedClient::fmt` already redacts any
228        // configured credential provider / token-env binding, so nothing secret
229        // reaches `{:?}` here either.
230        impl<R: ::processkit::ProcessRunner> ::core::fmt::Debug for $name<R> {
231            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
232                f.debug_struct(stringify!($name))
233                    .field("core", &self.core)
234                    .finish()
235            }
236        }
237
238        impl $name<::processkit::JobRunner> {
239            /// Create a client driving the real job-backed runner.
240            pub fn new() -> Self {
241                Self { core: $crate::ManagedClient::new($binary)
242                    $(.with_token_env($svc, $var))?
243                    $($(.default_env_remove($scrub))*)?
244                }
245            }
246        }
247
248        impl ::core::default::Default for $name<::processkit::JobRunner> {
249            fn default() -> Self {
250                Self::new()
251            }
252        }
253
254        impl<R: ::processkit::ProcessRunner> $name<R> {
255            /// Create a client driving `runner` — inject a fake in tests.
256            pub fn with_runner(runner: R) -> Self {
257                Self {
258                    core: $crate::ManagedClient::with_runner($binary, runner)
259                        $(.with_token_env($svc, $var))?
260                        $($(.default_env_remove($scrub))*)?,
261                }
262            }
263
264            /// Apply a default timeout to every command this client builds.
265            pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
266                self.core = self.core.default_timeout(timeout);
267                self
268            }
269
270            /// Set an environment variable on every command this client builds.
271            pub fn default_env(
272                mut self,
273                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
274                value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
275            ) -> Self {
276                self.core = self.core.default_env(key, value);
277                self
278            }
279
280            /// Remove an inherited environment variable on every command this client builds.
281            pub fn default_env_remove(
282                mut self,
283                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
284            ) -> Self {
285                self.core = self.core.default_env_remove(key);
286                self
287            }
288
289            /// Cancel every command this client builds when `token` fires.
290            pub fn default_cancel_on(mut self, token: ::processkit::CancellationToken) -> Self {
291                self.core = self.core.default_cancel_on(token);
292                self
293            }
294        }
295    };
296}
297
298/// Injection guard for bare positional argv slots: a caller-supplied value with a
299/// leading `-` would be parsed by the CLI as a *flag* (verified: `git checkout
300/// -evil` → "unknown switch"; jj likewise), and an empty (or whitespace-only)
301/// value silently changes most commands' meaning. Refuse both before anything
302/// spawns, surfacing an [`Error::Spawn`] naming `program`. An interior NUL is
303/// refused too (it can't be passed in argv and otherwise surfaces as an opaque
304/// OS spawn error). Flag-VALUE positions (`-m <msg>`, `--branch <b>`) don't need
305/// this — the CLI consumes the next token verbatim there.
306///
307/// The leading-`-` test is applied to the **trimmed** value, so a value like
308/// `" --upload-pack=…"` (leading whitespace) is still refused — the empty-check
309/// and the flag-check now agree on what "the value" is.
310pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
311    let trimmed = value.trim();
312    if trimmed.is_empty() || trimmed.starts_with('-') || value.contains('\0') {
313        return Err(Error::Spawn {
314            program: program.to_string(),
315            source: std::io::Error::new(
316                std::io::ErrorKind::InvalidInput,
317                format!(
318                    "{what} {value:?} would be parsed as a flag (or is empty / contains NUL) — \
319                     refusing to pass it as a positional argument"
320                ),
321            ),
322        });
323    }
324    Ok(())
325}
326
327/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
328pub const FETCH_ATTEMPTS: u32 = 3;
329/// Fixed backoff between fetch retries.
330pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
331/// Grace period for a timed-out fetch: on the deadline processkit signals the
332/// process tree (terminate), waits this long for it to exit cleanly — flush, close
333/// the connection, drop any lock — then hard-kills. Only takes effect when a
334/// per-client timeout is set (`Git::default_timeout` / `Jj::default_timeout`); a
335/// fetch with no deadline is unaffected.
336pub const FETCH_TIMEOUT_GRACE: Duration = Duration::from_secs(2);
337
338/// Lower-case substrings marking a merge that stopped on conflicts.
339const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
340/// Lower-case substrings marking a commit that found nothing to record.
341const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
342/// Lower-case substrings marking a transient (retryable) network/fetch failure.
343/// The timeout markers are kept *specific* (`connection timed out` /
344/// `operation timed out`) rather than a bare `timed out`, which would also match
345/// unrelated, non-network "timed out" messages (a lock wait, a hook) and trigger a
346/// spurious fetch retry.
347const TRANSIENT_FETCH_MARKERS: &[&str] = &[
348    "could not resolve host",
349    "couldn't resolve host",
350    "temporary failure in name resolution",
351    "connection timed out",
352    "connection refused",
353    "operation timed out",
354    "network is unreachable",
355    "failed to connect",
356    "could not read from remote repository",
357    "the remote end hung up",
358    "early eof",
359    "rpc failed",
360];
361
362/// Whether `err` is an [`Error::Exit`] whose captured output contains any marker.
363fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
364    let Error::Exit { stdout, stderr, .. } = err else {
365        return false;
366    };
367    let out = stdout.to_ascii_lowercase();
368    let errt = stderr.to_ascii_lowercase();
369    markers.iter().any(|m| out.contains(m) || errt.contains(m))
370}
371
372/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict. (jj
373/// surfaces conflicts as state rather than as errors, so this only fires on git
374/// output — see `vcs_core::Error::is_merge_conflict`.)
375pub fn is_merge_conflict(err: &Error) -> bool {
376    exit_output_matches(err, CONFLICT_MARKERS)
377}
378
379/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
380/// tree), as opposed to a real error.
381pub fn is_nothing_to_commit(err: &Error) -> bool {
382    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
383}
384
385/// Whether a failed `fetch`/`fetch_branch`/`remote_branch_exists` looks
386/// transient (DNS, a dropped connection, a fast network blip) and is worth
387/// retrying.
388///
389/// A processkit-level **timeout** is deliberately **not** classified transient
390/// (R6). A `.timeout()`-bounded run that expired has already consumed the caller's
391/// full deadline — retrying it would multiply the wall-clock by [`FETCH_ATTEMPTS`]
392/// (e.g. a black-holed remote under a 120 s deadline would block ≈ 6 min, three
393/// times the advertised ceiling). The deadline *is* the patience budget; a caller
394/// who wants longer should raise the timeout, not have it silently tripled. Fast
395/// transient failures (the io-level and marker cases below) still retry, because
396/// they fail quickly and a retry is cheap.
397pub fn is_transient_fetch_error(err: &Error) -> bool {
398    // An io-level transient from the spawn itself (interrupted / would-block / busy),
399    // which processkit classifies via `Error::is_transient()` (it covers `Spawn`/`Io`,
400    // not `Exit`/`Timeout`, so it composes cleanly with the marker scan below).
401    err.is_transient() || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
402}
403
404/// Lower-case substrings marking a **whole-repository / working-copy lock**
405/// contention failure — another process held the *one* repo-wide lock, so the
406/// command **never started** (clean, pre-execution) and touched nothing.
407///
408/// These are deliberately limited to the locks that guard the *entire* operation
409/// up front, so retrying is safe even on a **mutating** command: the repo was not
410/// modified at all. We intentionally do **not** include per-ref lock messages
411/// (`cannot lock ref`, `<ref>.lock`/`packed-refs.lock: File exists`): a multi-ref
412/// `push`/`fetch` updates refs sequentially, so a ref-lock failure can arrive
413/// *after* earlier refs already moved — replaying that is not idempotent. Network
414/// markers
415/// ([`TRANSIENT_FETCH_MARKERS`]) and conflict/exit failures are likewise absent.
416const LOCK_CONTENTION_MARKERS: &[&str] = &[
417    // git: the whole-repo index lock (pre-write). Match the **locale-stable path
418    // fragment** `index.lock`, not the translated `': File exists'` suffix — git
419    // localizes its messages, so a `LANG=de_DE` runner would never match the full
420    // English phrase. `index.lock` names the index lock specifically; per-ref locks
421    // (`<ref>.lock`, `packed-refs.lock`) are ruled out by the `refs/` guard in
422    // `is_lock_contention`. (This matches any `index.lock` *create* failure — a
423    // held lock, or e.g. `Permission denied` — all pre-write, so retrying is safe.)
424    "index.lock",
425    // jj: the working-copy lock and the operation-heads lock (both pre-mutation).
426    // These are jj's exact wordings (lower-cased for the classifier). NOTE: modern
427    // jj generally **blocks** on these locks until they're free rather than failing,
428    // so contention usually surfaces as a wait, not a classifiable error — these
429    // markers catch only the residual cases where jj does surface a lock error.
430    "failed to lock working copy",
431    "failed to lock operation heads store",
432];
433
434/// Whether `err` is a **whole-repository lock-contention** failure — another
435/// process held git's `index.lock` or jj's working-copy / op-heads lock, so the
436/// command couldn't even start. Such a failure is *pre-execution* and therefore
437/// safe to retry even on a **mutating** operation (the repo was never modified).
438/// Per-ref lock failures (`cannot lock ref`, `<ref>.lock`) are deliberately **not**
439/// classified here — they can occur mid-way through a multi-ref `push`/`fetch`,
440/// where a retry would not be idempotent. Conflict, "nothing to commit", a real
441/// non-zero exit, a timeout, a signal, or a missing binary are also **not** lock
442/// contention and must not be retried this way.
443pub fn is_lock_contention(err: &Error) -> bool {
444    // Rule out a **per-ref** lock first: it is *not* safely retryable (a multi-ref
445    // push/fetch can fail one ref's lock after earlier refs already moved). git's
446    // per-ref lock lives under `refs/` (`…/refs/heads/<name>.lock`) and its message
447    // names `refs/…`, whereas the whole-repo `index.lock` (`<gitdir>/index.lock`)
448    // never does — so a `refs/` mention excludes it, locale-independently. This also
449    // stops a branch literally named `index`/`reindex` (whose `…/reindex.lock`
450    // contains the substring `index.lock`) from matching the bare `index.lock`
451    // marker. (A repo whose *path* contains `refs/` then misses the index-lock retry
452    // — a benign false-negative, safer than a wrong retry.)
453    if exit_output_matches(err, &["refs/"]) {
454        return false;
455    }
456    exit_output_matches(err, LOCK_CONTENTION_MARKERS)
457}
458
459/// Whether `err` is an **input rejection** — a bad caller argument, encoded as an
460/// [`Error::Spawn`] whose source is `io::ErrorKind::InvalidInput`. This is the
461/// pattern the toolkit's own argument guards raise ([`reject_flag_like`] and the
462/// validating newtypes `RefName`/`RevSpec`/`RevsetExpr`) for a value that would be
463/// misparsed as a flag, is empty, or contains a NUL — and it also covers the
464/// spawn-time `InvalidInput` the OS raises for an un-spawnable argument (an interior
465/// NUL in a flag-value, or Windows' batch-arg-escaping refusal). All are genuine
466/// bad input, distinct from a real spawn failure (missing binary → `NotFound`, no
467/// perms → `PermissionDenied`) or a non-zero exit. A binding maps this to a
468/// `ValueError`; the facades re-expose it as `Error::is_invalid_input()`.
469pub fn is_invalid_input(err: &Error) -> bool {
470    matches!(
471        err,
472        Error::Spawn { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput
473    )
474}
475
476/// A bounded retry strategy: how many attempts, the (exponential) backoff between
477/// them, and whether to add full jitter. Used by [`ManagedClient`] to retry
478/// [`is_lock_contention`] failures. The [`Default`] is [`none`](RetryPolicy::none)
479/// (no retry) — retry is **opt-in**.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481#[non_exhaustive]
482pub struct RetryPolicy {
483    /// Total attempts including the first; `1` means no retry.
484    pub attempts: u32,
485    /// Delay before the first retry; doubles each subsequent retry (capped by
486    /// [`max_backoff`](RetryPolicy::max_backoff)). `ZERO` means retry immediately.
487    pub base_backoff: Duration,
488    /// Upper bound on the (pre-jitter) backoff delay. `ZERO` means uncapped.
489    pub max_backoff: Duration,
490    /// Apply **full jitter** — the actual delay is uniform in `[0, computed]` — to
491    /// avoid a thundering herd when many workers retry against one repository.
492    pub jitter: bool,
493}
494
495impl RetryPolicy {
496    /// No retry: a single attempt. The default.
497    pub const fn none() -> Self {
498        Self {
499            attempts: 1,
500            base_backoff: Duration::ZERO,
501            max_backoff: Duration::ZERO,
502            jitter: false,
503        }
504    }
505
506    /// A sensible default for repository lock contention: a handful of attempts
507    /// with short, jittered, exponential backoff (25 ms → 500 ms).
508    pub const fn lock_contention() -> Self {
509        Self {
510            attempts: 5,
511            base_backoff: Duration::from_millis(25),
512            max_backoff: Duration::from_millis(500),
513            jitter: true,
514        }
515    }
516
517    /// Set the total number of attempts (clamped to at least 1).
518    pub fn attempts(mut self, attempts: u32) -> Self {
519        self.attempts = attempts.max(1);
520        self
521    }
522
523    /// Set the base backoff (the delay before the first retry).
524    pub fn base_backoff(mut self, backoff: Duration) -> Self {
525        self.base_backoff = backoff;
526        self
527    }
528
529    /// Cap the (pre-jitter) backoff delay; `ZERO` leaves it uncapped.
530    pub fn max_backoff(mut self, max: Duration) -> Self {
531        self.max_backoff = max;
532        self
533    }
534
535    /// Toggle full jitter on the backoff delay.
536    pub fn with_jitter(mut self, jitter: bool) -> Self {
537        self.jitter = jitter;
538        self
539    }
540}
541
542impl Default for RetryPolicy {
543    /// No retry — retry is opt-in.
544    fn default() -> Self {
545        Self::none()
546    }
547}
548
549/// The (possibly jittered) backoff before the `retry_index`-th retry (0 = first).
550fn backoff_for(policy: &RetryPolicy, retry_index: u32) -> Duration {
551    if policy.base_backoff.is_zero() {
552        return Duration::ZERO;
553    }
554    let base = policy.base_backoff.as_nanos();
555    let scaled = base.saturating_mul(1u128 << retry_index.min(20));
556    let capped = if policy.max_backoff.is_zero() {
557        scaled
558    } else {
559        scaled.min(policy.max_backoff.as_nanos())
560    };
561    let delay = Duration::from_nanos(capped.min(u64::MAX as u128) as u64);
562    if policy.jitter {
563        full_jitter(delay)
564    } else {
565        delay
566    }
567}
568
569/// Full jitter: a uniform delay in `[0, max]`. Dependency-free randomness via the
570/// OS-seeded [`RandomState`](std::collections::hash_map::RandomState) — good enough
571/// to de-correlate retries, not cryptographic.
572fn full_jitter(max: Duration) -> Duration {
573    use std::hash::{BuildHasher, Hasher};
574    let nanos = max.as_nanos();
575    if nanos == 0 {
576        return Duration::ZERO;
577    }
578    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
579    hasher.write_u64(nanos as u64);
580    let r = hasher.finish() as u128;
581    Duration::from_nanos((r % (nanos + 1)).min(u64::MAX as u128) as u64)
582}
583
584/// Run `op`, retrying its result while `should_retry` says so and `policy` has
585/// attempts left, sleeping the (jittered, exponential) backoff between tries. The
586/// op is re-invoked from scratch each attempt, so it must be idempotent for the
587/// errors `should_retry` selects (lock-contention failures are — the command never
588/// ran). Returns the first `Ok`, or the last `Err`.
589pub async fn retry_async<T, Fut>(
590    policy: &RetryPolicy,
591    should_retry: impl Fn(&Error) -> bool,
592    mut op: impl FnMut() -> Fut,
593) -> Result<T>
594where
595    Fut: Future<Output = Result<T>>,
596{
597    let attempts = policy.attempts.max(1);
598    for attempt in 1..=attempts {
599        match op().await {
600            Ok(value) => return Ok(value),
601            Err(err) => {
602                if attempt == attempts || !should_retry(&err) {
603                    return Err(err);
604                }
605                let delay = backoff_for(policy, attempt - 1);
606                if !delay.is_zero() {
607                    tokio::time::sleep(delay).await;
608                }
609            }
610        }
611    }
612    unreachable!("the loop returns on the final attempt")
613}
614
615/// A [`CliClient`] wrapper that adds two opt-in concerns the CLI wrappers
616/// (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`) all share, without touching a
617/// single call site:
618///
619/// 1. **Lock-contention retry** ([`is_lock_contention`]) per a [`RetryPolicy`] —
620///    off by default ([`RetryPolicy::none`]); enable with
621///    [`with_retry`](ManagedClient::with_retry). Safe even for mutating commands,
622///    since lock contention is a clean pre-execution failure.
623/// 2. **Credential injection** from an opt-in [`CredentialProvider`] — off by
624///    default (no provider); attach one with
625///    [`with_credentials`](ManagedClient::with_credentials). When a forge
626///    *token-env* binding is configured
627///    ([`with_token_env`](ManagedClient::with_token_env)), every command run
628///    through this client gets the resolved token in that environment variable
629///    (e.g. `GH_TOKEN`). Backends that inject the secret differently (git's
630///    `credential.helper`) instead call
631///    [`resolve_credential`](ManagedClient::resolve_credential) at the command
632///    site. Resolution happens once per call, before the retry loop.
633///
634/// Both default to inert, so a client with neither configured behaves exactly
635/// like a bare `CliClient`.
636pub struct ManagedClient<R: ProcessRunner = JobRunner> {
637    inner: CliClient<R>,
638    retry: RetryPolicy,
639    credentials: Option<Arc<dyn CredentialProvider>>,
640    /// When set, the token is auto-injected into this env var on every command,
641    /// resolved for this service. Used by the forge clients (`GH_TOKEN`, …).
642    token_env: Option<(CredentialService, &'static str)>,
643}
644
645impl<R: ProcessRunner> fmt::Debug for ManagedClient<R> {
646    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647        f.debug_struct("ManagedClient")
648            .field("inner", &self.inner)
649            .field("retry", &self.retry)
650            // Never render the provider itself (it may close over a secret); just
651            // whether one is configured, plus the token-env binding.
652            .field("credentials", &self.credentials.is_some())
653            .field("token_env", &self.token_env)
654            .finish()
655    }
656}
657
658impl ManagedClient<JobRunner> {
659    /// A retrying client driving `program` on the real job-backed runner (no retry
660    /// until [`with_retry`](ManagedClient::with_retry)).
661    pub fn new(program: impl AsRef<OsStr>) -> Self {
662        Self {
663            inner: CliClient::new(program),
664            retry: RetryPolicy::none(),
665            credentials: None,
666            token_env: None,
667        }
668    }
669}
670
671impl<R: ProcessRunner> ManagedClient<R> {
672    /// A retrying client driving `program` on `runner` — inject a fake in tests.
673    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
674        Self {
675            inner: CliClient::with_runner(program, runner),
676            retry: RetryPolicy::none(),
677            credentials: None,
678            token_env: None,
679        }
680    }
681
682    /// Set the lock-contention retry policy (opt-in; default is no retry).
683    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
684        self.retry = policy;
685        self
686    }
687
688    /// The active retry policy.
689    pub fn retry_policy(&self) -> RetryPolicy {
690        self.retry
691    }
692
693    /// Attach a [`CredentialProvider`] (opt-in; default is none → ambient auth).
694    /// The provider is consulted per operation: automatically when a
695    /// [`with_token_env`](ManagedClient::with_token_env) binding is set, or
696    /// on demand via [`resolve_credential`](ManagedClient::resolve_credential).
697    ///
698    /// **Precedence:** a resolved token is injected *after* any
699    /// [`default_env`](ManagedClient::default_env), so the provider wins over a
700    /// static default and over the ambient CLI login. **Cancellation:** a
701    /// [`default_cancel_on`](ManagedClient::default_cancel_on) token bounds the
702    /// spawned *process*, not provider resolution — if your provider does slow I/O
703    /// (a vault lookup), bound it yourself.
704    #[must_use]
705    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
706        self.credentials = Some(provider);
707        self
708    }
709
710    /// Bind the resolved token to an environment variable injected on **every**
711    /// command this client runs (the forge case: `GH_TOKEN`, `GITLAB_TOKEN`). The
712    /// `service` tags the [`CredentialRequest`]. No effect without a provider.
713    #[must_use]
714    pub fn with_token_env(mut self, service: CredentialService, var: &'static str) -> Self {
715        self.token_env = Some((service, var));
716        self
717    }
718
719    /// Whether a credential provider is configured.
720    #[must_use]
721    pub fn has_credentials(&self) -> bool {
722        self.credentials.is_some()
723    }
724
725    /// Resolve a credential for `service`/`host` from the configured provider, or
726    /// `Ok(None)` if no provider is set or it defers to ambient auth. Backends
727    /// that inject the secret at the command site (git's `credential.helper`) call
728    /// this directly; the forge token-env path uses it internally.
729    pub async fn resolve_credential(
730        &self,
731        service: CredentialService,
732        host: Option<&str>,
733    ) -> Result<Option<Credential>> {
734        let Some(provider) = &self.credentials else {
735            return Ok(None);
736        };
737        let request = CredentialRequest { service, host };
738        // An empty (or whitespace-only) secret is not a usable credential —
739        // injecting an empty `GH_TOKEN`/`GITLAB_TOKEN` (or a `password=` line)
740        // would *override* the ambient login with nothing rather than defer to it.
741        // Treat it as `None` (ambient), keeping the "no usable credential ⇒
742        // ambient auth" contract consistent regardless of which adapter produced
743        // it (matching `EnvToken`'s own whitespace-only ⇒ unset rule).
744        Ok(provider
745            .credential(&request)
746            .await?
747            .filter(|cred| !cred.secret().expose().trim().is_empty()))
748    }
749
750    /// Materialize `call` into a [`Command`], injecting the forge token env if a
751    /// [`with_token_env`](ManagedClient::with_token_env) binding and a provider
752    /// are both configured. The single place the auto-injection happens, shared by
753    /// every retrying verb.
754    async fn prepare(&self, call: impl IntoCommand<R>) -> Result<Command> {
755        let cmd = call.into_command(&self.inner);
756        let Some((service, var)) = self.token_env else {
757            return Ok(cmd);
758        };
759        match self.resolve_credential(service, None).await? {
760            Some(cred) => Ok(cmd.env(var, cred.secret().expose())),
761            None => Ok(cmd),
762        }
763    }
764
765    /// Apply a default timeout to every command this client builds.
766    pub fn default_timeout(mut self, timeout: Duration) -> Self {
767        self.inner = self.inner.default_timeout(timeout);
768        self
769    }
770
771    /// Set an environment variable on every command this client builds.
772    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
773        self.inner = self.inner.default_env(key, value);
774        self
775    }
776
777    /// Remove an inherited environment variable on every command this client builds.
778    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
779        self.inner = self.inner.default_env_remove(key);
780        self
781    }
782
783    /// Cancel every command this client builds when `token` fires.
784    pub fn default_cancel_on(mut self, token: processkit::CancellationToken) -> Self {
785        self.inner = self.inner.default_cancel_on(token);
786        self
787    }
788
789    /// Build a [`Command`] for this client's program (passthrough).
790    pub fn command<I, S>(&self, args: I) -> Command
791    where
792        I: IntoIterator<Item = S>,
793        S: AsRef<OsStr>,
794    {
795        self.inner.command(args)
796    }
797
798    /// Build a [`Command`] bound to `dir` (passthrough).
799    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
800    where
801        I: IntoIterator<Item = S>,
802        S: AsRef<OsStr>,
803    {
804        self.inner.command_in(dir, args)
805    }
806
807    /// The underlying process runner (passthrough — e.g. for `output_all`).
808    pub fn runner(&self) -> &R {
809        self.inner.runner()
810    }
811
812    /// Like [`CliClient::run`], with credential injection and lock-retry.
813    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
814        let cmd = self.prepare(call).await?;
815        retry_async(&self.retry, is_lock_contention, || {
816            self.inner.run(cmd.clone())
817        })
818        .await
819    }
820
821    /// Like [`CliClient::run_unit`], with credential injection and lock-retry.
822    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
823        let cmd = self.prepare(call).await?;
824        retry_async(&self.retry, is_lock_contention, || {
825            self.inner.run_unit(cmd.clone())
826        })
827        .await
828    }
829
830    /// Like [`CliClient::output_string`], with credential injection. **No lock-retry:**
831    /// `output_string` returns `Ok` on a non-zero exit (it captures the result), so a
832    /// lock failure surfaces as an `Ok` here, not an `Err` the retry predicate could
833    /// match — route mutations that need lock-retry through
834    /// [`run`](Self::run)/[`run_unit`](Self::run_unit) instead.
835    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
836        let cmd = self.prepare(call).await?;
837        self.inner.output_string(cmd).await
838    }
839
840    /// Like [`run`](Self::run), but returns stdout **verbatim** — no `trim_end`.
841    /// For **content**-returning verbs (a file's bytes at a rev, a diff, a raw
842    /// template render) where the trailing newline(s) are part of the value, not
843    /// noise: trimming them corrupts a read-modify-write round-trip and desyncs a
844    /// diff's last hunk from its `@@` line count. Exit-checked like `run`; no
845    /// lock-retry (a content read is not a mutation).
846    pub async fn run_untrimmed(&self, call: impl IntoCommand<R>) -> Result<String> {
847        Ok(self
848            .output_string(call)
849            .await?
850            .ensure_success()?
851            .into_stdout())
852    }
853
854    /// Like [`CliClient::probe`] (zero-or-nonzero exit → `bool`), with credential
855    /// injection and lock-retry.
856    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
857        let cmd = self.prepare(call).await?;
858        retry_async(&self.retry, is_lock_contention, || {
859            self.inner.probe(cmd.clone())
860        })
861        .await
862    }
863
864    /// Like [`CliClient::exit_code`] (the raw exit code; a spawn failure or timeout
865    /// still errors), with credential injection and lock-retry.
866    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
867        let cmd = self.prepare(call).await?;
868        retry_async(&self.retry, is_lock_contention, || {
869            self.inner.exit_code(cmd.clone())
870        })
871        .await
872    }
873
874    /// Like [`CliClient::parse`] (credential injection applied; the `FnOnce` parser
875    /// can't be re-run, so lock-retry does not — parsing is a read, where lock
876    /// contention is not a concern anyway).
877    pub async fn parse<T>(
878        &self,
879        call: impl IntoCommand<R>,
880        parser: impl FnOnce(&str) -> T + Send,
881    ) -> Result<T>
882    where
883        T: Send,
884    {
885        let cmd = self.prepare(call).await?;
886        self.inner.parse(cmd, parser).await
887    }
888
889    /// Like [`CliClient::try_parse`] (credential injection applied; `FnOnce` parser,
890    /// and a read, so no lock-retry).
891    pub async fn try_parse<T>(
892        &self,
893        call: impl IntoCommand<R>,
894        parser: impl FnOnce(&str) -> Result<T> + Send,
895    ) -> Result<T>
896    where
897        T: Send,
898    {
899        let cmd = self.prepare(call).await?;
900        self.inner.try_parse(cmd, parser).await
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    #[test]
909    fn rejects_empty_and_leading_dash() {
910        assert!(reject_flag_like("git", "branch name", "-evil").is_err());
911        assert!(reject_flag_like("git", "branch name", "").is_err());
912        // Whitespace-only is as meaning-changing as empty — refuse it too.
913        assert!(reject_flag_like("git", "branch name", "  ").is_err());
914        assert!(reject_flag_like("git", "branch name", "\t").is_err());
915        assert!(reject_flag_like("git", "branch name", "feature").is_ok());
916        // Leading whitespace before a dash is still refused (the flag-check trims).
917        assert!(reject_flag_like("git", "remote", " --upload-pack=evil").is_err());
918        assert!(reject_flag_like("git", "remote", "\t-x").is_err());
919        // An interior NUL is refused (can't go in argv; opaque OS error otherwise).
920        assert!(reject_flag_like("git", "path", "a\0b").is_err());
921        // A leading-whitespace non-flag value is still accepted (not flag-like).
922        assert!(reject_flag_like("git", "branch name", "  feature").is_ok());
923        // The error names the program and surfaces as a spawn-side refusal.
924        let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
925        assert!(matches!(err, Error::Spawn { program, .. } if program == "jj"));
926    }
927
928    #[test]
929    fn classifies_merge_conflict() {
930        let on_stdout = Error::Exit {
931            program: "git".into(),
932            code: 1,
933            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
934            stderr: String::new(),
935        };
936        let on_stderr = Error::Exit {
937            program: "git".into(),
938            code: 1,
939            stdout: String::new(),
940            stderr: "Automatic merge failed; fix conflicts and then commit".into(),
941        };
942        let unrelated = Error::Exit {
943            program: "git".into(),
944            code: 128,
945            stdout: String::new(),
946            stderr: "fatal: not a git repository".into(),
947        };
948        assert!(is_merge_conflict(&on_stdout));
949        assert!(is_merge_conflict(&on_stderr));
950        assert!(!is_merge_conflict(&unrelated));
951        assert!(!is_nothing_to_commit(&on_stdout));
952    }
953
954    #[test]
955    fn classifies_nothing_to_commit_and_transient_fetch() {
956        let nothing = Error::Exit {
957            program: "git".into(),
958            code: 1,
959            stdout: "nothing to commit, working tree clean".into(),
960            stderr: String::new(),
961        };
962        assert!(is_nothing_to_commit(&nothing));
963
964        let dns = Error::Exit {
965            program: "git".into(),
966            code: 128,
967            stdout: String::new(),
968            stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
969        };
970        assert!(is_transient_fetch_error(&dns));
971        assert!(!is_transient_fetch_error(&nothing));
972
973        // A processkit timeout is deliberately NOT retried (R6): it already consumed
974        // the caller's full deadline, so retrying would multiply the wall-clock by
975        // FETCH_ATTEMPTS. The deadline is the patience budget; raise it, don't triple it.
976        let timeout = Error::Timeout {
977            program: "git".into(),
978            timeout: Duration::from_secs(10),
979            stdout: String::new(),
980            stderr: String::new(),
981        };
982        assert!(!is_transient_fetch_error(&timeout));
983    }
984
985    // R9: an io-level transient from the spawn (EINTR / EAGAIN / busy) is fetch-
986    // retryable too, via processkit's `Error::is_transient()`.
987    #[test]
988    fn classifies_io_transient_as_fetch_retryable() {
989        let interrupted = Error::Spawn {
990            program: "git".into(),
991            source: std::io::Error::from(std::io::ErrorKind::Interrupted),
992        };
993        assert!(
994            interrupted.is_transient(),
995            "processkit treats Interrupted as a transient io error"
996        );
997        assert!(is_transient_fetch_error(&interrupted));
998        // A non-transient io error (e.g. NotFound — the binary is missing) is not retried.
999        let missing = Error::Spawn {
1000            program: "git".into(),
1001            source: std::io::Error::from(std::io::ErrorKind::NotFound),
1002        };
1003        assert!(!is_transient_fetch_error(&missing));
1004    }
1005
1006    // R2: regression for the processkit 0.9.1 untruncated-`Error::Exit` fix. A large
1007    // output (well past the old 4 KiB cap) with the decisive marker near the END must
1008    // still classify — proving the classifiers see the whole captured stream.
1009    #[test]
1010    fn classifies_on_large_output_past_the_old_4kib_cap() {
1011        let padding = "noise line that says nothing\n".repeat(500); // ~14 KiB
1012        let conflict = Error::Exit {
1013            program: "git".into(),
1014            code: 1,
1015            stdout: format!("{padding}CONFLICT (content): Merge conflict in late.rs"),
1016            stderr: String::new(),
1017        };
1018        assert!(
1019            is_merge_conflict(&conflict),
1020            "a conflict marker past 4 KiB must still classify"
1021        );
1022
1023        let transient = Error::Exit {
1024            program: "git".into(),
1025            code: 128,
1026            stdout: String::new(),
1027            stderr: format!("{padding}fatal: unable to access: Could not resolve host: x"),
1028        };
1029        assert!(is_transient_fetch_error(&transient));
1030    }
1031
1032    // processkit's `Error` is `#[non_exhaustive]` and grows variants over time
1033    // (`NotReady`/`Unsupported`/`CassetteMiss`/`NotFound`/`Signalled`/`Cancelled`/
1034    // `ResourceLimit`). Unfamiliar variants must fall through every classifier to
1035    // "no" — a not-ready or unsupported run is neither a conflict, nor a clean
1036    // tree, nor worth a fetch retry.
1037    #[test]
1038    fn unfamiliar_error_variants_are_not_classified() {
1039        let not_ready = Error::NotReady {
1040            program: "git".into(),
1041            timeout: Duration::from_secs(5),
1042        };
1043        let unsupported = Error::Unsupported {
1044            operation: "suspend".into(),
1045        };
1046        for err in [&not_ready, &unsupported] {
1047            assert!(!is_merge_conflict(err));
1048            assert!(!is_nothing_to_commit(err));
1049            assert!(!is_transient_fetch_error(err));
1050        }
1051    }
1052
1053    // `Error::Cancelled` (a client-level `default_cancel_on` killing an in-flight
1054    // run; always available since cancellation became core in processkit 0.10) must
1055    // fall through every classifier to "no" — a cancelled fetch was *deliberately*
1056    // stopped, so replaying it would fight the cancellation. (Behaviour already held
1057    // via the `#[non_exhaustive]` fall-through above; this pins it as a first-class
1058    // assertion.)
1059    #[test]
1060    fn cancelled_is_not_transient_or_otherwise_classified() {
1061        let cancelled = Error::Cancelled {
1062            program: "git".into(),
1063        };
1064        assert!(!is_transient_fetch_error(&cancelled));
1065        assert!(!is_merge_conflict(&cancelled));
1066        assert!(!is_nothing_to_commit(&cancelled));
1067    }
1068
1069    // `Error::Signalled` (a process killed by a signal — e.g. an external SIGTERM/
1070    // SIGKILL, surfaced first-class since processkit 0.9.2 and carrying partial
1071    // `stdout`/`stderr` since 0.10) is *terminal*, not transient: a deliberate kill
1072    // should not be auto-retried, and a signal death is neither a merge conflict nor
1073    // a clean tree. processkit's own `is_transient()` agrees (false for `Signalled`),
1074    // so it falls through every classifier to "no" — pinned here, including the case
1075    // where the captured stderr happens to contain an otherwise-transient marker (a
1076    // killed fetch is still not ours to silently replay).
1077    #[test]
1078    fn signalled_is_terminal_not_transient() {
1079        let signalled = Error::Signalled {
1080            program: "git".into(),
1081            signal: Some(15),
1082            stdout: String::new(),
1083            stderr: "fatal: unable to access: Could not resolve host: x".into(),
1084        };
1085        assert!(!signalled.is_transient());
1086        assert!(!is_transient_fetch_error(&signalled));
1087        assert!(!is_merge_conflict(&signalled));
1088        assert!(!is_nothing_to_commit(&signalled));
1089    }
1090
1091    fn exit(program: &str, code: i32, stderr: &str) -> Error {
1092        Error::Exit {
1093            program: program.into(),
1094            code,
1095            stdout: String::new(),
1096            stderr: stderr.into(),
1097        }
1098    }
1099
1100    // `is_lock_contention` recognises ONLY the *whole-repo* / working-copy lock
1101    // failures (git index.lock, jj working-copy/op-heads lock) — the ones where the
1102    // command did nothing, so a retry is idempotent even on a mutation. Per-ref lock
1103    // failures and conflicts/timeouts are deliberately NOT classified (a multi-ref
1104    // op can fail a ref lock mid-way, where a retry would not be idempotent).
1105    #[test]
1106    fn classifies_lock_contention() {
1107        let lock_failures = [
1108            // git always names `index.lock` (locale-stable) in the lock-contention
1109            // message, even on a non-English runner where the surrounding prose is
1110            // translated.
1111            exit(
1112                "git",
1113                128,
1114                "fatal: Unable to create '/r/.git/index.lock': File exists.",
1115            ),
1116            // A German runner: the path fragment `index.lock` still matches.
1117            exit(
1118                "git",
1119                128,
1120                "fatal: Konnte '/r/.git/index.lock' nicht erstellen: Datei existiert bereits",
1121            ),
1122            // jj's *actual* wordings (verified against jj source) — note no "the".
1123            exit("jj", 1, "Error: Failed to lock working copy"),
1124            exit("jj", 1, "Error: Failed to lock operation heads store"),
1125        ];
1126        for e in &lock_failures {
1127            assert!(is_lock_contention(e), "should be lock contention: {e:?}");
1128            // A lock failure is NOT a transient *fetch* error — different class.
1129            assert!(!is_transient_fetch_error(e), "not a fetch error: {e:?}");
1130        }
1131        let not_locks = [
1132            exit("git", 1, "CONFLICT (content): Merge conflict in a.rs"),
1133            exit("git", 1, "error: pathspec 'x' did not match any file(s)"),
1134            exit("git", 128, "fatal: not a git repository"),
1135            // Per-ref locks are NOT classified — a multi-ref push/fetch can fail a
1136            // ref lock after earlier refs already moved (non-idempotent to replay).
1137            exit(
1138                "git",
1139                1,
1140                "error: cannot lock ref 'refs/heads/x': reference already exists",
1141            ),
1142            exit(
1143                "git",
1144                128,
1145                "Unable to create '/r/.git/packed-refs.lock': File exists.",
1146            ),
1147            // A per-ref lock for a branch literally named `index`: its
1148            // `…/refs/heads/index.lock` path contains the substring `index.lock`,
1149            // but the `refs/` mention correctly rules it out (not a whole-repo lock).
1150            exit(
1151                "git",
1152                128,
1153                "error: cannot lock ref 'refs/heads/index': Unable to create \
1154                 '/r/.git/refs/heads/index.lock': File exists.",
1155            ),
1156            Error::Timeout {
1157                program: "git".into(),
1158                timeout: Duration::from_secs(1),
1159                stdout: String::new(),
1160                stderr: String::new(),
1161            },
1162        ];
1163        for e in &not_locks {
1164            assert!(
1165                !is_lock_contention(e),
1166                "should NOT be lock contention: {e:?}"
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn classifies_invalid_input_from_the_guards() {
1173        // What `reject_flag_like` / the newtypes actually produce.
1174        let rejected = reject_flag_like("git", "reference", "-x").unwrap_err();
1175        assert!(
1176            is_invalid_input(&rejected),
1177            "guard rejection is invalid input"
1178        );
1179        assert!(is_invalid_input(
1180            &reject_flag_like("git", "x", "").unwrap_err()
1181        ));
1182
1183        // A real spawn failure (missing binary), a non-zero exit, and a timeout are
1184        // NOT invalid input — they're environment/usage failures, not a bad argument.
1185        let not_input = [
1186            Error::Spawn {
1187                program: "git".into(),
1188                source: std::io::Error::from(std::io::ErrorKind::NotFound),
1189            },
1190            exit("git", 1, "fatal: not a git repository"),
1191            Error::Timeout {
1192                program: "git".into(),
1193                timeout: Duration::from_secs(1),
1194                stdout: String::new(),
1195                stderr: String::new(),
1196            },
1197        ];
1198        for e in &not_input {
1199            assert!(!is_invalid_input(e), "should NOT be invalid input: {e:?}");
1200        }
1201    }
1202
1203    // Backoff is exponential off the base, capped at `max_backoff`, and zero when
1204    // there's no base (immediate retry).
1205    #[test]
1206    fn backoff_is_exponential_capped_and_zero_without_base() {
1207        let p = RetryPolicy::none()
1208            .attempts(6)
1209            .base_backoff(Duration::from_millis(10))
1210            .max_backoff(Duration::from_millis(80));
1211        assert_eq!(backoff_for(&p, 0), Duration::from_millis(10));
1212        assert_eq!(backoff_for(&p, 1), Duration::from_millis(20));
1213        assert_eq!(backoff_for(&p, 2), Duration::from_millis(40));
1214        assert_eq!(backoff_for(&p, 3), Duration::from_millis(80));
1215        assert_eq!(
1216            backoff_for(&p, 4),
1217            Duration::from_millis(80),
1218            "capped at max"
1219        );
1220        assert_eq!(
1221            backoff_for(&RetryPolicy::none(), 3),
1222            Duration::ZERO,
1223            "no base → no wait"
1224        );
1225    }
1226
1227    // Full jitter (used by `RetryPolicy::lock_contention`): every sampled backoff
1228    // stays within `[0, exponential cap]`, and successive samples de-correlate
1229    // (more than one distinct value) so retries don't thunder together. Pins the
1230    // jitter path, which the exponential test above deliberately turns off.
1231    #[test]
1232    fn jitter_stays_within_cap_and_decorrelates() {
1233        let p = RetryPolicy::none()
1234            .attempts(8)
1235            .base_backoff(Duration::from_millis(10))
1236            .max_backoff(Duration::from_millis(80))
1237            .with_jitter(true);
1238        // The cap at retry_index 3 is the full 80ms exponential value.
1239        let cap = Duration::from_millis(80);
1240        let mut seen = std::collections::HashSet::new();
1241        for _ in 0..1000 {
1242            let d = backoff_for(&p, 3);
1243            assert!(
1244                d <= cap,
1245                "jittered backoff {d:?} must stay within the cap {cap:?}"
1246            );
1247            seen.insert(d.as_nanos());
1248        }
1249        assert!(
1250            seen.len() > 1,
1251            "full jitter must produce a spread of delays, not a constant"
1252        );
1253        // A zero base still short-circuits to zero even with jitter on.
1254        assert_eq!(
1255            backoff_for(&RetryPolicy::none().with_jitter(true), 2),
1256            Duration::ZERO
1257        );
1258    }
1259
1260    // The executor: retries while the predicate matches and attempts remain, returns
1261    // the first Ok, doesn't retry a non-matching error, and exhausts to the last Err.
1262    #[tokio::test]
1263    async fn retry_async_retries_then_succeeds_and_respects_the_predicate() {
1264        use std::sync::atomic::{AtomicU32, Ordering};
1265        // Zero backoff → no sleep, deterministic & fast.
1266        let policy = RetryPolicy::none().attempts(4);
1267        let lock = || {
1268            exit(
1269                "git",
1270                128,
1271                "Unable to create '/r/.git/index.lock': File exists.",
1272            )
1273        };
1274
1275        // Fails twice with a lock error, then succeeds — retried to success.
1276        let calls = AtomicU32::new(0);
1277        let out: Result<u32> = retry_async(&policy, is_lock_contention, || {
1278            let n = calls.fetch_add(1, Ordering::SeqCst);
1279            let lock = lock();
1280            async move { if n < 2 { Err(lock) } else { Ok(n) } }
1281        })
1282        .await;
1283        assert_eq!(out.unwrap(), 2);
1284        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 try + 2 retries");
1285
1286        // A non-lock error is returned immediately (not retried).
1287        let calls = AtomicU32::new(0);
1288        let out: Result<u32> = retry_async(&policy, is_lock_contention, || {
1289            calls.fetch_add(1, Ordering::SeqCst);
1290            async { Err(exit("git", 1, "real, deterministic failure")) }
1291        })
1292        .await;
1293        assert!(out.is_err());
1294        assert_eq!(
1295            calls.load(Ordering::SeqCst),
1296            1,
1297            "non-retryable → single attempt"
1298        );
1299
1300        // Persistent lock contention exhausts the attempt budget.
1301        let calls = AtomicU32::new(0);
1302        let out: Result<u32> = retry_async(&policy, is_lock_contention, || {
1303            calls.fetch_add(1, Ordering::SeqCst);
1304            async { Err(exit("git", 128, "index.lock': File exists")) }
1305        })
1306        .await;
1307        assert!(out.is_err());
1308        assert_eq!(calls.load(Ordering::SeqCst), 4, "all attempts used");
1309    }
1310
1311    // `resolve_credential` returns `None` until a provider is attached, then the
1312    // provider's credential. (No process is spawned, so the real runner is fine.)
1313    #[tokio::test]
1314    async fn retrying_client_resolves_credential_opt_in() {
1315        let client = ManagedClient::new("git");
1316        assert!(!client.has_credentials());
1317        assert!(
1318            client
1319                .resolve_credential(CredentialService::Git, None)
1320                .await
1321                .unwrap()
1322                .is_none(),
1323            "no provider → ambient (None)"
1324        );
1325
1326        let client = client.with_credentials(Arc::new(StaticCredential::token("t0k")));
1327        assert!(client.has_credentials());
1328        let got = client
1329            .resolve_credential(CredentialService::Git, None)
1330            .await
1331            .unwrap()
1332            .expect("provider yields a credential");
1333        assert_eq!(got.secret().expose(), "t0k");
1334    }
1335
1336    // An empty (or whitespace-only) secret is treated as `None` (ambient):
1337    // injecting an empty token would override the ambient login with nothing
1338    // instead of deferring to it. Mirrors `EnvToken`'s whitespace-only ⇒ unset rule.
1339    #[tokio::test]
1340    async fn resolve_credential_treats_empty_secret_as_ambient() {
1341        // Service-agnostic: both the forge (token-env) and git (helper) paths route
1342        // through this chokepoint, so a blank secret is ambient for either.
1343        for blank in ["", "   ", "\t\n"] {
1344            let client = ManagedClient::new("git")
1345                .with_credentials(Arc::new(StaticCredential::token(blank)));
1346            for service in [CredentialService::GitHub, CredentialService::Git] {
1347                assert!(
1348                    client
1349                        .resolve_credential(service, None)
1350                        .await
1351                        .unwrap()
1352                        .is_none(),
1353                    "blank secret {blank:?} → ambient (None) for {service:?}"
1354                );
1355            }
1356        }
1357    }
1358}