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