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. A
38//!   [`default_cancel_on`](ManagedClient::default_cancel_on) token also cuts the
39//!   backoff short: cancelling mid-retry returns a structured [`Error::Cancelled`]
40//!   at once instead of sleeping out the remaining delay.
41//! - **[`CredentialProvider`] / [`Credential`] / [`Secret`]** — an opt-in seam for
42//!   supplying a secret *per operation* (a CI token, a vault lookup) instead of
43//!   relying on ambient CLI auth. `ManagedClient` injects the resolved token into
44//!   each command (the forge `GH_TOKEN`/`GITLAB_TOKEN` env); git uses
45//!   [`git_credential_helper`] to keep the secret out of `argv`. Default is no
46//!   provider → ambient auth, unchanged. See the [`credentials`](mod@credentials)
47//!   module for the full picture.
48//!
49//! # Recipes
50//!
51//! Classify a failed `fetch` to drive a retry decision — branch on intent, not on
52//! the error's internals:
53//!
54//! ```no_run
55//! use vcs_cli_support::{is_transient_fetch_error, FETCH_ATTEMPTS, FETCH_BACKOFF};
56//! # fn run() -> Result<(), processkit::Error> { todo!() }
57//! # fn demo() -> Result<(), processkit::Error> {
58//! for attempt in 1..=FETCH_ATTEMPTS {
59//!     match run() {
60//!         Ok(()) => break,
61//!         Err(e) if is_transient_fetch_error(&e) && attempt < FETCH_ATTEMPTS => {
62//!             std::thread::sleep(FETCH_BACKOFF); // DNS / dropped connection — worth a retry
63//!         }
64//!         Err(e) => return Err(e),               // anything else: give up
65//!     }
66//! }
67//! # Ok(()) }
68//! ```
69
70use std::ffi::OsStr;
71use std::fmt;
72use std::future::Future;
73use std::path::Path;
74use std::sync::Arc;
75use std::time::Duration;
76
77use processkit::{
78    CancellationToken, CliClient, Command, Error, IntoCommand, JobRunner, OutputBufferPolicy,
79    OverflowMode, ProcessResult, ProcessRunner, Result,
80};
81
82pub mod credentials;
83pub use credentials::{
84    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
85    GitCredentialHelper, Secret, StaticCredential, git_credential_helper, https_host, provider_fn,
86};
87
88/// JSON helpers shared by the forge wrappers, behind the `serde` feature — so the
89/// three forge parsers share one `null -> ""` and parse-error convention.
90#[cfg(feature = "serde")]
91#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
92pub mod json {
93    use processkit::{Error, Result};
94    use serde::Deserialize;
95    use serde::de::DeserializeOwned;
96
97    /// Deserialize a `String` a forge CLI may send as JSON `null` for an empty
98    /// optional value: `null` -> empty string, same as an absent key. `#[serde(default)]`
99    /// alone covers only an absent key; a present `null` would fail the whole-object
100    /// parse. Use as `#[serde(deserialize_with = "vcs_cli_support::json::null_to_empty")]`.
101    pub fn null_to_empty<'de, D>(deserializer: D) -> ::core::result::Result<String, D::Error>
102    where
103        D: serde::Deserializer<'de>,
104    {
105        Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
106    }
107
108    /// Deserialize a forge CLI's `--json` output into `T`, mapping a parse failure to
109    /// [`Error::Parse`] tagged with `program` (the CLI's binary name).
110    pub fn from_json<T: DeserializeOwned>(program: &str, json: &str) -> Result<T> {
111        serde_json::from_str(json).map_err(|e| Error::parse(program, e.to_string()))
112    }
113}
114
115/// A configurable ceiling on how much output a potentially large **content**
116/// operation may buffer before it is refused — a diff (`diff_text`/`diff`), a
117/// file's bytes at a revision (`show_file`/`file_show`), a forge PR/MR diff
118/// (`pr_diff`), and the diagnostic (error/progress) output of `clone`/`fetch`.
119///
120/// This is the single, shared knob the CLI wrappers (`vcs-git`, `vcs-jj`, the
121/// forge crates) and the facades (`vcs-core`, `vcs-forge`, the MCP server) all
122/// use, so the limit is configured and reasoned about one way across the
123/// workspace instead of one ad-hoc cap per client. Set a per-client default with
124/// each client's `default_output_budget(...)` builder (inherited by any facade
125/// built over that client); raise or lower it for a single call with the
126/// `*_within` method variants (`diff_text_within`, `show_file_within`,
127/// `pr_diff_within`, …). There is **no un-overridable global constant** — the
128/// default is [`unlimited`](OutputBudget::unlimited) (retain everything, the
129/// pre-budget behaviour), and every cap is a caller choice.
130///
131/// It projects onto two [`processkit`] [`OutputBufferPolicy`] shapes, so one
132/// budget drives both kinds of bounded output:
133///
134/// - [`content_policy`](OutputBudget::content_policy) — a **fail-loud** ceiling
135///   ([`OverflowMode::Error`]): once the cap is reached the run errors with
136///   [`Error::OutputTooLarge`], carrying the actual (`total_lines`/`total_bytes`)
137///   and allowed (`max_lines`/`max_bytes`) sizes. The pipe is still drained (the
138///   child never blocks) and output past the ceiling is **counted but never
139///   retained**, so memory stays bounded and a truncated result is never handed
140///   back as if complete. This is what the content verbs use.
141/// - [`diagnostic_policy`](OutputBudget::diagnostic_policy) — a **drop-oldest**
142///   tail bound: caps the retained error/progress output of a discard verb
143///   (`clone`/`fetch`) *without* converting a real failure into
144///   `OutputTooLarge`, so transient-failure classification still reads the
145///   (tail-preserved) message. This is the same shape the `gh run watch` cap
146///   uses.
147///
148/// The byte ceiling ([`bytes`](OutputBudget::bytes)) is the load-bearing memory
149/// bound: the content verbs capture raw stdout (no line splitting), where the
150/// byte cap — not the line cap — is what [`processkit`] enforces. A line ceiling
151/// ([`with_max_lines`](OutputBudget::with_max_lines)) is an optional extra that
152/// also bounds line-pumped output (a diagnostic stream, a verb's stderr).
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct OutputBudget {
155    max_bytes: Option<usize>,
156    max_lines: Option<usize>,
157}
158
159impl OutputBudget {
160    /// No ceiling — retain everything (the default, and the pre-budget
161    /// behaviour). [`content_policy`](Self::content_policy) /
162    /// [`diagnostic_policy`](Self::diagnostic_policy) return `None`, leaving the
163    /// command's own (unbounded) buffer untouched.
164    pub const fn unlimited() -> Self {
165        Self {
166            max_bytes: None,
167            max_lines: None,
168        }
169    }
170
171    /// A byte ceiling of `max_bytes` (the retained-text size, the unit
172    /// [`OutputBufferPolicy::max_bytes`] caps). The primary, memory-bounding
173    /// knob: it applies to the raw-stdout content path where a line cap would
174    /// not. Add a line ceiling with [`with_max_lines`](Self::with_max_lines).
175    pub const fn bytes(max_bytes: usize) -> Self {
176        Self {
177            max_bytes: Some(max_bytes),
178            max_lines: None,
179        }
180    }
181
182    /// Add a line ceiling of `max_lines` (an extra bound on line-pumped output —
183    /// diagnostics, a verb's stderr). Composes with any [`bytes`](Self::bytes)
184    /// cap; whichever ceiling is reached first fires.
185    #[must_use]
186    pub const fn with_max_lines(mut self, max_lines: usize) -> Self {
187        self.max_lines = Some(max_lines);
188        self
189    }
190
191    /// Whether no ceiling is set (retain everything).
192    pub const fn is_unlimited(&self) -> bool {
193        self.max_bytes.is_none() && self.max_lines.is_none()
194    }
195
196    /// The configured byte ceiling, if any.
197    pub const fn max_bytes(&self) -> Option<usize> {
198        self.max_bytes
199    }
200
201    /// The configured line ceiling, if any.
202    pub const fn max_lines(&self) -> Option<usize> {
203        self.max_lines
204    }
205
206    /// The **fail-loud** [`OutputBufferPolicy`] for a content verb — errors with
207    /// [`Error::OutputTooLarge`] once the ceiling is reached, never retaining or
208    /// returning a truncated tail. `None` when [`unlimited`](Self::unlimited)
209    /// (leave the command's default buffer).
210    pub fn content_policy(&self) -> Option<OutputBufferPolicy> {
211        if self.is_unlimited() {
212            return None;
213        }
214        // A byte cap (Some) keeps the fail-loud ceiling honest even with no line
215        // cap: `OverflowMode::Error` is "zero-tolerance" only when *neither* cap
216        // is set, so setting `max_bytes` gives it a real ceiling to fire on.
217        let mut policy = match self.max_lines {
218            Some(lines) => OutputBufferPolicy::fail_loud(lines),
219            None => OutputBufferPolicy::unbounded().with_overflow(OverflowMode::Error),
220        };
221        if let Some(bytes) = self.max_bytes {
222            policy = policy.with_max_bytes(bytes);
223        }
224        Some(policy)
225    }
226
227    /// The **drop-oldest** [`OutputBufferPolicy`] for a discard verb's diagnostic
228    /// output (`clone`/`fetch`): keeps the last `max_bytes`/`max_lines` (the tail,
229    /// where a CLI's fatal line sits) and flags truncation, but does **not** raise
230    /// [`Error::OutputTooLarge`] — so a genuine failure still surfaces as
231    /// `Error::Exit` and stays classifiable ([`is_transient_fetch_error`],
232    /// [`is_lock_contention`]). `None` when [`unlimited`](Self::unlimited).
233    pub fn diagnostic_policy(&self) -> Option<OutputBufferPolicy> {
234        if self.is_unlimited() {
235            return None;
236        }
237        let mut policy = match self.max_lines {
238            Some(lines) => OutputBufferPolicy::bounded(lines),
239            None => OutputBufferPolicy::unbounded(),
240        };
241        if let Some(bytes) = self.max_bytes {
242            policy = policy.with_max_bytes(bytes);
243        }
244        Some(policy)
245    }
246}
247
248impl Default for OutputBudget {
249    /// [`unlimited`](OutputBudget::unlimited) — the budget is opt-in.
250    fn default() -> Self {
251        Self::unlimited()
252    }
253}
254
255/// Generate the cwd-bound forwarders for a CLI wrapper's `…At` view.
256///
257/// Each CLI wrapper (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`, `vcs-gitea`)
258/// exposes a cwd-bound view — `GitAt`, `JjAt`, `GitHubAt`, `GitLabAt`, `GiteaAt` —
259/// that holds a reference to the client plus a pre-bound `dir`, and re-exposes the
260/// client's methods with `dir` already supplied. The forwarder bodies are
261/// byte-identical across the five backends but for a handful of names, so they live
262/// here once instead of as a copied `macro_rules!` per crate:
263///
264/// - `$view` — the bound view type (e.g. `GitAt`). It must be generic over
265///   `<'a, R: ProcessRunner>` and have a field named `$field` holding the client
266///   plus a `dir: &'a Path` field.
267/// - `$field` — the inner field naming the client (e.g. `git`, `gh`, `glab`,
268///   `tea`).
269/// - `$client` — a **string literal** naming the client type, used in the
270///   generated doc strings and rendered as an intra-doc link (e.g. `"Git"` →
271///   ``[`Git`]``).
272/// - `bare { … }` — methods forwarded verbatim to `self.$field`. Reserve this for
273///   the genuinely dir-*independent* calls (`version`, `capabilities`, a
274///   `clone`/`git_clone` that names its own destination): the view drops `dir`
275///   entirely, so a `bare` method never touches it.
276/// - `dir  { … }` — methods that take `self.dir` as their first argument.
277/// - `raw  { fn view(args…) -> Ret => target; … }` — the **raw escape hatches**
278///   (`run`/`run_raw`/`run_args`/`run_raw_args`). These used to sit in `bare`, so
279///   `git.at(dir).run(…)` silently ran in the *process* cwd, not the bound `dir` —
280///   a bound handle whose raw call could hit a different repository (M15/T-035).
281///   They are now **bound**: the view method `view` forwards to the client's
282///   dir-taking `target` (`self.$field.target(self.dir, args…)`), so a raw call
283///   *through the view* runs in `dir` like every other `…At` method. The
284///   **process-cwd** escape hatch is still there — call `run`/`run_raw`/… on the
285///   client itself (`git.run(…)`), not through `.at(dir)`.
286///
287/// The argument and return types in the method lists resolve in the **calling**
288/// crate, so they are written exactly as that wrapper's own methods are. The
289/// `ProcessRunner` bound is fully qualified (`::processkit::ProcessRunner`) so the
290/// expansion compiles regardless of which items the caller has imported.
291///
292/// ```ignore
293/// vcs_cli_support::at_forwarders! {
294///     GitAt, git, "Git",
295///     bare { fn version() -> Result<String>; }
296///     dir  { fn status() -> Result<Vec<StatusEntry>>; }
297///     raw  { fn run(args: &[String]) -> Result<String> => run_in; }
298/// }
299/// ```
300#[macro_export]
301macro_rules! at_forwarders {
302    (
303        $view:ident, $field:ident, $client:literal,
304        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
305        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
306        $( raw  { $( fn $rn:ident( $($ra:ident: $rt:ty),* $(,)? ) -> $rr:ty => $rtgt:ident; )* } )?
307    ) => {
308        impl<'a, R: ::processkit::ProcessRunner> $view<'a, R> {
309            $(
310                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($bn), "`.")]
311                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
312                    self.$field.$bn($($ba),*).await
313                }
314            )*
315            $(
316                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
317                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
318                    self.$field.$dn(self.dir, $($da),*).await
319                }
320            )*
321            $($(
322                #[doc = concat!(
323                    "Bound form of [`", $client, "`]'s `", stringify!($rn),
324                    "` raw escape hatch — runs the given argv **in the bound `dir`** \
325                     (forwards to the client's `", stringify!($rtgt), "`). For the \
326                     process-cwd escape hatch, call `", stringify!($rn),
327                    "` on [`", $client, "`] directly."
328                )]
329                pub async fn $rn(&self, $($ra: $rt),*) -> $rr {
330                    self.$field.$rtgt(self.dir, $($ra),*).await
331                }
332            )*)?
333        }
334    };
335}
336
337/// Emit the common client scaffold every CLI wrapper hand-writes around a
338/// [`ManagedClient`].
339///
340/// `vcs-git`, `vcs-jj`, `vcs-github`, and `vcs-gitlab` each wrap a
341/// [`ManagedClient`] in a thin newtype that re-exposes the same handful of
342/// constructors and default-applying builders — `new` / `Default` /
343/// `with_runner` / `default_timeout` / `default_env` / `default_env_remove` /
344/// `default_cancel_on` — with byte-identical bodies and doc strings. This macro
345/// generates that shared part so it can't drift between backends; each wrapper
346/// keeps its *capability* builders (`with_retry`, `with_credentials`, every verb,
347/// the `…At` view, …) hand-written in a separate `impl` block.
348///
349/// The generated newtype is `struct $name<R: ProcessRunner = JobRunner>` with a
350/// single private `core: ManagedClient<R>` field — accessible to the rest of the
351/// wrapper crate (same module). All paths are fully qualified, so the expansion
352/// compiles regardless of what the caller has imported.
353///
354/// - `$name` — the wrapper type (e.g. `Git`). The struct-level doc comment (and
355///   any other attributes) written before `struct` are attached to it verbatim.
356/// - `$binary` — the program the client drives (an expression, typically the
357///   crate's `BINARY` const).
358/// - `token_env = ($svc, $var)` — *optional*. When given, `new`/`with_runner`
359///   chain [`ManagedClient::with_token_env`] so a resolved credential is injected
360///   into the `$var` environment variable for service `$svc` (the forge case:
361///   `GH_TOKEN`, `GITLAB_TOKEN`). Omit it for the ambient-auth backends (git, jj).
362/// - `scrub_env = [ $var, … ]` — *optional*. When given, `new`/`with_runner`
363///   chain [`ManagedClient::default_env_remove`] for each var, so **every** client
364///   the macro generates drops those inherited environment variables by default
365///   (`vcs-git` uses it to scrub the repo-redirector vars — `GIT_DIR`, … — so a
366///   value leaking from the parent process can't retarget commands). Must come
367///   *after* `token_env` when both are present.
368///
369/// ```ignore
370/// vcs_cli_support::managed_client! {
371///     /// The real GitHub client.
372///     pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
373/// }
374/// vcs_cli_support::managed_client! {
375///     /// The real Git client — scrubs the repo-redirector env vars by default.
376///     pub struct Git => BINARY, scrub_env = ["GIT_DIR", "GIT_WORK_TREE"]
377/// }
378/// ```
379#[macro_export]
380macro_rules! managed_client {
381    (
382        $(#[$meta:meta])*
383        $vis:vis struct $name:ident => $binary:expr
384        $(, token_env = ($svc:expr, $var:expr) )?
385        $(, scrub_env = [ $($scrub:expr),* $(,)? ] )?
386        $(,)?
387    ) => {
388        $(#[$meta])*
389        $vis struct $name<R: ::processkit::ProcessRunner = ::processkit::JobRunner> {
390            core: $crate::ManagedClient<R>,
391        }
392
393        // Manual Debug: no `R: Debug` bound (matches `ManagedClient`'s own impl),
394        // delegating straight to `core` — `ManagedClient::fmt` already redacts any
395        // configured credential provider / token-env binding, so nothing secret
396        // reaches `{:?}` here either.
397        impl<R: ::processkit::ProcessRunner> ::core::fmt::Debug for $name<R> {
398            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
399                f.debug_struct(stringify!($name))
400                    .field("core", &self.core)
401                    .finish()
402            }
403        }
404
405        impl $name<::processkit::JobRunner> {
406            /// Create a client driving the real job-backed runner.
407            pub fn new() -> Self {
408                Self { core: $crate::ManagedClient::new($binary)
409                    $(.with_token_env($svc, $var))?
410                    $($(.default_env_remove($scrub))*)?
411                }
412            }
413        }
414
415        impl ::core::default::Default for $name<::processkit::JobRunner> {
416            fn default() -> Self {
417                Self::new()
418            }
419        }
420
421        impl<R: ::processkit::ProcessRunner> $name<R> {
422            /// Create a client driving `runner` — inject a fake in tests.
423            pub fn with_runner(runner: R) -> Self {
424                Self {
425                    core: $crate::ManagedClient::with_runner($binary, runner)
426                        $(.with_token_env($svc, $var))?
427                        $($(.default_env_remove($scrub))*)?,
428                }
429            }
430
431            /// Apply a default timeout to every command this client builds.
432            pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
433                self.core = self.core.default_timeout(timeout);
434                self
435            }
436
437            /// Set an environment variable on every command this client builds.
438            pub fn default_env(
439                mut self,
440                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
441                value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
442            ) -> Self {
443                self.core = self.core.default_env(key, value);
444                self
445            }
446
447            /// Remove an inherited environment variable on every command this client builds.
448            pub fn default_env_remove(
449                mut self,
450                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
451            ) -> Self {
452                self.core = self.core.default_env_remove(key);
453                self
454            }
455
456            /// Cancel every command this client builds when `token` fires.
457            pub fn default_cancel_on(mut self, token: ::processkit::CancellationToken) -> Self {
458                self.core = self.core.default_cancel_on(token);
459                self
460            }
461
462            /// Apply a default [`OutputBudget`](vcs_cli_support::OutputBudget) to the
463            /// potentially large **content** operations this client builds — the
464            /// diff/show/pr-diff verbs and the `clone`/`fetch` diagnostic capture.
465            /// Inherited by any facade built over this client. The default is
466            /// [`OutputBudget::unlimited`](vcs_cli_support::OutputBudget::unlimited)
467            /// (retain everything); a single call can still override it via the
468            /// `*_within` method variants.
469            pub fn default_output_budget(mut self, budget: $crate::OutputBudget) -> Self {
470                self.core = self.core.default_output_budget(budget);
471                self
472            }
473        }
474    };
475}
476
477/// Injection guard for bare positional argv slots: a caller-supplied value with a
478/// leading `-` would be parsed by the CLI as a *flag* (verified: `git checkout
479/// -evil` → "unknown switch"; jj likewise), and an empty (or whitespace-only)
480/// value silently changes most commands' meaning. Refuse both before anything
481/// spawns, surfacing an [`Error::Spawn`] naming `program`. An interior NUL is
482/// refused too (it can't be passed in argv and otherwise surfaces as an opaque
483/// OS spawn error). Flag-VALUE positions (`-m <msg>`, `--branch <b>`) don't need
484/// this — the CLI consumes the next token verbatim there.
485///
486/// The leading-`-` test is applied to the **trimmed** value, so a value like
487/// `" --upload-pack=…"` (leading whitespace) is still refused — the empty-check
488/// and the flag-check now agree on what "the value" is.
489pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
490    let trimmed = value.trim();
491    if trimmed.is_empty() || trimmed.starts_with('-') || value.contains('\0') {
492        return Err(Error::spawn(
493            program,
494            std::io::Error::new(
495                std::io::ErrorKind::InvalidInput,
496                format!(
497                    "{what} {value:?} would be parsed as a flag (or is empty / contains NUL) — \
498                     refusing to pass it as a positional argument"
499                ),
500            ),
501        ));
502    }
503    Ok(())
504}
505
506/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
507pub const FETCH_ATTEMPTS: u32 = 3;
508/// Fixed backoff between fetch retries.
509pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
510/// Grace period for a timed-out fetch: on the deadline processkit signals the
511/// process tree (terminate), waits this long for it to exit cleanly — flush, close
512/// the connection, drop any lock — then hard-kills. Only takes effect when a
513/// per-client timeout is set (`Git::default_timeout` / `Jj::default_timeout`); a
514/// fetch with no deadline is unaffected.
515pub const FETCH_TIMEOUT_GRACE: Duration = Duration::from_secs(2);
516
517/// Lower-case substrings marking a merge that stopped on conflicts.
518const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
519/// Lower-case substrings marking a commit that found nothing to record.
520const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
521/// Lower-case substrings marking a transient (retryable) network/fetch failure.
522/// The timeout markers are kept *specific* (`connection timed out` /
523/// `operation timed out`) rather than a bare `timed out`, which would also match
524/// unrelated, non-network "timed out" messages (a lock wait, a hook) and trigger a
525/// spurious fetch retry.
526const TRANSIENT_FETCH_MARKERS: &[&str] = &[
527    "could not resolve host",
528    "couldn't resolve host",
529    "temporary failure in name resolution",
530    "connection timed out",
531    "connection refused",
532    "operation timed out",
533    "network is unreachable",
534    "failed to connect",
535    "could not read from remote repository",
536    "the remote end hung up",
537    "early eof",
538    "rpc failed",
539];
540
541/// Whether `err` is an [`Error::Exit`] whose captured output contains any marker.
542fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
543    let Error::Exit { stdout, stderr, .. } = err else {
544        return false;
545    };
546    let out = stdout.to_ascii_lowercase();
547    let errt = stderr.to_ascii_lowercase();
548    markers.iter().any(|m| out.contains(m) || errt.contains(m))
549}
550
551/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict. (jj
552/// surfaces conflicts as state rather than as errors, so this only fires on git
553/// output — see `vcs_core::Error::is_merge_conflict`.)
554pub fn is_merge_conflict(err: &Error) -> bool {
555    exit_output_matches(err, CONFLICT_MARKERS)
556}
557
558/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
559/// tree), as opposed to a real error.
560pub fn is_nothing_to_commit(err: &Error) -> bool {
561    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
562}
563
564/// Whether a failed `fetch`/`fetch_branch`/`remote_branch_exists` looks
565/// transient (DNS, a dropped connection, a fast network blip) and is worth
566/// retrying.
567///
568/// A processkit-level **timeout** is deliberately **not** classified transient
569/// (R6). A `.timeout()`-bounded run that expired has already consumed the caller's
570/// full deadline — retrying it would multiply the wall-clock by [`FETCH_ATTEMPTS`]
571/// (e.g. a black-holed remote under a 120 s deadline would block ≈ 6 min, three
572/// times the advertised ceiling). The deadline *is* the patience budget; a caller
573/// who wants longer should raise the timeout, not have it silently tripled. Fast
574/// transient failures (the io-level and marker cases below) still retry, because
575/// they fail quickly and a retry is cheap.
576pub fn is_transient_fetch_error(err: &Error) -> bool {
577    // An io-level transient from the spawn itself (interrupted / would-block / busy),
578    // which processkit classifies via `Error::is_transient()` (it covers `Spawn`/`Io`,
579    // not `Exit`/`Timeout`, so it composes cleanly with the marker scan below).
580    err.is_transient() || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
581}
582
583/// Lower-case substrings marking a **whole-repository / working-copy lock**
584/// contention failure — another process held the *one* repo-wide lock, so the
585/// command **never started** (clean, pre-execution) and touched nothing.
586///
587/// These are deliberately limited to the locks that guard the *entire* operation
588/// up front, so retrying is safe even on a **mutating** command: the repo was not
589/// modified at all. We intentionally do **not** include per-ref lock messages
590/// (`cannot lock ref`, `<ref>.lock`/`packed-refs.lock: File exists`): a multi-ref
591/// `push`/`fetch` updates refs sequentially, so a ref-lock failure can arrive
592/// *after* earlier refs already moved — replaying that is not idempotent. Network
593/// markers
594/// ([`TRANSIENT_FETCH_MARKERS`]) and conflict/exit failures are likewise absent.
595const LOCK_CONTENTION_MARKERS: &[&str] = &[
596    // git: the whole-repo index lock (pre-write). Match the **locale-stable path
597    // fragment** `index.lock`, not the translated `': File exists'` suffix — git
598    // localizes its messages, so a `LANG=de_DE` runner would never match the full
599    // English phrase. `index.lock` names the index lock specifically; per-ref locks
600    // (`<ref>.lock`, `packed-refs.lock`) are ruled out by the `refs/` guard in
601    // `is_lock_contention`. (This matches any `index.lock` *create* failure — a
602    // held lock, or e.g. `Permission denied` — all pre-write, so retrying is safe.)
603    "index.lock",
604    // jj: the working-copy lock and the operation-heads lock (both pre-mutation).
605    // These are jj's exact wordings (lower-cased for the classifier). NOTE: modern
606    // jj generally **blocks** on these locks until they're free rather than failing,
607    // so contention usually surfaces as a wait, not a classifiable error — these
608    // markers catch only the residual cases where jj does surface a lock error.
609    "failed to lock working copy",
610    "failed to lock operation heads store",
611];
612
613/// Whether `err` is a **whole-repository lock-contention** failure — another
614/// process held git's `index.lock` or jj's working-copy / op-heads lock, so the
615/// command couldn't even start. Such a failure is *pre-execution* and therefore
616/// safe to retry even on a **mutating** operation (the repo was never modified).
617/// Per-ref lock failures (`cannot lock ref`, `<ref>.lock`) are deliberately **not**
618/// classified here — they can occur mid-way through a multi-ref `push`/`fetch`,
619/// where a retry would not be idempotent. Conflict, "nothing to commit", a real
620/// non-zero exit, a timeout, a signal, or a missing binary are also **not** lock
621/// contention and must not be retried this way.
622pub fn is_lock_contention(err: &Error) -> bool {
623    // Rule out a **per-ref** lock first: it is *not* safely retryable (a multi-ref
624    // push/fetch can fail one ref's lock after earlier refs already moved). git's
625    // per-ref lock lives under `refs/` (`…/refs/heads/<name>.lock`) and its message
626    // names `refs/…`, whereas the whole-repo `index.lock` (`<gitdir>/index.lock`)
627    // never does — so a `refs/` mention excludes it, locale-independently. This also
628    // stops a branch literally named `index`/`reindex` (whose `…/reindex.lock`
629    // contains the substring `index.lock`) from matching the bare `index.lock`
630    // marker. (A repo whose *path* contains `refs/` then misses the index-lock retry
631    // — a benign false-negative, safer than a wrong retry.)
632    if exit_output_matches(err, &["refs/"]) {
633        return false;
634    }
635    exit_output_matches(err, LOCK_CONTENTION_MARKERS)
636}
637
638/// Whether `err` is an **input rejection** — a bad caller argument, encoded as an
639/// [`Error::Spawn`] whose source is `io::ErrorKind::InvalidInput`. This is the
640/// pattern the toolkit's own argument guards raise ([`reject_flag_like`] and the
641/// validating newtypes `RefName`/`RevSpec`/`RevsetExpr`) for a value that would be
642/// misparsed as a flag, is empty, or contains a NUL — and it also covers the
643/// spawn-time `InvalidInput` the OS raises for an un-spawnable argument (an interior
644/// NUL in a flag-value, or Windows' batch-arg-escaping refusal). All are genuine
645/// bad input, distinct from a real spawn failure (missing binary → `NotFound`, no
646/// perms → `PermissionDenied`) or a non-zero exit. A binding maps this to a
647/// `ValueError`; the facades re-expose it as `Error::is_invalid_input()`.
648pub fn is_invalid_input(err: &Error) -> bool {
649    matches!(
650        err,
651        Error::Spawn { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput
652    )
653}
654
655/// A bounded retry strategy: how many attempts, the (exponential) backoff between
656/// them, and whether to add full jitter. Used by [`ManagedClient`] to retry
657/// [`is_lock_contention`] failures. The [`Default`] is [`none`](RetryPolicy::none)
658/// (no retry) — retry is **opt-in**.
659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
660#[non_exhaustive]
661pub struct RetryPolicy {
662    /// Total attempts including the first; `1` means no retry.
663    pub attempts: u32,
664    /// Delay before the first retry; doubles each subsequent retry (capped by
665    /// [`max_backoff`](RetryPolicy::max_backoff)). `ZERO` means retry immediately.
666    pub base_backoff: Duration,
667    /// Upper bound on the (pre-jitter) backoff delay. `ZERO` means uncapped.
668    pub max_backoff: Duration,
669    /// Apply **full jitter** — the actual delay is uniform in `[0, computed]` — to
670    /// avoid a thundering herd when many workers retry against one repository.
671    pub jitter: bool,
672}
673
674impl RetryPolicy {
675    /// No retry: a single attempt. The default.
676    pub const fn none() -> Self {
677        Self {
678            attempts: 1,
679            base_backoff: Duration::ZERO,
680            max_backoff: Duration::ZERO,
681            jitter: false,
682        }
683    }
684
685    /// A sensible default for repository lock contention: a handful of attempts
686    /// with short, jittered, exponential backoff (25 ms → 500 ms).
687    pub const fn lock_contention() -> Self {
688        Self {
689            attempts: 5,
690            base_backoff: Duration::from_millis(25),
691            max_backoff: Duration::from_millis(500),
692            jitter: true,
693        }
694    }
695
696    /// Set the total number of attempts (clamped to at least 1).
697    pub fn attempts(mut self, attempts: u32) -> Self {
698        self.attempts = attempts.max(1);
699        self
700    }
701
702    /// Set the base backoff (the delay before the first retry).
703    pub fn base_backoff(mut self, backoff: Duration) -> Self {
704        self.base_backoff = backoff;
705        self
706    }
707
708    /// Cap the (pre-jitter) backoff delay; `ZERO` leaves it uncapped.
709    pub fn max_backoff(mut self, max: Duration) -> Self {
710        self.max_backoff = max;
711        self
712    }
713
714    /// Toggle full jitter on the backoff delay.
715    pub fn with_jitter(mut self, jitter: bool) -> Self {
716        self.jitter = jitter;
717        self
718    }
719}
720
721impl Default for RetryPolicy {
722    /// No retry — retry is opt-in.
723    fn default() -> Self {
724        Self::none()
725    }
726}
727
728/// The (possibly jittered) backoff before the `retry_index`-th retry (0 = first).
729fn backoff_for(policy: &RetryPolicy, retry_index: u32) -> Duration {
730    if policy.base_backoff.is_zero() {
731        return Duration::ZERO;
732    }
733    let base = policy.base_backoff.as_nanos();
734    let scaled = base.saturating_mul(1u128 << retry_index.min(20));
735    let capped = if policy.max_backoff.is_zero() {
736        scaled
737    } else {
738        scaled.min(policy.max_backoff.as_nanos())
739    };
740    let delay = Duration::from_nanos(capped.min(u64::MAX as u128) as u64);
741    if policy.jitter {
742        full_jitter(delay)
743    } else {
744        delay
745    }
746}
747
748/// Full jitter: a uniform delay in `[0, max]`. Dependency-free randomness via the
749/// OS-seeded [`RandomState`](std::collections::hash_map::RandomState) — good enough
750/// to de-correlate retries, not cryptographic.
751fn full_jitter(max: Duration) -> Duration {
752    use std::hash::{BuildHasher, Hasher};
753    let nanos = max.as_nanos();
754    if nanos == 0 {
755        return Duration::ZERO;
756    }
757    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
758    hasher.write_u64(nanos as u64);
759    let r = hasher.finish() as u128;
760    Duration::from_nanos((r % (nanos + 1)).min(u64::MAX as u128) as u64)
761}
762
763/// The structured [`Error::Cancelled`] to surface when a cancellation token aborts
764/// the retry backoff, named for the same program as the attempt that just failed —
765/// so it reads exactly like the `Cancelled` a [`processkit`] run raises when its own
766/// [`default_cancel_on`](ManagedClient::default_cancel_on) token kills an in-flight
767/// process. Falls back to an empty program name only if the last error carried none
768/// (every real attempt error names its program).
769fn cancelled_error(last_err: &Error) -> Error {
770    Error::Cancelled {
771        program: last_err.program().unwrap_or_default().to_owned(),
772    }
773}
774
775/// Run `op`, retrying its result while `should_retry` says so and `policy` has
776/// attempts left, sleeping the (jittered, exponential) backoff between tries. The
777/// op is re-invoked from scratch each attempt, so it must be idempotent for the
778/// errors `should_retry` selects (lock-contention failures are — the command never
779/// ran). Returns the first `Ok`, or the last `Err`.
780///
781/// When `cancel` is `Some`, the backoff between attempts is **cancellation-aware**:
782/// if the token fires before or during a wait, the wait stops immediately and the
783/// whole retry aborts with a structured [`Error::Cancelled`] (naming the
784/// just-failed attempt's program). It does **not** sit out the rest of the delay,
785/// and — crucially — it launches **no** further attempt, so a cancel can never race
786/// a fresh op into flight (the attempt count stays deterministic). Pass `None` to
787/// keep the plain, uninterruptible backoff (behaviour unchanged from before this
788/// parameter existed).
789///
790/// The **first** attempt always runs; cancellation is only observed around the
791/// backoff. An `op` bound to the same token (a [`ManagedClient`] built with
792/// [`default_cancel_on`](ManagedClient::default_cancel_on)) still surfaces its own
793/// `Cancelled` when the token was already fired as it ran — `should_retry` returns
794/// `false` for that terminal error, so the loop returns it without a backoff anyway.
795pub async fn retry_async<T, Fut>(
796    policy: &RetryPolicy,
797    cancel: Option<&CancellationToken>,
798    should_retry: impl Fn(&Error) -> bool,
799    mut op: impl FnMut() -> Fut,
800) -> Result<T>
801where
802    Fut: Future<Output = Result<T>>,
803{
804    let attempts = policy.attempts.max(1);
805    for attempt in 1..=attempts {
806        match op().await {
807            Ok(value) => return Ok(value),
808            Err(err) => {
809                if attempt == attempts || !should_retry(&err) {
810                    return Err(err);
811                }
812                let delay = backoff_for(policy, attempt - 1);
813                match cancel {
814                    // Cancellation-aware backoff. `run_until_cancelled` drops the
815                    // pending sleep the instant the token fires (or returns at once
816                    // if it is already fired), so a cancelled retry never waits out
817                    // the full delay. We then abort with a structured `Cancelled`
818                    // instead of looping into another attempt — the same check also
819                    // covers a zero delay and a cancel that lands right as the wait
820                    // ends, so no attempt is ever launched after the token fired.
821                    Some(token) => {
822                        if !delay.is_zero() {
823                            let _ = token.run_until_cancelled(tokio::time::sleep(delay)).await;
824                        }
825                        if token.is_cancelled() {
826                            return Err(cancelled_error(&err));
827                        }
828                    }
829                    // No token: the original plain, uninterruptible backoff.
830                    None => {
831                        if !delay.is_zero() {
832                            tokio::time::sleep(delay).await;
833                        }
834                    }
835                }
836            }
837        }
838    }
839    unreachable!("the loop returns on the final attempt")
840}
841
842/// A [`CliClient`] wrapper that adds two opt-in concerns the CLI wrappers
843/// (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`) all share, without touching a
844/// single call site:
845///
846/// 1. **Lock-contention retry** ([`is_lock_contention`]) per a [`RetryPolicy`] —
847///    off by default ([`RetryPolicy::none`]); enable with
848///    [`with_retry`](ManagedClient::with_retry). Safe even for mutating commands,
849///    since lock contention is a clean pre-execution failure.
850/// 2. **Credential injection** from an opt-in [`CredentialProvider`] — off by
851///    default (no provider); attach one with
852///    [`with_credentials`](ManagedClient::with_credentials). When a forge
853///    *token-env* binding is configured
854///    ([`with_token_env`](ManagedClient::with_token_env)), every command run
855///    through this client gets the resolved token in that environment variable
856///    (e.g. `GH_TOKEN`). Backends that inject the secret differently (git's
857///    `credential.helper`) instead call
858///    [`resolve_credential`](ManagedClient::resolve_credential) at the command
859///    site. Resolution happens once per call, before the retry loop. A
860///    [`with_expected_host`](ManagedClient::with_expected_host) binding travels as
861///    the request's host so a **host-keyed** provider selects the right instance's
862///    secret; the `Ok(None)` / `Err` fallback (defer to ambient vs. fail-closed
863///    abort) is defined on
864///    [`resolve_credential`](ManagedClient::resolve_credential).
865///
866/// Both default to inert, so a client with neither configured behaves exactly
867/// like a bare `CliClient`.
868pub struct ManagedClient<R: ProcessRunner = JobRunner> {
869    inner: CliClient<R>,
870    retry: RetryPolicy,
871    credentials: Option<Arc<dyn CredentialProvider>>,
872    /// When set, the token is auto-injected into this env var on every command,
873    /// resolved for this service. Used by the forge clients (`GH_TOKEN`, …).
874    token_env: Option<(CredentialService, &'static str)>,
875    /// The remote host this client targets, set when a forge `with_host` builder
876    /// bound one. It becomes the [`CredentialRequest`]'s host on the auto-injected
877    /// token-env path (the forge case), so a **host-keyed** provider selects the
878    /// secret for *this* host and never a neighbouring instance's. `None` leaves the
879    /// request host unset — a host-keyed provider that can't place the request
880    /// returns `Ok(None)` and the command falls back to ambient auth, rather than
881    /// being handed the wrong host's secret.
882    expected_host: Option<String>,
883    /// A copy of the [`default_cancel_on`](Self::default_cancel_on) token, kept here
884    /// (as well as on `inner`, which bounds the spawned *process*) so the retry loop
885    /// can cut a lock-contention backoff short the instant cancellation fires,
886    /// instead of sleeping out the full delay before the next attempt.
887    cancel: Option<CancellationToken>,
888    /// The default output budget applied to the potentially large **content**
889    /// verbs this client builds (via [`run_untrimmed`](Self::run_untrimmed)) and,
890    /// on request, to a discard verb's diagnostic capture
891    /// ([`budget_diagnostics`](Self::budget_diagnostics)). Defaults to
892    /// [`OutputBudget::unlimited`] — no ceiling — so a client that never sets one
893    /// behaves exactly as before. A single call overrides it via
894    /// [`run_untrimmed_within`](Self::run_untrimmed_within).
895    output_budget: OutputBudget,
896}
897
898impl<R: ProcessRunner> fmt::Debug for ManagedClient<R> {
899    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
900        f.debug_struct("ManagedClient")
901            .field("inner", &self.inner)
902            .field("retry", &self.retry)
903            // Never render the provider itself (it may close over a secret); just
904            // whether one is configured, plus the token-env binding.
905            .field("credentials", &self.credentials.is_some())
906            .field("token_env", &self.token_env)
907            // A hostname, not a secret — safe to render; helps distinguish a
908            // host-bound client's `{:?}` from an unbound one.
909            .field("expected_host", &self.expected_host)
910            // The token itself is not meaningfully renderable; whether one is set
911            // matches `inner`'s own `has_default_cancel`, kept explicit here too.
912            .field("has_cancel", &self.cancel.is_some())
913            // A small plain cap (no secret) — safe to render.
914            .field("output_budget", &self.output_budget)
915            .finish()
916    }
917}
918
919impl ManagedClient<JobRunner> {
920    /// A retrying client driving `program` on the real job-backed runner (no retry
921    /// until [`with_retry`](ManagedClient::with_retry)).
922    pub fn new(program: impl AsRef<OsStr>) -> Self {
923        Self {
924            inner: CliClient::new(program),
925            retry: RetryPolicy::none(),
926            credentials: None,
927            token_env: None,
928            expected_host: None,
929            cancel: None,
930            output_budget: OutputBudget::unlimited(),
931        }
932    }
933}
934
935impl<R: ProcessRunner> ManagedClient<R> {
936    /// A retrying client driving `program` on `runner` — inject a fake in tests.
937    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
938        Self {
939            inner: CliClient::with_runner(program, runner),
940            retry: RetryPolicy::none(),
941            credentials: None,
942            token_env: None,
943            expected_host: None,
944            cancel: None,
945            output_budget: OutputBudget::unlimited(),
946        }
947    }
948
949    /// Set the lock-contention retry policy (opt-in; default is no retry).
950    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
951        self.retry = policy;
952        self
953    }
954
955    /// The active retry policy.
956    pub fn retry_policy(&self) -> RetryPolicy {
957        self.retry
958    }
959
960    /// Attach a [`CredentialProvider`] (opt-in; default is none → ambient auth).
961    /// The provider is consulted per operation: automatically when a
962    /// [`with_token_env`](ManagedClient::with_token_env) binding is set, or
963    /// on demand via [`resolve_credential`](ManagedClient::resolve_credential).
964    ///
965    /// **Precedence:** a resolved token is injected *after* any
966    /// [`default_env`](ManagedClient::default_env), so the provider wins over a
967    /// static default and over the ambient CLI login. **Cancellation:** a
968    /// [`default_cancel_on`](ManagedClient::default_cancel_on) token bounds the
969    /// spawned *process*, not provider resolution — if your provider does slow I/O
970    /// (a vault lookup), bound it yourself.
971    #[must_use]
972    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
973        self.credentials = Some(provider);
974        self
975    }
976
977    /// Bind the resolved token to an environment variable injected on **every**
978    /// command this client runs (the forge case: `GH_TOKEN`, `GITLAB_TOKEN`). The
979    /// `service` tags the [`CredentialRequest`]. No effect without a provider.
980    #[must_use]
981    pub fn with_token_env(mut self, service: CredentialService, var: &'static str) -> Self {
982        self.token_env = Some((service, var));
983        self
984    }
985
986    /// Bind the remote host this client targets (set by a forge `with_host`): it
987    /// travels as the [`CredentialRequest`]'s host whenever the token-env path
988    /// resolves a credential, so a **host-keyed** [`CredentialProvider`] returns the
989    /// secret for *this* host and nothing else — one client can't inject a
990    /// neighbouring instance's token. Without it the request host is unset (the
991    /// pre-host-context behaviour). No effect without a provider and a
992    /// [`with_token_env`](Self::with_token_env) binding.
993    #[must_use]
994    pub fn with_expected_host(mut self, host: impl Into<String>) -> Self {
995        self.expected_host = Some(host.into());
996        self
997    }
998
999    /// Whether a credential provider is configured.
1000    #[must_use]
1001    pub fn has_credentials(&self) -> bool {
1002        self.credentials.is_some()
1003    }
1004
1005    /// Resolve a credential for `service`/`host` from the configured provider, or
1006    /// `Ok(None)` if no provider is set or it defers to ambient auth. Backends
1007    /// that inject the secret at the command site (git's `credential.helper`) call
1008    /// this directly; the forge token-env path uses it internally.
1009    ///
1010    /// **Fallback policy (identical for read and write operations):**
1011    /// - **No provider**, or the provider returns **`Ok(None)`** → `Ok(None)`:
1012    ///   defer to the CLI's ambient auth, exactly as if no provider were configured.
1013    /// - A credential whose secret is **empty / whitespace-only** → treated as
1014    ///   `Ok(None)` (ambient): injecting an empty token would *override* the ambient
1015    ///   login with nothing instead of deferring to it.
1016    /// - The provider returns **`Err`** → the error propagates and **aborts** the
1017    ///   operation (**fail-closed**). A provider that cannot resolve (a vault outage)
1018    ///   is never silently downgraded to ambient auth.
1019    ///
1020    /// Passing the operation's `host` is what lets a **host-keyed** provider return
1021    /// the secret for *that* host (or `Ok(None)` for one it does not handle) — so it
1022    /// never hands back a neighbouring instance's token when the host is known, and
1023    /// an unknown/absent host defers to ambient rather than substituting a default
1024    /// secret.
1025    pub async fn resolve_credential(
1026        &self,
1027        service: CredentialService,
1028        host: Option<&str>,
1029    ) -> Result<Option<Credential>> {
1030        let Some(provider) = &self.credentials else {
1031            return Ok(None);
1032        };
1033        let request = CredentialRequest { service, host };
1034        // An empty (or whitespace-only) secret is not a usable credential —
1035        // injecting an empty `GH_TOKEN`/`GITLAB_TOKEN` (or a `password=` line)
1036        // would *override* the ambient login with nothing rather than defer to it.
1037        // Treat it as `None` (ambient), keeping the "no usable credential ⇒
1038        // ambient auth" contract consistent regardless of which adapter produced
1039        // it (matching `EnvToken`'s own whitespace-only ⇒ unset rule).
1040        Ok(provider
1041            .credential(&request)
1042            .await?
1043            .filter(|cred| !cred.secret().expose().trim().is_empty()))
1044    }
1045
1046    /// Materialize `call` into a [`Command`], injecting the forge token env if a
1047    /// [`with_token_env`](ManagedClient::with_token_env) binding and a provider
1048    /// are both configured. The single place the auto-injection happens, shared by
1049    /// every retrying verb.
1050    ///
1051    /// The request carries this client's
1052    /// [`expected_host`](ManagedClient::with_expected_host) (when a forge `with_host`
1053    /// set one), so a host-keyed provider picks the secret for that host. The
1054    /// resolution follows the [`resolve_credential`](ManagedClient::resolve_credential)
1055    /// fallback policy: `Ok(None)` (nothing for this host, or an empty secret) leaves
1056    /// the command on ambient auth — no env is set — while an `Err` **aborts** the
1057    /// command (fail-closed, via `?`). A provider that can't resolve is never
1058    /// silently downgraded to ambient, and a wrong host's secret is never
1059    /// substituted. This holds identically for read and write verbs (both route
1060    /// through here).
1061    async fn prepare(&self, call: impl IntoCommand<R>) -> Result<Command> {
1062        let cmd = call.into_command(&self.inner);
1063        let Some((service, var)) = self.token_env else {
1064            return Ok(cmd);
1065        };
1066        match self
1067            .resolve_credential(service, self.expected_host.as_deref())
1068            .await?
1069        {
1070            Some(cred) => Ok(cmd.env(var, cred.secret().expose())),
1071            None => Ok(cmd),
1072        }
1073    }
1074
1075    /// Apply a default timeout to every command this client builds.
1076    pub fn default_timeout(mut self, timeout: Duration) -> Self {
1077        self.inner = self.inner.default_timeout(timeout);
1078        self
1079    }
1080
1081    /// Set an environment variable on every command this client builds.
1082    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
1083        self.inner = self.inner.default_env(key, value);
1084        self
1085    }
1086
1087    /// Remove an inherited environment variable on every command this client builds.
1088    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
1089        self.inner = self.inner.default_env_remove(key);
1090        self
1091    }
1092
1093    /// Cancel every command this client builds when `token` fires — and cut a
1094    /// lock-contention retry backoff short the moment it does, so a cancelled
1095    /// operation returns promptly instead of sleeping out the remaining delay
1096    /// before its next attempt. The token is applied to the spawned process (via
1097    /// `inner`) *and* observed by the retry loop.
1098    pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
1099        self.inner = self.inner.default_cancel_on(token.clone());
1100        self.cancel = Some(token);
1101        self
1102    }
1103
1104    /// Set the default [`OutputBudget`] applied to the content verbs this client
1105    /// builds through [`run_untrimmed`](Self::run_untrimmed) — off by default
1106    /// ([`OutputBudget::unlimited`]). A single call can override it via
1107    /// [`run_untrimmed_within`](Self::run_untrimmed_within).
1108    pub fn default_output_budget(mut self, budget: OutputBudget) -> Self {
1109        self.output_budget = budget;
1110        self
1111    }
1112
1113    /// The active default output budget.
1114    pub fn output_budget(&self) -> OutputBudget {
1115        self.output_budget
1116    }
1117
1118    /// Apply this client's default budget to `cmd` as a **diagnostic** (drop-oldest
1119    /// tail) bound, for a discard verb that only surfaces its output on failure
1120    /// (`clone`/`fetch`). Caps the retained error/progress buffer without turning a
1121    /// real failure into [`Error::OutputTooLarge`] — the tail (where a CLI's fatal
1122    /// line sits) is preserved, so [`is_transient_fetch_error`] /
1123    /// [`is_lock_contention`] still classify it. A no-op when the budget is
1124    /// [`unlimited`](OutputBudget::unlimited).
1125    pub fn budget_diagnostics(&self, cmd: Command) -> Command {
1126        match self.output_budget.diagnostic_policy() {
1127            Some(policy) => cmd.output_buffer(policy),
1128            None => cmd,
1129        }
1130    }
1131
1132    /// Build a [`Command`] for this client's program (passthrough).
1133    pub fn command<I, S>(&self, args: I) -> Command
1134    where
1135        I: IntoIterator<Item = S>,
1136        S: AsRef<OsStr>,
1137    {
1138        self.inner.command(args)
1139    }
1140
1141    /// Build a [`Command`] bound to `dir` (passthrough).
1142    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
1143    where
1144        I: IntoIterator<Item = S>,
1145        S: AsRef<OsStr>,
1146    {
1147        self.inner.command_in(dir, args)
1148    }
1149
1150    /// The underlying process runner (passthrough — e.g. for `output_all`).
1151    pub fn runner(&self) -> &R {
1152        self.inner.runner()
1153    }
1154
1155    /// Like [`CliClient::run`], with credential injection and lock-retry.
1156    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
1157        let cmd = self.prepare(call).await?;
1158        retry_async(
1159            &self.retry,
1160            self.cancel.as_ref(),
1161            is_lock_contention,
1162            || self.inner.run(cmd.clone()),
1163        )
1164        .await
1165    }
1166
1167    /// Like [`CliClient::run_unit`], with credential injection and lock-retry.
1168    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
1169        let cmd = self.prepare(call).await?;
1170        retry_async(
1171            &self.retry,
1172            self.cancel.as_ref(),
1173            is_lock_contention,
1174            || self.inner.run_unit(cmd.clone()),
1175        )
1176        .await
1177    }
1178
1179    /// Like [`CliClient::output_string`], with credential injection. **No lock-retry:**
1180    /// `output_string` returns `Ok` on a non-zero exit (it captures the result), so a
1181    /// lock failure surfaces as an `Ok` here, not an `Err` the retry predicate could
1182    /// match — route mutations that need lock-retry through
1183    /// [`run`](Self::run)/[`run_unit`](Self::run_unit) instead.
1184    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
1185        let cmd = self.prepare(call).await?;
1186        self.inner.output_string(cmd).await
1187    }
1188
1189    /// Like [`CliClient::output_bytes`], with credential injection. Captures stdout
1190    /// as **raw bytes**, byte-exact — unlike [`output_string`](Self::output_string),
1191    /// which reassembles stdout from decoded lines and so drops a trailing newline.
1192    /// This is the byte-faithful path [`run_untrimmed`](Self::run_untrimmed) needs.
1193    /// **No lock-retry**, for the same reason as `output_string`: it returns `Ok`
1194    /// on a non-zero exit (it captures the result), so a lock failure surfaces as an
1195    /// `Ok` here rather than an `Err` the retry predicate could match.
1196    pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
1197        let cmd = self.prepare(call).await?;
1198        self.inner.output_bytes(cmd).await
1199    }
1200
1201    /// Like [`run`](Self::run), but returns stdout **verbatim** — no `trim_end`.
1202    /// For **content**-returning verbs (a file's bytes at a rev, a diff, a raw
1203    /// template render) where the trailing newline(s) are part of the value, not
1204    /// noise: trimming them corrupts a read-modify-write round-trip and desyncs a
1205    /// diff's last hunk from its `@@` line count. Exit-checked like `run`; no
1206    /// lock-retry (a content read is not a mutation).
1207    ///
1208    /// Routed through [`output_bytes`](Self::output_bytes) (raw stdout), not
1209    /// `output_string`, so the exact bytes — trailing newline included — survive:
1210    /// `output_string` rebuilds stdout from decoded lines and would drop that final
1211    /// `\n`. The raw bytes are then decoded losslessly with
1212    /// [`String::from_utf8_lossy`], the same raw-stdout-to-`String` convention used
1213    /// elsewhere in this workspace (e.g. `vcs-jj`).
1214    ///
1215    /// **Output budget:** this client's default [`OutputBudget`]
1216    /// ([`default_output_budget`](Self::default_output_budget)) is applied as a
1217    /// fail-loud byte ceiling — a content read past the cap errors with
1218    /// [`Error::OutputTooLarge`] (carrying the actual and allowed sizes) instead of
1219    /// buffering an unbounded blob, and a truncated read is never returned as if
1220    /// complete. Unlimited by default (unchanged behaviour). Override the ceiling
1221    /// for one call with [`run_untrimmed_within`](Self::run_untrimmed_within).
1222    pub async fn run_untrimmed(&self, call: impl IntoCommand<R>) -> Result<String> {
1223        self.run_untrimmed_within(call, self.output_budget).await
1224    }
1225
1226    /// Like [`run_untrimmed`](Self::run_untrimmed), but with an explicit per-call
1227    /// [`OutputBudget`] instead of this client's default — the per-call override
1228    /// used by the `*_within` content methods (`diff_text_within`,
1229    /// `show_file_within`, `pr_diff_within`, …) to read a legitimately large
1230    /// file/diff (a higher ceiling, or [`OutputBudget::unlimited`]) or to tighten
1231    /// the cap for one call.
1232    pub async fn run_untrimmed_within(
1233        &self,
1234        call: impl IntoCommand<R>,
1235        budget: OutputBudget,
1236    ) -> Result<String> {
1237        let cmd = self.prepare(call).await?;
1238        // A fail-loud byte ceiling: `output_bytes` raises `Error::OutputTooLarge`
1239        // the moment the raw stdout passes the cap (drained but not retained), so
1240        // this never returns a truncated blob as if it were complete.
1241        let cmd = match budget.content_policy() {
1242            Some(policy) => cmd.output_buffer(policy),
1243            None => cmd,
1244        };
1245        let bytes = self
1246            .inner
1247            .output_bytes(cmd)
1248            .await?
1249            .ensure_success()?
1250            .into_stdout();
1251        Ok(String::from_utf8_lossy(&bytes).into_owned())
1252    }
1253
1254    /// Like [`CliClient::probe`] (zero-or-nonzero exit → `bool`), with credential
1255    /// injection and lock-retry.
1256    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
1257        let cmd = self.prepare(call).await?;
1258        retry_async(
1259            &self.retry,
1260            self.cancel.as_ref(),
1261            is_lock_contention,
1262            || self.inner.probe(cmd.clone()),
1263        )
1264        .await
1265    }
1266
1267    /// Like [`CliClient::exit_code`] (the raw exit code; a spawn failure or timeout
1268    /// still errors), with credential injection and lock-retry.
1269    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
1270        let cmd = self.prepare(call).await?;
1271        retry_async(
1272            &self.retry,
1273            self.cancel.as_ref(),
1274            is_lock_contention,
1275            || self.inner.exit_code(cmd.clone()),
1276        )
1277        .await
1278    }
1279
1280    /// Like [`CliClient::parse`] (credential injection applied; the `FnOnce` parser
1281    /// can't be re-run, so lock-retry does not — parsing is a read, where lock
1282    /// contention is not a concern anyway).
1283    pub async fn parse<T>(
1284        &self,
1285        call: impl IntoCommand<R>,
1286        parser: impl FnOnce(&str) -> T + Send,
1287    ) -> Result<T>
1288    where
1289        T: Send,
1290    {
1291        let cmd = self.prepare(call).await?;
1292        self.inner.parse(cmd, parser).await
1293    }
1294
1295    /// Like [`parse`](Self::parse), but hands the parser **raw stdout bytes**
1296    /// instead of a lossily-decoded `&str`. This is the byte-faithful path a parser
1297    /// needs when a **path** (or any payload that need not be valid UTF-8) is part
1298    /// of the output: on Unix a filename can be arbitrary bytes, so decoding it
1299    /// through [`String::from_utf8_lossy`] first would substitute `U+FFFD` and make
1300    /// the path unusable to round-trip back into `add`/`commit_paths`. Routed
1301    /// through [`output_bytes`](Self::output_bytes) (byte-exact stdout) and
1302    /// exit-checked like [`parse`](Self::parse) (`ensure_success`); no lock-retry (a
1303    /// read). Text-only machine output (branch names, hashes, templated rows) should
1304    /// keep using [`parse`](Self::parse) — lossy decoding is acceptable there.
1305    pub async fn parse_bytes<T>(
1306        &self,
1307        call: impl IntoCommand<R>,
1308        parser: impl FnOnce(&[u8]) -> T + Send,
1309    ) -> Result<T>
1310    where
1311        T: Send,
1312    {
1313        let cmd = self.prepare(call).await?;
1314        let bytes = self
1315            .inner
1316            .output_bytes(cmd)
1317            .await?
1318            .ensure_success()?
1319            .into_stdout();
1320        Ok(parser(&bytes))
1321    }
1322
1323    /// Like [`CliClient::try_parse`] (credential injection applied; `FnOnce` parser,
1324    /// and a read, so no lock-retry).
1325    pub async fn try_parse<T>(
1326        &self,
1327        call: impl IntoCommand<R>,
1328        parser: impl FnOnce(&str) -> Result<T> + Send,
1329    ) -> Result<T>
1330    where
1331        T: Send,
1332    {
1333        let cmd = self.prepare(call).await?;
1334        self.inner.try_parse(cmd, parser).await
1335    }
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use super::*;
1341
1342    #[test]
1343    fn rejects_empty_and_leading_dash() {
1344        assert!(reject_flag_like("git", "branch name", "-evil").is_err());
1345        assert!(reject_flag_like("git", "branch name", "").is_err());
1346        // Whitespace-only is as meaning-changing as empty — refuse it too.
1347        assert!(reject_flag_like("git", "branch name", "  ").is_err());
1348        assert!(reject_flag_like("git", "branch name", "\t").is_err());
1349        assert!(reject_flag_like("git", "branch name", "feature").is_ok());
1350        // Leading whitespace before a dash is still refused (the flag-check trims).
1351        assert!(reject_flag_like("git", "remote", " --upload-pack=evil").is_err());
1352        assert!(reject_flag_like("git", "remote", "\t-x").is_err());
1353        // An interior NUL is refused (can't go in argv; opaque OS error otherwise).
1354        assert!(reject_flag_like("git", "path", "a\0b").is_err());
1355        // A leading-whitespace non-flag value is still accepted (not flag-like).
1356        assert!(reject_flag_like("git", "branch name", "  feature").is_ok());
1357        // The error names the program and surfaces as a spawn-side refusal.
1358        let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
1359        assert!(matches!(err, Error::Spawn { program, .. } if program == "jj"));
1360    }
1361
1362    #[test]
1363    fn classifies_merge_conflict() {
1364        let on_stdout = Error::exit("git", 1, "CONFLICT (content): Merge conflict in a.rs", "");
1365        let on_stderr = Error::exit(
1366            "git",
1367            1,
1368            "",
1369            "Automatic merge failed; fix conflicts and then commit",
1370        );
1371        let unrelated = Error::exit("git", 128, "", "fatal: not a git repository");
1372        assert!(is_merge_conflict(&on_stdout));
1373        assert!(is_merge_conflict(&on_stderr));
1374        assert!(!is_merge_conflict(&unrelated));
1375        assert!(!is_nothing_to_commit(&on_stdout));
1376    }
1377
1378    #[test]
1379    fn classifies_nothing_to_commit_and_transient_fetch() {
1380        let nothing = Error::exit("git", 1, "nothing to commit, working tree clean", "");
1381        assert!(is_nothing_to_commit(&nothing));
1382
1383        let dns = Error::exit(
1384            "git",
1385            128,
1386            "",
1387            "fatal: unable to access 'https://x/': Could not resolve host: x",
1388        );
1389        assert!(is_transient_fetch_error(&dns));
1390        assert!(!is_transient_fetch_error(&nothing));
1391
1392        // A processkit timeout is deliberately NOT retried (R6): it already consumed
1393        // the caller's full deadline, so retrying would multiply the wall-clock by
1394        // FETCH_ATTEMPTS. The deadline is the patience budget; raise it, don't triple it.
1395        let timeout = Error::timeout("git", Duration::from_secs(10), "", "");
1396        assert!(!is_transient_fetch_error(&timeout));
1397    }
1398
1399    // R9: an io-level transient from the spawn (EINTR / EAGAIN / busy) is fetch-
1400    // retryable too, via processkit's `Error::is_transient()`.
1401    #[test]
1402    fn classifies_io_transient_as_fetch_retryable() {
1403        let interrupted =
1404            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::Interrupted));
1405        assert!(
1406            interrupted.is_transient(),
1407            "processkit treats Interrupted as a transient io error"
1408        );
1409        assert!(is_transient_fetch_error(&interrupted));
1410        // A non-transient io error (e.g. NotFound — the binary is missing) is not retried.
1411        let missing = Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound));
1412        assert!(!is_transient_fetch_error(&missing));
1413    }
1414
1415    // R2: regression for the processkit 0.9.1 untruncated-`Error::Exit` fix. A large
1416    // output (well past the old 4 KiB cap) with the decisive marker near the END must
1417    // still classify — proving the classifiers see the whole captured stream.
1418    #[test]
1419    fn classifies_on_large_output_past_the_old_4kib_cap() {
1420        let padding = "noise line that says nothing\n".repeat(500); // ~14 KiB
1421        let conflict = Error::exit(
1422            "git",
1423            1,
1424            format!("{padding}CONFLICT (content): Merge conflict in late.rs"),
1425            "",
1426        );
1427        assert!(
1428            is_merge_conflict(&conflict),
1429            "a conflict marker past 4 KiB must still classify"
1430        );
1431
1432        let transient = Error::exit(
1433            "git",
1434            128,
1435            "",
1436            format!("{padding}fatal: unable to access: Could not resolve host: x"),
1437        );
1438        assert!(is_transient_fetch_error(&transient));
1439    }
1440
1441    // processkit's `Error` is `#[non_exhaustive]` and grows variants over time
1442    // (`NotReady`/`Unsupported`/`CassetteMiss`/`NotFound`/`Signalled`/`Cancelled`/
1443    // `ResourceLimit`). Unfamiliar variants must fall through every classifier to
1444    // "no" — a not-ready or unsupported run is neither a conflict, nor a clean
1445    // tree, nor worth a fetch retry.
1446    #[test]
1447    fn unfamiliar_error_variants_are_not_classified() {
1448        let not_ready = Error::NotReady {
1449            program: "git".into(),
1450            timeout: Duration::from_secs(5),
1451        };
1452        let unsupported = Error::Unsupported {
1453            operation: "suspend".into(),
1454        };
1455        for err in [&not_ready, &unsupported] {
1456            assert!(!is_merge_conflict(err));
1457            assert!(!is_nothing_to_commit(err));
1458            assert!(!is_transient_fetch_error(err));
1459        }
1460    }
1461
1462    // `Error::Cancelled` (a client-level `default_cancel_on` killing an in-flight
1463    // run; always available since cancellation became core in processkit 0.10) must
1464    // fall through every classifier to "no" — a cancelled fetch was *deliberately*
1465    // stopped, so replaying it would fight the cancellation. (Behaviour already held
1466    // via the `#[non_exhaustive]` fall-through above; this pins it as a first-class
1467    // assertion.)
1468    #[test]
1469    fn cancelled_is_not_transient_or_otherwise_classified() {
1470        let cancelled = Error::Cancelled {
1471            program: "git".into(),
1472        };
1473        assert!(!is_transient_fetch_error(&cancelled));
1474        assert!(!is_merge_conflict(&cancelled));
1475        assert!(!is_nothing_to_commit(&cancelled));
1476    }
1477
1478    // `Error::Signalled` (a process killed by a signal — e.g. an external SIGTERM/
1479    // SIGKILL, surfaced first-class since processkit 0.9.2 and carrying partial
1480    // `stdout`/`stderr` since 0.10) is *terminal*, not transient: a deliberate kill
1481    // should not be auto-retried, and a signal death is neither a merge conflict nor
1482    // a clean tree. processkit's own `is_transient()` agrees (false for `Signalled`),
1483    // so it falls through every classifier to "no" — pinned here, including the case
1484    // where the captured stderr happens to contain an otherwise-transient marker (a
1485    // killed fetch is still not ours to silently replay).
1486    #[test]
1487    fn signalled_is_terminal_not_transient() {
1488        let signalled = Error::signalled(
1489            "git",
1490            Some(15),
1491            "",
1492            "fatal: unable to access: Could not resolve host: x",
1493        );
1494        assert!(!signalled.is_transient());
1495        assert!(!is_transient_fetch_error(&signalled));
1496        assert!(!is_merge_conflict(&signalled));
1497        assert!(!is_nothing_to_commit(&signalled));
1498    }
1499
1500    fn exit(program: &str, code: i32, stderr: &str) -> Error {
1501        Error::exit(program, code, "", stderr)
1502    }
1503
1504    // `is_lock_contention` recognises ONLY the *whole-repo* / working-copy lock
1505    // failures (git index.lock, jj working-copy/op-heads lock) — the ones where the
1506    // command did nothing, so a retry is idempotent even on a mutation. Per-ref lock
1507    // failures and conflicts/timeouts are deliberately NOT classified (a multi-ref
1508    // op can fail a ref lock mid-way, where a retry would not be idempotent).
1509    #[test]
1510    fn classifies_lock_contention() {
1511        let lock_failures = [
1512            // git always names `index.lock` (locale-stable) in the lock-contention
1513            // message, even on a non-English runner where the surrounding prose is
1514            // translated.
1515            exit(
1516                "git",
1517                128,
1518                "fatal: Unable to create '/r/.git/index.lock': File exists.",
1519            ),
1520            // A German runner: the path fragment `index.lock` still matches.
1521            exit(
1522                "git",
1523                128,
1524                "fatal: Konnte '/r/.git/index.lock' nicht erstellen: Datei existiert bereits",
1525            ),
1526            // jj's *actual* wordings (verified against jj source) — note no "the".
1527            exit("jj", 1, "Error: Failed to lock working copy"),
1528            exit("jj", 1, "Error: Failed to lock operation heads store"),
1529        ];
1530        for e in &lock_failures {
1531            assert!(is_lock_contention(e), "should be lock contention: {e:?}");
1532            // A lock failure is NOT a transient *fetch* error — different class.
1533            assert!(!is_transient_fetch_error(e), "not a fetch error: {e:?}");
1534        }
1535        let not_locks = [
1536            exit("git", 1, "CONFLICT (content): Merge conflict in a.rs"),
1537            exit("git", 1, "error: pathspec 'x' did not match any file(s)"),
1538            exit("git", 128, "fatal: not a git repository"),
1539            // Per-ref locks are NOT classified — a multi-ref push/fetch can fail a
1540            // ref lock after earlier refs already moved (non-idempotent to replay).
1541            exit(
1542                "git",
1543                1,
1544                "error: cannot lock ref 'refs/heads/x': reference already exists",
1545            ),
1546            exit(
1547                "git",
1548                128,
1549                "Unable to create '/r/.git/packed-refs.lock': File exists.",
1550            ),
1551            // A per-ref lock for a branch literally named `index`: its
1552            // `…/refs/heads/index.lock` path contains the substring `index.lock`,
1553            // but the `refs/` mention correctly rules it out (not a whole-repo lock).
1554            exit(
1555                "git",
1556                128,
1557                "error: cannot lock ref 'refs/heads/index': Unable to create \
1558                 '/r/.git/refs/heads/index.lock': File exists.",
1559            ),
1560            Error::timeout("git", Duration::from_secs(1), "", ""),
1561        ];
1562        for e in &not_locks {
1563            assert!(
1564                !is_lock_contention(e),
1565                "should NOT be lock contention: {e:?}"
1566            );
1567        }
1568    }
1569
1570    #[test]
1571    fn classifies_invalid_input_from_the_guards() {
1572        // What `reject_flag_like` / the newtypes actually produce.
1573        let rejected = reject_flag_like("git", "reference", "-x").unwrap_err();
1574        assert!(
1575            is_invalid_input(&rejected),
1576            "guard rejection is invalid input"
1577        );
1578        assert!(is_invalid_input(
1579            &reject_flag_like("git", "x", "").unwrap_err()
1580        ));
1581
1582        // A real spawn failure (missing binary), a non-zero exit, and a timeout are
1583        // NOT invalid input — they're environment/usage failures, not a bad argument.
1584        let not_input = [
1585            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound)),
1586            exit("git", 1, "fatal: not a git repository"),
1587            Error::timeout("git", Duration::from_secs(1), "", ""),
1588        ];
1589        for e in &not_input {
1590            assert!(!is_invalid_input(e), "should NOT be invalid input: {e:?}");
1591        }
1592    }
1593
1594    // Backoff is exponential off the base, capped at `max_backoff`, and zero when
1595    // there's no base (immediate retry).
1596    #[test]
1597    fn backoff_is_exponential_capped_and_zero_without_base() {
1598        let p = RetryPolicy::none()
1599            .attempts(6)
1600            .base_backoff(Duration::from_millis(10))
1601            .max_backoff(Duration::from_millis(80));
1602        assert_eq!(backoff_for(&p, 0), Duration::from_millis(10));
1603        assert_eq!(backoff_for(&p, 1), Duration::from_millis(20));
1604        assert_eq!(backoff_for(&p, 2), Duration::from_millis(40));
1605        assert_eq!(backoff_for(&p, 3), Duration::from_millis(80));
1606        assert_eq!(
1607            backoff_for(&p, 4),
1608            Duration::from_millis(80),
1609            "capped at max"
1610        );
1611        assert_eq!(
1612            backoff_for(&RetryPolicy::none(), 3),
1613            Duration::ZERO,
1614            "no base → no wait"
1615        );
1616    }
1617
1618    // Full jitter (used by `RetryPolicy::lock_contention`): every sampled backoff
1619    // stays within `[0, exponential cap]`, and successive samples de-correlate
1620    // (more than one distinct value) so retries don't thunder together. Pins the
1621    // jitter path, which the exponential test above deliberately turns off.
1622    #[test]
1623    fn jitter_stays_within_cap_and_decorrelates() {
1624        let p = RetryPolicy::none()
1625            .attempts(8)
1626            .base_backoff(Duration::from_millis(10))
1627            .max_backoff(Duration::from_millis(80))
1628            .with_jitter(true);
1629        // The cap at retry_index 3 is the full 80ms exponential value.
1630        let cap = Duration::from_millis(80);
1631        let mut seen = std::collections::HashSet::new();
1632        for _ in 0..1000 {
1633            let d = backoff_for(&p, 3);
1634            assert!(
1635                d <= cap,
1636                "jittered backoff {d:?} must stay within the cap {cap:?}"
1637            );
1638            seen.insert(d.as_nanos());
1639        }
1640        assert!(
1641            seen.len() > 1,
1642            "full jitter must produce a spread of delays, not a constant"
1643        );
1644        // A zero base still short-circuits to zero even with jitter on.
1645        assert_eq!(
1646            backoff_for(&RetryPolicy::none().with_jitter(true), 2),
1647            Duration::ZERO
1648        );
1649    }
1650
1651    // The executor: retries while the predicate matches and attempts remain, returns
1652    // the first Ok, doesn't retry a non-matching error, and exhausts to the last Err.
1653    #[tokio::test]
1654    async fn retry_async_retries_then_succeeds_and_respects_the_predicate() {
1655        use std::sync::atomic::{AtomicU32, Ordering};
1656        // Zero backoff → no sleep, deterministic & fast.
1657        let policy = RetryPolicy::none().attempts(4);
1658        let lock = || {
1659            exit(
1660                "git",
1661                128,
1662                "Unable to create '/r/.git/index.lock': File exists.",
1663            )
1664        };
1665
1666        // Fails twice with a lock error, then succeeds — retried to success.
1667        let calls = AtomicU32::new(0);
1668        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1669            let n = calls.fetch_add(1, Ordering::SeqCst);
1670            let lock = lock();
1671            async move { if n < 2 { Err(lock) } else { Ok(n) } }
1672        })
1673        .await;
1674        assert_eq!(out.unwrap(), 2);
1675        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 try + 2 retries");
1676
1677        // A non-lock error is returned immediately (not retried).
1678        let calls = AtomicU32::new(0);
1679        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1680            calls.fetch_add(1, Ordering::SeqCst);
1681            async { Err(exit("git", 1, "real, deterministic failure")) }
1682        })
1683        .await;
1684        assert!(out.is_err());
1685        assert_eq!(
1686            calls.load(Ordering::SeqCst),
1687            1,
1688            "non-retryable → single attempt"
1689        );
1690
1691        // Persistent lock contention exhausts the attempt budget.
1692        let calls = AtomicU32::new(0);
1693        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1694            calls.fetch_add(1, Ordering::SeqCst);
1695            async { Err(exit("git", 128, "index.lock': File exists")) }
1696        })
1697        .await;
1698        assert!(out.is_err());
1699        assert_eq!(calls.load(Ordering::SeqCst), 4, "all attempts used");
1700    }
1701
1702    // A persistent lock error always retryable, for the cancellation tests below.
1703    fn lock_err() -> Error {
1704        exit(
1705            "git",
1706            128,
1707            "Unable to create '/r/.git/index.lock': File exists.",
1708        )
1709    }
1710
1711    // Cancellation scenario 1 — the token is **already fired** when the backoff is
1712    // about to begin: `retry_async` must not sleep out the (long) delay, and must
1713    // abort with a structured `Cancelled` after the single attempt that already ran,
1714    // launching no second one. On a paused clock the virtual time must not advance —
1715    // proving the full backoff was skipped, not merely fast.
1716    #[tokio::test(start_paused = true)]
1717    async fn cancel_before_backoff_aborts_without_waiting_or_retrying() {
1718        use std::sync::atomic::{AtomicU32, Ordering};
1719        let token = CancellationToken::new();
1720        token.cancel(); // already cancelled before we even start
1721        let policy = RetryPolicy::none()
1722            .attempts(5)
1723            .base_backoff(Duration::from_secs(3600)); // huge — must never be waited
1724        let calls = AtomicU32::new(0);
1725
1726        let start = tokio::time::Instant::now();
1727        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1728            calls.fetch_add(1, Ordering::SeqCst);
1729            async { Err(lock_err()) }
1730        })
1731        .await;
1732
1733        assert!(
1734            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1735            "a fired token aborts with a program-named Cancelled, got {out:?}"
1736        );
1737        assert_eq!(
1738            calls.load(Ordering::SeqCst),
1739            1,
1740            "one attempt ran; the cancel launched no retry"
1741        );
1742        assert_eq!(
1743            start.elapsed(),
1744            Duration::ZERO,
1745            "the backoff was cut short — no virtual time elapsed"
1746        );
1747    }
1748
1749    // Cancellation scenario 2 — the token fires **while the backoff sleep is
1750    // parked**. With a paused clock the (long) sleep cannot elapse on its own, so a
1751    // spawned task cancelling the token is what resolves the wait: the retry must
1752    // wake early and return `Cancelled` without a second attempt.
1753    #[tokio::test(start_paused = true)]
1754    async fn cancel_during_backoff_wakes_early_and_does_not_retry() {
1755        use std::sync::atomic::{AtomicU32, Ordering};
1756        let token = CancellationToken::new();
1757        let policy = RetryPolicy::none()
1758            .attempts(5)
1759            .base_backoff(Duration::from_secs(3600)); // never elapses under paused time
1760        let calls = AtomicU32::new(0);
1761
1762        let start = tokio::time::Instant::now();
1763        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1764            let n = calls.fetch_add(1, Ordering::SeqCst);
1765            let token = token.clone();
1766            async move {
1767                // On the first failure, schedule the cancel to land while we are
1768                // parked in the backoff sleep (the sleep can't fire under paused time,
1769                // so this is what unblocks the wait).
1770                if n == 0 {
1771                    tokio::spawn(async move { token.cancel() });
1772                }
1773                Err(lock_err())
1774            }
1775        })
1776        .await;
1777
1778        assert!(
1779            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1780            "a cancel during the sleep aborts with Cancelled, got {out:?}"
1781        );
1782        assert_eq!(
1783            calls.load(Ordering::SeqCst),
1784            1,
1785            "cancel woke the sleep early — no second attempt"
1786        );
1787        assert_eq!(
1788            start.elapsed(),
1789            Duration::ZERO,
1790            "woke on the cancel, not after the 1 h delay"
1791        );
1792    }
1793
1794    // Cancellation scenario 3 — the token fires such that it is observed **right
1795    // before the next attempt** would launch. With a zero backoff there is no sleep
1796    // to interrupt, so the op cancels the token as it fails; the guard between the
1797    // (no-op) backoff and the next attempt must still abort with `Cancelled` rather
1798    // than spinning up attempt #2.
1799    #[tokio::test(start_paused = true)]
1800    async fn cancel_right_before_next_attempt_aborts() {
1801        use std::sync::atomic::{AtomicU32, Ordering};
1802        let token = CancellationToken::new();
1803        let policy = RetryPolicy::none().attempts(5); // zero backoff → no sleep
1804        let calls = AtomicU32::new(0);
1805
1806        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1807            let n = calls.fetch_add(1, Ordering::SeqCst);
1808            let token = token.clone();
1809            async move {
1810                // Cancel as the first attempt fails: the post-backoff guard must catch
1811                // it before launching the next attempt.
1812                if n == 0 {
1813                    token.cancel();
1814                }
1815                Err(lock_err())
1816            }
1817        })
1818        .await;
1819
1820        assert!(
1821            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1822            "a cancel observed before the next attempt aborts with Cancelled, got {out:?}"
1823        );
1824        assert_eq!(
1825            calls.load(Ordering::SeqCst),
1826            1,
1827            "the guard stopped attempt #2 from launching"
1828        );
1829    }
1830
1831    // Without a token the backoff is unchanged: a persistent lock error still
1832    // exhausts every attempt (no early exit, `None` path preserved).
1833    #[tokio::test]
1834    async fn no_token_backoff_is_unchanged() {
1835        use std::sync::atomic::{AtomicU32, Ordering};
1836        let policy = RetryPolicy::none().attempts(3); // zero backoff, fast
1837        let calls = AtomicU32::new(0);
1838        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1839            calls.fetch_add(1, Ordering::SeqCst);
1840            async { Err(lock_err()) }
1841        })
1842        .await;
1843        assert!(
1844            matches!(out, Err(Error::Exit { .. })),
1845            "last error is the lock exit, not Cancelled"
1846        );
1847        assert_eq!(
1848            calls.load(Ordering::SeqCst),
1849            3,
1850            "all attempts used with no token"
1851        );
1852    }
1853
1854    // `resolve_credential` returns `None` until a provider is attached, then the
1855    // provider's credential. (No process is spawned, so the real runner is fine.)
1856    #[tokio::test]
1857    async fn retrying_client_resolves_credential_opt_in() {
1858        let client = ManagedClient::new("git");
1859        assert!(!client.has_credentials());
1860        assert!(
1861            client
1862                .resolve_credential(CredentialService::Git, None)
1863                .await
1864                .unwrap()
1865                .is_none(),
1866            "no provider → ambient (None)"
1867        );
1868
1869        let client = client.with_credentials(Arc::new(StaticCredential::token("t0k")));
1870        assert!(client.has_credentials());
1871        let got = client
1872            .resolve_credential(CredentialService::Git, None)
1873            .await
1874            .unwrap()
1875            .expect("provider yields a credential");
1876        assert_eq!(got.secret().expose(), "t0k");
1877    }
1878
1879    // An empty (or whitespace-only) secret is treated as `None` (ambient):
1880    // injecting an empty token would override the ambient login with nothing
1881    // instead of deferring to it. Mirrors `EnvToken`'s whitespace-only ⇒ unset rule.
1882    #[tokio::test]
1883    async fn resolve_credential_treats_empty_secret_as_ambient() {
1884        // Service-agnostic: both the forge (token-env) and git (helper) paths route
1885        // through this chokepoint, so a blank secret is ambient for either.
1886        for blank in ["", "   ", "\t\n"] {
1887            let client = ManagedClient::new("git")
1888                .with_credentials(Arc::new(StaticCredential::token(blank)));
1889            for service in [CredentialService::GitHub, CredentialService::Git] {
1890                assert!(
1891                    client
1892                        .resolve_credential(service, None)
1893                        .await
1894                        .unwrap()
1895                        .is_none(),
1896                    "blank secret {blank:?} → ambient (None) for {service:?}"
1897                );
1898            }
1899        }
1900    }
1901
1902    // The resolved request carries the operation's host, so a HOST-KEYED provider
1903    // returns the secret for exactly that host — and `Ok(None)` (deferring to
1904    // ambient) for a host it does not place or an absent one, never a wrong-host
1905    // secret. This is the seam `prepare` (forge token-env) and git's
1906    // `remote_credentials` both feed the target host into. (T-045)
1907    #[tokio::test]
1908    async fn resolve_credential_routes_on_request_host() {
1909        let provider = provider_fn(|r: &CredentialRequest<'_>| {
1910            Ok(match r.host {
1911                Some("github.com") => Some(Credential::token("saas")),
1912                Some("ghe.example.com") => Some(Credential::token("ent")),
1913                // An unknown or absent host defers to ambient rather than a default.
1914                _ => None,
1915            })
1916        });
1917        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
1918        let resolve =
1919            |host: Option<&'static str>| client.resolve_credential(CredentialService::GitHub, host);
1920
1921        assert_eq!(
1922            resolve(Some("github.com"))
1923                .await
1924                .unwrap()
1925                .unwrap()
1926                .secret()
1927                .expose(),
1928            "saas"
1929        );
1930        assert_eq!(
1931            resolve(Some("ghe.example.com"))
1932                .await
1933                .unwrap()
1934                .unwrap()
1935                .secret()
1936                .expose(),
1937            "ent"
1938        );
1939        assert!(
1940            resolve(Some("other.example")).await.unwrap().is_none(),
1941            "a host the provider doesn't place → ambient (None), not a wrong secret"
1942        );
1943        assert!(
1944            resolve(None).await.unwrap().is_none(),
1945            "an absent host → ambient (None)"
1946        );
1947    }
1948
1949    // Fail-closed: a provider `Err` propagates out of `resolve_credential` (and so
1950    // aborts the command in `prepare` / `remote_credentials`) for any host — it is
1951    // never swallowed into a silent ambient fallback. (T-045 fallback policy)
1952    #[tokio::test]
1953    async fn resolve_credential_propagates_provider_error_fail_closed() {
1954        let provider = provider_fn(|_r: &CredentialRequest<'_>| {
1955            Err(Error::spawn(
1956                "vault",
1957                std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault unreachable"),
1958            ))
1959        });
1960        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
1961        for host in [Some("github.com"), None] {
1962            assert!(
1963                client
1964                    .resolve_credential(CredentialService::GitHub, host)
1965                    .await
1966                    .is_err(),
1967                "provider error must propagate (fail-closed), host={host:?}"
1968            );
1969        }
1970    }
1971
1972    // The default budget is unlimited — no ceiling, so a client that never sets
1973    // one keeps its pre-budget (unbounded) capture behaviour, and both policy
1974    // projections are `None` (leave the command's own buffer untouched).
1975    #[test]
1976    fn output_budget_default_is_unlimited() {
1977        let b = OutputBudget::default();
1978        assert!(b.is_unlimited());
1979        assert_eq!(b, OutputBudget::unlimited());
1980        assert_eq!(b.max_bytes(), None);
1981        assert_eq!(b.max_lines(), None);
1982        assert!(b.content_policy().is_none());
1983        assert!(b.diagnostic_policy().is_none());
1984    }
1985
1986    // A byte cap projects onto a FAIL-LOUD content policy (errors past the cap,
1987    // never truncates) and a DROP-OLDEST diagnostic policy (bounded tail, never
1988    // errors) — the two shapes one budget drives.
1989    #[test]
1990    fn output_budget_bytes_projects_to_both_policies() {
1991        let b = OutputBudget::bytes(4096);
1992        assert!(!b.is_unlimited());
1993        assert_eq!(b.max_bytes(), Some(4096));
1994
1995        let content = b
1996            .content_policy()
1997            .expect("a byte budget yields a content policy");
1998        assert_eq!(
1999            content.overflow,
2000            OverflowMode::Error,
2001            "content is fail-loud"
2002        );
2003        assert_eq!(content.max_bytes, Some(4096));
2004        // No line cap set, so the fail-loud ceiling rests entirely on the byte cap
2005        // (which is exactly what the raw-stdout content path enforces).
2006        assert_eq!(content.max_lines, None);
2007
2008        let diag = b
2009            .diagnostic_policy()
2010            .expect("a byte budget yields a diagnostic policy");
2011        assert_eq!(
2012            diag.overflow,
2013            OverflowMode::DropOldest,
2014            "diagnostics keep the tail, never OutputTooLarge"
2015        );
2016        assert_eq!(diag.max_bytes, Some(4096));
2017    }
2018
2019    // A line ceiling composes with the byte cap on both projections.
2020    #[test]
2021    fn output_budget_with_max_lines_composes() {
2022        let b = OutputBudget::bytes(4096).with_max_lines(200);
2023        assert_eq!(b.max_lines(), Some(200));
2024        let content = b.content_policy().unwrap();
2025        assert_eq!(content.max_lines, Some(200));
2026        assert_eq!(content.max_bytes, Some(4096));
2027        assert_eq!(content.overflow, OverflowMode::Error);
2028        let diag = b.diagnostic_policy().unwrap();
2029        assert_eq!(diag.max_lines, Some(200));
2030        assert_eq!(diag.max_bytes, Some(4096));
2031        assert_eq!(diag.overflow, OverflowMode::DropOldest);
2032    }
2033
2034    // The client-level default budget round-trips through the builder/getter, and
2035    // `budget_diagnostics` applies (or, when unlimited, leaves) a command's buffer.
2036    #[test]
2037    fn managed_client_default_output_budget_round_trips() {
2038        let client = ManagedClient::new("git");
2039        assert!(client.output_budget().is_unlimited());
2040        let client = client.default_output_budget(OutputBudget::bytes(1 << 20));
2041        assert_eq!(client.output_budget(), OutputBudget::bytes(1 << 20));
2042    }
2043}