Skip to main content

vcs_git/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-git` — automate Git from Rust by driving the `git` CLI.
4//!
5//! You call typed `async` methods; `vcs-git` runs the real `git`, parses its
6//! output, and hands you structured values — so you get *git's own* behaviour,
7//! config, and credentials, not a reimplementation of the object format. Async,
8//! structured errors, mockable. Every command runs inside an OS **job** (an
9//! OS-level container that kills the whole process tree if your program exits, via
10//! [`processkit`]) so a `git` subprocess is never orphaned, with an optional
11//! per-client [timeout](Git::default_timeout).
12//!
13//! # What you can do
14//!
15//! Status & branches · stage, commit, checkout · diff & log · merge / rebase /
16//! reset · worktrees · tags · blame · clone · config · cherry-pick / revert · parse
17//! & resolve conflict markers · a hardened (hooks-off) profile for untrusted repos.
18//! One tiny call to start:
19//!
20//! ```no_run
21//! use std::path::Path;
22//! use vcs_git::{Git, GitApi};
23//! # async fn demo() -> Result<(), processkit::Error> {
24//! let git = Git::new();
25//! // `current_branch` is `Option` — `None` on a detached HEAD.
26//! println!("{:?}", git.current_branch(Path::new(".")).await?); // e.g. Some("main")
27//! # Ok(()) }
28//! ```
29//!
30//! # The surface (engineering reference)
31//!
32//! - **[`GitApi`]** — the object-safe trait every operation lives on. Depend on
33//!   `&dyn GitApi` (or generically on `impl GitApi`) so a test can swap the real
34//!   client for a double. Methods take the working directory as the first
35//!   argument and return typed results ([`StatusEntry`], [`Branch`], [`Commit`],
36//!   [`FileDiff`], [`BlameLine`], …) or a structured [`Error`].
37//! - **[`Git`]** — the real client. [`Git::new`] uses the job-backed runner;
38//!   [`Git::with_runner`] injects a fake one for tests. It is generic over the
39//!   [`ProcessRunner`] seam, defaulting to the production runner.
40//!   [`with_credentials`](Git::with_credentials) attaches a [`CredentialProvider`]
41//!   to authenticate HTTPS remote ops (fetch/push/clone/ls-remote) with a token
42//!   kept out of `argv` — opt-in, off by default (ambient helpers / SSH agent).
43//! - **[`GitAt`]** — a cwd-bound view ([`Git::at`]) whose methods drop the
44//!   leading `dir`, so `git.at(dir).status()` reads as `git.status(dir)` — handy
45//!   when one client drives one checkout.
46//! - **Builder specs** for the multi-option commands — [`CommitPaths`],
47//!   [`MergeCommit`] / [`MergeNoCommit`], [`GitPush`], [`CloneSpec`],
48//!   [`WorktreeAdd`], [`AnnotatedTag`], [`MergeCheck`], [`BranchDelete`],
49//!   [`StashPush`], [`WorktreeRemove`] — each `#[non_exhaustive]`, built
50//!   with a constructor + chained setters, named after the flags they emit.
51//! - **[`conflict`]** — a typed conflict-marker model: parse marker soup into
52//!   structured regions, re-render byte-exact, and resolve to a chosen side.
53//! - **[`Git::hardened`]** — a profile for untrusted repositories (hooks off,
54//!   `GIT_*` scrubbed, system config skipped); see the [`guide::security`] guide.
55//!
56//! # Recipes
57//!
58//! Read state — depend on the trait so the same code takes a real client or a mock:
59//!
60//! ```no_run
61//! use std::path::Path;
62//! use vcs_git::{Git, GitApi};
63//! # async fn demo() -> Result<(), processkit::Error> {
64//! let git = Git::new();
65//! let dir = Path::new(".");
66//! let branch = git.current_branch(dir).await?;        // the checked-out branch
67//! let dirty = !git.status(dir).await?.is_empty();     // any uncommitted change?
68//! # let _ = (branch, dirty); Ok(()) }
69//! ```
70//!
71//! Mutate through the builder specs — `fetch` retries transient network failures:
72//!
73//! ```no_run
74//! use std::path::Path;
75//! use vcs_git::{CommitPaths, Git, GitApi, GitPush, RefName};
76//! # async fn demo(git: &Git) -> Result<(), processkit::Error> {
77//! let dir = Path::new(".");
78//! git.fetch(dir).await?;
79//! git.commit_paths(dir, CommitPaths::new(["src/a.rs"], "wip")).await?;
80//! // Ref/revision inputs are validated newtypes — build them at the boundary.
81//! git.push(dir, GitPush::branch(RefName::new("feature")?).set_upstream()).await?;
82//! # Ok(()) }
83//! ```
84//!
85//! # Testing
86//!
87//! Two seams: enable the **`mock`** feature for a `mockall`-generated
88//! `MockGitApi` (stub whole methods), or inject a
89//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`Git::with_runner`] to
90//! exercise the *real* argv-building and parsing against canned output. The
91//! cross-cutting testing patterns live in
92//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
93//!
94//! # Features
95//!
96//! - **`mock`** — the `mockall`-generated `MockGitApi` (see *Testing* above).
97//! - **`tracing`** — a `tracing` event per command run.
98//! - **`serde`** — derives `serde::Serialize` on the public [`conflict`] model
99//!   ([`ConflictSegment`](conflict::ConflictSegment),
100//!   [`ConflictRegion`](conflict::ConflictRegion),
101//!   [`ResolutionSide`](conflict::ResolutionSide)) so a caller can emit a parsed
102//!   conflict as JSON. `Serialize` only — these types are a parser's *output*,
103//!   never a wire input.
104//!
105//! # Safety
106//!
107//! Every operation that takes a caller-supplied **reference name** or **revision
108//! expression** now does so through a validated newtype — [`RefName`] for
109//! branch/tag/ref names, [`RevSpec`] for revisions/ranges — so a flag-like or
110//! malformed value is rejected at construction, *before* it can reach an argv
111//! slot (a classifiable [`vcs_cli_support::is_invalid_input`] failure). The one
112//! context-dependent special value, git's `-` "previous branch", is modelled
113//! explicitly as [`CheckoutTarget::Previous`] rather than smuggled through a
114//! newtype. Remaining bare-positional inputs that are **not** refs/revisions
115//! (remote names, URLs, config keys) keep an internal
116//! [`reject_flag_like`](vcs_cli_support::reject_flag_like) guard — refused before
117//! spawning if empty or starting with `-`. Flag-value slots (`-b <name>`) are
118//! consumed verbatim; paths — and a config *value*, which may legitimately begin
119//! with `-` (e.g. `-1`) and so can't be flag-rejected — go through a `--` option
120//! terminator instead (see [`config_set`](GitApi::config_set)).
121//!
122//! One named exception to the "revisions go through `RevSpec`" rule:
123//! [`DiffSpec::Rev`] — the diff target on
124//! [`GitApi::diff_text`]/[`GitApi::diff`] and [`Git::diff_text_within`]/
125//! [`Git::diff_within`] — is a bare `String` from the shared, backend-agnostic
126//! `vcs-diff` crate, not a `RevSpec`. It is still guarded, just per-call rather
127//! than by the type: `diff_text_budgeted` runs the same
128//! [`reject_flag_like`](vcs_cli_support::reject_flag_like) check inline before
129//! using it, and a trailing `--` pins it as a revision rather than a pathspec.
130//! Behaviourally equivalent to `RevSpec::new`'s guarantee, just enforced at the
131//! call site because `vcs-diff` is intentionally a plain-data, dependency-free
132//! crate with no newtype of its own to construct through.
133//!
134//! # In-depth guide
135//!
136//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
137//! from `docs/`. See the [`guide`] module (and its
138//! [`security`](crate::guide::security) / [`conflicts`](crate::guide::conflicts)
139//! sub-guides).
140
141use std::path::{Path, PathBuf};
142use std::sync::Arc;
143use std::time::Duration;
144
145use processkit::Command;
146// Re-export the processkit types that appear in this crate's public API, so
147// consumers needn't depend on processkit directly — incl. `ProcessRunner` (the
148// `with_runner`/`Git<R>` seam) and the `JobRunner` default. (`Error`/`Result`/
149// `ProcessResult`/`ProcessRunner` are in scope here too via this `pub use`.)
150// `ErrorReason` and `ErrorKind` ride along deliberately: since processkit 3.0
151// `Error` is an opaque wrapper, so *classifying* a failure means reaching
152// `err.reason()` (variant-grain) or `err.kind()` (flat) — types a consumer cannot
153// name without them. Omitting them would leave the re-exported `Error` unmatched,
154// a silent capability regression rather than a mechanical rename.
155pub use processkit::{
156    Error, ErrorKind, ErrorReason, JobRunner, ProcessResult, ProcessRunner, Result,
157};
158// Re-exported so a consumer can name the token for `default_cancel_on` without
159// taking a direct `processkit` dependency.
160pub use processkit::CancellationToken;
161
162pub mod conflict;
163mod parse;
164#[doc(hidden)]
165pub use parse::parse_porcelain_v2;
166pub use parse::{
167    BlameLine, Branch, BranchStatus, CleanEntry, Commit, Remote, StashEntry, StatusEntry,
168    Submodule, SubmoduleState, SubmoduleStatus, Worktree,
169};
170// The git-format diff model + parser and the version type are shared with
171// `vcs-jj` (identical output) — re-exported so `vcs_git::FileDiff`,
172// `vcs_git::parse_diff`, `vcs_git::GitVersion`, … still resolve.
173pub use vcs_diff::{
174    ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
175};
176// The error classifiers live in the shared plumbing crate — re-exported so
177// `vcs_git::is_merge_conflict`, … still resolve.
178use vcs_cli_support::git_credential_helper;
179pub use vcs_cli_support::{
180    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, OutputBudget,
181    ProcessEvent, ProgressCallback, RetryPolicy, Secret, StaticCredential, is_lock_contention,
182    is_merge_conflict, is_nothing_to_commit, is_transient_fetch_error, provider_fn,
183};
184
185/// Name of the underlying CLI binary this crate drives.
186pub const BINARY: &str = "git";
187
188mod specs;
189pub use specs::{
190    AnnotatedTag, BisectResult, BisectStep, BranchDelete, CheckoutTarget, Clean, CleanIgnored,
191    CloneFilter, CloneSpec, CommitPaths, GitCapabilities, GitPush, MergeCheck, MergeCheckPartial,
192    MergeCommit, MergeNoCommit, RefName, RevSpec, SparseCheckoutSet, StashPush, SubmoduleUpdate,
193    WorktreeAdd, WorktreeRemove,
194};
195
196/// The Git operations this crate exposes — the interface consumers code against
197/// and mock in tests.
198///
199/// **Injection safety:** reference names and revision expressions are taken as
200/// the validated [`RefName`] / [`RevSpec`] newtypes (directly or inside an
201/// options struct), so a flag-like or malformed value is rejected at
202/// construction, before it can reach an argv slot. The remaining
203/// caller-supplied bare positionals that are *not* refs/revisions — remote
204/// names and URLs — keep an internal `reject_flag_like` guard: a value that is
205/// empty or begins with `-` is rejected with an [`ErrorReason::Spawn`] *before*
206/// spawning. Flag-value slots (`-m <msg>`, `--branch <b>`), filesystem path
207/// arguments (`--`-separated pathspecs, plus worktree paths and clone
208/// destinations — typed `Path`, caller-trusted), and the `run`/`run_raw`
209/// escape hatches are not guarded. The one context-dependent special value,
210/// git's `-` "previous branch", is [`CheckoutTarget::Previous`].
211#[cfg_attr(feature = "mock", mockall::automock)]
212#[async_trait::async_trait]
213pub trait GitApi: Send + Sync {
214    /// Run `git <args>` **in the process's current directory**, returning trimmed
215    /// stdout (throws on a non-zero exit). A raw escape hatch for unmodelled commands
216    /// — you supply the whole argv, so target a specific repo with `-C <dir>` in the
217    /// args. This method on the client is the **process-cwd** escape hatch; the
218    /// `at(dir)` bound view's [`run`](GitAt::run) is instead **bound to `dir`** (it
219    /// forwards to [`Git::run_in`], so `git.at(dir).run(…)` runs in the bound repo).
220    /// Use `git.at(dir).run(…)` (or [`Git::run_in`]) for the bound repo; use this for
221    /// the process cwd (T-035, was M15).
222    async fn run(&self, args: &[String]) -> Result<String>;
223    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
224    /// captured [`ProcessResult`].
225    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
226    /// Installed Git version (`git --version`).
227    async fn version(&self) -> Result<String>;
228    /// The installed binary's parsed version, as [`GitCapabilities`]
229    /// (`git --version`). A value type — probe once and keep it; an
230    /// unrecognisable version string is an [`ErrorReason::Parse`].
231    async fn capabilities(&self) -> Result<GitCapabilities>;
232    /// Working-tree status (`git status --porcelain=v1 -z`).
233    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
234    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
235    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
236    async fn status_text(&self, dir: &Path) -> Result<String>;
237    /// Like [`status`](GitApi::status) but ignoring untracked files
238    /// (`git status --porcelain=v1 -z --untracked-files=no`) — "is the *tracked*
239    /// tree dirty", staged or not.
240    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
241    /// A combined branch + working-tree snapshot in **one** spawn
242    /// (`git status --porcelain=v2 --branch -z`): HEAD, branch, upstream,
243    /// ahead/behind, and change counts — the data a prompt/status-bar needs
244    /// without N round-trips. See [`BranchStatus`].
245    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
246    /// Paths with unresolved merge conflicts, repo-relative with `/` separators
247    /// (`git diff --name-only --diff-filter=U -z`). Empty when there are none.
248    /// Returns [`PathBuf`]s built from the raw `-z` bytes, so a non-UTF-8
249    /// conflicted path survives losslessly.
250    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>>;
251    /// Current branch name, or `None` on a **detached HEAD**
252    /// (`git symbolic-ref --quiet --short HEAD`). Returns the branch name for a
253    /// normal branch **and for an unborn branch** (a fresh `init`/`clone` before the
254    /// first commit); `None` only when HEAD is detached. Mirrors
255    /// [`JjApi::current_bookmark`](../vcs_jj/trait.JjApi.html#tymethod.current_bookmark)'s
256    /// `Option` shape, so cross-backend code treats "no named branch/bookmark" the
257    /// same way on both wrappers.
258    async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
259    /// Local branches, current one flagged (`git branch`).
260    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
261    /// Up to `max` commits reachable from `revspec`, newest first
262    /// (`git log <revspec> -- `). Pass `"HEAD"` for the current branch's
263    /// history, or a range like `"main..HEAD"` / `"origin/main..HEAD"` to scope
264    /// it. Mirrors [`JjApi::log`](../vcs_jj/trait.JjApi.html#tymethod.log)'s
265    /// revset argument, so cross-backend code uses one signature. The
266    /// `revspec` is a validated [`RevSpec`], so it can never be parsed as a
267    /// flag. The argv ends in a literal `--` (end of revisions, no pathspecs)
268    /// so a `revspec` that happens to match an existing file's name (e.g.
269    /// `Makefile`) resolves as a revision or fails outright, rather than
270    /// silently falling back to git's pathspec DWIM and returning `HEAD`'s
271    /// history filtered by that path (C2/M13) — the same convention already
272    /// used by [`log_paths`](GitApi::log_paths),
273    /// [`diff_text`](GitApi::diff_text),
274    /// [`diff_range_is_empty`](GitApi::diff_range_is_empty),
275    /// [`diff_stat`](GitApi::diff_stat), and [`checkout`](GitApi::checkout).
276    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
277    /// Like [`log`](GitApi::log), but scoped to commits that touched `paths`
278    /// (`git --literal-pathspecs log <revspec> -n <max> -- <paths>`) — e.g. "who
279    /// changed this module". `--literal-pathspecs` matches a path containing
280    /// `*`/`?`/`[]` literally rather than as pathspec glob magic (R-02); the `--`
281    /// separator keeps a path from being read as a flag (same convention as
282    /// [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths)). An empty
283    /// `paths` is refused *before spawning*: silently falling back to
284    /// [`log`](GitApi::log)'s unrestricted history would defeat the "scoped to
285    /// these paths" contract. Mirrors
286    /// [`JjApi::log_paths`](../vcs_jj/trait.JjApi.html#tymethod.log_paths), which
287    /// takes filesets instead of pathspecs.
288    ///
289    /// Unlike [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths), git's
290    /// `log` has no `--pathspec-from-file` support, so a `paths` set that would
291    /// risk exceeding the OS argv limit is instead split into multiple `git log`
292    /// calls, each within budget; the per-call results are merged (deduplicated
293    /// by hash — a commit can touch paths spread across more than one chunk)
294    /// and restored to git's own commit order using a separate, pathless `git
295    /// log <revspec> --format=%H` oracle call (T-052/R-03): pathspec filtering
296    /// only drops non-matching commits, it never reorders the ones that
297    /// remain, so the oracle's order — over the *same* revspec, unrestricted
298    /// by paths — gives the exact relative order a single, hypothetical
299    /// unchunked call would have produced. Unlike sorting by a parsed date
300    /// field, this needs no assumption about author-vs-committer timestamps or
301    /// same-second ties (git log dates have no sub-second precision). A
302    /// `paths` set that fits in one call is unaffected — same single
303    /// invocation, same order, as before.
304    ///
305    /// Before any of that, `revspec` (which may be symbolic, e.g. `HEAD`, or a
306    /// range, e.g. `main..feature`) is resolved exactly once via `git
307    /// rev-parse` into a fixed set of commit ids that every chunk call and the
308    /// oracle call then reuse verbatim (T-052/R-04): without this, each of
309    /// those several independent invocations would re-resolve the same
310    /// symbolic text on its own, so a concurrent commit/reset/ref-move landing
311    /// between any two of them could make them see different repository
312    /// snapshots, silently omitting a newer matching commit, including one no
313    /// longer reachable, or interleaving two different histories into the
314    /// merged result. This resolution only happens on the chunked path (more
315    /// than one invocation); the single-call fast path is unaffected. Also
316    /// before any of that, every individual path is checked against the argv
317    /// budget on its own (T-052/R-05): `git log` has no NUL-safe transport to
318    /// fall back to the way `add`/`commit_paths` do, so a single path that by
319    /// itself cannot fit in argv is rejected up front with a clear error
320    /// rather than silently forwarded as an over-budget singleton chunk.
321    async fn log_paths(
322        &self,
323        dir: &Path,
324        revspec: &RevSpec,
325        max: usize,
326        paths: &[String],
327    ) -> Result<Vec<Commit>>;
328    /// Resolve a revision to a full hash (`git rev-parse --verify <rev>`). `--verify`
329    /// requires `rev` to name exactly one object, so a non-revision (e.g. a filename)
330    /// errors instead of being echoed back as a fake id.
331    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
332    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
333    /// e.g. to label a detached HEAD.
334    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
335    /// Initialise a repository (`git init`).
336    async fn init(&self, dir: &Path) -> Result<()>;
337    /// Stage `paths` (`git --literal-pathspecs add -- <paths>`) —
338    /// `--literal-pathspecs` applies regardless of path-set size, so a path
339    /// containing `*`/`?`/`[]` always matches literally rather than as pathspec
340    /// glob magic (R-01). A path set whose combined length would risk exceeding
341    /// the OS command-line limit (`ARGV_PATHSPEC_BUDGET`; Windows' `CreateProcess`
342    /// caps out around 32,767 characters) instead goes over stdin via
343    /// `--pathspec-from-file=- --pathspec-file-nul` — the paths never touch argv
344    /// at all in that case, so there is no upper bound left to exceed (T-052).
345    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
346    /// Commit staged changes (`git commit -m`).
347    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
348    /// Create a branch without switching to it (`git branch <name>`).
349    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()>;
350    /// Switch to a branch/revision, or the previous branch (`git checkout
351    /// <target>`); see [`CheckoutTarget`].
352    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()>;
353    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
354    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()>;
355    /// Commit exactly the spec's paths' working-tree content, ignoring the index
356    /// (`git --literal-pathspecs commit [--amend] -m <message> --only -- <paths>`);
357    /// see [`CommitPaths`]. `--literal-pathspecs` applies regardless of path-set
358    /// size, so a glob-magic character (`*`/`?`/`[]`) in a path is matched
359    /// literally rather than expanded — otherwise `commit_paths`'s "exactly these
360    /// paths" contract could be violated (R-01). Like [`add`](GitApi::add), a
361    /// path set that would risk exceeding the OS argv limit is instead routed
362    /// over stdin (`--pathspec-from-file=- --pathspec-file-nul`) — always as a
363    /// **single** `git commit` invocation either way, so the one-atomic-commit
364    /// contract is unaffected by the path set's size (T-052).
365    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
366    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
367    /// pre-fill an amend.
368    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
369    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
370    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
371    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
372    /// Whether the working tree has no unstaged modifications to **tracked** files
373    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
374    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
375    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
376
377    // --- Discovery / identity ------------------------------------------------
378
379    /// The repository's common git directory (`rev-parse --git-common-dir`) —
380    /// stable across linked worktrees.
381    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
382    /// This worktree's git directory (`rev-parse --git-dir`).
383    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
384    /// Resolve a revision to a commit hash, peeling tags
385    /// (`rev-parse --verify <rev>^{commit}`).
386    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
387    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
388    /// (short name only); `None` when `origin/HEAD` is unset.
389    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
390    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
391    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
392    /// Whether `origin` has `name`, without fetching (`ls-remote origin
393    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
394    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
395    /// credential or a flaky network can't hang the call.
396    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
397    /// A remote's URL (`remote get-url <remote>`).
398    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
399    /// Configured remotes from `remote -v`, coalesced to one typed row per name
400    /// with its fetch URL.
401    async fn remote_list(&self, dir: &Path) -> Result<Vec<Remote>>;
402    /// The current attached branch's upstream, e.g. `Some("origin/main")`
403    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
404    /// A detached HEAD or a directory outside a repository is an error.
405    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
406    /// Branch names on `remote`, without fetching
407    /// (`ls-remote --heads <remote>`).
408    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
409
410    // --- Branches ------------------------------------------------------------
411
412    /// Whether the [`MergeCheck`]'s `branch` is fully merged into its `base`
413    /// (`branch --merged <base>`). Build it as
414    /// `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)`
415    /// so the two refs can't be transposed (a swap would invert the answer).
416    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
417    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
418    /// (`branch --set-upstream-to=<upstream> <branch>`).
419    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()>;
420    /// Delete a local branch (`branch -d`, or `-D` when forced); see [`BranchDelete`].
421    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()>;
422    /// Rename a local branch (`branch -m <old> <new>`).
423    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()>;
424    /// Count commits in a range (`rev-list --count <range>`).
425    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize>;
426    /// Whether a diff range is empty (`diff --quiet <range>`).
427    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool>;
428    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
429    /// match `vcs_jj::JjApi::diff_stat`.
430    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat>;
431    /// Raw git-format unified diff text for `spec`
432    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output, returned
433    /// **verbatim** (a trailing blank context line is preserved, so the last hunk
434    /// stays in sync with its `@@` line count for a re-parse/re-apply).
435    ///
436    /// [`DiffSpec::Rev`] is a direct passthrough: the string becomes git's single
437    /// positional diff argument verbatim (`git diff <rev>`) — this crate doesn't
438    /// parse, classify, or rewrite it beyond the
439    /// [`reject_flag_like`](vcs_cli_support::reject_flag_like) guard against a
440    /// leading `-`. Whatever `git diff <rev>` would do at the shell for that exact
441    /// string is what `Rev(rev)` does here.
442    ///
443    /// One consequence follows from plain `git diff`'s own rules: a *single*
444    /// revision (no `..`/`...`) diffs the **working tree** against that revision,
445    /// not the revision against its parent — when `HEAD` exists,
446    /// `Rev("HEAD".into())` behaves exactly like [`DiffSpec::WorkingTree`], and
447    /// `Rev("abc".into())` includes any uncommitted changes on top of `abc`. On
448    /// an unborn repository the two differ: [`DiffSpec::WorkingTree`]
449    /// deliberately diffs against the empty tree, while `Rev("HEAD".into())`
450    /// passes the unresolved name through and errors. To compare two commits
451    /// with the working tree excluded, put a range in the `Rev` string instead
452    /// (`"abc..def"` / `"abc^..abc"`) — git's two-dot/three-dot forms diff
453    /// commit-to-commit. None of this is `vcs-git` logic; it's inherited by
454    /// passing the string straight to git, except for `WorkingTree`'s documented
455    /// unborn fallback.
456    ///
457    /// How `DiffSpec` is interpreted here is stable behavior, not an implementation detail.
458    /// Changing it would be a semver-breaking change.
459    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
460    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
461    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
462    /// Raw git-format unified diff comparing the tree at `from` with the tree at
463    /// `to` (`git diff <from> <to> --no-color --no-ext-diff -M`). Each endpoint
464    /// is a separately validated [`RevSpec`], so compound selectors stay in
465    /// their own argv slot and the `from` → `to` direction is explicit.
466    /// Unlike [`DiffSpec::Rev`], this operation never includes the working tree
467    /// implicitly: only the two supplied endpoints are compared.
468    ///
469    /// The default implementation returns [`ErrorReason::Unsupported`] so an
470    /// existing downstream implementor remains source-compatible; concrete
471    /// [`Git`] clients override it with the real Git operation.
472    async fn diff_text_between(
473        &self,
474        _dir: &Path,
475        _from: &RevSpec,
476        _to: &RevSpec,
477    ) -> Result<String> {
478        Err(Error::from(ErrorReason::Unsupported {
479            operation: "GitApi::diff_text_between".into(),
480        }))
481    }
482    /// Parsed per-file unified diff comparing the tree at `from` with the tree
483    /// at `to`, layered on [`diff_text_between`](GitApi::diff_text_between).
484    /// The default implementation uses the default text method, allowing an
485    /// implementor to opt into the parsed operation by overriding only that
486    /// method.
487    async fn diff_between(
488        &self,
489        dir: &Path,
490        from: &RevSpec,
491        to: &RevSpec,
492    ) -> Result<Vec<FileDiff>> {
493        let text = self.diff_text_between(dir, from, to).await?;
494        Ok(parse_diff(&text))
495    }
496
497    // --- In-progress state ---------------------------------------------------
498
499    /// Whether the index has no staged changes (`diff --cached --quiet`).
500    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
501    /// Whether a rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
502    /// **not** left by `git am`, exists under the git dir).
503    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
504    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
505    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
506    /// Whether a `git am` (mailbox apply) is in progress (`rebase-apply/applying`).
507    /// Distinct from a rebase, which shares the `rebase-apply` dir but without the
508    /// `applying` marker — aborting an am needs `am --abort`, not `rebase --abort`.
509    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
510    /// Whether a cherry-pick is in progress (`CHERRY_PICK_HEAD` under the git dir).
511    /// A cherry-pick conflict writes `CHERRY_PICK_HEAD`, **not** `MERGE_HEAD`, so
512    /// this is distinct from a merge and is aborted/continued with
513    /// `cherry-pick --abort` / `--continue`, not `merge --abort`.
514    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool>;
515    /// Whether a revert is in progress (`REVERT_HEAD` under the git dir). Like a
516    /// cherry-pick, a revert conflict writes its own head file, not `MERGE_HEAD`;
517    /// it is driven with `revert --abort` / `--continue`.
518    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool>;
519    /// Whether a `git bisect` session is in progress (`BISECT_LOG` under the git
520    /// dir). Ended with `bisect reset` (there is no `--continue`).
521    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool>;
522
523    // --- Mutations -----------------------------------------------------------
524
525    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
526    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
527    async fn fetch(&self, dir: &Path) -> Result<()>;
528    /// Fetch from the default remote while reporting process lifecycle and
529    /// stdout/stderr lines. This observes one process attempt (no automatic
530    /// replay), so [`ProcessEvent::Exited`] is always terminal.
531    async fn fetch_with_progress<'a>(
532        &self,
533        dir: &Path,
534        progress: &'a mut ProgressCallback<'a>,
535    ) -> Result<()>;
536    /// Fetch from a *named* remote (`fetch --quiet <remote>`), with
537    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried like
538    /// [`fetch`](GitApi::fetch).
539    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
540    /// Fetch a single branch from `origin` into its remote-tracking ref
541    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
542    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
543    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()>;
544    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
545    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
546    /// Push while reporting process lifecycle and stdout/stderr lines. Uses
547    /// git's `--progress` flag so progress is emitted even though output is piped.
548    async fn push_with_progress<'a>(
549        &self,
550        dir: &Path,
551        spec: GitPush,
552        progress: &'a mut ProgressCallback<'a>,
553    ) -> Result<()>;
554    /// Stage a branch's changes without committing (`merge --squash <branch>`).
555    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()>;
556    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
557    /// message it takes the default merge message non-interactively (`--no-edit`).
558    /// See [`MergeCommit`].
559    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
560    /// Merge a branch but stop before committing, so the result can be inspected
561    /// (`merge --no-commit [--squash | --no-ff] <branch>`). With `no_ff` (and not
562    /// `squash`) git records `MERGE_HEAD`, so the in-progress merge is abortable
563    /// via [`merge_abort`](GitApi::merge_abort) — the dry-run pattern. With
564    /// `squash`, git stages the squashed result but records **no** `MERGE_HEAD`,
565    /// so it is *not* an abortable merge: undo it with
566    /// [`reset_merge`](GitApi::reset_merge) / [`reset_hard`](GitApi::reset_hard),
567    /// not `merge_abort`. See [`MergeNoCommit`].
568    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
569    /// Abort an in-progress merge (`merge --abort`).
570    async fn merge_abort(&self, dir: &Path) -> Result<()>;
571    /// Finish a merge after resolving conflicts (`commit --no-edit`).
572    async fn merge_continue(&self, dir: &Path) -> Result<()>;
573    /// Undo an in-progress (or just-staged) merge: `reset --merge` resets the
574    /// index and the merge-touched working-tree files back to `HEAD` and drops
575    /// `MERGE_HEAD`, **discarding the merge's changes** while keeping unrelated
576    /// unstaged edits. Use it after `merge_squash` / `merge_no_commit(squash)`,
577    /// where there is no `MERGE_HEAD` for `merge_abort` to act on.
578    async fn reset_merge(&self, dir: &Path) -> Result<()>;
579    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
580    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
581    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
582    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
583    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()>;
584    /// Abort an in-progress rebase (`rebase --abort`).
585    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
586    /// Abort an in-progress `git am` (`am --abort`), restoring the pre-`am` HEAD.
587    async fn am_abort(&self, dir: &Path) -> Result<()>;
588    /// Continue a `git am` after resolving conflicts (`am --continue`); the editor
589    /// is suppressed (`GIT_EDITOR=true`) so the patch's message-confirm never hangs
590    /// a headless caller. On a multi-patch mailbox it can stop again on the next
591    /// patch's conflict (exit non-zero) — a conflict, not a hard error, like the
592    /// other sequencer `--continue`s.
593    async fn am_continue(&self, dir: &Path) -> Result<()>;
594    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
595    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
596    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
597    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
598    /// e.g. to save state before a copy-on-write restore. See [`StashPush`].
599    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()>;
600    /// Restore the most recent stash and drop it (`stash pop`).
601    async fn stash_pop(&self, dir: &Path) -> Result<()>;
602    /// The stash list, most-recent first (`stash@{0}`), machine-parsed via
603    /// `stash list -z --format=%gd%x1f%H%x1f%gs` into typed [`StashEntry`]
604    /// records — a read, unlike [`stash_push`](GitApi::stash_push)/
605    /// [`stash_pop`](GitApi::stash_pop).
606    async fn stash_list(&self, dir: &Path) -> Result<Vec<StashEntry>>;
607    /// Apply the stash at `index` (`stash@{<index>}`, [`StashEntry::index`])
608    /// **without** dropping it (`stash apply stash@{<index>}`) — unlike
609    /// [`stash_pop`](GitApi::stash_pop), the stash entry survives a successful
610    /// apply, so it can be applied again or dropped explicitly with
611    /// [`stash_drop`](GitApi::stash_drop).
612    async fn stash_apply(&self, dir: &Path, index: usize) -> Result<()>;
613    /// Drop the stash at `index` (`stash@{<index>}`, [`StashEntry::index`])
614    /// **without** applying it (`stash drop stash@{<index>}`) — discards the
615    /// stash entry; the working tree is untouched.
616    async fn stash_drop(&self, dir: &Path, index: usize) -> Result<()>;
617    /// Remove untracked files/directories from the working tree (`git clean`),
618    /// per [`Clean`]. **Destructive unless [`Clean::dry_run`] is set** — with
619    /// neither `dry_run` nor [`Clean::force`] picked, this refuses before
620    /// spawning `git` at all (an [`Error::spawn`] carrying
621    /// [`std::io::ErrorKind::InvalidInput`]), so deletion is never the default
622    /// and never implied by omission (see the [`Clean`] type docs). Returns
623    /// the typed list of paths removed (forced) or that would be removed (dry
624    /// run) — see [`CleanEntry`].
625    async fn clean(&self, dir: &Path, spec: Clean) -> Result<Vec<CleanEntry>>;
626
627    // --- Worktrees -----------------------------------------------------------
628
629    /// List worktrees (`worktree list --porcelain`).
630    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
631    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
632    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
633    /// Remove a worktree (`worktree remove [--force] <path>`); see [`WorktreeRemove`].
634    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()>;
635    /// Move a worktree (`worktree move <from> <to>`).
636    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
637    /// Prune stale worktree admin entries (`worktree prune`).
638    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
639
640    // --- Sparse checkout -----------------------------------------------------
641
642    /// Set the sparse-checkout directories or patterns
643    /// (`sparse-checkout set --cone|--no-cone -- <values>`); see
644    /// [`SparseCheckoutSet`]. Cone mode is the default and is recommended for
645    /// directory-oriented worktrees. Values are validated before spawning and
646    /// pinned after `--`, so a caller cannot turn one into a git option.
647    async fn sparse_checkout_set(&self, dir: &Path, spec: SparseCheckoutSet) -> Result<()>;
648    /// List the currently configured sparse-checkout directories or patterns
649    /// (`sparse-checkout list`) in git's emitted order. Pattern text is kept
650    /// verbatim apart from the line terminator, including meaningful spaces.
651    async fn sparse_checkout_list(&self, dir: &Path) -> Result<Vec<String>>;
652    /// Disable sparse checkout and repopulate the working tree
653    /// (`sparse-checkout disable`).
654    async fn sparse_checkout_disable(&self, dir: &Path) -> Result<()>;
655
656    // --- Submodules ----------------------------------------------------------
657
658    /// The submodules declared in `<dir>/.gitmodules`, parsed from the
659    /// machine-unambiguous `git config --file .gitmodules --list -z` source
660    /// (**not** a hand-rolled scan of the ini-style file). `dir` must be the
661    /// repository's top-level working directory, where `.gitmodules` lives.
662    ///
663    /// A repository with no submodules has no `.gitmodules` file; that is
664    /// reported as an **empty list**, not an error — the absent file is probed
665    /// before spawning, so the common no-submodule case does not even run git.
666    /// See [`Submodule`]. This is a pure read: `git config --file` only parses
667    /// the file, so it neither clones, fetches, nor executes any submodule's
668    /// config — the safe way to inspect what a (possibly untrusted)
669    /// `.gitmodules` declares before deciding whether to
670    /// [`submodule_update`](GitApi::submodule_update).
671    async fn submodule_list(&self, dir: &Path) -> Result<Vec<Submodule>>;
672
673    /// The sync state of the superproject's submodules (`git submodule status`):
674    /// the checked-out commit, path, and a typed [`SubmoduleState`] from the
675    /// line's leading `-`/`+`/`U`/space prefix. See [`SubmoduleStatus`].
676    ///
677    /// A read: it inspects the recorded gitlink and each initialized submodule's
678    /// HEAD (briefly running `git` inside each to compute the trailing
679    /// `describe`), but performs no checkout, fetch, or working-tree
680    /// materialization.
681    async fn submodule_status(&self, dir: &Path) -> Result<Vec<SubmoduleStatus>>;
682
683    /// Check out the submodules to the commits the superproject records
684    /// (`git submodule update [--init] [--recursive] [--depth <n>] [-- <paths>]`);
685    /// see [`SubmoduleUpdate`].
686    ///
687    /// **Security boundary — executes a nested repository's content.** Unlike
688    /// [`submodule_list`](GitApi::submodule_list) /
689    /// [`submodule_status`](GitApi::submodule_status), this **fetches from the
690    /// URLs `.gitmodules` records and materializes each submodule's working
691    /// tree**, so it runs that nested repo's checkout-time config (filter/smudge
692    /// drivers) and network transport. On a [`hardened`](Git::hardened) client
693    /// the hardened environment is inherited by the `git` subprocesses this
694    /// spawns, but the residual repo-local-config vectors apply per nested repo
695    /// too — for a fully untrusted superproject, run this only inside an OS-level
696    /// sandbox, or vet `.gitmodules` via `submodule_list` first. Terminal
697    /// prompts are pinned off (`GIT_TERMINAL_PROMPT=0`) so a submodule needing
698    /// credentials fails fast rather than hanging. See the submodules section of
699    /// the security guide.
700    ///
701    /// Positional submodule paths are flag-guarded and passed after a `--`
702    /// terminator, so a caller-supplied path can never be parsed as an option.
703    async fn submodule_update(&self, dir: &Path, spec: SubmoduleUpdate) -> Result<()>;
704
705    // --- Clone / tags / inspection --------------------------------------------
706
707    /// Clone `url` into `dest` (`git clone <url> <dest>` + [`CloneSpec`] flags).
708    /// Runs without a working directory — pass an **absolute** `dest`.
709    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
710    /// Clone while reporting process lifecycle and stdout/stderr lines. Uses
711    /// git's `--progress` flag and preserves [`clone_repo`](GitApi::clone_repo)'s
712    /// failed-destination cleanup contract.
713    async fn clone_repo_with_progress<'a>(
714        &self,
715        url: &str,
716        dest: &Path,
717        spec: CloneSpec,
718        progress: &'a mut ProgressCallback<'a>,
719    ) -> Result<()>;
720    /// Create a lightweight tag at `rev` (`tag <name> [<rev>]`; `None` = HEAD).
721    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()>;
722    /// Create an annotated tag (`tag -a <name> -m <message> [<rev>]`); see
723    /// [`AnnotatedTag`].
724    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
725    /// Tag names, sorted by git's default ordering (`tag --list`).
726    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
727    /// Delete a tag (`tag -d <name>`).
728    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()>;
729    /// A file's content at a revision (`git show <rev>:<path>`). `path` is
730    /// repo-relative; backslashes are normalised to `/` (git requires it).
731    /// Content is decoded **lossily** — binary files come back mangled rather
732    /// than erroring — and returned **verbatim**: the blob's trailing newline(s)
733    /// are preserved (not trimmed), so a read-modify-write round-trip is byte-exact.
734    ///
735    /// An **empty or whitespace-only** `path` is refused **before** `git` spawns,
736    /// with an [`is_invalid_input`](vcs_cli_support::is_invalid_input) error: a
737    /// bare `git show <rev>:` is not an error but the root **tree listing**, so
738    /// an unguarded empty path would return a directory index as if it were a
739    /// file's content. `vcs_jj::JjApi::file_show` refuses the same input in the
740    /// same form (T-149).
741    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String>;
742    /// The value of a config key, or `None` when unset (`config --get <key>`,
743    /// whose exit 1 covers both "unset" and "no such section" — git doesn't
744    /// distinguish). A multi-valued key errors; read those via `run`.
745    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
746    /// Set a config key in the repository's local config (`config -- <key> <value>`).
747    ///
748    /// The `--` option terminator pins both `key` and `value` as positionals, so a
749    /// `value` that looks like a flag (`--global`, `--file=<path>`, or a plain `-1`)
750    /// is written *literally* as the key's value — git can never reparse it as an
751    /// option redirecting the write to another config file. `value` therefore keeps
752    /// no flag-shape guard on purpose (a config value may legitimately start with
753    /// `-`); it is the flag *parse* that is blocked, not the leading dash. `key`
754    /// additionally can't be flag-shaped (guarded before the terminator is reached).
755    ///
756    /// **Trusted-input sink.** This still writes whatever key/value it's given —
757    /// including code-execution keys like `core.sshCommand` or `filter.<drv>.clean`.
758    /// The `--` guard stops a `value` being *misparsed* as a flag; it does **not**
759    /// sanitise a genuinely dangerous *key* you choose to write. Never wire
760    /// untrusted input into it; a `harden()`ed client does *not* protect against
761    /// config *you* write.
762    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
763    /// Add a remote (`remote add <name> <url>`).
764    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
765    /// Change a remote's URL (`remote set-url <name> <url>`).
766    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
767    /// Per-line authorship of `path` (`blame --line-porcelain [<rev>] -- <path>`;
768    /// `None` = the working tree's HEAD).
769    ///
770    /// Unlike [`show_file`](GitApi::show_file), `path` deliberately carries **no**
771    /// pre-spawn emptiness guard: it occupies a pathspec slot of its own behind
772    /// `--`, where git resolves an empty or whitespace-only value as an ordinary
773    /// path and **fails loudly** (`fatal: no such path '' in HEAD`, exit 128)
774    /// instead of quietly answering about something else. The T-149 investigation
775    /// verified this on git 2.55.0; the live `blame_and_show_file_reject_empty_path`
776    /// test pins it, so a future git that started accepting it would be caught.
777    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
778
779    // --- Sequencer -------------------------------------------------------------
780
781    /// Apply a commit onto the current branch (`cherry-pick <rev>`). A conflict
782    /// surfaces as an error classified by [`is_merge_conflict`].
783    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
784    /// Revert a commit with the default message (`revert --no-edit <rev>`).
785    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
786    /// Skip the current patch of a paused rebase (`rebase --skip`). Mainly for
787    /// the `apply` backend's "nothing to commit" stop — the default `merge`
788    /// backend auto-drops emptied patches on `--continue`.
789    async fn rebase_skip(&self, dir: &Path) -> Result<()>;
790    /// Abort an in-progress cherry-pick (`cherry-pick --abort`), restoring the
791    /// pre-cherry-pick state.
792    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()>;
793    /// Continue a cherry-pick after resolving conflicts (`cherry-pick --continue`);
794    /// the editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never
795    /// hangs a headless caller. On a multi-commit pick it can stop again on the
796    /// next commit's conflict (exit non-zero) — a conflict, not a hard error.
797    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()>;
798    /// Abort an in-progress revert (`revert --abort`), restoring the pre-revert
799    /// state.
800    async fn revert_abort(&self, dir: &Path) -> Result<()>;
801    /// Continue a revert after resolving conflicts (`revert --continue`); the
802    /// editor is suppressed like [`cherry_pick_continue`](GitApi::cherry_pick_continue),
803    /// and it too can stop on the next commit's conflict.
804    async fn revert_continue(&self, dir: &Path) -> Result<()>;
805    /// End a `git bisect` session (`bisect reset`), returning to the branch/commit
806    /// that was checked out before it started. This is the "abort" for a bisect;
807    /// bisect has no `--continue`.
808    async fn bisect_reset(&self, dir: &Path) -> Result<()>;
809    /// Start a bisect session with `bad` and `good` bounds
810    /// (`bisect start <bad> <good>`), returning the next checkout or the first
811    /// bad commit when Git can finish immediately. The consumer remains
812    /// responsible for running its test at each checkout and classifying it
813    /// with [`bisect_good`](Self::bisect_good), [`bisect_bad`](Self::bisect_bad),
814    /// or [`bisect_skip`](Self::bisect_skip). Call [`bisect_reset`](Self::bisect_reset)
815    /// when the session is complete or abandoned.
816    async fn bisect_start(&self, dir: &Path, bad: &RevSpec, good: &RevSpec) -> Result<BisectStep>;
817    /// Mark the currently checked-out bisect revision good (`bisect good`),
818    /// returning the next checkout or the first bad commit. Git advances the
819    /// session; this method does not run a consumer-supplied test.
820    async fn bisect_good(&self, dir: &Path) -> Result<BisectStep>;
821    /// Mark the currently checked-out bisect revision bad (`bisect bad`),
822    /// returning the next checkout or the first bad commit. Git advances the
823    /// session; this method does not run a consumer-supplied test.
824    async fn bisect_bad(&self, dir: &Path) -> Result<BisectStep>;
825    /// Skip the currently checked-out bisect revision (`bisect skip`),
826    /// returning the next checkout or the first bad commit. If Git reports an
827    /// ambiguous set of possible first bad commits, this returns
828    /// [`ErrorReason::Parse`] rather than choosing one silently; the session is
829    /// still owned by Git and may be reset or driven through the raw escape
830    /// hatch by the consumer.
831    async fn bisect_skip(&self, dir: &Path) -> Result<BisectStep>;
832}
833
834vcs_cli_support::managed_client! {
835    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject a
836    /// fake process executor; [`Git::new`] uses the real job-backed runner.
837    ///
838    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient): enable lock-contention retry with
839    /// [`with_retry`](Git::with_retry) (opt-in; off by default).
840    ///
841    /// **Every** client (not just [`hardened`](Git::hardened)) scrubs the inherited
842    /// repo-**redirector** environment variables below, so a `GIT_DIR` (etc.) leaking
843    /// from the parent process — e.g. running inside a git hook, which exports
844    /// `GIT_DIR`/`GIT_INDEX_FILE` — can't silently redirect commands at a *different*
845    /// repository than the bound `dir`. (`harden()` additionally scrubs the
846    /// command-hook vars and pins hooks/fsmonitor/sshCommand off.)
847    pub struct Git => BINARY, scrub_env = [
848        "GIT_DIR",
849        "GIT_WORK_TREE",
850        "GIT_INDEX_FILE",
851        "GIT_COMMON_DIR",
852        "GIT_OBJECT_DIRECTORY",
853        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
854        "GIT_NAMESPACE",
855    ]
856}
857
858impl<R: ProcessRunner> Git<R> {
859    /// Set the resettable output-inactivity window for Git's progress-streaming
860    /// fetch/push/clone methods. Disabled by default, preserving all existing
861    /// command timing when it is not configured.
862    pub fn default_inactivity_timeout(mut self, timeout: std::time::Duration) -> Self {
863        self.core = self.core.default_inactivity_timeout(timeout);
864        self
865    }
866
867    /// Retry **whole-repo lock-contention** failures (another process holds the
868    /// repo's `index.lock`) per `policy` — opt-in, off by default. Safe even for
869    /// mutating commands: that lock is acquired before any write, so a failure is
870    /// pre-execution (git never ran) and a retry can't double-apply. Per-ref lock
871    /// failures are *not* retried (a multi-ref op can fail a ref lock mid-way). See
872    /// [`RetryPolicy`] and [`is_lock_contention`].
873    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
874        self.core = self.core.with_retry(policy);
875        self
876    }
877
878    /// Supply credentials for **HTTPS** remote operations (`fetch`/`push`/`clone`/
879    /// `ls-remote`) via a [`CredentialProvider`] — opt-in, off by default (ambient
880    /// git credential helpers / SSH agent). When the provider yields a credential,
881    /// each remote op runs with an inline `credential.helper` that feeds the secret
882    /// from an environment variable, so the token never appears in `argv`. Local
883    /// operations are unaffected. This covers HTTPS only — an **SSH** remote ignores
884    /// the helper and authenticates via the ambient SSH agent, as before.
885    #[must_use]
886    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
887        self.core = self.core.with_credentials(provider);
888        self
889    }
890
891    /// Convenience for the common case: authenticate HTTPS remotes with a single
892    /// static `token` (a personal-access token; the default username
893    /// `x-access-token` is used). Shorthand for
894    /// `with_credentials(Arc::new(StaticCredential::token(token)))`. For a specific
895    /// username, build a [`Credential::userpass`] and use
896    /// [`with_credentials`](Git::with_credentials).
897    #[must_use]
898    pub fn with_token(self, token: impl Into<Secret>) -> Self {
899        self.with_credentials(Arc::new(StaticCredential::token(token)))
900    }
901
902    /// Convenience: read the HTTPS token from environment variable `var` at request
903    /// time; if `var` is unset/empty, fall back to ambient auth. Shorthand for
904    /// `with_credentials(Arc::new(EnvToken::new(var)))`.
905    #[must_use]
906    pub fn with_env_token(self, var: impl Into<String>) -> Self {
907        self.with_credentials(Arc::new(EnvToken::new(var)))
908    }
909
910    /// Resolve HTTPS credentials for a remote op into the leading `-c` config args
911    /// (an inline `credential.helper`) and the secret env to set on the command.
912    /// Both are empty when no provider is configured — ambient git auth, unchanged.
913    /// The secret lives only in the returned env, never in the args.
914    ///
915    /// `expect_host` scopes the helper to a host (the secret is released only for
916    /// that host, so a redirect/submodule to another host can't extract it).
917    /// Callers that know the operation's target host — e.g. `clone` from its URL —
918    /// pass it; the others pass `None` (the helper is ungated, as before).
919    ///
920    /// The same `expect_host` is **also** passed as the [`CredentialRequest`]'s host,
921    /// so a **host-keyed** provider selects the secret for that host — one `Git`
922    /// client cloning several hosts draws each host's own token, never a
923    /// neighbour's. A `None` host lets such a provider defer to ambient auth rather
924    /// than hand back the wrong host's secret (the fail-closed / ambient policy is
925    /// on [`ManagedClient::resolve_credential`](vcs_cli_support::ManagedClient::resolve_credential)).
926    async fn remote_credentials(
927        &self,
928        expect_host: Option<&str>,
929    ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
930        match self
931            .core
932            .resolve_credential(CredentialService::Git, expect_host)
933            .await?
934        {
935            Some(cred) => {
936                let helper = git_credential_helper(&cred, expect_host)?;
937                Ok((helper.config_args, helper.env))
938            }
939            None => Ok((Vec::new(), Vec::new())),
940        }
941    }
942}
943
944impl<R: ProcessRunner> Git<R> {
945    /// [`diff_text`](GitApi::diff_text) with an explicit per-call
946    /// [`OutputBudget`], instead of this client's
947    /// [`default_output_budget`](Git::default_output_budget). Use it to read a
948    /// legitimately large diff past a tighter client default
949    /// ([`OutputBudget::unlimited`], or a higher byte cap), or to tighten the cap
950    /// for one call. Past the ceiling the read errors with
951    /// [`ErrorReason::OutputTooLarge`] (actual and
952    /// allowed sizes) rather than buffering an unbounded diff.
953    pub async fn diff_text_within(
954        &self,
955        dir: &Path,
956        spec: DiffSpec,
957        budget: OutputBudget,
958    ) -> Result<String> {
959        self.diff_text_budgeted(dir, spec, budget).await
960    }
961
962    /// [`diff`](GitApi::diff) with an explicit per-call [`OutputBudget`] — the
963    /// parsed-model counterpart of [`diff_text_within`](Git::diff_text_within).
964    pub async fn diff_within(
965        &self,
966        dir: &Path,
967        spec: DiffSpec,
968        budget: OutputBudget,
969    ) -> Result<Vec<FileDiff>> {
970        let text = self.diff_text_budgeted(dir, spec, budget).await?;
971        Ok(parse_diff(&text))
972    }
973
974    /// [`GitApi::diff_text_between`] with an explicit per-call
975    /// [`OutputBudget`], instead of this client's default budget.
976    pub async fn diff_text_between_within(
977        &self,
978        dir: &Path,
979        from: &RevSpec,
980        to: &RevSpec,
981        budget: OutputBudget,
982    ) -> Result<String> {
983        self.diff_text_between_budgeted(dir, from, to, budget).await
984    }
985
986    /// [`GitApi::diff_between`] with an explicit per-call [`OutputBudget`].
987    pub async fn diff_between_within(
988        &self,
989        dir: &Path,
990        from: &RevSpec,
991        to: &RevSpec,
992        budget: OutputBudget,
993    ) -> Result<Vec<FileDiff>> {
994        let text = self
995            .diff_text_between_budgeted(dir, from, to, budget)
996            .await?;
997        Ok(parse_diff(&text))
998    }
999
1000    /// Shared body of [`diff_text`](GitApi::diff_text) /
1001    /// [`diff_text_within`](Git::diff_text_within): builds the `git diff` and runs
1002    /// it under `budget` (a fail-loud byte ceiling; unbounded when the budget is
1003    /// [`OutputBudget::unlimited`]).
1004    async fn diff_text_budgeted(
1005        &self,
1006        dir: &Path,
1007        spec: DiffSpec,
1008        budget: OutputBudget,
1009    ) -> Result<String> {
1010        // The target is a single positional arg: `HEAD` for the working tree, or
1011        // the caller's `Rev` string passed straight through, unparsed. `-M`
1012        // enables rename detection; `--no-color` / `--no-ext-diff` keep the
1013        // output stable and machine-parseable.
1014        //
1015        // That passthrough is why a lone `Rev` revision (no `..`/`...`) still
1016        // includes working-copy changes: `git diff <rev>` diffs the working tree
1017        // against `<rev>`. When `HEAD` exists that matches `WorkingTree`'s target;
1018        // on an unborn repository, `WorkingTree` selects the empty tree below
1019        // while a literal `Rev("HEAD")` remains unresolved and errors. See
1020        // `GitApi::diff_text`'s doc for the full explanation.
1021        let target = match spec {
1022            DiffSpec::WorkingTree => {
1023                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
1024                // diff against the empty tree so a pre-first-commit working tree
1025                // still yields its additions instead of a hard failure. The empty
1026                // tree's id depends on the repo's object format (the SHA-1
1027                // `EMPTY_TREE_SHA1` doesn't exist in a SHA-256 repo), so resolve it
1028                // from git rather than hard-coding — see `empty_tree_oid`.
1029                if self.is_unborn(dir).await? {
1030                    self.empty_tree_oid(dir).await?
1031                } else {
1032                    "HEAD".to_string()
1033                }
1034            }
1035            DiffSpec::Rev(rev) => {
1036                reject_flag_like("revision", &rev)?;
1037                rev
1038            }
1039        };
1040        // The explicit prefixes pin the `a/`…`b/` form the shared parser extracts
1041        // paths from — a user's `diff.noprefix` / `diff.mnemonicPrefix` config
1042        // would otherwise change the headers and make every file silently vanish
1043        // from the parse. (Command-line prefixes override both config options.)
1044        // `run_untrimmed_within`: trimming the diff would drop a trailing blank
1045        // context line, desyncing the last hunk from its `@@` line count for a
1046        // consumer that re-applies or re-parses it (H7); the budget bounds it.
1047        // Trailing `--`: pin `target` as a revision, never a pathspec — without it
1048        // a `Rev` that happens to name a tracked path would diff the working tree
1049        // for that path instead of the intended commit (the C2/M13 collision
1050        // class). `reject_flag_like` already blocks a leading `-`; `--` closes the
1051        // path-collision half.
1052        self.core
1053            .run_untrimmed_within(
1054                self.core.command_in(
1055                    dir,
1056                    [
1057                        "diff",
1058                        target.as_str(),
1059                        "--no-color",
1060                        "--no-ext-diff",
1061                        "-M",
1062                        "--src-prefix=a/",
1063                        "--dst-prefix=b/",
1064                        "--",
1065                    ],
1066                ),
1067                budget,
1068            )
1069            .await
1070    }
1071
1072    /// Shared body of [`GitApi::diff_text_between`] and
1073    /// [`Git::diff_text_between_within`]. Both endpoints are already validated
1074    /// by [`RevSpec`]; keeping them as separate argv values avoids turning a
1075    /// compound selector into a range string or allowing one endpoint to be
1076    /// reinterpreted as a pathspec.
1077    async fn diff_text_between_budgeted(
1078        &self,
1079        dir: &Path,
1080        from: &RevSpec,
1081        to: &RevSpec,
1082        budget: OutputBudget,
1083    ) -> Result<String> {
1084        // Keep the same stable output flags and explicit prefixes as the
1085        // one-endpoint diff. The trailing `--` makes both validated endpoints
1086        // unambiguously revisions, never pathspecs; it also keeps the endpoint
1087        // direction exactly `from` → `to`.
1088        self.core
1089            .run_untrimmed_within(
1090                self.core.command_in(
1091                    dir,
1092                    [
1093                        "diff",
1094                        from.as_str(),
1095                        to.as_str(),
1096                        "--no-color",
1097                        "--no-ext-diff",
1098                        "-M",
1099                        "--src-prefix=a/",
1100                        "--dst-prefix=b/",
1101                        "--",
1102                    ],
1103                ),
1104                budget,
1105            )
1106            .await
1107    }
1108
1109    /// [`show_file`](GitApi::show_file) with an explicit per-call
1110    /// [`OutputBudget`], instead of this client's
1111    /// [`default_output_budget`](Git::default_output_budget). Reads a blob's bytes
1112    /// under `budget`: past the ceiling the read errors with
1113    /// [`ErrorReason::OutputTooLarge`] rather than
1114    /// buffering an unbounded file.
1115    ///
1116    /// Rejects an empty/whitespace-only `path` before spawning, exactly as
1117    /// [`show_file`](GitApi::show_file) documents (it delegates here).
1118    pub async fn show_file_within(
1119        &self,
1120        dir: &Path,
1121        rev: &RevSpec,
1122        path: &str,
1123        budget: OutputBudget,
1124    ) -> Result<String> {
1125        // An empty (or whitespace-only) `path` would reduce `spec` below to a bare
1126        // `<rev>:`, which git does NOT reject: it prints the root TREE LISTING and
1127        // exits 0, so the read would hand back a directory index dressed up as a
1128        // file's content. Refuse before spawning — see `reject_empty_path` for why
1129        // this slot takes an emptiness guard rather than `reject_flag_like`, and
1130        // for the (differently shaped, equally silent) jj half of the same bug.
1131        reject_empty_path("file path", path)?;
1132        let rev = rev.as_str();
1133        // git rejects backslash separators in the `<rev>:<path>` spec ("exists on
1134        // disk, but not in <rev>") — normalise for Windows callers. Only on Windows:
1135        // on Unix a backslash is a legal filename byte, and rewriting it would make
1136        // a literal `a\b.txt` unresolvable.
1137        #[cfg(windows)]
1138        let path = path.replace('\\', "/");
1139        let spec = format!("{rev}:{path}");
1140        // `run_untrimmed_within`: a blob's trailing newline(s) are part of its
1141        // content — trimming them corrupts a read-modify-write round-trip (H7); the
1142        // budget bounds it.
1143        self.core
1144            .run_untrimmed_within(self.core.command_in(dir, ["show", spec.as_str()]), budget)
1145            .await
1146    }
1147}
1148
1149/// Set each secret environment variable on `cmd` (the values from
1150/// [`Git::remote_credentials`]). A no-op when `envs` is empty.
1151fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
1152    envs.iter()
1153        .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
1154}
1155
1156#[async_trait::async_trait]
1157impl<R: ProcessRunner> GitApi for Git<R> {
1158    async fn run(&self, args: &[String]) -> Result<String> {
1159        self.core.run(args).await
1160    }
1161
1162    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1163        self.core.output_string(args).await
1164    }
1165
1166    async fn version(&self) -> Result<String> {
1167        self.core.run(["--version"]).await
1168    }
1169
1170    async fn capabilities(&self) -> Result<GitCapabilities> {
1171        let raw = self.version().await?;
1172        let version = parse::parse_git_version(&raw).ok_or_else(|| {
1173            Error::parse(
1174                BINARY,
1175                format!("unrecognisable `git --version` output: {raw:?}"),
1176            )
1177        })?;
1178        Ok(GitCapabilities { version })
1179    }
1180
1181    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1182        // `parse_bytes`: `-z` paths are raw bytes that may not be valid UTF-8 on
1183        // Unix, so parse from the byte stream — a lossy `String` decode would
1184        // corrupt a non-ASCII/non-UTF-8 filename before it reaches the caller.
1185        self.core
1186            .parse_bytes(
1187                self.core
1188                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1189                parse::parse_porcelain,
1190            )
1191            .await
1192    }
1193
1194    async fn status_text(&self, dir: &Path) -> Result<String> {
1195        self.core
1196            .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1197            .await
1198    }
1199
1200    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1201        // `GIT_OPTIONAL_LOCKS=0`: skip the opportunistic index refresh-write a
1202        // `status` may otherwise persist. This is the snapshot/poll primitive —
1203        // a filesystem watcher re-querying through it must not have the query
1204        // itself dirty `.git/index` and re-trigger the watch (verified: with
1205        // optional locks off, a re-query writes nothing).
1206        self.core
1207            .parse(
1208                self.core
1209                    .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1210                    .env("GIT_OPTIONAL_LOCKS", "0"),
1211                parse::parse_porcelain_v2,
1212            )
1213            .await
1214    }
1215
1216    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1217        self.core
1218            .parse_bytes(
1219                self.core.command_in(
1220                    dir,
1221                    ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1222                ),
1223                parse::parse_porcelain,
1224            )
1225            .await
1226    }
1227
1228    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>> {
1229        // `-z` keeps special-character paths literal (no C-style quoting); parse
1230        // from raw bytes so a non-UTF-8 conflicted path survives losslessly.
1231        self.core
1232            .parse_bytes(
1233                self.core
1234                    .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1235                parse::parse_nul_paths,
1236            )
1237            .await
1238    }
1239
1240    async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1241        // `symbolic-ref --quiet --short HEAD` is the one command that answers all
1242        // three head states correctly in a single spawn: it prints the branch name
1243        // (exit 0) for a normal **and an unborn** branch (a fresh `init`/`clone`
1244        // before the first commit — where `rev-parse --abbrev-ref HEAD` instead
1245        // *errors* with exit 128), and `--quiet` makes a detached HEAD a silent
1246        // exit 1 (HEAD isn't a symbolic ref) rather than a `fatal:`. So map exit
1247        // 0 → `Some(branch)`, exit 1 → `None` (detached), and anything else (e.g.
1248        // not a repository, exit 128) stays a real error.
1249        let res = self
1250            .core
1251            .output_string(
1252                self.core
1253                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1254            )
1255            .await?;
1256        match res.code() {
1257            Some(0) => Ok(Some(res.stdout().trim().to_string())),
1258            Some(1) => Ok(None), // detached HEAD: no named branch
1259            _ => {
1260                let _ = res.ensure_success()?;
1261                Ok(None) // unreachable: a non-zero exit always errors above
1262            }
1263        }
1264    }
1265
1266    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1267        // `--no-column` + `--no-color`: `column.ui = always` would columnate
1268        // several names onto one line and `color.{ui,branch} = always` would inject
1269        // ANSI escapes — both even when piped, corrupting the line parser and the
1270        // returned names.
1271        self.core
1272            .parse(
1273                self.core
1274                    .command_in(dir, ["branch", "--no-column", "--no-color"]),
1275                parse::parse_branches,
1276            )
1277            .await
1278    }
1279
1280    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>> {
1281        let n = format!("-n{max}");
1282        self.core
1283            .parse(
1284                self.core.command_in(
1285                    dir,
1286                    [
1287                        "log",
1288                        revspec.as_str(),
1289                        n.as_str(),
1290                        "-z",
1291                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1292                        "--",
1293                    ],
1294                ),
1295                parse::parse_log,
1296            )
1297            .await
1298    }
1299
1300    async fn log_paths(
1301        &self,
1302        dir: &Path,
1303        revspec: &RevSpec,
1304        max: usize,
1305        paths: &[String],
1306    ) -> Result<Vec<Commit>> {
1307        // An empty `paths` would build `git log <revspec> -n <max> -- ` — no
1308        // pathspecs after `--` is the same as no `--` at all, i.e. an
1309        // UNRESTRICTED log. That's the opposite of "scoped to these paths",
1310        // so refuse before spawning (mirrors `JjApi::commit_paths`'s
1311        // empty-fileset guard).
1312        if paths.is_empty() {
1313            return Err(Error::spawn(
1314                BINARY,
1315                std::io::Error::new(
1316                    std::io::ErrorKind::InvalidInput,
1317                    "log_paths requires at least one path — an empty list would log \
1318                     unrestricted history, not history scoped to the named paths",
1319                ),
1320            ));
1321        }
1322        // R-05: a single path this long can never be transmitted at all — `git
1323        // log` has no `--pathspec-from-file`/NUL-safe transport to fall back
1324        // to (unlike `add`/`commit_paths`, verified against real git: the flag
1325        // is rejected with "unrecognized argument"), so no chunking scheme can
1326        // help it. Reject up front, before `chunk_pathspecs` would otherwise
1327        // emit it as an over-budget singleton chunk that `git`/the OS would
1328        // then fail on anyway, with a much less legible spawn error.
1329        if let Some(oversized) = paths.iter().find(|p| p.len() + 1 > ARGV_PATHSPEC_BUDGET) {
1330            return Err(Error::spawn(
1331                BINARY,
1332                std::io::Error::new(
1333                    std::io::ErrorKind::InvalidInput,
1334                    format!(
1335                        "log_paths: a single path is {} bytes, exceeding the \
1336                         {ARGV_PATHSPEC_BUDGET}-byte argv pathspec budget on its own — \
1337                         `git log` has no NUL-safe pathspec-from-file transport (unlike \
1338                         `add`/`commit_paths`), so this path cannot be transmitted at all: \
1339                         {oversized:?}",
1340                        oversized.len() + 1,
1341                    ),
1342                ),
1343            ));
1344        }
1345        let n = format!("-n{max}");
1346        let chunks = chunk_pathspecs(paths);
1347        if chunks.len() <= 1 {
1348            // The common case: everything fits one call — byte-identical to the
1349            // pre-T-052 behavior (order included). A single invocation can't
1350            // observe a moving repository state mid-operation, so `revspec` is
1351            // forwarded as-is — no R-04 resolution needed here.
1352            let command = self.log_paths_command(
1353                dir,
1354                [revspec.as_str()],
1355                &n,
1356                paths.iter().map(String::as_str),
1357            );
1358            return self.core.parse(command, parse::parse_log).await;
1359        }
1360        // Large path set (T-052): `git log` has no `--pathspec-from-file`
1361        // support (unlike `add`/`commit_paths`), so split the pathspecs across
1362        // multiple argv-budget-sized calls and merge the results: dedup by hash
1363        // (a commit can touch paths spread across more than one chunk), then
1364        // reorder and cap at `max`. Requesting `-n max` per chunk is enough to
1365        // guarantee the merged top-`max` is correct: any commit within the true
1366        // (merged) top `max` has at most `max - 1` newer commits in the *entire*
1367        // union of chunk results, so it has at most that many newer commits
1368        // within its own chunk too — i.e. it always ranks within that chunk's
1369        // own top `max`. This bound is about *how many* qualifying commits can
1370        // precede another, not about how they are ordered, so it holds
1371        // regardless of the ordering mechanism below.
1372        //
1373        // R-04: this branch makes several independent `git` invocations (one
1374        // per chunk, plus the order oracle below), each of which would
1375        // otherwise re-resolve `revspec` on its own — a symbolic name like
1376        // `HEAD` can move (or a range's endpoints can) between any two of
1377        // them. Resolve it exactly once, up front, into the fixed set of
1378        // commit ids `git log` would internally expand it to (a plain rev
1379        // resolves to one id; a range like `A..B` resolves to two tokens, the
1380        // excluded side `^`-prefixed — `git rev-parse` performs the same
1381        // expansion `git log` does internally, so forwarding its output
1382        // verbatim is behavior-preserving for what a single, hypothetical
1383        // unchunked call would have seen). Every call below then reuses this
1384        // one fixed snapshot, so no ref movement during the operation can make
1385        // two of them disagree about what "`revspec`" names.
1386        let resolved_revspec: Vec<String> = self
1387            .core
1388            .run(self.core.command_in(dir, ["rev-parse", revspec.as_str()]))
1389            .await?
1390            .lines()
1391            .map(str::trim)
1392            .filter(|line| !line.is_empty())
1393            .map(str::to_string)
1394            .collect();
1395        let mut merged: Vec<Commit> = Vec::new();
1396        let mut seen = std::collections::HashSet::new();
1397        for chunk in &chunks {
1398            let command = self.log_paths_command(
1399                dir,
1400                resolved_revspec.iter().map(String::as_str),
1401                &n,
1402                chunk.iter().copied(),
1403            );
1404            let commits = self.core.parse(command, parse::parse_log).await?;
1405            for commit in commits {
1406                if seen.insert(commit.hash.clone()) {
1407                    merged.push(commit);
1408                }
1409            }
1410        }
1411        // Merging per-chunk call results loses git's own single-call order, so
1412        // restore it (R-03) via a hash-order oracle: one extra, pathless `git
1413        // log <revspec> --format=%H` call over the *same*, now-frozen revspec
1414        // (cheap — no per-path diff computation, just hashes) gives the exact
1415        // relative order a single unchunked call would have produced, since
1416        // pathspec filtering only drops non-matching commits without
1417        // reordering the ones that remain. A commit absent from the oracle
1418        // (should not happen — it always names a commit `log_paths` itself
1419        // just returned as reachable from `revspec`) sorts after every ranked
1420        // commit rather than panicking.
1421        let order_command = self.log_paths_order_command(dir, &resolved_revspec);
1422        let order = self.core.parse(order_command, parse_commit_order).await?;
1423        let rank: std::collections::HashMap<&str, usize> = order
1424            .iter()
1425            .enumerate()
1426            .map(|(index, hash)| (hash.as_str(), index))
1427            .collect();
1428        merged.sort_by_key(|commit| {
1429            rank.get(commit.hash.as_str())
1430                .copied()
1431                .unwrap_or(usize::MAX)
1432        });
1433        merged.truncate(max);
1434        Ok(merged)
1435    }
1436
1437    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1438        // `--verify`: without it, `git rev-parse Makefile` echoes the *filename* back
1439        // as a fake object id (exit 0), so a caller resolving an untrusted revision
1440        // could get a non-hash. `--verify` requires `rev` to name exactly one object,
1441        // erroring otherwise — a valid revision still resolves to the same full hash
1442        // (M13; matches `rev_parse_short`/`resolve_commit`, which already `--verify`).
1443        self.core
1444            .run(
1445                self.core
1446                    .command_in(dir, ["rev-parse", "--verify", rev.as_str()]),
1447            )
1448            .await
1449    }
1450
1451    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1452        // `--verify` (matching `rev_parse`/`resolve_commit`): require `rev` to name
1453        // exactly one object, erroring otherwise. Unlike bare `rev-parse` — which
1454        // echoes a filename back as a fake id (the M13 bug) — `--short` already
1455        // rejects a plain path (`Needed a single revision`), so this is
1456        // consistency / defense-in-depth: it pins the single-object contract
1457        // explicitly instead of leaning on `--short`'s incidental rejection. A real
1458        // revision still abbreviates the same.
1459        self.core
1460            .run(
1461                self.core
1462                    .command_in(dir, ["rev-parse", "--verify", "--short", rev.as_str()]),
1463            )
1464            .await
1465    }
1466
1467    async fn init(&self, dir: &Path) -> Result<()> {
1468        self.core
1469            .run_unit(self.core.command_in(dir, ["init"]))
1470            .await
1471    }
1472
1473    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1474        if pathspec_argv_len(paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1475            // Large path set (T-052): route over stdin instead of argv, so
1476            // there is no OS command-line limit left to exceed.
1477            // `--literal-pathspecs` matches each path byte-for-byte instead of
1478            // treating `*`/`?`/`[]` as pathspec glob magic.
1479            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1480                paths.iter().map(|p| p.as_os_str()),
1481            )?);
1482            let command = self
1483                .core
1484                .command_in(
1485                    dir,
1486                    [
1487                        "--literal-pathspecs",
1488                        "add",
1489                        "--pathspec-from-file=-",
1490                        "--pathspec-file-nul",
1491                    ],
1492                )
1493                .stdin(stdin);
1494            return self.core.run_unit(command).await;
1495        }
1496        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1497        // as the stdin branch above — without it, a path containing `*`/`?`/`[]`
1498        // would be read as pathspec glob magic instead of matched byte-for-byte
1499        // (R-01). `--` separates the pathspecs so a path can never be read as an
1500        // option.
1501        let mut command = self
1502            .core
1503            .command_in(dir, ["--literal-pathspecs", "add", "--"]);
1504        for path in paths {
1505            command = command.arg(path);
1506        }
1507        self.core.run_unit(command).await
1508    }
1509
1510    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1511        // C locale: a failure's output feeds `is_nothing_to_commit`.
1512        self.core
1513            .run_unit(c_locale(
1514                self.core.command_in(dir, ["commit", "-m", message]),
1515            ))
1516            .await
1517    }
1518
1519    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()> {
1520        self.core
1521            .run_unit(self.core.command_in(dir, ["branch", name.as_str()]))
1522            .await
1523    }
1524
1525    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
1526        // `target.as_arg()` is either a validated `RevSpec` or the fixed `-`
1527        // literal ([`CheckoutTarget::Previous`]) — never caller-controlled argv,
1528        // so no flag can be injected here. The trailing `--` marks the end of
1529        // revisions with no pathspecs following, so git resolves the target as a
1530        // ref *only*. Without it a target that doesn't name a ref but names a
1531        // tracked path silently falls into pathspec mode and restores that path
1532        // from the index, discarding unstaged edits (verified: `git checkout
1533        // notes.txt` → "Updated 1 path", exit 0; `git checkout notes.txt --` →
1534        // hard error).
1535        self.core
1536            .run_unit(
1537                self.core
1538                    .command_in(dir, ["checkout", target.as_arg(), "--"]),
1539            )
1540            .await
1541    }
1542
1543    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()> {
1544        self.core
1545            .run_unit(
1546                self.core
1547                    .command_in(dir, ["checkout", "--detach", commit.as_str()]),
1548            )
1549            .await
1550    }
1551
1552    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1553        // `--only -- <paths>` commits exactly these paths' working-tree content
1554        // regardless of the index; `--` keeps a path from being read as an option.
1555        // C locale: a failure's output feeds `is_nothing_to_commit`.
1556        if pathspec_argv_len(spec.paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1557            // Large path set (T-052): same NUL-safe stdin transport as `add`'s
1558            // twin branch. Still exactly one `git commit` invocation either
1559            // way — the atomic-commit contract never depends on the path
1560            // set's size, since no chunking is ever needed here.
1561            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1562                spec.paths.iter().map(|p| p.as_os_str()),
1563            )?);
1564            let mut command =
1565                c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1566            if spec.amend {
1567                command = command.arg("--amend");
1568            }
1569            command = command
1570                .arg("-m")
1571                .arg(spec.message)
1572                .arg("--only")
1573                .arg("--pathspec-from-file=-")
1574                .arg("--pathspec-file-nul")
1575                .stdin(stdin);
1576            return self.core.run_unit(command).await;
1577        }
1578        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1579        // as the stdin branch above — without it, a glob-magic character
1580        // (`*`/`?`/`[]`) in a path would be expanded as a pathspec pattern
1581        // instead of matched byte-for-byte, which could commit the wrong files
1582        // and violate `commit_paths`'s "exactly these paths" contract (R-01).
1583        let mut command = c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1584        if spec.amend {
1585            command = command.arg("--amend");
1586        }
1587        command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1588        for path in &spec.paths {
1589            command = command.arg(path);
1590        }
1591        self.core.run_unit(command).await
1592    }
1593
1594    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1595        self.core
1596            .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1597            .await
1598    }
1599
1600    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1601        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
1602        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
1603        // and surfaces anything else (e.g. 128, not a repo) as `ErrorReason::Exit`.
1604        Ok(!self
1605            .core
1606            .probe(
1607                self.core
1608                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1609            )
1610            .await?)
1611    }
1612
1613    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1614        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
1615        // `probe` errors on any other code / timeout / signal.
1616        self.core
1617            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1618            .await
1619    }
1620
1621    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1622        Ok(PathBuf::from(
1623            self.core
1624                .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1625                .await?,
1626        ))
1627    }
1628
1629    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1630        Ok(PathBuf::from(
1631            self.core
1632                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1633                .await?,
1634        ))
1635    }
1636
1637    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1638        // `^{commit}` peels an annotated tag down to the commit it points at.
1639        let spec = format!("{}^{{commit}}", rev.as_str());
1640        self.core
1641            .run(
1642                self.core
1643                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1644            )
1645            .await
1646    }
1647
1648    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1649        // `--quiet` makes an *unset* origin/HEAD a silent **exit 1** (no `fatal:`
1650        // on stderr); that's "no default branch", not an error. Map exit 0 → the
1651        // branch, exit 1 → `None`, and anything else (a real failure like "not a
1652        // repository" exit 128, or a timeout/signal with no exit code) surfaces via
1653        // `ensure_success` — mirroring `config_get`, rather than swallowing it.
1654        let res = self
1655            .core
1656            .output_string(
1657                self.core
1658                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1659            )
1660            .await?;
1661        match res.code() {
1662            Some(0) => {
1663                // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so
1664                // a slashed default branch (e.g. "release/v2") survives intact.
1665                let out = res.stdout().trim();
1666                Ok(Some(
1667                    out.strip_prefix("refs/remotes/origin/")
1668                        .unwrap_or(out)
1669                        .to_string(),
1670                ))
1671            }
1672            Some(1) => Ok(None), // unset origin/HEAD
1673            _ => {
1674                let _ = res.ensure_success()?;
1675                Ok(None) // unreachable: a non-zero/no-code exit always errors above
1676            }
1677        }
1678    }
1679
1680    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1681        let refname = format!("refs/heads/{}", name.as_str());
1682        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
1683        self.core
1684            .probe(
1685                self.core
1686                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1687            )
1688            .await
1689    }
1690
1691    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1692        // `RefName` already forbids the glob/control/`:` characters this probe
1693        // must exclude (its `check-ref-format` rules are a strict superset), so
1694        // the value is safe to interpolate into the `refs/heads/<name>` ref below.
1695        let name = name.as_str();
1696        // No credential prompt, bounded wait: a missing helper or a flaky network
1697        // must not hang the call. `output_string` reports a timeout as a flagged result
1698        // (non-zero exit) rather than erroring, so an unreachable remote reads as
1699        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
1700        // spawn failure (no `git`) still surfaces as an error.
1701        //
1702        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
1703        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
1704        // `refs/heads/<name>` matches only the exact branch.
1705        let refname = format!("refs/heads/{name}");
1706        let (pre, envs) = self.remote_credentials(None).await?;
1707        let mut args: Vec<String> = pre;
1708        args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1709        let cmd = apply_secret_env(
1710            self.core
1711                .command_in(dir, &args)
1712                .env("GIT_TERMINAL_PROMPT", "0")
1713                .timeout(Duration::from_secs(10)),
1714            &envs,
1715        );
1716        let res = self.core.output_string(cmd).await?;
1717        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1718    }
1719
1720    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1721        reject_flag_like("remote name", remote)?;
1722        self.core
1723            .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1724            .await
1725    }
1726
1727    async fn remote_list(&self, dir: &Path) -> Result<Vec<Remote>> {
1728        self.core
1729            .parse(
1730                self.core.command_in(dir, ["remote", "-v"]),
1731                parse::parse_remotes,
1732            )
1733            .await
1734    }
1735
1736    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1737        // Validate that HEAD is attached before asking for `@{u}`. Git otherwise
1738        // uses exit 128 both for "no upstream" and for detached HEAD/not-a-repo,
1739        // so the upstream query alone cannot distinguish those states.
1740        let head = self
1741            .core
1742            .output_string(
1743                self.core
1744                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1745            )
1746            .await?;
1747        let _ = head.ensure_success()?;
1748
1749        // Once HEAD is known to be an attached branch, exit 128 is the documented
1750        // "no upstream configured" case. Every other failure, including a timeout
1751        // or signal (which has no exit code), remains a real error.
1752        let res = self
1753            .core
1754            .output_string(self.core.command_in(
1755                dir,
1756                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1757            ))
1758            .await?;
1759        match res.code() {
1760            Some(0) => {
1761                let name = res.stdout().trim();
1762                Ok((!name.is_empty()).then(|| name.to_string()))
1763            }
1764            Some(128) => Ok(None),
1765            _ => {
1766                let _ = res.ensure_success()?;
1767                Ok(None) // unreachable: every remaining outcome is unsuccessful
1768            }
1769        }
1770    }
1771
1772    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1773        reject_flag_like("remote name", remote)?;
1774        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
1775        // never block on an interactive auth prompt. A provider, if set, supplies
1776        // the credential via an inline helper (token kept out of argv).
1777        let (pre, envs) = self.remote_credentials(None).await?;
1778        let mut args: Vec<String> = pre;
1779        args.extend(["ls-remote", "--heads", remote].map(String::from));
1780        let cmd = apply_secret_env(
1781            self.core
1782                .command_in(dir, &args)
1783                .env("GIT_TERMINAL_PROMPT", "0"),
1784            &envs,
1785        );
1786        self.core.parse(cmd, parse::parse_ls_remote_heads).await
1787    }
1788
1789    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
1790        // `--no-column` + `--no-color`: under `column.ui = always` git would pack
1791        // several names per line and under `color.{ui,branch} = always` it would
1792        // inject ANSI escapes — both even when piped, so the marker-stripping
1793        // compare below would never match (a false "not merged").
1794        let out = self
1795            .core
1796            .run(self.core.command_in(
1797                dir,
1798                [
1799                    "branch",
1800                    "--merged",
1801                    spec.base.as_str(),
1802                    "--no-column",
1803                    "--no-color",
1804                ],
1805            ))
1806            .await?;
1807        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
1808        // drop exactly those two columns rather than trimming a char class (which
1809        // would over-strip a name that legitimately began with the marker char).
1810        Ok(out
1811            .lines()
1812            .filter_map(|line| line.get(2..))
1813            .any(|b| b == spec.branch.as_str()))
1814    }
1815
1816    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()> {
1817        let flag = format!("--set-upstream-to={}", upstream.as_str());
1818        self.core
1819            .run_unit(
1820                self.core
1821                    .command_in(dir, ["branch", flag.as_str(), branch.as_str()]),
1822            )
1823            .await
1824    }
1825
1826    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()> {
1827        let flag = if spec.force { "-D" } else { "-d" };
1828        self.core
1829            .run_unit(
1830                self.core
1831                    .command_in(dir, ["branch", flag, spec.name.as_str()]),
1832            )
1833            .await
1834    }
1835
1836    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()> {
1837        self.core
1838            .run_unit(
1839                self.core
1840                    .command_in(dir, ["branch", "-m", old.as_str(), new.as_str()]),
1841            )
1842            .await
1843    }
1844
1845    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize> {
1846        self.core
1847            .try_parse(
1848                self.core
1849                    .command_in(dir, ["rev-list", "--count", range.as_str()]),
1850                |s| {
1851                    s.trim()
1852                        .parse::<usize>()
1853                        .map_err(|e| Error::parse(BINARY, e.to_string()))
1854                },
1855            )
1856            .await
1857    }
1858
1859    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool> {
1860        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
1861        // The trailing `--` forces `range` to be read as a revision/range, not a
1862        // pathspec: without it `git diff --quiet Makefile` diffs the *working
1863        // tree* limited to that path (exit 1 = "has changes"), so a caller string
1864        // that names a file returns a plausible-but-wrong bool instead of erroring
1865        // (the C2/M13 pathspec-collision class). With `--`, an unresolvable
1866        // revision exits 128, which `probe` surfaces as an honest error.
1867        self.core
1868            .probe(
1869                self.core
1870                    .command_in(dir, ["diff", "--quiet", range.as_str(), "--"]),
1871            )
1872            .await
1873    }
1874
1875    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat> {
1876        // `LC_ALL=C`: git's `--shortstat` summary ("N file(s) changed, …") is
1877        // gettext-translated, but `parse_shortstat` keys on the English
1878        // "file"/"insertion"/"deletion" — without C locale a non-English git
1879        // returns an all-zero `DiffStat` rather than the real counts.
1880        // Trailing `--`: force `range` to resolve as a revision/range, never a
1881        // pathspec (see `diff_range_is_empty` — a path-named `range` would
1882        // otherwise stat the working tree for that path instead of erroring).
1883        self.core
1884            .parse(
1885                c_locale(
1886                    self.core
1887                        .command_in(dir, ["diff", "--shortstat", range.as_str(), "--"]),
1888                ),
1889                parse::parse_shortstat,
1890            )
1891            .await
1892    }
1893
1894    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1895        self.diff_text_budgeted(dir, spec, self.core.output_budget())
1896            .await
1897    }
1898
1899    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1900        let text = self.diff_text(dir, spec).await?;
1901        Ok(parse_diff(&text))
1902    }
1903
1904    async fn diff_text_between(&self, dir: &Path, from: &RevSpec, to: &RevSpec) -> Result<String> {
1905        self.diff_text_between_budgeted(dir, from, to, self.core.output_budget())
1906            .await
1907    }
1908
1909    async fn diff_between(
1910        &self,
1911        dir: &Path,
1912        from: &RevSpec,
1913        to: &RevSpec,
1914    ) -> Result<Vec<FileDiff>> {
1915        let text = self.diff_text_between(dir, from, to).await?;
1916        Ok(parse_diff(&text))
1917    }
1918
1919    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1920        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
1921        self.core
1922            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1923            .await
1924    }
1925
1926    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1927        let git_dir = self.resolved_git_dir(dir).await?;
1928        // `rebase-merge/` is a merge-backend rebase. `rebase-apply/` is shared by an
1929        // apply-backend rebase AND `git am` — but `git am` marks it with an `applying`
1930        // file, so exclude that (it's an am, aborted with `am --abort`, not
1931        // `rebase --abort`; see `is_am_in_progress`). M20.
1932        let rebase_apply = git_dir.join("rebase-apply");
1933        let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
1934        Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
1935    }
1936
1937    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
1938        // `git am` uses `rebase-apply/` with an `applying` marker file (an
1939        // apply-backend rebase uses the same dir *without* it).
1940        Ok(self
1941            .resolved_git_dir(dir)
1942            .await?
1943            .join("rebase-apply")
1944            .join("applying")
1945            .exists())
1946    }
1947
1948    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1949        Ok(self
1950            .resolved_git_dir(dir)
1951            .await?
1952            .join("MERGE_HEAD")
1953            .exists())
1954    }
1955
1956    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool> {
1957        Ok(self
1958            .resolved_git_dir(dir)
1959            .await?
1960            .join("CHERRY_PICK_HEAD")
1961            .exists())
1962    }
1963
1964    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool> {
1965        Ok(self
1966            .resolved_git_dir(dir)
1967            .await?
1968            .join("REVERT_HEAD")
1969            .exists())
1970    }
1971
1972    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool> {
1973        // `BISECT_LOG` is git's own canonical "a bisect is running" marker (it also
1974        // drives `git bisect log`); the other BISECT_* files are session details.
1975        Ok(self
1976            .resolved_git_dir(dir)
1977            .await?
1978            .join("BISECT_LOG")
1979            .exists())
1980    }
1981
1982    async fn fetch(&self, dir: &Path) -> Result<()> {
1983        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
1984        // rather than blocking on an interactive prompt — matching the other
1985        // remote ops (`fetch_branch`, `push`, `remote_branch_exists`).
1986        // Fetch is idempotent, so `retry` replays it on a transient failure
1987        // (DNS/timeout/dropped connection); a non-transient error fails at once.
1988        // C locale: the retry decision classifies the failure's message.
1989        // Leading `-c` credential.helper (+ secret env) when a provider is set.
1990        let (pre, envs) = self.remote_credentials(None).await?;
1991        let mut args: Vec<String> = pre;
1992        args.extend(["fetch", "--quiet"].map(String::from));
1993        // `budget_diagnostics`: bound the retained failure/progress output (a
1994        // drop-oldest tail — never `OutputTooLarge`, so `is_transient_fetch_error`
1995        // still classifies the tail-preserved message). Unbounded by default.
1996        let cmd = self.core.budget_diagnostics(apply_secret_env(
1997            vcs_cli_support::apply_fetch_completion_policy(
1998                c_locale(self.core.command_in(dir, &args))
1999                    .env("GIT_TERMINAL_PROMPT", "0")
2000                    // Unix uses its graceful signal; Windows opts console git into
2001                    // CTRL_BREAK when delivery is available, with the existing
2002                    // hard-kill fallback for every other child.
2003                    .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2004            ),
2005            &envs,
2006        ));
2007        self.core.run_unit(cmd).await
2008    }
2009
2010    async fn fetch_with_progress<'a>(
2011        &self,
2012        dir: &Path,
2013        progress: &'a mut ProgressCallback<'a>,
2014    ) -> Result<()> {
2015        let (pre, envs) = self.remote_credentials(None).await?;
2016        let mut args: Vec<String> = pre;
2017        // `--progress` forces git to emit transfer progress even though
2018        // processkit pipes stderr rather than attaching a terminal.
2019        args.extend(["fetch", "--progress"].map(String::from));
2020        let cmd = self.core.budget_diagnostics(apply_secret_env(
2021            vcs_cli_support::apply_fetch_completion_policy(
2022                c_locale(self.core.command_in(dir, &args)).env("GIT_TERMINAL_PROMPT", "0"),
2023            ),
2024            &envs,
2025        ));
2026        self.core.run_with_progress(cmd, progress).await
2027    }
2028
2029    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
2030        // A leading-`-` remote is a bare positional here — and a flag like
2031        // `--upload-pack=<cmd>` would run an arbitrary local program for a
2032        // local/ext transport, so this guard is load-bearing for security.
2033        reject_flag_like("remote", remote)?;
2034        // Same containment as `fetch` (prompt off, C locale, transient retry,
2035        // optional credential helper), with the remote named explicitly.
2036        let (pre, envs) = self.remote_credentials(None).await?;
2037        let mut args: Vec<String> = pre;
2038        args.extend(["fetch", "--quiet", remote].map(String::from));
2039        let cmd = self.core.budget_diagnostics(apply_secret_env(
2040            vcs_cli_support::apply_fetch_completion_policy(
2041                c_locale(self.core.command_in(dir, &args))
2042                    .env("GIT_TERMINAL_PROMPT", "0")
2043                    .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2044            ),
2045            &envs,
2046        ));
2047        self.core.run_unit(cmd).await
2048    }
2049
2050    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()> {
2051        // `RefName` already forbids the glob/control/`:` characters this refspec
2052        // must exclude (a strict superset), so both interpolations below are safe.
2053        let branch = branch.as_str();
2054        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
2055        let (pre, envs) = self.remote_credentials(None).await?;
2056        let mut args: Vec<String> = pre;
2057        args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
2058        let cmd = self.core.budget_diagnostics(apply_secret_env(
2059            vcs_cli_support::apply_fetch_completion_policy(
2060                c_locale(self.core.command_in(dir, &args))
2061                    .env("GIT_TERMINAL_PROMPT", "0")
2062                    .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2063            ),
2064            &envs,
2065        ));
2066        self.core.run_unit(cmd).await
2067    }
2068
2069    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
2070        reject_flag_like("remote", &spec.remote)?;
2071        reject_flag_like("refspec", &spec.refspec)?;
2072        // M16: `reject_flag_like` catches a leading `-`/empty/NUL, but not the refspec
2073        // metacharacters that silently change what a push *does* — a leading `+`
2074        // (force-push, overwriting the remote non-fast-forward) or an extra `:` (push
2075        // to an unexpected remote ref). A valid refspec here is `branch` or
2076        // `local:remote_branch` (the single `:` is API-constructed by
2077        // `GitPush::refspec`), so allow at most one `:` and no leading `+` on either
2078        // side. A caller who genuinely needs a force-push must do it explicitly via
2079        // `run(["push", "--force", …])`, not smuggle a `+` through a branch name.
2080        let sides: Vec<&str> = spec.refspec.split(':').collect();
2081        if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
2082            return Err(processkit::Error::spawn(
2083                BINARY,
2084                std::io::Error::new(
2085                    std::io::ErrorKind::InvalidInput,
2086                    format!(
2087                        "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
2088                         metacharacter — pass a plain branch or `local:remote`, or use \
2089                         `run([\"push\", …])` for a force-push",
2090                        spec.refspec
2091                    ),
2092                ),
2093            ));
2094        }
2095        let (pre, envs) = self.remote_credentials(None).await?;
2096        let mut args: Vec<String> = pre;
2097        args.push("push".to_string());
2098        if spec.set_upstream {
2099            args.push("-u".to_string());
2100        }
2101        args.push(spec.remote.clone());
2102        args.push(spec.refspec.clone());
2103        let cmd = apply_secret_env(
2104            vcs_cli_support::apply_fetch_completion_policy(
2105                self.core
2106                    .command_in(dir, &args)
2107                    .env("GIT_TERMINAL_PROMPT", "0"),
2108            ),
2109            &envs,
2110        );
2111        self.core.run_unit(cmd).await
2112    }
2113
2114    async fn push_with_progress<'a>(
2115        &self,
2116        dir: &Path,
2117        spec: GitPush,
2118        progress: &'a mut ProgressCallback<'a>,
2119    ) -> Result<()> {
2120        reject_flag_like("remote", &spec.remote)?;
2121        reject_flag_like("refspec", &spec.refspec)?;
2122        let sides: Vec<&str> = spec.refspec.split(':').collect();
2123        if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
2124            return Err(processkit::Error::spawn(
2125                BINARY,
2126                std::io::Error::new(
2127                    std::io::ErrorKind::InvalidInput,
2128                    format!(
2129                        "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
2130                         metacharacter — pass a plain branch or `local:remote`, or use \
2131                         `run([\"push\", …])` for a force-push",
2132                        spec.refspec
2133                    ),
2134                ),
2135            ));
2136        }
2137        let (pre, envs) = self.remote_credentials(None).await?;
2138        let mut args: Vec<String> = pre;
2139        args.extend(["push", "--progress"].map(String::from));
2140        if spec.set_upstream {
2141            args.push("-u".to_string());
2142        }
2143        args.push(spec.remote);
2144        args.push(spec.refspec);
2145        let cmd = apply_secret_env(
2146            vcs_cli_support::apply_fetch_completion_policy(
2147                self.core
2148                    .command_in(dir, &args)
2149                    .env("GIT_TERMINAL_PROMPT", "0"),
2150            ),
2151            &envs,
2152        );
2153        self.core.run_with_progress(cmd, progress).await
2154    }
2155
2156    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()> {
2157        // C locale: a conflict's output feeds `is_merge_conflict` (same reason as
2158        // `merge_commit`/`merge_no_commit`). `--squash` never commits, so no editor.
2159        self.core
2160            .run_unit(c_locale(
2161                self.core
2162                    .command_in(dir, ["merge", "--squash", branch.as_str()]),
2163            ))
2164            .await
2165    }
2166
2167    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
2168        let mut args: Vec<&str> = vec!["merge"];
2169        if spec.no_ff {
2170            args.push("--no-ff");
2171        }
2172        if let Some(msg) = spec.message.as_deref() {
2173            args.push("-m");
2174            args.push(msg);
2175        } else {
2176            // No message → take the default merge message non-interactively
2177            // instead of opening `$EDITOR` (which would hang a headless caller).
2178            args.push("--no-edit");
2179        }
2180        args.push(spec.branch.as_str());
2181        // C locale: a conflict's output feeds `is_merge_conflict`.
2182        self.core
2183            .run_unit(c_locale(self.core.command_in(dir, args)))
2184            .await
2185    }
2186
2187    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
2188        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
2189        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
2190        // a squash never fast-forwards anyway, so it takes precedence.
2191        if spec.squash {
2192            args.push("--squash");
2193        } else if spec.no_ff {
2194            args.push("--no-ff");
2195        }
2196        args.push(spec.branch.as_str());
2197        // C locale: a conflict's output feeds `is_merge_conflict`.
2198        self.core
2199            .run_unit(c_locale(self.core.command_in(dir, args)))
2200            .await
2201    }
2202
2203    async fn merge_abort(&self, dir: &Path) -> Result<()> {
2204        self.core.run_unit(self.merge_abort_command(dir)).await
2205    }
2206
2207    async fn merge_continue(&self, dir: &Path) -> Result<()> {
2208        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
2209        // headless backstop so a commit hook re-opening the editor can't hang.
2210        // C locale: the failure output feeds the classifiers (a still-conflicted
2211        // tree reports "nothing to commit"-adjacent / conflict messages).
2212        self.core
2213            .run_unit(no_editor(c_locale(
2214                self.core.command_in(dir, ["commit", "--no-edit"]),
2215            )))
2216            .await
2217    }
2218
2219    async fn reset_merge(&self, dir: &Path) -> Result<()> {
2220        self.core
2221            .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
2222            .await
2223    }
2224
2225    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2226        self.core
2227            .run_unit(self.core.command_in(dir, ["reset", "--hard", rev.as_str()]))
2228            .await
2229    }
2230
2231    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()> {
2232        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
2233        // the message-confirm on `--continue`) never hangs a headless caller.
2234        // C locale: a conflict's output feeds `is_merge_conflict`.
2235        self.core
2236            .run_unit(no_editor(c_locale(
2237                self.core.command_in(dir, ["rebase", onto.as_str()]),
2238            )))
2239            .await
2240    }
2241
2242    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
2243        self.core
2244            .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
2245            .await
2246    }
2247
2248    async fn am_abort(&self, dir: &Path) -> Result<()> {
2249        self.core
2250            .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
2251            .await
2252    }
2253
2254    async fn am_continue(&self, dir: &Path) -> Result<()> {
2255        // `am --continue` re-applies the resolved patch and commits it; a patch's
2256        // message-confirm could open the editor — force a no-op editor so a headless
2257        // caller can't hang. C locale: a re-conflict's output feeds `is_merge_conflict`.
2258        self.core
2259            .run_unit(no_editor(c_locale(
2260                self.core.command_in(dir, ["am", "--continue"]),
2261            )))
2262            .await
2263    }
2264
2265    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
2266        self.core
2267            .run_unit(no_editor(c_locale(
2268                self.core.command_in(dir, ["rebase", "--continue"]),
2269            )))
2270            .await
2271    }
2272
2273    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()> {
2274        let mut command = self.core.command_in(dir, ["stash", "push"]);
2275        if spec.include_untracked {
2276            command = command.arg("--include-untracked");
2277        }
2278        self.core.run_unit(command).await
2279    }
2280
2281    async fn stash_pop(&self, dir: &Path) -> Result<()> {
2282        // C locale: a conflicting `stash pop` emits git's merge-machinery
2283        // `CONFLICT (...)` output, which feeds `is_merge_conflict` (e.g. via
2284        // `switch_with_stash`) — a translated message would defeat it.
2285        self.core
2286            .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
2287            .await
2288    }
2289
2290    async fn stash_list(&self, dir: &Path) -> Result<Vec<StashEntry>> {
2291        self.core
2292            .parse(
2293                self.core
2294                    .command_in(dir, ["stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs"]),
2295                parse::parse_stash_list,
2296            )
2297            .await
2298    }
2299
2300    async fn stash_apply(&self, dir: &Path, index: usize) -> Result<()> {
2301        // `stash@{<index>}` — the selector always starts with the fixed literal
2302        // `stash@{`, so a `usize` index can never be parsed as a flag; no
2303        // injection guard is needed on top of that shape. C locale: a
2304        // conflicting apply emits the same merge-machinery output `stash_pop`
2305        // does, feeding `is_merge_conflict`.
2306        let command = self
2307            .core
2308            .command_in(dir, ["stash", "apply"])
2309            .arg(format!("stash@{{{index}}}"));
2310        self.core.run_unit(c_locale(command)).await
2311    }
2312
2313    async fn stash_drop(&self, dir: &Path, index: usize) -> Result<()> {
2314        let command = self
2315            .core
2316            .command_in(dir, ["stash", "drop"])
2317            .arg(format!("stash@{{{index}}}"));
2318        self.core.run_unit(command).await
2319    }
2320
2321    async fn clean(&self, dir: &Path, spec: Clean) -> Result<Vec<CleanEntry>> {
2322        // Neither `dry_run` nor `force`: refuse before spawning, rather than
2323        // either running a no-op-guarded `git clean` whose safety depends on
2324        // the caller's `clean.requireForce` config, or silently doing nothing.
2325        if !spec.dry_run && !spec.force {
2326            return Err(Error::spawn(
2327                BINARY,
2328                std::io::Error::new(
2329                    std::io::ErrorKind::InvalidInput,
2330                    "clean requires either Clean::dry_run() or Clean::force() — refusing \
2331                     to run with neither, rather than depend on this repository's \
2332                     `clean.requireForce` config to guard the delete",
2333                ),
2334            ));
2335        }
2336        let mut command = self.core.command_in(dir, ["clean"]);
2337        // `dry_run` always wins: with it set, `-f` is never sent, so a spec
2338        // that (oddly) sets both can never actually delete.
2339        command = command.arg(if spec.dry_run { "-n" } else { "-f" });
2340        if spec.directories {
2341            command = command.arg("-d");
2342        }
2343        match spec.ignored {
2344            CleanIgnored::Exclude => {}
2345            CleanIgnored::Include => command = command.arg("-x"),
2346            CleanIgnored::Only => command = command.arg("-X"),
2347        }
2348        // C locale: `parse_clean_output` keys on the English "Would remove "/
2349        // "Removing " prefixes; git gettext-translates both under a non-English
2350        // locale. Unwrapped, a forced clean would still delete files but return
2351        // an empty `Vec<CleanEntry>` (losing the audit trail of what was
2352        // removed), and a dry run would falsely report nothing to remove — same
2353        // failure mode `diff_stat`'s `c_locale` wrap guards against above.
2354        self.core
2355            .parse(c_locale(command), parse::parse_clean_output)
2356            .await
2357    }
2358
2359    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
2360        // `parse_bytes`: the porcelain `worktree <path>` value is a filesystem path
2361        // that need not be valid UTF-8 on Unix, so parse from raw stdout bytes — a
2362        // lossy `String` decode would corrupt a non-UTF-8 worktree name to `U+FFFD`
2363        // and leak a wrong path into the facade's `WorktreeInfo.path`. (Deliberately
2364        // no `-z`: `worktree list --porcelain -z` is git ≥ 2.36, above this crate's
2365        // 2.31 support floor; newline framing already covers the non-UTF-8 case.)
2366        self.core
2367            .parse_bytes(
2368                self.core
2369                    .command_in(dir, ["worktree", "list", "--porcelain"]),
2370                parse::parse_worktree_porcelain,
2371            )
2372            .await
2373    }
2374
2375    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
2376        reject_flag_like_path("worktree path", &spec.path)?;
2377        let mut command = self.core.command_in(dir, ["worktree", "add"]);
2378        if let Some(name) = spec.new_branch.as_ref() {
2379            command = command.arg("-b").arg(name.as_str());
2380        }
2381        if spec.no_checkout {
2382            command = command.arg("--no-checkout");
2383        }
2384        command = command.arg(&spec.path);
2385        if let Some(commitish) = spec.commitish.as_ref() {
2386            command = command.arg(commitish.as_str());
2387        }
2388        self.core.run_unit(command).await
2389    }
2390
2391    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()> {
2392        reject_flag_like_path("worktree path", &spec.path)?;
2393        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
2394        if spec.force {
2395            command = command.arg("--force");
2396        }
2397        command = command.arg(&spec.path);
2398        self.core.run_unit(command).await
2399    }
2400
2401    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
2402        reject_flag_like_path("worktree move source path", from)?;
2403        reject_flag_like_path("worktree move destination path", to)?;
2404        let command = self
2405            .core
2406            .command_in(dir, ["worktree", "move"])
2407            .arg(from)
2408            .arg(to);
2409        self.core.run_unit(command).await
2410    }
2411
2412    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
2413        self.core
2414            .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
2415            .await
2416    }
2417
2418    async fn sparse_checkout_set(&self, dir: &Path, spec: SparseCheckoutSet) -> Result<()> {
2419        if spec.patterns.is_empty() {
2420            return Err(Error::spawn(
2421                BINARY,
2422                std::io::Error::new(
2423                    std::io::ErrorKind::InvalidInput,
2424                    "sparse_checkout_set requires at least one directory or pattern",
2425                ),
2426            ));
2427        }
2428        // `--` pins every caller-provided value as a positional. Keep the
2429        // content guard as well: a leading dash is almost always an accidental
2430        // option-shaped path/pattern, and rejecting it gives the same clear
2431        // pre-spawn error as the other bare positional surfaces.
2432        for pattern in &spec.patterns {
2433            reject_flag_like("sparse-checkout pattern", pattern)?;
2434        }
2435
2436        let mode = if spec.cone { "--cone" } else { "--no-cone" };
2437        let mut command = self
2438            .core
2439            .command_in(dir, ["sparse-checkout", "set", mode, "--"]);
2440        for pattern in &spec.patterns {
2441            command = command.arg(pattern);
2442        }
2443        self.core.run_unit(command).await
2444    }
2445
2446    async fn sparse_checkout_list(&self, dir: &Path) -> Result<Vec<String>> {
2447        self.core
2448            .parse(
2449                self.core.command_in(dir, ["sparse-checkout", "list"]),
2450                parse_sparse_checkout_list,
2451            )
2452            .await
2453    }
2454
2455    async fn sparse_checkout_disable(&self, dir: &Path) -> Result<()> {
2456        self.core
2457            .run_unit(self.core.command_in(dir, ["sparse-checkout", "disable"]))
2458            .await
2459    }
2460
2461    async fn submodule_list(&self, dir: &Path) -> Result<Vec<Submodule>> {
2462        // `.gitmodules` at the repo top is the registry of declared submodules; a
2463        // repo with none simply has no such file. Probe for it (relative to `dir`,
2464        // exactly where `git config --file .gitmodules` resolves it) first: an
2465        // absent file means "no submodules" — an empty list, not git's exit-128
2466        // "unable to read config file" error — and no process is spawned in the
2467        // common no-submodule case. A present-but-malformed file still surfaces as
2468        // a real error from the parse below.
2469        if !dir.join(".gitmodules").exists() {
2470            return Ok(Vec::new());
2471        }
2472        // `parse_bytes`: a submodule `path` value may not be valid UTF-8 on Unix,
2473        // so parse from raw stdout bytes (a lossy `String` decode would corrupt it
2474        // to `U+FFFD`). `-z` frames each `key\nvalue` record with a NUL, robust
2475        // against a value that contains `=` or whitespace.
2476        self.core
2477            .parse_bytes(
2478                self.core
2479                    .command_in(dir, ["config", "--file", ".gitmodules", "--list", "-z"]),
2480                parse::parse_gitmodules_config,
2481            )
2482            .await
2483    }
2484
2485    async fn submodule_status(&self, dir: &Path) -> Result<Vec<SubmoduleStatus>> {
2486        // `parse_bytes`: the reported submodule path may not be valid UTF-8 on
2487        // Unix. `git submodule status` has no `-z` option (it is not a plumbing
2488        // command), so the parser splits the optional trailing ` (describe)` from
2489        // the path heuristically — see `parse_submodule_status`.
2490        self.core
2491            .parse_bytes(
2492                self.core.command_in(dir, ["submodule", "status"]),
2493                parse::parse_submodule_status,
2494            )
2495            .await
2496    }
2497
2498    async fn submodule_update(&self, dir: &Path, spec: SubmoduleUpdate) -> Result<()> {
2499        // Guard each positional submodule path: a leading-`-` value after the
2500        // `--` terminator is already inert as a flag, but reject it up front for
2501        // a clear, uniform error (matching the other bare-positional slots).
2502        for path in &spec.paths {
2503            reject_flag_like("submodule path", path)?;
2504        }
2505        let mut command = self.core.command_in(dir, ["submodule", "update"]);
2506        if spec.init {
2507            command = command.arg("--init");
2508        }
2509        if spec.recursive {
2510            command = command.arg("--recursive");
2511        }
2512        if let Some(depth) = spec.depth {
2513            command = command.arg("--depth").arg(depth.to_string());
2514        }
2515        if !spec.paths.is_empty() {
2516            // `--` closes git's option grammar so every following path is a bare
2517            // positional, never reparsed as a flag.
2518            command = command.arg("--");
2519            for path in &spec.paths {
2520                command = command.arg(path);
2521            }
2522        }
2523        // `GIT_TERMINAL_PROMPT=0`: `update` fetches each submodule, so a missing
2524        // credential must fail fast rather than block on an interactive prompt —
2525        // matching `fetch`/`clone`.
2526        self.core
2527            .run_unit(command.env("GIT_TERMINAL_PROMPT", "0"))
2528            .await
2529    }
2530
2531    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
2532        // A leading-`-` url is a bare positional — `git clone --upload-pack=<cmd>`
2533        // would run an arbitrary local program. A real URL never leads with `-`,
2534        // so this guard has no false positives.
2535        reject_flag_like("url", url)?;
2536        validate_clone_spec(&spec)?;
2537        // No working directory: clone creates `dest` itself, so `dest` should
2538        // be absolute (a relative path would resolve against this process' cwd).
2539        // Leading `-c` credential.helper (+ secret env) when a provider is set,
2540        // scoped to the clone URL's host so a cross-host redirect/submodule during
2541        // the clone can't extract the token (the URL is often externally supplied).
2542        let (pre, envs) = self
2543            .remote_credentials(vcs_cli_support::https_host(url).as_deref())
2544            .await?;
2545        let mut initial: Vec<String> = pre;
2546        initial.extend(clone_args(&spec, false));
2547        let command = self.core.command(&initial);
2548        // `budget_diagnostics`: bound the retained clone progress/failure output
2549        // (a drop-oldest tail — never `OutputTooLarge`, so a real failure stays a
2550        // classifiable `ErrorReason::Exit`). Unbounded by default.
2551        let command = self.core.budget_diagnostics(apply_secret_env(
2552            vcs_cli_support::apply_fetch_completion_policy(
2553                command.arg(url).arg(dest).env("GIT_TERMINAL_PROMPT", "0"),
2554            ),
2555            &envs,
2556        ));
2557
2558        // R7: git populates `dest` incrementally, so a failed clone (timeout, network,
2559        // auth) can leave a **partial, non-empty** `dest` that blocks a retry with
2560        // "destination path already exists and is not empty". The completion
2561        // policy's soft trigger is best-effort on Windows (the child must share the
2562        // console and handle CTRL_BREAK), and the Unix grace is too short to delete
2563        // a multi-GB partial. So clean it ourselves,
2564        // via the shared `vcs_cli_support` helper (also used by `vcs_jj::git_clone`) —
2565        // see its docs for the "never touch a non-empty pre-existing dest" contract and
2566        // why `cleanable` must be computed before the clone runs.
2567        let cleanable = vcs_cli_support::clone_dest_cleanable(dest);
2568        let result = self.core.run_unit(command).await;
2569        if result.is_err() {
2570            vcs_cli_support::cleanup_failed_clone_dest(dest, cleanable);
2571        }
2572        result
2573    }
2574
2575    async fn clone_repo_with_progress<'a>(
2576        &self,
2577        url: &str,
2578        dest: &Path,
2579        spec: CloneSpec,
2580        progress: &'a mut ProgressCallback<'a>,
2581    ) -> Result<()> {
2582        reject_flag_like("url", url)?;
2583        validate_clone_spec(&spec)?;
2584        let (pre, envs) = self
2585            .remote_credentials(vcs_cli_support::https_host(url).as_deref())
2586            .await?;
2587        let mut initial: Vec<String> = pre;
2588        initial.extend(clone_args(&spec, true));
2589        let command = self.core.command(&initial);
2590        let command = self.core.budget_diagnostics(apply_secret_env(
2591            vcs_cli_support::apply_fetch_completion_policy(
2592                command.arg(url).arg(dest).env("GIT_TERMINAL_PROMPT", "0"),
2593            ),
2594            &envs,
2595        ));
2596        let cleanable = vcs_cli_support::clone_dest_cleanable(dest);
2597        let result = self.core.run_with_progress(command, progress).await;
2598        if result.is_err() {
2599            vcs_cli_support::cleanup_failed_clone_dest(dest, cleanable);
2600        }
2601        result
2602    }
2603
2604    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()> {
2605        let mut args = vec!["tag", name.as_str()];
2606        if let Some(rev) = rev.as_ref() {
2607            args.push(rev.as_str());
2608        }
2609        self.core.run_unit(self.core.command_in(dir, args)).await
2610    }
2611
2612    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
2613        let mut args = vec!["tag", "-a", spec.name.as_str(), "-m", &spec.message];
2614        if let Some(rev) = spec.rev.as_ref() {
2615            args.push(rev.as_str());
2616        }
2617        self.core.run_unit(self.core.command_in(dir, args)).await
2618    }
2619
2620    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
2621        // `--no-column`: a user's `column.ui = always` would pack several tags
2622        // onto one line even when piped, corrupting the one-per-line split.
2623        let out = self
2624            .core
2625            .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
2626            .await?;
2627        Ok(out.lines().map(str::to_string).collect())
2628    }
2629
2630    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()> {
2631        self.core
2632            .run_unit(self.core.command_in(dir, ["tag", "-d", name.as_str()]))
2633            .await
2634    }
2635
2636    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String> {
2637        self.show_file_within(dir, rev, path, self.core.output_budget())
2638            .await
2639    }
2640
2641    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
2642        reject_flag_like("config key", key)?;
2643        let res = self
2644            .core
2645            .output_string(self.core.command_in(dir, ["config", "--get", key]))
2646            .await?;
2647        match res.code() {
2648            // Exit 1 = unset (git lumps "no such key/section" in here too).
2649            Some(1) => Ok(None),
2650            // Strip only git's trailing line terminator (`\n`, or `\r\n`), not all
2651            // trailing whitespace: a config value can legitimately end in spaces or
2652            // a tab (e.g. a templated prefix), and `--get` returns a single line, so
2653            // it never itself ends in a newline.
2654            Some(0) => Ok(Some(
2655                res.stdout().trim_end_matches(['\r', '\n']).to_string(),
2656            )),
2657            _ => {
2658                let _ = res.ensure_success()?;
2659                Ok(None) // unreachable: a non-zero exit always errors above.
2660            }
2661        }
2662    }
2663
2664    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
2665        reject_flag_like("config key", key)?;
2666        // `--` closes git's option grammar: `key` and `value` are pinned as bare
2667        // positionals, so a `value` shaped like a flag (`--global`, `--file=<path>`,
2668        // `--worktree`) is stored *literally* rather than reparsed by git as an
2669        // option that would redirect the write to another config file. `value`
2670        // deliberately keeps no `reject_flag_like` guard — a config value may
2671        // legitimately begin with `-` (e.g. `-1`); it is the flag *parse* that must
2672        // be blocked, not the leading dash. (`key` stays flag-guarded above, before
2673        // the terminator is even reached.) Verified on git 2.54; the `repo.rs`
2674        // integration round-trip re-checks it on the live binary. (T-083.)
2675        self.core
2676            .run_unit(self.core.command_in(dir, ["config", "--", key, value]))
2677            .await
2678    }
2679
2680    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2681        reject_flag_like("remote name", name)?;
2682        reject_flag_like("url", url)?;
2683        self.core
2684            .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2685            .await
2686    }
2687
2688    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2689        reject_flag_like("remote name", name)?;
2690        reject_flag_like("url", url)?;
2691        self.core
2692            .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2693            .await
2694    }
2695
2696    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>> {
2697        // No `reject_empty_path` here on purpose (T-149, the symmetric read this
2698        // task audited alongside `show_file`): `path` is its own pathspec behind
2699        // `--`, not text spliced into a `<rev>:<path>` spec, and git answers an
2700        // empty/whitespace-only pathspec with a hard `fatal: no such path …`
2701        // (exit 128) rather than a silently different result — the trait docs
2702        // record the finding, the live test pins it.
2703        let mut args = vec!["blame", "--line-porcelain"];
2704        if let Some(rev) = rev.as_ref() {
2705            args.push(rev.as_str());
2706        }
2707        args.push("--");
2708        args.push(path);
2709        self.core
2710            .parse(
2711                self.core.command_in(dir, args),
2712                parse::parse_blame_porcelain,
2713            )
2714            .await
2715    }
2716
2717    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2718        // No editor opens non-interactively, but keep the headless backstop.
2719        // C locale: a conflict's output feeds `is_merge_conflict`.
2720        self.core
2721            .run_unit(no_editor(c_locale(
2722                self.core.command_in(dir, ["cherry-pick", rev.as_str()]),
2723            )))
2724            .await
2725    }
2726
2727    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2728        self.core
2729            .run_unit(no_editor(c_locale(
2730                self.core
2731                    .command_in(dir, ["revert", "--no-edit", rev.as_str()]),
2732            )))
2733            .await
2734    }
2735
2736    async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2737        self.core
2738            .run_unit(no_editor(c_locale(
2739                self.core.command_in(dir, ["rebase", "--skip"]),
2740            )))
2741            .await
2742    }
2743
2744    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()> {
2745        // No editor on --abort, but keep the C locale so any failure output still
2746        // feeds the classifiers uniformly with the rest of the sequencer.
2747        self.core
2748            .run_unit(c_locale(
2749                self.core.command_in(dir, ["cherry-pick", "--abort"]),
2750            ))
2751            .await
2752    }
2753
2754    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()> {
2755        // `--continue` re-commits the resolved pick and may open the editor to
2756        // confirm the message — force a no-op editor so a headless caller can't
2757        // hang. C locale: a re-conflict's output feeds `is_merge_conflict`.
2758        self.core
2759            .run_unit(no_editor(c_locale(
2760                self.core.command_in(dir, ["cherry-pick", "--continue"]),
2761            )))
2762            .await
2763    }
2764
2765    async fn revert_abort(&self, dir: &Path) -> Result<()> {
2766        self.core
2767            .run_unit(c_locale(self.core.command_in(dir, ["revert", "--abort"])))
2768            .await
2769    }
2770
2771    async fn revert_continue(&self, dir: &Path) -> Result<()> {
2772        self.core
2773            .run_unit(no_editor(c_locale(
2774                self.core.command_in(dir, ["revert", "--continue"]),
2775            )))
2776            .await
2777    }
2778
2779    async fn bisect_reset(&self, dir: &Path) -> Result<()> {
2780        self.core
2781            .run_unit(c_locale(self.core.command_in(dir, ["bisect", "reset"])))
2782            .await
2783    }
2784
2785    async fn bisect_start(&self, dir: &Path, bad: &RevSpec, good: &RevSpec) -> Result<BisectStep> {
2786        self.core
2787            .try_parse(
2788                c_locale(
2789                    self.core
2790                        .command_in(dir, ["bisect", "start", bad.as_str(), good.as_str()]),
2791                ),
2792                parse::parse_bisect_step,
2793            )
2794            .await
2795    }
2796
2797    async fn bisect_good(&self, dir: &Path) -> Result<BisectStep> {
2798        self.core
2799            .try_parse(
2800                c_locale(self.core.command_in(dir, ["bisect", "good"])),
2801                parse::parse_bisect_step,
2802            )
2803            .await
2804    }
2805
2806    async fn bisect_bad(&self, dir: &Path) -> Result<BisectStep> {
2807        self.core
2808            .try_parse(
2809                c_locale(self.core.command_in(dir, ["bisect", "bad"])),
2810                parse::parse_bisect_step,
2811            )
2812            .await
2813    }
2814
2815    async fn bisect_skip(&self, dir: &Path) -> Result<BisectStep> {
2816        self.core
2817            .try_parse(
2818                c_locale(self.core.command_in(dir, ["bisect", "skip"])),
2819                parse::parse_bisect_step,
2820            )
2821            .await
2822    }
2823}
2824
2825impl<R: ProcessRunner> Git<R> {
2826    /// Build one `git --literal-pathspecs log <revs...> -n<max> -z --format=…
2827    /// -- <paths>` call — used both for the common case (everything fits one
2828    /// invocation, the direct [`GitApi::log_paths`] call, where `revs` is the
2829    /// single, as-given `revspec.as_str()`) and for each chunk of its
2830    /// large-path-set fallback (T-052; the two need no different format,
2831    /// since chunked order is restored afterwards by
2832    /// [`Self::log_paths_order_command`], not by anything embedded in each
2833    /// chunk's own output — R-03). On the chunked path, `revs` is instead the
2834    /// caller's already-resolved, fixed commit-id tokens (T-052/R-04; see
2835    /// [`GitApi::log_paths`]) — one for a plain rev, two (tip + `^`-prefixed
2836    /// exclusion) for a range — reused verbatim across every chunk so none of
2837    /// them can observe a differently-moved ref than another.
2838    /// `--literal-pathspecs` matches a path containing `*`/`?`/`[]` literally
2839    /// rather than as pathspec glob magic (R-02).
2840    fn log_paths_command<'a>(
2841        &self,
2842        dir: &Path,
2843        revs: impl IntoIterator<Item = &'a str>,
2844        n: &str,
2845        paths: impl IntoIterator<Item = &'a str>,
2846    ) -> Command {
2847        let mut command = self.core.command_in(dir, ["--literal-pathspecs", "log"]);
2848        for rev in revs {
2849            command = command.arg(rev);
2850        }
2851        command = command
2852            .arg(n)
2853            .arg("-z")
2854            .arg("--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s")
2855            .arg("--");
2856        for path in paths {
2857            command = command.arg(path);
2858        }
2859        command
2860    }
2861
2862    /// Build the pathless `git log <revs...> -z --format=%H` commit-order
2863    /// oracle used to restore git's own order across `log_paths`'s merged
2864    /// chunk results (T-052/R-03; see [`GitApi::log_paths`]). `revs` is the
2865    /// same already-resolved, fixed commit-id tokens the chunk calls used
2866    /// (T-052/R-04) — resolving the revspec independently here, after the
2867    /// chunk calls already ran, would reopen exactly the race that resolving
2868    /// it once up front closes. No `-n` cap: a commit surviving
2869    /// path-filtering into the merged top-`max` can sit arbitrarily far back
2870    /// in the *unrestricted* history (many untouched commits between it and
2871    /// the tip), so the oracle must be able to rank it — capping this call
2872    /// risks an unranked commit outside the map. No paths, so no
2873    /// `--literal-pathspecs` is needed here. Parsed by [`parse_commit_order`].
2874    fn log_paths_order_command(&self, dir: &Path, revs: &[String]) -> Command {
2875        let mut command = self.core.command_in(dir, ["log"]);
2876        for rev in revs {
2877            command = command.arg(rev);
2878        }
2879        command.arg("-z").arg("--format=%H")
2880    }
2881
2882    /// Build the `merge --abort` command with the C locale forced (a failed
2883    /// abort's output feeds the same classifiers as the merge it undoes). Shared
2884    /// by the token-inheriting [`merge_abort`](GitApi::merge_abort) and the
2885    /// detached [`merge_abort_detached`](Self::merge_abort_detached) so the two
2886    /// argv/locale forms cannot drift.
2887    fn merge_abort_command(&self, dir: &Path) -> Command {
2888        c_locale(self.core.command_in(dir, ["merge", "--abort"]))
2889    }
2890
2891    /// Turn a repo-scoped `command` into a rollback **cleanup** step: it drops
2892    /// this client's [`default_cancel_on`](Git::default_cancel_on) token for a
2893    /// fresh, never-fired one and applies the bounded
2894    /// [`MERGE_ABORT_CLEANUP_TIMEOUT`] deadline — the git analogue of jj's
2895    /// `rollback_cmd_in`. A command built this way runs even after the client
2896    /// token that governed the probe has already fired, so `Repo::try_merge`'s
2897    /// Ok/Err cleanup can both **decide** to roll back (the
2898    /// [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached)
2899    /// probe) and **perform** it
2900    /// ([`merge_abort_detached`](Self::merge_abort_detached)) despite a cancelled
2901    /// or timed-out probe merge — the *whole* decision-plus-command rollback path
2902    /// is detached, matching jj rather than only the final abort command.
2903    fn detached_cleanup(&self, command: Command) -> Command {
2904        command
2905            .cancel_on(CancellationToken::new())
2906            .timeout(MERGE_ABORT_CLEANUP_TIMEOUT)
2907    }
2908
2909    /// Abort an in-progress merge (`merge --abort`) as a **rollback cleanup** that
2910    /// deliberately does **not** inherit this client's
2911    /// [`default_cancel_on`](Git::default_cancel_on) token and runs under its own
2912    /// bounded `MERGE_ABORT_CLEANUP_TIMEOUT` deadline — mirroring jj's
2913    /// `Jj::rollback_to`.
2914    ///
2915    /// The trait [`merge_abort`](GitApi::merge_abort) inherits the client's cancel
2916    /// token, so once that token has already fired — a cancelled or timed-out probe
2917    /// merge — the abort that should undo the half-staged trial merge would itself
2918    /// be cancelled, leaving the merge in the working tree. Building it through the
2919    /// internal `detached_cleanup` (a fresh, never-fired [`CancellationToken`] and a
2920    /// full fresh timeout budget) lets it complete regardless, so a probe's own
2921    /// cancellation no longer disables its rollback. Same `merge --abort` argv as
2922    /// [`merge_abort`](GitApi::merge_abort); used by the facade `Repo::try_merge`'s
2923    /// error-branch cleanup — paired with
2924    /// [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached) so the
2925    /// *decision* to abort is detached too, not just this command.
2926    pub async fn merge_abort_detached(&self, dir: &Path) -> Result<()> {
2927        self.core
2928            .run_unit(self.detached_cleanup(self.merge_abort_command(dir)))
2929            .await
2930    }
2931
2932    /// [`is_merge_in_progress`](GitApi::is_merge_in_progress) on the detached
2933    /// rollback-cleanup context (the internal `detached_cleanup`), so the
2934    /// **decision** of whether a trial merge is still staged survives an
2935    /// already-fired client [`default_cancel_on`](Git::default_cancel_on) token.
2936    ///
2937    /// The trait probe's underlying `rev-parse --git-dir` inherits the client
2938    /// token; once a probe merge's cancellation has fired, that probe's `?` would
2939    /// propagate `ErrorReason::Cancelled` **before** the abort that undoes the trial
2940    /// merge is ever reached, leaving it staged in the working tree. Resolving the
2941    /// git dir on a fresh token instead lets the decision complete, so it pairs
2942    /// with [`merge_abort_detached`](Self::merge_abort_detached) to make **both**
2943    /// halves of `Repo::try_merge`'s Ok/Err cleanup cancellation-safe — matching
2944    /// jj, whose op-log rollback probe also runs on the detached context.
2945    pub async fn is_merge_in_progress_detached(&self, dir: &Path) -> Result<bool> {
2946        Ok(self
2947            .resolved_git_dir_detached(dir)
2948            .await?
2949            .join("MERGE_HEAD")
2950            .exists())
2951    }
2952}
2953
2954// --- Internal helpers --------------------------------------------------------
2955//
2956// The error classifiers (`is_merge_conflict`/`is_nothing_to_commit`/
2957// `is_transient_fetch_error`), the fetch-retry policy, and the argv injection
2958// guard now live in the shared `vcs-cli-support` crate (re-exported at the top of
2959// this module); what remains here is git-specific.
2960
2961/// Git's well-known **SHA-1** empty-tree object id. This value exists **only in a
2962/// SHA-1 repository**: a repo created with `extensions.objectFormat=sha256` has a
2963/// different empty-tree id (and this SHA-1 one resolves to no object there), so
2964/// this constant is *not* a universal stand-in for `HEAD` when diffing an unborn
2965/// working tree. For the id that matches a repository's active object format, use
2966/// [`Git::empty_tree_oid`], which asks git for it — that is what
2967/// [`diff_text`](GitApi::diff_text)`(DiffSpec::WorkingTree)` uses on an unborn repo.
2968pub const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2969
2970/// Total attempts / fixed backoff for a transient-retried `fetch` — the shared
2971/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
2972const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2973const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2974
2975/// Bounded deadline for the detached rollback abort
2976/// ([`Git::merge_abort_detached`]) — the git analogue of jj's `ROLLBACK_TIMEOUT`.
2977/// Because that cleanup deliberately runs on a FRESH cancel token (so a cancelled
2978/// or timed-out probe merge cannot also cancel it), it needs its own explicit
2979/// deadline to stay bounded rather than inheriting one from the main operation.
2980const MERGE_ABORT_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
2981
2982/// Point git's editor at a no-op so any command that would open `$EDITOR`
2983/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
2984/// non-interactively instead of hanging a headless caller.
2985fn no_editor(cmd: processkit::Command) -> processkit::Command {
2986    cmd.env("GIT_EDITOR", "true")
2987        .env("GIT_SEQUENCE_EDITOR", "true")
2988}
2989
2990/// Force the C locale on a command whose output feeds the error classifiers
2991/// (`is_merge_conflict`, `is_nothing_to_commit`, `is_transient_fetch_error`):
2992/// they match untranslated English substrings, and a localized git would emit
2993/// translated messages, silently turning a classified failure (conflict /
2994/// clean-tree / transient) into an unclassified one.
2995fn c_locale(cmd: processkit::Command) -> processkit::Command {
2996    cmd.env("LC_ALL", "C")
2997}
2998
2999/// Injection guard for bare positional argv slots — delegates to the shared
3000/// [`vcs_cli_support::reject_flag_like`], naming this crate's binary so the
3001/// ~45 call sites stay `reject_flag_like(what, value)`.
3002fn reject_flag_like(what: &str, value: &str) -> Result<()> {
3003    vcs_cli_support::reject_flag_like(BINARY, what, value)
3004}
3005
3006/// Validate the caller-controlled values that `clone` puts in option slots
3007/// before credential resolution or any runner call. `--origin` is a flag-value
3008/// position, but git treats another leading dash as a new option when the value
3009/// is missing or malformed, so keep it behind the same project guard as bare
3010/// positional names.
3011fn validate_clone_spec(spec: &CloneSpec) -> Result<()> {
3012    if let Some(origin) = spec.origin.as_deref() {
3013        reject_flag_like("origin name", origin)?;
3014    }
3015    Ok(())
3016}
3017
3018/// Build the clone-specific argv suffix once for both clone surfaces. Keeping
3019/// this in one place prevents progress reporting from drifting away from the
3020/// ordinary clone's option order or omission rules.
3021fn clone_args(spec: &CloneSpec, progress: bool) -> Vec<String> {
3022    let mut args = vec!["clone".to_string()];
3023    if progress {
3024        args.push("--progress".to_string());
3025    }
3026    if let Some(branch) = spec.branch.as_deref() {
3027        args.push("--branch".to_string());
3028        args.push(branch.to_string());
3029    }
3030    if let Some(depth) = spec.depth {
3031        args.push("--depth".to_string());
3032        args.push(depth.to_string());
3033    }
3034    if let Some(filter) = spec.filter {
3035        args.push(format!("--filter={}", filter.cli_value()));
3036    }
3037    if spec.single_branch {
3038        args.push("--single-branch".to_string());
3039    }
3040    if let Some(origin) = spec.origin.as_deref() {
3041        args.push("--origin".to_string());
3042        args.push(origin.to_string());
3043    }
3044    if spec.bare {
3045        args.push("--bare".to_string());
3046    }
3047    args
3048}
3049
3050/// [`reject_flag_like`] for a bare positional **path** argv slot (worktree
3051/// add/remove/move), whose values are typed `PathBuf`/`&Path` rather than
3052/// `&str` and so may be non-UTF-8 on Unix. Checks a lossy-UTF-8 rendering of
3053/// `path` — `to_string_lossy` never panics, so this never aborts on invalid
3054/// UTF-8 — for a leading `-` (after trim), emptiness, or an embedded NUL; a
3055/// leading `-`/NUL/emptiness is always ASCII and so survives the lossy
3056/// conversion unchanged regardless of any invalid bytes elsewhere in `path`.
3057/// Only the *check* is lossy: the value actually handed to `Command::arg`
3058/// stays the original `path`, byte-for-byte, so a legitimate non-UTF-8 path
3059/// with no leading `-` still reaches the child process unaltered.
3060fn reject_flag_like_path(what: &str, path: &Path) -> Result<()> {
3061    reject_flag_like(what, &path.to_string_lossy())
3062}
3063
3064/// Parse `git sparse-checkout list` without trimming pattern content. Git emits
3065/// one directory/pattern per line; `split_terminator` drops only the final
3066/// framing newline, while the explicit CR removal keeps Windows output stable.
3067fn parse_sparse_checkout_list(output: &str) -> Vec<String> {
3068    output
3069        .split_terminator('\n')
3070        .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
3071        .collect()
3072}
3073
3074/// Emptiness guard for a caller-supplied file path that is **interpolated into a
3075/// larger argument** (`show_file`'s `<rev>:<path>` spec) instead of occupying an
3076/// argv slot of its own.
3077///
3078/// [`reject_flag_like`] is the wrong check for such a slot in both directions: a
3079/// leading `-` is inert inside `<rev>:<path>` (rejecting it would refuse a
3080/// perfectly legitimate `-dash.txt`), while emptiness — which `reject_flag_like`
3081/// happens to cover for *bare* positionals — is exactly what silently changes the
3082/// command's meaning here. `git show <rev>:` is not an error: git prints the
3083/// **root tree listing** and exits 0 (verified on git 2.55.0), so an unguarded
3084/// empty path returns a directory index as if it were the file's content.
3085///
3086/// `vcs_jj`'s `file_show` carries the mirror guard with a byte-identical message
3087/// (only the program name differs), so a cross-backend caller sees ONE error
3088/// form. Its empty-path degradation differs in shape but not in kind: an empty
3089/// path becomes the fileset `root-file:""`, which is a valid *existing* path
3090/// (the workspace root) matching no file, so `jj file show` exits 0 with **empty
3091/// output** — a file that "exists and is empty" (verified on jj 0.38.0).
3092///
3093/// Whitespace-only is refused with the empty string. A name made only of spaces
3094/// is legal on Unix, but at this boundary it is indistinguishable from the far
3095/// likelier caller bug (a blank/unset path variable), and refusing it keeps one
3096/// rule — and one error — across both backends.
3097///
3098/// An interior NUL needs no check here: it can only reach `Command::arg`, which
3099/// already fails the spawn with the same `io::ErrorKind::InvalidInput` this guard
3100/// raises, so the classification a caller sees is unchanged.
3101fn reject_empty_path(what: &str, path: &str) -> Result<()> {
3102    if path.trim().is_empty() {
3103        return Err(Error::spawn(
3104            BINARY,
3105            std::io::Error::new(
3106                std::io::ErrorKind::InvalidInput,
3107                format!(
3108                    "{what} {path:?} is empty or whitespace-only — an empty path silently \
3109                     re-targets the read at the repository root instead of a single file; \
3110                     refusing before spawning"
3111                ),
3112            ),
3113        ));
3114    }
3115    Ok(())
3116}
3117
3118// --- Large path-set transport (T-052) ----------------------------------------
3119//
3120// `add`/`commit_paths` build one `git` argv per call whether their path set has
3121// three entries or three hundred thousand. Windows' `CreateProcess` rejects a
3122// command line longer than roughly 32,767 UTF-16 code units (`OS error 206`);
3123// POSIX's `ARG_MAX` is typically far larger but shared with the environment
3124// block. [`ARGV_PATHSPEC_BUDGET`] is a conservative byte budget for the *paths*
3125// portion of such a call (not counting the program name, subcommand, or
3126// flags — negligible next to this), chosen with a wide margin under the
3127// tighter Windows ceiling so an ordinary call (a handful of paths) never
3128// crosses it. Crossing it switches `add`/`commit_paths` to the NUL-safe
3129// `--pathspec-from-file=- --pathspec-file-nul` transport ([`pathspec_nul_bytes`])
3130// — unbounded, since the paths then never touch argv at all — and switches
3131// `log_paths` (for which git has no `--pathspec-from-file` support) to chunked
3132// invocations ([`chunk_pathspecs`]) merged back into one result.
3133
3134/// Conservative byte budget for the *pathspec* portion of a `git` argv — see
3135/// the module-level comment above this constant for the reasoning.
3136const ARGV_PATHSPEC_BUDGET: usize = 6_000;
3137
3138/// Sum of each path's encoded byte length plus one (a stand-in for the
3139/// separating argv-slot overhead), compared against [`ARGV_PATHSPEC_BUDGET`]
3140/// to decide whether a path set is too large for one plain-argv invocation.
3141fn pathspec_argv_len<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> usize {
3142    paths
3143        .into_iter()
3144        .map(|p| p.as_encoded_bytes().len() + 1)
3145        .sum()
3146}
3147
3148/// Build the NUL-delimited pathspec payload for `--pathspec-from-file=-
3149/// --pathspec-file-nul` from `paths`, entirely in memory before anything is
3150/// spawned. A path embedding a NUL byte — impossible on a real filesystem, but
3151/// checked anyway as defense-in-depth — would silently split into two
3152/// pathspecs on that separator, one of them possibly matching an unintended
3153/// file; refusing it here, before the command is built, keeps input
3154/// preparation atomic: either every path is valid and the one `git` invocation
3155/// runs, or none of it does (no partially-applied result to unwind). Returns
3156/// the raw bytes (rather than a [`processkit::Stdin`] directly) so this pure
3157/// step stays unit-testable — wrap the result in
3158/// [`processkit::Stdin::from_bytes`] at the call site.
3159fn pathspec_nul_bytes<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> Result<Vec<u8>> {
3160    let mut buf = Vec::new();
3161    for path in paths {
3162        let bytes = path.as_encoded_bytes();
3163        if bytes.contains(&0) {
3164            return Err(Error::spawn(
3165                BINARY,
3166                std::io::Error::new(
3167                    std::io::ErrorKind::InvalidInput,
3168                    "path contains an embedded NUL byte, which the \
3169                     --pathspec-file-nul transport uses as its separator — \
3170                     refusing before spawning rather than silently splitting \
3171                     it into two pathspecs",
3172                ),
3173            ));
3174        }
3175        buf.extend_from_slice(bytes);
3176        buf.push(0);
3177    }
3178    Ok(buf)
3179}
3180
3181/// Split `paths` into groups whose combined length (each entry's byte length
3182/// plus one, matching [`pathspec_argv_len`]) stays within
3183/// [`ARGV_PATHSPEC_BUDGET`] — every group gets at least one path (a single path
3184/// already over budget still gets its own singleton group; nothing shorter is
3185/// possible). Preserves `paths`' order, both within and across groups.
3186fn chunk_pathspecs(paths: &[String]) -> Vec<Vec<&str>> {
3187    let mut chunks: Vec<Vec<&str>> = Vec::new();
3188    let mut current: Vec<&str> = Vec::new();
3189    let mut current_len = 0usize;
3190    for path in paths {
3191        let len = path.len() + 1;
3192        if !current.is_empty() && current_len + len > ARGV_PATHSPEC_BUDGET {
3193            chunks.push(std::mem::take(&mut current));
3194            current_len = 0;
3195        }
3196        current.push(path.as_str());
3197        current_len += len;
3198    }
3199    if !current.is_empty() {
3200        chunks.push(current);
3201    }
3202    chunks
3203}
3204
3205/// Parse [`Git::log_paths_order_command`]'s `git log -z --format=%H` output
3206/// into an ordered list of hashes — git's own commit order for the queried
3207/// revspec, used as the ranking oracle that restores order across
3208/// `log_paths`'s merged chunk results (T-052/R-03; see [`GitApi::log_paths`]).
3209fn parse_commit_order(output: &str) -> Vec<String> {
3210    output
3211        .split('\0')
3212        .filter(|rec| !rec.is_empty())
3213        .map(str::to_string)
3214        .collect()
3215}
3216
3217// The six raw escape-hatch helpers (`run_args`/`run_raw_args`/`run_in`/… and the
3218// `*_in` twins) are byte-identical forwards into `core` across all five CLI
3219// wrappers, so the shared macro in `vcs-cli-support` generates them (see
3220// `vcs_cli_support::raw_run_forwarders!`).
3221vcs_cli_support::raw_run_forwarders! {
3222    Git, "git", "\"status\", \"-s\"", "",
3223    "the same unguarded escape hatch — only the working directory is bound, \
3224     no `-C`/extra flag is injected"
3225}
3226
3227impl<R: ProcessRunner> Git<R> {
3228    /// The empty-tree object id for the repository at `dir`, matching its **active
3229    /// object format** — the format-correct stand-in for `HEAD` when diffing/stat-ing
3230    /// the working tree of an unborn (no-commits-yet) repository.
3231    ///
3232    /// Computed with `git hash-object -t tree --stdin` fed an empty stdin: an empty
3233    /// tree object is empty content, so git returns its id under whichever hash the
3234    /// repo uses (`4b825dc…` for SHA-1, a 64-hex digest for `extensions.objectFormat=
3235    /// sha256`). This asks git rather than hard-coding [`EMPTY_TREE_SHA1`], which is
3236    /// wrong in a SHA-256 repo. `--stdin` (not `-w`) only *computes* the id — nothing
3237    /// is written to the object database.
3238    pub async fn empty_tree_oid(&self, dir: &Path) -> Result<String> {
3239        self.core
3240            .run(
3241                self.core
3242                    .command_in(dir, ["hash-object", "-t", "tree", "--stdin"])
3243                    .stdin(processkit::Stdin::empty()),
3244            )
3245            .await
3246    }
3247
3248    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
3249    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
3250    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
3251    /// driving many directories (e.g. linked worktrees) from one client.
3252    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
3253        GitAt { git: self, dir }
3254    }
3255
3256    /// Harden this client for driving repositories it didn't create: running
3257    /// `git` inside an untrusted checkout executes that repository's hooks and
3258    /// honours its config — arbitrary code execution by default. The profile
3259    /// (applied to **every** command this client runs):
3260    ///
3261    /// **⚠ Requires git ≥ 2.31.** The hook / `fsmonitor` / `sshCommand` pins ride
3262    /// git's env-based config (`GIT_CONFIG_COUNT`), which older git **silently
3263    /// ignores** — so on git < 2.31 `harden()` still scrubs the environment and
3264    /// turns prompts off, but repo-local hooks/fsmonitor/sshCommand are **not**
3265    /// disabled (no error is raised). [`capabilities().ensure_supported()`](GitCapabilities::ensure_supported)
3266    /// now enforces the **≥ 2.31 floor** (major.minor), so a too-old git is rejected
3267    /// up front with a clear message instead of silently no-op-ing the pins — call it
3268    /// before relying on `harden()` against a fully untrusted repo on a host you don't
3269    /// control, or add an OS-level sandbox. (`docs/audit-2026-07.md` H3, M29.)
3270    ///
3271    /// - **Disables hooks** — `core.hooksPath=/dev/null` pinned via git's
3272    ///   env-based config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, git ≥ 2.31;
3273    ///   verified to suppress hooks on Windows too) — and `core.fsmonitor`
3274    ///   (a config-driven daemon launch). Env-config overrides even the
3275    ///   *repo-local* `.git/config` for the keys it names, so these pins beat a
3276    ///   poisoned `.git/config`.
3277    /// - **Neutralizes `core.sshCommand`** (pinned empty) — the config-key twin of
3278    ///   the scrubbed `GIT_SSH_COMMAND`, an arbitrary program git would run for the
3279    ///   SSH transport. Empty is falsy to git, so the default `ssh` (ambient
3280    ///   `~/.ssh/config`/agent) still works; only the repo's override is dropped.
3281    /// - **Removes inherited repo redirectors** so a poisoned parent
3282    ///   environment can't point commands at another repository: `GIT_DIR`,
3283    ///   `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`,
3284    ///   `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`,
3285    ///   `GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
3286    ///   `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`. (The first seven are also
3287    ///   scrubbed by *every* client — see the type-level doc — not just here.)
3288    /// - **Removes inherited command hooks** that make git spawn an arbitrary
3289    ///   program from the *environment* (a second code-execution path besides
3290    ///   repo hooks): `GIT_SSH_COMMAND`/`GIT_SSH` (transport), `GIT_ASKPASS`
3291    ///   (credential prompt), `GIT_EXTERNAL_DIFF` (diff driver), `GIT_PAGER`,
3292    ///   `GIT_EDITOR`/`GIT_SEQUENCE_EDITOR`, `GIT_PROXY_COMMAND` (a program for a
3293    ///   `git://` connection), `GIT_EXEC_PATH` (relocates git's own sub-commands),
3294    ///   and `GIT_TEMPLATE_DIR` (seeds hooks/config on `init`/`clone`). It also drops
3295    ///   the pathspec-mode vars (`GIT_LITERAL_PATHSPECS` / `GIT_GLOB_PATHSPECS` /
3296    ///   `GIT_NOGLOB_PATHSPECS` / `GIT_ICASE_PATHSPECS`), which silently change which
3297    ///   paths a command matches. The library's own auth seam
3298    ///   ([`with_credentials`](Git::with_credentials)) injects credentials via a
3299    ///   git `credential.helper` / token env, **not** these variables, so it keeps
3300    ///   working through a hardened client; an operator who deliberately relies on
3301    ///   an ambient `GIT_SSH_COMMAND`/`GIT_ASKPASS` should inject it per-call
3302    ///   instead of inheriting it into an untrusted-repo run.
3303    /// - **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal
3304    ///   prompts off everywhere (`GIT_TERMINAL_PROMPT=0`).
3305    ///
3306    /// **Residual repo-local-config vectors (NOT neutralized).** `harden()` closes
3307    /// the *hooks*, `fsmonitor`, `core.sshCommand`, and the env redirector/command-
3308    /// hook paths — but a few **repo-local `.git/config` / `.gitattributes`** keys
3309    /// still run an arbitrary program and are not pinned: `filter.<drv>.clean`/
3310    /// `smudge` (run on any working-tree materialization — `checkout`, `stash pop`,
3311    /// `worktree add`), and `diff.<drv>.textconv` / `diff.external` (run when a diff
3312    /// is produced; [`diff_text`](GitApi::diff_text) defends itself with
3313    /// `--no-ext-diff`, but other diff/blame reads do not). So for a **fully
3314    /// untrusted** repo, do not materialize its working tree or run diffs through a
3315    /// hardened client without an OS-level sandbox — `harden()` is hardening, not a
3316    /// sandbox.
3317    ///
3318    /// What it does NOT do beyond that: sandbox the git binary itself, or stop the
3319    /// repo's *content* from being malicious. In a **colocated jj repo**, git hooks
3320    /// only run when *git* commands run — harden the `Git` client; `Jj` needs
3321    /// no equivalent (jj has no repo-local hooks; see the vcs-jj docs).
3322    ///
3323    /// Chainable — `Git::with_runner(rec).harden()` works in tests; use
3324    /// [`Git::hardened()`](Git::hardened) for the common case.
3325    pub fn harden(self) -> Self {
3326        let removed = [
3327            // Repo redirectors — point git at another repo/index/object store.
3328            // (`GIT_DIR`…`GIT_NAMESPACE` are also scrubbed by *every* client via the
3329            // `managed_client!` `scrub_env`; re-listed here so the hardened profile is
3330            // self-contained and its double-removal is harmless.)
3331            "GIT_DIR",
3332            "GIT_WORK_TREE",
3333            "GIT_INDEX_FILE",
3334            "GIT_COMMON_DIR",
3335            "GIT_OBJECT_DIRECTORY",
3336            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3337            "GIT_NAMESPACE",
3338            "GIT_CEILING_DIRECTORIES",
3339            "GIT_CONFIG_PARAMETERS",
3340            "GIT_CONFIG_GLOBAL",
3341            "GIT_CONFIG_SYSTEM",
3342            // Command hooks — make git spawn an arbitrary program from the env.
3343            "GIT_SSH_COMMAND",
3344            "GIT_SSH",
3345            "GIT_ASKPASS",
3346            "GIT_EXTERNAL_DIFF",
3347            "GIT_PAGER",
3348            "GIT_EDITOR",
3349            "GIT_SEQUENCE_EDITOR",
3350            // More env command-hooks (M14): `GIT_PROXY_COMMAND` runs an arbitrary
3351            // program for a `git://` connection; `GIT_EXEC_PATH` relocates where git
3352            // finds its own sub-commands (so `git-<x>` becomes attacker-chosen);
3353            // `GIT_TEMPLATE_DIR` seeds hooks/config into a repo on `init`/`clone`.
3354            "GIT_PROXY_COMMAND",
3355            "GIT_EXEC_PATH",
3356            "GIT_TEMPLATE_DIR",
3357            // Pathspec interpretation (M14) — not code-execution, but they silently
3358            // change which paths a command matches, so pin deterministic behavior.
3359            "GIT_LITERAL_PATHSPECS",
3360            "GIT_GLOB_PATHSPECS",
3361            "GIT_NOGLOB_PATHSPECS",
3362            "GIT_ICASE_PATHSPECS",
3363        ];
3364        let mut hardened = self;
3365        for key in removed {
3366            hardened = hardened.default_env_remove(key);
3367        }
3368        hardened
3369            .default_env("GIT_CONFIG_NOSYSTEM", "1")
3370            .default_env("GIT_TERMINAL_PROMPT", "0")
3371            // Env-config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`) overrides even the
3372            // *repo-local* `.git/config` for the keys it names — so these pins beat
3373            // a poisoned `.git/config`, which `GIT_CONFIG_NOSYSTEM` (system) and the
3374            // scrubbed `GIT_CONFIG_GLOBAL` (global) do not reach.
3375            .default_env("GIT_CONFIG_COUNT", "3")
3376            .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
3377            // `/dev/null` as the hooks dir disables hooks on every platform,
3378            // Windows included: git looks for `<hooksPath>/<hook-name>`, and no
3379            // such file can exist under `/dev/null` (it is not a directory), so the
3380            // lookup always misses and no hook runs. A literal POSIX path is fine
3381            // on Windows here — it is used as a path *prefix* to probe, never
3382            // opened — and it reads unambiguously as "nowhere."
3383            .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
3384            .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
3385            .default_env("GIT_CONFIG_VALUE_1", "false")
3386            // Neutralize a repo-local `core.sshCommand` (an arbitrary program git
3387            // runs for the SSH transport on fetch/push/clone) — the config-key twin
3388            // of the scrubbed `GIT_SSH_COMMAND` env var. An empty value is falsy to
3389            // git, so it falls back to the default `ssh` (ambient `~/.ssh/config` /
3390            // agent still work); only the repo's override is dropped.
3391            .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
3392            .default_env("GIT_CONFIG_VALUE_2", "")
3393    }
3394
3395    /// Switch to `branch`, carrying uncommitted changes (tracked *and*
3396    /// untracked) across via the stash: `stash push -u` → `checkout` →
3397    /// `stash pop --index`. `--index` restores the staged/unstaged split faithfully
3398    /// (a bare `pop` returns everything unstaged). A clean tree skips the round-trip;
3399    /// and because `stash push` can exit 0 having saved **nothing** (e.g. a
3400    /// submodule-only change), the stash-list depth is checked around the push so a
3401    /// no-op push doesn't leave the later pop grabbing an older, unrelated stash.
3402    ///
3403    /// **Single-actor contract:** this assumes no other process pushes or pops a
3404    /// stash in the same repository between this call's own `stash push` and `pop`.
3405    ///
3406    /// Failure behaviour:
3407    /// - `checkout` fails (atomic — the working copy stays on the original
3408    ///   branch): the stash is popped back to restore the original state, and
3409    ///   the checkout error is returned. If that restoring pop *also* fails,
3410    ///   the changes stay safe in the stash (`git stash list`).
3411    /// - `stash pop` on the target branch conflicts: the error is returned with
3412    ///   the target branch checked out; git keeps the stash entry, so the
3413    ///   changes can be resolved or re-applied manually.
3414    ///
3415    /// Inherent (not on the object-safe trait): a composed operation, not a 1:1
3416    /// CLI verb — mock the underlying `status`/`stash_*`/`checkout` instead.
3417    pub async fn switch_with_stash(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
3418        // Untracked-inclusive guard to match `stash push -u`: "dirty" must mean
3419        // the same thing to the guard and to the stash. Fast path for a clean tree.
3420        if self.status(dir).await?.is_empty() {
3421            return self.checkout(dir, target).await;
3422        }
3423        // `stash push` exits 0 having saved **nothing** when the only dirt is
3424        // unstashable (e.g. a submodule-only change that `status` still reports), so a
3425        // bare `stash pop` afterwards would splat an UNRELATED pre-existing stash — data
3426        // loss. Bracket the push with the stash-list depth to learn whether it actually
3427        // saved, and only pop when it did. (Single-actor contract: a concurrent
3428        // `stash push`/`pop` by another process between our two calls is out of scope.)
3429        let depth_before = self.stash_depth(dir).await?;
3430        self.stash_push(dir, StashPush::new().include_untracked())
3431            .await?;
3432        if self.stash_depth(dir).await? <= depth_before {
3433            // Nothing was stashed — switch as-is rather than pop someone else's entry.
3434            return self.checkout(dir, target).await;
3435        }
3436        // `--index` restores the staged/unstaged split faithfully; a bare `pop` would
3437        // bring everything back UNSTAGED, silently flattening the index.
3438        match self.checkout(dir, target).await {
3439            Ok(()) => self.stash_pop_index(dir).await,
3440            Err(err) => {
3441                // A failed checkout is atomic — we are still on the original branch, so
3442                // popping restores the exact pre-call state. If the pop fails too, the
3443                // stash entry is preserved for the caller.
3444                let _ = self.stash_pop_index(dir).await;
3445                Err(err)
3446            }
3447        }
3448    }
3449
3450    /// The number of entries in the stash list (`git stash list`) — used by
3451    /// [`switch_with_stash`](Git::switch_with_stash) to tell whether a `stash push`
3452    /// actually saved anything.
3453    async fn stash_depth(&self, dir: &Path) -> Result<usize> {
3454        let out = self
3455            .core
3456            .run(self.core.command_in(dir, ["stash", "list"]))
3457            .await?;
3458        Ok(out.lines().filter(|l| !l.is_empty()).count())
3459    }
3460
3461    /// `git stash pop --index` — restore the top stash *preserving* the staged/unstaged
3462    /// split (a bare `pop` returns everything unstaged). C locale so a conflicting pop's
3463    /// `CONFLICT (...)` output still feeds `is_merge_conflict`.
3464    async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
3465        self.core
3466            .run_unit(c_locale(
3467                self.core.command_in(dir, ["stash", "pop", "--index"]),
3468            ))
3469            .await
3470    }
3471
3472    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
3473    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
3474    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
3475        self.resolve_git_dir(dir, self.core.command_in(dir, ["rev-parse", "--git-dir"]))
3476            .await
3477    }
3478
3479    /// [`resolved_git_dir`](Self::resolved_git_dir) on the detached
3480    /// rollback-cleanup context (see [`detached_cleanup`](Self::detached_cleanup)):
3481    /// the `rev-parse --git-dir` behind the MERGE_HEAD probe runs on a fresh,
3482    /// never-fired cancel token, so a client cancellation that already fired during
3483    /// the probe merge cannot short-circuit the *decision* half of `try_merge`'s
3484    /// Ok/Err cleanup. Backs [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached).
3485    async fn resolved_git_dir_detached(&self, dir: &Path) -> Result<PathBuf> {
3486        self.resolve_git_dir(
3487            dir,
3488            self.detached_cleanup(self.core.command_in(dir, ["rev-parse", "--git-dir"])),
3489        )
3490        .await
3491    }
3492
3493    /// Run a `rev-parse --git-dir` `command` and absolutise a relative git dir
3494    /// against `dir`. Shared by [`resolved_git_dir`](Self::resolved_git_dir) and
3495    /// its detached variant so the token-inheriting and detached probes cannot
3496    /// drift in how they resolve the path.
3497    async fn resolve_git_dir(&self, dir: &Path, command: Command) -> Result<PathBuf> {
3498        let git_dir = PathBuf::from(self.core.run(command).await?);
3499        Ok(if git_dir.is_absolute() {
3500            git_dir
3501        } else {
3502            dir.join(git_dir)
3503        })
3504    }
3505}
3506
3507impl Git {
3508    /// A hardened real (job-backed) client — `Git::new().harden()`; see
3509    /// [`harden`](Git::harden) for what the profile does.
3510    pub fn hardened() -> Self {
3511        Self::new().harden()
3512    }
3513}
3514
3515/// A [`Git`] client with a working directory bound, so calls drop the leading
3516/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
3517/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
3518/// copy: it only borrows the client and the path.
3519pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
3520    git: &'a Git<R>,
3521    dir: &'a Path,
3522}
3523
3524// Hand-written rather than derived: the view only holds two references, so it is
3525// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
3526// bound that the real default `JobRunner` doesn't satisfy, silently dropping
3527// `Copy` on the production `Repo::git_at()` handle.
3528impl<R: ProcessRunner> Clone for GitAt<'_, R> {
3529    fn clone(&self) -> Self {
3530        *self
3531    }
3532}
3533impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
3534
3535// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
3536// verbatim, `dir` methods inject `self.dir` as the first argument. The shared
3537// macro lives in `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
3538vcs_cli_support::at_forwarders! {
3539    GitAt, git, "Git",
3540    bare {
3541        fn version() -> Result<String>;
3542        fn capabilities() -> Result<GitCapabilities>;
3543        fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
3544    }
3545    dir {
3546        fn status() -> Result<Vec<StatusEntry>>;
3547        fn status_text() -> Result<String>;
3548        fn status_tracked() -> Result<Vec<StatusEntry>>;
3549        fn branch_status() -> Result<BranchStatus>;
3550        fn conflicted_files() -> Result<Vec<PathBuf>>;
3551        fn current_branch() -> Result<Option<String>>;
3552        fn branches() -> Result<Vec<Branch>>;
3553        fn log(revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
3554        fn log_paths(revspec: &RevSpec, max: usize, paths: &[String]) -> Result<Vec<Commit>>;
3555        fn rev_parse(rev: &RevSpec) -> Result<String>;
3556        fn rev_parse_short(rev: &RevSpec) -> Result<String>;
3557        fn init() -> Result<()>;
3558        fn add(paths: &[PathBuf]) -> Result<()>;
3559        fn commit(message: &str) -> Result<()>;
3560        fn create_branch(name: &RefName) -> Result<()>;
3561        fn checkout(target: &CheckoutTarget) -> Result<()>;
3562        fn checkout_detach(commit: &RevSpec) -> Result<()>;
3563        fn commit_paths(spec: CommitPaths) -> Result<()>;
3564        fn last_commit_message() -> Result<String>;
3565        fn is_unborn() -> Result<bool>;
3566        fn diff_is_empty() -> Result<bool>;
3567        fn common_dir() -> Result<PathBuf>;
3568        fn git_dir() -> Result<PathBuf>;
3569        fn resolve_commit(rev: &RevSpec) -> Result<String>;
3570        fn remote_head_branch() -> Result<Option<String>>;
3571        fn branch_exists(name: &RefName) -> Result<bool>;
3572        fn remote_branch_exists(name: &RefName) -> Result<bool>;
3573        fn remote_url(remote: &str) -> Result<String>;
3574        fn remote_list() -> Result<Vec<Remote>>;
3575        fn upstream() -> Result<Option<String>>;
3576        fn remote_branches(remote: &str) -> Result<Vec<String>>;
3577        fn is_merged(spec: MergeCheck) -> Result<bool>;
3578        fn set_upstream(branch: &RefName, upstream: &RefName) -> Result<()>;
3579        fn delete_branch(spec: BranchDelete) -> Result<()>;
3580        fn rename_branch(old: &RefName, new: &RefName) -> Result<()>;
3581        fn rev_list_count(range: &RevSpec) -> Result<usize>;
3582        fn diff_range_is_empty(range: &RevSpec) -> Result<bool>;
3583        fn diff_stat(range: &RevSpec) -> Result<DiffStat>;
3584        fn diff_text(spec: DiffSpec) -> Result<String>;
3585        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
3586        fn diff_text_between(from: &RevSpec, to: &RevSpec) -> Result<String>;
3587        fn diff_between(from: &RevSpec, to: &RevSpec) -> Result<Vec<FileDiff>>;
3588        fn diff_text_between_within(
3589            from: &RevSpec,
3590            to: &RevSpec,
3591            budget: OutputBudget,
3592        ) -> Result<String>;
3593        fn diff_between_within(
3594            from: &RevSpec,
3595            to: &RevSpec,
3596            budget: OutputBudget,
3597        ) -> Result<Vec<FileDiff>>;
3598        fn staged_is_empty() -> Result<bool>;
3599        fn is_rebase_in_progress() -> Result<bool>;
3600        fn is_merge_in_progress() -> Result<bool>;
3601        fn is_am_in_progress() -> Result<bool>;
3602        fn is_cherry_pick_in_progress() -> Result<bool>;
3603        fn is_revert_in_progress() -> Result<bool>;
3604        fn is_bisect_in_progress() -> Result<bool>;
3605        fn fetch() -> Result<()>;
3606        fn fetch_from(remote: &str) -> Result<()>;
3607        fn fetch_branch(branch: &RefName) -> Result<()>;
3608        fn push(spec: GitPush) -> Result<()>;
3609        fn merge_squash(branch: &RevSpec) -> Result<()>;
3610        fn merge_commit(spec: MergeCommit) -> Result<()>;
3611        fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
3612        fn merge_abort() -> Result<()>;
3613        fn merge_continue() -> Result<()>;
3614        fn reset_merge() -> Result<()>;
3615        fn reset_hard(rev: &RevSpec) -> Result<()>;
3616        fn rebase(onto: &RevSpec) -> Result<()>;
3617        fn rebase_abort() -> Result<()>;
3618        fn am_abort() -> Result<()>;
3619        fn am_continue() -> Result<()>;
3620        fn rebase_continue() -> Result<()>;
3621        fn stash_push(spec: StashPush) -> Result<()>;
3622        fn stash_pop() -> Result<()>;
3623        fn stash_list() -> Result<Vec<StashEntry>>;
3624        fn stash_apply(index: usize) -> Result<()>;
3625        fn stash_drop(index: usize) -> Result<()>;
3626        fn clean(spec: Clean) -> Result<Vec<CleanEntry>>;
3627        fn switch_with_stash(target: &CheckoutTarget) -> Result<()>;
3628        fn worktree_list() -> Result<Vec<Worktree>>;
3629        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
3630        fn worktree_remove(spec: WorktreeRemove) -> Result<()>;
3631        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
3632        fn worktree_prune() -> Result<()>;
3633        fn sparse_checkout_set(spec: SparseCheckoutSet) -> Result<()>;
3634        fn sparse_checkout_list() -> Result<Vec<String>>;
3635        fn sparse_checkout_disable() -> Result<()>;
3636        fn submodule_list() -> Result<Vec<Submodule>>;
3637        fn submodule_status() -> Result<Vec<SubmoduleStatus>>;
3638        fn submodule_update(spec: SubmoduleUpdate) -> Result<()>;
3639        fn tag_create(name: &RefName, rev: Option<RevSpec>) -> Result<()>;
3640        fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
3641        fn tag_list() -> Result<Vec<String>>;
3642        fn tag_delete(name: &RefName) -> Result<()>;
3643        fn show_file(rev: &RevSpec, path: &str) -> Result<String>;
3644        fn config_get(key: &str) -> Result<Option<String>>;
3645        fn config_set(key: &str, value: &str) -> Result<()>;
3646        fn remote_add(name: &str, url: &str) -> Result<()>;
3647        fn remote_set_url(name: &str, url: &str) -> Result<()>;
3648        fn blame(path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
3649        fn cherry_pick(rev: &RevSpec) -> Result<()>;
3650        fn revert(rev: &RevSpec) -> Result<()>;
3651        fn rebase_skip() -> Result<()>;
3652        fn cherry_pick_abort() -> Result<()>;
3653        fn cherry_pick_continue() -> Result<()>;
3654        fn revert_abort() -> Result<()>;
3655        fn revert_continue() -> Result<()>;
3656        fn bisect_reset() -> Result<()>;
3657        fn bisect_start(bad: &RevSpec, good: &RevSpec) -> Result<BisectStep>;
3658        fn bisect_good() -> Result<BisectStep>;
3659        fn bisect_bad() -> Result<BisectStep>;
3660        fn bisect_skip() -> Result<BisectStep>;
3661    }
3662    // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
3663    // twins) so `git.at(dir).run(…)` runs in the bound repo, not the process cwd.
3664    // For the process-cwd hatch call `run`/`run_raw`/… on `Git` directly.
3665    raw {
3666        fn run(args: &[String]) -> Result<String> => run_in;
3667        fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
3668        fn run_args(args: &[&str]) -> Result<String> => run_args_in;
3669        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
3670    }
3671}
3672
3673/// Synchronous, best-effort helpers for contexts that cannot `.await`.
3674pub mod blocking;
3675
3676#[cfg(test)]
3677mod tests {
3678    use super::*;
3679
3680    /// The [`ErrorReason`] behind a failed result. Since processkit 3.0 `Error` is
3681    /// an opaque wrapper, so the variant assertions below reach the reason through
3682    /// it instead of matching the error directly.
3683    fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
3684        out.as_ref().err().map(Error::reason)
3685    }
3686
3687    // Terse constructors for the validated newtypes in test call sites; the
3688    // literals here are always valid, so `unwrap` is fine in tests.
3689    fn rn(s: &str) -> RefName {
3690        RefName::new(s).unwrap()
3691    }
3692    fn rv(s: &str) -> RevSpec {
3693        RevSpec::new(s).unwrap()
3694    }
3695    fn ct(s: &str) -> CheckoutTarget {
3696        if s == "-" {
3697            CheckoutTarget::Previous
3698        } else {
3699            CheckoutTarget::Ref(rv(s))
3700        }
3701    }
3702    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
3703
3704    #[test]
3705    fn binary_name_is_git() {
3706        assert_eq!(BINARY, "git");
3707    }
3708
3709    // Compile-time guard: the bound view must stay `Copy` for the *default*
3710    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
3711    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
3712    #[allow(dead_code)]
3713    fn bound_view_is_copy_for_default_runner() {
3714        fn assert_copy<T: Copy>() {}
3715        assert_copy::<GitAt<'static, processkit::JobRunner>>();
3716    }
3717
3718    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
3719    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
3720    // in the right place and nothing else changes.
3721    #[tokio::test]
3722    async fn bound_view_matches_dir_taking_calls() {
3723        let dir = Path::new("/repo");
3724        let rec = RecordingRunner::replying(Reply::ok(""));
3725        let git = Git::with_runner(&rec);
3726
3727        // A method with trailing args (dir injected first).
3728        git.merge_commit(dir, MergeCommit::branch(rv("feat")).no_ff())
3729            .await
3730            .unwrap();
3731        git.at(dir)
3732            .merge_commit(MergeCommit::branch(rv("feat")).no_ff())
3733            .await
3734            .unwrap();
3735        // A method taking a path arg after dir.
3736        git.worktree_remove(dir, WorktreeRemove::new("/wt").force())
3737            .await
3738            .unwrap();
3739        git.at(dir)
3740            .worktree_remove(WorktreeRemove::new("/wt").force())
3741            .await
3742            .unwrap();
3743        // One of the new query methods.
3744        git.conflicted_files(dir).await.unwrap();
3745        git.at(dir).conflicted_files().await.unwrap();
3746        // One of the §4 additions.
3747        git.tag_delete(dir, &rn("v1")).await.unwrap();
3748        git.at(dir).tag_delete(&rn("v1")).await.unwrap();
3749
3750        let calls = rec.calls();
3751        assert_eq!(calls[0].args_str(), calls[1].args_str());
3752        assert_eq!(calls[2].args_str(), calls[3].args_str());
3753        assert_eq!(calls[4].args_str(), calls[5].args_str());
3754        assert_eq!(calls[6].args_str(), calls[7].args_str());
3755        // The bound calls also carried the bound dir as their working directory.
3756        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
3757        assert_eq!(calls[3].cwd.as_deref(), Some(dir));
3758    }
3759
3760    // T-035: the raw escape hatches reached *through* the bound view
3761    // (`git.at(dir).run…`) now run in the bound `dir`, while the same-named methods
3762    // on the client stay in the process cwd. Guards that a bound handle's raw call
3763    // can no longer silently target another repo, and that the explicit process-cwd
3764    // hatch is preserved.
3765    #[tokio::test]
3766    async fn bound_view_raw_hatch_runs_in_bound_dir() {
3767        let dir = Path::new("/repo");
3768        let rec = RecordingRunner::replying(Reply::ok(""));
3769        let git = Git::with_runner(&rec);
3770
3771        // Through the bound view: every raw form carries the bound dir as its cwd.
3772        git.at(dir).run(&["status".to_string()]).await.unwrap();
3773        let _ = git.at(dir).run_raw(&["status".to_string()]).await.unwrap();
3774        git.at(dir).run_args(&["status"]).await.unwrap();
3775        let _ = git.at(dir).run_raw_args(&["status"]).await.unwrap();
3776        // On the client directly: the process-cwd escape hatch (no bound dir).
3777        git.run(&["status".to_string()]).await.unwrap();
3778        let _ = git.run_raw(&["status".to_string()]).await.unwrap();
3779        git.run_args(&["status"]).await.unwrap();
3780        let _ = git.run_raw_args(&["status"]).await.unwrap();
3781
3782        let calls = rec.calls();
3783        for c in &calls[0..4] {
3784            assert_eq!(
3785                c.cwd.as_deref(),
3786                Some(dir),
3787                "raw call through the bound view runs in the bound dir"
3788            );
3789            assert_eq!(c.args_str(), ["status"]);
3790        }
3791        for c in &calls[4..8] {
3792            assert_eq!(
3793                c.cwd.as_deref(),
3794                None,
3795                "raw call on the client stays in the process cwd"
3796            );
3797            assert_eq!(c.args_str(), ["status"]);
3798        }
3799    }
3800
3801    // Hermetic: the real status() command-building + porcelain parsing run
3802    // against a scripted runner — no `git` binary needed, so this runs on CI.
3803    #[tokio::test]
3804    async fn status_parses_scripted_output() {
3805        // `-z` output: NUL-delimited records, raw paths.
3806        let git = Git::with_runner(
3807            ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
3808        );
3809        let entries = git.status(Path::new(".")).await.expect("status");
3810        assert_eq!(entries.len(), 2);
3811        assert_eq!(entries[0].code, " M");
3812        assert_eq!(entries[1].path, Path::new("b.rs"));
3813    }
3814
3815    // `status_tracked` is `status` minus untracked files — same parser, extra flag.
3816    #[tokio::test]
3817    async fn status_tracked_excludes_untracked_flag() {
3818        let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
3819        let git = Git::with_runner(&rec);
3820        let entries = git.status_tracked(Path::new(".")).await.expect("status");
3821        assert_eq!(entries.len(), 1);
3822        assert_eq!(entries[0].code, " M");
3823        assert_eq!(
3824            rec.only_call().args_str(),
3825            ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
3826        );
3827    }
3828
3829    // `branch_status` builds the porcelain v2 + branch + -z argv and parses the
3830    // combined header/entry output in one call.
3831    #[tokio::test]
3832    async fn branch_status_builds_v2_branch_args_and_parses() {
3833        let out = concat!(
3834            "# branch.oid abc\0",
3835            "# branch.head main\0",
3836            "# branch.upstream origin/main\0",
3837            "# branch.ab +1 -0\0",
3838            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
3839            "? new.txt\0",
3840        );
3841        let rec = RecordingRunner::replying(Reply::ok(out));
3842        let git = Git::with_runner(&rec);
3843        let s = git
3844            .branch_status(Path::new("."))
3845            .await
3846            .expect("branch_status");
3847        assert_eq!(
3848            rec.only_call().args_str(),
3849            ["status", "--porcelain=v2", "--branch", "-z"]
3850        );
3851        // The poll primitive must not itself write the index (and re-trigger a
3852        // filesystem watcher re-querying through it).
3853        assert!(rec.only_call().envs.iter().any(|(k, v)| {
3854            k.to_str() == Some("GIT_OPTIONAL_LOCKS")
3855                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3856        }));
3857        assert_eq!(s.branch.as_deref(), Some("main"));
3858        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
3859        assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
3860        assert_eq!(s.tracked_changes, 1);
3861        assert_eq!(s.untracked, 1);
3862        assert!(s.is_dirty());
3863    }
3864
3865    // `conflicted_files` lists unmerged paths NUL-delimited (no quoting).
3866    #[tokio::test]
3867    async fn conflicted_files_builds_args_and_parses_nul_list() {
3868        let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
3869        let git = Git::with_runner(&rec);
3870        let paths = git
3871            .conflicted_files(Path::new("."))
3872            .await
3873            .expect("conflicted_files");
3874        assert_eq!(
3875            paths,
3876            [PathBuf::from("a.rs"), PathBuf::from("sub/spaced name.rs")]
3877        );
3878        assert_eq!(
3879            rec.only_call().args_str(),
3880            ["diff", "--name-only", "--diff-filter=U", "-z"]
3881        );
3882    }
3883
3884    #[tokio::test]
3885    async fn rev_parse_short_builds_short_flag() {
3886        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
3887        let git = Git::with_runner(&rec);
3888        let out = git
3889            .rev_parse_short(Path::new("/r"), &rv("HEAD"))
3890            .await
3891            .unwrap();
3892        assert_eq!(out, "a1b2c3d");
3893        assert_eq!(
3894            rec.only_call().args_str(),
3895            ["rev-parse", "--verify", "--short", "HEAD"]
3896        );
3897    }
3898
3899    // M13: `rev_parse` passes `--verify` so a non-revision (a filename) errors
3900    // instead of being echoed back as a fake object id.
3901    #[tokio::test]
3902    async fn rev_parse_verifies_the_revision() {
3903        let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
3904        let git = Git::with_runner(&rec);
3905        let out = git.rev_parse(Path::new("/r"), &rv("HEAD")).await.unwrap();
3906        assert_eq!(out, "deadbeef");
3907        assert_eq!(
3908            rec.only_call().args_str(),
3909            ["rev-parse", "--verify", "HEAD"]
3910        );
3911    }
3912
3913    // M20: `git am` and an apply-backend rebase share the `rebase-apply/` dir, but am
3914    // marks it with an `applying` file. `is_am_in_progress` must fire only for the am,
3915    // and `is_rebase_in_progress` must NOT (so an am isn't aborted with `rebase --abort`).
3916    #[tokio::test]
3917    async fn distinguishes_git_am_from_an_apply_backend_rebase() {
3918        use vcs_testkit::TempDir;
3919        let gd = TempDir::new("m20-am");
3920        let git = Git::with_runner(ScriptedRunner::new().on(
3921            ["git", "rev-parse", "--git-dir"],
3922            Reply::ok(gd.path().to_str().unwrap()),
3923        ));
3924        let apply = gd.path().join("rebase-apply");
3925        std::fs::create_dir_all(&apply).unwrap();
3926
3927        // With the `applying` marker → a `git am`.
3928        std::fs::write(apply.join("applying"), b"").unwrap();
3929        assert!(
3930            git.is_am_in_progress(Path::new("/r")).await.unwrap(),
3931            "am detected"
3932        );
3933        assert!(
3934            !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3935            "a git am is NOT reported as a rebase"
3936        );
3937
3938        // Without it → an apply-backend rebase.
3939        std::fs::remove_file(apply.join("applying")).unwrap();
3940        assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
3941        assert!(
3942            git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3943            "a bare rebase-apply dir is a rebase"
3944        );
3945    }
3946
3947    // T-044: the sequencer states each key off their own git-dir marker, and a
3948    // cherry-pick/revert conflict does NOT write `MERGE_HEAD` — so none of them is
3949    // mistaken for a merge (which would dispatch `merge --abort` on a real repo).
3950    #[tokio::test]
3951    async fn detects_cherry_pick_revert_and_bisect_markers() {
3952        use vcs_testkit::TempDir;
3953        let gd = TempDir::new("t044-seq");
3954        let git = Git::with_runner(ScriptedRunner::new().on(
3955            ["git", "rev-parse", "--git-dir"],
3956            Reply::ok(gd.path().to_str().unwrap()),
3957        ));
3958        let d = Path::new("/r");
3959        let touch = |name: &str| std::fs::write(gd.path().join(name), b"x\n").unwrap();
3960        let rm = |name: &str| std::fs::remove_file(gd.path().join(name)).unwrap();
3961
3962        // A cherry-pick: CHERRY_PICK_HEAD present, and crucially NOT read as a merge.
3963        touch("CHERRY_PICK_HEAD");
3964        assert!(git.is_cherry_pick_in_progress(d).await.unwrap());
3965        assert!(!git.is_merge_in_progress(d).await.unwrap());
3966        assert!(!git.is_revert_in_progress(d).await.unwrap());
3967        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3968        rm("CHERRY_PICK_HEAD");
3969
3970        // A revert.
3971        touch("REVERT_HEAD");
3972        assert!(git.is_revert_in_progress(d).await.unwrap());
3973        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3974        assert!(!git.is_merge_in_progress(d).await.unwrap());
3975        rm("REVERT_HEAD");
3976
3977        // A bisect (keyed off BISECT_LOG).
3978        touch("BISECT_LOG");
3979        assert!(git.is_bisect_in_progress(d).await.unwrap());
3980        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3981        assert!(!git.is_revert_in_progress(d).await.unwrap());
3982        rm("BISECT_LOG");
3983
3984        // Clean git dir → none fire.
3985        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3986        assert!(!git.is_revert_in_progress(d).await.unwrap());
3987        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3988    }
3989
3990    #[tokio::test]
3991    async fn bisect_operations_parse_steps_and_build_safe_argv() {
3992        const GOOD: &str = "1111111111111111111111111111111111111111";
3993        const BAD: &str = "2222222222222222222222222222222222222222";
3994        const CANDIDATE_ONE: &str = "3333333333333333333333333333333333333333";
3995        const CANDIDATE_TWO: &str = "4444444444444444444444444444444444444444";
3996        const CANDIDATE_THREE: &str = "5555555555555555555555555555555555555555";
3997        const FIRST_BAD: &str = "6666666666666666666666666666666666666666";
3998        let dir = Path::new("/r");
3999        let rec = RecordingRunner::new(
4000            ScriptedRunner::new()
4001                .on(
4002                    ["git", "bisect", "start", BAD, GOOD],
4003                    Reply::ok(format!(
4004                        "Bisecting: 1 revision left\n[{CANDIDATE_ONE}] c1\n"
4005                    )),
4006                )
4007                .on(
4008                    ["git", "bisect", "good"],
4009                    Reply::ok(format!(
4010                        "Bisecting: 0 revisions left\n[{CANDIDATE_TWO}] c2\n"
4011                    )),
4012                )
4013                .on(
4014                    ["git", "bisect", "skip"],
4015                    Reply::ok(format!(
4016                        "Bisecting: 0 revisions left\n[{CANDIDATE_THREE}] c3\n"
4017                    )),
4018                )
4019                .on(
4020                    ["git", "bisect", "bad"],
4021                    Reply::ok(format!(
4022                        "{FIRST_BAD} is the first 'bad' commit\ncommit {FIRST_BAD}\n"
4023                    )),
4024                ),
4025        );
4026        let git = Git::with_runner(&rec);
4027
4028        let start = git.bisect_start(dir, &rv(BAD), &rv(GOOD)).await.unwrap();
4029        assert_eq!(
4030            start,
4031            BisectStep::NextCandidate {
4032                revision: rv(CANDIDATE_ONE)
4033            }
4034        );
4035
4036        let good = git.at(dir).bisect_good().await.unwrap();
4037        assert_eq!(good.revision().as_str(), CANDIDATE_TWO);
4038        assert!(!good.is_first_bad());
4039
4040        let skip = git.bisect_skip(dir).await.unwrap();
4041        assert_eq!(skip.revision().as_str(), CANDIDATE_THREE);
4042        assert!(!skip.is_first_bad());
4043
4044        let bad = git.bisect_bad(dir).await.unwrap();
4045        assert_eq!(bad.revision().as_str(), FIRST_BAD);
4046        assert!(bad.is_first_bad());
4047
4048        let calls = rec.calls();
4049        assert_eq!(calls.len(), 4);
4050        assert_eq!(calls[0].args_str(), ["bisect", "start", BAD, GOOD]);
4051        assert_eq!(calls[1].args_str(), ["bisect", "good"]);
4052        assert_eq!(calls[2].args_str(), ["bisect", "skip"]);
4053        assert_eq!(calls[3].args_str(), ["bisect", "bad"]);
4054        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
4055        assert!(calls.iter().all(|call| {
4056            call.envs.iter().any(|(name, value)| {
4057                name.to_str() == Some("LC_ALL")
4058                    && value.as_deref().and_then(|value| value.to_str()) == Some("C")
4059            })
4060        }));
4061    }
4062
4063    #[tokio::test]
4064    async fn bisect_malformed_or_ambiguous_output_is_parse_error() {
4065        let malformed = Git::with_runner(ScriptedRunner::new().on(
4066            ["git", "bisect", "good"],
4067            Reply::ok("[not-an-object-id] candidate\n"),
4068        ));
4069        let err = malformed
4070            .bisect_good(Path::new("/r"))
4071            .await
4072            .expect_err("malformed object id must not be accepted");
4073        assert!(matches!(err.reason(), ErrorReason::Parse { .. }));
4074
4075        let ambiguous = Git::with_runner(ScriptedRunner::new().on(
4076            ["git", "bisect", "skip"],
4077            Reply::ok(
4078                "There are only 'skip'ped commits left to test.\n\
4079                 [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] one\n\
4080                 [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb] two\n",
4081            ),
4082        ));
4083        let err = ambiguous
4084            .bisect_skip(Path::new("/r"))
4085            .await
4086            .expect_err("multiple possible candidates must not choose one");
4087        assert!(matches!(err.reason(), ErrorReason::Parse { .. }));
4088    }
4089
4090    #[tokio::test]
4091    async fn bisect_nonzero_exit_preserves_structured_error() {
4092        let git =
4093            Git::with_runner(ScriptedRunner::new().on(
4094                ["git", "bisect", "bad"],
4095                Reply::fail(128, "fatal: no bisect session").with_stdout(
4096                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is the first bad commit\n",
4097                ),
4098            ));
4099        let err = git
4100            .bisect_bad(Path::new("/r"))
4101            .await
4102            .expect_err("git's non-zero result must not be parsed as success");
4103        assert!(matches!(
4104            err.reason(),
4105            ErrorReason::Exit {
4106                code: 128,
4107                stderr,
4108                ..
4109            } if stderr.contains("no bisect session")
4110        ));
4111    }
4112
4113    // A non-zero exit surfaces as a structured `ErrorReason::Exit`.
4114    #[tokio::test]
4115    async fn nonzero_exit_is_structured_error() {
4116        let git = Git::with_runner(
4117            ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
4118        );
4119        match git.status(Path::new(".")).await.unwrap_err().into_reason() {
4120            ErrorReason::Exit { code, stderr, .. } => {
4121                assert_eq!(code, 128);
4122                assert!(stderr.contains("not a git repository"), "{stderr}");
4123            }
4124            other => panic!("expected Exit, got {other:?}"),
4125        }
4126    }
4127
4128    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
4129    // anything else is a real failure surfaced as ErrorReason::Exit.
4130    #[tokio::test]
4131    async fn diff_is_empty_maps_exit_codes() {
4132        let clean =
4133            Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
4134        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
4135
4136        let dirty = Git::with_runner(
4137            ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
4138        );
4139        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
4140
4141        let broken = Git::with_runner(ScriptedRunner::new().on(
4142            ["git", "diff", "--quiet"],
4143            Reply::fail(128, "fatal: not a repo"),
4144        ));
4145        assert!(matches!(
4146            broken
4147                .diff_is_empty(Path::new("."))
4148                .await
4149                .unwrap_err()
4150                .reason(),
4151            ErrorReason::Exit { code: 128, .. }
4152        ));
4153    }
4154
4155    // `add` must insert `--` before the pathspecs so a path can never be parsed
4156    // as an option, and `--literal-pathspecs` so a glob-magic character in a
4157    // path matches literally (R-01). No fallback rule: the run only matches if
4158    // `--literal-pathspecs add --` was built.
4159    #[tokio::test]
4160    async fn add_inserts_pathspec_separator() {
4161        let git = Git::with_runner(
4162            ScriptedRunner::new().on(["git", "--literal-pathspecs", "add", "--"], Reply::ok("")),
4163        );
4164        git.add(Path::new("."), &[PathBuf::from("f.rs")])
4165            .await
4166            .expect("add should build `--literal-pathspecs add -- <paths>`");
4167    }
4168
4169    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` must route
4170    // through the NUL-safe `--pathspec-from-file=- --pathspec-file-nul`
4171    // transport instead of the plain `add -- <paths>` argv: no per-path argv
4172    // entries, the payload travels over stdin instead (T-052).
4173    #[tokio::test]
4174    async fn add_large_path_set_uses_pathspec_from_file_stdin() {
4175        let rec = RecordingRunner::replying(Reply::ok(""));
4176        let git = Git::with_runner(&rec);
4177        let paths: Vec<PathBuf> = (0..2_000)
4178            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
4179            .collect();
4180        git.add(Path::new("."), &paths).await.expect("add");
4181        let call = rec.only_call();
4182        assert_eq!(
4183            call.args_str(),
4184            [
4185                "--literal-pathspecs",
4186                "add",
4187                "--pathspec-from-file=-",
4188                "--pathspec-file-nul",
4189            ]
4190        );
4191        assert!(call.has_stdin, "paths must travel over stdin, not argv");
4192    }
4193
4194    #[tokio::test]
4195    async fn worktree_list_parses_porcelain() {
4196        let git = Git::with_runner(ScriptedRunner::new().on(
4197            ["git", "worktree", "list"],
4198            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
4199        ));
4200        let wts = git.worktree_list(Path::new(".")).await.expect("list");
4201        assert_eq!(wts.len(), 1);
4202        assert_eq!(wts[0].branch.as_deref(), Some("main"));
4203        assert_eq!(wts[0].head.as_deref(), Some("abc"));
4204    }
4205
4206    // `submodule_list` builds the machine-unambiguous `config --file .gitmodules
4207    // --list -z` argv and parses the `-z` records — but only after the on-disk
4208    // `.gitmodules` probe passes, so this test writes a real file into the bound
4209    // dir before scripting the runner.
4210    #[tokio::test]
4211    async fn submodule_list_builds_config_argv_and_parses() {
4212        use vcs_testkit::TempDir;
4213        let tmp = TempDir::new("t096-list");
4214        std::fs::write(
4215            tmp.path().join(".gitmodules"),
4216            "[submodule \"libs/sub\"]\n\tpath = libs/sub\n\turl = ../sub\n",
4217        )
4218        .unwrap();
4219        let rec = RecordingRunner::replying(Reply::ok(
4220            "submodule.libs/sub.path\nlibs/sub\0submodule.libs/sub.url\n../sub\0",
4221        ));
4222        let git = Git::with_runner(&rec);
4223        let subs = git.submodule_list(tmp.path()).await.expect("list");
4224        assert_eq!(
4225            rec.only_call().args_str(),
4226            ["config", "--file", ".gitmodules", "--list", "-z"]
4227        );
4228        assert_eq!(subs.len(), 1);
4229        assert_eq!(subs[0].name, "libs/sub");
4230        assert_eq!(subs[0].path, PathBuf::from("libs/sub"));
4231        assert_eq!(subs[0].url, "../sub");
4232    }
4233
4234    // No `.gitmodules` on disk ⇒ an empty list, and git is never spawned (the
4235    // absent-file probe short-circuits before the runner).
4236    #[tokio::test]
4237    async fn submodule_list_without_gitmodules_is_empty_without_spawning() {
4238        use vcs_testkit::TempDir;
4239        let tmp = TempDir::new("t096-nolist");
4240        let rec = RecordingRunner::replying(Reply::ok("unused"));
4241        let git = Git::with_runner(&rec);
4242        let subs = git.submodule_list(tmp.path()).await.expect("list");
4243        assert!(subs.is_empty());
4244        assert!(
4245            rec.calls().is_empty(),
4246            "no `.gitmodules` must not spawn git"
4247        );
4248    }
4249
4250    // `submodule_status` builds `submodule status` and parses the prefix states.
4251    #[tokio::test]
4252    async fn submodule_status_builds_argv_and_parses() {
4253        let rec = RecordingRunner::replying(Reply::ok(
4254            " 833caa0 libs/sub (heads/main)\n-deadbee other/sub\n",
4255        ));
4256        let git = Git::with_runner(&rec);
4257        let got = git
4258            .submodule_status(Path::new("/repo"))
4259            .await
4260            .expect("status");
4261        assert_eq!(rec.only_call().args_str(), ["submodule", "status"]);
4262        assert_eq!(got.len(), 2);
4263        assert_eq!(got[0].state, SubmoduleState::Current);
4264        assert_eq!(got[0].path, PathBuf::from("libs/sub"));
4265        assert_eq!(got[0].describe.as_deref(), Some("heads/main"));
4266        assert_eq!(got[1].state, SubmoduleState::Uninitialized);
4267        assert_eq!(got[1].sha, "deadbee");
4268    }
4269
4270    // The full `submodule update` builder emits its flags in a fixed order, ends
4271    // the paths behind `--`, and pins terminal prompts off.
4272    #[tokio::test]
4273    async fn submodule_update_builds_all_flags_in_order() {
4274        let rec = RecordingRunner::replying(Reply::ok(""));
4275        let git = Git::with_runner(&rec);
4276        git.submodule_update(
4277            Path::new("/repo"),
4278            SubmoduleUpdate::new()
4279                .init()
4280                .recursive()
4281                .depth(1)
4282                .path("libs/sub"),
4283        )
4284        .await
4285        .expect("update");
4286        let call = rec.only_call();
4287        assert_eq!(
4288            call.args_str(),
4289            [
4290                "submodule",
4291                "update",
4292                "--init",
4293                "--recursive",
4294                "--depth",
4295                "1",
4296                "--",
4297                "libs/sub"
4298            ]
4299        );
4300        assert!(
4301            call.env_is("GIT_TERMINAL_PROMPT", "0"),
4302            "a fetching update must not block on a credential prompt"
4303        );
4304    }
4305
4306    // A default spec is a bare `submodule update` — no flags, no `--`.
4307    #[tokio::test]
4308    async fn submodule_update_default_is_bare() {
4309        let rec = RecordingRunner::replying(Reply::ok(""));
4310        let git = Git::with_runner(&rec);
4311        git.submodule_update(Path::new("/repo"), SubmoduleUpdate::new())
4312            .await
4313            .expect("update");
4314        assert_eq!(rec.only_call().args_str(), ["submodule", "update"]);
4315    }
4316
4317    // A flag-shaped positional path is refused before spawning — the `--`
4318    // terminator already makes it inert, but the guard gives a clean error.
4319    #[tokio::test]
4320    async fn submodule_update_rejects_flag_like_path() {
4321        let rec = RecordingRunner::replying(Reply::ok(""));
4322        let git = Git::with_runner(&rec);
4323        let err = git
4324            .submodule_update(
4325                Path::new("/repo"),
4326                SubmoduleUpdate::new().path("--upload-pack=/bin/evil"),
4327            )
4328            .await
4329            .expect_err("a flag-like submodule path must be refused");
4330        assert!(vcs_cli_support::is_invalid_input(&err));
4331        assert!(rec.calls().is_empty(), "nothing may spawn");
4332    }
4333
4334    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
4335    // in that exact order; only the full argv is scripted (no fallback).
4336    #[tokio::test]
4337    async fn worktree_add_builds_branch_path_and_base() {
4338        let rec = RecordingRunner::replying(Reply::ok(""));
4339        let git = Git::with_runner(&rec);
4340        git.worktree_add(
4341            Path::new("/repo"),
4342            WorktreeAdd::create_branch("/wt", rn("feature"), rv("main")),
4343        )
4344        .await
4345        .expect("worktree add");
4346        assert_eq!(
4347            rec.only_call().args_str(),
4348            ["worktree", "add", "-b", "feature", "/wt", "main"]
4349        );
4350    }
4351
4352    #[tokio::test]
4353    async fn worktree_remove_passes_force_then_path() {
4354        let rec = RecordingRunner::replying(Reply::ok(""));
4355        let git = Git::with_runner(&rec);
4356        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt").force())
4357            .await
4358            .expect("remove");
4359        assert_eq!(
4360            rec.only_call().args_str(),
4361            ["worktree", "remove", "--force", "/wt"]
4362        );
4363    }
4364
4365    // The default (un-forced) spec omits `--force`.
4366    #[tokio::test]
4367    async fn worktree_remove_default_omits_force() {
4368        let rec = RecordingRunner::replying(Reply::ok(""));
4369        let git = Git::with_runner(&rec);
4370        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt"))
4371            .await
4372            .expect("remove");
4373        assert_eq!(rec.only_call().args_str(), ["worktree", "remove", "/wt"]);
4374    }
4375
4376    // `--no-checkout` must land between `-b <name>` and the path.
4377    #[tokio::test]
4378    async fn worktree_add_no_checkout_inserts_flag() {
4379        let rec = RecordingRunner::replying(Reply::ok(""));
4380        let git = Git::with_runner(&rec);
4381        git.worktree_add(
4382            Path::new("/repo"),
4383            WorktreeAdd::checkout("/wt", rv("main")).no_checkout(),
4384        )
4385        .await
4386        .expect("worktree add");
4387        assert_eq!(
4388            rec.only_call().args_str(),
4389            ["worktree", "add", "--no-checkout", "/wt", "main"]
4390        );
4391    }
4392
4393    // A flag-shaped `path` is refused before spawning — `worktree add -b <name>
4394    // -evil <base>` would otherwise let git reparse `-evil` as an unknown flag
4395    // rather than the intended path.
4396    #[tokio::test]
4397    async fn worktree_add_rejects_flag_like_path() {
4398        let rec = RecordingRunner::replying(Reply::ok(""));
4399        let git = Git::with_runner(&rec);
4400        let err = git
4401            .worktree_add(
4402                Path::new("/repo"),
4403                WorktreeAdd::checkout("-evil", rv("main")),
4404            )
4405            .await
4406            .expect_err("a flag-like worktree path must be refused");
4407        assert!(vcs_cli_support::is_invalid_input(&err));
4408        assert!(rec.calls().is_empty(), "nothing may spawn");
4409    }
4410
4411    // Empty/whitespace-only paths are refused the same way.
4412    #[tokio::test]
4413    async fn worktree_add_rejects_empty_path() {
4414        let rec = RecordingRunner::replying(Reply::ok(""));
4415        let git = Git::with_runner(&rec);
4416        let err = git
4417            .worktree_add(Path::new("/repo"), WorktreeAdd::checkout("  ", rv("main")))
4418            .await
4419            .expect_err("an empty worktree path must be refused");
4420        assert!(vcs_cli_support::is_invalid_input(&err));
4421        assert!(rec.calls().is_empty(), "nothing may spawn");
4422    }
4423
4424    #[tokio::test]
4425    async fn worktree_remove_rejects_flag_like_path() {
4426        let rec = RecordingRunner::replying(Reply::ok(""));
4427        let git = Git::with_runner(&rec);
4428        let err = git
4429            .worktree_remove(Path::new("/repo"), WorktreeRemove::new("--force"))
4430            .await
4431            .expect_err("a flag-like worktree path must be refused");
4432        assert!(vcs_cli_support::is_invalid_input(&err));
4433        assert!(rec.calls().is_empty(), "nothing may spawn");
4434    }
4435
4436    #[tokio::test]
4437    async fn worktree_remove_rejects_empty_path() {
4438        let rec = RecordingRunner::replying(Reply::ok(""));
4439        let git = Git::with_runner(&rec);
4440        let err = git
4441            .worktree_remove(Path::new("/repo"), WorktreeRemove::new(""))
4442            .await
4443            .expect_err("an empty worktree path must be refused");
4444        assert!(vcs_cli_support::is_invalid_input(&err));
4445        assert!(rec.calls().is_empty(), "nothing may spawn");
4446    }
4447
4448    // The valid-path case still builds the expected argv (no regression from
4449    // the new guard).
4450    #[tokio::test]
4451    async fn worktree_move_builds_from_then_to() {
4452        let rec = RecordingRunner::replying(Reply::ok(""));
4453        let git = Git::with_runner(&rec);
4454        git.worktree_move(
4455            Path::new("/repo"),
4456            Path::new("/wt-old"),
4457            Path::new("/wt-new"),
4458        )
4459        .await
4460        .expect("worktree move");
4461        assert_eq!(
4462            rec.only_call().args_str(),
4463            ["worktree", "move", "/wt-old", "/wt-new"]
4464        );
4465    }
4466
4467    // Both positionals are guarded — a flag-like `from` is caught first.
4468    #[tokio::test]
4469    async fn worktree_move_rejects_flag_like_from() {
4470        let rec = RecordingRunner::replying(Reply::ok(""));
4471        let git = Git::with_runner(&rec);
4472        let err = git
4473            .worktree_move(Path::new("/repo"), Path::new("-evil"), Path::new("/wt-new"))
4474            .await
4475            .expect_err("a flag-like `from` must be refused");
4476        assert!(vcs_cli_support::is_invalid_input(&err));
4477        assert!(rec.calls().is_empty(), "nothing may spawn");
4478    }
4479
4480    // A flag-like `to` is caught too, even when `from` is valid.
4481    #[tokio::test]
4482    async fn worktree_move_rejects_flag_like_to() {
4483        let rec = RecordingRunner::replying(Reply::ok(""));
4484        let git = Git::with_runner(&rec);
4485        let err = git
4486            .worktree_move(Path::new("/repo"), Path::new("/wt-old"), Path::new("-evil"))
4487            .await
4488            .expect_err("a flag-like `to` must be refused");
4489        assert!(vcs_cli_support::is_invalid_input(&err));
4490        assert!(rec.calls().is_empty(), "nothing may spawn");
4491    }
4492
4493    #[tokio::test]
4494    async fn sparse_checkout_set_defaults_to_cone_and_pins_patterns() {
4495        let rec = RecordingRunner::replying(Reply::ok(""));
4496        let git = Git::with_runner(&rec);
4497        git.sparse_checkout_set(
4498            Path::new("/repo"),
4499            SparseCheckoutSet::new(["src", "Cargo.toml"]),
4500        )
4501        .await
4502        .expect("sparse checkout set");
4503        assert_eq!(
4504            rec.only_call().args_str(),
4505            [
4506                "sparse-checkout",
4507                "set",
4508                "--cone",
4509                "--",
4510                "src",
4511                "Cargo.toml"
4512            ]
4513        );
4514    }
4515
4516    #[tokio::test]
4517    async fn sparse_checkout_set_non_cone_uses_only_no_cone_mode_flag() {
4518        let rec = RecordingRunner::replying(Reply::ok(""));
4519        let git = Git::with_runner(&rec);
4520        git.sparse_checkout_set(
4521            Path::new("/repo"),
4522            SparseCheckoutSet::new(["/*", "!/docs/"]).non_cone(),
4523        )
4524        .await
4525        .expect("sparse checkout set");
4526        assert_eq!(
4527            rec.only_call().args_str(),
4528            ["sparse-checkout", "set", "--no-cone", "--", "/*", "!/docs/"]
4529        );
4530    }
4531
4532    #[tokio::test]
4533    async fn sparse_checkout_set_rejects_empty_and_flag_like_patterns_before_spawn() {
4534        let empty_rec = RecordingRunner::replying(Reply::ok("unused"));
4535        let empty_git = Git::with_runner(&empty_rec);
4536        let empty_err = empty_git
4537            .sparse_checkout_set(
4538                Path::new("/repo"),
4539                SparseCheckoutSet::new(std::iter::empty::<String>()),
4540            )
4541            .await
4542            .expect_err("an empty sparse set must be refused");
4543        assert!(vcs_cli_support::is_invalid_input(&empty_err));
4544        assert!(empty_rec.calls().is_empty(), "nothing may spawn");
4545
4546        for pattern in ["--evil", " "] {
4547            let rec = RecordingRunner::replying(Reply::ok("unused"));
4548            let git = Git::with_runner(&rec);
4549            let err = git
4550                .sparse_checkout_set(Path::new("/repo"), SparseCheckoutSet::new([pattern]))
4551                .await
4552                .expect_err("an invalid sparse path/pattern must be refused");
4553            assert!(vcs_cli_support::is_invalid_input(&err));
4554            assert!(rec.calls().is_empty(), "nothing may spawn for {pattern:?}");
4555        }
4556    }
4557
4558    #[tokio::test]
4559    async fn sparse_checkout_list_preserves_pattern_text_and_line_order() {
4560        let rec = RecordingRunner::replying(Reply::ok("src\r\n  !/docs \n"));
4561        let git = Git::with_runner(&rec);
4562        assert_eq!(
4563            git.sparse_checkout_list(Path::new("/repo"))
4564                .await
4565                .expect("sparse checkout list"),
4566            ["src".to_string(), "  !/docs ".to_string()]
4567        );
4568        assert_eq!(rec.only_call().args_str(), ["sparse-checkout", "list"]);
4569    }
4570
4571    #[tokio::test]
4572    async fn sparse_checkout_disable_builds_the_typed_command() {
4573        let rec = RecordingRunner::replying(Reply::ok(""));
4574        let git = Git::with_runner(&rec);
4575        git.sparse_checkout_disable(Path::new("/repo"))
4576            .await
4577            .expect("sparse checkout disable");
4578        assert_eq!(rec.only_call().args_str(), ["sparse-checkout", "disable"]);
4579    }
4580
4581    #[tokio::test]
4582    async fn checkout_detach_builds_args() {
4583        let rec = RecordingRunner::replying(Reply::ok(""));
4584        let git = Git::with_runner(&rec);
4585        git.checkout_detach(Path::new("."), &rv("abc123"))
4586            .await
4587            .expect("detach");
4588        assert_eq!(
4589            rec.only_call().args_str(),
4590            ["checkout", "--detach", "abc123"]
4591        );
4592    }
4593
4594    // current_branch reads `symbolic-ref --quiet --short HEAD`: exit 0 → the branch
4595    // name (a normal *or* unborn branch), exit 1 → None (detached HEAD), and any
4596    // other non-zero (e.g. not a repository) stays a real error.
4597    #[tokio::test]
4598    async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
4599        // A normal branch (exit 0) — and the argv is pinned.
4600        let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
4601        let on_branch = Git::with_runner(&rec);
4602        assert_eq!(
4603            on_branch.current_branch(Path::new(".")).await.unwrap(),
4604            Some("feature/x".to_string())
4605        );
4606        assert_eq!(
4607            rec.only_call().args_str(),
4608            ["symbolic-ref", "--quiet", "--short", "HEAD"]
4609        );
4610        // An unborn branch also exits 0 with the branch name (the bug this fixes:
4611        // the old `rev-parse --abbrev-ref HEAD` errored with exit 128 here).
4612        let unborn = Git::with_runner(
4613            ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
4614        );
4615        assert_eq!(
4616            unborn.current_branch(Path::new(".")).await.unwrap(),
4617            Some("main".to_string())
4618        );
4619        // A detached HEAD exits 1 silently → None.
4620        let detached =
4621            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
4622        assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
4623        // Any other non-zero (not a repository, exit 128) is a real error.
4624        let not_repo = Git::with_runner(ScriptedRunner::new().on(
4625            ["git", "symbolic-ref"],
4626            Reply::fail(128, "fatal: not a git repository"),
4627        ));
4628        assert!(not_repo.current_branch(Path::new(".")).await.is_err());
4629    }
4630
4631    // Partial amend commit must build `--literal-pathspecs commit --amend -m
4632    // <msg> --only -- <paths>` — `--literal-pathspecs` so a glob-magic
4633    // character in a path matches literally (R-01).
4634    #[tokio::test]
4635    async fn commit_paths_builds_only_amend_args() {
4636        let rec = RecordingRunner::replying(Reply::ok(""));
4637        let git = Git::with_runner(&rec);
4638        git.commit_paths(
4639            Path::new("."),
4640            CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
4641        )
4642        .await
4643        .expect("commit_paths");
4644        assert_eq!(
4645            rec.only_call().args_str(),
4646            [
4647                "--literal-pathspecs",
4648                "commit",
4649                "--amend",
4650                "-m",
4651                "msg",
4652                "--only",
4653                "--",
4654                "a.rs",
4655                "b.rs"
4656            ]
4657        );
4658    }
4659
4660    // Same transport switch as `add`'s twin test: a path set over
4661    // `ARGV_PATHSPEC_BUDGET` commits through `--pathspec-from-file=-
4662    // --pathspec-file-nul` (paths over stdin) instead of a plain `-- <paths>`
4663    // argv tail — and it is still exactly **one** `git commit` call (T-052).
4664    #[tokio::test]
4665    async fn commit_paths_large_path_set_uses_pathspec_from_file_stdin() {
4666        let rec = RecordingRunner::replying(Reply::ok(""));
4667        let git = Git::with_runner(&rec);
4668        let paths: Vec<PathBuf> = (0..2_000)
4669            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
4670            .collect();
4671        git.commit_paths(Path::new("."), CommitPaths::new(paths, "msg").amend())
4672            .await
4673            .expect("commit_paths");
4674        let calls = rec.calls();
4675        assert_eq!(calls.len(), 1, "must be a single atomic commit invocation");
4676        let call = &calls[0];
4677        assert_eq!(
4678            call.args_str(),
4679            [
4680                "--literal-pathspecs",
4681                "commit",
4682                "--amend",
4683                "-m",
4684                "msg",
4685                "--only",
4686                "--pathspec-from-file=-",
4687                "--pathspec-file-nul",
4688            ]
4689        );
4690        assert!(call.has_stdin, "paths must travel over stdin, not argv");
4691    }
4692
4693    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
4694    // unborn (true), anything else is a structured error.
4695    #[tokio::test]
4696    async fn is_unborn_maps_exit_codes() {
4697        let born =
4698            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
4699        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
4700        let unborn =
4701            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
4702        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
4703        let broken = Git::with_runner(
4704            ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
4705        );
4706        assert!(matches!(
4707            broken.is_unborn(Path::new(".")).await.unwrap_err().reason(),
4708            ErrorReason::Exit { code: 128, .. }
4709        ));
4710    }
4711
4712    #[tokio::test]
4713    async fn log_builds_revspec_and_format() {
4714        let rec = RecordingRunner::replying(Reply::ok(""));
4715        let git = Git::with_runner(&rec);
4716        git.log(Path::new("."), &rv("main..HEAD"), 5)
4717            .await
4718            .expect("log");
4719        assert_eq!(
4720            rec.only_call().args_str(),
4721            [
4722                "log",
4723                "main..HEAD",
4724                "-n5",
4725                "-z",
4726                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4727                "--"
4728            ]
4729        );
4730    }
4731
4732    // `log_paths` must insert `--literal-pathspecs` (R-02) and `--` exactly
4733    // once, right before the pathspecs, after the same revspec/count/format
4734    // arguments `log` builds.
4735    #[tokio::test]
4736    async fn log_paths_builds_revspec_format_and_pathspec_separator() {
4737        let rec = RecordingRunner::replying(Reply::ok(""));
4738        let git = Git::with_runner(&rec);
4739        git.log_paths(
4740            Path::new("."),
4741            &rv("main..HEAD"),
4742            5,
4743            &["src/a.rs".to_string(), "src/b.rs".to_string()],
4744        )
4745        .await
4746        .expect("log_paths");
4747        let args = rec.only_call().args_str();
4748        assert_eq!(
4749            args,
4750            [
4751                "--literal-pathspecs",
4752                "log",
4753                "main..HEAD",
4754                "-n5",
4755                "-z",
4756                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4757                "--",
4758                "src/a.rs",
4759                "src/b.rs",
4760            ]
4761        );
4762        assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
4763    }
4764
4765    // An empty `paths` slice must NOT degrade to an unrestricted `git log` —
4766    // it's refused before any spawn (mirrors `commit_paths_refuses_empty_*` in
4767    // vcs-jj).
4768    #[tokio::test]
4769    async fn log_paths_refuses_empty_paths_without_spawning() {
4770        let rec = RecordingRunner::replying(Reply::ok(""));
4771        let git = Git::with_runner(&rec);
4772        let err = git
4773            .log_paths(Path::new("."), &rv("HEAD"), 5, &[])
4774            .await
4775            .expect_err("empty paths must be refused");
4776        assert!(
4777            matches!(err.reason(), ErrorReason::Spawn { .. }),
4778            "got {err:?}"
4779        );
4780        assert!(rec.calls().is_empty(), "nothing may spawn");
4781    }
4782
4783    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` splits
4784    // `log` into per-chunk calls (`git log` has no `--pathspec-from-file`
4785    // support, unlike `add`/`commit_paths`; each chunk call also carries
4786    // `--literal-pathspecs`, R-02) and merges the results: dedup by hash (the
4787    // "shared" commit appears in both chunks' canned output), reordered to
4788    // match a separate, pathless oracle call's commit order, capped at `max`
4789    // (T-052/R-03).
4790    //
4791    // All three commits share the exact same (second-resolution) author date
4792    // — a date-based sort would have no signal to order them at all and
4793    // could only fall back to arbitrary/input order — yet the oracle still
4794    // produces a definite, correct order, because it comes from git's own
4795    // traversal rather than from parsed timestamps. This is exactly the case
4796    // R-03 flagged as unfixable by refining the date sort further.
4797    #[tokio::test]
4798    async fn log_paths_large_path_set_chunks_dedupes_and_reorders_by_oracle_order() {
4799        // Two paths, each already over budget alone once paired — `chunk_pathspecs`
4800        // puts each in its own singleton chunk.
4801        let path_a = "a".repeat(4_000);
4802        let path_b = "b".repeat(4_000);
4803        // R-04: the chunked path resolves `revspec` once via `git rev-parse`
4804        // before any chunk/oracle call, then reuses the resolved token
4805        // (deliberately distinct from the literal `"HEAD"` text) everywhere
4806        // below — proving every one of those calls used the frozen snapshot,
4807        // not the original symbolic name.
4808        let common = [
4809            "git",
4810            "--literal-pathspecs",
4811            "log",
4812            "resolved-head-sha",
4813            "-n5",
4814            "-z",
4815            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4816            "--",
4817        ];
4818        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4819        chunk_a_args.push(path_a.clone());
4820        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4821        chunk_b_args.push(path_b.clone());
4822
4823        // Chunk A: "newer-a" + "shared", both dated 2026-01-02.
4824        let reply_a = Reply::ok(
4825            "aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0\
4826             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4827                .to_string(),
4828        );
4829        // Chunk B: "newest-b" (also dated 2026-01-02) + the SAME "shared"
4830        // commit again (touches a path in both chunks).
4831        let reply_b = Reply::ok(
4832            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-02T00:00:00Z\u{1f}newest-b\0\
4833             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4834                .to_string(),
4835        );
4836        // The oracle: git's real, unrestricted commit order for the same
4837        // revspec — puts "shared" between the other two, an order no sort of
4838        // the (identical) merged timestamps could ever reproduce.
4839        let order_reply = Reply::ok("bbb1\0shar\0aaa1\0".to_string());
4840
4841        let git = Git::with_runner(
4842            ScriptedRunner::new()
4843                .on(
4844                    ["git", "rev-parse", "HEAD"],
4845                    Reply::ok("resolved-head-sha\n".to_string()),
4846                )
4847                .on(chunk_a_args, reply_a)
4848                .on(chunk_b_args, reply_b)
4849                .on(
4850                    ["git", "log", "resolved-head-sha", "-z", "--format=%H"],
4851                    order_reply,
4852                ),
4853        );
4854
4855        let commits = git
4856            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4857            .await
4858            .expect("log_paths");
4859
4860        assert_eq!(
4861            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
4862            ["bbb1", "shar", "aaa1"],
4863            "expected the oracle's commit order across chunks, with the shared \
4864             commit deduplicated"
4865        );
4866    }
4867
4868    // The single-call path (small path sets) must return byte-identical order
4869    // to what a chunked call over the same commits produces via the oracle —
4870    // i.e. chunking never changes which order callers see for a path set
4871    // that happens to be small (T-052/R-03 regression guard).
4872    #[tokio::test]
4873    async fn log_paths_single_call_and_chunked_call_agree_on_order() {
4874        let single_reply = Reply::ok(
4875            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0\
4876             aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0",
4877        );
4878        let single_call_git = Git::with_runner(ScriptedRunner::new().on(
4879            [
4880                "git",
4881                "--literal-pathspecs",
4882                "log",
4883                "HEAD",
4884                "-n5",
4885                "-z",
4886                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4887                "--",
4888                "src/a.rs",
4889                "src/b.rs",
4890            ],
4891            single_reply,
4892        ));
4893        let single_commits = single_call_git
4894            .log_paths(
4895                Path::new("."),
4896                &rv("HEAD"),
4897                5,
4898                &["src/a.rs".to_string(), "src/b.rs".to_string()],
4899            )
4900            .await
4901            .expect("log_paths");
4902
4903        let path_a = "a".repeat(4_000);
4904        let path_b = "b".repeat(4_000);
4905        let common = [
4906            "git",
4907            "--literal-pathspecs",
4908            "log",
4909            "HEAD",
4910            "-n5",
4911            "-z",
4912            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4913            "--",
4914        ];
4915        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4916        chunk_a_args.push(path_a.clone());
4917        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4918        chunk_b_args.push(path_b.clone());
4919        let reply_a =
4920            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4921        let reply_b =
4922            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4923        // The oracle agrees with the single-call order: "newest-b" before
4924        // "newer-a".
4925        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4926        let chunked_git = Git::with_runner(
4927            ScriptedRunner::new()
4928                // R-04: the chunked path resolves `revspec` once via `git
4929                // rev-parse` before the chunk/oracle calls below.
4930                .on(
4931                    ["git", "rev-parse", "HEAD"],
4932                    Reply::ok("HEAD\n".to_string()),
4933                )
4934                .on(chunk_a_args, reply_a)
4935                .on(chunk_b_args, reply_b)
4936                .on(["git", "log", "HEAD", "-z", "--format=%H"], order_reply),
4937        );
4938        let chunked_commits = chunked_git
4939            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4940            .await
4941            .expect("log_paths");
4942
4943        assert_eq!(
4944            single_commits
4945                .iter()
4946                .map(|c| c.hash.as_str())
4947                .collect::<Vec<_>>(),
4948            chunked_commits
4949                .iter()
4950                .map(|c| c.hash.as_str())
4951                .collect::<Vec<_>>(),
4952            "single-call and chunked-call order must agree when the oracle \
4953             agrees with the single-call order"
4954        );
4955    }
4956
4957    // R-04: a range revspec (`A..B`) resolves via `git rev-parse` to *two*
4958    // tokens — the tip and a `^`-prefixed exclusion — and both chunk calls
4959    // plus the oracle call must forward both tokens verbatim, not just the
4960    // original `"main..feature"` text. This is exactly the expansion `git
4961    // log` would perform internally, so it's behavior-preserving while also
4962    // fixing the moving-ref race (a concurrent `main` or `feature` move
4963    // between chunk/oracle calls can no longer change what any of them see,
4964    // since all of them now share the one resolution taken up front).
4965    #[tokio::test]
4966    async fn log_paths_range_revspec_is_resolved_once_and_reused_across_chunks() {
4967        let path_a = "a".repeat(4_000);
4968        let path_b = "b".repeat(4_000);
4969        let common = [
4970            "git",
4971            "--literal-pathspecs",
4972            "log",
4973            "feature-sha",
4974            "^main-sha",
4975            "-n5",
4976            "-z",
4977            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4978            "--",
4979        ];
4980        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4981        chunk_a_args.push(path_a.clone());
4982        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4983        chunk_b_args.push(path_b.clone());
4984        let reply_a =
4985            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4986        let reply_b =
4987            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4988        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4989
4990        let git = Git::with_runner(
4991            ScriptedRunner::new()
4992                .on(
4993                    ["git", "rev-parse", "main..feature"],
4994                    Reply::ok("feature-sha\n^main-sha\n".to_string()),
4995                )
4996                .on(chunk_a_args, reply_a)
4997                .on(chunk_b_args, reply_b)
4998                .on(
4999                    [
5000                        "git",
5001                        "log",
5002                        "feature-sha",
5003                        "^main-sha",
5004                        "-z",
5005                        "--format=%H",
5006                    ],
5007                    order_reply,
5008                ),
5009        );
5010
5011        let commits = git
5012            .log_paths(Path::new("."), &rv("main..feature"), 5, &[path_a, path_b])
5013            .await
5014            .expect("log_paths");
5015        assert_eq!(
5016            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
5017            ["bbb1", "aaa1"]
5018        );
5019    }
5020
5021    // R-05: `git log` has no NUL-safe fallback transport the way
5022    // `add`/`commit_paths` do, so a single path that alone exceeds the argv
5023    // budget must be refused up front — never spawned as an over-budget
5024    // singleton chunk.
5025    #[tokio::test]
5026    async fn log_paths_rejects_individually_oversized_path_without_spawning() {
5027        let rec = RecordingRunner::replying(Reply::ok(""));
5028        let git = Git::with_runner(&rec);
5029        let huge = "z".repeat(ARGV_PATHSPEC_BUDGET + 1);
5030        let err = git
5031            .log_paths(Path::new("."), &rv("HEAD"), 5, &[huge])
5032            .await
5033            .expect_err("an individually oversized path must be refused");
5034        assert!(
5035            matches!(err.reason(), ErrorReason::Spawn { .. }),
5036            "got {err:?}"
5037        );
5038        assert!(rec.calls().is_empty(), "nothing may spawn");
5039    }
5040
5041    // --- T-052 pure-helper tests ----------------------------------------------
5042
5043    #[test]
5044    fn pathspec_argv_len_sums_bytes_plus_one_per_path() {
5045        use std::ffi::OsStr;
5046        let paths = [OsStr::new("a.rs"), OsStr::new("dir/b.rs")];
5047        // "a.rs" (4) + 1, "dir/b.rs" (8) + 1.
5048        assert_eq!(pathspec_argv_len(paths), 5 + 9);
5049        assert_eq!(pathspec_argv_len(std::iter::empty()), 0);
5050    }
5051
5052    // Each path lands verbatim between NUL separators, in order — including a
5053    // leading dash, embedded spaces, and a glob-magic character (the whole
5054    // point of the `--literal-pathspecs` + NUL transport: no argv/shell layer
5055    // to mis-parse them, and git is told not to treat them as pathspec magic
5056    // either).
5057    #[test]
5058    fn pathspec_nul_bytes_joins_paths_literally_in_order() {
5059        use std::ffi::OsStr;
5060        let paths = [
5061            OsStr::new("-weird.txt"),
5062            OsStr::new("has space.txt"),
5063            OsStr::new("glob[1].txt"),
5064        ];
5065        let bytes = pathspec_nul_bytes(paths).expect("no embedded NUL");
5066        assert_eq!(bytes, b"-weird.txt\0has space.txt\0glob[1].txt\0".to_vec());
5067    }
5068
5069    // An embedded NUL byte would silently truncate a pathspec-file-nul entry,
5070    // splitting one path into two on the very separator the transport relies
5071    // on — refused before anything is built, so `commit_paths`'s "no partial
5072    // result" contract holds even for this input-prep failure.
5073    #[test]
5074    fn pathspec_nul_bytes_rejects_embedded_nul() {
5075        use std::ffi::OsStr;
5076        // A real filesystem path can't contain a NUL byte, but the guard must
5077        // still catch a pathological caller-constructed one.
5078        let bad = unsafe { OsStr::from_encoded_bytes_unchecked(b"a\0b") };
5079        let err = pathspec_nul_bytes([bad]).expect_err("embedded NUL must be refused");
5080        assert!(
5081            matches!(err.reason(), ErrorReason::Spawn { .. }),
5082            "got {err:?}"
5083        );
5084    }
5085
5086    #[test]
5087    fn chunk_pathspecs_packs_under_budget_and_splits_over_it() {
5088        let short = vec!["a".to_string(), "b".to_string(), "c".to_string()];
5089        assert_eq!(chunk_pathspecs(&short), vec![vec!["a", "b", "c"]]);
5090
5091        // Two paths that individually fit but together exceed the budget split
5092        // into two singleton chunks, in order.
5093        let big_a = "a".repeat(ARGV_PATHSPEC_BUDGET - 100);
5094        let big_b = "b".repeat(ARGV_PATHSPEC_BUDGET - 100);
5095        let big = vec![big_a.clone(), big_b.clone()];
5096        assert_eq!(
5097            chunk_pathspecs(&big),
5098            vec![vec![big_a.as_str()], vec![big_b.as_str()]]
5099        );
5100
5101        // A single path already over budget on its own still gets a (singleton)
5102        // chunk — nothing shorter is possible.
5103        let huge = vec!["z".repeat(ARGV_PATHSPEC_BUDGET * 2)];
5104        assert_eq!(chunk_pathspecs(&huge), vec![vec![huge[0].as_str()]]);
5105
5106        assert!(chunk_pathspecs(&[]).is_empty());
5107    }
5108
5109    #[test]
5110    fn parse_commit_order_splits_on_nul_and_drops_trailing_empty() {
5111        assert_eq!(
5112            parse_commit_order("aaa1\0bbb2\0ccc3\0"),
5113            vec!["aaa1", "bbb2", "ccc3"]
5114        );
5115        assert!(parse_commit_order("").is_empty());
5116    }
5117
5118    #[tokio::test]
5119    async fn stash_push_adds_include_untracked() {
5120        let rec = RecordingRunner::replying(Reply::ok(""));
5121        let git = Git::with_runner(&rec);
5122        git.stash_push(Path::new("."), StashPush::new().include_untracked())
5123            .await
5124            .expect("stash");
5125        assert_eq!(
5126            rec.only_call().args_str(),
5127            ["stash", "push", "--include-untracked"]
5128        );
5129    }
5130
5131    // The default spec stashes tracked changes only — no `--include-untracked`.
5132    #[tokio::test]
5133    async fn stash_push_default_omits_include_untracked() {
5134        let rec = RecordingRunner::replying(Reply::ok(""));
5135        let git = Git::with_runner(&rec);
5136        git.stash_push(Path::new("."), StashPush::new())
5137            .await
5138            .expect("stash");
5139        assert_eq!(rec.only_call().args_str(), ["stash", "push"]);
5140    }
5141
5142    #[tokio::test]
5143    async fn stash_list_builds_argv_and_parses_entries() {
5144        let rec = RecordingRunner::replying(Reply::ok(
5145            "stash@{0}\u{1f}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u{1f}\
5146             On feature: my label\0",
5147        ));
5148        let git = Git::with_runner(&rec);
5149        let entries = git.stash_list(Path::new(".")).await.expect("stash_list");
5150        assert_eq!(
5151            rec.only_call().args_str(),
5152            ["stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs"]
5153        );
5154        assert_eq!(entries.len(), 1);
5155        assert_eq!(entries[0].index, 0);
5156        assert_eq!(entries[0].branch.as_deref(), Some("feature"));
5157        assert_eq!(entries[0].message, "my label");
5158    }
5159
5160    #[tokio::test]
5161    async fn stash_apply_builds_the_indexed_selector() {
5162        let rec = RecordingRunner::replying(Reply::ok(""));
5163        let git = Git::with_runner(&rec);
5164        git.stash_apply(Path::new("."), 2).await.expect("apply");
5165        assert_eq!(rec.only_call().args_str(), ["stash", "apply", "stash@{2}"]);
5166    }
5167
5168    #[tokio::test]
5169    async fn stash_drop_builds_the_indexed_selector() {
5170        let rec = RecordingRunner::replying(Reply::ok(""));
5171        let git = Git::with_runner(&rec);
5172        git.stash_drop(Path::new("."), 0).await.expect("drop");
5173        assert_eq!(rec.only_call().args_str(), ["stash", "drop", "stash@{0}"]);
5174    }
5175
5176    // `clean` must refuse to run at all — no spawn — when the spec has picked
5177    // neither `dry_run` nor `force`: the crate's own guard, independent of the
5178    // caller's `clean.requireForce` git config.
5179    #[tokio::test]
5180    async fn clean_refuses_with_neither_dry_run_nor_force_without_spawning() {
5181        let rec = RecordingRunner::replying(Reply::ok("unused"));
5182        let git = Git::with_runner(&rec);
5183        let err = git
5184            .clean(Path::new("."), Clean::new())
5185            .await
5186            .expect_err("neither dry_run nor force must be refused");
5187        assert!(
5188            matches!(err.reason(), ErrorReason::Spawn { .. }),
5189            "got {err:?}"
5190        );
5191        assert!(rec.calls().is_empty(), "nothing may spawn");
5192    }
5193
5194    #[tokio::test]
5195    async fn clean_dry_run_builds_n_flag_and_parses_would_remove() {
5196        let rec =
5197            RecordingRunner::replying(Reply::ok("Would remove junk.txt\nWould remove sub/\n"));
5198        let git = Git::with_runner(&rec);
5199        let entries = git
5200            .clean(Path::new("."), Clean::new().dry_run())
5201            .await
5202            .expect("dry run");
5203        assert_eq!(rec.only_call().args_str(), ["clean", "-n"]);
5204        assert_eq!(entries.len(), 2);
5205        assert_eq!(entries[0].path, PathBuf::from("junk.txt"));
5206        assert!(!entries[0].is_dir);
5207        assert_eq!(entries[1].path, PathBuf::from("sub"));
5208        assert!(entries[1].is_dir);
5209    }
5210
5211    #[tokio::test]
5212    async fn clean_force_builds_f_flag_and_parses_removing() {
5213        let rec = RecordingRunner::replying(Reply::ok("Removing junk.txt\n"));
5214        let git = Git::with_runner(&rec);
5215        let entries = git
5216            .clean(Path::new("."), Clean::new().force())
5217            .await
5218            .expect("force clean");
5219        assert_eq!(rec.only_call().args_str(), ["clean", "-f"]);
5220        assert_eq!(
5221            entries,
5222            vec![CleanEntry {
5223                path: PathBuf::from("junk.txt"),
5224                is_dir: false,
5225            }]
5226        );
5227    }
5228
5229    // `parse_clean_output` keys on the English "Would remove "/"Removing "
5230    // prefixes, which git gettext-translates under a non-English locale; `clean`
5231    // must force `LC_ALL=C` so those prefixes are always present, the same way
5232    // `diff_stat` forces it for `parse_shortstat`'s English keys.
5233    #[tokio::test]
5234    async fn clean_forces_c_locale() {
5235        let rec = RecordingRunner::replying(Reply::ok(""));
5236        let git = Git::with_runner(&rec);
5237        git.clean(Path::new("."), Clean::new().dry_run())
5238            .await
5239            .expect("dry run");
5240        assert!(
5241            rec.only_call().envs.iter().any(|(k, v)| {
5242                k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|o| o.to_str()) == Some("C")
5243            }),
5244            "clean should force LC_ALL=C"
5245        );
5246    }
5247
5248    // `dry_run` must win when a spec (oddly) sets both: `-f` is never sent, so
5249    // the call can never actually delete while also asking for a preview.
5250    #[tokio::test]
5251    async fn clean_dry_run_takes_priority_over_force() {
5252        let rec = RecordingRunner::replying(Reply::ok(""));
5253        let git = Git::with_runner(&rec);
5254        git.clean(Path::new("."), Clean::new().force().dry_run())
5255            .await
5256            .expect("dry run wins");
5257        assert_eq!(rec.only_call().args_str(), ["clean", "-n"]);
5258    }
5259
5260    #[tokio::test]
5261    async fn clean_directories_inserts_d_flag() {
5262        let rec = RecordingRunner::replying(Reply::ok(""));
5263        let git = Git::with_runner(&rec);
5264        git.clean(Path::new("."), Clean::new().dry_run().directories())
5265            .await
5266            .expect("dry run + directories");
5267        assert_eq!(rec.only_call().args_str(), ["clean", "-n", "-d"]);
5268    }
5269
5270    #[tokio::test]
5271    async fn clean_include_ignored_inserts_x_flag() {
5272        let rec = RecordingRunner::replying(Reply::ok(""));
5273        let git = Git::with_runner(&rec);
5274        git.clean(Path::new("."), Clean::new().dry_run().include_ignored())
5275            .await
5276            .expect("dry run + include ignored");
5277        assert_eq!(rec.only_call().args_str(), ["clean", "-n", "-x"]);
5278    }
5279
5280    #[tokio::test]
5281    async fn clean_only_ignored_inserts_capital_x_flag() {
5282        let rec = RecordingRunner::replying(Reply::ok(""));
5283        let git = Git::with_runner(&rec);
5284        git.clean(Path::new("."), Clean::new().dry_run().only_ignored())
5285            .await
5286            .expect("dry run + only ignored");
5287        assert_eq!(rec.only_call().args_str(), ["clean", "-n", "-X"]);
5288    }
5289
5290    // Every option combined: force + directories + include-ignored, in the
5291    // order the builder emits them.
5292    #[tokio::test]
5293    async fn clean_combines_force_directories_and_ignored_in_order() {
5294        let rec = RecordingRunner::replying(Reply::ok(""));
5295        let git = Git::with_runner(&rec);
5296        git.clean(
5297            Path::new("."),
5298            Clean::new().force().directories().include_ignored(),
5299        )
5300        .await
5301        .expect("combined flags");
5302        assert_eq!(rec.only_call().args_str(), ["clean", "-f", "-d", "-x"]);
5303    }
5304
5305    // `diff_text` for the working tree must build `diff HEAD` plus the stable
5306    // machine-output flags, in order.
5307    #[tokio::test]
5308    async fn diff_text_builds_working_tree_args() {
5309        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
5310        // targets HEAD. The probe is the first call; the diff is the last.
5311        let rec = RecordingRunner::replying(Reply::ok(""));
5312        let git = Git::with_runner(&rec);
5313        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
5314            .await
5315            .expect("diff_text");
5316        assert_eq!(
5317            rec.calls().last().unwrap().args_str(),
5318            [
5319                "diff",
5320                "HEAD",
5321                "--no-color",
5322                "--no-ext-diff",
5323                "-M",
5324                // Pin the parser's `a/`…`b/` headers against a user's
5325                // `diff.noprefix`/`diff.mnemonicPrefix` config.
5326                "--src-prefix=a/",
5327                "--dst-prefix=b/",
5328                // End-of-revisions: `HEAD` is a revision, never a pathspec.
5329                "--",
5330            ]
5331        );
5332    }
5333
5334    // Two endpoint selectors stay independent argv values, preserving both the
5335    // from -> to direction and compound-selector contents without constructing a
5336    // range string that Git could parse differently.
5337    #[tokio::test]
5338    async fn diff_text_between_builds_explicit_endpoint_args() {
5339        let from = rv("main | release");
5340        let to = rv("feature | hotfix");
5341        let diff = "diff --git a/m b/m\n@@ -1 +1 @@\n-a\n+b\n";
5342        let rec = RecordingRunner::replying(Reply::ok(diff));
5343        let git = Git::with_runner(&rec);
5344        git.diff_text_between(Path::new("."), &from, &to)
5345            .await
5346            .expect("diff_text_between");
5347
5348        assert_eq!(
5349            rec.only_call().args_str(),
5350            [
5351                "diff",
5352                "main | release",
5353                "feature | hotfix",
5354                "--no-color",
5355                "--no-ext-diff",
5356                "-M",
5357                "--src-prefix=a/",
5358                "--dst-prefix=b/",
5359                "--",
5360            ]
5361        );
5362    }
5363
5364    #[tokio::test]
5365    async fn diff_between_reuses_parser_and_bound_view() {
5366        let from = rv("base");
5367        let to = rv("tip");
5368        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
5369        let rec = RecordingRunner::replying(Reply::ok(out));
5370        let git = Git::with_runner(&rec);
5371        let files = git
5372            .at(Path::new("/repo"))
5373            .diff_between(&from, &to)
5374            .await
5375            .expect("diff_between");
5376        assert_eq!(files.len(), 1);
5377        assert_eq!(files[0].path, Path::new("m"));
5378        assert_eq!(rec.only_call().cwd.as_deref(), Some(Path::new("/repo")));
5379    }
5380
5381    // On an unborn repo the working-tree diff targets the empty tree instead of
5382    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
5383    // empty-tree id is resolved from git (`hash-object`, so it is object-format
5384    // correct — not the hard-coded SHA-1 id), and the diff rule only matches that
5385    // resolved argv, so a `HEAD` target would miss it.
5386    #[tokio::test]
5387    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
5388        // A stand-in id `empty_tree_oid` "computes"; the diff must target exactly it.
5389        let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
5390        let git = Git::with_runner(
5391            ScriptedRunner::new()
5392                .on(["git", "rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
5393                .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
5394                .on(["git", "diff", oid], Reply::ok("EMPTY")),
5395        );
5396        let out = git
5397            .diff_text(Path::new("."), DiffSpec::WorkingTree)
5398            .await
5399            .expect("diff_text");
5400        assert_eq!(out, "EMPTY");
5401    }
5402
5403    // `empty_tree_oid` asks git to hash an empty tree (`hash-object -t tree
5404    // --stdin`), so the id tracks the repo's object format instead of being a
5405    // hard-coded SHA-1 constant. The `--stdin` (not `-w`) form only computes it.
5406    #[tokio::test]
5407    async fn empty_tree_oid_hashes_an_empty_tree() {
5408        let rec = RecordingRunner::replying(Reply::ok(format!("{EMPTY_TREE_SHA1}\n")));
5409        let git = Git::with_runner(&rec);
5410        let oid = git
5411            .empty_tree_oid(Path::new("."))
5412            .await
5413            .expect("empty_tree_oid");
5414        assert_eq!(oid, EMPTY_TREE_SHA1);
5415        assert_eq!(
5416            rec.only_call().args_str(),
5417            ["hash-object", "-t", "tree", "--stdin"]
5418        );
5419    }
5420
5421    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
5422    // canned git-format output.
5423    #[tokio::test]
5424    async fn diff_parses_scripted_output() {
5425        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
5426        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
5427        let files = git
5428            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
5429            .await
5430            .expect("diff");
5431        assert_eq!(files.len(), 1);
5432        assert_eq!(files[0].path, Path::new("m"));
5433        assert_eq!(files[0].change, ChangeKind::Modified);
5434    }
5435
5436    #[tokio::test]
5437    async fn branch_exists_maps_exit_codes() {
5438        let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
5439        assert!(
5440            yes.branch_exists(Path::new("."), &rn("main"))
5441                .await
5442                .unwrap()
5443        );
5444        let no =
5445            Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
5446        assert!(!no.branch_exists(Path::new("."), &rn("nope")).await.unwrap());
5447    }
5448
5449    // The full ref prefix is stripped but a slashed default branch survives; an
5450    // unset origin/HEAD (non-zero exit) is `None`, not an error.
5451    #[tokio::test]
5452    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
5453        let simple = Git::with_runner(ScriptedRunner::new().on(
5454            ["git", "symbolic-ref"],
5455            Reply::ok("refs/remotes/origin/main\n"),
5456        ));
5457        assert_eq!(
5458            simple
5459                .remote_head_branch(Path::new("."))
5460                .await
5461                .unwrap()
5462                .as_deref(),
5463            Some("main")
5464        );
5465
5466        let slashed = Git::with_runner(ScriptedRunner::new().on(
5467            ["git", "symbolic-ref"],
5468            Reply::ok("refs/remotes/origin/release/v2\n"),
5469        ));
5470        assert_eq!(
5471            slashed
5472                .remote_head_branch(Path::new("."))
5473                .await
5474                .unwrap()
5475                .as_deref(),
5476            Some("release/v2")
5477        );
5478
5479        let unset =
5480            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
5481        assert!(
5482            unset
5483                .remote_head_branch(Path::new("."))
5484                .await
5485                .unwrap()
5486                .is_none()
5487        );
5488    }
5489
5490    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
5491    // stdout as "absent".
5492    #[tokio::test]
5493    async fn remote_branch_exists_sets_env_and_reads_stdout() {
5494        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
5495        let git = Git::with_runner(&rec);
5496        assert!(
5497            git.remote_branch_exists(Path::new("/repo"), &rn("main"))
5498                .await
5499                .unwrap()
5500        );
5501        let call = rec.only_call();
5502        assert!(call.envs.iter().any(|(k, v)| {
5503            k.to_str() == Some("GIT_TERMINAL_PROMPT")
5504                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
5505        }));
5506        // Exact-ref query — a bare `main` would tail-match `bar/main`.
5507        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
5508
5509        let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
5510        assert!(
5511            !empty
5512                .remote_branch_exists(Path::new("."), &rn("x"))
5513                .await
5514                .unwrap()
5515        );
5516    }
5517
5518    // The glob/control/`:`/space names `remote_branch_exists` must exclude are now
5519    // refused at `RefName` construction — an invalid value can't reach the method
5520    // at all, so the exclusion is enforced by the type rather than an ad-hoc guard.
5521    #[test]
5522    fn remote_branch_invalid_names_rejected_at_refname() {
5523        for name in [
5524            "",
5525            "feature/*",
5526            "feature/?",
5527            "feature/[a]",
5528            "a:b",
5529            "two words",
5530            "bad\nname",
5531        ] {
5532            let err = RefName::new(name).expect_err("invalid remote branch name must be rejected");
5533            assert!(vcs_cli_support::is_invalid_input(&err), "{name:?}");
5534        }
5535    }
5536
5537    #[tokio::test]
5538    async fn remote_branch_exists_accepts_valid_names() {
5539        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/feature/T-010_fix\n"));
5540        let git = Git::with_runner(&rec);
5541
5542        assert!(
5543            git.remote_branch_exists(Path::new("/repo"), &rn("feature/T-010_fix"))
5544                .await
5545                .expect("valid remote branch name")
5546        );
5547        assert_eq!(
5548            rec.only_call().args_str(),
5549            ["ls-remote", "origin", "refs/heads/feature/T-010_fix"]
5550        );
5551    }
5552
5553    // `remote_branch_exists` sets a per-command `Command::timeout` (10 s) so an
5554    // unreachable or hung remote can't wedge the call — the "bounded wait" its own
5555    // doc-comment promises. That bound is only hermetically testable because of the
5556    // processkit 2.1 guarantee that a `ScriptedRunner` **pending** reply on a bulk
5557    // verb (`output_string`) now honors `Command::timeout`; under the old 1.2.x
5558    // semantics a pending reply parked forever regardless of the deadline. On a
5559    // paused clock the command's 10 s deadline elapses in virtual time, so a hung
5560    // `ls-remote` resolves as "absent" (`false`, empty output) instead of hanging.
5561    // The outer 1 h guard turns a regression (pending parking forever) into a clear
5562    // failure rather than a wedged suite; the command's own 10 s bound fires first.
5563    #[tokio::test(start_paused = true)]
5564    async fn remote_branch_exists_bounded_wait_resolves_a_hung_remote() {
5565        let git =
5566            Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::pending()));
5567        let name = rn("main");
5568        let probe = git.remote_branch_exists(Path::new("/r"), &name);
5569        let exists = tokio::time::timeout(std::time::Duration::from_secs(3600), probe)
5570            .await
5571            .expect("the per-command 10 s timeout must resolve a hung ls-remote")
5572            .expect("a timed-out best-effort probe is `Ok(false)`, not an error");
5573        assert!(!exists, "an unreachable remote reads as absent");
5574    }
5575
5576    #[tokio::test]
5577    async fn diff_stat_parses_counts() {
5578        let git = Git::with_runner(ScriptedRunner::new().on(
5579            ["git", "diff", "--shortstat"],
5580            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
5581        ));
5582        let stat = git
5583            .diff_stat(Path::new("."), &rv("main..HEAD"))
5584            .await
5585            .unwrap();
5586        assert_eq!(
5587            (stat.files_changed, stat.insertions, stat.deletions),
5588            (2, 5, 1)
5589        );
5590    }
5591
5592    // The range-taking diff verbs terminate their argv with `--` so a `range`
5593    // that names a tracked path resolves as a revision (and errors) rather than
5594    // silently degrading into a pathspec-scoped working-tree diff (C2/M13).
5595    #[tokio::test]
5596    async fn diff_range_verbs_terminate_revisions_with_dashes() {
5597        let rec = RecordingRunner::replying(Reply::ok(""));
5598        let git = Git::with_runner(&rec);
5599        git.diff_range_is_empty(Path::new("/r"), &rv("main..HEAD"))
5600            .await
5601            .expect("diff_range_is_empty");
5602        assert_eq!(
5603            rec.only_call().args_str(),
5604            ["diff", "--quiet", "main..HEAD", "--"]
5605        );
5606
5607        let rec = RecordingRunner::replying(Reply::ok(" 0 files changed\n"));
5608        let git = Git::with_runner(&rec);
5609        git.diff_stat(Path::new("/r"), &rv("main..HEAD"))
5610            .await
5611            .expect("diff_stat");
5612        assert_eq!(
5613            rec.only_call().args_str(),
5614            ["diff", "--shortstat", "main..HEAD", "--"]
5615        );
5616    }
5617
5618    #[tokio::test]
5619    async fn status_text_returns_raw_porcelain() {
5620        let git = Git::with_runner(ScriptedRunner::new().on(
5621            ["git", "status", "--porcelain=v1"],
5622            Reply::ok(" M a.rs\n?? b.rs\n"),
5623        ));
5624        let text = git.status_text(Path::new(".")).await.expect("status_text");
5625        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
5626    }
5627
5628    #[tokio::test]
5629    async fn run_args_forwards_str_slices() {
5630        let git =
5631            Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
5632        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
5633    }
5634
5635    #[tokio::test]
5636    async fn merge_commit_builds_no_ff_and_message() {
5637        let rec = RecordingRunner::replying(Reply::ok(""));
5638        let git = Git::with_runner(&rec);
5639        git.merge_commit(
5640            Path::new("/r"),
5641            MergeCommit::branch(rv("feature"))
5642                .no_ff()
5643                .message("merge it"),
5644        )
5645        .await
5646        .unwrap();
5647        assert_eq!(
5648            rec.only_call().args_str(),
5649            ["merge", "--no-ff", "-m", "merge it", "feature"]
5650        );
5651    }
5652
5653    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
5654    #[tokio::test]
5655    async fn merge_commit_without_message_uses_no_edit() {
5656        let rec = RecordingRunner::replying(Reply::ok(""));
5657        let git = Git::with_runner(&rec);
5658        git.merge_commit(Path::new("/r"), MergeCommit::branch(rv("feature")))
5659            .await
5660            .unwrap();
5661        assert_eq!(
5662            rec.only_call().args_str(),
5663            ["merge", "--no-edit", "feature"]
5664        );
5665    }
5666
5667    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
5668    #[tokio::test]
5669    async fn rebase_suppresses_editor() {
5670        let rec = RecordingRunner::replying(Reply::ok(""));
5671        let git = Git::with_runner(&rec);
5672        git.rebase(Path::new("/r"), &rv("main")).await.unwrap();
5673        let call = rec.only_call();
5674        assert_eq!(call.args_str(), ["rebase", "main"]);
5675        assert!(call.envs.iter().any(|(k, v)| {
5676            k.to_str() == Some("GIT_EDITOR")
5677                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
5678        }));
5679    }
5680
5681    #[tokio::test]
5682    async fn push_builds_set_upstream_remote_refspec() {
5683        let rec = RecordingRunner::replying(Reply::ok(""));
5684        let git = Git::with_runner(&rec);
5685        git.push(
5686            Path::new("/r"),
5687            GitPush::refspec(&rn("feat"), &rn("feature")).set_upstream(),
5688        )
5689        .await
5690        .unwrap();
5691        assert_eq!(
5692            rec.only_call().args_str(),
5693            ["push", "-u", "origin", "feat:feature"]
5694        );
5695    }
5696
5697    // The common bare-branch push: `push origin <branch>` (no `-u`), with prompts
5698    // off so a credential-needing remote fails fast instead of hanging.
5699    #[tokio::test]
5700    async fn push_bare_branch_builds_origin_branch_prompt_off() {
5701        let rec = RecordingRunner::replying(Reply::ok(""));
5702        let git = Git::with_runner(&rec);
5703        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5704            .await
5705            .unwrap();
5706        let call = rec.only_call();
5707        assert_eq!(call.args_str(), ["push", "origin", "feature"]);
5708        assert!(call.envs.iter().any(|(k, v)| {
5709            k.to_str() == Some("GIT_TERMINAL_PROMPT")
5710                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
5711        }));
5712    }
5713
5714    // M16: a `+` (force-push) or an extra `:` (multi-ref) smuggled into a branch name
5715    // is refused before spawning — force-pushing must be explicit via `run`.
5716    #[tokio::test]
5717    async fn push_rejects_force_and_multiref_metacharacters() {
5718        let rec = RecordingRunner::replying(Reply::ok(""));
5719        let git = Git::with_runner(&rec);
5720        // A `:` (an extra ref) is not a legal `RefName` character, so a multi-ref
5721        // refspec is refused at construction — it can never reach `GitPush`.
5722        for bad in ["+main:main", "a:b:c", "main:prod"] {
5723            assert!(RefName::new(bad).is_err(), "{bad:?} carries a `:`");
5724        }
5725        // A leading `+` (force) IS a legal ref character, so it passes `RefName` —
5726        // but the push refspec guard still refuses it before spawning. A force-push
5727        // must be explicit via `run`.
5728        assert!(
5729            git.push(Path::new("/r"), GitPush::branch(rn("+main")))
5730                .await
5731                .is_err(),
5732            "a force refspec must be refused before spawning"
5733        );
5734        // A legitimate `local:remote` refspec still works (the single `:` is the
5735        // API-inserted separator between two validated `RefName`s).
5736        assert!(
5737            git.push(Path::new("/r"), GitPush::refspec(&rn("main"), &rn("prod")))
5738                .await
5739                .is_ok()
5740        );
5741        assert!(
5742            rec.calls()
5743                .iter()
5744                .all(|c| c.args_str().last().unwrap() != "+main")
5745        );
5746    }
5747
5748    // `.remote()` swaps the remote token in place.
5749    #[tokio::test]
5750    async fn push_remote_override_swaps_remote() {
5751        let rec = RecordingRunner::replying(Reply::ok(""));
5752        let git = Git::with_runner(&rec);
5753        git.push(
5754            Path::new("/r"),
5755            GitPush::branch(rn("feature")).remote("upstream"),
5756        )
5757        .await
5758        .unwrap();
5759        assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
5760    }
5761
5762    // With a credential provider, a remote op gets a leading `-c credential.helper`
5763    // pair (the secret referenced by env-var NAME) plus the secret in the env — and
5764    // the token value never appears in argv. Covers push (mutating) and fetch.
5765    #[tokio::test]
5766    async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
5767        let rec = RecordingRunner::replying(Reply::ok(""));
5768        let git = Git::with_runner(&rec)
5769            .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
5770        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5771            .await
5772            .unwrap();
5773        let call = rec.only_call();
5774        let args = call.args_str();
5775        // A leading helper-reset + inline helper precede the subcommand.
5776        assert_eq!(args[0], "-c", "config flag leads the argv");
5777        assert!(
5778            args.iter().any(|a| a == "credential.helper="),
5779            "inherited helpers are cleared first: {args:?}"
5780        );
5781        assert!(
5782            args.iter()
5783                .any(|a| a.contains("credential.helper=!f()")
5784                    && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
5785            "inline helper references the secret by env-var name: {args:?}"
5786        );
5787        assert!(
5788            args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
5789            "the real subcommand still runs: {args:?}"
5790        );
5791        // The secret value is NEVER in argv.
5792        assert!(
5793            !args.iter().any(|a| a.contains("ghp_secret123")),
5794            "secret leaked into argv: {args:?}"
5795        );
5796        // The secret lives in the env, under the helper's var name.
5797        let pw = call
5798            .envs
5799            .iter()
5800            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
5801            .and_then(|(_, v)| v.as_ref())
5802            .and_then(|v| v.to_str());
5803        assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
5804    }
5805
5806    // Without a provider, remote ops are byte-identical to before — no `-c`
5807    // credential helper, no secret env (ambient git auth, unchanged).
5808    #[tokio::test]
5809    async fn default_client_injects_no_credential_helper() {
5810        let rec = RecordingRunner::replying(Reply::ok(""));
5811        let git = Git::with_runner(&rec);
5812        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5813            .await
5814            .unwrap();
5815        let call = rec.only_call();
5816        assert_eq!(
5817            call.args_str(),
5818            ["push", "origin", "feature"],
5819            "no credential `-c` args without a provider"
5820        );
5821        assert!(
5822            !call
5823                .envs
5824                .iter()
5825                .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
5826            "no secret env without a provider"
5827        );
5828    }
5829
5830    // `clone_repo` builds its argv via a different path (`command()` + `.arg()`
5831    // chaining, not `command_in` + extend), so verify the `-c` credential args
5832    // still LEAD it and the real clone flags/url/dest follow the subcommand.
5833    #[tokio::test]
5834    async fn with_credentials_clone_puts_config_flags_before_subcommand() {
5835        let rec = RecordingRunner::replying(Reply::ok(""));
5836        let git =
5837            Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
5838        git.clone_repo(
5839            "https://example.com/r.git",
5840            Path::new("/dest"),
5841            CloneSpec::default().branch("main"),
5842        )
5843        .await
5844        .unwrap();
5845        let call = rec.only_call();
5846        let args = call.args_str();
5847        assert_eq!(args[0], "-c", "config flags lead the clone argv");
5848        let clone_at = args
5849            .iter()
5850            .position(|a| a == "clone")
5851            .expect("clone present");
5852        // Only credential `-c` flags precede the `clone` subcommand.
5853        assert!(
5854            args[..clone_at]
5855                .iter()
5856                .all(|a| a == "-c" || a.starts_with("credential.helper")),
5857            "only credential -c flags precede `clone`: {args:?}"
5858        );
5859        // The real clone flags/url/dest follow the subcommand.
5860        let tail = &args[clone_at..];
5861        assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
5862        assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
5863        assert!(
5864            !args.iter().any(|a| a.contains("s3cr3t")),
5865            "secret not in argv"
5866        );
5867        // H5: clone scopes the helper to the URL's host (in env, never argv), so a
5868        // cross-host redirect/submodule during the clone can't extract the token.
5869        let host = call
5870            .envs
5871            .iter()
5872            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
5873            .and_then(|(_, v)| v.as_ref())
5874            .and_then(|v| v.to_str());
5875        assert_eq!(
5876            host,
5877            Some("example.com"),
5878            "the clone URL's host scopes the credential helper"
5879        );
5880        // The host scoping travels in env; the credential `-c` flags that precede
5881        // `clone` must not bake the host into the helper config.
5882        assert!(
5883            args[..clone_at].iter().all(|a| !a.contains("example.com")),
5884            "host stays in env, not the credential config args: {:?}",
5885            &args[..clone_at]
5886        );
5887    }
5888
5889    // A `Credential::userpass` username threads through to the helper's env on a
5890    // remote op (here `fetch`) — the non-default-username path, end-to-end.
5891    #[tokio::test]
5892    async fn with_credentials_userpass_threads_username_through_env() {
5893        let rec = RecordingRunner::replying(Reply::ok(""));
5894        let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
5895            Credential::userpass("alice", "s3cr3t"),
5896        )));
5897        git.fetch(Path::new("/r")).await.unwrap();
5898        let call = rec.only_call();
5899        let user = call
5900            .envs
5901            .iter()
5902            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
5903            .and_then(|(_, v)| v.as_ref())
5904            .and_then(|v| v.to_str());
5905        assert_eq!(user, Some("alice"), "userpass username reaches the env");
5906        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
5907        assert!(call.args_str().contains(&"fetch".to_string()));
5908    }
5909
5910    // ONE client, several hosts: a host-keyed provider hands each clone only its OWN
5911    // host's secret — routed by the URL's host, which now reaches the
5912    // `CredentialRequest` — and the inline helper is gated to that host, so a
5913    // neighbouring instance's token can never leak into another host's clone. (T-045)
5914    #[tokio::test]
5915    async fn one_client_host_keyed_provider_isolates_tokens_across_hosts() {
5916        let provider = Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
5917            Ok(match r.host {
5918                Some("github.com") => Some(Credential::token("gh-secret")),
5919                Some("gitlab.example") => Some(Credential::token("gl-secret")),
5920                _ => None,
5921            })
5922        }));
5923        let rec = RecordingRunner::replying(Reply::ok(""));
5924        let git = Git::with_runner(&rec).with_credentials(provider);
5925
5926        // The same client clones two different hosts, back to back.
5927        git.clone_repo(
5928            "https://github.com/o/r.git",
5929            Path::new("/dest-gh"),
5930            CloneSpec::default(),
5931        )
5932        .await
5933        .unwrap();
5934        git.clone_repo(
5935            "https://gitlab.example/o/r.git",
5936            Path::new("/dest-gl"),
5937            CloneSpec::default(),
5938        )
5939        .await
5940        .unwrap();
5941
5942        let calls = rec.calls();
5943        assert_eq!(calls.len(), 2, "two clones recorded");
5944        // Clone #1 (github.com) → the github.com secret, gated to github.com.
5945        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
5946        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_HOST", "github.com"));
5947        // Clone #2 (gitlab.example) → the gitlab secret, gated to gitlab.example.
5948        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
5949        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_HOST", "gitlab.example"));
5950        // No cross-contamination: neither host's secret bleeds into the other's
5951        // clone, and no secret ever reaches argv.
5952        assert!(!calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
5953        assert!(!calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
5954        for c in calls.iter() {
5955            assert!(
5956                !c.args_str()
5957                    .iter()
5958                    .any(|a| a.contains("gh-secret") || a.contains("gl-secret")),
5959                "secrets stay out of argv"
5960            );
5961        }
5962    }
5963
5964    // Fallback policy on the git helper path, read (`fetch`) vs write (`push`):
5965    // `Ok(None)` → ambient (no inline credential helper, no secret env); `Err` →
5966    // fail-closed abort (git never spawns). (T-045)
5967    #[tokio::test]
5968    async fn git_credential_fallback_policy_for_read_and_write() {
5969        // Ok(None): ambient — no helper `-c` flags lead the argv, no secret env.
5970        let rec = RecordingRunner::replying(Reply::ok(""));
5971        let git = Git::with_runner(&rec)
5972            .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))));
5973        git.fetch(Path::new("/r")).await.unwrap();
5974        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5975            .await
5976            .unwrap();
5977        for c in rec.calls().iter() {
5978            assert!(
5979                !c.has_env("VCS_TOOLKIT_GIT_PASSWORD"),
5980                "ambient: no secret env on {:?}",
5981                c.args_str()
5982            );
5983            assert_ne!(
5984                c.args_str().first().map(String::as_str),
5985                Some("-c"),
5986                "ambient: no leading credential -c flags"
5987            );
5988        }
5989
5990        // Err: fail-closed — the op aborts and git is never spawned, for read & write.
5991        let rec = RecordingRunner::replying(Reply::ok(""));
5992        let git = Git::with_runner(&rec).with_credentials(Arc::new(provider_fn(
5993            |_r: &CredentialRequest<'_>| {
5994                Err(Error::spawn(
5995                    BINARY,
5996                    std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
5997                ))
5998            },
5999        )));
6000        assert!(
6001            git.fetch(Path::new("/r")).await.is_err(),
6002            "read aborts on provider error"
6003        );
6004        assert!(
6005            git.push(Path::new("/r"), GitPush::branch(rn("feature")))
6006                .await
6007                .is_err(),
6008            "write aborts on provider error"
6009        );
6010        assert!(
6011            rec.calls().is_empty(),
6012            "git never spawns when the provider errored"
6013        );
6014    }
6015
6016    // No-provider is byte-identical for the read/clone arg-construction paths too,
6017    // not only `push` (fetch uses `command_in`+extend; clone uses `command`+chain).
6018    #[tokio::test]
6019    async fn default_client_no_helper_on_fetch_and_clone() {
6020        let rec = RecordingRunner::replying(Reply::ok(""));
6021        Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
6022        assert_eq!(
6023            rec.only_call().args_str(),
6024            ["fetch", "--quiet"],
6025            "fetch unchanged without a provider"
6026        );
6027
6028        let rec = RecordingRunner::replying(Reply::ok(""));
6029        Git::with_runner(&rec)
6030            .clone_repo(
6031                "https://example.com/r.git",
6032                Path::new("/dest"),
6033                CloneSpec::default(),
6034            )
6035            .await
6036            .unwrap();
6037        assert_eq!(
6038            rec.only_call().args_str()[0],
6039            "clone",
6040            "clone leads with the subcommand (no `-c`) without a provider"
6041        );
6042    }
6043
6044    // The `with_token` convenience drives the same HTTPS credential.helper path as
6045    // `with_credentials` (secret in env, helper `-c` leads, not in argv).
6046    #[tokio::test]
6047    async fn with_token_convenience_authenticates_https_remote() {
6048        let rec = RecordingRunner::replying(Reply::ok(""));
6049        let git = Git::with_runner(&rec).with_token("ghp_conv");
6050        git.fetch(Path::new("/r")).await.unwrap();
6051        let call = rec.only_call();
6052        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
6053        let pw = call
6054            .envs
6055            .iter()
6056            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
6057            .and_then(|(_, v)| v.as_ref())
6058            .and_then(|v| v.to_str());
6059        assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
6060        assert!(
6061            !call.args_str().iter().any(|a| a.contains("ghp_conv")),
6062            "secret not in argv"
6063        );
6064    }
6065
6066    #[tokio::test]
6067    async fn upstream_distinguishes_no_upstream_from_errors() {
6068        let set = Git::with_runner(
6069            ScriptedRunner::new()
6070                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
6071                .on(["git", "rev-parse"], Reply::ok("origin/main\n")),
6072        );
6073        assert_eq!(
6074            set.upstream(Path::new(".")).await.unwrap().as_deref(),
6075            Some("origin/main")
6076        );
6077        // On a valid attached branch, exit 128 from `@{u}` means no upstream.
6078        let unset = Git::with_runner(
6079            ScriptedRunner::new()
6080                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
6081                .on(["git", "rev-parse"], Reply::fail(128, "")),
6082        );
6083        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
6084
6085        // Detached HEAD is rejected by the attached-branch probe.
6086        let detached =
6087            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
6088        assert!(detached.upstream(Path::new(".")).await.is_err());
6089
6090        // A directory outside a repository is a real error too.
6091        let not_repo = Git::with_runner(ScriptedRunner::new().on(
6092            ["git", "symbolic-ref"],
6093            Reply::fail(128, "fatal: not a git repository"),
6094        ));
6095        assert!(not_repo.upstream(Path::new(".")).await.is_err());
6096
6097        // Other numeric failures and no-code outcomes must not read as "unset".
6098        let broken = Git::with_runner(
6099            ScriptedRunner::new()
6100                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
6101                .on(["git", "rev-parse"], Reply::fail(1, "corrupt config")),
6102        );
6103        assert!(broken.upstream(Path::new(".")).await.is_err());
6104
6105        let timed_out = Git::with_runner(
6106            ScriptedRunner::new()
6107                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
6108                .on(["git", "rev-parse"], Reply::timeout()),
6109        );
6110        assert!(timed_out.upstream(Path::new(".")).await.is_err());
6111
6112        let signalled = Git::with_runner(
6113            ScriptedRunner::new()
6114                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
6115                .on(["git", "rev-parse"], Reply::signalled(Some(9))),
6116        );
6117        assert!(signalled.upstream(Path::new(".")).await.is_err());
6118    }
6119
6120    // remote_head_branch maps the `symbolic-ref --quiet` exit code: 0 → the branch
6121    // (ref prefix stripped), 1 → None (unset origin/HEAD), and anything else (a real
6122    // failure / timeout) surfaces rather than being swallowed as "no default branch".
6123    #[tokio::test]
6124    async fn remote_head_branch_maps_exit_codes() {
6125        let set = Git::with_runner(ScriptedRunner::new().on(
6126            ["git", "symbolic-ref"],
6127            Reply::ok("refs/remotes/origin/release/v2\n"),
6128        ));
6129        assert_eq!(
6130            set.remote_head_branch(Path::new("."))
6131                .await
6132                .unwrap()
6133                .as_deref(),
6134            Some("release/v2"),
6135            "the full ref prefix is stripped, slashes preserved"
6136        );
6137        let unset =
6138            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
6139        assert!(
6140            unset
6141                .remote_head_branch(Path::new("."))
6142                .await
6143                .unwrap()
6144                .is_none()
6145        );
6146        // A real failure (exit 128, not the silent --quiet exit 1) surfaces.
6147        let err = Git::with_runner(ScriptedRunner::new().on(
6148            ["git", "symbolic-ref"],
6149            Reply::fail(128, "fatal: not a git repository"),
6150        ));
6151        assert!(err.remote_head_branch(Path::new(".")).await.is_err());
6152        // A timeout surfaces too.
6153        let timed_out =
6154            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
6155        assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
6156    }
6157
6158    #[tokio::test]
6159    async fn set_upstream_builds_branch_flag() {
6160        let rec = RecordingRunner::replying(Reply::ok(""));
6161        let git = Git::with_runner(&rec);
6162        git.set_upstream(Path::new("/r"), &rn("feat"), &rn("origin/feature"))
6163            .await
6164            .unwrap();
6165        assert_eq!(
6166            rec.only_call().args_str(),
6167            ["branch", "--set-upstream-to=origin/feature", "feat"]
6168        );
6169    }
6170
6171    #[tokio::test]
6172    async fn remote_branches_parses_ls_remote() {
6173        let git = Git::with_runner(ScriptedRunner::new().on(
6174            ["git", "ls-remote"],
6175            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
6176        ));
6177        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
6178        assert_eq!(branches, ["main", "feat/x"]);
6179    }
6180
6181    #[tokio::test]
6182    async fn remote_list_runs_remote_v_and_prefers_fetch_url() {
6183        let git = Git::with_runner(ScriptedRunner::new().on(
6184            ["git", "remote", "-v"],
6185            Reply::ok(
6186                "origin https://example.test/fetch.git (fetch)\n\
6187                 origin https://example.test/push.git (push)\n",
6188            ),
6189        ));
6190        assert_eq!(
6191            git.remote_list(Path::new("/repo")).await.unwrap(),
6192            vec![Remote {
6193                name: "origin".into(),
6194                url: "https://example.test/fetch.git".into(),
6195            }]
6196        );
6197    }
6198
6199    #[tokio::test]
6200    async fn delete_branch_force_uses_capital_d() {
6201        let rec = RecordingRunner::replying(Reply::ok(""));
6202        let git = Git::with_runner(&rec);
6203        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")).force())
6204            .await
6205            .unwrap();
6206        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
6207    }
6208
6209    // The default (un-forced) spec uses lowercase `-d`.
6210    #[tokio::test]
6211    async fn delete_branch_default_uses_lowercase_d() {
6212        let rec = RecordingRunner::replying(Reply::ok(""));
6213        let git = Git::with_runner(&rec);
6214        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")))
6215            .await
6216            .unwrap();
6217        assert_eq!(rec.only_call().args_str(), ["branch", "-d", "old"]);
6218    }
6219
6220    // `branch --merged` marks the current branch with `*` and a branch checked out
6221    // in another worktree with `+`; both must still match after marker stripping.
6222    #[tokio::test]
6223    async fn is_merged_strips_branch_markers() {
6224        let git = Git::with_runner(ScriptedRunner::new().on(
6225            ["git", "branch", "--merged"],
6226            Reply::ok("  main\n* feature\n+ wt-branch\n"),
6227        ));
6228        for name in ["main", "feature", "wt-branch"] {
6229            assert!(
6230                git.is_merged(
6231                    Path::new("."),
6232                    MergeCheck::branch(rn(name)).into_base(rv("main"))
6233                )
6234                .await
6235                .unwrap(),
6236                "{name} should be reported merged"
6237            );
6238        }
6239        assert!(
6240            !git.is_merged(
6241                Path::new("."),
6242                MergeCheck::branch(rn("absent")).into_base(rv("main"))
6243            )
6244            .await
6245            .unwrap()
6246        );
6247    }
6248
6249    // A5: the `MergeCheck` builder lands branch/base in the right slots, and
6250    // `is_merged` queries `branch --merged <base>` — so a transposed pair would
6251    // change the emitted command, not silently invert a same-shaped call.
6252    #[tokio::test]
6253    async fn merge_check_names_branch_and_base_without_transposition() {
6254        use processkit::testing::RecordingRunner;
6255        let spec = MergeCheck::branch(rn("feature")).into_base(rv("main"));
6256        assert_eq!(spec.branch.as_str(), "feature");
6257        assert_eq!(spec.base.as_str(), "main");
6258
6259        let rec = RecordingRunner::replying(Reply::ok("  feature\n* main\n"));
6260        let merged = Git::with_runner(&rec)
6261            .is_merged(
6262                Path::new("/repo"),
6263                MergeCheck::branch(rn("feature")).into_base(rv("main")),
6264            )
6265            .await
6266            .unwrap();
6267        // `feature` appears under `branch --merged main`, so it reports merged — and
6268        // the emitted args put `base` (main) in the `--merged` slot, not `branch`.
6269        assert!(merged, "feature is listed as merged into main");
6270        assert_eq!(
6271            rec.only_call().args_str(),
6272            ["branch", "--merged", "main", "--no-column", "--no-color"]
6273        );
6274    }
6275
6276    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
6277    // a remote needing auth — matching the other remote ops.
6278    #[tokio::test]
6279    async fn fetch_disables_terminal_prompt() {
6280        let rec = RecordingRunner::replying(Reply::ok(""));
6281        let git = Git::with_runner(&rec);
6282        git.fetch(Path::new("/r")).await.unwrap();
6283        let call = rec.only_call();
6284        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
6285        assert!(call.envs.iter().any(|(k, v)| {
6286            k.to_str() == Some("GIT_TERMINAL_PROMPT")
6287                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
6288        }));
6289    }
6290
6291    #[tokio::test]
6292    async fn network_progress_variants_stream_scripted_lifecycles() {
6293        use std::sync::{Arc, Mutex};
6294
6295        let runner = ScriptedRunner::new()
6296            .on(
6297                ["git", "fetch", "--progress"],
6298                Reply::ok("fetch-out").with_stderr("fetch-progress"),
6299            )
6300            .on(
6301                ["git", "push", "--progress", "-u", "origin", "main"],
6302                Reply::ok("push-out").with_stderr("push-progress"),
6303            )
6304            .on(
6305                ["git", "clone", "--progress", "https://x/r", "/dest"],
6306                Reply::ok("clone-out").with_stderr("clone-progress"),
6307            );
6308        let git = Git::with_runner(runner);
6309
6310        for operation in 0..3 {
6311            let names = Arc::new(Mutex::new(Vec::new()));
6312            let sink = Arc::clone(&names);
6313            let mut progress = move |event: ProcessEvent| {
6314                sink.lock().unwrap().push(event.name());
6315            };
6316            match operation {
6317                0 => git
6318                    .fetch_with_progress(Path::new("/repo"), &mut progress)
6319                    .await
6320                    .unwrap(),
6321                1 => git
6322                    .push_with_progress(
6323                        Path::new("/repo"),
6324                        GitPush::branch(rn("main")).set_upstream(),
6325                        &mut progress,
6326                    )
6327                    .await
6328                    .unwrap(),
6329                2 => git
6330                    .clone_repo_with_progress(
6331                        "https://x/r",
6332                        Path::new("/dest"),
6333                        CloneSpec::new(),
6334                        &mut progress,
6335                    )
6336                    .await
6337                    .unwrap(),
6338                _ => unreachable!(),
6339            }
6340            drop(progress);
6341            let names = names.lock().unwrap();
6342            assert_eq!(names.first(), Some(&"started"));
6343            assert!(names.contains(&"stdout"));
6344            assert!(names.contains(&"stderr"));
6345            assert_eq!(names.last(), Some(&"exited"));
6346        }
6347    }
6348
6349    #[tokio::test(start_paused = true)]
6350    async fn fetch_progress_watchdog_is_opt_in_and_reports_inactivity() {
6351        let git = Git::with_runner(ScriptedRunner::new().on(
6352            ["git", "fetch", "--progress"],
6353            Reply::lines(["late"]).with_line_delay(std::time::Duration::from_secs(10)),
6354        ))
6355        .default_inactivity_timeout(std::time::Duration::from_secs(3));
6356        let mut progress = |_event: ProcessEvent| {};
6357
6358        let err = git
6359            .fetch_with_progress(Path::new("/repo"), &mut progress)
6360            .await
6361            .expect_err("a silent git progress stream should trip the opt-in watchdog");
6362        assert!(matches!(
6363            err.reason(),
6364            ErrorReason::Timeout {
6365                timeout,
6366                inactivity: true,
6367                ..
6368            } if *timeout == std::time::Duration::from_secs(3)
6369        ));
6370    }
6371
6372    #[tokio::test]
6373    async fn streamed_clone_failure_keeps_the_cleanup_contract() {
6374        use vcs_testkit::TempDir;
6375
6376        let root = TempDir::new("stream-clone-cleanup");
6377        let dest = root.path().join("dest");
6378        std::fs::create_dir(&dest).unwrap();
6379        let git = Git::with_runner(ScriptedRunner::new().on(
6380            ["git", "clone", "--progress"],
6381            Reply::fail(128, "network failed"),
6382        ));
6383        let mut progress = |_event: ProcessEvent| {};
6384        assert!(
6385            git.clone_repo_with_progress("https://x/r", &dest, CloneSpec::new(), &mut progress)
6386                .await
6387                .is_err()
6388        );
6389        assert!(!dest.exists(), "a cleanable partial destination is removed");
6390    }
6391
6392    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
6393    #[tokio::test]
6394    async fn fetch_retries_transient_failures() {
6395        let rec = RecordingRunner::replying(Reply::fail(
6396            128,
6397            "fatal: unable to access: Could not resolve host: example.com",
6398        ));
6399        let git = Git::with_runner(&rec);
6400        assert!(git.fetch(Path::new("/r")).await.is_err());
6401        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
6402    }
6403
6404    // R6 (a `fetch` timeout is NOT retried) is pinned at the unit level by
6405    // `vcs_cli_support`'s `classifies_nothing_to_commit_and_transient_fetch`
6406    // (`is_transient_fetch_error(&Timeout) == false`); together with
6407    // `fetch_retries_transient_failures` above (the loop retries exactly what the
6408    // predicate accepts) that proves the timeout is terminal for the fetch-retry. A
6409    // faithful end-to-end timeout is awkward to simulate hermetically (a paused-clock
6410    // `Reply::pending()` doesn't auto-fire the per-command deadline), so it isn't
6411    // duplicated here.
6412
6413    // Opt-in lock-contention retry: a mutation that fails because another process
6414    // holds `index.lock` is retried and succeeds — the command never ran, so the
6415    // retry is safe. `RetryPolicy::none().attempts(3)` keeps the backoff at zero so
6416    // the test never sleeps.
6417    #[tokio::test]
6418    async fn with_retry_retries_lock_contention_on_a_mutation() {
6419        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
6420            ["git", "commit"],
6421            [
6422                Reply::fail(
6423                    128,
6424                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
6425                ),
6426                Reply::ok(""),
6427            ],
6428        ));
6429        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
6430        git.commit(Path::new("/r"), "msg")
6431            .await
6432            .expect("retried past the lock");
6433        assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
6434    }
6435
6436    // Retry is off by default — the same lock failure propagates without `with_retry`.
6437    #[tokio::test]
6438    async fn default_client_does_not_retry_lock_contention() {
6439        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
6440            ["git", "commit"],
6441            [
6442                Reply::fail(
6443                    128,
6444                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
6445                ),
6446                Reply::ok(""),
6447            ],
6448        ));
6449        let git = Git::with_runner(&rec);
6450        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
6451        assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
6452    }
6453
6454    // Even with retry on, a real (non-lock) failure is returned immediately — only
6455    // lock contention is retried, so a genuine error is never silently repeated.
6456    #[tokio::test]
6457    async fn with_retry_does_not_retry_a_real_failure() {
6458        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
6459            ["git", "commit"],
6460            [
6461                Reply::fail(1, "error: pathspec 'x' did not match"),
6462                Reply::ok(""),
6463            ],
6464        ));
6465        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
6466        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
6467        assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
6468    }
6469
6470    // A non-transient failure fails fast — no retry.
6471    #[tokio::test]
6472    async fn fetch_does_not_retry_permanent_failures() {
6473        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
6474        let git = Git::with_runner(&rec);
6475        assert!(git.fetch(Path::new("/r")).await.is_err());
6476        assert_eq!(rec.calls().len(), 1);
6477    }
6478
6479    // Client-level cancellation (processkit 0.8 `cancellation` feature) on a
6480    // *retried* op: a `fetch` built on a client with `default_cancel_on(token)`
6481    // parks until the token fires, then surfaces `ErrorReason::Cancelled` — and because
6482    // cancellation is **terminal** (not transient), the fetch-retry does NOT
6483    // replay it (one spawn, not FETCH_ATTEMPTS). Hermetic via `Reply::pending()`
6484    // on a paused clock.
6485    #[tokio::test(start_paused = true)]
6486    async fn fetch_cancels_and_does_not_retry() {
6487        use processkit::CancellationToken;
6488        let token = CancellationToken::new();
6489        let rec =
6490            RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
6491        let git = Git::with_runner(&rec).default_cancel_on(token.clone());
6492        let call = git.fetch(Path::new("/r"));
6493        tokio::pin!(call);
6494        assert!(
6495            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
6496                .await
6497                .is_err(),
6498            "fetch must park until the token fires"
6499        );
6500        token.cancel();
6501        assert!(matches!(
6502            call.await.unwrap_err().reason(),
6503            ErrorReason::Cancelled { .. }
6504        ));
6505        assert_eq!(
6506            rec.calls().len(),
6507            1,
6508            "cancellation is terminal — the fetch-retry must not replay it"
6509        );
6510    }
6511
6512    // The injection barrier now has two tiers:
6513    //  1. ref-name / revision inputs are validated NEWTYPES, so a flag-like or
6514    //     malformed value is rejected at *construction* — it can never reach an
6515    //     argv slot (migration tests below); and
6516    //  2. the remaining bare-positional `&str` inputs that are not refs/revisions
6517    //     (remote names, URLs, config keys) keep the internal `reject_flag_like`
6518    //     guard, refused before anything spawns.
6519
6520    // Tier 1 — the newtypes reject the flag-like / malformed values the typed ops
6521    // would otherwise have received, as a classifiable invalid-input error.
6522    #[test]
6523    fn validated_ref_and_rev_newtypes_reject_bad_values() {
6524        // RefName: the load-bearing core of `check-ref-format`.
6525        for ok in ["main", "feature/x", "origin/main", "v1.2.3", "a-b_c"] {
6526            assert!(RefName::new(ok).is_ok(), "{ok}");
6527        }
6528        for bad in [
6529            "", "-evil", "--force", "-D", "-bad", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b",
6530            "a?b", "a*b", "a[b", "a\\b", "end/", "x.lock",
6531        ] {
6532            let err = RefName::new(bad).expect_err(&format!("{bad:?} must be rejected"));
6533            assert!(
6534                vcs_cli_support::is_invalid_input(&err),
6535                "{bad:?} must classify as invalid input"
6536            );
6537        }
6538        // RevSpec: non-empty and not flag-shaped (git's revision grammar is
6539        // otherwise too rich to validate here), so `-` special values are refused.
6540        for ok in ["HEAD", "HEAD~2", "main..feature", "@{-1}", "abc123"] {
6541            assert!(RevSpec::new(ok).is_ok(), "{ok}");
6542        }
6543        for bad in ["", "-evil", "-i", "-n", "-s"] {
6544            let err = RevSpec::new(bad).expect_err(&format!("{bad:?} must be rejected"));
6545            assert!(vcs_cli_support::is_invalid_input(&err), "{bad:?}");
6546        }
6547    }
6548
6549    // Tier 2 — the ops that still take a bare `&str` (remote names, URLs, config
6550    // keys) refuse a flag-like value BEFORE anything spawns. `DiffSpec::Rev` is a
6551    // shared cross-backend `String`, so it keeps the same internal guard.
6552    #[tokio::test]
6553    async fn str_positionals_are_rejected_before_spawning() {
6554        let rec = RecordingRunner::replying(Reply::ok(""));
6555        let git = Git::with_runner(&rec);
6556        let dir = Path::new("/r");
6557
6558        assert!(git.config_set(dir, "-evil", "v").await.is_err());
6559        assert!(git.config_get(dir, "-evil").await.is_err());
6560        assert!(git.remote_url(dir, "-evil").await.is_err());
6561        assert!(git.remote_branches(dir, "-evil").await.is_err());
6562        assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
6563        assert!(git.remote_add(dir, "-evil", "url").await.is_err());
6564        assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
6565        assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
6566        assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
6567        // A leading-`-` url is an RCE-class flag injection.
6568        assert!(
6569            git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
6570                .await
6571                .is_err()
6572        );
6573        // `DiffSpec::Rev` carries a raw (internally-guarded) revision string.
6574        assert!(
6575            git.diff_text(dir, DiffSpec::Rev("-evil".into()))
6576                .await
6577                .is_err()
6578        );
6579
6580        assert!(
6581            rec.calls().is_empty(),
6582            "nothing may spawn: {:?}",
6583            rec.calls()
6584        );
6585    }
6586
6587    // A legitimate ref/revision still flows through the typed path unchanged (with
6588    // the trailing `--` that keeps a path-like ref out of pathspec mode — C2), and
6589    // git's `-` "previous branch" is carried safely as `CheckoutTarget::Previous`
6590    // (a fixed literal, not caller-controlled argv).
6591    #[tokio::test]
6592    async fn typed_checkout_targets_pass_through() {
6593        let rec = RecordingRunner::replying(Reply::ok(""));
6594        let git = Git::with_runner(&rec);
6595        git.checkout(Path::new("/r"), &CheckoutTarget::Ref(rv("feature/x")))
6596            .await
6597            .expect("checkout");
6598        assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
6599
6600        let rec = RecordingRunner::replying(Reply::ok(""));
6601        let git = Git::with_runner(&rec);
6602        git.checkout(Path::new("/r"), &CheckoutTarget::Previous)
6603            .await
6604            .expect("checkout -");
6605        assert_eq!(rec.only_call().args_str(), ["checkout", "-", "--"]);
6606    }
6607
6608    // The hardened profile lands its env pairs/removals on EVERY command, and
6609    // composes with per-command env like GIT_TERMINAL_PROMPT.
6610    #[tokio::test]
6611    async fn harden_applies_env_profile_to_every_command() {
6612        let rec = RecordingRunner::replying(Reply::ok(""));
6613        let git = Git::with_runner(&rec).harden();
6614        git.status(Path::new("/r")).await.expect("status");
6615        git.fetch(Path::new("/r")).await.expect("fetch");
6616
6617        for call in rec.calls() {
6618            let has = |k: &str, v: &str| {
6619                call.envs.iter().any(|(key, val)| {
6620                    key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
6621                })
6622            };
6623            let removed = |k: &str| {
6624                call.envs
6625                    .iter()
6626                    .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
6627            };
6628            assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
6629            assert!(has("GIT_CONFIG_COUNT", "3"));
6630            assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
6631            assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
6632            assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
6633            // The repo-local core.sshCommand kill-switch (pinned empty).
6634            assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
6635            assert!(has("GIT_CONFIG_VALUE_2", ""));
6636            assert!(has("GIT_TERMINAL_PROMPT", "0"));
6637            assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
6638            assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
6639            // Command-hook env vectors are scrubbed too.
6640            assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
6641            assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
6642            assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
6643            assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
6644            // M14: the additional code-execution vectors + pathspec-mode vars.
6645            assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
6646            assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
6647            assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
6648            assert!(
6649                removed("GIT_ICASE_PATHSPECS"),
6650                "GIT_ICASE_PATHSPECS scrubbed"
6651            );
6652        }
6653    }
6654
6655    // H4: EVERY git client (not just `harden()`) scrubs the repo-**redirector** env
6656    // vars, so a `GIT_DIR`/`GIT_INDEX_FILE` leaking from the parent (e.g. running
6657    // inside a git hook, which exports them) can't silently retarget a command at a
6658    // different repository than the bound `dir`. The command-hook scrubs and config
6659    // pins stay `harden()`-only.
6660    #[tokio::test]
6661    async fn default_client_scrubs_repo_redirector_env() {
6662        let rec = RecordingRunner::replying(Reply::ok(""));
6663        let git = Git::with_runner(&rec); // NOT hardened
6664        git.status(Path::new("/r")).await.expect("status");
6665        let call = rec.only_call();
6666        let removed = |k: &str| {
6667            call.envs
6668                .iter()
6669                .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
6670        };
6671        let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
6672        for var in [
6673            "GIT_DIR",
6674            "GIT_WORK_TREE",
6675            "GIT_INDEX_FILE",
6676            "GIT_COMMON_DIR",
6677            "GIT_OBJECT_DIRECTORY",
6678            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
6679            "GIT_NAMESPACE",
6680        ] {
6681            assert!(removed(var), "{var} must be scrubbed on the default client");
6682        }
6683        // `harden()`-only surface is absent on a plain client.
6684        assert!(
6685            !has_key("GIT_SSH_COMMAND"),
6686            "command-hook scrub is harden()-only"
6687        );
6688        assert!(
6689            !has_key("GIT_CONFIG_NOSYSTEM"),
6690            "config pins are harden()-only"
6691        );
6692    }
6693
6694    // RefName/RevSpec accept/reject tables.
6695    #[test]
6696    fn ref_name_and_rev_spec_validate() {
6697        for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
6698            assert!(RefName::new(ok).is_ok(), "{ok}");
6699        }
6700        for bad in [
6701            "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
6702            "a\\b", "end/", "x.lock",
6703        ] {
6704            assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
6705        }
6706        assert!(RevSpec::new("HEAD~2").is_ok());
6707        assert!(RevSpec::new("main..feature").is_ok());
6708        assert!(RevSpec::new("-evil").is_err());
6709        assert!(RevSpec::new("").is_err());
6710    }
6711
6712    // capabilities parses real-world version shapes (incl. the Windows build
6713    // trailer) and gates on the real (2, 31) major.minor floor.
6714    #[tokio::test]
6715    async fn capabilities_parse_and_gate_versions() {
6716        let gh = Git::with_runner(ScriptedRunner::new().on(
6717            ["git", "--version"],
6718            Reply::ok("git version 2.54.0.windows.1\n"),
6719        ));
6720        let caps = gh.capabilities().await.expect("capabilities");
6721        assert_eq!(caps.version.to_string(), "2.54.0");
6722        assert!(caps.is_supported());
6723        caps.ensure_supported().expect("supported");
6724
6725        // Two-part versions parse (patch defaults to 0); an ancient major fails
6726        // the gate with a clear message.
6727        let old = Git::with_runner(
6728            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
6729        );
6730        let caps = old.capabilities().await.expect("capabilities");
6731        assert_eq!(
6732            caps.version,
6733            GitVersion {
6734                major: 1,
6735                minor: 9,
6736                patch: 0
6737            }
6738        );
6739        let err = caps.ensure_supported().expect_err("unsupported");
6740        // The message must name the floor and the found version.
6741        let ErrorReason::Spawn { source, .. } = err.reason() else {
6742            panic!("expected Spawn, got {err:?}");
6743        };
6744        let message = source.to_string();
6745        assert!(message.contains(">= 2"), "names the floor: {message}");
6746        assert!(
6747            message.contains("1.9.0"),
6748            "names the found version: {message}"
6749        );
6750
6751        // M29: a 2.x git BELOW the 2.31 minor floor is now rejected too — the crate's
6752        // argv (harden's GIT_CONFIG_COUNT, porcelain=v2, stash push) needs ≥ 2.31, so a
6753        // major-only gate that passed 2.7 then failed later with a cryptic argv error.
6754        let mid = Git::with_runner(
6755            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 2.7.4\n")),
6756        );
6757        let caps = mid.capabilities().await.expect("capabilities");
6758        assert!(!caps.is_supported(), "2.7.4 is below the 2.31 floor");
6759        let err = caps.ensure_supported().expect_err("2.7.4 unsupported");
6760        let ErrorReason::Spawn { source, .. } = err.reason() else {
6761            panic!("expected Spawn, got {err:?}");
6762        };
6763        assert!(
6764            source.to_string().contains(">= 2.31"),
6765            "names the 2.31 floor"
6766        );
6767
6768        // Garbage output is a parse error, not a silent zero version.
6769        let garbage = Git::with_runner(
6770            ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
6771        );
6772        assert!(matches!(
6773            garbage.capabilities().await.unwrap_err().reason(),
6774            ErrorReason::Parse { .. }
6775        ));
6776    }
6777
6778    // clone_repo is dir-less and appends only the requested flags.
6779    #[tokio::test]
6780    async fn clone_repo_builds_flags_and_runs_dirless() {
6781        let rec = RecordingRunner::replying(Reply::ok(""));
6782        let git = Git::with_runner(&rec);
6783        git.clone_repo(
6784            "https://example.com/r.git",
6785            Path::new("/dest"),
6786            CloneSpec::new().branch("main").depth(1).bare(),
6787        )
6788        .await
6789        .expect("clone");
6790        let call = rec.only_call();
6791        assert_eq!(
6792            call.args_str(),
6793            [
6794                "clone",
6795                "--branch",
6796                "main",
6797                "--depth",
6798                "1",
6799                "--bare",
6800                "https://example.com/r.git",
6801                "/dest"
6802            ]
6803        );
6804        assert_eq!(call.cwd, None, "clone runs without a working directory");
6805
6806        let bare = RecordingRunner::replying(Reply::ok(""));
6807        let git = Git::with_runner(&bare);
6808        git.clone_repo("u", Path::new("/d"), CloneSpec::new())
6809            .await
6810            .expect("clone");
6811        assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
6812    }
6813
6814    #[tokio::test]
6815    async fn clone_surfaces_share_typed_large_repo_flags_and_order() {
6816        let rec = RecordingRunner::replying(Reply::ok(""));
6817        let git = Git::with_runner(&rec);
6818        let spec = CloneSpec::new()
6819            .branch("main")
6820            .depth(2)
6821            .filter(CloneFilter::BlobNone)
6822            .single_branch()
6823            .origin("upstream")
6824            .bare();
6825        git.clone_repo("https://example.com/r.git", Path::new("/dest"), spec)
6826            .await
6827            .expect("clone");
6828
6829        let mut progress = |_event: ProcessEvent| {};
6830        git.clone_repo_with_progress(
6831            "https://example.com/r.git",
6832            Path::new("/dest-progress"),
6833            CloneSpec::new()
6834                .branch("main")
6835                .depth(2)
6836                .filter(CloneFilter::TreeZero)
6837                .single_branch()
6838                .origin("upstream")
6839                .bare(),
6840            &mut progress,
6841        )
6842        .await
6843        .expect("progress clone");
6844
6845        let calls = rec.calls();
6846        assert_eq!(
6847            calls[0].args_str(),
6848            [
6849                "clone",
6850                "--branch",
6851                "main",
6852                "--depth",
6853                "2",
6854                "--filter=blob:none",
6855                "--single-branch",
6856                "--origin",
6857                "upstream",
6858                "--bare",
6859                "https://example.com/r.git",
6860                "/dest"
6861            ]
6862        );
6863        assert_eq!(
6864            calls[1].args_str(),
6865            [
6866                "clone",
6867                "--progress",
6868                "--branch",
6869                "main",
6870                "--depth",
6871                "2",
6872                "--filter=tree:0",
6873                "--single-branch",
6874                "--origin",
6875                "upstream",
6876                "--bare",
6877                "https://example.com/r.git",
6878                "/dest-progress"
6879            ]
6880        );
6881    }
6882
6883    #[tokio::test]
6884    async fn clone_rejects_invalid_origin_before_runner_for_both_surfaces() {
6885        for origin in ["", " ", "--upload-pack=evil", "bad\0name"] {
6886            let rec = RecordingRunner::replying(Reply::ok(""));
6887            let git = Git::with_runner(&rec);
6888            let result = git
6889                .clone_repo(
6890                    "https://example.com/r.git",
6891                    Path::new("/dest"),
6892                    CloneSpec::new().origin(origin),
6893                )
6894                .await;
6895            assert!(matches!(
6896                err_reason(&result),
6897                Some(ErrorReason::Spawn { source, .. })
6898                    if source.kind() == std::io::ErrorKind::InvalidInput
6899            ));
6900            assert!(rec.calls().is_empty(), "invalid origin must not spawn");
6901
6902            let rec = RecordingRunner::replying(Reply::ok(""));
6903            let git = Git::with_runner(&rec);
6904            let mut progress = |_event: ProcessEvent| {};
6905            let result = git
6906                .clone_repo_with_progress(
6907                    "https://example.com/r.git",
6908                    Path::new("/dest"),
6909                    CloneSpec::new().origin(origin),
6910                    &mut progress,
6911                )
6912                .await;
6913            assert!(matches!(
6914                err_reason(&result),
6915                Some(ErrorReason::Spawn { source, .. })
6916                    if source.kind() == std::io::ErrorKind::InvalidInput
6917            ));
6918            assert!(
6919                rec.calls().is_empty(),
6920                "invalid origin must not spawn through progress"
6921            );
6922        }
6923    }
6924
6925    // R7: a failed clone cleans a `dest` it could have *created* (absent or empty) so
6926    // a retry isn't blocked by "destination already exists and is not empty" — but it
6927    // must NEVER delete a non-empty pre-existing dir (git would have refused, so the
6928    // caller's data is untouched). Scripted-fail clone + real temp dirs (only the fs
6929    // cleanup is real; nothing spawns).
6930    #[tokio::test]
6931    async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
6932        use vcs_testkit::TempDir;
6933        let tmp = TempDir::new("r7-clone");
6934        let git = Git::with_runner(ScriptedRunner::new().on(
6935            ["git", "clone"],
6936            Reply::fail(
6937                128,
6938                "fatal: could not read Username for 'https://x': prompts disabled",
6939            ),
6940        ));
6941
6942        // A non-empty caller dir must survive a failed clone.
6943        let occupied = tmp.path().join("occupied");
6944        std::fs::create_dir(&occupied).unwrap();
6945        std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
6946        assert!(
6947            git.clone_repo("https://x/r", &occupied, CloneSpec::new())
6948                .await
6949                .is_err()
6950        );
6951        assert!(
6952            occupied.join("keep.txt").exists(),
6953            "a non-empty caller dir must survive a failed clone"
6954        );
6955
6956        // An empty dest we could have populated is removed on failure.
6957        let empty = tmp.path().join("empty");
6958        std::fs::create_dir(&empty).unwrap();
6959        assert!(
6960            git.clone_repo("https://x/r", &empty, CloneSpec::new())
6961                .await
6962                .is_err()
6963        );
6964        assert!(
6965            !empty.exists(),
6966            "an empty dest is cleaned so a retry isn't blocked"
6967        );
6968
6969        // A pre-existing FILE at `dest` must survive (read_dir errs → cleanable, but
6970        // remove_dir_all refuses a non-dir). Pins that a future "also remove a file"
6971        // change can't slip in unnoticed.
6972        let file_dest = tmp.path().join("a-file");
6973        std::fs::write(&file_dest, b"caller file").unwrap();
6974        assert!(
6975            git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
6976                .await
6977                .is_err()
6978        );
6979        assert!(
6980            file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
6981            "a caller's file at dest must survive a failed clone"
6982        );
6983
6984        // A symlink `dest` → an EMPTY dir the caller owns: `read_dir` follows the
6985        // link and sees empty, so `cleanable` is true and `remove_dir_all` DOES run on
6986        // the link — it must unlink only the symlink, never delete THROUGH it. The
6987        // target dir (and a sibling sentinel) must survive.
6988        #[cfg(unix)]
6989        {
6990            let target = tmp.path().join("link-target"); // stays empty → cleanable path
6991            std::fs::create_dir(&target).unwrap();
6992            let sentinel = tmp.path().join("sibling.txt");
6993            std::fs::write(&sentinel, b"untouched").unwrap();
6994            let link = tmp.path().join("a-symlink");
6995            std::os::unix::fs::symlink(&target, &link).unwrap();
6996            assert!(
6997                git.clone_repo("https://x/r", &link, CloneSpec::new())
6998                    .await
6999                    .is_err()
7000            );
7001            assert!(
7002                target.exists() && sentinel.exists(),
7003                "a failed clone must unlink at most the symlink, never delete through it"
7004            );
7005        }
7006    }
7007
7008    #[tokio::test]
7009    async fn tag_methods_build_args() {
7010        let rec = RecordingRunner::replying(Reply::ok(""));
7011        let git = Git::with_runner(&rec);
7012        git.tag_create(Path::new("/r"), &rn("v1"), None)
7013            .await
7014            .unwrap();
7015        git.tag_create(Path::new("/r"), &rn("v1"), Some(rv("abc")))
7016            .await
7017            .unwrap();
7018        git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new(rn("v2"), "notes"))
7019            .await
7020            .unwrap();
7021        git.tag_delete(Path::new("/r"), &rn("v1")).await.unwrap();
7022        let calls = rec.calls();
7023        assert_eq!(calls[0].args_str(), ["tag", "v1"]);
7024        assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
7025        assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
7026        assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
7027    }
7028
7029    #[tokio::test]
7030    async fn tag_list_splits_lines() {
7031        let git = Git::with_runner(
7032            ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
7033        );
7034        assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
7035    }
7036
7037    // The line-parsed list commands must pass `--no-column`: a user's
7038    // `column.ui = always` would pack several names per line, and
7039    // `color.{ui,branch} = always` would inject ANSI escapes — both even when
7040    // piped. Branch listings disable both; `git tag` isn't colorized, so it only
7041    // needs `--no-column`.
7042    #[tokio::test]
7043    async fn list_commands_disable_column_and_color() {
7044        let rec = RecordingRunner::replying(Reply::ok(""));
7045        let git = Git::with_runner(&rec);
7046        git.branches(Path::new(".")).await.unwrap();
7047        git.is_merged(
7048            Path::new("."),
7049            MergeCheck::branch(rn("b")).into_base(rv("main")),
7050        )
7051        .await
7052        .unwrap();
7053        git.tag_list(Path::new(".")).await.unwrap();
7054        let calls = rec.calls();
7055        assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
7056        assert_eq!(
7057            calls[1].args_str(),
7058            ["branch", "--merged", "main", "--no-column", "--no-color"]
7059        );
7060        assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
7061    }
7062
7063    // Commands whose failure output feeds the error classifiers must force the
7064    // C locale — a translated message would defeat the substring matching.
7065    #[tokio::test]
7066    async fn classified_commands_force_c_locale() {
7067        let rec = RecordingRunner::replying(Reply::ok(""));
7068        let git = Git::with_runner(&rec);
7069        git.commit(Path::new("."), "msg").await.unwrap();
7070        git.merge_commit(Path::new("."), MergeCommit::branch(rv("b")))
7071            .await
7072            .unwrap();
7073        git.merge_squash(Path::new("."), &rv("b")).await.unwrap();
7074        git.merge_no_commit(Path::new("."), MergeNoCommit::branch(rv("b")))
7075            .await
7076            .unwrap();
7077        git.cherry_pick(Path::new("."), &rv("abc")).await.unwrap();
7078        git.stash_pop(Path::new(".")).await.unwrap();
7079        git.fetch(Path::new(".")).await.unwrap();
7080        for call in rec.calls() {
7081            assert!(
7082                call.envs.iter().any(|(k, v)| {
7083                    k.to_str() == Some("LC_ALL")
7084                        && v.as_deref().and_then(|o| o.to_str()) == Some("C")
7085                }),
7086                "{:?} should force LC_ALL=C",
7087                call.args_str()
7088            );
7089        }
7090    }
7091
7092    // `merge_abort_detached` is the rollback-cleanup abort the facade's `try_merge`
7093    // uses: it emits the same `merge --abort` as `merge_abort`, but on a FRESH
7094    // cancel token, so an already-fired client `default_cancel_on` (a cancelled or
7095    // timed-out probe merge) cannot also cancel the cleanup. The bare `merge_abort`
7096    // on the same client IS cancelled — proving the fired token is genuinely live
7097    // and the detached path deliberately steps around it, not that the token is
7098    // inert. The git mirror of jj's `rollback_to_survives_fired_cancellation`.
7099    #[tokio::test]
7100    async fn merge_abort_detached_survives_fired_cancellation() {
7101        use processkit::CancellationToken;
7102        let token = CancellationToken::new();
7103        token.cancel(); // as a cancelled/deadline-hit probe merge would leave it
7104        let rec = RecordingRunner::replying(Reply::ok(""));
7105        let git = Git::with_runner(&rec).default_cancel_on(token);
7106        let dir = Path::new("/r");
7107
7108        git.merge_abort_detached(dir)
7109            .await
7110            .expect("the detached cleanup must run despite the fired client token");
7111        assert_eq!(rec.only_call().args_str(), ["merge", "--abort"]);
7112
7113        // Sanity: the token-inheriting `merge_abort` on the same (fired) client IS
7114        // cancelled, so the detached path really did side-step a live token.
7115        let bare = git.merge_abort(dir).await;
7116        assert!(
7117            matches!(err_reason(&bare), Some(ErrorReason::Cancelled { .. })),
7118            "a bare merge_abort must inherit the fired token: {bare:?}"
7119        );
7120    }
7121
7122    // `is_merge_in_progress_detached` is the rollback-cleanup DECISION probe the
7123    // facade's `try_merge` gates its abort on: it answers "is a trial merge still
7124    // staged?" via `rev-parse --git-dir` on a FRESH cancel token, so an
7125    // already-fired client `default_cancel_on` (a cancelled/timed-out probe merge)
7126    // cannot short-circuit the decision and thereby skip the abort. The bare
7127    // token-inheriting `is_merge_in_progress` on the same client IS cancelled —
7128    // proving the fired token is live and the detached probe deliberately steps
7129    // around it. Pairs with `merge_abort_detached_survives_fired_cancellation`: both
7130    // halves of the git cleanup survive, matching jj's op-log-probe-plus-restore.
7131    #[tokio::test]
7132    async fn is_merge_in_progress_detached_survives_fired_cancellation() {
7133        use processkit::CancellationToken;
7134        use vcs_testkit::TempDir;
7135        let gd = TempDir::new("merge-in-progress-detached");
7136        std::fs::write(gd.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
7137        let token = CancellationToken::new();
7138        token.cancel(); // as a cancelled/deadline-hit probe merge would leave it
7139        let git = Git::with_runner(ScriptedRunner::new().on(
7140            ["git", "rev-parse", "--git-dir"],
7141            Reply::ok(gd.path().to_str().unwrap()),
7142        ))
7143        .default_cancel_on(token);
7144        let dir = Path::new("/r");
7145
7146        assert!(
7147            git.is_merge_in_progress_detached(dir)
7148                .await
7149                .expect("the detached probe must resolve the git dir despite the fired token"),
7150            "MERGE_HEAD exists, so the detached probe must report a merge in progress"
7151        );
7152
7153        // Sanity: the token-inheriting `is_merge_in_progress` on the same (fired)
7154        // client IS cancelled, so the detached probe really did side-step a live
7155        // token rather than the token being inert.
7156        let bare = git.is_merge_in_progress(dir).await;
7157        assert!(
7158            matches!(err_reason(&bare), Some(ErrorReason::Cancelled { .. })),
7159            "a bare is_merge_in_progress must inherit the fired token: {bare:?}"
7160        );
7161    }
7162
7163    // T-149: an empty / whitespace-only `path` is refused BEFORE `git show`
7164    // spawns. `git show <rev>:` is not an error — git prints the root TREE
7165    // LISTING and exits 0 (verified on git 2.55.0), so without this guard the
7166    // read hands back a directory index as if it were a file's content. The
7167    // runner is scripted to reply with exactly that listing, so a regression
7168    // fails on the returned value, not merely on the missing error.
7169    #[tokio::test]
7170    async fn show_file_rejects_empty_path_before_spawn() {
7171        for path in ["", " ", "   ", "\t", "\n", " \t \n "] {
7172            let rec = RecordingRunner::replying(Reply::ok("tree HEAD:\n\nf.txt\nsub/\n"));
7173            let git = Git::with_runner(&rec);
7174
7175            let err = git
7176                .show_file(Path::new("/r"), &rv("HEAD"), path)
7177                .await
7178                .expect_err(&format!("show_file must refuse {path:?}"));
7179            assert!(
7180                vcs_cli_support::is_invalid_input(&err),
7181                "{path:?} must classify as invalid input: {err:?}"
7182            );
7183            assert!(
7184                err.to_string().contains("empty or whitespace-only"),
7185                "the refusal names the cause: {err}"
7186            );
7187
7188            // The budgeted entry point is the one carrying the guard; the
7189            // per-call override must not be a way around it.
7190            let err = git
7191                .show_file_within(
7192                    Path::new("/r"),
7193                    &rv("HEAD"),
7194                    path,
7195                    OutputBudget::unlimited(),
7196                )
7197                .await
7198                .expect_err(&format!("show_file_within must refuse {path:?}"));
7199            assert!(
7200                vcs_cli_support::is_invalid_input(&err),
7201                "{path:?} must classify as invalid input: {err:?}"
7202            );
7203
7204            assert!(
7205                rec.calls().is_empty(),
7206                "nothing may spawn for {path:?}: {:?}",
7207                rec.calls()
7208            );
7209        }
7210    }
7211
7212    // The guard rejects *emptiness*, not a leading dash or interior whitespace:
7213    // a `-dash.txt` is inert inside the `<rev>:<path>` spec, and a name that
7214    // merely contains (or is padded by) spaces is a legitimate file.
7215    #[tokio::test]
7216    async fn show_file_accepts_dash_leading_and_space_bearing_paths() {
7217        for path in ["-dash.txt", "--force", "a b.txt", " padded.txt "] {
7218            let rec = RecordingRunner::replying(Reply::ok("content\n"));
7219            let git = Git::with_runner(&rec);
7220            git.show_file(Path::new("/r"), &rv("HEAD"), path)
7221                .await
7222                .unwrap_or_else(|e| panic!("{path:?} must be accepted: {e:?}"));
7223            let spec = format!("HEAD:{path}");
7224            assert_eq!(rec.only_call().args_str(), ["show", spec.as_str()]);
7225        }
7226    }
7227
7228    // The `<rev>:<path>` spec requires forward slashes — Windows callers may
7229    // hand in backslashes. The normalisation is Windows-only.
7230    #[cfg(windows)]
7231    #[tokio::test]
7232    async fn show_file_normalises_path_separators() {
7233        let rec = RecordingRunner::replying(Reply::ok("content\n"));
7234        let git = Git::with_runner(&rec);
7235        let out = git
7236            .show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
7237            .await
7238            .expect("show_file");
7239        // The blob's trailing newline is preserved verbatim (H7) — not trimmed.
7240        assert_eq!(out, "content\n");
7241        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
7242    }
7243
7244    // H7: content verbs return git's output byte-for-byte — the round-trip-corrupting
7245    // cases are multiple trailing newlines and a missing final newline.
7246    #[tokio::test]
7247    async fn content_verbs_preserve_exact_trailing_bytes() {
7248        for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces   \n"] {
7249            let rec = RecordingRunner::replying(Reply::ok(raw));
7250            let git = Git::with_runner(&rec);
7251            let out = git
7252                .show_file(Path::new("/r"), &rv("HEAD"), "f.txt")
7253                .await
7254                .expect("show_file");
7255            assert_eq!(out, raw, "show_file returns bytes verbatim");
7256        }
7257        // diff_text is verbatim too (its trailing blank context line must survive so
7258        // the last hunk stays in sync with its `@@` count).
7259        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
7260        let rec = RecordingRunner::replying(Reply::ok(diff));
7261        let git = Git::with_runner(&rec);
7262        assert_eq!(
7263            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
7264                .await
7265                .expect("diff_text"),
7266            diff
7267        );
7268    }
7269
7270    // On Unix a backslash is a legal filename byte — the spec must pass through
7271    // verbatim so a literal `a\b.txt` stays resolvable.
7272    #[cfg(not(windows))]
7273    #[tokio::test]
7274    async fn show_file_keeps_backslashes_on_unix() {
7275        let rec = RecordingRunner::replying(Reply::ok("content\n"));
7276        let git = Git::with_runner(&rec);
7277        git.show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
7278            .await
7279            .expect("show_file");
7280        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
7281    }
7282
7283    // T-049: a content read (`diff_text`) whose output exceeds the client's default
7284    // OutputBudget is refused with a structured `OutputTooLarge` carrying the actual
7285    // (`total_bytes`) and allowed (`max_bytes`) sizes — never a silently truncated
7286    // diff handed back as if complete. The huge output is drained but NOT retained
7287    // (the error carries only counts, not the multi-KiB blob): the bounded-memory
7288    // contract.
7289    // T-130: audited against processkit 3.0's raw-pipe-byte accounting and kept as
7290    // is. A content read captures RAW stdout, whose byte accounting 3.0 did not
7291    // change (only the line-pumped streams were re-based), and the fixture is ~2x
7292    // the ceiling under either unit — so this still asserts the contract it states
7293    // rather than passing on a coincidence. The exact boundary, and the stderr
7294    // stream that DID shift, are pinned in `vcs_cli_support`'s `content_budget_*`
7295    // tests; see `OutputBudget::bytes` for the per-stream unit.
7296    #[tokio::test]
7297    async fn diff_text_over_budget_errors_output_too_large() {
7298        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
7299        assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
7300        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
7301            .default_output_budget(OutputBudget::bytes(64 * 1024));
7302        match git
7303            .diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
7304            .await
7305            .map_err(Error::into_reason)
7306        {
7307            Err(ErrorReason::OutputTooLarge {
7308                program,
7309                max_bytes,
7310                total_bytes,
7311                ..
7312            }) => {
7313                assert_eq!(program, "git");
7314                assert_eq!(max_bytes, Some(64 * 1024), "the allowed ceiling");
7315                assert!(
7316                    total_bytes > 64 * 1024,
7317                    "the actual size ({total_bytes}) exceeds the allowed cap"
7318                );
7319            }
7320            other => panic!("expected OutputTooLarge, got {other:?}"),
7321        }
7322    }
7323
7324    // Below the budget the full diff comes back verbatim — the ceiling only fires on
7325    // an over-cap read, so ordinary diffs are unaffected.
7326    #[tokio::test]
7327    async fn diff_text_under_budget_returns_full_output() {
7328        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
7329        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(diff)))
7330            .default_output_budget(OutputBudget::bytes(64 * 1024));
7331        assert_eq!(
7332            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
7333                .await
7334                .expect("under-budget diff_text"),
7335            diff
7336        );
7337    }
7338
7339    // The per-call override reads a legitimately large diff past a tight client
7340    // default: `diff_text_within(..., unlimited())` returns the full output the
7341    // default budget would have refused.
7342    #[tokio::test]
7343    async fn diff_text_within_override_reads_past_the_default() {
7344        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
7345        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
7346            .default_output_budget(OutputBudget::bytes(64 * 1024));
7347        // The default budget would refuse it…
7348        assert!(matches!(
7349            err_reason(
7350                &git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
7351                    .await
7352            ),
7353            Some(ErrorReason::OutputTooLarge { .. })
7354        ));
7355        // …but an explicit unlimited override reads it in full.
7356        let got = git
7357            .diff_text_within(
7358                Path::new("/r"),
7359                DiffSpec::Rev("HEAD".into()),
7360                OutputBudget::unlimited(),
7361            )
7362            .await
7363            .expect("override reads the large diff");
7364        assert_eq!(got, big);
7365    }
7366
7367    #[tokio::test]
7368    async fn diff_text_between_within_honours_output_budget() {
7369        let from = rv("base");
7370        let to = rv("tip");
7371        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
7372        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
7373            .default_output_budget(OutputBudget::bytes(64 * 1024));
7374        assert!(matches!(
7375            err_reason(&git.diff_text_between(Path::new("/r"), &from, &to).await),
7376            Some(ErrorReason::OutputTooLarge { .. })
7377        ));
7378
7379        let got = git
7380            .diff_text_between_within(Path::new("/r"), &from, &to, OutputBudget::unlimited())
7381            .await
7382            .expect("unlimited override reads the large diff");
7383        assert_eq!(got, big);
7384    }
7385
7386    // A blob read (`show_file`) honours the same budget and its per-call override.
7387    #[tokio::test]
7388    async fn show_file_over_budget_errors_and_override_reads() {
7389        let big = "x".repeat(200_000);
7390        let git = Git::with_runner(ScriptedRunner::new().on(["git", "show"], Reply::ok(&big)))
7391            .default_output_budget(OutputBudget::bytes(64 * 1024));
7392        assert!(matches!(
7393            err_reason(&git.show_file(Path::new("/r"), &rv("HEAD"), "big.bin").await),
7394            Some(ErrorReason::OutputTooLarge { .. })
7395        ));
7396        let got = git
7397            .show_file_within(
7398                Path::new("/r"),
7399                &rv("HEAD"),
7400                "big.bin",
7401                OutputBudget::unlimited(),
7402            )
7403            .await
7404            .expect("override reads the large blob");
7405        assert_eq!(got, big);
7406    }
7407
7408    // A client with no budget set keeps the pre-budget behaviour: even a huge diff
7409    // is returned in full (the default is unlimited, never `OutputTooLarge`).
7410    #[tokio::test]
7411    async fn default_client_has_no_budget() {
7412        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
7413        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)));
7414        assert_eq!(
7415            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
7416                .await
7417                .expect("unbudgeted client returns the full diff"),
7418            big
7419        );
7420    }
7421
7422    // config --get: exit 0 → Some(value), exit 1 → None (unset), other → error.
7423    #[tokio::test]
7424    async fn config_get_maps_exit_codes() {
7425        let set = Git::with_runner(
7426            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
7427        );
7428        assert_eq!(
7429            set.config_get(Path::new("."), "user.name").await.unwrap(),
7430            Some("Alice".to_string())
7431        );
7432        // Only git's trailing newline (here `\r\n`) is stripped — a value's own
7433        // trailing spaces are preserved (they can be meaningful).
7434        let spaced = Git::with_runner(
7435            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix:  \r\n")),
7436        );
7437        assert_eq!(
7438            spaced.config_get(Path::new("."), "x.y").await.unwrap(),
7439            Some("prefix:  ".to_string())
7440        );
7441        let unset = Git::with_runner(
7442            ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
7443        );
7444        assert_eq!(
7445            unset.config_get(Path::new("."), "user.name").await.unwrap(),
7446            None
7447        );
7448        // A multi-valued key (exit 2) or worse is a real error.
7449        let multi = Git::with_runner(ScriptedRunner::new().on(
7450            ["git", "config", "--get"],
7451            Reply::fail(2, "multiple values"),
7452        ));
7453        assert!(
7454            multi
7455                .config_get(Path::new("."), "remote.all")
7456                .await
7457                .is_err()
7458        );
7459    }
7460
7461    // T-083: `config_set` pins `key` and `value` behind a `--` option terminator,
7462    // so a `value` shaped like a flag (`--global`, `--file=<path>`) sits in argv
7463    // where git can only read it as a positional value — never as an option
7464    // redirecting the write to another config file. The exact argv is pinned here;
7465    // `repo.rs`'s round-trip re-checks the effect on the live git binary.
7466    #[tokio::test]
7467    async fn config_set_pins_value_behind_option_terminator() {
7468        // A flag-shaped value must land *after* `--`, verbatim.
7469        let rec = RecordingRunner::replying(Reply::ok(""));
7470        let git = Git::with_runner(&rec);
7471        git.config_set(Path::new("/r"), "user.name", "--file=/etc/evil")
7472            .await
7473            .unwrap();
7474        assert_eq!(
7475            rec.only_call().args_str(),
7476            ["config", "--", "user.name", "--file=/etc/evil"],
7477            "value must sit after `--`, unreachable to git's option parser"
7478        );
7479
7480        // A legitimately `-`-leading value is passed through untouched — the guard
7481        // blocks the flag *parse*, not the leading dash, so `-1` is not rejected.
7482        let rec = RecordingRunner::replying(Reply::ok(""));
7483        let git = Git::with_runner(&rec);
7484        git.config_set(Path::new("/r"), "gc.auto", "-1")
7485            .await
7486            .unwrap();
7487        assert_eq!(
7488            rec.only_call().args_str(),
7489            ["config", "--", "gc.auto", "-1"]
7490        );
7491    }
7492
7493    #[tokio::test]
7494    async fn blame_builds_rev_before_pathspec_separator() {
7495        let rec = RecordingRunner::replying(Reply::ok(""));
7496        let git = Git::with_runner(&rec);
7497        git.blame(Path::new("/r"), "src/lib.rs", Some(rv("HEAD~1")))
7498            .await
7499            .unwrap();
7500        git.blame(Path::new("/r"), "src/lib.rs", None)
7501            .await
7502            .unwrap();
7503        let calls = rec.calls();
7504        assert_eq!(
7505            calls[0].args_str(),
7506            ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
7507        );
7508        assert_eq!(
7509            calls[1].args_str(),
7510            ["blame", "--line-porcelain", "--", "src/lib.rs"]
7511        );
7512    }
7513
7514    // revert must never open an editor: --no-edit plus the env backstop.
7515    #[tokio::test]
7516    async fn sequencer_methods_suppress_editors() {
7517        let rec = RecordingRunner::replying(Reply::ok(""));
7518        let git = Git::with_runner(&rec);
7519        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
7520        git.cherry_pick(Path::new("/r"), &rv("abc")).await.unwrap();
7521        git.rebase_skip(Path::new("/r")).await.unwrap();
7522        let calls = rec.calls();
7523        assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
7524        assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
7525        assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
7526        for call in &calls {
7527            assert!(
7528                call.envs
7529                    .iter()
7530                    .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
7531                "editor suppressed on {:?}",
7532                call.args_str()
7533            );
7534        }
7535    }
7536
7537    // T-044: each sequencer abort/continue/reset issues its OWN git subcommand, and
7538    // the two `--continue` variants (which can re-open the commit-message editor)
7539    // suppress it so a headless caller never hangs.
7540    #[tokio::test]
7541    async fn sequencer_abort_continue_reset_commands() {
7542        let rec = RecordingRunner::replying(Reply::ok(""));
7543        let git = Git::with_runner(&rec);
7544        let d = Path::new("/r");
7545        git.cherry_pick_abort(d).await.unwrap();
7546        git.cherry_pick_continue(d).await.unwrap();
7547        git.revert_abort(d).await.unwrap();
7548        git.revert_continue(d).await.unwrap();
7549        git.bisect_reset(d).await.unwrap();
7550        let calls = rec.calls();
7551        assert_eq!(calls[0].args_str(), ["cherry-pick", "--abort"]);
7552        assert_eq!(calls[1].args_str(), ["cherry-pick", "--continue"]);
7553        assert_eq!(calls[2].args_str(), ["revert", "--abort"]);
7554        assert_eq!(calls[3].args_str(), ["revert", "--continue"]);
7555        assert_eq!(calls[4].args_str(), ["bisect", "reset"]);
7556        // Only the `--continue` commits can prompt an editor; those must suppress it.
7557        let has_editor = |idx: usize| {
7558            calls[idx]
7559                .envs
7560                .iter()
7561                .any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
7562        };
7563        assert!(has_editor(1), "cherry-pick --continue suppresses editor");
7564        assert!(has_editor(3), "revert --continue suppresses editor");
7565    }
7566
7567    // T-065: `git am` gets both drivers — `am --abort` and `am --continue`. The
7568    // continue re-applies the resolved patch and may prompt to confirm the message,
7569    // so it suppresses the editor like the other sequencer `--continue`s; the abort
7570    // does not prompt, so it does not.
7571    #[tokio::test]
7572    async fn am_abort_and_continue_commands() {
7573        let rec = RecordingRunner::replying(Reply::ok(""));
7574        let git = Git::with_runner(&rec);
7575        let d = Path::new("/r");
7576        git.am_abort(d).await.unwrap();
7577        git.am_continue(d).await.unwrap();
7578        let calls = rec.calls();
7579        assert_eq!(calls[0].args_str(), ["am", "--abort"]);
7580        assert_eq!(calls[1].args_str(), ["am", "--continue"]);
7581        let has_editor = |idx: usize| {
7582            calls[idx]
7583                .envs
7584                .iter()
7585                .any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
7586        };
7587        assert!(
7588            !has_editor(0),
7589            "am --abort does not prompt, no editor override"
7590        );
7591        assert!(has_editor(1), "am --continue suppresses editor");
7592    }
7593
7594    // harden() scrubs GIT_EDITOR/GIT_SEQUENCE_EDITOR from the inherited
7595    // environment, but a sequencer command sets its own `GIT_EDITOR=true` per call
7596    // (no_editor). `command_in` applies the client-level removal eagerly, so the env
7597    // list ends up `[…, (GIT_EDITOR, None), …, (GIT_EDITOR, "true")]` — and at spawn
7598    // each op is applied in order (processkit's `Command` does `env_remove` then
7599    // `env`), so the LAST write wins. The effective value MUST be `true`, else a
7600    // hardened `revert`/`cherry-pick`/`rebase` would lose its no-op editor and hang
7601    // a headless caller. Pin that effective-precedence (fold in spawn order).
7602    #[tokio::test]
7603    async fn hardened_sequencer_keeps_its_no_op_editor() {
7604        let rec = RecordingRunner::replying(Reply::ok(""));
7605        let git = Git::with_runner(&rec).harden();
7606        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
7607        let call = rec.only_call();
7608        // Resolve each editor var the way the OS does: last op for the key wins.
7609        let effective = |var: &str| {
7610            call.envs
7611                .iter()
7612                .rfind(|(k, _)| k.to_str() == Some(var))
7613                .and_then(|(_, v)| v.as_deref())
7614                .and_then(|v| v.to_str())
7615        };
7616        // Both no-op editors must survive harden()'s scrub (symmetric precedence),
7617        // else a hardened sequencer command hangs a headless caller.
7618        assert_eq!(
7619            effective("GIT_EDITOR"),
7620            Some("true"),
7621            "the per-command no-op editor must survive harden()'s scrub"
7622        );
7623        assert_eq!(
7624            effective("GIT_SEQUENCE_EDITOR"),
7625            Some("true"),
7626            "the per-command no-op sequence editor must survive harden()'s scrub"
7627        );
7628    }
7629
7630    #[tokio::test]
7631    async fn remote_add_and_set_url_build_args() {
7632        let rec = RecordingRunner::replying(Reply::ok(""));
7633        let git = Git::with_runner(&rec);
7634        git.remote_add(Path::new("/r"), "up", "https://x/y.git")
7635            .await
7636            .unwrap();
7637        git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
7638            .await
7639            .unwrap();
7640        let calls = rec.calls();
7641        assert_eq!(
7642            calls[0].args_str(),
7643            ["remote", "add", "up", "https://x/y.git"]
7644        );
7645        assert_eq!(
7646            calls[1].args_str(),
7647            ["remote", "set-url", "up", "https://x/z.git"]
7648        );
7649    }
7650
7651    // Dirty tree that stashes: status → list(before) → push → list(after, deeper) →
7652    // checkout → pop --index, in that order.
7653    #[tokio::test]
7654    async fn switch_with_stash_round_trips_dirty_tree() {
7655        let rec = RecordingRunner::new(
7656            ScriptedRunner::new()
7657                .on(["git", "status"], Reply::ok(" M a.rs\0"))
7658                // Stash-list depth goes 0 → 1, so the push is known to have saved.
7659                .on_sequence(
7660                    ["git", "stash", "list"],
7661                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
7662                )
7663                .on(["git", "stash", "push"], Reply::ok(""))
7664                .on(["git", "checkout"], Reply::ok(""))
7665                .on(["git", "stash", "pop"], Reply::ok("")),
7666        );
7667        let git = Git::with_runner(&rec);
7668        git.switch_with_stash(Path::new("/r"), &ct("feature"))
7669            .await
7670            .expect("switch");
7671        let calls = rec.calls();
7672        assert_eq!(calls.len(), 6);
7673        assert_eq!(
7674            calls[2].args_str(),
7675            ["stash", "push", "--include-untracked"]
7676        );
7677        assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
7678        // `--index` restores the staged/unstaged split (M12).
7679        assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
7680    }
7681
7682    // M12: a dirty tree whose dirt `stash push` can't save (e.g. a submodule-only
7683    // change) — the stash-list depth is unchanged, so we must NOT pop an unrelated
7684    // pre-existing stash. Switch as-is.
7685    #[tokio::test]
7686    async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
7687        let rec = RecordingRunner::new(
7688            ScriptedRunner::new()
7689                .on(["git", "status"], Reply::ok(" M sub\0"))
7690                // Depth stays 1 across the push → nothing was actually stashed.
7691                .on(
7692                    ["git", "stash", "list"],
7693                    Reply::ok("stash@{0}: someone else's WIP\n"),
7694                )
7695                .on(
7696                    ["git", "stash", "push"],
7697                    Reply::ok("No local changes to save\n"),
7698                )
7699                .on(["git", "checkout"], Reply::ok("")),
7700        );
7701        let git = Git::with_runner(&rec);
7702        git.switch_with_stash(Path::new("/r"), &ct("feature"))
7703            .await
7704            .expect("switch");
7705        assert!(
7706            rec.calls()
7707                .iter()
7708                .all(|c| c.args_str() != ["stash", "pop", "--index"]
7709                    && c.args_str() != ["stash", "pop"]),
7710            "must not pop an unrelated stash when the push saved nothing"
7711        );
7712    }
7713
7714    // A clean tree skips the stash round-trip — a no-op `stash push` would make
7715    // the later pop grab an older, unrelated stash.
7716    #[tokio::test]
7717    async fn switch_with_stash_skips_stash_on_clean_tree() {
7718        let rec = RecordingRunner::new(
7719            ScriptedRunner::new()
7720                .on(["git", "status"], Reply::ok(""))
7721                .on(["git", "checkout"], Reply::ok("")),
7722        );
7723        let git = Git::with_runner(&rec);
7724        git.switch_with_stash(Path::new("/r"), &ct("feature"))
7725            .await
7726            .expect("switch");
7727        let calls = rec.calls();
7728        assert_eq!(calls.len(), 2);
7729        assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
7730    }
7731
7732    // A failed checkout pops the stash back (we are still on the original
7733    // branch) and surfaces the checkout error.
7734    #[tokio::test]
7735    async fn switch_with_stash_restores_on_checkout_failure() {
7736        let rec = RecordingRunner::new(
7737            ScriptedRunner::new()
7738                .on(["git", "status"], Reply::ok(" M a.rs\0"))
7739                .on_sequence(
7740                    ["git", "stash", "list"],
7741                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
7742                )
7743                .on(["git", "stash", "push"], Reply::ok(""))
7744                .on(
7745                    ["git", "checkout"],
7746                    Reply::fail(1, "error: pathspec 'nope'"),
7747                )
7748                .on(["git", "stash", "pop"], Reply::ok("")),
7749        );
7750        let git = Git::with_runner(&rec);
7751        let err = git
7752            .switch_with_stash(Path::new("/r"), &ct("nope"))
7753            .await
7754            .expect_err("checkout error must surface");
7755        assert!(matches!(err.reason(), ErrorReason::Exit { .. }));
7756        let calls = rec.calls();
7757        assert_eq!(
7758            calls.last().unwrap().args_str(),
7759            ["stash", "pop", "--index"],
7760            "restoring pop ran with --index"
7761        );
7762    }
7763
7764    // `fetch_from` names the remote, keeps the prompt off, and shares the
7765    // transient retry.
7766    #[tokio::test]
7767    async fn fetch_from_builds_args_and_retries() {
7768        let rec = RecordingRunner::replying(Reply::ok(""));
7769        let git = Git::with_runner(&rec);
7770        git.fetch_from(Path::new("/r"), "upstream")
7771            .await
7772            .expect("fetch_from");
7773        let call = rec.only_call();
7774        assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
7775        assert!(call.envs.iter().any(|(k, v)| {
7776            k.to_str() == Some("GIT_TERMINAL_PROMPT")
7777                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
7778        }));
7779
7780        let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
7781        let git = Git::with_runner(&failing);
7782        assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
7783        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
7784    }
7785
7786    // As with `remote_branch_exists`, the names `fetch_branch` must exclude are now
7787    // refused at `RefName` construction, before the refspec is ever built.
7788    #[test]
7789    fn fetch_branch_invalid_names_rejected_at_refname() {
7790        for branch in [
7791            "",
7792            "feature/*",
7793            "feature/?",
7794            "feature/[a]",
7795            "a:b",
7796            "two words",
7797            "bad\tname",
7798        ] {
7799            let err = RefName::new(branch).expect_err("invalid fetch branch name must be rejected");
7800            assert!(vcs_cli_support::is_invalid_input(&err), "{branch:?}");
7801        }
7802    }
7803
7804    #[tokio::test]
7805    async fn fetch_branch_accepts_valid_names() {
7806        let rec = RecordingRunner::replying(Reply::ok(""));
7807        let git = Git::with_runner(&rec);
7808
7809        git.fetch_branch(Path::new("/repo"), &rn("feature/T-010_fix"))
7810            .await
7811            .expect("valid fetch branch name");
7812        assert_eq!(
7813            rec.only_call().args_str(),
7814            [
7815                "fetch",
7816                "--quiet",
7817                "origin",
7818                "refs/heads/feature/T-010_fix:refs/remotes/origin/feature/T-010_fix"
7819            ]
7820        );
7821    }
7822
7823    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
7824    // tested with a generated mock.
7825    #[cfg(feature = "mock")]
7826    #[tokio::test]
7827    async fn consumer_mocks_the_interface() {
7828        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
7829            git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
7830        }
7831        let mut mock = MockGitApi::new();
7832        mock.expect_current_branch()
7833            .returning(|_| Ok(Some("main".to_string())));
7834        assert!(on_branch(&mock, "main").await);
7835
7836        let from = rv("base");
7837        let to = rv("tip");
7838        mock.expect_diff_text_between()
7839            .returning(|_, _, _| Ok(String::new()));
7840        assert!(
7841            mock.diff_text_between(Path::new("."), &from, &to)
7842                .await
7843                .is_ok()
7844        );
7845    }
7846}
7847
7848// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
7849#[doc = include_str!("../docs/git.md")]
7850#[allow(rustdoc::broken_intra_doc_links)]
7851pub mod guide {
7852    #[doc = include_str!("../docs/security.md")]
7853    #[allow(rustdoc::broken_intra_doc_links)]
7854    pub mod security {}
7855    #[doc = include_str!("../docs/conflicts.md")]
7856    #[allow(rustdoc::broken_intra_doc_links)]
7857    pub mod conflicts {}
7858}