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/// R7 clone-cleanup: whether `dest` is safe to remove if a `clone`/`git_clone`
507/// about to run into it fails — either absent, or an already-empty directory.
508/// Compute this **before** running the clone, and pass the result to
509/// [`cleanup_failed_clone_dest`] on the error path — `git`/`jj` both refuse to
510/// clone into a **non-empty** existing directory, so if `dest` already had
511/// contents going in, a failure means that refusal, and the caller's
512/// pre-existing data must never be deleted. Re-checking emptiness *after* the
513/// clone ran would be wrong: a failed clone can leave `dest` partially
514/// populated, so a post-hoc check could wrongly call a partial clone's leftovers
515/// "empty" (or simply disagree with the pre-clone state).
516///
517/// Shared by `vcs_git::clone_repo` and `vcs_jj::git_clone`, which previously
518/// carried a byte-identical copy of this check plus its own best-effort
519/// `remove_dir_all` on the error path.
520pub fn clone_dest_cleanable(dest: &Path) -> bool {
521    match std::fs::read_dir(dest) {
522        Err(_) => true, // absent/unreadable → clone would create it
523        Ok(mut entries) => entries.next().is_none(), // an empty directory
524    }
525}
526
527/// Best-effort cleanup of a failed clone's partial `dest` (R7) — call only on
528/// the clone's error path, passing `cleanable` as computed by
529/// [`clone_dest_cleanable`] **before** the clone ran. A no-op when `cleanable`
530/// is `false` (never touches a non-empty pre-existing `dest`). Swallows a
531/// `remove_dir_all` failure (e.g. another process holding a file open) — this
532/// is opportunistic tidy-up, not something a clone failure should itself fail
533/// on.
534pub fn cleanup_failed_clone_dest(dest: &Path, cleanable: bool) {
535    if cleanable {
536        let _ = std::fs::remove_dir_all(dest);
537    }
538}
539
540/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
541pub const FETCH_ATTEMPTS: u32 = 3;
542/// Fixed backoff between fetch retries.
543pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
544/// Grace period for a timed-out fetch: on the deadline processkit signals the
545/// process tree (terminate), waits this long for it to exit cleanly — flush, close
546/// the connection, drop any lock — then hard-kills. Only takes effect when a
547/// per-client timeout is set (`Git::default_timeout` / `Jj::default_timeout`); a
548/// fetch with no deadline is unaffected.
549pub const FETCH_TIMEOUT_GRACE: Duration = Duration::from_secs(2);
550
551/// Lower-case substrings marking a merge that stopped on conflicts.
552const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
553/// Lower-case substrings marking a commit that found nothing to record.
554const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
555/// Lower-case substrings marking a transient (retryable) network/fetch failure.
556/// The timeout markers are kept *specific* (`connection timed out` /
557/// `operation timed out`) rather than a bare `timed out`, which would also match
558/// unrelated, non-network "timed out" messages (a lock wait, a hook) and trigger a
559/// spurious fetch retry.
560const TRANSIENT_FETCH_MARKERS: &[&str] = &[
561    "could not resolve host",
562    "couldn't resolve host",
563    "temporary failure in name resolution",
564    "connection timed out",
565    "connection refused",
566    "operation timed out",
567    "network is unreachable",
568    "failed to connect",
569    "could not read from remote repository",
570    "the remote end hung up",
571    "early eof",
572    "rpc failed",
573];
574
575/// Whether `err` is an [`Error::Exit`] whose captured output contains any marker.
576fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
577    let Error::Exit { stdout, stderr, .. } = err else {
578        return false;
579    };
580    let out = stdout.to_ascii_lowercase();
581    let errt = stderr.to_ascii_lowercase();
582    markers.iter().any(|m| out.contains(m) || errt.contains(m))
583}
584
585/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict. (jj
586/// surfaces conflicts as state rather than as errors, so this only fires on git
587/// output — see `vcs_core::Error::is_merge_conflict`.)
588pub fn is_merge_conflict(err: &Error) -> bool {
589    exit_output_matches(err, CONFLICT_MARKERS)
590}
591
592/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
593/// tree), as opposed to a real error.
594pub fn is_nothing_to_commit(err: &Error) -> bool {
595    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
596}
597
598/// Whether a failed `fetch`/`fetch_branch`/`remote_branch_exists` looks
599/// transient (DNS, a dropped connection, a fast network blip) and is worth
600/// retrying.
601///
602/// A processkit-level **timeout** is deliberately **not** classified transient
603/// (R6). A `.timeout()`-bounded run that expired has already consumed the caller's
604/// full deadline — retrying it would multiply the wall-clock by [`FETCH_ATTEMPTS`]
605/// (e.g. a black-holed remote under a 120 s deadline would block ≈ 6 min, three
606/// times the advertised ceiling). The deadline *is* the patience budget; a caller
607/// who wants longer should raise the timeout, not have it silently tripled. Fast
608/// transient failures (the io-level and marker cases below) still retry, because
609/// they fail quickly and a retry is cheap.
610pub fn is_transient_fetch_error(err: &Error) -> bool {
611    // An io-level transient from the spawn itself (interrupted / would-block / busy),
612    // which processkit classifies via `Error::is_transient()` (it covers `Spawn`/`Io`,
613    // not `Exit`/`Timeout`, so it composes cleanly with the marker scan below).
614    err.is_transient() || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
615}
616
617/// Lower-case substrings marking a **whole-repository / working-copy lock**
618/// contention failure — another process held the *one* repo-wide lock, so the
619/// command **never started** (clean, pre-execution) and touched nothing.
620///
621/// These are deliberately limited to the locks that guard the *entire* operation
622/// up front, so retrying is safe even on a **mutating** command: the repo was not
623/// modified at all. We intentionally do **not** include per-ref lock messages
624/// (`cannot lock ref`, `<ref>.lock`/`packed-refs.lock: File exists`): a multi-ref
625/// `push`/`fetch` updates refs sequentially, so a ref-lock failure can arrive
626/// *after* earlier refs already moved — replaying that is not idempotent. Network
627/// markers
628/// ([`TRANSIENT_FETCH_MARKERS`]) and conflict/exit failures are likewise absent.
629const LOCK_CONTENTION_MARKERS: &[&str] = &[
630    // git: the whole-repo index lock (pre-write). Match the **locale-stable path
631    // fragment** `index.lock`, not the translated `': File exists'` suffix — git
632    // localizes its messages, so a `LANG=de_DE` runner would never match the full
633    // English phrase. `index.lock` names the index lock specifically; per-ref locks
634    // (`<ref>.lock`, `packed-refs.lock`) are ruled out by the `refs/` guard in
635    // `is_lock_contention`. (This matches any `index.lock` *create* failure — a
636    // held lock, or e.g. `Permission denied` — all pre-write, so retrying is safe.)
637    "index.lock",
638    // jj: the working-copy lock and the operation-heads lock (both pre-mutation).
639    // These are jj's exact wordings (lower-cased for the classifier). NOTE: modern
640    // jj generally **blocks** on these locks until they're free rather than failing,
641    // so contention usually surfaces as a wait, not a classifiable error — these
642    // markers catch only the residual cases where jj does surface a lock error.
643    "failed to lock working copy",
644    "failed to lock operation heads store",
645];
646
647/// Whether `err` is a **whole-repository lock-contention** failure — another
648/// process held git's `index.lock` or jj's working-copy / op-heads lock, so the
649/// command couldn't even start. Such a failure is *pre-execution* and therefore
650/// safe to retry even on a **mutating** operation (the repo was never modified).
651/// Per-ref lock failures (`cannot lock ref`, `<ref>.lock`) are deliberately **not**
652/// classified here — they can occur mid-way through a multi-ref `push`/`fetch`,
653/// where a retry would not be idempotent. Conflict, "nothing to commit", a real
654/// non-zero exit, a timeout, a signal, or a missing binary are also **not** lock
655/// contention and must not be retried this way.
656pub fn is_lock_contention(err: &Error) -> bool {
657    // Rule out a **per-ref** lock first: it is *not* safely retryable (a multi-ref
658    // push/fetch can fail one ref's lock after earlier refs already moved). git's
659    // per-ref lock lives under `refs/` (`…/refs/heads/<name>.lock`) and its message
660    // names `refs/…`, whereas the whole-repo `index.lock` (`<gitdir>/index.lock`)
661    // never does — so a `refs/` mention excludes it, locale-independently. This also
662    // stops a branch literally named `index`/`reindex` (whose `…/reindex.lock`
663    // contains the substring `index.lock`) from matching the bare `index.lock`
664    // marker. (A repo whose *path* contains `refs/` then misses the index-lock retry
665    // — a benign false-negative, safer than a wrong retry.)
666    if exit_output_matches(err, &["refs/"]) {
667        return false;
668    }
669    exit_output_matches(err, LOCK_CONTENTION_MARKERS)
670}
671
672/// Whether `err` is an **input rejection** — a bad caller argument, encoded as an
673/// [`Error::Spawn`] whose source is `io::ErrorKind::InvalidInput`. This is the
674/// pattern the toolkit's own argument guards raise ([`reject_flag_like`] and the
675/// validating newtypes `RefName`/`RevSpec`/`RevsetExpr`) for a value that would be
676/// misparsed as a flag, is empty, or contains a NUL — and it also covers the
677/// spawn-time `InvalidInput` the OS raises for an un-spawnable argument (an interior
678/// NUL in a flag-value, or Windows' batch-arg-escaping refusal). All are genuine
679/// bad input, distinct from a real spawn failure (missing binary → `NotFound`, no
680/// perms → `PermissionDenied`) or a non-zero exit. A binding maps this to a
681/// `ValueError`; the facades re-expose it as `Error::is_invalid_input()`.
682pub fn is_invalid_input(err: &Error) -> bool {
683    matches!(
684        err,
685        Error::Spawn { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput
686    )
687}
688
689/// A bounded retry strategy: how many attempts, the (exponential) backoff between
690/// them, and whether to add full jitter. Used by [`ManagedClient`] to retry
691/// [`is_lock_contention`] failures. The [`Default`] is [`none`](RetryPolicy::none)
692/// (no retry) — retry is **opt-in**.
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694#[non_exhaustive]
695pub struct RetryPolicy {
696    /// Total attempts including the first; `1` means no retry.
697    pub attempts: u32,
698    /// Delay before the first retry; doubles each subsequent retry (capped by
699    /// [`max_backoff`](RetryPolicy::max_backoff)). `ZERO` means retry immediately.
700    pub base_backoff: Duration,
701    /// Upper bound on the (pre-jitter) backoff delay. `ZERO` means uncapped.
702    pub max_backoff: Duration,
703    /// Apply **full jitter** — the actual delay is uniform in `[0, computed]` — to
704    /// avoid a thundering herd when many workers retry against one repository.
705    pub jitter: bool,
706}
707
708impl RetryPolicy {
709    /// No retry: a single attempt. The default.
710    pub const fn none() -> Self {
711        Self {
712            attempts: 1,
713            base_backoff: Duration::ZERO,
714            max_backoff: Duration::ZERO,
715            jitter: false,
716        }
717    }
718
719    /// A sensible default for repository lock contention: a handful of attempts
720    /// with short, jittered, exponential backoff (25 ms → 500 ms).
721    pub const fn lock_contention() -> Self {
722        Self {
723            attempts: 5,
724            base_backoff: Duration::from_millis(25),
725            max_backoff: Duration::from_millis(500),
726            jitter: true,
727        }
728    }
729
730    /// Set the total number of attempts (clamped to at least 1).
731    pub fn attempts(mut self, attempts: u32) -> Self {
732        self.attempts = attempts.max(1);
733        self
734    }
735
736    /// Set the base backoff (the delay before the first retry).
737    pub fn base_backoff(mut self, backoff: Duration) -> Self {
738        self.base_backoff = backoff;
739        self
740    }
741
742    /// Cap the (pre-jitter) backoff delay; `ZERO` leaves it uncapped.
743    pub fn max_backoff(mut self, max: Duration) -> Self {
744        self.max_backoff = max;
745        self
746    }
747
748    /// Toggle full jitter on the backoff delay.
749    pub fn with_jitter(mut self, jitter: bool) -> Self {
750        self.jitter = jitter;
751        self
752    }
753}
754
755impl Default for RetryPolicy {
756    /// No retry — retry is opt-in.
757    fn default() -> Self {
758        Self::none()
759    }
760}
761
762/// The (possibly jittered) backoff before the `retry_index`-th retry (0 = first).
763fn backoff_for(policy: &RetryPolicy, retry_index: u32) -> Duration {
764    if policy.base_backoff.is_zero() {
765        return Duration::ZERO;
766    }
767    let base = policy.base_backoff.as_nanos();
768    let scaled = base.saturating_mul(1u128 << retry_index.min(20));
769    let capped = if policy.max_backoff.is_zero() {
770        scaled
771    } else {
772        scaled.min(policy.max_backoff.as_nanos())
773    };
774    let delay = Duration::from_nanos(capped.min(u64::MAX as u128) as u64);
775    if policy.jitter {
776        full_jitter(delay)
777    } else {
778        delay
779    }
780}
781
782/// Full jitter: a uniform delay in `[0, max]`. Dependency-free randomness via the
783/// OS-seeded [`RandomState`](std::collections::hash_map::RandomState) — good enough
784/// to de-correlate retries, not cryptographic.
785fn full_jitter(max: Duration) -> Duration {
786    use std::hash::{BuildHasher, Hasher};
787    let nanos = max.as_nanos();
788    if nanos == 0 {
789        return Duration::ZERO;
790    }
791    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
792    hasher.write_u64(nanos as u64);
793    let r = hasher.finish() as u128;
794    Duration::from_nanos((r % (nanos + 1)).min(u64::MAX as u128) as u64)
795}
796
797/// The structured [`Error::Cancelled`] to surface when a cancellation token aborts
798/// the retry backoff, named for the same program as the attempt that just failed —
799/// so it reads exactly like the `Cancelled` a [`processkit`] run raises when its own
800/// [`default_cancel_on`](ManagedClient::default_cancel_on) token kills an in-flight
801/// process. Falls back to an empty program name only if the last error carried none
802/// (every real attempt error names its program).
803fn cancelled_error(last_err: &Error) -> Error {
804    Error::Cancelled {
805        program: last_err.program().unwrap_or_default().to_owned(),
806    }
807}
808
809/// Run `op`, retrying its result while `should_retry` says so and `policy` has
810/// attempts left, sleeping the (jittered, exponential) backoff between tries. The
811/// op is re-invoked from scratch each attempt, so it must be idempotent for the
812/// errors `should_retry` selects (lock-contention failures are — the command never
813/// ran). Returns the first `Ok`, or the last `Err`.
814///
815/// When `cancel` is `Some`, the backoff between attempts is **cancellation-aware**:
816/// if the token fires before or during a wait, the wait stops immediately and the
817/// whole retry aborts with a structured [`Error::Cancelled`] (naming the
818/// just-failed attempt's program). It does **not** sit out the rest of the delay,
819/// and — crucially — it launches **no** further attempt, so a cancel can never race
820/// a fresh op into flight (the attempt count stays deterministic). Pass `None` to
821/// keep the plain, uninterruptible backoff (behaviour unchanged from before this
822/// parameter existed).
823///
824/// The **first** attempt always runs; cancellation is only observed around the
825/// backoff. An `op` bound to the same token (a [`ManagedClient`] built with
826/// [`default_cancel_on`](ManagedClient::default_cancel_on)) still surfaces its own
827/// `Cancelled` when the token was already fired as it ran — `should_retry` returns
828/// `false` for that terminal error, so the loop returns it without a backoff anyway.
829pub async fn retry_async<T, Fut>(
830    policy: &RetryPolicy,
831    cancel: Option<&CancellationToken>,
832    should_retry: impl Fn(&Error) -> bool,
833    mut op: impl FnMut() -> Fut,
834) -> Result<T>
835where
836    Fut: Future<Output = Result<T>>,
837{
838    let attempts = policy.attempts.max(1);
839    for attempt in 1..=attempts {
840        match op().await {
841            Ok(value) => return Ok(value),
842            Err(err) => {
843                if attempt == attempts || !should_retry(&err) {
844                    return Err(err);
845                }
846                let delay = backoff_for(policy, attempt - 1);
847                match cancel {
848                    // Cancellation-aware backoff. `run_until_cancelled` drops the
849                    // pending sleep the instant the token fires (or returns at once
850                    // if it is already fired), so a cancelled retry never waits out
851                    // the full delay. We then abort with a structured `Cancelled`
852                    // instead of looping into another attempt — the same check also
853                    // covers a zero delay and a cancel that lands right as the wait
854                    // ends, so no attempt is ever launched after the token fired.
855                    Some(token) => {
856                        if !delay.is_zero() {
857                            let _ = token.run_until_cancelled(tokio::time::sleep(delay)).await;
858                        }
859                        if token.is_cancelled() {
860                            return Err(cancelled_error(&err));
861                        }
862                    }
863                    // No token: the original plain, uninterruptible backoff.
864                    None => {
865                        if !delay.is_zero() {
866                            tokio::time::sleep(delay).await;
867                        }
868                    }
869                }
870            }
871        }
872    }
873    unreachable!("the loop returns on the final attempt")
874}
875
876/// A [`CliClient`] wrapper that adds two opt-in concerns the CLI wrappers
877/// (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`) all share, without touching a
878/// single call site:
879///
880/// 1. **Lock-contention retry** ([`is_lock_contention`]) per a [`RetryPolicy`] —
881///    off by default ([`RetryPolicy::none`]); enable with
882///    [`with_retry`](ManagedClient::with_retry). Safe even for mutating commands,
883///    since lock contention is a clean pre-execution failure.
884/// 2. **Credential injection** from an opt-in [`CredentialProvider`] — off by
885///    default (no provider); attach one with
886///    [`with_credentials`](ManagedClient::with_credentials). When a forge
887///    *token-env* binding is configured
888///    ([`with_token_env`](ManagedClient::with_token_env)), every command run
889///    through this client gets the resolved token in that environment variable
890///    (e.g. `GH_TOKEN`). Backends that inject the secret differently (git's
891///    `credential.helper`) instead call
892///    [`resolve_credential`](ManagedClient::resolve_credential) at the command
893///    site. Resolution happens once per call, before the retry loop. A
894///    [`with_expected_host`](ManagedClient::with_expected_host) binding travels as
895///    the request's host so a **host-keyed** provider selects the right instance's
896///    secret; the `Ok(None)` / `Err` fallback (defer to ambient vs. fail-closed
897///    abort) is defined on
898///    [`resolve_credential`](ManagedClient::resolve_credential).
899///
900/// Both default to inert, so a client with neither configured behaves exactly
901/// like a bare `CliClient`.
902pub struct ManagedClient<R: ProcessRunner = JobRunner> {
903    inner: CliClient<R>,
904    retry: RetryPolicy,
905    credentials: Option<Arc<dyn CredentialProvider>>,
906    /// When set, the token is auto-injected into this env var on every command,
907    /// resolved for this service. Used by the forge clients (`GH_TOKEN`, …).
908    token_env: Option<(CredentialService, &'static str)>,
909    /// The remote host this client targets, set when a forge `with_host` builder
910    /// bound one. It becomes the [`CredentialRequest`]'s host on the auto-injected
911    /// token-env path (the forge case), so a **host-keyed** provider selects the
912    /// secret for *this* host and never a neighbouring instance's. `None` leaves the
913    /// request host unset — a host-keyed provider that can't place the request
914    /// returns `Ok(None)` and the command falls back to ambient auth, rather than
915    /// being handed the wrong host's secret.
916    expected_host: Option<String>,
917    /// A copy of the [`default_cancel_on`](Self::default_cancel_on) token, kept here
918    /// (as well as on `inner`, which bounds the spawned *process*) so the retry loop
919    /// can cut a lock-contention backoff short the instant cancellation fires,
920    /// instead of sleeping out the full delay before the next attempt.
921    cancel: Option<CancellationToken>,
922    /// The default output budget applied to the potentially large **content**
923    /// verbs this client builds (via [`run_untrimmed`](Self::run_untrimmed)) and,
924    /// on request, to a discard verb's diagnostic capture
925    /// ([`budget_diagnostics`](Self::budget_diagnostics)). Defaults to
926    /// [`OutputBudget::unlimited`] — no ceiling — so a client that never sets one
927    /// behaves exactly as before. A single call overrides it via
928    /// [`run_untrimmed_within`](Self::run_untrimmed_within).
929    output_budget: OutputBudget,
930}
931
932impl<R: ProcessRunner> fmt::Debug for ManagedClient<R> {
933    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
934        f.debug_struct("ManagedClient")
935            .field("inner", &self.inner)
936            .field("retry", &self.retry)
937            // Never render the provider itself (it may close over a secret); just
938            // whether one is configured, plus the token-env binding.
939            .field("credentials", &self.credentials.is_some())
940            .field("token_env", &self.token_env)
941            // A hostname, not a secret — safe to render; helps distinguish a
942            // host-bound client's `{:?}` from an unbound one.
943            .field("expected_host", &self.expected_host)
944            // The token itself is not meaningfully renderable; whether one is set
945            // matches `inner`'s own `has_default_cancel`, kept explicit here too.
946            .field("has_cancel", &self.cancel.is_some())
947            // A small plain cap (no secret) — safe to render.
948            .field("output_budget", &self.output_budget)
949            .finish()
950    }
951}
952
953impl ManagedClient<JobRunner> {
954    /// A retrying client driving `program` on the real job-backed runner (no retry
955    /// until [`with_retry`](ManagedClient::with_retry)).
956    pub fn new(program: impl AsRef<OsStr>) -> Self {
957        Self {
958            inner: CliClient::new(program),
959            retry: RetryPolicy::none(),
960            credentials: None,
961            token_env: None,
962            expected_host: None,
963            cancel: None,
964            output_budget: OutputBudget::unlimited(),
965        }
966    }
967}
968
969impl<R: ProcessRunner> ManagedClient<R> {
970    /// A retrying client driving `program` on `runner` — inject a fake in tests.
971    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
972        Self {
973            inner: CliClient::with_runner(program, runner),
974            retry: RetryPolicy::none(),
975            credentials: None,
976            token_env: None,
977            expected_host: None,
978            cancel: None,
979            output_budget: OutputBudget::unlimited(),
980        }
981    }
982
983    /// Set the lock-contention retry policy (opt-in; default is no retry).
984    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
985        self.retry = policy;
986        self
987    }
988
989    /// The active retry policy.
990    pub fn retry_policy(&self) -> RetryPolicy {
991        self.retry
992    }
993
994    /// Attach a [`CredentialProvider`] (opt-in; default is none → ambient auth).
995    /// The provider is consulted per operation: automatically when a
996    /// [`with_token_env`](ManagedClient::with_token_env) binding is set, or
997    /// on demand via [`resolve_credential`](ManagedClient::resolve_credential).
998    ///
999    /// **Precedence:** a resolved token is injected *after* any
1000    /// [`default_env`](ManagedClient::default_env), so the provider wins over a
1001    /// static default and over the ambient CLI login. **Cancellation:** a
1002    /// [`default_cancel_on`](ManagedClient::default_cancel_on) token bounds the
1003    /// spawned *process*, not provider resolution — if your provider does slow I/O
1004    /// (a vault lookup), bound it yourself.
1005    #[must_use]
1006    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1007        self.credentials = Some(provider);
1008        self
1009    }
1010
1011    /// Bind the resolved token to an environment variable injected on **every**
1012    /// command this client runs (the forge case: `GH_TOKEN`, `GITLAB_TOKEN`). The
1013    /// `service` tags the [`CredentialRequest`]. No effect without a provider.
1014    #[must_use]
1015    pub fn with_token_env(mut self, service: CredentialService, var: &'static str) -> Self {
1016        self.token_env = Some((service, var));
1017        self
1018    }
1019
1020    /// Bind the remote host this client targets (set by a forge `with_host`): it
1021    /// travels as the [`CredentialRequest`]'s host whenever the token-env path
1022    /// resolves a credential, so a **host-keyed** [`CredentialProvider`] returns the
1023    /// secret for *this* host and nothing else — one client can't inject a
1024    /// neighbouring instance's token. Without it the request host is unset (the
1025    /// pre-host-context behaviour). No effect without a provider and a
1026    /// [`with_token_env`](Self::with_token_env) binding.
1027    #[must_use]
1028    pub fn with_expected_host(mut self, host: impl Into<String>) -> Self {
1029        self.expected_host = Some(host.into());
1030        self
1031    }
1032
1033    /// Whether a credential provider is configured.
1034    #[must_use]
1035    pub fn has_credentials(&self) -> bool {
1036        self.credentials.is_some()
1037    }
1038
1039    /// Resolve a credential for `service`/`host` from the configured provider, or
1040    /// `Ok(None)` if no provider is set or it defers to ambient auth. Backends
1041    /// that inject the secret at the command site (git's `credential.helper`) call
1042    /// this directly; the forge token-env path uses it internally.
1043    ///
1044    /// **Fallback policy (identical for read and write operations):**
1045    /// - **No provider**, or the provider returns **`Ok(None)`** → `Ok(None)`:
1046    ///   defer to the CLI's ambient auth, exactly as if no provider were configured.
1047    /// - A credential whose secret is **empty / whitespace-only** → treated as
1048    ///   `Ok(None)` (ambient): injecting an empty token would *override* the ambient
1049    ///   login with nothing instead of deferring to it.
1050    /// - The provider returns **`Err`** → the error propagates and **aborts** the
1051    ///   operation (**fail-closed**). A provider that cannot resolve (a vault outage)
1052    ///   is never silently downgraded to ambient auth.
1053    ///
1054    /// Passing the operation's `host` is what lets a **host-keyed** provider return
1055    /// the secret for *that* host (or `Ok(None)` for one it does not handle) — so it
1056    /// never hands back a neighbouring instance's token when the host is known, and
1057    /// an unknown/absent host defers to ambient rather than substituting a default
1058    /// secret.
1059    pub async fn resolve_credential(
1060        &self,
1061        service: CredentialService,
1062        host: Option<&str>,
1063    ) -> Result<Option<Credential>> {
1064        let Some(provider) = &self.credentials else {
1065            return Ok(None);
1066        };
1067        let request = CredentialRequest { service, host };
1068        // An empty (or whitespace-only) secret is not a usable credential —
1069        // injecting an empty `GH_TOKEN`/`GITLAB_TOKEN` (or a `password=` line)
1070        // would *override* the ambient login with nothing rather than defer to it.
1071        // Treat it as `None` (ambient), keeping the "no usable credential ⇒
1072        // ambient auth" contract consistent regardless of which adapter produced
1073        // it (matching `EnvToken`'s own whitespace-only ⇒ unset rule).
1074        Ok(provider
1075            .credential(&request)
1076            .await?
1077            .filter(|cred| !cred.secret().expose().trim().is_empty()))
1078    }
1079
1080    /// Materialize `call` into a [`Command`], injecting the forge token env if a
1081    /// [`with_token_env`](ManagedClient::with_token_env) binding and a provider
1082    /// are both configured. The single place the auto-injection happens, shared by
1083    /// every retrying verb.
1084    ///
1085    /// The request carries this client's
1086    /// [`expected_host`](ManagedClient::with_expected_host) (when a forge `with_host`
1087    /// set one), so a host-keyed provider picks the secret for that host. The
1088    /// resolution follows the [`resolve_credential`](ManagedClient::resolve_credential)
1089    /// fallback policy: `Ok(None)` (nothing for this host, or an empty secret) leaves
1090    /// the command on ambient auth — no env is set — while an `Err` **aborts** the
1091    /// command (fail-closed, via `?`). A provider that can't resolve is never
1092    /// silently downgraded to ambient, and a wrong host's secret is never
1093    /// substituted. This holds identically for read and write verbs (both route
1094    /// through here).
1095    async fn prepare(&self, call: impl IntoCommand<R>) -> Result<Command> {
1096        let cmd = call.into_command(&self.inner);
1097        let Some((service, var)) = self.token_env else {
1098            return Ok(cmd);
1099        };
1100        match self
1101            .resolve_credential(service, self.expected_host.as_deref())
1102            .await?
1103        {
1104            Some(cred) => Ok(cmd.env(var, cred.secret().expose())),
1105            None => Ok(cmd),
1106        }
1107    }
1108
1109    /// Apply a default timeout to every command this client builds.
1110    pub fn default_timeout(mut self, timeout: Duration) -> Self {
1111        self.inner = self.inner.default_timeout(timeout);
1112        self
1113    }
1114
1115    /// Set an environment variable on every command this client builds.
1116    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
1117        self.inner = self.inner.default_env(key, value);
1118        self
1119    }
1120
1121    /// Remove an inherited environment variable on every command this client builds.
1122    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
1123        self.inner = self.inner.default_env_remove(key);
1124        self
1125    }
1126
1127    /// Cancel every command this client builds when `token` fires — and cut a
1128    /// lock-contention retry backoff short the moment it does, so a cancelled
1129    /// operation returns promptly instead of sleeping out the remaining delay
1130    /// before its next attempt. The token is applied to the spawned process (via
1131    /// `inner`) *and* observed by the retry loop.
1132    pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
1133        self.inner = self.inner.default_cancel_on(token.clone());
1134        self.cancel = Some(token);
1135        self
1136    }
1137
1138    /// Set the default [`OutputBudget`] applied to the content verbs this client
1139    /// builds through [`run_untrimmed`](Self::run_untrimmed) — off by default
1140    /// ([`OutputBudget::unlimited`]). A single call can override it via
1141    /// [`run_untrimmed_within`](Self::run_untrimmed_within).
1142    pub fn default_output_budget(mut self, budget: OutputBudget) -> Self {
1143        self.output_budget = budget;
1144        self
1145    }
1146
1147    /// The active default output budget.
1148    pub fn output_budget(&self) -> OutputBudget {
1149        self.output_budget
1150    }
1151
1152    /// Apply this client's default budget to `cmd` as a **diagnostic** (drop-oldest
1153    /// tail) bound, for a discard verb that only surfaces its output on failure
1154    /// (`clone`/`fetch`). Caps the retained error/progress buffer without turning a
1155    /// real failure into [`Error::OutputTooLarge`] — the tail (where a CLI's fatal
1156    /// line sits) is preserved, so [`is_transient_fetch_error`] /
1157    /// [`is_lock_contention`] still classify it. A no-op when the budget is
1158    /// [`unlimited`](OutputBudget::unlimited).
1159    pub fn budget_diagnostics(&self, cmd: Command) -> Command {
1160        match self.output_budget.diagnostic_policy() {
1161            Some(policy) => cmd.output_buffer(policy),
1162            None => cmd,
1163        }
1164    }
1165
1166    /// Build a [`Command`] for this client's program (passthrough).
1167    pub fn command<I, S>(&self, args: I) -> Command
1168    where
1169        I: IntoIterator<Item = S>,
1170        S: AsRef<OsStr>,
1171    {
1172        self.inner.command(args)
1173    }
1174
1175    /// Build a [`Command`] bound to `dir` (passthrough).
1176    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
1177    where
1178        I: IntoIterator<Item = S>,
1179        S: AsRef<OsStr>,
1180    {
1181        self.inner.command_in(dir, args)
1182    }
1183
1184    /// The underlying process runner (passthrough — e.g. for `output_all`).
1185    pub fn runner(&self) -> &R {
1186        self.inner.runner()
1187    }
1188
1189    /// Like [`CliClient::run`], with credential injection and lock-retry.
1190    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
1191        let cmd = self.prepare(call).await?;
1192        retry_async(
1193            &self.retry,
1194            self.cancel.as_ref(),
1195            is_lock_contention,
1196            || self.inner.run(cmd.clone()),
1197        )
1198        .await
1199    }
1200
1201    /// Like [`CliClient::run_unit`], with credential injection and lock-retry.
1202    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
1203        let cmd = self.prepare(call).await?;
1204        retry_async(
1205            &self.retry,
1206            self.cancel.as_ref(),
1207            is_lock_contention,
1208            || self.inner.run_unit(cmd.clone()),
1209        )
1210        .await
1211    }
1212
1213    /// Like [`CliClient::output_string`], with credential injection. **No lock-retry:**
1214    /// `output_string` returns `Ok` on a non-zero exit (it captures the result), so a
1215    /// lock failure surfaces as an `Ok` here, not an `Err` the retry predicate could
1216    /// match — route mutations that need lock-retry through
1217    /// [`run`](Self::run)/[`run_unit`](Self::run_unit) instead.
1218    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
1219        let cmd = self.prepare(call).await?;
1220        self.inner.output_string(cmd).await
1221    }
1222
1223    /// Like [`CliClient::output_bytes`], with credential injection. Captures stdout
1224    /// as **raw bytes**, byte-exact — unlike [`output_string`](Self::output_string),
1225    /// which reassembles stdout from decoded lines and so drops a trailing newline.
1226    /// This is the byte-faithful path [`run_untrimmed`](Self::run_untrimmed) needs.
1227    /// **No lock-retry**, for the same reason as `output_string`: it returns `Ok`
1228    /// on a non-zero exit (it captures the result), so a lock failure surfaces as an
1229    /// `Ok` here rather than an `Err` the retry predicate could match.
1230    pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
1231        let cmd = self.prepare(call).await?;
1232        self.inner.output_bytes(cmd).await
1233    }
1234
1235    /// Like [`run`](Self::run), but returns stdout **verbatim** — no `trim_end`.
1236    /// For **content**-returning verbs (a file's bytes at a rev, a diff, a raw
1237    /// template render) where the trailing newline(s) are part of the value, not
1238    /// noise: trimming them corrupts a read-modify-write round-trip and desyncs a
1239    /// diff's last hunk from its `@@` line count. Exit-checked like `run`; no
1240    /// lock-retry (a content read is not a mutation).
1241    ///
1242    /// Routed through [`output_bytes`](Self::output_bytes) (raw stdout), not
1243    /// `output_string`, so the exact bytes — trailing newline included — survive:
1244    /// `output_string` rebuilds stdout from decoded lines and would drop that final
1245    /// `\n`. The raw bytes are then decoded with
1246    /// [`String::from_utf8_lossy`], the same lossy raw-stdout-to-`String` convention
1247    /// used elsewhere in this workspace (e.g. `vcs-jj`).
1248    ///
1249    /// **Output budget:** this client's default [`OutputBudget`]
1250    /// ([`default_output_budget`](Self::default_output_budget)) is applied as a
1251    /// fail-loud byte ceiling — a content read past the cap errors with
1252    /// [`Error::OutputTooLarge`] (carrying the actual and allowed sizes) instead of
1253    /// buffering an unbounded blob, and a truncated read is never returned as if
1254    /// complete. Unlimited by default (unchanged behaviour). Override the ceiling
1255    /// for one call with [`run_untrimmed_within`](Self::run_untrimmed_within).
1256    pub async fn run_untrimmed(&self, call: impl IntoCommand<R>) -> Result<String> {
1257        self.run_untrimmed_within(call, self.output_budget).await
1258    }
1259
1260    /// Like [`run_untrimmed`](Self::run_untrimmed), but with an explicit per-call
1261    /// [`OutputBudget`] instead of this client's default — the per-call override
1262    /// used by the `*_within` content methods (`diff_text_within`,
1263    /// `show_file_within`, `pr_diff_within`, …) to read a legitimately large
1264    /// file/diff (a higher ceiling, or [`OutputBudget::unlimited`]) or to tighten
1265    /// the cap for one call.
1266    pub async fn run_untrimmed_within(
1267        &self,
1268        call: impl IntoCommand<R>,
1269        budget: OutputBudget,
1270    ) -> Result<String> {
1271        let cmd = self.prepare(call).await?;
1272        // A fail-loud byte ceiling: `output_bytes` raises `Error::OutputTooLarge`
1273        // the moment the raw stdout passes the cap (drained but not retained), so
1274        // this never returns a truncated blob as if it were complete.
1275        let cmd = match budget.content_policy() {
1276            Some(policy) => cmd.output_buffer(policy),
1277            None => cmd,
1278        };
1279        let bytes = self
1280            .inner
1281            .output_bytes(cmd)
1282            .await?
1283            .ensure_success()?
1284            .into_stdout();
1285        Ok(String::from_utf8_lossy(&bytes).into_owned())
1286    }
1287
1288    /// Like [`CliClient::probe`] (zero-or-nonzero exit → `bool`), with credential
1289    /// injection and lock-retry.
1290    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
1291        let cmd = self.prepare(call).await?;
1292        retry_async(
1293            &self.retry,
1294            self.cancel.as_ref(),
1295            is_lock_contention,
1296            || self.inner.probe(cmd.clone()),
1297        )
1298        .await
1299    }
1300
1301    /// Like [`CliClient::exit_code`] (the raw exit code; a spawn failure or timeout
1302    /// still errors), with credential injection and lock-retry.
1303    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
1304        let cmd = self.prepare(call).await?;
1305        retry_async(
1306            &self.retry,
1307            self.cancel.as_ref(),
1308            is_lock_contention,
1309            || self.inner.exit_code(cmd.clone()),
1310        )
1311        .await
1312    }
1313
1314    /// Like [`CliClient::parse`] (credential injection applied; the `FnOnce` parser
1315    /// can't be re-run, so lock-retry does not — parsing is a read, where lock
1316    /// contention is not a concern anyway).
1317    pub async fn parse<T>(
1318        &self,
1319        call: impl IntoCommand<R>,
1320        parser: impl FnOnce(&str) -> T + Send,
1321    ) -> Result<T>
1322    where
1323        T: Send,
1324    {
1325        let cmd = self.prepare(call).await?;
1326        self.inner.parse(cmd, parser).await
1327    }
1328
1329    /// Like [`parse`](Self::parse), but hands the parser **raw stdout bytes**
1330    /// instead of a lossily-decoded `&str`. This is the byte-faithful path a parser
1331    /// needs when a **path** (or any payload that need not be valid UTF-8) is part
1332    /// of the output: on Unix a filename can be arbitrary bytes, so decoding it
1333    /// through [`String::from_utf8_lossy`] first would substitute `U+FFFD` and make
1334    /// the path unusable to round-trip back into `add`/`commit_paths`. Routed
1335    /// through [`output_bytes`](Self::output_bytes) (byte-exact stdout) and
1336    /// exit-checked like [`parse`](Self::parse) (`ensure_success`); no lock-retry (a
1337    /// read). Text-only machine output (branch names, hashes, templated rows) should
1338    /// keep using [`parse`](Self::parse) — lossy decoding is acceptable there.
1339    pub async fn parse_bytes<T>(
1340        &self,
1341        call: impl IntoCommand<R>,
1342        parser: impl FnOnce(&[u8]) -> T + Send,
1343    ) -> Result<T>
1344    where
1345        T: Send,
1346    {
1347        let cmd = self.prepare(call).await?;
1348        let bytes = self
1349            .inner
1350            .output_bytes(cmd)
1351            .await?
1352            .ensure_success()?
1353            .into_stdout();
1354        Ok(parser(&bytes))
1355    }
1356
1357    /// Like [`CliClient::try_parse`] (credential injection applied; `FnOnce` parser,
1358    /// and a read, so no lock-retry).
1359    pub async fn try_parse<T>(
1360        &self,
1361        call: impl IntoCommand<R>,
1362        parser: impl FnOnce(&str) -> Result<T> + Send,
1363    ) -> Result<T>
1364    where
1365        T: Send,
1366    {
1367        let cmd = self.prepare(call).await?;
1368        self.inner.try_parse(cmd, parser).await
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    #[test]
1377    fn rejects_empty_and_leading_dash() {
1378        assert!(reject_flag_like("git", "branch name", "-evil").is_err());
1379        assert!(reject_flag_like("git", "branch name", "").is_err());
1380        // Whitespace-only is as meaning-changing as empty — refuse it too.
1381        assert!(reject_flag_like("git", "branch name", "  ").is_err());
1382        assert!(reject_flag_like("git", "branch name", "\t").is_err());
1383        assert!(reject_flag_like("git", "branch name", "feature").is_ok());
1384        // Leading whitespace before a dash is still refused (the flag-check trims).
1385        assert!(reject_flag_like("git", "remote", " --upload-pack=evil").is_err());
1386        assert!(reject_flag_like("git", "remote", "\t-x").is_err());
1387        // An interior NUL is refused (can't go in argv; opaque OS error otherwise).
1388        assert!(reject_flag_like("git", "path", "a\0b").is_err());
1389        // A leading-whitespace non-flag value is still accepted (not flag-like).
1390        assert!(reject_flag_like("git", "branch name", "  feature").is_ok());
1391        // The error names the program and surfaces as a spawn-side refusal.
1392        let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
1393        assert!(matches!(err, Error::Spawn { program, .. } if program == "jj"));
1394    }
1395
1396    #[test]
1397    fn classifies_merge_conflict() {
1398        let on_stdout = Error::exit("git", 1, "CONFLICT (content): Merge conflict in a.rs", "");
1399        let on_stderr = Error::exit(
1400            "git",
1401            1,
1402            "",
1403            "Automatic merge failed; fix conflicts and then commit",
1404        );
1405        let unrelated = Error::exit("git", 128, "", "fatal: not a git repository");
1406        assert!(is_merge_conflict(&on_stdout));
1407        assert!(is_merge_conflict(&on_stderr));
1408        assert!(!is_merge_conflict(&unrelated));
1409        assert!(!is_nothing_to_commit(&on_stdout));
1410    }
1411
1412    #[test]
1413    fn classifies_nothing_to_commit_and_transient_fetch() {
1414        let nothing = Error::exit("git", 1, "nothing to commit, working tree clean", "");
1415        assert!(is_nothing_to_commit(&nothing));
1416
1417        let dns = Error::exit(
1418            "git",
1419            128,
1420            "",
1421            "fatal: unable to access 'https://x/': Could not resolve host: x",
1422        );
1423        assert!(is_transient_fetch_error(&dns));
1424        assert!(!is_transient_fetch_error(&nothing));
1425
1426        // A processkit timeout is deliberately NOT retried (R6): it already consumed
1427        // the caller's full deadline, so retrying would multiply the wall-clock by
1428        // FETCH_ATTEMPTS. The deadline is the patience budget; raise it, don't triple it.
1429        let timeout = Error::timeout("git", Duration::from_secs(10), "", "");
1430        assert!(!is_transient_fetch_error(&timeout));
1431    }
1432
1433    // R9: an io-level transient from the spawn (EINTR / EAGAIN / busy) is fetch-
1434    // retryable too, via processkit's `Error::is_transient()`.
1435    #[test]
1436    fn classifies_io_transient_as_fetch_retryable() {
1437        let interrupted =
1438            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::Interrupted));
1439        assert!(
1440            interrupted.is_transient(),
1441            "processkit treats Interrupted as a transient io error"
1442        );
1443        assert!(is_transient_fetch_error(&interrupted));
1444        // A non-transient io error (e.g. NotFound — the binary is missing) is not retried.
1445        let missing = Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound));
1446        assert!(!is_transient_fetch_error(&missing));
1447    }
1448
1449    // R2: regression for the processkit 0.9.1 untruncated-`Error::Exit` fix. A large
1450    // output (well past the old 4 KiB cap) with the decisive marker near the END must
1451    // still classify — proving the classifiers see the whole captured stream.
1452    #[test]
1453    fn classifies_on_large_output_past_the_old_4kib_cap() {
1454        let padding = "noise line that says nothing\n".repeat(500); // ~14 KiB
1455        let conflict = Error::exit(
1456            "git",
1457            1,
1458            format!("{padding}CONFLICT (content): Merge conflict in late.rs"),
1459            "",
1460        );
1461        assert!(
1462            is_merge_conflict(&conflict),
1463            "a conflict marker past 4 KiB must still classify"
1464        );
1465
1466        let transient = Error::exit(
1467            "git",
1468            128,
1469            "",
1470            format!("{padding}fatal: unable to access: Could not resolve host: x"),
1471        );
1472        assert!(is_transient_fetch_error(&transient));
1473    }
1474
1475    // processkit's `Error` is `#[non_exhaustive]` and grows variants over time
1476    // (`NotReady`/`Unsupported`/`CassetteMiss`/`NotFound`/`Signalled`/`Cancelled`/
1477    // `ResourceLimit`). Unfamiliar variants must fall through every classifier to
1478    // "no" — a not-ready or unsupported run is neither a conflict, nor a clean
1479    // tree, nor worth a fetch retry.
1480    #[test]
1481    fn unfamiliar_error_variants_are_not_classified() {
1482        let not_ready = Error::NotReady {
1483            program: "git".into(),
1484            timeout: Duration::from_secs(5),
1485        };
1486        let unsupported = Error::Unsupported {
1487            operation: "suspend".into(),
1488        };
1489        for err in [&not_ready, &unsupported] {
1490            assert!(!is_merge_conflict(err));
1491            assert!(!is_nothing_to_commit(err));
1492            assert!(!is_transient_fetch_error(err));
1493        }
1494    }
1495
1496    // `Error::Cancelled` (a client-level `default_cancel_on` killing an in-flight
1497    // run; always available since cancellation became core in processkit 0.10) must
1498    // fall through every classifier to "no" — a cancelled fetch was *deliberately*
1499    // stopped, so replaying it would fight the cancellation. (Behaviour already held
1500    // via the `#[non_exhaustive]` fall-through above; this pins it as a first-class
1501    // assertion.)
1502    #[test]
1503    fn cancelled_is_not_transient_or_otherwise_classified() {
1504        let cancelled = Error::Cancelled {
1505            program: "git".into(),
1506        };
1507        assert!(!is_transient_fetch_error(&cancelled));
1508        assert!(!is_merge_conflict(&cancelled));
1509        assert!(!is_nothing_to_commit(&cancelled));
1510    }
1511
1512    // `Error::Signalled` (a process killed by a signal — e.g. an external SIGTERM/
1513    // SIGKILL, surfaced first-class since processkit 0.9.2 and carrying partial
1514    // `stdout`/`stderr` since 0.10) is *terminal*, not transient: a deliberate kill
1515    // should not be auto-retried, and a signal death is neither a merge conflict nor
1516    // a clean tree. processkit's own `is_transient()` agrees (false for `Signalled`),
1517    // so it falls through every classifier to "no" — pinned here, including the case
1518    // where the captured stderr happens to contain an otherwise-transient marker (a
1519    // killed fetch is still not ours to silently replay).
1520    #[test]
1521    fn signalled_is_terminal_not_transient() {
1522        let signalled = Error::signalled(
1523            "git",
1524            Some(15),
1525            "",
1526            "fatal: unable to access: Could not resolve host: x",
1527        );
1528        assert!(!signalled.is_transient());
1529        assert!(!is_transient_fetch_error(&signalled));
1530        assert!(!is_merge_conflict(&signalled));
1531        assert!(!is_nothing_to_commit(&signalled));
1532    }
1533
1534    fn exit(program: &str, code: i32, stderr: &str) -> Error {
1535        Error::exit(program, code, "", stderr)
1536    }
1537
1538    // `is_lock_contention` recognises ONLY the *whole-repo* / working-copy lock
1539    // failures (git index.lock, jj working-copy/op-heads lock) — the ones where the
1540    // command did nothing, so a retry is idempotent even on a mutation. Per-ref lock
1541    // failures and conflicts/timeouts are deliberately NOT classified (a multi-ref
1542    // op can fail a ref lock mid-way, where a retry would not be idempotent).
1543    #[test]
1544    fn classifies_lock_contention() {
1545        let lock_failures = [
1546            // git always names `index.lock` (locale-stable) in the lock-contention
1547            // message, even on a non-English runner where the surrounding prose is
1548            // translated.
1549            exit(
1550                "git",
1551                128,
1552                "fatal: Unable to create '/r/.git/index.lock': File exists.",
1553            ),
1554            // A German runner: the path fragment `index.lock` still matches.
1555            exit(
1556                "git",
1557                128,
1558                "fatal: Konnte '/r/.git/index.lock' nicht erstellen: Datei existiert bereits",
1559            ),
1560            // jj's *actual* wordings (verified against jj source) — note no "the".
1561            exit("jj", 1, "Error: Failed to lock working copy"),
1562            exit("jj", 1, "Error: Failed to lock operation heads store"),
1563        ];
1564        for e in &lock_failures {
1565            assert!(is_lock_contention(e), "should be lock contention: {e:?}");
1566            // A lock failure is NOT a transient *fetch* error — different class.
1567            assert!(!is_transient_fetch_error(e), "not a fetch error: {e:?}");
1568        }
1569        let not_locks = [
1570            exit("git", 1, "CONFLICT (content): Merge conflict in a.rs"),
1571            exit("git", 1, "error: pathspec 'x' did not match any file(s)"),
1572            exit("git", 128, "fatal: not a git repository"),
1573            // Per-ref locks are NOT classified — a multi-ref push/fetch can fail a
1574            // ref lock after earlier refs already moved (non-idempotent to replay).
1575            exit(
1576                "git",
1577                1,
1578                "error: cannot lock ref 'refs/heads/x': reference already exists",
1579            ),
1580            exit(
1581                "git",
1582                128,
1583                "Unable to create '/r/.git/packed-refs.lock': File exists.",
1584            ),
1585            // A per-ref lock for a branch literally named `index`: its
1586            // `…/refs/heads/index.lock` path contains the substring `index.lock`,
1587            // but the `refs/` mention correctly rules it out (not a whole-repo lock).
1588            exit(
1589                "git",
1590                128,
1591                "error: cannot lock ref 'refs/heads/index': Unable to create \
1592                 '/r/.git/refs/heads/index.lock': File exists.",
1593            ),
1594            Error::timeout("git", Duration::from_secs(1), "", ""),
1595        ];
1596        for e in &not_locks {
1597            assert!(
1598                !is_lock_contention(e),
1599                "should NOT be lock contention: {e:?}"
1600            );
1601        }
1602    }
1603
1604    #[test]
1605    fn classifies_invalid_input_from_the_guards() {
1606        // What `reject_flag_like` / the newtypes actually produce.
1607        let rejected = reject_flag_like("git", "reference", "-x").unwrap_err();
1608        assert!(
1609            is_invalid_input(&rejected),
1610            "guard rejection is invalid input"
1611        );
1612        assert!(is_invalid_input(
1613            &reject_flag_like("git", "x", "").unwrap_err()
1614        ));
1615
1616        // A real spawn failure (missing binary), a non-zero exit, and a timeout are
1617        // NOT invalid input — they're environment/usage failures, not a bad argument.
1618        let not_input = [
1619            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound)),
1620            exit("git", 1, "fatal: not a git repository"),
1621            Error::timeout("git", Duration::from_secs(1), "", ""),
1622        ];
1623        for e in &not_input {
1624            assert!(!is_invalid_input(e), "should NOT be invalid input: {e:?}");
1625        }
1626    }
1627
1628    // Backoff is exponential off the base, capped at `max_backoff`, and zero when
1629    // there's no base (immediate retry).
1630    #[test]
1631    fn backoff_is_exponential_capped_and_zero_without_base() {
1632        let p = RetryPolicy::none()
1633            .attempts(6)
1634            .base_backoff(Duration::from_millis(10))
1635            .max_backoff(Duration::from_millis(80));
1636        assert_eq!(backoff_for(&p, 0), Duration::from_millis(10));
1637        assert_eq!(backoff_for(&p, 1), Duration::from_millis(20));
1638        assert_eq!(backoff_for(&p, 2), Duration::from_millis(40));
1639        assert_eq!(backoff_for(&p, 3), Duration::from_millis(80));
1640        assert_eq!(
1641            backoff_for(&p, 4),
1642            Duration::from_millis(80),
1643            "capped at max"
1644        );
1645        assert_eq!(
1646            backoff_for(&RetryPolicy::none(), 3),
1647            Duration::ZERO,
1648            "no base → no wait"
1649        );
1650    }
1651
1652    // Full jitter (used by `RetryPolicy::lock_contention`): every sampled backoff
1653    // stays within `[0, exponential cap]`, and successive samples de-correlate
1654    // (more than one distinct value) so retries don't thunder together. Pins the
1655    // jitter path, which the exponential test above deliberately turns off.
1656    #[test]
1657    fn jitter_stays_within_cap_and_decorrelates() {
1658        let p = RetryPolicy::none()
1659            .attempts(8)
1660            .base_backoff(Duration::from_millis(10))
1661            .max_backoff(Duration::from_millis(80))
1662            .with_jitter(true);
1663        // The cap at retry_index 3 is the full 80ms exponential value.
1664        let cap = Duration::from_millis(80);
1665        let mut seen = std::collections::HashSet::new();
1666        for _ in 0..1000 {
1667            let d = backoff_for(&p, 3);
1668            assert!(
1669                d <= cap,
1670                "jittered backoff {d:?} must stay within the cap {cap:?}"
1671            );
1672            seen.insert(d.as_nanos());
1673        }
1674        assert!(
1675            seen.len() > 1,
1676            "full jitter must produce a spread of delays, not a constant"
1677        );
1678        // A zero base still short-circuits to zero even with jitter on.
1679        assert_eq!(
1680            backoff_for(&RetryPolicy::none().with_jitter(true), 2),
1681            Duration::ZERO
1682        );
1683    }
1684
1685    // The executor: retries while the predicate matches and attempts remain, returns
1686    // the first Ok, doesn't retry a non-matching error, and exhausts to the last Err.
1687    #[tokio::test]
1688    async fn retry_async_retries_then_succeeds_and_respects_the_predicate() {
1689        use std::sync::atomic::{AtomicU32, Ordering};
1690        // Zero backoff → no sleep, deterministic & fast.
1691        let policy = RetryPolicy::none().attempts(4);
1692        let lock = || {
1693            exit(
1694                "git",
1695                128,
1696                "Unable to create '/r/.git/index.lock': File exists.",
1697            )
1698        };
1699
1700        // Fails twice with a lock error, then succeeds — retried to success.
1701        let calls = AtomicU32::new(0);
1702        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1703            let n = calls.fetch_add(1, Ordering::SeqCst);
1704            let lock = lock();
1705            async move { if n < 2 { Err(lock) } else { Ok(n) } }
1706        })
1707        .await;
1708        assert_eq!(out.unwrap(), 2);
1709        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 try + 2 retries");
1710
1711        // A non-lock error is returned immediately (not retried).
1712        let calls = AtomicU32::new(0);
1713        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1714            calls.fetch_add(1, Ordering::SeqCst);
1715            async { Err(exit("git", 1, "real, deterministic failure")) }
1716        })
1717        .await;
1718        assert!(out.is_err());
1719        assert_eq!(
1720            calls.load(Ordering::SeqCst),
1721            1,
1722            "non-retryable → single attempt"
1723        );
1724
1725        // Persistent lock contention exhausts the attempt budget.
1726        let calls = AtomicU32::new(0);
1727        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1728            calls.fetch_add(1, Ordering::SeqCst);
1729            async { Err(exit("git", 128, "index.lock': File exists")) }
1730        })
1731        .await;
1732        assert!(out.is_err());
1733        assert_eq!(calls.load(Ordering::SeqCst), 4, "all attempts used");
1734    }
1735
1736    // A persistent lock error always retryable, for the cancellation tests below.
1737    fn lock_err() -> Error {
1738        exit(
1739            "git",
1740            128,
1741            "Unable to create '/r/.git/index.lock': File exists.",
1742        )
1743    }
1744
1745    // Cancellation scenario 1 — the token is **already fired** when the backoff is
1746    // about to begin: `retry_async` must not sleep out the (long) delay, and must
1747    // abort with a structured `Cancelled` after the single attempt that already ran,
1748    // launching no second one. On a paused clock the virtual time must not advance —
1749    // proving the full backoff was skipped, not merely fast.
1750    #[tokio::test(start_paused = true)]
1751    async fn cancel_before_backoff_aborts_without_waiting_or_retrying() {
1752        use std::sync::atomic::{AtomicU32, Ordering};
1753        let token = CancellationToken::new();
1754        token.cancel(); // already cancelled before we even start
1755        let policy = RetryPolicy::none()
1756            .attempts(5)
1757            .base_backoff(Duration::from_secs(3600)); // huge — must never be waited
1758        let calls = AtomicU32::new(0);
1759
1760        let start = tokio::time::Instant::now();
1761        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1762            calls.fetch_add(1, Ordering::SeqCst);
1763            async { Err(lock_err()) }
1764        })
1765        .await;
1766
1767        assert!(
1768            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1769            "a fired token aborts with a program-named Cancelled, got {out:?}"
1770        );
1771        assert_eq!(
1772            calls.load(Ordering::SeqCst),
1773            1,
1774            "one attempt ran; the cancel launched no retry"
1775        );
1776        assert_eq!(
1777            start.elapsed(),
1778            Duration::ZERO,
1779            "the backoff was cut short — no virtual time elapsed"
1780        );
1781    }
1782
1783    // Cancellation scenario 2 — the token fires **while the backoff sleep is
1784    // parked**. With a paused clock the (long) sleep cannot elapse on its own, so a
1785    // spawned task cancelling the token is what resolves the wait: the retry must
1786    // wake early and return `Cancelled` without a second attempt.
1787    #[tokio::test(start_paused = true)]
1788    async fn cancel_during_backoff_wakes_early_and_does_not_retry() {
1789        use std::sync::atomic::{AtomicU32, Ordering};
1790        let token = CancellationToken::new();
1791        let policy = RetryPolicy::none()
1792            .attempts(5)
1793            .base_backoff(Duration::from_secs(3600)); // never elapses under paused time
1794        let calls = AtomicU32::new(0);
1795
1796        let start = tokio::time::Instant::now();
1797        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1798            let n = calls.fetch_add(1, Ordering::SeqCst);
1799            let token = token.clone();
1800            async move {
1801                // On the first failure, schedule the cancel to land while we are
1802                // parked in the backoff sleep (the sleep can't fire under paused time,
1803                // so this is what unblocks the wait).
1804                if n == 0 {
1805                    tokio::spawn(async move { token.cancel() });
1806                }
1807                Err(lock_err())
1808            }
1809        })
1810        .await;
1811
1812        assert!(
1813            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1814            "a cancel during the sleep aborts with Cancelled, got {out:?}"
1815        );
1816        assert_eq!(
1817            calls.load(Ordering::SeqCst),
1818            1,
1819            "cancel woke the sleep early — no second attempt"
1820        );
1821        assert_eq!(
1822            start.elapsed(),
1823            Duration::ZERO,
1824            "woke on the cancel, not after the 1 h delay"
1825        );
1826    }
1827
1828    // Cancellation scenario 3 — the token fires such that it is observed **right
1829    // before the next attempt** would launch. With a zero backoff there is no sleep
1830    // to interrupt, so the op cancels the token as it fails; the guard between the
1831    // (no-op) backoff and the next attempt must still abort with `Cancelled` rather
1832    // than spinning up attempt #2.
1833    #[tokio::test(start_paused = true)]
1834    async fn cancel_right_before_next_attempt_aborts() {
1835        use std::sync::atomic::{AtomicU32, Ordering};
1836        let token = CancellationToken::new();
1837        let policy = RetryPolicy::none().attempts(5); // zero backoff → no sleep
1838        let calls = AtomicU32::new(0);
1839
1840        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
1841            let n = calls.fetch_add(1, Ordering::SeqCst);
1842            let token = token.clone();
1843            async move {
1844                // Cancel as the first attempt fails: the post-backoff guard must catch
1845                // it before launching the next attempt.
1846                if n == 0 {
1847                    token.cancel();
1848                }
1849                Err(lock_err())
1850            }
1851        })
1852        .await;
1853
1854        assert!(
1855            matches!(out, Err(Error::Cancelled { ref program }) if program == "git"),
1856            "a cancel observed before the next attempt aborts with Cancelled, got {out:?}"
1857        );
1858        assert_eq!(
1859            calls.load(Ordering::SeqCst),
1860            1,
1861            "the guard stopped attempt #2 from launching"
1862        );
1863    }
1864
1865    // Without a token the backoff is unchanged: a persistent lock error still
1866    // exhausts every attempt (no early exit, `None` path preserved).
1867    #[tokio::test]
1868    async fn no_token_backoff_is_unchanged() {
1869        use std::sync::atomic::{AtomicU32, Ordering};
1870        let policy = RetryPolicy::none().attempts(3); // zero backoff, fast
1871        let calls = AtomicU32::new(0);
1872        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
1873            calls.fetch_add(1, Ordering::SeqCst);
1874            async { Err(lock_err()) }
1875        })
1876        .await;
1877        assert!(
1878            matches!(out, Err(Error::Exit { .. })),
1879            "last error is the lock exit, not Cancelled"
1880        );
1881        assert_eq!(
1882            calls.load(Ordering::SeqCst),
1883            3,
1884            "all attempts used with no token"
1885        );
1886    }
1887
1888    // `resolve_credential` returns `None` until a provider is attached, then the
1889    // provider's credential. (No process is spawned, so the real runner is fine.)
1890    #[tokio::test]
1891    async fn retrying_client_resolves_credential_opt_in() {
1892        let client = ManagedClient::new("git");
1893        assert!(!client.has_credentials());
1894        assert!(
1895            client
1896                .resolve_credential(CredentialService::Git, None)
1897                .await
1898                .unwrap()
1899                .is_none(),
1900            "no provider → ambient (None)"
1901        );
1902
1903        let client = client.with_credentials(Arc::new(StaticCredential::token("t0k")));
1904        assert!(client.has_credentials());
1905        let got = client
1906            .resolve_credential(CredentialService::Git, None)
1907            .await
1908            .unwrap()
1909            .expect("provider yields a credential");
1910        assert_eq!(got.secret().expose(), "t0k");
1911    }
1912
1913    // An empty (or whitespace-only) secret is treated as `None` (ambient):
1914    // injecting an empty token would override the ambient login with nothing
1915    // instead of deferring to it. Mirrors `EnvToken`'s whitespace-only ⇒ unset rule.
1916    #[tokio::test]
1917    async fn resolve_credential_treats_empty_secret_as_ambient() {
1918        // Service-agnostic: both the forge (token-env) and git (helper) paths route
1919        // through this chokepoint, so a blank secret is ambient for either.
1920        for blank in ["", "   ", "\t\n"] {
1921            let client = ManagedClient::new("git")
1922                .with_credentials(Arc::new(StaticCredential::token(blank)));
1923            for service in [CredentialService::GitHub, CredentialService::Git] {
1924                assert!(
1925                    client
1926                        .resolve_credential(service, None)
1927                        .await
1928                        .unwrap()
1929                        .is_none(),
1930                    "blank secret {blank:?} → ambient (None) for {service:?}"
1931                );
1932            }
1933        }
1934    }
1935
1936    // The resolved request carries the operation's host, so a HOST-KEYED provider
1937    // returns the secret for exactly that host — and `Ok(None)` (deferring to
1938    // ambient) for a host it does not place or an absent one, never a wrong-host
1939    // secret. This is the seam `prepare` (forge token-env) and git's
1940    // `remote_credentials` both feed the target host into. (T-045)
1941    #[tokio::test]
1942    async fn resolve_credential_routes_on_request_host() {
1943        let provider = provider_fn(|r: &CredentialRequest<'_>| {
1944            Ok(match r.host {
1945                Some("github.com") => Some(Credential::token("saas")),
1946                Some("ghe.example.com") => Some(Credential::token("ent")),
1947                // An unknown or absent host defers to ambient rather than a default.
1948                _ => None,
1949            })
1950        });
1951        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
1952        let resolve =
1953            |host: Option<&'static str>| client.resolve_credential(CredentialService::GitHub, host);
1954
1955        assert_eq!(
1956            resolve(Some("github.com"))
1957                .await
1958                .unwrap()
1959                .unwrap()
1960                .secret()
1961                .expose(),
1962            "saas"
1963        );
1964        assert_eq!(
1965            resolve(Some("ghe.example.com"))
1966                .await
1967                .unwrap()
1968                .unwrap()
1969                .secret()
1970                .expose(),
1971            "ent"
1972        );
1973        assert!(
1974            resolve(Some("other.example")).await.unwrap().is_none(),
1975            "a host the provider doesn't place → ambient (None), not a wrong secret"
1976        );
1977        assert!(
1978            resolve(None).await.unwrap().is_none(),
1979            "an absent host → ambient (None)"
1980        );
1981    }
1982
1983    // Fail-closed: a provider `Err` propagates out of `resolve_credential` (and so
1984    // aborts the command in `prepare` / `remote_credentials`) for any host — it is
1985    // never swallowed into a silent ambient fallback. (T-045 fallback policy)
1986    #[tokio::test]
1987    async fn resolve_credential_propagates_provider_error_fail_closed() {
1988        let provider = provider_fn(|_r: &CredentialRequest<'_>| {
1989            Err(Error::spawn(
1990                "vault",
1991                std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault unreachable"),
1992            ))
1993        });
1994        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
1995        for host in [Some("github.com"), None] {
1996            assert!(
1997                client
1998                    .resolve_credential(CredentialService::GitHub, host)
1999                    .await
2000                    .is_err(),
2001                "provider error must propagate (fail-closed), host={host:?}"
2002            );
2003        }
2004    }
2005
2006    // The default budget is unlimited — no ceiling, so a client that never sets
2007    // one keeps its pre-budget (unbounded) capture behaviour, and both policy
2008    // projections are `None` (leave the command's own buffer untouched).
2009    #[test]
2010    fn output_budget_default_is_unlimited() {
2011        let b = OutputBudget::default();
2012        assert!(b.is_unlimited());
2013        assert_eq!(b, OutputBudget::unlimited());
2014        assert_eq!(b.max_bytes(), None);
2015        assert_eq!(b.max_lines(), None);
2016        assert!(b.content_policy().is_none());
2017        assert!(b.diagnostic_policy().is_none());
2018    }
2019
2020    // A byte cap projects onto a FAIL-LOUD content policy (errors past the cap,
2021    // never truncates) and a DROP-OLDEST diagnostic policy (bounded tail, never
2022    // errors) — the two shapes one budget drives.
2023    #[test]
2024    fn output_budget_bytes_projects_to_both_policies() {
2025        let b = OutputBudget::bytes(4096);
2026        assert!(!b.is_unlimited());
2027        assert_eq!(b.max_bytes(), Some(4096));
2028
2029        let content = b
2030            .content_policy()
2031            .expect("a byte budget yields a content policy");
2032        assert_eq!(
2033            content.overflow,
2034            OverflowMode::Error,
2035            "content is fail-loud"
2036        );
2037        assert_eq!(content.max_bytes, Some(4096));
2038        // No line cap set, so the fail-loud ceiling rests entirely on the byte cap
2039        // (which is exactly what the raw-stdout content path enforces).
2040        assert_eq!(content.max_lines, None);
2041
2042        let diag = b
2043            .diagnostic_policy()
2044            .expect("a byte budget yields a diagnostic policy");
2045        assert_eq!(
2046            diag.overflow,
2047            OverflowMode::DropOldest,
2048            "diagnostics keep the tail, never OutputTooLarge"
2049        );
2050        assert_eq!(diag.max_bytes, Some(4096));
2051    }
2052
2053    // A line ceiling composes with the byte cap on both projections.
2054    #[test]
2055    fn output_budget_with_max_lines_composes() {
2056        let b = OutputBudget::bytes(4096).with_max_lines(200);
2057        assert_eq!(b.max_lines(), Some(200));
2058        let content = b.content_policy().unwrap();
2059        assert_eq!(content.max_lines, Some(200));
2060        assert_eq!(content.max_bytes, Some(4096));
2061        assert_eq!(content.overflow, OverflowMode::Error);
2062        let diag = b.diagnostic_policy().unwrap();
2063        assert_eq!(diag.max_lines, Some(200));
2064        assert_eq!(diag.max_bytes, Some(4096));
2065        assert_eq!(diag.overflow, OverflowMode::DropOldest);
2066    }
2067
2068    // The client-level default budget round-trips through the builder/getter, and
2069    // `budget_diagnostics` applies (or, when unlimited, leaves) a command's buffer.
2070    #[test]
2071    fn managed_client_default_output_budget_round_trips() {
2072        let client = ManagedClient::new("git");
2073        assert!(client.output_budget().is_unlimited());
2074        let client = client.default_output_budget(OutputBudget::bytes(1 << 20));
2075        assert_eq!(client.output_budget(), OutputBudget::bytes(1 << 20));
2076    }
2077}