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