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(¬hing));
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(¬hing));
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 [¬_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 ¬_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 ¬_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}