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