Skip to main content

vcs_cli_support/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-cli-support` — the [`processkit`]-coupled plumbing the CLI wrappers reuse.
4//!
5//! `vcs-git` / `vcs-jj` / `vcs-github` all drive a CLI through [`processkit`], so
6//! they share three concerns that *touch* [`processkit::Error`]: an argv injection
7//! guard, a fetch-retry policy, and a set of [`Error`] classifiers. Extracting them
8//! here keeps the std-only `vcs-diff` clean of the `processkit` dependency, and —
9//! more to the point — keeps the marker lists and classifier logic from drifting
10//! between backends. The wrapper crates re-export these items (so you reach them
11//! as `vcs_git::is_merge_conflict`, not via this crate's name) and rarely name
12//! `vcs-cli-support` directly.
13//!
14//! # The surface
15//!
16//! - **[`reject_flag_like`]** — the injection guard for bare positional argv slots.
17//!   A caller value that is empty/whitespace, or starts with `-`, is refused before
18//!   spawning (the CLI would parse it as a flag); flag-*value* slots (`-m <msg>`)
19//!   are consumed verbatim and skip the check. Wrappers call it with their own
20//!   binary name so the surfaced [`ErrorReason::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 [`ErrorReason::Exit`] output against fixed marker
28//!   lists; a [`processkit`] [`ErrorReason::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 [`ErrorReason::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::collections::VecDeque;
71use std::ffi::OsStr;
72use std::fmt;
73use std::future::Future;
74use std::path::Path;
75use std::sync::Arc;
76use std::time::{Duration, Instant};
77
78use processkit::prelude::StreamExt;
79use processkit::{
80    CancellationToken, CliClient, Command, Error, ErrorReason, IntoCommand, JobRunner,
81    OutputBufferPolicy, OverflowMode, ProcessResult, ProcessRunner, Result,
82};
83
84/// One lifecycle/progress event from a streamed CLI operation.
85///
86/// Re-exported from [`processkit`] so wrapper users do not need a second direct
87/// dependency merely to inspect progress. The stream starts with
88/// [`ProcessEvent::Started`], carries stdout/stderr lines, and ends with
89/// [`ProcessEvent::Exited`]. The enum is non-exhaustive; consumers should keep a
90/// wildcard match arm.
91pub use processkit::ProcessEvent;
92
93/// Object-safe callback accepted by the typed streaming operations.
94///
95/// `Send` lets an async trait future containing the borrowed callback remain
96/// `Send`, preserving `GitApi`/`JjApi`/facade trait-object usability. A callback
97/// panic is caught and disables further callback delivery for that run; output
98/// draining and process completion continue, matching processkit's hardened
99/// per-line handler contract.
100pub type ProgressCallback<'a> = dyn FnMut(ProcessEvent) + Send + 'a;
101
102/// Run one command while forwarding its lifecycle and output events.
103///
104/// `events()` and `finish()` are deliberately driven concurrently: the terminal
105/// `Exited` event is published by the finisher, so draining the stream first
106/// would deadlock. Output lines are also retained locally so a rejected exit is
107/// promoted to the same structured processkit error shape as `run_unit`, with
108/// the command's stdout/stderr available to classifiers.
109///
110/// This function observes exactly **one process lifecycle** and therefore does
111/// not apply a command/client retry policy: `Exited` is always the final event.
112/// A caller that wants to replay a failed streamed operation can decide that
113/// explicitly after receiving the terminal event and returned error.
114///
115/// **Retention is unbounded**, the pre-budget behaviour: every streamed line is
116/// kept until the process exits. Bound it with
117/// [`run_with_progress_within`] — the streaming counterpart of
118/// [`ManagedClient::budget_diagnostics`] — when the command can produce a lot of
119/// output (`clone`/`fetch --progress` on a large repository).
120pub async fn run_with_progress<R: ProcessRunner + ?Sized>(
121    runner: &R,
122    command: &Command,
123    progress: &mut ProgressCallback<'_>,
124) -> Result<()> {
125    run_with_progress_within(runner, command, progress, OutputBudget::unlimited()).await
126}
127
128/// [`run_with_progress`] with an explicit [`OutputBudget`] bounding the output
129/// it retains locally — the memory ceiling a long streamed run needs.
130///
131/// This is the streaming half of the contract
132/// [`budget_diagnostics`](ManagedClient::budget_diagnostics) applies to the
133/// *captured* twin of the same verbs. That one bounds the buffer **processkit**
134/// retains for a non-streamed `clone`/`fetch`; this one bounds the copy **this
135/// function** keeps in order to promote a rejected exit into a structured error.
136/// A streamed run needs both: the event stream delivers every line regardless of
137/// the command's own [`OutputBufferPolicy`], so without a ceiling here a
138/// `git clone --progress` of a large repository re-introduces exactly the
139/// unbounded retention the budget exists to prevent.
140///
141/// The ceiling is **drop-oldest and never fail-loud**, like
142/// [`OutputBudget::diagnostic_policy`]: passing it truncates what is retained,
143/// it never turns a successful (or plainly failed) run into
144/// [`ErrorReason::OutputTooLarge`]. Each stream carries the ceiling
145/// **independently** — `stdout` and `stderr` get their own budget, so the
146/// worst-case retained memory is about twice the cap, matching how
147/// [`OutputBudget`] rides a captured verb's two streams. The retained **tail**
148/// is what a CLI's fatal line sits in, so a bounded run stays classifiable by
149/// [`is_transient_fetch_error`] / [`is_lock_contention`], and a non-zero exit is
150/// still promoted to a structured [`ErrorReason::Exit`] carrying that (truncated,
151/// non-empty) text.
152///
153/// Only what *this function* retains is bounded: `progress` is still invoked for
154/// every event, so a caller that wants more of the stream can keep it — bounded
155/// however it chooses — from its own callback.
156///
157/// # What the byte ceiling counts here
158///
159/// [`OutputBudget::bytes`] is charged against **the retained text itself**: the
160/// decoded content of the retained lines plus the single `\n` this function
161/// inserts between each retained pair. The invariant is therefore as direct as
162/// it looks — the `stdout`/`stderr` handed to the error is never longer than
163/// `max_bytes` bytes.
164///
165/// That is deliberately a *third* unit, and the two processkit ones are neither
166/// of them (see [`OutputBudget::bytes`] for those): the fail-loud content
167/// ceiling counts raw pipe bytes with every terminator charged, processkit's own
168/// drop-oldest retention counts decoded line content with none charged, and this
169/// one charges exactly the separators it actually holds — one per retained pair.
170/// For `n` retained LF-terminated lines that is one byte *less* than the raw
171/// pipe bytes they arrived as, and `n - 1` bytes *more* than processkit's
172/// drop-mode accounting of the same lines. [`OutputBudget::with_max_lines`]
173/// caps the retained **line count** on top of that, dropping oldest-first too.
174///
175/// A single line longer than the byte cap is kept as its **own tail** (cut on a
176/// UTF-8 char boundary), not dropped whole as processkit's drop-mode buffer
177/// would drop it: under the default `\n` line framing, carriage-return progress
178/// output — precisely what `--progress` emits — arrives as one ever-growing
179/// line, and dropping it whole would retain nothing at all of the stream this
180/// ceiling exists to bound.
181pub async fn run_with_progress_within<R: ProcessRunner + ?Sized>(
182    runner: &R,
183    command: &Command,
184    progress: &mut ProgressCallback<'_>,
185    budget: OutputBudget,
186) -> Result<()> {
187    let program = command.program().to_string_lossy().into_owned();
188    let ok_codes = command
189        .configured_ok_codes()
190        .map_or_else(|| vec![0], <[i32]>::to_vec);
191    let absolute_timeout = command.configured_timeout();
192    let inactivity_timeout = command.configured_inactivity_timeout();
193    let started = Instant::now();
194
195    let mut run = runner.start(command).await?;
196    let mut events = run.events()?;
197    // Each stream gets the whole budget, independently — the same shape a
198    // captured verb's two streams carry (see `OutputBudget`).
199    let mut stdout = RetainedStream::new(budget);
200    let mut stderr = RetainedStream::new(budget);
201    let forward = async {
202        let mut callback_active = true;
203        while let Some(event) = events.next().await {
204            let target = match &event {
205                ProcessEvent::Stdout(_) => Some(&mut stdout),
206                ProcessEvent::Stderr(_) => Some(&mut stderr),
207                _ => None,
208            };
209            if let (Some(target), Some(line)) = (target, event.text()) {
210                target.push(line);
211            }
212            if callback_active
213                && std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| progress(event)))
214                    .is_err()
215            {
216                // A UI callback must not strand the child or its pipe pumps.
217                // Disable it, keep draining, and let the real process outcome win.
218                callback_active = false;
219            }
220        }
221    };
222    let (_, finished) = tokio::join!(forward, run.finish());
223    let finished = finished?;
224    let timeout = if finished.outcome.inactivity_timed_out() {
225        inactivity_timeout
226    } else {
227        absolute_timeout
228    };
229    // Report our own drop-oldest truncation alongside processkit's: `truncated`
230    // is a signal, not a verdict — `ensure_success` never reads it, so a bounded
231    // stream still surfaces as `Exit`, never as `OutputTooLarge`.
232    let truncated = finished.stderr_truncated || stdout.truncated() || stderr.truncated();
233    ProcessResult::from_parts(
234        program,
235        stdout.into_string(),
236        stderr.into_string(),
237        finished.outcome,
238        timeout,
239        started.elapsed(),
240        truncated,
241        0,
242        0,
243        ok_codes,
244    )
245    .ensure_success()
246    .map(drop)
247}
248
249/// What [`run_with_progress_within`] retains locally from one streamed channel
250/// (`stdout` or `stderr`).
251///
252/// Two shapes, so the default costs nothing: with no ceiling the lines land in
253/// one growing `String` exactly as they did before the budget existed, and only
254/// a budgeted run pays for the drop-oldest bookkeeping.
255enum RetainedStream {
256    /// No ceiling — every line, appended verbatim.
257    All(String),
258    /// A drop-oldest tail bounded by the budget.
259    Tail(RetainedTail),
260}
261
262/// The bounded half of [`RetainedStream`]: the retained lines, oldest dropped
263/// first once a ceiling is passed.
264struct RetainedTail {
265    /// The retained lines, oldest at the front.
266    lines: VecDeque<String>,
267    /// The rendered length of `lines` — the sum of their byte lengths plus the
268    /// one `\n` joining each retained pair. This is the quantity `max_bytes`
269    /// caps (an upper bound on the rendered string: a *leading* empty line's
270    /// separator is elided on render, so the estimate can only over-count).
271    bytes: usize,
272    max_bytes: Option<usize>,
273    max_lines: Option<usize>,
274    /// Whether anything was dropped (a whole line, or the head of an over-cap
275    /// one) — reported through `ProcessResult::truncated`.
276    truncated: bool,
277}
278
279impl RetainedStream {
280    fn new(budget: OutputBudget) -> Self {
281        if budget.is_unlimited() {
282            Self::All(String::new())
283        } else {
284            Self::Tail(RetainedTail {
285                lines: VecDeque::new(),
286                bytes: 0,
287                max_bytes: budget.max_bytes(),
288                max_lines: budget.max_lines(),
289                truncated: false,
290            })
291        }
292    }
293
294    /// Append one streamed line.
295    fn push(&mut self, line: &str) {
296        match self {
297            Self::All(text) => {
298                if !text.is_empty() {
299                    text.push('\n');
300                }
301                text.push_str(line);
302            }
303            Self::Tail(tail) => tail.push(line),
304        }
305    }
306
307    /// Whether the ceiling dropped anything.
308    fn truncated(&self) -> bool {
309        match self {
310            Self::All(_) => false,
311            Self::Tail(tail) => tail.truncated,
312        }
313    }
314
315    /// The retained text: the retained lines joined by `\n`.
316    fn into_string(self) -> String {
317        match self {
318            Self::All(text) => text,
319            Self::Tail(tail) => {
320                let mut text = String::with_capacity(tail.bytes);
321                for line in &tail.lines {
322                    // The same rule the unbounded path applies, so a budgeted
323                    // run renders its tail exactly as an unbudgeted one renders
324                    // the whole stream.
325                    if !text.is_empty() {
326                        text.push('\n');
327                    }
328                    text.push_str(line);
329                }
330                text
331            }
332        }
333    }
334}
335
336impl RetainedTail {
337    fn push(&mut self, line: &str) {
338        // An over-cap line is kept as its own tail rather than dropped whole:
339        // `--progress` output is one ever-growing `\r`-updated line under the
340        // default `\n` framing, and the tail is where the fatal message sits.
341        let line = match self.max_bytes {
342            Some(max) if line.len() > max => {
343                self.truncated = true;
344                let mut start = line.len() - max;
345                while !line.is_char_boundary(start) {
346                    start += 1;
347                }
348                &line[start..]
349            }
350            _ => line,
351        };
352        self.bytes += line.len() + usize::from(!self.lines.is_empty());
353        self.lines.push_back(line.to_string());
354
355        while self.max_lines.is_some_and(|max| self.lines.len() > max) {
356            self.drop_oldest();
357        }
358        // Never drop the last line to satisfy the byte cap: it was cut to fit
359        // above, so the buffer is already within budget once it stands alone.
360        while self.max_bytes.is_some_and(|max| self.bytes > max) && self.lines.len() > 1 {
361            self.drop_oldest();
362        }
363    }
364
365    fn drop_oldest(&mut self) {
366        if let Some(dropped) = self.lines.pop_front() {
367            self.bytes -= dropped.len() + usize::from(!self.lines.is_empty());
368            self.truncated = true;
369        }
370    }
371}
372
373pub mod credentials;
374pub use credentials::{
375    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
376    GitCredentialHelper, Secret, StaticCredential, git_credential_helper, https_host, provider_fn,
377};
378
379pub mod logging;
380pub use logging::{
381    CommandObserver, CommandRecord, CommandStatus, LoggingRunner, StderrObserver, redact_args,
382    redact_value,
383};
384
385/// JSON helpers shared by the forge wrappers, behind the `serde` feature — so the
386/// three forge parsers share one `null -> ""` and parse-error convention.
387#[cfg(feature = "serde")]
388#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
389pub mod json {
390    use processkit::{Error, Result};
391    use serde::Deserialize;
392    use serde::de::DeserializeOwned;
393
394    /// Deserialize a `String` a forge CLI may send as JSON `null` for an empty
395    /// optional value: `null` -> empty string, same as an absent key. `#[serde(default)]`
396    /// alone covers only an absent key; a present `null` would fail the whole-object
397    /// parse. Use as `#[serde(deserialize_with = "vcs_cli_support::json::null_to_empty")]`.
398    pub fn null_to_empty<'de, D>(deserializer: D) -> ::core::result::Result<String, D::Error>
399    where
400        D: serde::Deserializer<'de>,
401    {
402        Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
403    }
404
405    /// Deserialize a forge CLI's `--json` output into `T`, mapping a parse failure to
406    /// [`ErrorReason::Parse`](processkit::ErrorReason::Parse) tagged with `program`
407    /// (the CLI's binary name).
408    pub fn from_json<T: DeserializeOwned>(program: &str, json: &str) -> Result<T> {
409        serde_json::from_str(json).map_err(|e| Error::parse(program, e.to_string()))
410    }
411}
412
413/// A configurable ceiling on how much output a potentially large **content**
414/// operation may buffer before it is refused — a diff (`diff_text`/`diff`), a
415/// file's bytes at a revision (`show_file`/`file_show`), a forge PR/MR diff
416/// (`pr_diff`), and the diagnostic (error/progress) output of `clone`/`fetch`.
417///
418/// This is the single, shared knob the CLI wrappers (`vcs-git`, `vcs-jj`, the
419/// forge crates) and the facades (`vcs-core`, `vcs-forge`, the MCP server) all
420/// use, so the limit is configured and reasoned about one way across the
421/// workspace instead of one ad-hoc cap per client. Set a per-client default with
422/// each client's `default_output_budget(...)` builder (inherited by any facade
423/// built over that client); raise or lower it for a single call with the
424/// `*_within` method variants (`diff_text_within`, `show_file_within`,
425/// `pr_diff_within`, …). There is **no un-overridable global constant** — the
426/// default is [`unlimited`](OutputBudget::unlimited) (retain everything, the
427/// pre-budget behaviour), and every cap is a caller choice.
428///
429/// It projects onto two [`processkit`] [`OutputBufferPolicy`] shapes, so one
430/// budget drives both kinds of bounded output:
431///
432/// - [`content_policy`](OutputBudget::content_policy) — a **fail-loud** ceiling
433///   ([`OverflowMode::Error`]): once the cap is reached the run errors with
434///   [`ErrorReason::OutputTooLarge`], carrying the actual (`total_lines`/`total_bytes`)
435///   and allowed (`max_lines`/`max_bytes`) sizes — the reported `total_bytes` is
436///   in the same unit as the ceiling that fired, see [`bytes`](OutputBudget::bytes).
437///   The pipe is still drained (the child never blocks) and output past the
438///   ceiling is **counted but never retained**, so memory stays bounded and a
439///   truncated result is never handed back as if complete. This is what the
440///   content verbs use.
441/// - [`diagnostic_policy`](OutputBudget::diagnostic_policy) — a **drop-oldest**
442///   tail bound: caps the retained error/progress output of a discard verb
443///   (`clone`/`fetch`) *without* converting a real failure into
444///   `OutputTooLarge`, so transient-failure classification still reads the
445///   (tail-preserved) message. This is the same shape the `gh run watch` cap
446///   uses.
447///
448/// The byte ceiling ([`bytes`](OutputBudget::bytes)) is the load-bearing memory
449/// bound: the content verbs capture raw stdout (no line splitting), where the
450/// byte cap — not the line cap — is what [`processkit`] enforces. A line ceiling
451/// ([`with_max_lines`](OutputBudget::with_max_lines)) is an optional extra that
452/// also bounds line-pumped output (a diagnostic stream, a verb's stderr).
453///
454/// A **streamed** run ([`run_with_progress_within`]) is bounded by the same
455/// budget without going through an `OutputBufferPolicy` at all: its events carry
456/// every line whatever the command's buffer does, so the drop-oldest tail is
457/// applied to the copy that function retains, in its own documented unit. That
458/// is what makes a streaming `clone`/`fetch` memory-bounded by the very knob
459/// that bounds its captured twin.
460///
461/// Each captured stream carries the ceiling **independently**: a content verb's
462/// raw stdout and its line-pumped stderr each get their own `max_bytes` budget
463/// (so one call's worst-case retained memory is about twice the cap, not the
464/// cap), and either one reaching it is what raises `OutputTooLarge`. What
465/// counts as a "byte" is not the same on the two streams — see
466/// [`bytes`](OutputBudget::bytes).
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub struct OutputBudget {
469    max_bytes: Option<usize>,
470    max_lines: Option<usize>,
471}
472
473impl OutputBudget {
474    /// No ceiling — retain everything (the default, and the pre-budget
475    /// behaviour). [`content_policy`](Self::content_policy) /
476    /// [`diagnostic_policy`](Self::diagnostic_policy) return `None`, leaving the
477    /// command's own (unbounded) buffer untouched.
478    pub const fn unlimited() -> Self {
479        Self {
480            max_bytes: None,
481            max_lines: None,
482        }
483    }
484
485    /// A byte ceiling of `max_bytes`, in the unit
486    /// [`OutputBufferPolicy::max_bytes`] caps. The primary, memory-bounding
487    /// knob: it applies to the raw-stdout content path where a line cap would
488    /// not. Add a line ceiling with [`with_max_lines`](Self::with_max_lines).
489    ///
490    /// # What a byte counts as
491    ///
492    /// The unit differs by **stream**, because the two are captured differently.
493    /// Both ceilings fire strictly *past* the cap: output sitting exactly on
494    /// `max_bytes` is still accepted.
495    ///
496    /// - **Raw stdout** — what every content verb reads
497    ///   ([`ManagedClient::run_untrimmed`]: `diff_text`, `show_file`, `pr_diff`,
498    ///   `template_query`, …): the cap counts the bytes read from the pipe
499    ///   **verbatim**, with no line framing to strip and nothing decoded. This
500    ///   is what [`processkit`] has always counted on this path, so its 3.0
501    ///   switch to raw-pipe-byte accounting did **not** move this ceiling — a
502    ///   64 KiB content cap refuses exactly the same reads it did before.
503    /// - **Line-pumped stderr** (and any line-captured stream): since processkit
504    ///   3.0 the fail-loud ceiling counts the raw bytes read from the pipe —
505    ///   **line terminators and invalid-UTF-8 bytes included** — where it
506    ///   previously counted only the decoded line *content*. A plain LF stream
507    ///   therefore counts one byte per line more than it used to (the LF is now
508    ///   charged, not just a CRLF's extra `\r`), so a cap set against the old
509    ///   unit trips marginally earlier on this stream.
510    ///
511    /// [`ErrorReason::OutputTooLarge`]'s reported `total_bytes` is in the same
512    /// unit as whichever of those ceilings fired.
513    ///
514    /// The drop-oldest [`diagnostic_policy`](Self::diagnostic_policy) is a third
515    /// case, unaffected by that change: what it *retains* is bounded by the
516    /// decoded line-content bytes it holds, not by the raw bytes it saw. A
517    /// **streamed** run's local tail ([`run_with_progress_within`]) is a fourth:
518    /// it charges the decoded line content *plus* the one `\n` joining each
519    /// retained pair — the bytes of the string it actually hands back.
520    pub const fn bytes(max_bytes: usize) -> Self {
521        Self {
522            max_bytes: Some(max_bytes),
523            max_lines: None,
524        }
525    }
526
527    /// Add a line ceiling of `max_lines` (an extra bound on line-pumped output —
528    /// diagnostics, a verb's stderr). Composes with any [`bytes`](Self::bytes)
529    /// cap; whichever ceiling is reached first fires.
530    #[must_use]
531    pub const fn with_max_lines(mut self, max_lines: usize) -> Self {
532        self.max_lines = Some(max_lines);
533        self
534    }
535
536    /// Whether no ceiling is set (retain everything).
537    pub const fn is_unlimited(&self) -> bool {
538        self.max_bytes.is_none() && self.max_lines.is_none()
539    }
540
541    /// The configured byte ceiling, if any.
542    pub const fn max_bytes(&self) -> Option<usize> {
543        self.max_bytes
544    }
545
546    /// The configured line ceiling, if any.
547    pub const fn max_lines(&self) -> Option<usize> {
548        self.max_lines
549    }
550
551    /// The **fail-loud** [`OutputBufferPolicy`] for a content verb — errors with
552    /// [`ErrorReason::OutputTooLarge`] once the ceiling is reached, never retaining or
553    /// returning a truncated tail. `None` when [`unlimited`](Self::unlimited)
554    /// (leave the command's default buffer).
555    pub fn content_policy(&self) -> Option<OutputBufferPolicy> {
556        if self.is_unlimited() {
557            return None;
558        }
559        // A byte cap (Some) keeps the fail-loud ceiling honest even with no line
560        // cap: `OverflowMode::Error` is "zero-tolerance" only when *neither* cap
561        // is set, so setting `max_bytes` gives it a real ceiling to fire on.
562        let mut policy = match self.max_lines {
563            Some(lines) => OutputBufferPolicy::fail_loud(lines),
564            None => OutputBufferPolicy::unbounded().with_overflow(OverflowMode::Error),
565        };
566        if let Some(bytes) = self.max_bytes {
567            policy = policy.with_max_bytes(bytes);
568        }
569        Some(policy)
570    }
571
572    /// The **drop-oldest** [`OutputBufferPolicy`] for a discard verb's diagnostic
573    /// output (`clone`/`fetch`): keeps the last `max_bytes`/`max_lines` (the tail,
574    /// where a CLI's fatal line sits) and flags truncation, but does **not** raise
575    /// [`ErrorReason::OutputTooLarge`] — so a genuine failure still surfaces as
576    /// `ErrorReason::Exit` and stays classifiable ([`is_transient_fetch_error`],
577    /// [`is_lock_contention`]). `None` when [`unlimited`](Self::unlimited).
578    ///
579    /// The retained tail is measured in **decoded line-content** bytes, which is
580    /// [`processkit`]'s drop-mode accounting and is deliberately *not* the
581    /// raw-pipe-byte unit its 3.0 release re-based the fail-loud
582    /// [`OverflowMode::Error`] ceiling onto — so how much tail this projection
583    /// keeps is unchanged by that release. Note also that a single line longer
584    /// than `max_bytes` is never assembled by the pump and so is dropped whole
585    /// (counted only as truncation): keep the byte cap comfortably above a
586    /// plausible single fatal line.
587    pub fn diagnostic_policy(&self) -> Option<OutputBufferPolicy> {
588        if self.is_unlimited() {
589            return None;
590        }
591        let mut policy = match self.max_lines {
592            Some(lines) => OutputBufferPolicy::bounded(lines),
593            None => OutputBufferPolicy::unbounded(),
594        };
595        if let Some(bytes) = self.max_bytes {
596            policy = policy.with_max_bytes(bytes);
597        }
598        Some(policy)
599    }
600}
601
602impl Default for OutputBudget {
603    /// [`unlimited`](OutputBudget::unlimited) — the budget is opt-in.
604    fn default() -> Self {
605        Self::unlimited()
606    }
607}
608
609/// Generate the cwd-bound forwarders for a CLI wrapper's `…At` view.
610///
611/// Each CLI wrapper (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`, `vcs-gitea`)
612/// exposes a cwd-bound view — `GitAt`, `JjAt`, `GitHubAt`, `GitLabAt`, `GiteaAt` —
613/// that holds a reference to the client plus a pre-bound `dir`, and re-exposes the
614/// client's methods with `dir` already supplied. The forwarder bodies are
615/// byte-identical across the five backends but for a handful of names, so they live
616/// here once instead of as a copied `macro_rules!` per crate:
617///
618/// - `$view` — the bound view type (e.g. `GitAt`). It must be generic over
619///   `<'a, R: ProcessRunner>` and have a field named `$field` holding the client
620///   plus a `dir: &'a Path` field.
621/// - `$field` — the inner field naming the client (e.g. `git`, `gh`, `glab`,
622///   `tea`).
623/// - `$client` — a **string literal** naming the client type, used in the
624///   generated doc strings and rendered as an intra-doc link (e.g. `"Git"` →
625///   ``[`Git`]``).
626/// - `bare { … }` — methods forwarded verbatim to `self.$field`. Reserve this for
627///   the genuinely dir-*independent* calls (`version`, `capabilities`, a
628///   `clone`/`git_clone` that names its own destination): the view drops `dir`
629///   entirely, so a `bare` method never touches it.
630/// - `dir  { … }` — methods that take `self.dir` as their first argument.
631/// - `raw  { fn view(args…) -> Ret => target; … }` — the **raw escape hatches**
632///   (`run`/`run_raw`/`run_args`/`run_raw_args`). These used to sit in `bare`, so
633///   `git.at(dir).run(…)` silently ran in the *process* cwd, not the bound `dir` —
634///   a bound handle whose raw call could hit a different repository (M15/T-035).
635///   They are now **bound**: the view method `view` forwards to the client's
636///   dir-taking `target` (`self.$field.target(self.dir, args…)`), so a raw call
637///   *through the view* runs in `dir` like every other `…At` method. The
638///   **process-cwd** escape hatch is still there — call `run`/`run_raw`/… on the
639///   client itself (`git.run(…)`), not through `.at(dir)`.
640///
641/// The argument and return types in the method lists resolve in the **calling**
642/// crate, so they are written exactly as that wrapper's own methods are. The
643/// `ProcessRunner` bound is fully qualified (`::processkit::ProcessRunner`) so the
644/// expansion compiles regardless of which items the caller has imported.
645///
646/// ```ignore
647/// vcs_cli_support::at_forwarders! {
648///     GitAt, git, "Git",
649///     bare { fn version() -> Result<String>; }
650///     dir  { fn status() -> Result<Vec<StatusEntry>>; }
651///     raw  { fn run(args: &[String]) -> Result<String> => run_in; }
652/// }
653/// ```
654#[macro_export]
655macro_rules! at_forwarders {
656    (
657        $view:ident, $field:ident, $client:literal,
658        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
659        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
660        $( raw  { $( fn $rn:ident( $($ra:ident: $rt:ty),* $(,)? ) -> $rr:ty => $rtgt:ident; )* } )?
661    ) => {
662        impl<'a, R: ::processkit::ProcessRunner> $view<'a, R> {
663            $(
664                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($bn), "`.")]
665                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
666                    self.$field.$bn($($ba),*).await
667                }
668            )*
669            $(
670                #[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
671                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
672                    self.$field.$dn(self.dir, $($da),*).await
673                }
674            )*
675            $($(
676                #[doc = concat!(
677                    "Bound form of [`", $client, "`]'s `", stringify!($rn),
678                    "` raw escape hatch — runs the given argv **in the bound `dir`** \
679                     (forwards to the client's `", stringify!($rtgt), "`). For the \
680                     process-cwd escape hatch, call `", stringify!($rn),
681                    "` on [`", $client, "`] directly."
682                )]
683                pub async fn $rn(&self, $($ra: $rt),*) -> $rr {
684                    self.$field.$rtgt(self.dir, $($ra),*).await
685                }
686            )*)?
687        }
688    };
689}
690
691/// Emit the six **raw escape-hatch** helpers every CLI wrapper hand-writes on its
692/// client — `run_args` / `run_raw_args` / `run_in` / `run_raw_in` / `run_args_in`
693/// / `run_raw_args_in`.
694///
695/// These are the `&[&str]` and dir-bound twins of the object-safe `run`/`run_raw`
696/// trait methods: `run_args`/`run_raw_args` take `&[&str]` (no `Vec<String>`
697/// allocation), the `*_in` variants bind a `dir`, and the `run_raw_*` variants
698/// never error on a non-zero exit. Their bodies are byte-identical across the five
699/// backends — thin forwards into the `core: ManagedClient` field that
700/// [`managed_client!`](crate::managed_client) generates — so, like
701/// [`at_forwarders!`](crate::at_forwarders), they live here once instead of as a
702/// copied block per crate.
703///
704/// The generated methods land in a fresh `impl<R: ProcessRunner> $name<R>` block
705/// (so invoke this at module scope, next to the crate's other `impl` blocks), and
706/// forward to `self.core.run` / `self.core.output_string` (`+ command_in` for the
707/// `*_in` variants) — the same field `managed_client!` emits. All paths are fully
708/// qualified, so the expansion compiles regardless of what the caller imported.
709///
710/// The doc strings are generated to match the hand-written ones, cross-links
711/// included: `run_raw_args` → `run_args`, each `*_in` → its non-`_in` twin (and
712/// back), the object-safe `run`/`run_raw` on `$name`Api, and the bound
713/// `$name`At forwarders. The three type names — the client `$name`, its trait
714/// `$name`Api, and its bound view `$name`At — are all derived from `$name`.
715///
716/// - `$name` — the wrapper client type (e.g. `Git`). Names the `impl` target and,
717///   via `concat!`, the `…Api` / `…At` link targets (`GitApi`, `GitAt`).
718/// - `$binary` — a **string literal** naming the CLI (e.g. `"git"`, `"gh"`), used
719///   both as the program in the prose (`` `git <args>` ``) and as the example's
720///   receiver (`` `git.run_args(…)` ``).
721/// - `$args_example` — a **string literal** with the argv shown in `run_args`'
722///   example, i.e. the contents of the `&[…]` (e.g. `"\"status\", \"-s\""`
723///   renders `` `git.run_args(&["status", "-s"])` ``).
724/// - `$in_infers` — a **string literal** spliced after "as its working directory"
725///   in `run_in`'s doc, for backends that infer their target from `dir`'s remote
726///   (`", so `gh` infers the repo from `dir`'s remote"`); `""` for the rest.
727/// - `$in_flag_note` — a **string literal** for `run_in`'s trailing "Argv is
728///   forwarded verbatim (…)" parenthetical — the backend-specific note on what is
729///   (not) injected (e.g. ``"only the working directory is bound, no `-C`/extra
730///   flag is injected"``).
731///
732/// ```ignore
733/// vcs_cli_support::raw_run_forwarders! {
734///     Git, "git", "\"status\", \"-s\"", "",
735///     "the same unguarded escape hatch — only the working directory is bound, \
736///      no `-C`/extra flag is injected"
737/// }
738/// ```
739#[macro_export]
740macro_rules! raw_run_forwarders {
741    (
742        $name:ident, $binary:literal, $args_example:literal, $in_infers:literal, $in_flag_note:literal $(,)?
743    ) => {
744        impl<R: ::processkit::ProcessRunner> $name<R> {
745            #[doc = concat!(
746                "Run `", $binary, " <args>` over string slices — `", $binary, ".run_args(&[",
747                $args_example, "])` without allocating a `Vec<String>`. Inherent (not on the \
748                 object-safe trait), so it can take `&[&str]`; forwards to the same path as [`",
749                stringify!($name), "Api::run`]."
750            )]
751            pub async fn run_args(&self, args: &[&str]) -> ::processkit::Result<String> {
752                self.core.run(args).await
753            }
754
755            #[doc = concat!(
756                "Like [`run_args`](", stringify!($name), "::run_args) but never errors on a \
757                 non-zero exit (mirrors [`", stringify!($name), "Api::run_raw`])."
758            )]
759            pub async fn run_raw_args(
760                &self,
761                args: &[&str],
762            ) -> ::processkit::Result<::processkit::ProcessResult<String>> {
763                self.core.output_string(args).await
764            }
765
766            #[doc = concat!(
767                "Run `", $binary, " <args>` **in `dir`** (the process is spawned with `dir` as \
768                 its working directory", $in_infers, "), returning trimmed stdout — the dir-bound \
769                 twin of the process-cwd [`run`](", stringify!($name), "Api::run). This is what [`",
770                stringify!($name), "At::run`] forwards to; call [`run`](", stringify!($name),
771                "Api::run) on the client for the process-cwd escape hatch. Argv is forwarded \
772                 verbatim (", $in_flag_note, ")."
773            )]
774            pub async fn run_in(
775                &self,
776                dir: &::std::path::Path,
777                args: &[String],
778            ) -> ::processkit::Result<String> {
779                self.core.run(self.core.command_in(dir, args)).await
780            }
781
782            #[doc = concat!(
783                "Like [`run_in`](", stringify!($name), "::run_in) but never errors on a non-zero \
784                 exit — the dir-bound twin of [`run_raw`](", stringify!($name), "Api::run_raw). \
785                 What [`", stringify!($name), "At::run_raw`] forwards to."
786            )]
787            pub async fn run_raw_in(
788                &self,
789                dir: &::std::path::Path,
790                args: &[String],
791            ) -> ::processkit::Result<::processkit::ProcessResult<String>> {
792                self.core
793                    .output_string(self.core.command_in(dir, args))
794                    .await
795            }
796
797            #[doc = concat!(
798                "Like [`run_args`](", stringify!($name), "::run_args) but **bound to `dir`** — the \
799                 `&[&str]` twin of [`run_in`](", stringify!($name), "::run_in). What [`",
800                stringify!($name), "At::run_args`] forwards to."
801            )]
802            pub async fn run_args_in(
803                &self,
804                dir: &::std::path::Path,
805                args: &[&str],
806            ) -> ::processkit::Result<String> {
807                self.core.run(self.core.command_in(dir, args)).await
808            }
809
810            #[doc = concat!(
811                "Like [`run_raw_args`](", stringify!($name), "::run_raw_args) but **bound to \
812                 `dir`** — the `&[&str]` twin of [`run_raw_in`](", stringify!($name),
813                "::run_raw_in). What [`", stringify!($name), "At::run_raw_args`] forwards to."
814            )]
815            pub async fn run_raw_args_in(
816                &self,
817                dir: &::std::path::Path,
818                args: &[&str],
819            ) -> ::processkit::Result<::processkit::ProcessResult<String>> {
820                self.core
821                    .output_string(self.core.command_in(dir, args))
822                    .await
823            }
824        }
825    };
826}
827
828/// Emit the common client scaffold every CLI wrapper hand-writes around a
829/// [`ManagedClient`].
830///
831/// `vcs-git`, `vcs-jj`, `vcs-github`, and `vcs-gitlab` each wrap a
832/// [`ManagedClient`] in a thin newtype that re-exposes the same handful of
833/// constructors and default-applying builders — `new` / `Default` /
834/// `with_runner` / `default_timeout` / `default_env` / `default_env_remove` /
835/// `default_cancel_on` — with byte-identical bodies and doc strings. This macro
836/// generates that shared part so it can't drift between backends; each wrapper
837/// keeps its *capability* builders (`with_retry`, `with_credentials`, every verb,
838/// the `…At` view, …) hand-written in a separate `impl` block.
839///
840/// The generated newtype is `struct $name<R: ProcessRunner = JobRunner>` with a
841/// single private `core: ManagedClient<R>` field — accessible to the rest of the
842/// wrapper crate (same module). All paths are fully qualified, so the expansion
843/// compiles regardless of what the caller has imported.
844///
845/// - `$name` — the wrapper type (e.g. `Git`). The struct-level doc comment (and
846///   any other attributes) written before `struct` are attached to it verbatim.
847/// - `$binary` — the program the client drives (an expression, typically the
848///   crate's `BINARY` const).
849/// - `token_env = ($svc, $var)` — *optional*. When given, `new`/`with_runner`
850///   chain [`ManagedClient::with_token_env`] so a resolved credential is injected
851///   into the `$var` environment variable for service `$svc` (the forge case:
852///   `GH_TOKEN`, `GITLAB_TOKEN`). Omit it for the ambient-auth backends (git, jj).
853/// - `scrub_env = [ $var, … ]` — *optional*. When given, `new`/`with_runner`
854///   chain [`ManagedClient::default_env_remove`] for each var, so **every** client
855///   the macro generates drops those inherited environment variables by default
856///   (`vcs-git` uses it to scrub the repo-redirector vars — `GIT_DIR`, … — so a
857///   value leaking from the parent process can't retarget commands). Must come
858///   *after* `token_env` when both are present.
859///
860/// ```ignore
861/// vcs_cli_support::managed_client! {
862///     /// The real GitHub client.
863///     pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
864/// }
865/// vcs_cli_support::managed_client! {
866///     /// The real Git client — scrubs the repo-redirector env vars by default.
867///     pub struct Git => BINARY, scrub_env = ["GIT_DIR", "GIT_WORK_TREE"]
868/// }
869/// ```
870#[macro_export]
871macro_rules! managed_client {
872    (
873        $(#[$meta:meta])*
874        $vis:vis struct $name:ident => $binary:expr
875        $(, token_env = ($svc:expr, $var:expr) )?
876        $(, scrub_env = [ $($scrub:expr),* $(,)? ] )?
877        $(,)?
878    ) => {
879        $(#[$meta])*
880        $vis struct $name<R: ::processkit::ProcessRunner = ::processkit::JobRunner> {
881            core: $crate::ManagedClient<R>,
882        }
883
884        // Manual Debug: no `R: Debug` bound (matches `ManagedClient`'s own impl),
885        // delegating straight to `core` — `ManagedClient::fmt` already redacts any
886        // configured credential provider / token-env binding, so nothing secret
887        // reaches `{:?}` here either.
888        impl<R: ::processkit::ProcessRunner> ::core::fmt::Debug for $name<R> {
889            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
890                f.debug_struct(stringify!($name))
891                    .field("core", &self.core)
892                    .finish()
893            }
894        }
895
896        impl $name<::processkit::JobRunner> {
897            /// Create a client driving the real job-backed runner.
898            pub fn new() -> Self {
899                Self { core: $crate::ManagedClient::new($binary)
900                    $(.with_token_env($svc, $var))?
901                    $($(.default_env_remove($scrub))*)?
902                }
903            }
904        }
905
906        impl ::core::default::Default for $name<::processkit::JobRunner> {
907            fn default() -> Self {
908                Self::new()
909            }
910        }
911
912        impl<R: ::processkit::ProcessRunner> $name<R> {
913            /// Create a client driving `runner` — inject a fake in tests.
914            pub fn with_runner(runner: R) -> Self {
915                Self {
916                    core: $crate::ManagedClient::with_runner($binary, runner)
917                        $(.with_token_env($svc, $var))?
918                        $($(.default_env_remove($scrub))*)?,
919                }
920            }
921
922            /// Apply a default timeout to every command this client builds.
923            pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
924                self.core = self.core.default_timeout(timeout);
925                self
926            }
927
928            /// Set an environment variable on every command this client builds.
929            pub fn default_env(
930                mut self,
931                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
932                value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
933            ) -> Self {
934                self.core = self.core.default_env(key, value);
935                self
936            }
937
938            /// Remove an inherited environment variable on every command this client builds.
939            pub fn default_env_remove(
940                mut self,
941                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
942            ) -> Self {
943                self.core = self.core.default_env_remove(key);
944                self
945            }
946
947            /// Cancel every command this client builds when `token` fires.
948            ///
949            /// Network `fetch`/`push`/`clone` commands additionally use the shared
950            /// [`FETCH_TIMEOUT_GRACE`] cancellation window when this token fires,
951            /// including the Windows-only soft-trigger opt-in where processkit can
952            /// deliver it. The process outcome remains
953            /// [`ErrorReason::Cancelled`]; the grace changes only the teardown path.
954            /// Other commands keep their existing immediate-cancellation policy.
955            pub fn default_cancel_on(mut self, token: ::processkit::CancellationToken) -> Self {
956                self.core = self.core.default_cancel_on(token);
957                self
958            }
959
960            /// Apply a default [`OutputBudget`](vcs_cli_support::OutputBudget) to the
961            /// potentially large **content** operations this client builds — the
962            /// diff/show/pr-diff verbs and the `clone`/`fetch` diagnostic capture.
963            /// Inherited by any facade built over this client. The default is
964            /// [`OutputBudget::unlimited`](vcs_cli_support::OutputBudget::unlimited)
965            /// (retain everything); a single call can still override it via the
966            /// `*_within` method variants.
967            pub fn default_output_budget(mut self, budget: $crate::OutputBudget) -> Self {
968                self.core = self.core.default_output_budget(budget);
969                self
970            }
971        }
972    };
973}
974
975/// Injection guard for bare positional argv slots: a caller-supplied value with a
976/// leading `-` would be parsed by the CLI as a *flag* (verified: `git checkout
977/// -evil` → "unknown switch"; jj likewise), and an empty (or whitespace-only)
978/// value silently changes most commands' meaning. Refuse both before anything
979/// spawns, surfacing an [`ErrorReason::Spawn`] naming `program`. An interior NUL is
980/// refused too (it can't be passed in argv and otherwise surfaces as an opaque
981/// OS spawn error). Flag-VALUE positions (`-m <msg>`, `--branch <b>`) don't need
982/// this — the CLI consumes the next token verbatim there.
983///
984/// The leading-`-` test is applied to the **trimmed** value, so a value like
985/// `" --upload-pack=…"` (leading whitespace) is still refused — the empty-check
986/// and the flag-check now agree on what "the value" is.
987pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
988    let trimmed = value.trim();
989    if trimmed.is_empty() || trimmed.starts_with('-') || value.contains('\0') {
990        return Err(Error::spawn(
991            program,
992            std::io::Error::new(
993                std::io::ErrorKind::InvalidInput,
994                format!(
995                    "{what} {value:?} would be parsed as a flag (or is empty / contains NUL) — \
996                     refusing to pass it as a positional argument"
997                ),
998            ),
999        ));
1000    }
1001    Ok(())
1002}
1003
1004/// R7 clone-cleanup: whether `dest` is safe to remove if a `clone`/`git_clone`
1005/// about to run into it fails — either **provably absent** (`read_dir` fails
1006/// with `NotFound`), or an already-empty directory. Compute this **before**
1007/// running the clone, and pass the result to [`cleanup_failed_clone_dest`] on
1008/// the error path — `git`/`jj` both refuse to clone into a **non-empty**
1009/// existing directory, so if `dest` already had contents going in, a failure
1010/// means that refusal, and the caller's pre-existing data must never be
1011/// deleted. Re-checking emptiness *after* the clone ran would be wrong: a
1012/// failed clone can leave `dest` partially populated, so a post-hoc check
1013/// could wrongly call a partial clone's leftovers "empty" (or simply disagree
1014/// with the pre-clone state).
1015///
1016/// Any `read_dir` failure *other than* `NotFound` (permission denied, a
1017/// transient I/O error, `dest` being a plain file — `NotADirectory`) is
1018/// treated as **not** cleanable: it doesn't prove `dest` is absent, and
1019/// `dest` may well be a pre-existing non-empty directory the caller can't
1020/// read into right now. Deleting on an unproven guess would risk
1021/// `remove_dir_all`-ing a directory full of the caller's data; cleanup simply
1022/// becoming a no-op is the safe degradation (the clone itself already failed
1023/// with a clear git/jj error).
1024///
1025/// Shared by `vcs_git::clone_repo` and `vcs_jj::git_clone`, which previously
1026/// carried a byte-identical copy of this check plus its own best-effort
1027/// `remove_dir_all` on the error path.
1028pub fn clone_dest_cleanable(dest: &Path) -> bool {
1029    match std::fs::read_dir(dest) {
1030        Err(err) => err.kind() == std::io::ErrorKind::NotFound, // proven absent
1031        Ok(mut entries) => entries.next().is_none(),            // an empty directory
1032    }
1033}
1034
1035/// Best-effort cleanup of a failed clone's partial `dest` (R7) — call only on
1036/// the clone's error path, passing `cleanable` as computed by
1037/// [`clone_dest_cleanable`] **before** the clone ran. A no-op when `cleanable`
1038/// is `false` — including whenever `dest`'s state couldn't be proven safe
1039/// (absent, or an already-empty directory): this never touches a non-empty
1040/// pre-existing `dest`, nor one `clone_dest_cleanable` simply failed to read.
1041/// Swallows a `remove_dir_all` failure (e.g. another process holding a file
1042/// open) — this is opportunistic tidy-up, not something a clone failure
1043/// should itself fail on.
1044pub fn cleanup_failed_clone_dest(dest: &Path, cleanable: bool) {
1045    if cleanable {
1046        let _ = std::fs::remove_dir_all(dest);
1047    }
1048}
1049
1050/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
1051pub const FETCH_ATTEMPTS: u32 = 3;
1052/// Fixed backoff between fetch retries.
1053pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
1054/// Grace period for a network operation that times out **or is cancelled**: on Unix
1055/// processkit sends its graceful terminate signal and waits this long before
1056/// hard-killing. On Windows
1057/// the grace window is useful only when a child has a soft trigger: the shared
1058/// [`apply_fetch_completion_policy`] opts console children into `CTRL_BREAK`, and
1059/// processkit also tries `WM_CLOSE` for windowed children. A child without a
1060/// shared console, including one started with `create_no_window` or
1061/// `DETACHED_PROCESS`, skips the soft trigger and falls back to the atomic job
1062/// kill. The cancellation outcome is still [`ErrorReason::Cancelled`]. The policy
1063/// only takes effect when the relevant timeout or `default_cancel_on` token is set;
1064/// a network operation with neither is unaffected.
1065pub const FETCH_TIMEOUT_GRACE: Duration = Duration::from_secs(2);
1066
1067/// Apply the shared completion policy to a network command.
1068///
1069/// The policy applies [`FETCH_TIMEOUT_GRACE`] to both timeout and cancellation. On
1070/// Windows it additionally opts the direct console child into `CTRL_BREAK`, so a
1071/// child that handles the event can flush buffers, close connections, and release
1072/// locks during the grace window. Delivery requires a console shared with the
1073/// child; GUI/service callers without one and children created with
1074/// [`Command::create_no_window`](processkit::Command::create_no_window) or
1075/// `DETACHED_PROCESS` receive no console event. Windowed children may still
1076/// receive processkit's best-effort `WM_CLOSE`, and any survivor is hard-killed
1077/// after the grace window. On Unix the Windows builder is a no-op, so the
1078/// existing signal → grace → hard-kill semantics are unchanged. The helper does not
1079/// alter the structured cancellation outcome.
1080pub fn apply_fetch_completion_policy(command: Command) -> Command {
1081    let command = command
1082        .timeout_grace(FETCH_TIMEOUT_GRACE)
1083        .cancel_grace(FETCH_TIMEOUT_GRACE);
1084    #[cfg(windows)]
1085    {
1086        command.windows_graceful_ctrl_break()
1087    }
1088    #[cfg(not(windows))]
1089    {
1090        command
1091    }
1092}
1093
1094/// Lower-case substrings marking a merge that stopped on conflicts.
1095const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
1096/// Lower-case substrings marking a commit that found nothing to record.
1097const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
1098/// Lower-case substrings marking a transient (retryable) network/fetch failure.
1099/// The timeout markers are kept *specific* (`connection timed out` /
1100/// `operation timed out`) rather than a bare `timed out`, which would also match
1101/// unrelated, non-network "timed out" messages (a lock wait, a hook) and trigger a
1102/// spurious fetch retry.
1103const TRANSIENT_FETCH_MARKERS: &[&str] = &[
1104    "could not resolve host",
1105    "couldn't resolve host",
1106    "temporary failure in name resolution",
1107    "connection timed out",
1108    "connection refused",
1109    "operation timed out",
1110    "network is unreachable",
1111    "failed to connect",
1112    "could not read from remote repository",
1113    "the remote end hung up",
1114    "early eof",
1115    "rpc failed",
1116];
1117
1118/// Whether `err` is an [`ErrorReason::Exit`] whose captured output contains any
1119/// marker.
1120///
1121/// Matches the reason rather than reading [`Error::stdout`]/[`Error::stderr`]: those
1122/// accessors also return the partial output of a `Timeout` or `Signalled` run, which
1123/// must not be scanned for a "conflict"/"nothing to commit"/lock marker — only a
1124/// completed non-zero exit carries a verdict the markers describe.
1125fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
1126    let ErrorReason::Exit { stdout, stderr, .. } = err.reason() else {
1127        return false;
1128    };
1129    let out = stdout.to_ascii_lowercase();
1130    let errt = stderr.to_ascii_lowercase();
1131    markers.iter().any(|m| out.contains(m) || errt.contains(m))
1132}
1133
1134/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict. (jj
1135/// surfaces conflicts as state rather than as errors, so this only fires on git
1136/// output — see `vcs_core::Error::is_merge_conflict`.)
1137pub fn is_merge_conflict(err: &Error) -> bool {
1138    exit_output_matches(err, CONFLICT_MARKERS)
1139}
1140
1141/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
1142/// tree), as opposed to a real error.
1143pub fn is_nothing_to_commit(err: &Error) -> bool {
1144    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
1145}
1146
1147/// Whether a failed `fetch`/`fetch_branch`/`remote_branch_exists` looks
1148/// transient (DNS, a dropped connection, a fast network blip) and is worth
1149/// retrying.
1150///
1151/// A processkit-level **timeout** is deliberately **not** classified transient
1152/// (R6). A `.timeout()`-bounded run that expired has already consumed the caller's
1153/// full deadline — retrying it would multiply the wall-clock by [`FETCH_ATTEMPTS`]
1154/// (e.g. a black-holed remote under a 120 s deadline would block ≈ 6 min, three
1155/// times the advertised ceiling). The deadline *is* the patience budget; a caller
1156/// who wants longer should raise the timeout, not have it silently tripled. Fast
1157/// transient failures (the io-level and marker cases below) still retry, because
1158/// they fail quickly and a retry is cheap.
1159pub fn is_transient_fetch_error(err: &Error) -> bool {
1160    // An io-level transient from the spawn itself (interrupted / would-block / busy),
1161    // which processkit classifies via `Error::is_transient()` (it covers `Spawn`/`Io`,
1162    // not `Exit`/`Timeout`, so it composes cleanly with the marker scan below).
1163    err.is_transient() || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
1164}
1165
1166/// Lower-case substrings marking a **whole-repository / working-copy lock**
1167/// contention failure — another process held the *one* repo-wide lock, so the
1168/// command **never started** (clean, pre-execution) and touched nothing.
1169///
1170/// These are deliberately limited to the locks that guard the *entire* operation
1171/// up front, so retrying is safe even on a **mutating** command: the repo was not
1172/// modified at all. We intentionally do **not** include per-ref lock messages
1173/// (`cannot lock ref`, `<ref>.lock`/`packed-refs.lock: File exists`): a multi-ref
1174/// `push`/`fetch` updates refs sequentially, so a ref-lock failure can arrive
1175/// *after* earlier refs already moved — replaying that is not idempotent. Network
1176/// markers
1177/// ([`TRANSIENT_FETCH_MARKERS`]) and conflict/exit failures are likewise absent.
1178const LOCK_CONTENTION_MARKERS: &[&str] = &[
1179    // git: the whole-repo index lock (pre-write). Match the **locale-stable path
1180    // fragment** `index.lock`, not the translated `': File exists'` suffix — git
1181    // localizes its messages, so a `LANG=de_DE` runner would never match the full
1182    // English phrase. `index.lock` names the index lock specifically; per-ref locks
1183    // (`<ref>.lock`, `packed-refs.lock`) are ruled out by the `refs/` guard in
1184    // `is_lock_contention`. (This matches any `index.lock` *create* failure — a
1185    // held lock, or e.g. `Permission denied` — all pre-write, so retrying is safe.)
1186    "index.lock",
1187    // jj: the working-copy lock and the operation-heads lock (both pre-mutation).
1188    // These are jj's exact wordings (lower-cased for the classifier). NOTE: modern
1189    // jj generally **blocks** on these locks until they're free rather than failing,
1190    // so contention usually surfaces as a wait, not a classifiable error — these
1191    // markers catch only the residual cases where jj does surface a lock error.
1192    "failed to lock working copy",
1193    "failed to lock operation heads store",
1194];
1195
1196/// Whether `err` is a **whole-repository lock-contention** failure — another
1197/// process held git's `index.lock` or jj's working-copy / op-heads lock, so the
1198/// command couldn't even start. Such a failure is *pre-execution* and therefore
1199/// safe to retry even on a **mutating** operation (the repo was never modified).
1200/// Per-ref lock failures (`cannot lock ref`, `<ref>.lock`) are deliberately **not**
1201/// classified here — they can occur mid-way through a multi-ref `push`/`fetch`,
1202/// where a retry would not be idempotent. Conflict, "nothing to commit", a real
1203/// non-zero exit, a timeout, a signal, or a missing binary are also **not** lock
1204/// contention and must not be retried this way.
1205pub fn is_lock_contention(err: &Error) -> bool {
1206    // Rule out a **per-ref** lock first: it is *not* safely retryable (a multi-ref
1207    // push/fetch can fail one ref's lock after earlier refs already moved). git's
1208    // per-ref lock lives under `refs/` (`…/refs/heads/<name>.lock`) and its message
1209    // names `refs/…`, whereas the whole-repo `index.lock` (`<gitdir>/index.lock`)
1210    // never does — so a `refs/` mention excludes it, locale-independently. This also
1211    // stops a branch literally named `index`/`reindex` (whose `…/reindex.lock`
1212    // contains the substring `index.lock`) from matching the bare `index.lock`
1213    // marker. (A repo whose *path* contains `refs/` then misses the index-lock retry
1214    // — a benign false-negative, safer than a wrong retry.)
1215    if exit_output_matches(err, &["refs/"]) {
1216        return false;
1217    }
1218    exit_output_matches(err, LOCK_CONTENTION_MARKERS)
1219}
1220
1221/// Whether `err` is an **input rejection** — a bad caller argument, encoded as an
1222/// [`ErrorReason::Spawn`] whose source is `io::ErrorKind::InvalidInput`. This is the
1223/// pattern the toolkit's own argument guards raise ([`reject_flag_like`] and the
1224/// validating newtypes `RefName`/`RevSpec`/`RevsetExpr`) for a value that would be
1225/// misparsed as a flag, is empty, or contains a NUL — and it also covers the
1226/// spawn-time `InvalidInput` the OS raises for an un-spawnable argument (an interior
1227/// NUL in a flag-value, or Windows' batch-arg-escaping refusal). All are genuine
1228/// bad input, distinct from a real spawn failure (missing binary → `NotFound`, no
1229/// perms → `PermissionDenied`) or a non-zero exit. A binding maps this to a
1230/// `ValueError`; the facades re-expose it as `Error::is_invalid_input()`.
1231pub fn is_invalid_input(err: &Error) -> bool {
1232    matches!(
1233        err.reason(),
1234        ErrorReason::Spawn { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput
1235    )
1236}
1237
1238/// A bounded retry strategy: how many attempts, the (exponential) backoff between
1239/// them, and whether to add full jitter. Used by [`ManagedClient`] to retry
1240/// [`is_lock_contention`] failures. The [`Default`] is [`none`](RetryPolicy::none)
1241/// (no retry) — retry is **opt-in**.
1242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1243#[non_exhaustive]
1244pub struct RetryPolicy {
1245    /// Total attempts including the first; `1` means no retry.
1246    pub attempts: u32,
1247    /// Delay before the first retry; doubles each subsequent retry (capped by
1248    /// [`max_backoff`](RetryPolicy::max_backoff)). `ZERO` means retry immediately.
1249    pub base_backoff: Duration,
1250    /// Upper bound on the (pre-jitter) backoff delay. `ZERO` means uncapped.
1251    pub max_backoff: Duration,
1252    /// Apply **full jitter** — the actual delay is uniform in `[0, computed]` — to
1253    /// avoid a thundering herd when many workers retry against one repository.
1254    pub jitter: bool,
1255}
1256
1257impl RetryPolicy {
1258    /// No retry: a single attempt. The default.
1259    pub const fn none() -> Self {
1260        Self {
1261            attempts: 1,
1262            base_backoff: Duration::ZERO,
1263            max_backoff: Duration::ZERO,
1264            jitter: false,
1265        }
1266    }
1267
1268    /// A sensible default for repository lock contention: a handful of attempts
1269    /// with short, jittered, exponential backoff (25 ms → 500 ms).
1270    pub const fn lock_contention() -> Self {
1271        Self {
1272            attempts: 5,
1273            base_backoff: Duration::from_millis(25),
1274            max_backoff: Duration::from_millis(500),
1275            jitter: true,
1276        }
1277    }
1278
1279    /// Set the total number of attempts (clamped to at least 1).
1280    pub fn attempts(mut self, attempts: u32) -> Self {
1281        self.attempts = attempts.max(1);
1282        self
1283    }
1284
1285    /// Set the base backoff (the delay before the first retry).
1286    pub fn base_backoff(mut self, backoff: Duration) -> Self {
1287        self.base_backoff = backoff;
1288        self
1289    }
1290
1291    /// Cap the (pre-jitter) backoff delay; `ZERO` leaves it uncapped.
1292    pub fn max_backoff(mut self, max: Duration) -> Self {
1293        self.max_backoff = max;
1294        self
1295    }
1296
1297    /// Toggle full jitter on the backoff delay.
1298    pub fn with_jitter(mut self, jitter: bool) -> Self {
1299        self.jitter = jitter;
1300        self
1301    }
1302}
1303
1304impl Default for RetryPolicy {
1305    /// No retry — retry is opt-in.
1306    fn default() -> Self {
1307        Self::none()
1308    }
1309}
1310
1311/// The (possibly jittered) backoff before the `retry_index`-th retry (0 = first).
1312fn backoff_for(policy: &RetryPolicy, retry_index: u32) -> Duration {
1313    if policy.base_backoff.is_zero() {
1314        return Duration::ZERO;
1315    }
1316    let base = policy.base_backoff.as_nanos();
1317    let scaled = base.saturating_mul(1u128 << retry_index.min(20));
1318    let capped = if policy.max_backoff.is_zero() {
1319        scaled
1320    } else {
1321        scaled.min(policy.max_backoff.as_nanos())
1322    };
1323    let delay = Duration::from_nanos(capped.min(u64::MAX as u128) as u64);
1324    if policy.jitter {
1325        full_jitter(delay)
1326    } else {
1327        delay
1328    }
1329}
1330
1331/// Full jitter: a uniform delay in `[0, max]`. Dependency-free randomness via the
1332/// OS-seeded [`RandomState`](std::collections::hash_map::RandomState) — good enough
1333/// to de-correlate retries, not cryptographic.
1334fn full_jitter(max: Duration) -> Duration {
1335    use std::hash::{BuildHasher, Hasher};
1336    let nanos = max.as_nanos();
1337    if nanos == 0 {
1338        return Duration::ZERO;
1339    }
1340    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
1341    hasher.write_u64(nanos as u64);
1342    let r = hasher.finish() as u128;
1343    Duration::from_nanos((r % (nanos + 1)).min(u64::MAX as u128) as u64)
1344}
1345
1346/// The structured [`ErrorReason::Cancelled`] to surface when a cancellation token
1347/// aborts the retry backoff, named for the same program as the attempt that just
1348/// failed — so it reads exactly like the `Cancelled` a [`processkit`] run raises when
1349/// its own [`default_cancel_on`](ManagedClient::default_cancel_on) token kills an
1350/// in-flight process. Falls back to an empty program name only if the last error
1351/// carried none (every real attempt error names its program).
1352fn cancelled_error(last_err: &Error) -> Error {
1353    ErrorReason::Cancelled {
1354        program: last_err.program().unwrap_or_default().to_owned(),
1355    }
1356    .into()
1357}
1358
1359/// Run `op`, retrying its result while `should_retry` says so and `policy` has
1360/// attempts left, sleeping the (jittered, exponential) backoff between tries. The
1361/// op is re-invoked from scratch each attempt, so it must be idempotent for the
1362/// errors `should_retry` selects (lock-contention failures are — the command never
1363/// ran). Returns the first `Ok`, or the last `Err`.
1364///
1365/// When `cancel` is `Some`, the backoff between attempts is **cancellation-aware**:
1366/// if the token fires before or during a wait, the wait stops immediately and the
1367/// whole retry aborts with a structured [`ErrorReason::Cancelled`] (naming the
1368/// just-failed attempt's program). It does **not** sit out the rest of the delay,
1369/// and — crucially — it launches **no** further attempt, so a cancel can never race
1370/// a fresh op into flight (the attempt count stays deterministic). Pass `None` to
1371/// keep the plain, uninterruptible backoff (behaviour unchanged from before this
1372/// parameter existed).
1373///
1374/// The **first** attempt always runs; cancellation is only observed around the
1375/// backoff. An `op` bound to the same token (a [`ManagedClient`] built with
1376/// [`default_cancel_on`](ManagedClient::default_cancel_on)) still surfaces its own
1377/// `Cancelled` when the token was already fired as it ran — `should_retry` returns
1378/// `false` for that terminal error, so the loop returns it without a backoff anyway.
1379pub async fn retry_async<T, Fut>(
1380    policy: &RetryPolicy,
1381    cancel: Option<&CancellationToken>,
1382    should_retry: impl Fn(&Error) -> bool,
1383    mut op: impl FnMut() -> Fut,
1384) -> Result<T>
1385where
1386    Fut: Future<Output = Result<T>>,
1387{
1388    let attempts = policy.attempts.max(1);
1389    for attempt in 1..=attempts {
1390        match op().await {
1391            Ok(value) => return Ok(value),
1392            Err(err) => {
1393                if attempt == attempts || !should_retry(&err) {
1394                    return Err(err);
1395                }
1396                let delay = backoff_for(policy, attempt - 1);
1397                match cancel {
1398                    // Cancellation-aware backoff. `run_until_cancelled` drops the
1399                    // pending sleep the instant the token fires (or returns at once
1400                    // if it is already fired), so a cancelled retry never waits out
1401                    // the full delay. We then abort with a structured `Cancelled`
1402                    // instead of looping into another attempt — the same check also
1403                    // covers a zero delay and a cancel that lands right as the wait
1404                    // ends, so no attempt is ever launched after the token fired.
1405                    Some(token) => {
1406                        if !delay.is_zero() {
1407                            let _ = token.run_until_cancelled(tokio::time::sleep(delay)).await;
1408                        }
1409                        if token.is_cancelled() {
1410                            return Err(cancelled_error(&err));
1411                        }
1412                    }
1413                    // No token: the original plain, uninterruptible backoff.
1414                    None => {
1415                        if !delay.is_zero() {
1416                            tokio::time::sleep(delay).await;
1417                        }
1418                    }
1419                }
1420            }
1421        }
1422    }
1423    unreachable!("the loop returns on the final attempt")
1424}
1425
1426/// A [`CliClient`] wrapper that adds two opt-in concerns the CLI wrappers
1427/// (`vcs-git`, `vcs-jj`, `vcs-github`, `vcs-gitlab`) all share, without touching a
1428/// single call site:
1429///
1430/// 1. **Lock-contention retry** ([`is_lock_contention`]) per a [`RetryPolicy`] —
1431///    off by default ([`RetryPolicy::none`]); enable with
1432///    [`with_retry`](ManagedClient::with_retry). Safe even for mutating commands,
1433///    since lock contention is a clean pre-execution failure.
1434/// 2. **Credential injection** from an opt-in [`CredentialProvider`] — off by
1435///    default (no provider); attach one with
1436///    [`with_credentials`](ManagedClient::with_credentials). When a forge
1437///    *token-env* binding is configured
1438///    ([`with_token_env`](ManagedClient::with_token_env)), every command run
1439///    through this client gets the resolved token in that environment variable
1440///    (e.g. `GH_TOKEN`). Backends that inject the secret differently (git's
1441///    `credential.helper`) instead call
1442///    [`resolve_credential`](ManagedClient::resolve_credential) at the command
1443///    site. Resolution happens once per call, before the retry loop. A
1444///    [`with_expected_host`](ManagedClient::with_expected_host) binding travels as
1445///    the request's host so a **host-keyed** provider selects the right instance's
1446///    secret; the `Ok(None)` / `Err` fallback (defer to ambient vs. fail-closed
1447///    abort) is defined on
1448///    [`resolve_credential`](ManagedClient::resolve_credential).
1449///
1450/// Both default to inert, so a client with neither configured behaves exactly
1451/// like a bare `CliClient`.
1452pub struct ManagedClient<R: ProcessRunner = JobRunner> {
1453    inner: CliClient<R>,
1454    retry: RetryPolicy,
1455    credentials: Option<Arc<dyn CredentialProvider>>,
1456    /// When set, the token is auto-injected into this env var on every command,
1457    /// resolved for this service. Used by the forge clients (`GH_TOKEN`, …).
1458    token_env: Option<(CredentialService, &'static str)>,
1459    /// The remote host this client targets, set when a forge `with_host` builder
1460    /// bound one. It becomes the [`CredentialRequest`]'s host on the auto-injected
1461    /// token-env path (the forge case), so a **host-keyed** provider selects the
1462    /// secret for *this* host and never a neighbouring instance's. `None` leaves the
1463    /// request host unset — a host-keyed provider that can't place the request
1464    /// returns `Ok(None)` and the command falls back to ambient auth, rather than
1465    /// being handed the wrong host's secret.
1466    expected_host: Option<String>,
1467    /// A copy of the [`default_cancel_on`](Self::default_cancel_on) token, kept here
1468    /// (as well as on `inner`, which bounds the spawned *process*) so the retry loop
1469    /// can cut a lock-contention backoff short the instant cancellation fires,
1470    /// instead of sleeping out the full delay before the next attempt.
1471    cancel: Option<CancellationToken>,
1472    /// The default output budget applied to the potentially large **content**
1473    /// verbs this client builds (via [`run_untrimmed`](Self::run_untrimmed)), to
1474    /// the output a **streamed** run retains
1475    /// ([`run_with_progress`](Self::run_with_progress)) and, on request, to a
1476    /// discard verb's diagnostic capture
1477    /// ([`budget_diagnostics`](Self::budget_diagnostics)). Defaults to
1478    /// [`OutputBudget::unlimited`] — no ceiling — so a client that never sets one
1479    /// behaves exactly as before. A single call overrides it via
1480    /// [`run_untrimmed_within`](Self::run_untrimmed_within) /
1481    /// [`run_with_progress_within`](Self::run_with_progress_within).
1482    output_budget: OutputBudget,
1483    /// Optional resettable output-inactivity window applied to streamed runs.
1484    /// `None` preserves the pre-watchdog behaviour: a streamed command is bounded
1485    /// only by its absolute deadline or cancellation policy.
1486    inactivity_timeout: Option<Duration>,
1487}
1488
1489impl<R: ProcessRunner> fmt::Debug for ManagedClient<R> {
1490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1491        f.debug_struct("ManagedClient")
1492            .field("inner", &self.inner)
1493            .field("retry", &self.retry)
1494            // Never render the provider itself (it may close over a secret); just
1495            // whether one is configured, plus the token-env binding.
1496            .field("credentials", &self.credentials.is_some())
1497            .field("token_env", &self.token_env)
1498            // A hostname, not a secret — safe to render; helps distinguish a
1499            // host-bound client's `{:?}` from an unbound one.
1500            .field("expected_host", &self.expected_host)
1501            // The token itself is not meaningfully renderable; whether one is set
1502            // matches `inner`'s own `has_default_cancel`, kept explicit here too.
1503            .field("has_cancel", &self.cancel.is_some())
1504            // A small plain cap (no secret) — safe to render.
1505            .field("output_budget", &self.output_budget)
1506            .field("inactivity_timeout", &self.inactivity_timeout)
1507            .finish()
1508    }
1509}
1510
1511impl ManagedClient<JobRunner> {
1512    /// A retrying client driving `program` on the real job-backed runner (no retry
1513    /// until [`with_retry`](ManagedClient::with_retry)).
1514    pub fn new(program: impl AsRef<OsStr>) -> Self {
1515        Self {
1516            inner: CliClient::new(program),
1517            retry: RetryPolicy::none(),
1518            credentials: None,
1519            token_env: None,
1520            expected_host: None,
1521            cancel: None,
1522            output_budget: OutputBudget::unlimited(),
1523            inactivity_timeout: None,
1524        }
1525    }
1526}
1527
1528impl<R: ProcessRunner> ManagedClient<R> {
1529    /// A retrying client driving `program` on `runner` — inject a fake in tests.
1530    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
1531        Self {
1532            inner: CliClient::with_runner(program, runner),
1533            retry: RetryPolicy::none(),
1534            credentials: None,
1535            token_env: None,
1536            expected_host: None,
1537            cancel: None,
1538            output_budget: OutputBudget::unlimited(),
1539            inactivity_timeout: None,
1540        }
1541    }
1542
1543    /// Set the lock-contention retry policy (opt-in; default is no retry).
1544    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
1545        self.retry = policy;
1546        self
1547    }
1548
1549    /// The active retry policy.
1550    pub fn retry_policy(&self) -> RetryPolicy {
1551        self.retry
1552    }
1553
1554    /// Attach a [`CredentialProvider`] (opt-in; default is none → ambient auth).
1555    /// The provider is consulted per operation: automatically when a
1556    /// [`with_token_env`](ManagedClient::with_token_env) binding is set, or
1557    /// on demand via [`resolve_credential`](ManagedClient::resolve_credential).
1558    ///
1559    /// **Precedence:** a resolved token is injected *after* any
1560    /// [`default_env`](ManagedClient::default_env), so the provider wins over a
1561    /// static default and over the ambient CLI login. **Cancellation:** a
1562    /// [`default_cancel_on`](ManagedClient::default_cancel_on) token bounds the
1563    /// spawned *process*, not provider resolution — if your provider does slow I/O
1564    /// (a vault lookup), bound it yourself.
1565    #[must_use]
1566    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1567        self.credentials = Some(provider);
1568        self
1569    }
1570
1571    /// Bind the resolved token to an environment variable injected on **every**
1572    /// command this client runs (the forge case: `GH_TOKEN`, `GITLAB_TOKEN`). The
1573    /// `service` tags the [`CredentialRequest`]. No effect without a provider.
1574    #[must_use]
1575    pub fn with_token_env(mut self, service: CredentialService, var: &'static str) -> Self {
1576        self.token_env = Some((service, var));
1577        self
1578    }
1579
1580    /// Bind the remote host this client targets (set by a forge `with_host`): it
1581    /// travels as the [`CredentialRequest`]'s host whenever the token-env path
1582    /// resolves a credential, so a **host-keyed** [`CredentialProvider`] returns the
1583    /// secret for *this* host and nothing else — one client can't inject a
1584    /// neighbouring instance's token. Without it the request host is unset (the
1585    /// pre-host-context behaviour). No effect without a provider and a
1586    /// [`with_token_env`](Self::with_token_env) binding.
1587    #[must_use]
1588    pub fn with_expected_host(mut self, host: impl Into<String>) -> Self {
1589        self.expected_host = Some(host.into());
1590        self
1591    }
1592
1593    /// Whether a credential provider is configured.
1594    #[must_use]
1595    pub fn has_credentials(&self) -> bool {
1596        self.credentials.is_some()
1597    }
1598
1599    /// Resolve a credential for `service`/`host` from the configured provider, or
1600    /// `Ok(None)` if no provider is set or it defers to ambient auth. Backends
1601    /// that inject the secret at the command site (git's `credential.helper`) call
1602    /// this directly; the forge token-env path uses it internally.
1603    ///
1604    /// **Fallback policy (identical for read and write operations):**
1605    /// - **No provider**, or the provider returns **`Ok(None)`** → `Ok(None)`:
1606    ///   defer to the CLI's ambient auth, exactly as if no provider were configured.
1607    /// - A credential whose secret is **empty / whitespace-only** → treated as
1608    ///   `Ok(None)` (ambient): injecting an empty token would *override* the ambient
1609    ///   login with nothing instead of deferring to it.
1610    /// - The provider returns **`Err`** → the error propagates and **aborts** the
1611    ///   operation (**fail-closed**). A provider that cannot resolve (a vault outage)
1612    ///   is never silently downgraded to ambient auth.
1613    ///
1614    /// Passing the operation's `host` is what lets a **host-keyed** provider return
1615    /// the secret for *that* host (or `Ok(None)` for one it does not handle) — so it
1616    /// never hands back a neighbouring instance's token when the host is known, and
1617    /// an unknown/absent host defers to ambient rather than substituting a default
1618    /// secret.
1619    pub async fn resolve_credential(
1620        &self,
1621        service: CredentialService,
1622        host: Option<&str>,
1623    ) -> Result<Option<Credential>> {
1624        let Some(provider) = &self.credentials else {
1625            return Ok(None);
1626        };
1627        let request = CredentialRequest { service, host };
1628        // An empty (or whitespace-only) secret is not a usable credential —
1629        // injecting an empty `GH_TOKEN`/`GITLAB_TOKEN` (or a `password=` line)
1630        // would *override* the ambient login with nothing rather than defer to it.
1631        // Treat it as `None` (ambient), keeping the "no usable credential ⇒
1632        // ambient auth" contract consistent regardless of which adapter produced
1633        // it (matching `EnvToken`'s own whitespace-only ⇒ unset rule).
1634        let credential = provider.credential(&request).await?;
1635        let Some(credential) = credential else {
1636            return Ok(None);
1637        };
1638        credential.validate_for_resolution()?;
1639        if credential.secret().expose().trim().is_empty() {
1640            return Ok(None);
1641        }
1642        Ok(Some(credential))
1643    }
1644
1645    /// Materialize `call` into a [`Command`], injecting the forge token env if a
1646    /// [`with_token_env`](ManagedClient::with_token_env) binding and a provider
1647    /// are both configured. The single place the auto-injection happens, shared by
1648    /// every retrying verb.
1649    ///
1650    /// The request carries this client's
1651    /// [`expected_host`](ManagedClient::with_expected_host) (when a forge `with_host`
1652    /// set one), so a host-keyed provider picks the secret for that host. The
1653    /// resolution follows the [`resolve_credential`](ManagedClient::resolve_credential)
1654    /// fallback policy: `Ok(None)` (nothing for this host, or an empty secret) leaves
1655    /// the command on ambient auth — no env is set — while an `Err` **aborts** the
1656    /// command (fail-closed, via `?`). A provider that can't resolve is never
1657    /// silently downgraded to ambient, and a wrong host's secret is never
1658    /// substituted. This holds identically for read and write verbs (both route
1659    /// through here).
1660    async fn prepare(&self, call: impl IntoCommand<R>) -> Result<Command> {
1661        let cmd = call.into_command(&self.inner);
1662        let Some((service, var)) = self.token_env else {
1663            return Ok(cmd);
1664        };
1665        match self
1666            .resolve_credential(service, self.expected_host.as_deref())
1667            .await?
1668        {
1669            Some(cred) => Ok(cmd.env(var, cred.secret().expose())),
1670            None => Ok(cmd),
1671        }
1672    }
1673
1674    /// Apply a default timeout to every command this client builds.
1675    pub fn default_timeout(mut self, timeout: Duration) -> Self {
1676        self.inner = self.inner.default_timeout(timeout);
1677        self
1678    }
1679
1680    /// Set the resettable output-inactivity window for streamed runs.
1681    ///
1682    /// The default is disabled (`None`), preserving the pre-watchdog behaviour.
1683    /// The window is applied only by [`run_with_progress`](Self::run_with_progress)
1684    /// and its per-call budgeted sibling; captured commands keep their existing
1685    /// absolute-timeout, retry, credential, and cleanup semantics. A successful
1686    /// read from either output stream resets the window. For `jj` callers this is
1687    /// an explicit opt-in: unlike Git's `--progress`, jj does not force progress
1688    /// when stderr is piped, so a configured window is safe only when the selected
1689    /// jj version/environment emits progress under that transport.
1690    pub fn default_inactivity_timeout(mut self, timeout: Duration) -> Self {
1691        self.inactivity_timeout = Some(timeout);
1692        self
1693    }
1694
1695    /// Set an environment variable on every command this client builds.
1696    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
1697        self.inner = self.inner.default_env(key, value);
1698        self
1699    }
1700
1701    /// Remove an inherited environment variable on every command this client builds.
1702    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
1703        self.inner = self.inner.default_env_remove(key);
1704        self
1705    }
1706
1707    /// Cancel every command this client builds when `token` fires — and cut a
1708    /// lock-contention retry backoff short the moment it does, so a cancelled
1709    /// operation returns promptly instead of sleeping out the remaining delay
1710    /// before its next attempt. Network `fetch`/`push`/`clone` commands add the
1711    /// shared [`FETCH_TIMEOUT_GRACE`] soft-completion window (and the Windows
1712    /// console trigger when available); their structured result remains
1713    /// [`ErrorReason::Cancelled`]. The token is applied to the spawned process
1714    /// (via `inner`) *and* observed by the retry loop. Other commands retain their
1715    /// existing cancellation policy.
1716    pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
1717        self.inner = self.inner.default_cancel_on(token.clone());
1718        self.cancel = Some(token);
1719        self
1720    }
1721
1722    /// Set the default [`OutputBudget`] applied to the content verbs this client
1723    /// builds through [`run_untrimmed`](Self::run_untrimmed), to a discard verb's
1724    /// diagnostics via [`budget_diagnostics`](Self::budget_diagnostics), and to
1725    /// the output a streamed run retains
1726    /// ([`run_with_progress`](Self::run_with_progress)) — off by default
1727    /// ([`OutputBudget::unlimited`]). A single call can override it via
1728    /// [`run_untrimmed_within`](Self::run_untrimmed_within) /
1729    /// [`run_with_progress_within`](Self::run_with_progress_within).
1730    pub fn default_output_budget(mut self, budget: OutputBudget) -> Self {
1731        self.output_budget = budget;
1732        self
1733    }
1734
1735    /// The active default output budget.
1736    pub fn output_budget(&self) -> OutputBudget {
1737        self.output_budget
1738    }
1739
1740    /// Apply this client's default budget to `cmd` as a **diagnostic** (drop-oldest
1741    /// tail) bound, for a discard verb that only surfaces its output on failure
1742    /// (`clone`/`fetch`). Caps the retained error/progress buffer without turning a
1743    /// real failure into [`ErrorReason::OutputTooLarge`] — the tail (where a CLI's fatal
1744    /// line sits) is preserved, so [`is_transient_fetch_error`] /
1745    /// [`is_lock_contention`] still classify it. A no-op when the budget is
1746    /// [`unlimited`](OutputBudget::unlimited).
1747    ///
1748    /// This bounds what **processkit** retains for a captured run. The streamed
1749    /// twin of the same verbs bounds what the *event* consumer retains instead,
1750    /// under the same budget — see
1751    /// [`run_with_progress`](Self::run_with_progress); a streaming `clone`/`fetch`
1752    /// wants both, since a command buffer policy does not bound an event stream.
1753    pub fn budget_diagnostics(&self, cmd: Command) -> Command {
1754        match self.output_budget.diagnostic_policy() {
1755            Some(policy) => cmd.output_buffer(policy),
1756            None => cmd,
1757        }
1758    }
1759
1760    /// Build a [`Command`] for this client's program (passthrough).
1761    pub fn command<I, S>(&self, args: I) -> Command
1762    where
1763        I: IntoIterator<Item = S>,
1764        S: AsRef<OsStr>,
1765    {
1766        self.inner.command(args)
1767    }
1768
1769    /// Build a [`Command`] bound to `dir` (passthrough).
1770    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
1771    where
1772        I: IntoIterator<Item = S>,
1773        S: AsRef<OsStr>,
1774    {
1775        self.inner.command_in(dir, args)
1776    }
1777
1778    /// The underlying process runner (passthrough — e.g. for `output_all`).
1779    pub fn runner(&self) -> &R {
1780        self.inner.runner()
1781    }
1782
1783    /// Like [`CliClient::run`], with credential injection and lock-retry.
1784    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
1785        let cmd = self.prepare(call).await?;
1786        retry_async(
1787            &self.retry,
1788            self.cancel.as_ref(),
1789            is_lock_contention,
1790            || self.inner.run(cmd.clone()),
1791        )
1792        .await
1793    }
1794
1795    /// Like [`CliClient::run_unit`], with credential injection and lock-retry.
1796    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
1797        let cmd = self.prepare(call).await?;
1798        retry_async(
1799            &self.retry,
1800            self.cancel.as_ref(),
1801            is_lock_contention,
1802            || self.inner.run_unit(cmd.clone()),
1803        )
1804        .await
1805    }
1806
1807    /// Run one command while forwarding live process events to `progress`.
1808    ///
1809    /// Credential injection and all command defaults are applied exactly as for
1810    /// [`run_unit`](Self::run_unit). Unlike that captured-output path, this is a
1811    /// deliberately single-attempt lifecycle: neither this client's optional
1812    /// lock retry nor a command retry is applied, so the callback observes one
1813    /// `Started … Exited` sequence and `Exited` remains terminal. The returned
1814    /// error still carries the streamed stdout/stderr for normal processkit
1815    /// classification.
1816    ///
1817    /// **Output budget:** this client's default [`OutputBudget`]
1818    /// ([`default_output_budget`](Self::default_output_budget)) bounds the
1819    /// stdout/stderr this call retains, as a **drop-oldest tail** — the
1820    /// streaming counterpart of what
1821    /// [`budget_diagnostics`](Self::budget_diagnostics) applies to a captured
1822    /// `clone`/`fetch`, and the reason a streamed one is memory-bounded by the
1823    /// same knob rather than growing with the repository. Never fail-loud: a
1824    /// bounded run still surfaces its real outcome, with the classifiable tail
1825    /// of its output. Unlimited by default (unchanged behaviour); override for
1826    /// one call with
1827    /// [`run_with_progress_within`](Self::run_with_progress_within), whose docs
1828    /// (and [`crate::run_with_progress_within`]'s) define what the ceiling counts.
1829    pub async fn run_with_progress(
1830        &self,
1831        call: impl IntoCommand<R>,
1832        progress: &mut ProgressCallback<'_>,
1833    ) -> Result<()> {
1834        self.run_with_progress_within(call, progress, self.output_budget)
1835            .await
1836    }
1837
1838    /// Like [`run_with_progress`](Self::run_with_progress), but with an explicit
1839    /// per-call [`OutputBudget`] instead of this client's default — the
1840    /// streaming sibling of
1841    /// [`run_untrimmed_within`](Self::run_untrimmed_within), for a call that
1842    /// wants a tighter tail than the client's default, or
1843    /// [`OutputBudget::unlimited`] to keep the whole stream for one operation.
1844    /// See [`crate::run_with_progress_within`] for the drop-oldest semantics and
1845    /// the unit the byte ceiling counts.
1846    pub async fn run_with_progress_within(
1847        &self,
1848        call: impl IntoCommand<R>,
1849        progress: &mut ProgressCallback<'_>,
1850        budget: OutputBudget,
1851    ) -> Result<()> {
1852        let mut cmd = self.prepare(call).await?;
1853        if cmd.configured_inactivity_timeout().is_none()
1854            && let Some(timeout) = self.inactivity_timeout
1855        {
1856            cmd = cmd.inactivity_timeout(timeout);
1857        }
1858        crate::run_with_progress_within(self.inner.runner(), &cmd, progress, budget).await
1859    }
1860
1861    /// Like [`CliClient::output_string`], with credential injection. **No lock-retry:**
1862    /// `output_string` returns `Ok` on a non-zero exit (it captures the result), so a
1863    /// lock failure surfaces as an `Ok` here, not an `Err` the retry predicate could
1864    /// match — route mutations that need lock-retry through
1865    /// [`run`](Self::run)/[`run_unit`](Self::run_unit) instead.
1866    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
1867        let cmd = self.prepare(call).await?;
1868        self.inner.output_string(cmd).await
1869    }
1870
1871    /// Like [`CliClient::output_bytes`], with credential injection. Captures stdout
1872    /// as **raw bytes**, byte-exact — unlike [`output_string`](Self::output_string),
1873    /// which reassembles stdout from decoded lines and so drops a trailing newline.
1874    /// This is the byte-faithful path [`run_untrimmed`](Self::run_untrimmed) needs.
1875    /// **No lock-retry**, for the same reason as `output_string`: it returns `Ok`
1876    /// on a non-zero exit (it captures the result), so a lock failure surfaces as an
1877    /// `Ok` here rather than an `Err` the retry predicate could match.
1878    pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
1879        let cmd = self.prepare(call).await?;
1880        self.inner.output_bytes(cmd).await
1881    }
1882
1883    /// Like [`run`](Self::run), but returns stdout **verbatim** — no `trim_end`.
1884    /// For **content**-returning verbs (a file's bytes at a rev, a diff, a raw
1885    /// template render) where the trailing newline(s) are part of the value, not
1886    /// noise: trimming them corrupts a read-modify-write round-trip and desyncs a
1887    /// diff's last hunk from its `@@` line count. Exit-checked like `run`; no
1888    /// lock-retry (a content read is not a mutation).
1889    ///
1890    /// Routed through [`output_bytes`](Self::output_bytes) (raw stdout), not
1891    /// `output_string`, so the exact bytes — trailing newline included — survive:
1892    /// `output_string` rebuilds stdout from decoded lines and would drop that final
1893    /// `\n`. The raw bytes are then decoded with
1894    /// [`String::from_utf8_lossy`], the same lossy raw-stdout-to-`String` convention
1895    /// used elsewhere in this workspace (e.g. `vcs-jj`).
1896    ///
1897    /// **Output budget:** this client's default [`OutputBudget`]
1898    /// ([`default_output_budget`](Self::default_output_budget)) is applied as a
1899    /// fail-loud byte ceiling — a content read past the cap errors with
1900    /// [`ErrorReason::OutputTooLarge`] (carrying the actual and allowed sizes) instead of
1901    /// buffering an unbounded blob, and a truncated read is never returned as if
1902    /// complete. Unlimited by default (unchanged behaviour). Override the ceiling
1903    /// for one call with [`run_untrimmed_within`](Self::run_untrimmed_within).
1904    ///
1905    /// The ceiling rides **both** captured streams independently: the raw stdout
1906    /// this verb returns, counted verbatim, and the command's line-pumped stderr,
1907    /// counted as raw pipe bytes (terminators included) since processkit 3.0 — so
1908    /// a command that floods stderr past the cap fails loud here too. See
1909    /// [`OutputBudget::bytes`] for the per-stream unit.
1910    pub async fn run_untrimmed(&self, call: impl IntoCommand<R>) -> Result<String> {
1911        self.run_untrimmed_within(call, self.output_budget).await
1912    }
1913
1914    /// Like [`run_untrimmed`](Self::run_untrimmed), but with an explicit per-call
1915    /// [`OutputBudget`] instead of this client's default — the per-call override
1916    /// used by the `*_within` content methods (`diff_text_within`,
1917    /// `show_file_within`, `pr_diff_within`, …) to read a legitimately large
1918    /// file/diff (a higher ceiling, or [`OutputBudget::unlimited`]) or to tighten
1919    /// the cap for one call.
1920    pub async fn run_untrimmed_within(
1921        &self,
1922        call: impl IntoCommand<R>,
1923        budget: OutputBudget,
1924    ) -> Result<String> {
1925        let cmd = self.prepare(call).await?;
1926        // A fail-loud byte ceiling: `output_bytes` raises `ErrorReason::OutputTooLarge`
1927        // the moment the raw stdout passes the cap (drained but not retained), so
1928        // this never returns a truncated blob as if it were complete.
1929        let cmd = match budget.content_policy() {
1930            Some(policy) => cmd.output_buffer(policy),
1931            None => cmd,
1932        };
1933        let bytes = self
1934            .inner
1935            .output_bytes(cmd)
1936            .await?
1937            .ensure_success()?
1938            .into_stdout();
1939        Ok(String::from_utf8_lossy(&bytes).into_owned())
1940    }
1941
1942    /// Like [`CliClient::probe`] (zero-or-nonzero exit → `bool`), with credential
1943    /// injection and lock-retry.
1944    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
1945        let cmd = self.prepare(call).await?;
1946        retry_async(
1947            &self.retry,
1948            self.cancel.as_ref(),
1949            is_lock_contention,
1950            || self.inner.probe(cmd.clone()),
1951        )
1952        .await
1953    }
1954
1955    /// Like [`CliClient::exit_code`] (the raw exit code; a spawn failure or timeout
1956    /// still errors), with credential injection and lock-retry.
1957    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
1958        let cmd = self.prepare(call).await?;
1959        retry_async(
1960            &self.retry,
1961            self.cancel.as_ref(),
1962            is_lock_contention,
1963            || self.inner.exit_code(cmd.clone()),
1964        )
1965        .await
1966    }
1967
1968    /// Like [`CliClient::parse`] (credential injection applied; the `FnOnce` parser
1969    /// can't be re-run, so lock-retry does not — parsing is a read, where lock
1970    /// contention is not a concern anyway).
1971    pub async fn parse<T>(
1972        &self,
1973        call: impl IntoCommand<R>,
1974        parser: impl FnOnce(&str) -> T + Send,
1975    ) -> Result<T>
1976    where
1977        T: Send,
1978    {
1979        let cmd = self.prepare(call).await?;
1980        self.inner.parse(cmd, parser).await
1981    }
1982
1983    /// Like [`parse`](Self::parse), but hands the parser **raw stdout bytes**
1984    /// instead of a lossily-decoded `&str`. This is the byte-faithful path a parser
1985    /// needs when a **path** (or any payload that need not be valid UTF-8) is part
1986    /// of the output: on Unix a filename can be arbitrary bytes, so decoding it
1987    /// through [`String::from_utf8_lossy`] first would substitute `U+FFFD` and make
1988    /// the path unusable to round-trip back into `add`/`commit_paths`. Routed
1989    /// through [`output_bytes`](Self::output_bytes) (byte-exact stdout) and
1990    /// exit-checked like [`parse`](Self::parse) (`ensure_success`); no lock-retry (a
1991    /// read). Text-only machine output (branch names, hashes, templated rows) should
1992    /// keep using [`parse`](Self::parse) — lossy decoding is acceptable there.
1993    pub async fn parse_bytes<T>(
1994        &self,
1995        call: impl IntoCommand<R>,
1996        parser: impl FnOnce(&[u8]) -> T + Send,
1997    ) -> Result<T>
1998    where
1999        T: Send,
2000    {
2001        let cmd = self.prepare(call).await?;
2002        let bytes = self
2003            .inner
2004            .output_bytes(cmd)
2005            .await?
2006            .ensure_success()?
2007            .into_stdout();
2008        Ok(parser(&bytes))
2009    }
2010
2011    /// Like [`CliClient::try_parse`] (credential injection applied; `FnOnce` parser,
2012    /// and a read, so no lock-retry).
2013    pub async fn try_parse<T>(
2014        &self,
2015        call: impl IntoCommand<R>,
2016        parser: impl FnOnce(&str) -> Result<T> + Send,
2017    ) -> Result<T>
2018    where
2019        T: Send,
2020    {
2021        let cmd = self.prepare(call).await?;
2022        self.inner.try_parse(cmd, parser).await
2023    }
2024}
2025
2026#[cfg(test)]
2027mod tests {
2028    use super::*;
2029    use processkit::testing::{Reply, ScriptedRunner};
2030    use proptest::prelude::*;
2031    use std::sync::Mutex;
2032
2033    struct UnvalidatedProvider(Credential);
2034
2035    #[async_trait::async_trait]
2036    impl CredentialProvider for UnvalidatedProvider {
2037        async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
2038            Ok(Some(self.0.clone()))
2039        }
2040    }
2041
2042    #[tokio::test]
2043    async fn streamed_run_replays_scripted_lifecycle_and_preserves_failure_output() {
2044        let runner = ScriptedRunner::new().on(
2045            ["tool", "network-op"],
2046            Reply::fail(23, "remote rejected").with_stdout("sent objects"),
2047        );
2048        let seen = Arc::new(Mutex::new(Vec::new()));
2049        let sink = Arc::clone(&seen);
2050        let mut progress = move |event| sink.lock().unwrap().push(event);
2051
2052        let err = run_with_progress(
2053            &runner,
2054            &Command::new("tool").arg("network-op"),
2055            &mut progress,
2056        )
2057        .await
2058        .expect_err("the scripted non-zero exit stays an error");
2059        drop(progress);
2060
2061        assert!(matches!(
2062            err.reason(),
2063            ErrorReason::Exit { code: 23, stdout, stderr, .. }
2064                if stdout == "sent objects" && stderr == "remote rejected"
2065        ));
2066        let events = seen.lock().unwrap();
2067        assert!(matches!(events.first(), Some(ProcessEvent::Started { .. })));
2068        assert!(
2069            events
2070                .iter()
2071                .any(|event| event.text() == Some("sent objects"))
2072        );
2073        assert!(
2074            events
2075                .iter()
2076                .any(|event| event.text() == Some("remote rejected"))
2077        );
2078        assert!(matches!(
2079            events.last(),
2080            Some(ProcessEvent::Exited(outcome)) if outcome.code() == Some(23)
2081        ));
2082    }
2083
2084    #[test]
2085    fn fetch_completion_policy_records_grace_and_platform_soft_trigger() {
2086        let command = apply_fetch_completion_policy(Command::new("git").arg("fetch"));
2087        let debug = format!("{command:?}");
2088
2089        assert!(debug.contains("timeout_grace: Some(2s)"), "{debug}");
2090        assert!(debug.contains("cancel_grace: Some(2s)"), "{debug}");
2091        #[cfg(windows)]
2092        assert!(
2093            debug.contains("windows_graceful_ctrl_break: true"),
2094            "Windows network commands must opt into CTRL_BREAK: {debug}"
2095        );
2096        #[cfg(not(windows))]
2097        assert!(
2098            debug.contains("windows_graceful_ctrl_break: false"),
2099            "the Windows-only trigger must remain a Unix no-op: {debug}"
2100        );
2101    }
2102
2103    #[tokio::test(start_paused = true)]
2104    async fn managed_client_stream_watchdog_is_disabled_by_default() {
2105        let client = ManagedClient::with_runner(
2106            "tool",
2107            ScriptedRunner::new().on(
2108                ["tool", "network-op"],
2109                Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
2110            ),
2111        );
2112        assert!(
2113            client
2114                .command(["network-op"])
2115                .configured_inactivity_timeout()
2116                .is_none(),
2117            "the new watchdog must not alter an unconfigured command"
2118        );
2119
2120        let mut progress = |_event: ProcessEvent| {};
2121        client
2122            .run_with_progress(client.command(["network-op"]), &mut progress)
2123            .await
2124            .expect("the default-disabled stream remains compatible with slow output");
2125    }
2126
2127    #[tokio::test(start_paused = true)]
2128    async fn managed_client_stream_watchdog_distinguishes_inactivity_from_deadline() {
2129        let delayed = || {
2130            ScriptedRunner::new().on(
2131                ["tool", "network-op"],
2132                Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
2133            )
2134        };
2135
2136        let watchdog = ManagedClient::with_runner("tool", delayed())
2137            .default_inactivity_timeout(Duration::from_secs(3));
2138        let mut progress = |_event: ProcessEvent| {};
2139        let err = watchdog
2140            .run_with_progress(watchdog.command(["network-op"]), &mut progress)
2141            .await
2142            .expect_err("the inactivity watchdog must fire before the delayed line");
2143        assert!(matches!(
2144            err.reason(),
2145            ErrorReason::Timeout {
2146                timeout,
2147                inactivity: true,
2148                ..
2149            } if *timeout == Duration::from_secs(3)
2150        ));
2151
2152        let deadline = ManagedClient::with_runner("tool", delayed())
2153            .default_timeout(Duration::from_secs(3))
2154            .default_inactivity_timeout(Duration::from_secs(30));
2155        let err = deadline
2156            .run_with_progress(deadline.command(["network-op"]), &mut progress)
2157            .await
2158            .expect_err("the absolute deadline must remain a distinct outcome");
2159        assert!(matches!(
2160            err.reason(),
2161            ErrorReason::Timeout {
2162                timeout,
2163                inactivity: false,
2164                ..
2165            } if *timeout == Duration::from_secs(3)
2166        ));
2167    }
2168
2169    #[tokio::test(start_paused = true)]
2170    async fn managed_client_stream_watchdog_resets_for_legitimately_slow_output() {
2171        let client = ManagedClient::with_runner(
2172            "tool",
2173            ScriptedRunner::new().on(
2174                ["tool", "network-op"],
2175                Reply::lines(["one", "two", "three"]).with_line_delay(Duration::from_secs(2)),
2176            ),
2177        )
2178        .default_inactivity_timeout(Duration::from_secs(3));
2179        let mut progress = |_event: ProcessEvent| {};
2180
2181        client
2182            .run_with_progress(client.command(["network-op"]), &mut progress)
2183            .await
2184            .expect("each output line resets the watchdog");
2185    }
2186
2187    #[tokio::test(start_paused = true)]
2188    async fn managed_client_stream_watchdog_preserves_explicit_command_timeout() {
2189        let client = ManagedClient::with_runner(
2190            "tool",
2191            ScriptedRunner::new().on(
2192                ["tool", "network-op"],
2193                Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
2194            ),
2195        )
2196        .default_inactivity_timeout(Duration::from_secs(3));
2197        let command = client
2198            .command(["network-op"])
2199            .inactivity_timeout(Duration::from_secs(30));
2200        assert_eq!(
2201            command.configured_inactivity_timeout(),
2202            Some(Duration::from_secs(30))
2203        );
2204
2205        let mut progress = |_event: ProcessEvent| {};
2206        client
2207            .run_with_progress(command, &mut progress)
2208            .await
2209            .expect("the client default must not override a per-command watchdog");
2210    }
2211
2212    // Processkit 3.3 makes the late-cancellation boundary explicit: a token is
2213    // judged at the first exit observation, not at the instant the child died. The
2214    // wrapper's real streamed path is exactly `events()` joined with `finish()`;
2215    // keep this case here because a normal `run_with_progress_within` call has no
2216    // hook between `start()` and that join. A scripted reply has already exited by
2217    // the time `start()` returns, so firing the token before the joined finisher's
2218    // first observation must still produce `Cancelled`.
2219    #[tokio::test]
2220    async fn streamed_run_late_cancel_before_first_finish_observation_is_cancelled() {
2221        let token = CancellationToken::new();
2222        let runner =
2223            ScriptedRunner::new().on(["tool", "network-op"], Reply::ok("").with_stdout("done\n"));
2224        let mut run = runner
2225            .start(
2226                &Command::new("tool")
2227                    .arg("network-op")
2228                    .cancel_on(token.clone()),
2229            )
2230            .await
2231            .expect("scripted start");
2232        let mut events = run.events().expect("events stream");
2233
2234        // The scripted child is already exited, but `events()` is not the exit
2235        // observer. This is the late-token/first-finisher boundary under test.
2236        token.cancel();
2237        let forward = async { while events.next().await.is_some() {} };
2238        let (_, finished) = tokio::join!(forward, run.finish());
2239
2240        assert!(
2241            matches!(
2242                finished.as_ref().map_err(Error::reason),
2243                Err(ErrorReason::Cancelled { program }) if program == "tool"
2244            ),
2245            "first finish observation must classify the fired token: {finished:?}"
2246        );
2247    }
2248
2249    #[tokio::test]
2250    async fn streamed_run_isolates_a_panicking_progress_callback() {
2251        use std::sync::atomic::{AtomicUsize, Ordering};
2252
2253        let runner = ScriptedRunner::new().on(
2254            ["tool", "network-op"],
2255            Reply::ok("out").with_stderr("progress"),
2256        );
2257        let calls = Arc::new(AtomicUsize::new(0));
2258        let seen = Arc::clone(&calls);
2259        let mut progress = move |_event| {
2260            seen.fetch_add(1, Ordering::SeqCst);
2261            panic!("broken UI callback");
2262        };
2263
2264        run_with_progress(
2265            &runner,
2266            &Command::new("tool").arg("network-op"),
2267            &mut progress,
2268        )
2269        .await
2270        .expect("the process outcome wins over a callback panic");
2271        assert_eq!(calls.load(Ordering::SeqCst), 1, "callback is disabled");
2272    }
2273
2274    #[test]
2275    fn rejects_empty_and_leading_dash() {
2276        assert!(reject_flag_like("git", "branch name", "-evil").is_err());
2277        assert!(reject_flag_like("git", "branch name", "").is_err());
2278        // Whitespace-only is as meaning-changing as empty — refuse it too.
2279        assert!(reject_flag_like("git", "branch name", "  ").is_err());
2280        assert!(reject_flag_like("git", "branch name", "\t").is_err());
2281        assert!(reject_flag_like("git", "branch name", "feature").is_ok());
2282        // Leading whitespace before a dash is still refused (the flag-check trims).
2283        assert!(reject_flag_like("git", "remote", " --upload-pack=evil").is_err());
2284        assert!(reject_flag_like("git", "remote", "\t-x").is_err());
2285        // An interior NUL is refused (can't go in argv; opaque OS error otherwise).
2286        assert!(reject_flag_like("git", "path", "a\0b").is_err());
2287        // A leading-whitespace non-flag value is still accepted (not flag-like).
2288        assert!(reject_flag_like("git", "branch name", "  feature").is_ok());
2289        // The error names the program and surfaces as a spawn-side refusal.
2290        let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
2291        assert!(matches!(err.reason(), ErrorReason::Spawn { program, .. } if program == "jj"));
2292    }
2293
2294    #[test]
2295    fn classifies_merge_conflict() {
2296        let on_stdout = Error::exit("git", 1, "CONFLICT (content): Merge conflict in a.rs", "");
2297        let on_stderr = Error::exit(
2298            "git",
2299            1,
2300            "",
2301            "Automatic merge failed; fix conflicts and then commit",
2302        );
2303        let unrelated = Error::exit("git", 128, "", "fatal: not a git repository");
2304        assert!(is_merge_conflict(&on_stdout));
2305        assert!(is_merge_conflict(&on_stderr));
2306        assert!(!is_merge_conflict(&unrelated));
2307        assert!(!is_nothing_to_commit(&on_stdout));
2308    }
2309
2310    #[test]
2311    fn classifies_nothing_to_commit_and_transient_fetch() {
2312        let nothing = Error::exit("git", 1, "nothing to commit, working tree clean", "");
2313        assert!(is_nothing_to_commit(&nothing));
2314
2315        let dns = Error::exit(
2316            "git",
2317            128,
2318            "",
2319            "fatal: unable to access 'https://x/': Could not resolve host: x",
2320        );
2321        assert!(is_transient_fetch_error(&dns));
2322        assert!(!is_transient_fetch_error(&nothing));
2323
2324        // A processkit timeout is deliberately NOT retried (R6): it already consumed
2325        // the caller's full deadline, so retrying would multiply the wall-clock by
2326        // FETCH_ATTEMPTS. The deadline is the patience budget; raise it, don't triple it.
2327        let timeout = Error::timeout("git", Duration::from_secs(10), "", "");
2328        assert!(!is_transient_fetch_error(&timeout));
2329    }
2330
2331    // R9: an io-level transient from the spawn (EINTR / EAGAIN / busy) is fetch-
2332    // retryable too, via processkit's `Error::is_transient()`.
2333    #[test]
2334    fn classifies_io_transient_as_fetch_retryable() {
2335        let interrupted =
2336            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::Interrupted));
2337        assert!(
2338            interrupted.is_transient(),
2339            "processkit treats Interrupted as a transient io error"
2340        );
2341        assert!(is_transient_fetch_error(&interrupted));
2342        // A non-transient io error (e.g. NotFound — the binary is missing) is not retried.
2343        let missing = Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound));
2344        assert!(!is_transient_fetch_error(&missing));
2345    }
2346
2347    // R2: regression for the processkit 0.9.1 untruncated-`ErrorReason::Exit` fix. A large
2348    // output (well past the old 4 KiB cap) with the decisive marker near the END must
2349    // still classify — proving the classifiers see the whole captured stream.
2350    #[test]
2351    fn classifies_on_large_output_past_the_old_4kib_cap() {
2352        let padding = "noise line that says nothing\n".repeat(500); // ~14 KiB
2353        let conflict = Error::exit(
2354            "git",
2355            1,
2356            format!("{padding}CONFLICT (content): Merge conflict in late.rs"),
2357            "",
2358        );
2359        assert!(
2360            is_merge_conflict(&conflict),
2361            "a conflict marker past 4 KiB must still classify"
2362        );
2363
2364        let transient = Error::exit(
2365            "git",
2366            128,
2367            "",
2368            format!("{padding}fatal: unable to access: Could not resolve host: x"),
2369        );
2370        assert!(is_transient_fetch_error(&transient));
2371    }
2372
2373    // processkit's `ErrorReason` is `#[non_exhaustive]` and grows variants over time
2374    // (`NotReady`/`Unsupported`/`CassetteMiss`/`NotFound`/`Signalled`/`Cancelled`/
2375    // `ResourceLimit`). Unfamiliar variants must fall through every classifier to
2376    // "no" — a not-ready or unsupported run is neither a conflict, nor a clean
2377    // tree, nor worth a fetch retry.
2378    #[test]
2379    fn unfamiliar_error_variants_are_not_classified() {
2380        let not_ready = Error::from(ErrorReason::NotReady {
2381            program: "git".into(),
2382            timeout: Duration::from_secs(5),
2383        });
2384        let unsupported = Error::from(ErrorReason::Unsupported {
2385            operation: "suspend".into(),
2386        });
2387        for err in [&not_ready, &unsupported] {
2388            assert!(!is_merge_conflict(err));
2389            assert!(!is_nothing_to_commit(err));
2390            assert!(!is_transient_fetch_error(err));
2391        }
2392    }
2393
2394    // `ErrorReason::Cancelled` (a client-level `default_cancel_on` killing an
2395    // in-flight run; always available since cancellation became core in processkit
2396    // 0.10) must fall through every classifier to "no" — a cancelled fetch was
2397    // *deliberately* stopped, so replaying it would fight the cancellation. (Behaviour
2398    // already held via the `#[non_exhaustive]` fall-through above; this pins it as a
2399    // first-class assertion.)
2400    #[test]
2401    fn cancelled_is_not_transient_or_otherwise_classified() {
2402        let cancelled = Error::from(ErrorReason::Cancelled {
2403            program: "git".into(),
2404        });
2405        assert!(!is_transient_fetch_error(&cancelled));
2406        assert!(!is_merge_conflict(&cancelled));
2407        assert!(!is_nothing_to_commit(&cancelled));
2408    }
2409
2410    // `ErrorReason::Signalled` (a process killed by a signal — e.g. an external SIGTERM/
2411    // SIGKILL, surfaced first-class since processkit 0.9.2 and carrying partial
2412    // `stdout`/`stderr` since 0.10) is *terminal*, not transient: a deliberate kill
2413    // should not be auto-retried, and a signal death is neither a merge conflict nor
2414    // a clean tree. processkit's own `is_transient()` agrees (false for `Signalled`),
2415    // so it falls through every classifier to "no" — pinned here, including the case
2416    // where the captured stderr happens to contain an otherwise-transient marker (a
2417    // killed fetch is still not ours to silently replay).
2418    #[test]
2419    fn signalled_is_terminal_not_transient() {
2420        let signalled = Error::signalled(
2421            "git",
2422            Some(15),
2423            "",
2424            "fatal: unable to access: Could not resolve host: x",
2425        );
2426        assert!(!signalled.is_transient());
2427        assert!(!is_transient_fetch_error(&signalled));
2428        assert!(!is_merge_conflict(&signalled));
2429        assert!(!is_nothing_to_commit(&signalled));
2430    }
2431
2432    fn exit(program: &str, code: i32, stderr: &str) -> Error {
2433        Error::exit(program, code, "", stderr)
2434    }
2435
2436    // `is_lock_contention` recognises ONLY the *whole-repo* / working-copy lock
2437    // failures (git index.lock, jj working-copy/op-heads lock) — the ones where the
2438    // command did nothing, so a retry is idempotent even on a mutation. Per-ref lock
2439    // failures and conflicts/timeouts are deliberately NOT classified (a multi-ref
2440    // op can fail a ref lock mid-way, where a retry would not be idempotent).
2441    #[test]
2442    fn classifies_lock_contention() {
2443        let lock_failures = [
2444            // git always names `index.lock` (locale-stable) in the lock-contention
2445            // message, even on a non-English runner where the surrounding prose is
2446            // translated.
2447            exit(
2448                "git",
2449                128,
2450                "fatal: Unable to create '/r/.git/index.lock': File exists.",
2451            ),
2452            // A German runner: the path fragment `index.lock` still matches.
2453            exit(
2454                "git",
2455                128,
2456                "fatal: Konnte '/r/.git/index.lock' nicht erstellen: Datei existiert bereits",
2457            ),
2458            // jj's *actual* wordings (verified against jj source) — note no "the".
2459            exit("jj", 1, "Error: Failed to lock working copy"),
2460            exit("jj", 1, "Error: Failed to lock operation heads store"),
2461        ];
2462        for e in &lock_failures {
2463            assert!(is_lock_contention(e), "should be lock contention: {e:?}");
2464            // A lock failure is NOT a transient *fetch* error — different class.
2465            assert!(!is_transient_fetch_error(e), "not a fetch error: {e:?}");
2466        }
2467        let not_locks = [
2468            exit("git", 1, "CONFLICT (content): Merge conflict in a.rs"),
2469            exit("git", 1, "error: pathspec 'x' did not match any file(s)"),
2470            exit("git", 128, "fatal: not a git repository"),
2471            // Per-ref locks are NOT classified — a multi-ref push/fetch can fail a
2472            // ref lock after earlier refs already moved (non-idempotent to replay).
2473            exit(
2474                "git",
2475                1,
2476                "error: cannot lock ref 'refs/heads/x': reference already exists",
2477            ),
2478            exit(
2479                "git",
2480                128,
2481                "Unable to create '/r/.git/packed-refs.lock': File exists.",
2482            ),
2483            // A per-ref lock for a branch literally named `index`: its
2484            // `…/refs/heads/index.lock` path contains the substring `index.lock`,
2485            // but the `refs/` mention correctly rules it out (not a whole-repo lock).
2486            exit(
2487                "git",
2488                128,
2489                "error: cannot lock ref 'refs/heads/index': Unable to create \
2490                 '/r/.git/refs/heads/index.lock': File exists.",
2491            ),
2492            Error::timeout("git", Duration::from_secs(1), "", ""),
2493        ];
2494        for e in &not_locks {
2495            assert!(
2496                !is_lock_contention(e),
2497                "should NOT be lock contention: {e:?}"
2498            );
2499        }
2500    }
2501
2502    #[test]
2503    fn classifies_invalid_input_from_the_guards() {
2504        // What `reject_flag_like` / the newtypes actually produce.
2505        let rejected = reject_flag_like("git", "reference", "-x").unwrap_err();
2506        assert!(
2507            is_invalid_input(&rejected),
2508            "guard rejection is invalid input"
2509        );
2510        assert!(is_invalid_input(
2511            &reject_flag_like("git", "x", "").unwrap_err()
2512        ));
2513
2514        // A real spawn failure (missing binary), a non-zero exit, and a timeout are
2515        // NOT invalid input — they're environment/usage failures, not a bad argument.
2516        let not_input = [
2517            Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound)),
2518            exit("git", 1, "fatal: not a git repository"),
2519            Error::timeout("git", Duration::from_secs(1), "", ""),
2520        ];
2521        for e in &not_input {
2522            assert!(!is_invalid_input(e), "should NOT be invalid input: {e:?}");
2523        }
2524    }
2525
2526    // R7 (T-085): `clone_dest_cleanable` must return `true` only when `dest`'s
2527    // absence/emptiness is actually proven, never on an unrelated `read_dir`
2528    // failure — that path used to be `Err(_) => true`, which could tell
2529    // `cleanup_failed_clone_dest` to `remove_dir_all` a pre-existing, non-empty
2530    // directory it merely failed to read (permission denied, transient I/O).
2531    #[test]
2532    fn clone_dest_cleanable_requires_proven_absence_or_emptiness() {
2533        use vcs_testkit::TempDir;
2534
2535        // Absent (NotFound) → cleanable. `TempDir::new` creates its own dir
2536        // eagerly, so the proven-absent case has to be an as-yet-unjoined
2537        // subpath of a live `TempDir`: the join alone creates nothing, so
2538        // `absent` stays absent, while the parent `TempDir` still guarantees
2539        // cleanup (mirrors `crates/core/tests/repo.rs`'s
2540        // `tmp.path().join("dest") // never created` pattern).
2541        let absent_parent = TempDir::new("clone-dest-absent");
2542        let absent = absent_parent.path().join("absent");
2543        assert!(clone_dest_cleanable(&absent));
2544
2545        // An existing, empty directory → cleanable.
2546        let empty = TempDir::new("clone-dest-empty");
2547        assert!(clone_dest_cleanable(empty.path()));
2548
2549        // An existing, non-empty directory → NOT cleanable.
2550        let nonempty = TempDir::new("clone-dest-nonempty");
2551        std::fs::write(nonempty.path().join("keep.txt"), b"user data").expect("write file");
2552        assert!(!clone_dest_cleanable(nonempty.path()));
2553
2554        // `dest` is a plain file, not a directory: `read_dir` fails with
2555        // `NotADirectory`/similar — NOT `NotFound` — so this must NOT be
2556        // classified as cleanable, even though `remove_dir_all` would in fact
2557        // fail harmlessly on a file. The point is the classification must not
2558        // rely on that coincidence.
2559        let file_parent = TempDir::new("clone-dest-file");
2560        let file = file_parent.path().join("not-a-dir");
2561        std::fs::write(&file, b"not a directory").expect("write file");
2562        let err = std::fs::read_dir(&file).expect_err("read_dir on a file fails");
2563        assert_ne!(
2564            err.kind(),
2565            std::io::ErrorKind::NotFound,
2566            "must be a genuine NotADirectory-style failure, not NotFound"
2567        );
2568        assert!(!clone_dest_cleanable(&file));
2569
2570        // And `cleanup_failed_clone_dest` must leave that file untouched when
2571        // called with `cleanable = false`.
2572        cleanup_failed_clone_dest(&file, false);
2573        assert!(
2574            file.is_file(),
2575            "cleanup must not touch a non-cleanable dest"
2576        );
2577    }
2578
2579    // Backoff is exponential off the base, capped at `max_backoff`, and zero when
2580    // there's no base (immediate retry).
2581    #[test]
2582    fn backoff_is_exponential_capped_and_zero_without_base() {
2583        let p = RetryPolicy::none()
2584            .attempts(6)
2585            .base_backoff(Duration::from_millis(10))
2586            .max_backoff(Duration::from_millis(80));
2587        assert_eq!(backoff_for(&p, 0), Duration::from_millis(10));
2588        assert_eq!(backoff_for(&p, 1), Duration::from_millis(20));
2589        assert_eq!(backoff_for(&p, 2), Duration::from_millis(40));
2590        assert_eq!(backoff_for(&p, 3), Duration::from_millis(80));
2591        assert_eq!(
2592            backoff_for(&p, 4),
2593            Duration::from_millis(80),
2594            "capped at max"
2595        );
2596        assert_eq!(
2597            backoff_for(&RetryPolicy::none(), 3),
2598            Duration::ZERO,
2599            "no base → no wait"
2600        );
2601    }
2602
2603    // Full jitter (used by `RetryPolicy::lock_contention`): every sampled backoff
2604    // stays within `[0, exponential cap]`, and successive samples de-correlate
2605    // (more than one distinct value) so retries don't thunder together. Pins the
2606    // jitter path, which the exponential test above deliberately turns off.
2607    #[test]
2608    fn jitter_stays_within_cap_and_decorrelates() {
2609        let p = RetryPolicy::none()
2610            .attempts(8)
2611            .base_backoff(Duration::from_millis(10))
2612            .max_backoff(Duration::from_millis(80))
2613            .with_jitter(true);
2614        // The cap at retry_index 3 is the full 80ms exponential value.
2615        let cap = Duration::from_millis(80);
2616        let mut seen = std::collections::HashSet::new();
2617        for _ in 0..1000 {
2618            let d = backoff_for(&p, 3);
2619            assert!(
2620                d <= cap,
2621                "jittered backoff {d:?} must stay within the cap {cap:?}"
2622            );
2623            seen.insert(d.as_nanos());
2624        }
2625        assert!(
2626            seen.len() > 1,
2627            "full jitter must produce a spread of delays, not a constant"
2628        );
2629        // A zero base still short-circuits to zero even with jitter on.
2630        assert_eq!(
2631            backoff_for(&RetryPolicy::none().with_jitter(true), 2),
2632            Duration::ZERO
2633        );
2634    }
2635
2636    // The executor: retries while the predicate matches and attempts remain, returns
2637    // the first Ok, doesn't retry a non-matching error, and exhausts to the last Err.
2638    #[tokio::test]
2639    async fn retry_async_retries_then_succeeds_and_respects_the_predicate() {
2640        use std::sync::atomic::{AtomicU32, Ordering};
2641        // Zero backoff → no sleep, deterministic & fast.
2642        let policy = RetryPolicy::none().attempts(4);
2643        let lock = || {
2644            exit(
2645                "git",
2646                128,
2647                "Unable to create '/r/.git/index.lock': File exists.",
2648            )
2649        };
2650
2651        // Fails twice with a lock error, then succeeds — retried to success.
2652        let calls = AtomicU32::new(0);
2653        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
2654            let n = calls.fetch_add(1, Ordering::SeqCst);
2655            let lock = lock();
2656            async move { if n < 2 { Err(lock) } else { Ok(n) } }
2657        })
2658        .await;
2659        assert_eq!(out.unwrap(), 2);
2660        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 try + 2 retries");
2661
2662        // A non-lock error is returned immediately (not retried).
2663        let calls = AtomicU32::new(0);
2664        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
2665            calls.fetch_add(1, Ordering::SeqCst);
2666            async { Err(exit("git", 1, "real, deterministic failure")) }
2667        })
2668        .await;
2669        assert!(out.is_err());
2670        assert_eq!(
2671            calls.load(Ordering::SeqCst),
2672            1,
2673            "non-retryable → single attempt"
2674        );
2675
2676        // Persistent lock contention exhausts the attempt budget.
2677        let calls = AtomicU32::new(0);
2678        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
2679            calls.fetch_add(1, Ordering::SeqCst);
2680            async { Err(exit("git", 128, "index.lock': File exists")) }
2681        })
2682        .await;
2683        assert!(out.is_err());
2684        assert_eq!(calls.load(Ordering::SeqCst), 4, "all attempts used");
2685    }
2686
2687    // A persistent lock error always retryable, for the cancellation tests below.
2688    fn lock_err() -> Error {
2689        exit(
2690            "git",
2691            128,
2692            "Unable to create '/r/.git/index.lock': File exists.",
2693        )
2694    }
2695
2696    /// The [`ErrorReason`] behind a failed result, so the assertions below can match
2697    /// a variant on the now-opaque [`Error`] wrapper without unwrapping by hand.
2698    fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
2699        out.as_ref().err().map(Error::reason)
2700    }
2701
2702    // Cancellation scenario 1 — the token is **already fired** when the backoff is
2703    // about to begin: `retry_async` must not sleep out the (long) delay, and must
2704    // abort with a structured `Cancelled` after the single attempt that already ran,
2705    // launching no second one. On a paused clock the virtual time must not advance —
2706    // proving the full backoff was skipped, not merely fast.
2707    #[tokio::test(start_paused = true)]
2708    async fn cancel_before_backoff_aborts_without_waiting_or_retrying() {
2709        use std::sync::atomic::{AtomicU32, Ordering};
2710        let token = CancellationToken::new();
2711        token.cancel(); // already cancelled before we even start
2712        let policy = RetryPolicy::none()
2713            .attempts(5)
2714            .base_backoff(Duration::from_secs(3600)); // huge — must never be waited
2715        let calls = AtomicU32::new(0);
2716
2717        let start = tokio::time::Instant::now();
2718        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
2719            calls.fetch_add(1, Ordering::SeqCst);
2720            async { Err(lock_err()) }
2721        })
2722        .await;
2723
2724        assert!(
2725            matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
2726            "a fired token aborts with a program-named Cancelled, got {out:?}"
2727        );
2728        assert_eq!(
2729            calls.load(Ordering::SeqCst),
2730            1,
2731            "one attempt ran; the cancel launched no retry"
2732        );
2733        assert_eq!(
2734            start.elapsed(),
2735            Duration::ZERO,
2736            "the backoff was cut short — no virtual time elapsed"
2737        );
2738    }
2739
2740    // Cancellation scenario 2 — the token fires **while the backoff sleep is
2741    // parked**. With a paused clock the (long) sleep cannot elapse on its own, so a
2742    // spawned task cancelling the token is what resolves the wait: the retry must
2743    // wake early and return `Cancelled` without a second attempt.
2744    #[tokio::test(start_paused = true)]
2745    async fn cancel_during_backoff_wakes_early_and_does_not_retry() {
2746        use std::sync::atomic::{AtomicU32, Ordering};
2747        let token = CancellationToken::new();
2748        let policy = RetryPolicy::none()
2749            .attempts(5)
2750            .base_backoff(Duration::from_secs(3600)); // never elapses under paused time
2751        let calls = AtomicU32::new(0);
2752
2753        let start = tokio::time::Instant::now();
2754        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
2755            let n = calls.fetch_add(1, Ordering::SeqCst);
2756            let token = token.clone();
2757            async move {
2758                // On the first failure, schedule the cancel to land while we are
2759                // parked in the backoff sleep (the sleep can't fire under paused time,
2760                // so this is what unblocks the wait).
2761                if n == 0 {
2762                    tokio::spawn(async move { token.cancel() });
2763                }
2764                Err(lock_err())
2765            }
2766        })
2767        .await;
2768
2769        assert!(
2770            matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
2771            "a cancel during the sleep aborts with Cancelled, got {out:?}"
2772        );
2773        assert_eq!(
2774            calls.load(Ordering::SeqCst),
2775            1,
2776            "cancel woke the sleep early — no second attempt"
2777        );
2778        assert_eq!(
2779            start.elapsed(),
2780            Duration::ZERO,
2781            "woke on the cancel, not after the 1 h delay"
2782        );
2783    }
2784
2785    // Cancellation scenario 3 — the token fires such that it is observed **right
2786    // before the next attempt** would launch. With a zero backoff there is no sleep
2787    // to interrupt, so the op cancels the token as it fails; the guard between the
2788    // (no-op) backoff and the next attempt must still abort with `Cancelled` rather
2789    // than spinning up attempt #2.
2790    #[tokio::test(start_paused = true)]
2791    async fn cancel_right_before_next_attempt_aborts() {
2792        use std::sync::atomic::{AtomicU32, Ordering};
2793        let token = CancellationToken::new();
2794        let policy = RetryPolicy::none().attempts(5); // zero backoff → no sleep
2795        let calls = AtomicU32::new(0);
2796
2797        let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
2798            let n = calls.fetch_add(1, Ordering::SeqCst);
2799            let token = token.clone();
2800            async move {
2801                // Cancel as the first attempt fails: the post-backoff guard must catch
2802                // it before launching the next attempt.
2803                if n == 0 {
2804                    token.cancel();
2805                }
2806                Err(lock_err())
2807            }
2808        })
2809        .await;
2810
2811        assert!(
2812            matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
2813            "a cancel observed before the next attempt aborts with Cancelled, got {out:?}"
2814        );
2815        assert_eq!(
2816            calls.load(Ordering::SeqCst),
2817            1,
2818            "the guard stopped attempt #2 from launching"
2819        );
2820    }
2821
2822    // Without a token the backoff is unchanged: a persistent lock error still
2823    // exhausts every attempt (no early exit, `None` path preserved).
2824    #[tokio::test]
2825    async fn no_token_backoff_is_unchanged() {
2826        use std::sync::atomic::{AtomicU32, Ordering};
2827        let policy = RetryPolicy::none().attempts(3); // zero backoff, fast
2828        let calls = AtomicU32::new(0);
2829        let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
2830            calls.fetch_add(1, Ordering::SeqCst);
2831            async { Err(lock_err()) }
2832        })
2833        .await;
2834        assert!(
2835            matches!(err_reason(&out), Some(ErrorReason::Exit { .. })),
2836            "last error is the lock exit, not Cancelled"
2837        );
2838        assert_eq!(
2839            calls.load(Ordering::SeqCst),
2840            3,
2841            "all attempts used with no token"
2842        );
2843    }
2844
2845    // `resolve_credential` returns `None` until a provider is attached, then the
2846    // provider's credential. (No process is spawned, so the real runner is fine.)
2847    #[tokio::test]
2848    async fn retrying_client_resolves_credential_opt_in() {
2849        let client = ManagedClient::new("git");
2850        assert!(!client.has_credentials());
2851        assert!(
2852            client
2853                .resolve_credential(CredentialService::Git, None)
2854                .await
2855                .unwrap()
2856                .is_none(),
2857            "no provider → ambient (None)"
2858        );
2859
2860        let client = client.with_credentials(Arc::new(StaticCredential::token("t0k")));
2861        assert!(client.has_credentials());
2862        let got = client
2863            .resolve_credential(CredentialService::Git, None)
2864            .await
2865            .unwrap()
2866            .expect("provider yields a credential");
2867        assert_eq!(got.secret().expose(), "t0k");
2868    }
2869
2870    // An empty (or whitespace-only) secret is treated as `None` (ambient):
2871    // injecting an empty token would override the ambient login with nothing
2872    // instead of deferring to it. Mirrors `EnvToken`'s whitespace-only ⇒ unset rule.
2873    #[tokio::test]
2874    async fn resolve_credential_treats_empty_secret_as_ambient() {
2875        // Service-agnostic: both the forge (token-env) and git (helper) paths route
2876        // through this chokepoint, so a blank secret is ambient for either.
2877        for blank in ["", "   ", "\t"] {
2878            let client = ManagedClient::new("git")
2879                .with_credentials(Arc::new(StaticCredential::token(blank)));
2880            for service in [CredentialService::GitHub, CredentialService::Git] {
2881                assert!(
2882                    client
2883                        .resolve_credential(service, None)
2884                        .await
2885                        .unwrap()
2886                        .is_none(),
2887                    "blank secret {blank:?} → ambient (None) for {service:?}"
2888                );
2889            }
2890        }
2891    }
2892
2893    #[tokio::test]
2894    async fn resolve_credential_rejects_crlf_secret_before_blank_fallback() {
2895        for bad_secret in ["\r", "\n", "\t\r ", " \n\t"] {
2896            for service in [CredentialService::GitHub, CredentialService::Git] {
2897                let client = ManagedClient::new("git")
2898                    .with_credentials(Arc::new(UnvalidatedProvider(Credential::token(bad_secret))));
2899                let error = client
2900                    .resolve_credential(service, None)
2901                    .await
2902                    .expect_err("CR/LF secret must not become ambient auth");
2903                assert!(
2904                    is_invalid_input(&error),
2905                    "credential rejection must be InvalidInput: {error:?}"
2906                );
2907                assert!(error.to_string().contains("secret"));
2908            }
2909        }
2910    }
2911
2912    #[tokio::test]
2913    async fn resolve_credential_rejects_malformed_username_before_blank_fallback() {
2914        for blank_secret in ["", "   ", "\t"] {
2915            for bad_username in ["alice\r", "alice\n", "alice\t\r ", "alice \n\t"] {
2916                let client = ManagedClient::new("git").with_credentials(Arc::new(
2917                    UnvalidatedProvider(Credential::userpass(bad_username, blank_secret)),
2918                ));
2919                let error = client
2920                    .resolve_credential(CredentialService::Git, None)
2921                    .await
2922                    .expect_err("malformed username must not become ambient auth");
2923                assert!(
2924                    is_invalid_input(&error),
2925                    "credential rejection must be InvalidInput: {error:?}"
2926                );
2927                assert!(error.to_string().contains("username"));
2928            }
2929        }
2930    }
2931
2932    // The resolved request carries the operation's host, so a HOST-KEYED provider
2933    // returns the secret for exactly that host — and `Ok(None)` (deferring to
2934    // ambient) for a host it does not place or an absent one, never a wrong-host
2935    // secret. This is the seam `prepare` (forge token-env) and git's
2936    // `remote_credentials` both feed the target host into. (T-045)
2937    #[tokio::test]
2938    async fn resolve_credential_routes_on_request_host() {
2939        let provider = provider_fn(|r: &CredentialRequest<'_>| {
2940            Ok(match r.host {
2941                Some("github.com") => Some(Credential::token("saas")),
2942                Some("ghe.example.com") => Some(Credential::token("ent")),
2943                // An unknown or absent host defers to ambient rather than a default.
2944                _ => None,
2945            })
2946        });
2947        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
2948        let resolve =
2949            |host: Option<&'static str>| client.resolve_credential(CredentialService::GitHub, host);
2950
2951        assert_eq!(
2952            resolve(Some("github.com"))
2953                .await
2954                .unwrap()
2955                .unwrap()
2956                .secret()
2957                .expose(),
2958            "saas"
2959        );
2960        assert_eq!(
2961            resolve(Some("ghe.example.com"))
2962                .await
2963                .unwrap()
2964                .unwrap()
2965                .secret()
2966                .expose(),
2967            "ent"
2968        );
2969        assert!(
2970            resolve(Some("other.example")).await.unwrap().is_none(),
2971            "a host the provider doesn't place → ambient (None), not a wrong secret"
2972        );
2973        assert!(
2974            resolve(None).await.unwrap().is_none(),
2975            "an absent host → ambient (None)"
2976        );
2977    }
2978
2979    // Fail-closed: a provider `Err` propagates out of `resolve_credential` (and so
2980    // aborts the command in `prepare` / `remote_credentials`) for any host — it is
2981    // never swallowed into a silent ambient fallback. (T-045 fallback policy)
2982    #[tokio::test]
2983    async fn resolve_credential_propagates_provider_error_fail_closed() {
2984        let provider = provider_fn(|_r: &CredentialRequest<'_>| {
2985            Err(Error::spawn(
2986                "vault",
2987                std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault unreachable"),
2988            ))
2989        });
2990        let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
2991        for host in [Some("github.com"), None] {
2992            assert!(
2993                client
2994                    .resolve_credential(CredentialService::GitHub, host)
2995                    .await
2996                    .is_err(),
2997                "provider error must propagate (fail-closed), host={host:?}"
2998            );
2999        }
3000    }
3001
3002    // The default budget is unlimited — no ceiling, so a client that never sets
3003    // one keeps its pre-budget (unbounded) capture behaviour, and both policy
3004    // projections are `None` (leave the command's own buffer untouched).
3005    #[test]
3006    fn output_budget_default_is_unlimited() {
3007        let b = OutputBudget::default();
3008        assert!(b.is_unlimited());
3009        assert_eq!(b, OutputBudget::unlimited());
3010        assert_eq!(b.max_bytes(), None);
3011        assert_eq!(b.max_lines(), None);
3012        assert!(b.content_policy().is_none());
3013        assert!(b.diagnostic_policy().is_none());
3014    }
3015
3016    // A byte cap projects onto a FAIL-LOUD content policy (errors past the cap,
3017    // never truncates) and a DROP-OLDEST diagnostic policy (bounded tail, never
3018    // errors) — the two shapes one budget drives.
3019    #[test]
3020    fn output_budget_bytes_projects_to_both_policies() {
3021        let b = OutputBudget::bytes(4096);
3022        assert!(!b.is_unlimited());
3023        assert_eq!(b.max_bytes(), Some(4096));
3024
3025        let content = b
3026            .content_policy()
3027            .expect("a byte budget yields a content policy");
3028        assert_eq!(
3029            content.overflow,
3030            OverflowMode::Error,
3031            "content is fail-loud"
3032        );
3033        assert_eq!(content.max_bytes, Some(4096));
3034        // No line cap set, so the fail-loud ceiling rests entirely on the byte cap
3035        // (which is exactly what the raw-stdout content path enforces).
3036        assert_eq!(content.max_lines, None);
3037
3038        let diag = b
3039            .diagnostic_policy()
3040            .expect("a byte budget yields a diagnostic policy");
3041        assert_eq!(
3042            diag.overflow,
3043            OverflowMode::DropOldest,
3044            "diagnostics keep the tail, never OutputTooLarge"
3045        );
3046        assert_eq!(diag.max_bytes, Some(4096));
3047    }
3048
3049    // A line ceiling composes with the byte cap on both projections.
3050    #[test]
3051    fn output_budget_with_max_lines_composes() {
3052        let b = OutputBudget::bytes(4096).with_max_lines(200);
3053        assert_eq!(b.max_lines(), Some(200));
3054        let content = b.content_policy().unwrap();
3055        assert_eq!(content.max_lines, Some(200));
3056        assert_eq!(content.max_bytes, Some(4096));
3057        assert_eq!(content.overflow, OverflowMode::Error);
3058        let diag = b.diagnostic_policy().unwrap();
3059        assert_eq!(diag.max_lines, Some(200));
3060        assert_eq!(diag.max_bytes, Some(4096));
3061        assert_eq!(diag.overflow, OverflowMode::DropOldest);
3062    }
3063
3064    // The client-level default budget round-trips through the builder/getter, and
3065    // `budget_diagnostics` applies (or, when unlimited, leaves) a command's buffer.
3066    #[test]
3067    fn managed_client_default_output_budget_round_trips() {
3068        let client = ManagedClient::new("git");
3069        assert!(client.output_budget().is_unlimited());
3070        let client = client.default_output_budget(OutputBudget::bytes(1 << 20));
3071        assert_eq!(client.output_budget(), OutputBudget::bytes(1 << 20));
3072    }
3073
3074    // T-130 (processkit 3.0 raw-byte-accounting audit): pin the *unit* of the byte
3075    // ceiling on the raw-stdout content path — the path every content verb reads
3076    // through. There is no line framing here, so the cap counts pipe bytes
3077    // verbatim (terminators included, nothing decoded) and fires strictly PAST the
3078    // cap. processkit counted exactly this on the raw path before 3.0 as well,
3079    // which is precisely why its switch to raw-pipe-byte accounting did NOT move
3080    // this ceiling; every pre-existing over-budget test across the wrapper crates
3081    // uses a fixture ~2x its cap and so could not have detected a shift either
3082    // way. This one is exact on both sides of the boundary.
3083    #[tokio::test]
3084    async fn content_budget_counts_raw_stdout_bytes_verbatim() {
3085        // 8 raw bytes: 6 of line content plus the two LF terminators.
3086        let out = "abc\ndef\n";
3087        assert_eq!(out.len(), 8);
3088        let client = ManagedClient::with_runner(
3089            "tool",
3090            ScriptedRunner::new().on(["tool", "read"], Reply::ok(out)),
3091        );
3092
3093        // Sitting exactly on the cap is within budget, and the bytes come back
3094        // verbatim (the trailing LF is content here, not a stripped terminator).
3095        let got = client
3096            .run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(8))
3097            .await
3098            .expect("stdout exactly on the cap is within budget");
3099        assert_eq!(got, out);
3100
3101        // A cap of 6 — the decoded line-CONTENT total, i.e. what the pre-3.0 line
3102        // accounting would have measured — is not the unit on this path: the raw
3103        // 8 bytes trip it, and the reported total is the raw one.
3104        match client
3105            .run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(6))
3106            .await
3107            .map_err(Error::into_reason)
3108        {
3109            Err(ErrorReason::OutputTooLarge {
3110                max_bytes,
3111                total_bytes,
3112                max_lines,
3113                total_lines,
3114                ..
3115            }) => {
3116                assert_eq!(max_bytes, Some(6), "the allowed ceiling");
3117                assert_eq!(total_bytes, 8, "raw pipe bytes, both terminators charged");
3118                // Raw stdout has no lines, so only the byte ceiling is reported.
3119                assert_eq!(max_lines, None);
3120                assert_eq!(total_lines, 0);
3121            }
3122            other => panic!("expected OutputTooLarge, got {other:?}"),
3123        }
3124    }
3125
3126    // T-130: the same content ceiling also rides the command's line-pumped STDERR,
3127    // and *that* stream is where processkit 3.0's accounting change is observable:
3128    // the fail-loud ceiling now charges every line terminator, so a stderr flood
3129    // whose decoded content would still have fitted the cap trips it. This is a
3130    // real behavioural change this workspace's budget inherited from the 3.0 bump
3131    // (T-129) that no test covered — the audit's genuinely uncovered gap.
3132    #[tokio::test]
3133    async fn content_budget_charges_stderr_line_terminators() {
3134        // 18 raw stderr bytes: 16 of line content plus the two LF terminators.
3135        let warnings = "warn one\nwarn two\n";
3136        assert_eq!(warnings.len(), 18);
3137        let client = ManagedClient::with_runner(
3138            "tool",
3139            ScriptedRunner::new().on(["tool", "read"], Reply::ok("ok\n").with_stderr(warnings)),
3140        );
3141
3142        // 17 sits above the 16 decoded content bytes and below the 18 raw ones, so
3143        // it is exactly the cap the old accounting would have let through.
3144        match client
3145            .run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(17))
3146            .await
3147            .map_err(Error::into_reason)
3148        {
3149            Err(ErrorReason::OutputTooLarge {
3150                max_bytes,
3151                total_bytes,
3152                total_lines,
3153                ..
3154            }) => {
3155                assert_eq!(max_bytes, Some(17), "the allowed ceiling");
3156                assert_eq!(total_bytes, 18, "raw stderr bytes, terminators charged");
3157                assert_eq!(total_lines, 2, "every line is counted, dropped or not");
3158            }
3159            other => panic!("expected OutputTooLarge from stderr, got {other:?}"),
3160        }
3161
3162        // One byte more of budget accepts the same stderr (the ceiling fires
3163        // strictly past the cap) and hands back stdout untouched.
3164        let got = client
3165            .run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(18))
3166            .await
3167            .expect("stderr exactly on the cap is within budget");
3168        assert_eq!(got, "ok\n");
3169    }
3170
3171    // --- T-148: the streamed run's own retention ceiling ------------------
3172    //
3173    // `run_with_progress` keeps its own copy of every streamed line so a rejected
3174    // exit can be promoted to a structured error. A command's `OutputBufferPolicy`
3175    // does not bound an event stream, so that copy is where a streaming
3176    // `clone`/`fetch --progress` used to grow without limit. These tests pin the
3177    // ceiling that now bounds it: what it counts, that it drops oldest-first
3178    // rather than failing loud, and that the default stays unbounded.
3179
3180    // Drive one scripted streamed run under `budget`; hand back the error the
3181    // non-zero exit is promoted to — the only place the locally retained
3182    // stdout/stderr is observable.
3183    async fn streamed_failure(reply: Reply, budget: OutputBudget) -> Error {
3184        let runner = ScriptedRunner::new().on(["tool", "network-op"], reply);
3185        let mut progress = |_event: ProcessEvent| {};
3186        run_with_progress_within(
3187            &runner,
3188            &Command::new("tool").arg("network-op"),
3189            &mut progress,
3190            budget,
3191        )
3192        .await
3193        .expect_err("the scripted non-zero exit stays an error")
3194    }
3195
3196    // The retained streams `ensure_success` carried into the structured error.
3197    fn exit_streams(err: &Error) -> (&str, &str) {
3198        match err.reason() {
3199            ErrorReason::Exit { stdout, stderr, .. } => (stdout, stderr),
3200            other => panic!("expected a structured Exit, got {other:?}"),
3201        }
3202    }
3203
3204    // The unit question, pinned exactly (the [[K-073]] `ScriptedRunner` pattern:
3205    // the canned output flows through the REAL pump, so a one-byte accounting
3206    // shift is observable). Three lines admit three different answers to "how big
3207    // is this?", and the two caps below straddle ours:
3208    //
3209    //   12 — the raw pipe bytes, every terminator charged. What processkit 3.0's
3210    //        fail-loud CONTENT ceiling counts on a line-pumped stream ([[K-070]]).
3211    //    9 — the decoded line content alone, no terminator charged. What
3212    //        processkit's own drop-oldest retention counts.
3213    //   11 — the string this function retains: the content plus the one `\n` it
3214    //        puts between each retained PAIR. **This** is the unit here.
3215    //
3216    // A cap of 11 would already be over budget under the raw-byte unit, and a cap
3217    // of 10 would still be within budget under the content-only one — so the pair
3218    // of assertions admits no other reading.
3219    #[tokio::test]
3220    async fn streamed_tail_counts_the_bytes_it_retains() {
3221        let stream = "aaa\nbbb\nccc\n";
3222        assert_eq!(stream.len(), 12, "raw pipe bytes");
3223
3224        let err = streamed_failure(Reply::fail(1, stream), OutputBudget::bytes(11)).await;
3225        assert_eq!(
3226            exit_streams(&err).1,
3227            "aaa\nbbb\nccc",
3228            "11 bytes is the whole retained tail — on the cap, not past it"
3229        );
3230
3231        let err = streamed_failure(Reply::fail(1, stream), OutputBudget::bytes(10)).await;
3232        assert_eq!(
3233            exit_streams(&err).1,
3234            "bbb\nccc",
3235            "one byte less drops exactly the oldest line"
3236        );
3237    }
3238
3239    // The point of the ceiling: a flood far past the cap is bounded on BOTH
3240    // streams — each carrying the budget independently, as a captured verb's two
3241    // streams do ([[K-072]]) — while the tail, where a CLI's fatal line sits,
3242    // survives intact. The non-zero exit is still promoted to a structured error
3243    // over that truncated text, and it still classifies.
3244    #[tokio::test]
3245    async fn streamed_budget_bounds_both_streams_and_keeps_the_tail_classifiable() {
3246        const CAP: usize = 128;
3247        let mut stdout = String::new();
3248        let mut stderr = String::new();
3249        for i in 0..500 {
3250            stdout.push_str(&format!("Receiving objects:  {i}% (0/0)\n"));
3251            stderr.push_str(&format!("remote: Counting objects: {i}\n"));
3252        }
3253        stdout.push_str("fatal: the remote end hung up unexpectedly\n");
3254        stderr.push_str("fatal: early EOF\n");
3255        assert!(stdout.len() > 20 * CAP && stderr.len() > 20 * CAP);
3256
3257        let err = streamed_failure(
3258            Reply::fail(128, stderr).with_stdout(stdout),
3259            OutputBudget::bytes(CAP),
3260        )
3261        .await;
3262        let (out, err_text) = exit_streams(&err);
3263        assert!(out.len() <= CAP, "stdout bounded: {} bytes", out.len());
3264        assert!(
3265            err_text.len() <= CAP,
3266            "stderr bounded independently: {} bytes",
3267            err_text.len()
3268        );
3269        assert!(!out.contains("Receiving objects:  0%"), "oldest dropped");
3270        assert!(
3271            !err_text.contains("Counting objects: 0\n"),
3272            "oldest dropped"
3273        );
3274        assert!(out.ends_with("fatal: the remote end hung up unexpectedly"));
3275        assert!(err_text.ends_with("fatal: early EOF"));
3276        assert!(
3277            matches!(err.reason(), ErrorReason::Exit { code: 128, .. }),
3278            "a truncated capture is still promoted to a structured exit error"
3279        );
3280        assert!(
3281            is_transient_fetch_error(&err),
3282            "the retained tail still carries the transient marker"
3283        );
3284    }
3285
3286    // The same for the other stderr-text classifier, and with a filler that a
3287    // real repository could produce: `is_lock_contention` reads the tail it is
3288    // left with, not the flood that preceded it.
3289    #[tokio::test]
3290    async fn streamed_budget_keeps_lock_contention_classifiable() {
3291        let mut stderr = String::new();
3292        for i in 0..200 {
3293            stderr.push_str(&format!("warning: unable to rmdir stale-{i}\n"));
3294        }
3295        stderr.push_str("fatal: Unable to create '/w/.git/index.lock': File exists.\n");
3296
3297        let err = streamed_failure(Reply::fail(128, stderr), OutputBudget::bytes(96)).await;
3298        let retained = exit_streams(&err).1;
3299        assert!(retained.len() <= 96, "bounded: {} bytes", retained.len());
3300        assert!(is_lock_contention(&err), "retained tail: {retained:?}");
3301    }
3302
3303    // Drop-oldest, never fail-loud (the `diagnostic_policy` half of the contract,
3304    // not the `content_policy` one): passing the ceiling truncates what is kept
3305    // and leaves the run's real outcome alone — a successful stream stays `Ok`
3306    // instead of becoming `OutputTooLarge`. Delivery is untouched too: the
3307    // callback still observes every line, only *retention* is bounded.
3308    #[tokio::test]
3309    async fn streamed_budget_truncates_without_failing_the_run() {
3310        use std::sync::atomic::{AtomicUsize, Ordering};
3311
3312        let mut stream = String::new();
3313        for i in 0..400 {
3314            stream.push_str(&format!("Receiving objects:  {i}% (0/0)\n"));
3315        }
3316        let runner = ScriptedRunner::new().on(
3317            ["tool", "network-op"],
3318            Reply::ok(stream.clone()).with_stderr(stream),
3319        );
3320        let delivered = Arc::new(AtomicUsize::new(0));
3321        let seen = Arc::clone(&delivered);
3322        let mut progress = move |event: ProcessEvent| {
3323            if matches!(event, ProcessEvent::Stdout(_)) {
3324                seen.fetch_add(1, Ordering::SeqCst);
3325            }
3326        };
3327
3328        run_with_progress_within(
3329            &runner,
3330            &Command::new("tool").arg("network-op"),
3331            &mut progress,
3332            OutputBudget::bytes(64),
3333        )
3334        .await
3335        .expect("a bounded stream is truncated, not failed");
3336        drop(progress);
3337        assert_eq!(
3338            delivered.load(Ordering::SeqCst),
3339            400,
3340            "the ceiling bounds retention, not what the callback is shown"
3341        );
3342    }
3343
3344    // An over-cap single line keeps its own tail (cut on a UTF-8 char boundary)
3345    // rather than being dropped whole the way processkit's drop-mode buffer drops
3346    // it: under the default `\n` framing, `--progress`'s carriage-return output is
3347    // ONE ever-growing line, so dropping it whole would retain nothing at all of
3348    // the stream this ceiling exists to bound. The cut here lands mid-`☃` (a
3349    // 3-byte char straddling the boundary), so the walk to the next boundary is
3350    // what keeps the slice valid — and shorter than the cap, never longer.
3351    #[tokio::test]
3352    async fn streamed_tail_cuts_an_over_cap_line_on_a_char_boundary() {
3353        let line = format!("{}☃fatal: early EOF", "a".repeat(10));
3354        assert_eq!(line.len(), 29);
3355        // 29 - 18 = 11 is a continuation byte of the snowman (bytes 10..13).
3356        assert!(!line.is_char_boundary(11));
3357
3358        let err = streamed_failure(Reply::fail(128, line), OutputBudget::bytes(18)).await;
3359        let retained = exit_streams(&err).1;
3360        assert_eq!(
3361            retained, "fatal: early EOF",
3362            "the tail survives; the straddling char is dropped whole"
3363        );
3364        assert!(retained.len() <= 18);
3365        assert!(is_transient_fetch_error(&err));
3366    }
3367
3368    // The line ceiling composes, dropping oldest-first like the byte one.
3369    #[tokio::test]
3370    async fn streamed_tail_honours_a_line_ceiling() {
3371        let err = streamed_failure(
3372            Reply::fail(1, "one\ntwo\nthree\nfour\nfive\n"),
3373            OutputBudget::bytes(1024).with_max_lines(2),
3374        )
3375        .await;
3376        assert_eq!(exit_streams(&err).1, "four\nfive");
3377    }
3378
3379    // The regression anchor for the default path: with no budget — the free
3380    // function, or a client that never set one — retention is unbounded, exactly
3381    // as it was before the ceiling existed. Every line of both streams is kept,
3382    // joined by `\n` with no trailing terminator.
3383    #[tokio::test]
3384    async fn streamed_run_without_a_budget_retains_everything() {
3385        let lines: Vec<String> = (0..300)
3386            .map(|i| format!("remote: Counting objects: {i}"))
3387            .collect();
3388        let expected = lines.join("\n");
3389        let stream = format!("{expected}\n");
3390        assert!(stream.len() > 8000);
3391
3392        let runner = ScriptedRunner::new().on(
3393            ["tool", "network-op"],
3394            Reply::fail(1, stream.clone()).with_stdout(stream),
3395        );
3396        let mut progress = |_event: ProcessEvent| {};
3397        let err = run_with_progress(
3398            &runner,
3399            &Command::new("tool").arg("network-op"),
3400            &mut progress,
3401        )
3402        .await
3403        .expect_err("the scripted non-zero exit stays an error");
3404        assert_eq!(exit_streams(&err), (expected.as_str(), expected.as_str()));
3405
3406        // The client path defaults to the same unlimited budget.
3407        let client = ManagedClient::with_runner("tool", runner);
3408        assert!(client.output_budget().is_unlimited());
3409        let err = client
3410            .run_with_progress(client.command(["network-op"]), &mut progress)
3411            .await
3412            .expect_err("the scripted non-zero exit stays an error");
3413        assert_eq!(exit_streams(&err), (expected.as_str(), expected.as_str()));
3414    }
3415
3416    // The client plumbs its own default budget into the streamed path — the same
3417    // knob that bounds a captured `clone`/`fetch`'s diagnostics — and a single
3418    // call can override it in either direction, like `run_untrimmed_within`.
3419    #[tokio::test]
3420    async fn managed_client_streams_within_its_budget() {
3421        let runner =
3422            ScriptedRunner::new().on(["tool", "network-op"], Reply::fail(1, "aaa\nbbb\nccc\n"));
3423        let client = ManagedClient::with_runner("tool", runner)
3424            .default_output_budget(OutputBudget::bytes(7));
3425        let mut progress = |_event: ProcessEvent| {};
3426
3427        let err = client
3428            .run_with_progress(client.command(["network-op"]), &mut progress)
3429            .await
3430            .expect_err("the scripted non-zero exit stays an error");
3431        assert_eq!(
3432            exit_streams(&err).1,
3433            "bbb\nccc",
3434            "the client default applies"
3435        );
3436
3437        let err = client
3438            .run_with_progress_within(
3439                client.command(["network-op"]),
3440                &mut progress,
3441                OutputBudget::bytes(3),
3442            )
3443            .await
3444            .expect_err("the scripted non-zero exit stays an error");
3445        assert_eq!(exit_streams(&err).1, "ccc", "a tighter per-call cap wins");
3446
3447        let err = client
3448            .run_with_progress_within(
3449                client.command(["network-op"]),
3450                &mut progress,
3451                OutputBudget::unlimited(),
3452            )
3453            .await
3454            .expect_err("the scripted non-zero exit stays an error");
3455        assert_eq!(
3456            exit_streams(&err).1,
3457            "aaa\nbbb\nccc",
3458            "and so does lifting the cap for one call"
3459        );
3460    }
3461
3462    // The separator bookkeeping behind that ceiling is the one place a
3463    // drop-oldest tail goes subtly wrong: an off-by-one surfaces only on a
3464    // particular sequence of drops, and the exact-boundary test above pins one
3465    // such sequence. This pins the **invariant** over arbitrary ones, including
3466    // the shapes a fixture rarely reaches — empty lines (whose separator the
3467    // renderer elides, so the running estimate over-counts and must stay an
3468    // upper bound), lines far past the cap (kept as their own tail), and
3469    // multi-byte text (a cut that has to walk to a char boundary). Driven
3470    // against the retention buffer directly: no runtime, no scripted process,
3471    // just the arithmetic.
3472    proptest! {
3473        #![proptest_config(ProptestConfig::with_cases(256))]
3474
3475        #[test]
3476        fn a_streamed_tail_stays_within_its_budget_whatever_the_stream(
3477            lines in prop::collection::vec("[a-z ☃]{0,16}", 1..40),
3478            max_bytes in 1usize..48,
3479            line_cap in prop::option::of(1usize..12),
3480        ) {
3481            let budget = match line_cap {
3482                Some(max_lines) => OutputBudget::bytes(max_bytes).with_max_lines(max_lines),
3483                None => OutputBudget::bytes(max_bytes),
3484            };
3485            let mut retained = RetainedStream::new(budget);
3486            for line in &lines {
3487                retained.push(line);
3488            }
3489            let text = retained.into_string();
3490
3491            // The whole point: what is handed to the error is bounded, always.
3492            prop_assert!(
3493                text.len() <= max_bytes,
3494                "retained {} bytes over a {max_bytes}-byte cap: {text:?}",
3495                text.len()
3496            );
3497            if let Some(max_lines) = line_cap {
3498                // Rendered separators are at most one per retained pair, so the
3499                // split count can only under-report the retained lines.
3500                prop_assert!(text.split('\n').count() <= max_lines);
3501            }
3502            // The tail is the part worth keeping (a CLI's fatal line lands
3503            // last), so the newest line survives whenever it fits at all.
3504            let last = lines.last().expect("the strategy generates at least one line");
3505            if last.len() <= max_bytes {
3506                prop_assert!(
3507                    text.ends_with(last.as_str()),
3508                    "the newest line {last:?} fits the cap but is not the tail of {text:?}"
3509                );
3510            }
3511        }
3512    }
3513}