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//! # Safety
95//!
96//! Every operation that takes a caller-supplied **reference name** or **revision
97//! expression** now does so through a validated newtype — [`RefName`] for
98//! branch/tag/ref names, [`RevSpec`] for revisions/ranges — so a flag-like or
99//! malformed value is rejected at construction, *before* it can reach an argv
100//! slot (a classifiable [`vcs_cli_support::is_invalid_input`] failure). The one
101//! context-dependent special value, git's `-` "previous branch", is modelled
102//! explicitly as [`CheckoutTarget::Previous`] rather than smuggled through a
103//! newtype. Remaining bare-positional inputs that are **not** refs/revisions
104//! (remote names, URLs, config keys) keep an internal
105//! [`reject_flag_like`](vcs_cli_support::reject_flag_like) guard — refused before
106//! spawning if empty or starting with `-`. Flag-value slots (`-b <name>`) are
107//! consumed verbatim; paths always go through `--` / pathspec.
108//!
109//! # In-depth guide
110//!
111//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
112//! from `docs/`. See the [`guide`] module (and its
113//! [`security`](crate::guide::security) / [`conflicts`](crate::guide::conflicts)
114//! sub-guides).
115
116use std::path::{Path, PathBuf};
117use std::sync::Arc;
118use std::time::Duration;
119
120use processkit::Command;
121// Re-export the processkit types that appear in this crate's public API, so
122// consumers needn't depend on processkit directly — incl. `ProcessRunner` (the
123// `with_runner`/`Git<R>` seam) and the `JobRunner` default. (`Error`/`Result`/
124// `ProcessResult`/`ProcessRunner` are in scope here too via this `pub use`.)
125pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
126// Re-exported so a consumer can name the token for `default_cancel_on` without
127// taking a direct `processkit` dependency.
128pub use processkit::CancellationToken;
129
130pub mod conflict;
131mod parse;
132pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
133// The git-format diff model + parser and the version type are shared with
134// `vcs-jj` (identical output) — re-exported so `vcs_git::FileDiff`,
135// `vcs_git::parse_diff`, `vcs_git::GitVersion`, … still resolve.
136pub use vcs_diff::{
137    ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
138};
139// The error classifiers live in the shared plumbing crate — re-exported so
140// `vcs_git::is_merge_conflict`, … still resolve.
141use vcs_cli_support::git_credential_helper;
142pub use vcs_cli_support::{
143    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, OutputBudget,
144    RetryPolicy, Secret, StaticCredential, is_lock_contention, is_merge_conflict,
145    is_nothing_to_commit, is_transient_fetch_error, provider_fn,
146};
147
148/// Name of the underlying CLI binary this crate drives.
149pub const BINARY: &str = "git";
150
151/// Options for [`GitApi::worktree_add`] (`git worktree add`).
152///
153/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
154/// [`WorktreeAdd::create_branch`] rather than a struct literal.
155#[derive(Debug, Clone)]
156#[non_exhaustive]
157pub struct WorktreeAdd {
158    /// Filesystem path for the new worktree.
159    pub path: PathBuf,
160    /// Create and check out this new branch (`-b <name>`); `None` checks out an
161    /// existing ref.
162    pub new_branch: Option<RefName>,
163    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
164    pub commitish: Option<RevSpec>,
165    /// Register the worktree without populating its files (`--no-checkout`) — the
166    /// caller fills the working tree itself (e.g. a copy-on-write clone).
167    pub no_checkout: bool,
168}
169
170impl WorktreeAdd {
171    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
172    /// `git worktree add <path> <commitish>`.
173    pub fn checkout(path: impl Into<PathBuf>, commitish: RevSpec) -> Self {
174        Self {
175            path: path.into(),
176            new_branch: None,
177            commitish: Some(commitish),
178            no_checkout: false,
179        }
180    }
181
182    /// A worktree at `path` creating a new branch `name` based on `commitish`:
183    /// `git worktree add -b <name> <path> <commitish>`.
184    pub fn create_branch(path: impl Into<PathBuf>, name: RefName, commitish: RevSpec) -> Self {
185        Self {
186            path: path.into(),
187            new_branch: Some(name),
188            commitish: Some(commitish),
189            no_checkout: false,
190        }
191    }
192
193    /// Register the worktree without checking out its files (`--no-checkout`),
194    /// for a caller that populates the working tree itself.
195    pub fn no_checkout(mut self) -> Self {
196        self.no_checkout = true;
197        self
198    }
199}
200
201/// Options for [`GitApi::push`] (`git push`).
202///
203/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
204/// [`GitPush::refspec`] rather than a struct literal.
205#[derive(Debug, Clone)]
206#[non_exhaustive]
207pub struct GitPush {
208    /// Remote to push to (defaults to `origin`).
209    pub remote: String,
210    /// The refspec — a bare branch name, or `local:remote_branch`.
211    pub refspec: String,
212    /// Set the pushed branch as the upstream (`-u`).
213    pub set_upstream: bool,
214}
215
216impl GitPush {
217    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
218    pub fn branch(name: RefName) -> Self {
219        Self {
220            remote: "origin".to_string(),
221            refspec: name.as_str().to_string(),
222            set_upstream: false,
223        }
224    }
225
226    /// Push `local` to a differently-named `remote_branch`
227    /// (`git push origin <local>:<remote_branch>`). Both sides are validated
228    /// [`RefName`]s, so the single `:` is always the API-inserted separator — a
229    /// caller cannot smuggle an extra ref or a force (`+`) through them.
230    pub fn refspec(local: &RefName, remote_branch: &RefName) -> Self {
231        Self {
232            remote: "origin".to_string(),
233            refspec: format!("{}:{}", local.as_str(), remote_branch.as_str()),
234            set_upstream: false,
235        }
236    }
237
238    /// Push to a non-default remote.
239    pub fn remote(mut self, remote: impl Into<String>) -> Self {
240        self.remote = remote.into();
241        self
242    }
243
244    /// Record the pushed branch as the local branch's upstream (`-u`).
245    pub fn set_upstream(mut self) -> Self {
246        self.set_upstream = true;
247        self
248    }
249}
250
251/// Options for [`GitApi::clone_repo`] (`git clone`).
252///
253/// `#[non_exhaustive]`, so build it through [`CloneSpec::new`] and the chained
254/// setters rather than a struct literal.
255#[derive(Debug, Clone, Default)]
256#[non_exhaustive]
257pub struct CloneSpec {
258    /// Check out this branch instead of the remote's default (`--branch`).
259    pub branch: Option<String>,
260    /// Shallow-clone to this many commits (`--depth`). git silently ignores
261    /// the flag for a plain local-path source (warns, still clones fully);
262    /// use a `file://` URL to shallow-clone locally.
263    pub depth: Option<u32>,
264    /// Create a bare repository (`--bare`).
265    pub bare: bool,
266}
267
268impl CloneSpec {
269    /// A plain full clone of the remote's default branch.
270    pub fn new() -> Self {
271        Self::default()
272    }
273
274    /// Check out `branch` instead of the remote's default (`--branch`).
275    pub fn branch(mut self, branch: impl Into<String>) -> Self {
276        self.branch = Some(branch.into());
277        self
278    }
279
280    /// Shallow-clone to `depth` commits (`--depth`); see the field doc for the
281    /// local-path caveat.
282    pub fn depth(mut self, depth: u32) -> Self {
283        self.depth = Some(depth);
284        self
285    }
286
287    /// Clone as a bare repository (`--bare`).
288    pub fn bare(mut self) -> Self {
289        self.bare = true;
290        self
291    }
292}
293
294/// Options for [`GitApi::commit_paths`] (`git commit --only`).
295///
296/// `#[non_exhaustive]`, so build it through [`CommitPaths::new`] and the chained
297/// setters rather than a struct literal.
298#[derive(Debug, Clone)]
299#[non_exhaustive]
300pub struct CommitPaths {
301    /// The exact paths whose working-tree content to commit (`--only -- <paths>`).
302    pub paths: Vec<PathBuf>,
303    /// The commit message (`-m`).
304    pub message: String,
305    /// Amend the previous commit instead of creating a new one (`--amend`).
306    pub amend: bool,
307}
308
309impl CommitPaths {
310    /// Commit exactly `paths`' working-tree content with `message`
311    /// (`git commit -m <message> --only -- <paths>`).
312    pub fn new(
313        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
314        message: impl Into<String>,
315    ) -> Self {
316        Self {
317            paths: paths.into_iter().map(Into::into).collect(),
318            message: message.into(),
319            amend: false,
320        }
321    }
322
323    /// Amend the previous commit instead of creating a new one (`--amend`).
324    pub fn amend(mut self) -> Self {
325        self.amend = true;
326        self
327    }
328}
329
330/// Partial [`MergeCheck`] — names the branch being tested; chain
331/// [`into_base`](MergeCheckPartial::into_base) to name the base it must be merged into.
332#[derive(Debug, Clone)]
333pub struct MergeCheckPartial {
334    branch: RefName,
335}
336
337impl MergeCheckPartial {
338    /// The base commit-ish `branch` should be fully merged **into**.
339    pub fn into_base(self, base: RevSpec) -> MergeCheck {
340        MergeCheck {
341            branch: self.branch,
342            base,
343        }
344    }
345}
346
347/// A "is `branch` fully merged into `base`?" check for [`GitApi::is_merged`].
348///
349/// Built as `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)` — the two same-typed
350/// refs are named across **two** builder steps, so they can't be silently transposed
351/// (a swap would *invert* the answer). `#[non_exhaustive]`.
352#[derive(Debug, Clone, PartialEq, Eq)]
353#[non_exhaustive]
354pub struct MergeCheck {
355    /// The branch/ref being tested for having been merged.
356    pub branch: RefName,
357    /// The base commit-ish it should be fully merged into.
358    pub base: RevSpec,
359}
360
361impl MergeCheck {
362    /// Name the `branch` to test; chain [`into_base`](MergeCheckPartial::into_base).
363    pub fn branch(name: RefName) -> MergeCheckPartial {
364        MergeCheckPartial { branch: name }
365    }
366}
367
368/// Options for [`GitApi::merge_commit`] (`git merge` that commits the result).
369///
370/// `#[non_exhaustive]`, so build it through [`MergeCommit::branch`] and the
371/// chained setters rather than a struct literal.
372#[derive(Debug, Clone)]
373#[non_exhaustive]
374pub struct MergeCommit {
375    /// The commit-ish to merge in.
376    pub branch: RevSpec,
377    /// Always create a merge commit, even when a fast-forward was possible
378    /// (`--no-ff`).
379    pub no_ff: bool,
380    /// The merge commit message (`-m`); `None` takes the default message
381    /// non-interactively (`--no-edit`).
382    pub message: Option<String>,
383}
384
385impl MergeCommit {
386    /// Merge `target` taking the default merge message non-interactively
387    /// (`git merge --no-edit <target>`).
388    pub fn branch(target: RevSpec) -> Self {
389        Self {
390            branch: target,
391            no_ff: false,
392            message: None,
393        }
394    }
395
396    /// Always create a merge commit, even when a fast-forward was possible
397    /// (`--no-ff`).
398    pub fn no_ff(mut self) -> Self {
399        self.no_ff = true;
400        self
401    }
402
403    /// Use `m` as the merge commit message (`-m`).
404    pub fn message(mut self, m: impl Into<String>) -> Self {
405        self.message = Some(m.into());
406        self
407    }
408}
409
410/// Options for [`GitApi::merge_no_commit`] (`git merge --no-commit`).
411///
412/// `#[non_exhaustive]`, so build it through [`MergeNoCommit::branch`] and the
413/// chained setters rather than a struct literal.
414#[derive(Debug, Clone)]
415#[non_exhaustive]
416pub struct MergeNoCommit {
417    /// The commit-ish to merge in.
418    pub branch: RevSpec,
419    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`);
420    /// takes precedence over `no_ff` (git rejects the pair).
421    pub squash: bool,
422    /// Always record a real (abortable) merge, even when a fast-forward was
423    /// possible (`--no-ff`).
424    pub no_ff: bool,
425}
426
427impl MergeNoCommit {
428    /// Merge `target` but stop before committing (`git merge --no-commit <target>`).
429    pub fn branch(target: RevSpec) -> Self {
430        Self {
431            branch: target,
432            squash: false,
433            no_ff: false,
434        }
435    }
436
437    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`).
438    pub fn squash(mut self) -> Self {
439        self.squash = true;
440        self
441    }
442
443    /// Always record a real (abortable) merge, even when a fast-forward was
444    /// possible (`--no-ff`).
445    pub fn no_ff(mut self) -> Self {
446        self.no_ff = true;
447        self
448    }
449}
450
451/// Options for [`GitApi::tag_create_annotated`] (`git tag -a`).
452///
453/// `#[non_exhaustive]`, so build it through [`AnnotatedTag::new`] and the chained
454/// setter rather than a struct literal.
455#[derive(Debug, Clone)]
456#[non_exhaustive]
457pub struct AnnotatedTag {
458    /// The tag name.
459    pub name: RefName,
460    /// The tag message (`-m`).
461    pub message: String,
462    /// The revision to tag (`<rev>`); `None` tags `HEAD`.
463    pub rev: Option<RevSpec>,
464}
465
466impl AnnotatedTag {
467    /// An annotated tag `name` with `message` at `HEAD`
468    /// (`git tag -a <name> -m <message>`).
469    pub fn new(name: RefName, message: impl Into<String>) -> Self {
470        Self {
471            name,
472            message: message.into(),
473            rev: None,
474        }
475    }
476
477    /// Tag `r` instead of `HEAD`.
478    pub fn rev(mut self, r: RevSpec) -> Self {
479        self.rev = Some(r);
480        self
481    }
482}
483
484/// Options for [`GitApi::delete_branch`] (`git branch -d`/`-D`).
485///
486/// `#[non_exhaustive]`, so build it through [`BranchDelete::new`] and the chained
487/// [`force`](BranchDelete::force) setter rather than a struct literal — a bare
488/// `bool` at the call site (`delete_branch(name, true)`) doesn't say what `true`
489/// means, and this leaves room to add options without a breaking signature change.
490#[derive(Debug, Clone, PartialEq, Eq)]
491#[non_exhaustive]
492pub struct BranchDelete {
493    /// The local branch name to delete.
494    pub name: RefName,
495    /// Delete even if not fully merged — `git branch -D` vs `-d`.
496    pub force: bool,
497}
498
499impl BranchDelete {
500    /// Delete branch `name`; not forced (git refuses an unmerged branch).
501    pub fn new(name: RefName) -> Self {
502        Self { name, force: false }
503    }
504
505    /// Delete even if not fully merged (`-D`).
506    pub fn force(mut self) -> Self {
507        self.force = true;
508        self
509    }
510}
511
512/// Options for [`GitApi::stash_push`] (`git stash push`).
513///
514/// `#[non_exhaustive]`, so build it through [`StashPush::new`] and the chained
515/// [`include_untracked`](StashPush::include_untracked) setter rather than a bare
516/// `bool` (`stash_push(dir, true)` doesn't say what `true` selects).
517#[derive(Debug, Clone, Default, PartialEq, Eq)]
518#[non_exhaustive]
519pub struct StashPush {
520    /// Also stash untracked files (`--include-untracked`).
521    pub include_untracked: bool,
522}
523
524impl StashPush {
525    /// Stash the tracked working-tree changes only.
526    pub fn new() -> Self {
527        Self::default()
528    }
529
530    /// Also stash untracked files (`--include-untracked`).
531    pub fn include_untracked(mut self) -> Self {
532        self.include_untracked = true;
533        self
534    }
535}
536
537/// Options for [`GitApi::worktree_remove`] (`git worktree remove`).
538///
539/// `#[non_exhaustive]`, so build it through [`WorktreeRemove::new`] and the chained
540/// [`force`](WorktreeRemove::force) setter rather than a struct literal — a bare
541/// `bool` (`worktree_remove(path, true)`) doesn't say what `true` means.
542#[derive(Debug, Clone, PartialEq, Eq)]
543#[non_exhaustive]
544pub struct WorktreeRemove {
545    /// The attached worktree path to remove.
546    pub path: PathBuf,
547    /// Remove even when the worktree has uncommitted changes (`--force`).
548    pub force: bool,
549}
550
551impl WorktreeRemove {
552    /// Remove the worktree at `path`; not forced (git refuses a dirty one).
553    pub fn new(path: impl Into<PathBuf>) -> Self {
554        Self {
555            path: path.into(),
556            force: false,
557        }
558    }
559
560    /// Remove even when the worktree has uncommitted changes (`--force`).
561    pub fn force(mut self) -> Self {
562        self.force = true;
563        self
564    }
565}
566
567/// A validated git reference name (branch/tag/remote-tracking ref). Every
568/// [`GitApi`] operation that names a branch, tag, or ref to **create, delete,
569/// rename, or look up by exact name** takes a `RefName` (directly or inside its
570/// options struct), so a name from untrusted input (UIs, bots, agents) is
571/// validated once, at construction, and the type — not an internal guard — is
572/// the argv-injection barrier from then on. For a general commit-ish or range
573/// (`checkout`, `reset_hard`, `log`, `diff` ranges, …) use the more permissive
574/// [`RevSpec`] instead.
575///
576/// Rules follow the load-bearing core of `git check-ref-format`: non-empty,
577/// no leading `-` or `.`, no `..`, no control characters or space, none of
578/// `~ ^ : ? * [ \`, no trailing `/` or `.lock`. A rejected name is an
579/// [`vcs_cli_support::is_invalid_input`] failure.
580#[derive(Debug, Clone, PartialEq, Eq, Hash)]
581pub struct RefName(String);
582
583impl RefName {
584    /// Validate `name` as a reference name.
585    pub fn new(name: impl Into<String>) -> Result<Self> {
586        let name = name.into();
587        let bad = name.is_empty()
588            || name.starts_with('-')
589            || name.starts_with('.')
590            || name.ends_with('/')
591            || name.ends_with(".lock")
592            || name.contains("..")
593            || name
594                .chars()
595                .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
596        if bad {
597            return Err(Error::spawn(
598                BINARY,
599                std::io::Error::new(
600                    std::io::ErrorKind::InvalidInput,
601                    format!("invalid git reference name: {name:?}"),
602                ),
603            ));
604        }
605        Ok(RefName(name))
606    }
607
608    /// The validated name.
609    pub fn as_str(&self) -> &str {
610        &self.0
611    }
612}
613
614impl std::fmt::Display for RefName {
615    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
616        f.write_str(&self.0)
617    }
618}
619
620/// A validated revision/range expression (`HEAD~2`, `main..feature`). Every
621/// [`GitApi`] operation that resolves a general **commit-ish or range** takes a
622/// `RevSpec`, so an untrusted revision is validated once, at construction.
623/// Deliberately *minimal* — git's revision grammar is too rich to validate
624/// here — it only guarantees the expression is non-empty and cannot be parsed
625/// as a flag (no leading `-`). For a value that must be a genuine ref **name**
626/// (to create/delete/rename a branch or tag) use the stricter [`RefName`]. A
627/// rejected expression is an [`vcs_cli_support::is_invalid_input`] failure.
628#[derive(Debug, Clone, PartialEq, Eq, Hash)]
629pub struct RevSpec(String);
630
631impl RevSpec {
632    /// Validate `rev` as a revision/range expression (non-empty, no leading `-`).
633    pub fn new(rev: impl Into<String>) -> Result<Self> {
634        let rev = rev.into();
635        reject_flag_like("revision", &rev)?;
636        Ok(RevSpec(rev))
637    }
638
639    /// The validated expression.
640    pub fn as_str(&self) -> &str {
641        &self.0
642    }
643}
644
645impl std::fmt::Display for RevSpec {
646    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647        f.write_str(&self.0)
648    }
649}
650
651impl std::str::FromStr for RefName {
652    type Err = Error;
653    fn from_str(s: &str) -> Result<Self> {
654        Self::new(s)
655    }
656}
657
658impl std::str::FromStr for RevSpec {
659    type Err = Error;
660    fn from_str(s: &str) -> Result<Self> {
661        Self::new(s)
662    }
663}
664
665/// What [`GitApi::checkout`] switches to: a validated ref/revision, or git's `-`
666/// "previous branch" shortcut.
667///
668/// `-` is the one place a leading-`-` token is legitimate — it is git's
669/// `@{-1}` shorthand, not caller-controlled argv — so it is modelled as a
670/// distinct [`Previous`](CheckoutTarget::Previous) variant emitting a fixed
671/// literal, rather than punching a hole in [`RevSpec`]'s no-leading-`-`
672/// invariant (which the other commit-ish operations rely on).
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum CheckoutTarget {
675    /// Check out this validated ref or revision.
676    Ref(RevSpec),
677    /// Check out the previous branch (`git checkout -`).
678    Previous,
679}
680
681impl CheckoutTarget {
682    /// Check out a validated ref/revision.
683    pub fn rev(rev: RevSpec) -> Self {
684        Self::Ref(rev)
685    }
686
687    /// Check out the previous branch (`git checkout -`).
688    pub fn previous() -> Self {
689        Self::Previous
690    }
691
692    /// The single argv token this target expands to.
693    fn as_arg(&self) -> &str {
694        match self {
695            Self::Ref(rev) => rev.as_str(),
696            Self::Previous => "-",
697        }
698    }
699}
700
701impl From<RevSpec> for CheckoutTarget {
702    fn from(rev: RevSpec) -> Self {
703        Self::Ref(rev)
704    }
705}
706
707/// What the installed `git` binary supports, probed via
708/// [`GitApi::capabilities`]. A value type — the client holds no state, so
709/// probe once and keep the result (callers cache it).
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
711#[non_exhaustive]
712pub struct GitCapabilities {
713    /// The binary's parsed version.
714    pub version: GitVersion,
715}
716
717/// The oldest git this crate is written against — **2.31**, the highest version its
718/// own argv actually requires (validated on 2.54). `harden()` pins config through
719/// `GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n` (added in **2.31**); `branch_status`/`snapshot`
720/// read `status --porcelain=v2` (2.11) and `switch_with_stash` uses `stash push` (2.13),
721/// all below 2.31. Gating on the real minor floor makes [`ensure_supported`] catch a
722/// too-old git with a clear message rather than letting it pass and then fail later with
723/// a cryptic argv error — the M29 fix (the previous gate was major-only, so 2.7 "passed"
724/// then broke). (Contrast vcs-jj, whose floor is precise per its empirically-validated
725/// parser release.)
726const MIN_SUPPORTED_MAJOR: u64 = 2;
727const MIN_SUPPORTED_MINOR: u64 = 31;
728
729impl GitCapabilities {
730    /// Whether the binary meets the supported floor (git ≥ 2.31).
731    pub fn is_supported(&self) -> bool {
732        (self.version.major, self.version.minor) >= (MIN_SUPPORTED_MAJOR, MIN_SUPPORTED_MINOR)
733    }
734
735    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs git
736    /// ≥ 2.31, found 2.7.4" instead of a cryptic argv failure later.
737    pub fn ensure_supported(&self) -> Result<()> {
738        if self.is_supported() {
739            return Ok(());
740        }
741        Err(Error::spawn(
742            BINARY,
743            std::io::Error::new(
744                std::io::ErrorKind::Unsupported,
745                format!(
746                    "vcs-git requires git >= {MIN_SUPPORTED_MAJOR}.{MIN_SUPPORTED_MINOR} \
747                     (validated on 2.54), found {}",
748                    self.version
749                ),
750            ),
751        ))
752    }
753}
754
755/// The Git operations this crate exposes — the interface consumers code against
756/// and mock in tests.
757///
758/// **Injection safety:** reference names and revision expressions are taken as
759/// the validated [`RefName`] / [`RevSpec`] newtypes (directly or inside an
760/// options struct), so a flag-like or malformed value is rejected at
761/// construction, before it can reach an argv slot. The remaining
762/// caller-supplied bare positionals that are *not* refs/revisions — remote
763/// names and URLs — keep an internal `reject_flag_like` guard: a value that is
764/// empty or begins with `-` is rejected with an [`Error::Spawn`] *before*
765/// spawning. Flag-value slots (`-m <msg>`, `--branch <b>`), filesystem path
766/// arguments (`--`-separated pathspecs, plus worktree paths and clone
767/// destinations — typed `Path`, caller-trusted), and the `run`/`run_raw`
768/// escape hatches are not guarded. The one context-dependent special value,
769/// git's `-` "previous branch", is [`CheckoutTarget::Previous`].
770#[cfg_attr(feature = "mock", mockall::automock)]
771#[async_trait::async_trait]
772pub trait GitApi: Send + Sync {
773    /// Run `git <args>` **in the process's current directory**, returning trimmed
774    /// stdout (throws on a non-zero exit). A raw escape hatch for unmodelled commands
775    /// — you supply the whole argv, so target a specific repo with `-C <dir>` in the
776    /// args. This method on the client is the **process-cwd** escape hatch; the
777    /// `at(dir)` bound view's [`run`](GitAt::run) is instead **bound to `dir`** (it
778    /// forwards to [`Git::run_in`], so `git.at(dir).run(…)` runs in the bound repo).
779    /// Use `git.at(dir).run(…)` (or [`Git::run_in`]) for the bound repo; use this for
780    /// the process cwd (T-035, was M15).
781    async fn run(&self, args: &[String]) -> Result<String>;
782    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
783    /// captured [`ProcessResult`].
784    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
785    /// Installed Git version (`git --version`).
786    async fn version(&self) -> Result<String>;
787    /// The installed binary's parsed version, as [`GitCapabilities`]
788    /// (`git --version`). A value type — probe once and keep it; an
789    /// unrecognisable version string is an [`Error::Parse`].
790    async fn capabilities(&self) -> Result<GitCapabilities>;
791    /// Working-tree status (`git status --porcelain=v1 -z`).
792    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
793    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
794    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
795    async fn status_text(&self, dir: &Path) -> Result<String>;
796    /// Like [`status`](GitApi::status) but ignoring untracked files
797    /// (`git status --porcelain=v1 -z --untracked-files=no`) — "is the *tracked*
798    /// tree dirty", staged or not.
799    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
800    /// A combined branch + working-tree snapshot in **one** spawn
801    /// (`git status --porcelain=v2 --branch -z`): HEAD, branch, upstream,
802    /// ahead/behind, and change counts — the data a prompt/status-bar needs
803    /// without N round-trips. See [`BranchStatus`].
804    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
805    /// Paths with unresolved merge conflicts, repo-relative with `/` separators
806    /// (`git diff --name-only --diff-filter=U -z`). Empty when there are none.
807    /// Returns [`PathBuf`]s built from the raw `-z` bytes, so a non-UTF-8
808    /// conflicted path survives losslessly.
809    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>>;
810    /// Current branch name, or `None` on a **detached HEAD**
811    /// (`git symbolic-ref --quiet --short HEAD`). Returns the branch name for a
812    /// normal branch **and for an unborn branch** (a fresh `init`/`clone` before the
813    /// first commit); `None` only when HEAD is detached. Mirrors
814    /// [`JjApi::current_bookmark`](../vcs_jj/trait.JjApi.html#tymethod.current_bookmark)'s
815    /// `Option` shape, so cross-backend code treats "no named branch/bookmark" the
816    /// same way on both wrappers.
817    async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
818    /// Local branches, current one flagged (`git branch`).
819    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
820    /// Up to `max` commits reachable from `revspec`, newest first
821    /// (`git log <revspec>`). Pass `"HEAD"` for the current branch's history, or
822    /// a range like `"main..HEAD"` / `"origin/main..HEAD"` to scope it. Mirrors
823    /// [`JjApi::log`](../vcs_jj/trait.JjApi.html#tymethod.log)'s revset argument,
824    /// so cross-backend code uses one signature. The `revspec` is a validated
825    /// [`RevSpec`], so it can never be parsed as a flag.
826    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
827    /// Like [`log`](GitApi::log), but scoped to commits that touched `paths`
828    /// (`git --literal-pathspecs log <revspec> -n <max> -- <paths>`) — e.g. "who
829    /// changed this module". `--literal-pathspecs` matches a path containing
830    /// `*`/`?`/`[]` literally rather than as pathspec glob magic (R-02); the `--`
831    /// separator keeps a path from being read as a flag (same convention as
832    /// [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths)). An empty
833    /// `paths` is refused *before spawning*: silently falling back to
834    /// [`log`](GitApi::log)'s unrestricted history would defeat the "scoped to
835    /// these paths" contract. Mirrors
836    /// [`JjApi::log_paths`](../vcs_jj/trait.JjApi.html#tymethod.log_paths), which
837    /// takes filesets instead of pathspecs.
838    ///
839    /// Unlike [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths), git's
840    /// `log` has no `--pathspec-from-file` support, so a `paths` set that would
841    /// risk exceeding the OS argv limit is instead split into multiple `git log`
842    /// calls, each within budget; the per-call results are merged (deduplicated
843    /// by hash — a commit can touch paths spread across more than one chunk)
844    /// and restored to git's own commit order using a separate, pathless `git
845    /// log <revspec> --format=%H` oracle call (T-052/R-03): pathspec filtering
846    /// only drops non-matching commits, it never reorders the ones that
847    /// remain, so the oracle's order — over the *same* revspec, unrestricted
848    /// by paths — gives the exact relative order a single, hypothetical
849    /// unchunked call would have produced. Unlike sorting by a parsed date
850    /// field, this needs no assumption about author-vs-committer timestamps or
851    /// same-second ties (git log dates have no sub-second precision). A
852    /// `paths` set that fits in one call is unaffected — same single
853    /// invocation, same order, as before.
854    ///
855    /// Before any of that, `revspec` (which may be symbolic, e.g. `HEAD`, or a
856    /// range, e.g. `main..feature`) is resolved exactly once via `git
857    /// rev-parse` into a fixed set of commit ids that every chunk call and the
858    /// oracle call then reuse verbatim (T-052/R-04): without this, each of
859    /// those several independent invocations would re-resolve the same
860    /// symbolic text on its own, so a concurrent commit/reset/ref-move landing
861    /// between any two of them could make them see different repository
862    /// snapshots, silently omitting a newer matching commit, including one no
863    /// longer reachable, or interleaving two different histories into the
864    /// merged result. This resolution only happens on the chunked path (more
865    /// than one invocation); the single-call fast path is unaffected. Also
866    /// before any of that, every individual path is checked against the argv
867    /// budget on its own (T-052/R-05): `git log` has no NUL-safe transport to
868    /// fall back to the way `add`/`commit_paths` do, so a single path that by
869    /// itself cannot fit in argv is rejected up front with a clear error
870    /// rather than silently forwarded as an over-budget singleton chunk.
871    async fn log_paths(
872        &self,
873        dir: &Path,
874        revspec: &RevSpec,
875        max: usize,
876        paths: &[String],
877    ) -> Result<Vec<Commit>>;
878    /// Resolve a revision to a full hash (`git rev-parse --verify <rev>`). `--verify`
879    /// requires `rev` to name exactly one object, so a non-revision (e.g. a filename)
880    /// errors instead of being echoed back as a fake id.
881    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
882    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
883    /// e.g. to label a detached HEAD.
884    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
885    /// Initialise a repository (`git init`).
886    async fn init(&self, dir: &Path) -> Result<()>;
887    /// Stage `paths` (`git --literal-pathspecs add -- <paths>`) —
888    /// `--literal-pathspecs` applies regardless of path-set size, so a path
889    /// containing `*`/`?`/`[]` always matches literally rather than as pathspec
890    /// glob magic (R-01). A path set whose combined length would risk exceeding
891    /// the OS command-line limit (`ARGV_PATHSPEC_BUDGET`; Windows' `CreateProcess`
892    /// caps out around 32,767 characters) instead goes over stdin via
893    /// `--pathspec-from-file=- --pathspec-file-nul` — the paths never touch argv
894    /// at all in that case, so there is no upper bound left to exceed (T-052).
895    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
896    /// Commit staged changes (`git commit -m`).
897    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
898    /// Create a branch without switching to it (`git branch <name>`).
899    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()>;
900    /// Switch to a branch/revision, or the previous branch (`git checkout
901    /// <target>`); see [`CheckoutTarget`].
902    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()>;
903    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
904    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()>;
905    /// Commit exactly the spec's paths' working-tree content, ignoring the index
906    /// (`git --literal-pathspecs commit [--amend] -m <message> --only -- <paths>`);
907    /// see [`CommitPaths`]. `--literal-pathspecs` applies regardless of path-set
908    /// size, so a glob-magic character (`*`/`?`/`[]`) in a path is matched
909    /// literally rather than expanded — otherwise `commit_paths`'s "exactly these
910    /// paths" contract could be violated (R-01). Like [`add`](GitApi::add), a
911    /// path set that would risk exceeding the OS argv limit is instead routed
912    /// over stdin (`--pathspec-from-file=- --pathspec-file-nul`) — always as a
913    /// **single** `git commit` invocation either way, so the one-atomic-commit
914    /// contract is unaffected by the path set's size (T-052).
915    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
916    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
917    /// pre-fill an amend.
918    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
919    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
920    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
921    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
922    /// Whether the working tree has no unstaged modifications to **tracked** files
923    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
924    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
925    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
926
927    // --- Discovery / identity ------------------------------------------------
928
929    /// The repository's common git directory (`rev-parse --git-common-dir`) —
930    /// stable across linked worktrees.
931    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
932    /// This worktree's git directory (`rev-parse --git-dir`).
933    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
934    /// Resolve a revision to a commit hash, peeling tags
935    /// (`rev-parse --verify <rev>^{commit}`).
936    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
937    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
938    /// (short name only); `None` when `origin/HEAD` is unset.
939    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
940    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
941    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
942    /// Whether `origin` has `name`, without fetching (`ls-remote origin
943    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
944    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
945    /// credential or a flaky network can't hang the call.
946    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
947    /// A remote's URL (`remote get-url <remote>`).
948    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
949    /// The current attached branch's upstream, e.g. `Some("origin/main")`
950    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
951    /// A detached HEAD or a directory outside a repository is an error.
952    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
953    /// Branch names on `remote`, without fetching
954    /// (`ls-remote --heads <remote>`).
955    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
956
957    // --- Branches ------------------------------------------------------------
958
959    /// Whether the [`MergeCheck`]'s `branch` is fully merged into its `base`
960    /// (`branch --merged <base>`). Build it as
961    /// `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)`
962    /// so the two refs can't be transposed (a swap would invert the answer).
963    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
964    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
965    /// (`branch --set-upstream-to=<upstream> <branch>`).
966    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()>;
967    /// Delete a local branch (`branch -d`, or `-D` when forced); see [`BranchDelete`].
968    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()>;
969    /// Rename a local branch (`branch -m <old> <new>`).
970    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()>;
971    /// Count commits in a range (`rev-list --count <range>`).
972    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize>;
973    /// Whether a diff range is empty (`diff --quiet <range>`).
974    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool>;
975    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
976    /// match `vcs_jj::JjApi::diff_stat`.
977    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat>;
978    /// Raw git-format unified diff text for `spec`
979    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output, returned
980    /// **verbatim** (a trailing blank context line is preserved, so the last hunk
981    /// stays in sync with its `@@` line count for a re-parse/re-apply).
982    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
983    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
984    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
985
986    // --- In-progress state ---------------------------------------------------
987
988    /// Whether the index has no staged changes (`diff --cached --quiet`).
989    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
990    /// Whether a rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
991    /// **not** left by `git am`, exists under the git dir).
992    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
993    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
994    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
995    /// Whether a `git am` (mailbox apply) is in progress (`rebase-apply/applying`).
996    /// Distinct from a rebase, which shares the `rebase-apply` dir but without the
997    /// `applying` marker — aborting an am needs `am --abort`, not `rebase --abort`.
998    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
999    /// Whether a cherry-pick is in progress (`CHERRY_PICK_HEAD` under the git dir).
1000    /// A cherry-pick conflict writes `CHERRY_PICK_HEAD`, **not** `MERGE_HEAD`, so
1001    /// this is distinct from a merge and is aborted/continued with
1002    /// `cherry-pick --abort` / `--continue`, not `merge --abort`.
1003    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool>;
1004    /// Whether a revert is in progress (`REVERT_HEAD` under the git dir). Like a
1005    /// cherry-pick, a revert conflict writes its own head file, not `MERGE_HEAD`;
1006    /// it is driven with `revert --abort` / `--continue`.
1007    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool>;
1008    /// Whether a `git bisect` session is in progress (`BISECT_LOG` under the git
1009    /// dir). Ended with `bisect reset` (there is no `--continue`).
1010    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool>;
1011
1012    // --- Mutations -----------------------------------------------------------
1013
1014    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
1015    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
1016    async fn fetch(&self, dir: &Path) -> Result<()>;
1017    /// Fetch from a *named* remote (`fetch --quiet <remote>`), with
1018    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried like
1019    /// [`fetch`](GitApi::fetch).
1020    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
1021    /// Fetch a single branch from `origin` into its remote-tracking ref
1022    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
1023    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
1024    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()>;
1025    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
1026    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
1027    /// Stage a branch's changes without committing (`merge --squash <branch>`).
1028    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()>;
1029    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
1030    /// message it takes the default merge message non-interactively (`--no-edit`).
1031    /// See [`MergeCommit`].
1032    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
1033    /// Merge a branch but stop before committing, so the result can be inspected
1034    /// (`merge --no-commit [--squash | --no-ff] <branch>`). With `no_ff` (and not
1035    /// `squash`) git records `MERGE_HEAD`, so the in-progress merge is abortable
1036    /// via [`merge_abort`](GitApi::merge_abort) — the dry-run pattern. With
1037    /// `squash`, git stages the squashed result but records **no** `MERGE_HEAD`,
1038    /// so it is *not* an abortable merge: undo it with
1039    /// [`reset_merge`](GitApi::reset_merge) / [`reset_hard`](GitApi::reset_hard),
1040    /// not `merge_abort`. See [`MergeNoCommit`].
1041    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
1042    /// Abort an in-progress merge (`merge --abort`).
1043    async fn merge_abort(&self, dir: &Path) -> Result<()>;
1044    /// Finish a merge after resolving conflicts (`commit --no-edit`).
1045    async fn merge_continue(&self, dir: &Path) -> Result<()>;
1046    /// Undo an in-progress (or just-staged) merge: `reset --merge` resets the
1047    /// index and the merge-touched working-tree files back to `HEAD` and drops
1048    /// `MERGE_HEAD`, **discarding the merge's changes** while keeping unrelated
1049    /// unstaged edits. Use it after `merge_squash` / `merge_no_commit(squash)`,
1050    /// where there is no `MERGE_HEAD` for `merge_abort` to act on.
1051    async fn reset_merge(&self, dir: &Path) -> Result<()>;
1052    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
1053    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1054    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
1055    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
1056    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()>;
1057    /// Abort an in-progress rebase (`rebase --abort`).
1058    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
1059    /// Abort an in-progress `git am` (`am --abort`), restoring the pre-`am` HEAD.
1060    async fn am_abort(&self, dir: &Path) -> Result<()>;
1061    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
1062    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
1063    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
1064    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
1065    /// e.g. to save state before a copy-on-write restore. See [`StashPush`].
1066    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()>;
1067    /// Restore the most recent stash and drop it (`stash pop`).
1068    async fn stash_pop(&self, dir: &Path) -> Result<()>;
1069
1070    // --- Worktrees -----------------------------------------------------------
1071
1072    /// List worktrees (`worktree list --porcelain`).
1073    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
1074    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
1075    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
1076    /// Remove a worktree (`worktree remove [--force] <path>`); see [`WorktreeRemove`].
1077    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()>;
1078    /// Move a worktree (`worktree move <from> <to>`).
1079    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
1080    /// Prune stale worktree admin entries (`worktree prune`).
1081    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
1082
1083    // --- Clone / tags / inspection --------------------------------------------
1084
1085    /// Clone `url` into `dest` (`git clone <url> <dest>` + [`CloneSpec`] flags).
1086    /// Runs without a working directory — pass an **absolute** `dest`.
1087    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
1088    /// Create a lightweight tag at `rev` (`tag <name> [<rev>]`; `None` = HEAD).
1089    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()>;
1090    /// Create an annotated tag (`tag -a <name> -m <message> [<rev>]`); see
1091    /// [`AnnotatedTag`].
1092    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
1093    /// Tag names, sorted by git's default ordering (`tag --list`).
1094    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
1095    /// Delete a tag (`tag -d <name>`).
1096    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()>;
1097    /// A file's content at a revision (`git show <rev>:<path>`). `path` is
1098    /// repo-relative; backslashes are normalised to `/` (git requires it).
1099    /// Content is decoded **lossily** — binary files come back mangled rather
1100    /// than erroring — and returned **verbatim**: the blob's trailing newline(s)
1101    /// are preserved (not trimmed), so a read-modify-write round-trip is byte-exact.
1102    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String>;
1103    /// The value of a config key, or `None` when unset (`config --get <key>`,
1104    /// whose exit 1 covers both "unset" and "no such section" — git doesn't
1105    /// distinguish). A multi-valued key errors; read those via `run`.
1106    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
1107    /// Set a config key in the repository's local config (`config <key> <value>`).
1108    ///
1109    /// **Trusted-input sink.** `key` is guarded against a flag-shape, but this
1110    /// writes whatever key/value it's given — including code-execution keys like
1111    /// `core.sshCommand` or `filter.<drv>.clean`. Never wire untrusted input into
1112    /// it; a `harden()`ed client does *not* protect against config *you* write.
1113    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
1114    /// Add a remote (`remote add <name> <url>`).
1115    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
1116    /// Change a remote's URL (`remote set-url <name> <url>`).
1117    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
1118    /// Per-line authorship of `path` (`blame --line-porcelain [<rev>] -- <path>`;
1119    /// `None` = the working tree's HEAD).
1120    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
1121
1122    // --- Sequencer -------------------------------------------------------------
1123
1124    /// Apply a commit onto the current branch (`cherry-pick <rev>`). A conflict
1125    /// surfaces as an error classified by [`is_merge_conflict`].
1126    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1127    /// Revert a commit with the default message (`revert --no-edit <rev>`).
1128    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1129    /// Skip the current patch of a paused rebase (`rebase --skip`). Mainly for
1130    /// the `apply` backend's "nothing to commit" stop — the default `merge`
1131    /// backend auto-drops emptied patches on `--continue`.
1132    async fn rebase_skip(&self, dir: &Path) -> Result<()>;
1133    /// Abort an in-progress cherry-pick (`cherry-pick --abort`), restoring the
1134    /// pre-cherry-pick state.
1135    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()>;
1136    /// Continue a cherry-pick after resolving conflicts (`cherry-pick --continue`);
1137    /// the editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never
1138    /// hangs a headless caller. On a multi-commit pick it can stop again on the
1139    /// next commit's conflict (exit non-zero) — a conflict, not a hard error.
1140    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()>;
1141    /// Abort an in-progress revert (`revert --abort`), restoring the pre-revert
1142    /// state.
1143    async fn revert_abort(&self, dir: &Path) -> Result<()>;
1144    /// Continue a revert after resolving conflicts (`revert --continue`); the
1145    /// editor is suppressed like [`cherry_pick_continue`](GitApi::cherry_pick_continue),
1146    /// and it too can stop on the next commit's conflict.
1147    async fn revert_continue(&self, dir: &Path) -> Result<()>;
1148    /// End a `git bisect` session (`bisect reset`), returning to the branch/commit
1149    /// that was checked out before it started. This is the "abort" for a bisect;
1150    /// bisect has no `--continue`.
1151    async fn bisect_reset(&self, dir: &Path) -> Result<()>;
1152}
1153
1154vcs_cli_support::managed_client! {
1155    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject a
1156    /// fake process executor; [`Git::new`] uses the real job-backed runner.
1157    ///
1158    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient): enable lock-contention retry with
1159    /// [`with_retry`](Git::with_retry) (opt-in; off by default).
1160    ///
1161    /// **Every** client (not just [`hardened`](Git::hardened)) scrubs the inherited
1162    /// repo-**redirector** environment variables below, so a `GIT_DIR` (etc.) leaking
1163    /// from the parent process — e.g. running inside a git hook, which exports
1164    /// `GIT_DIR`/`GIT_INDEX_FILE` — can't silently redirect commands at a *different*
1165    /// repository than the bound `dir`. (`harden()` additionally scrubs the
1166    /// command-hook vars and pins hooks/fsmonitor/sshCommand off.)
1167    pub struct Git => BINARY, scrub_env = [
1168        "GIT_DIR",
1169        "GIT_WORK_TREE",
1170        "GIT_INDEX_FILE",
1171        "GIT_COMMON_DIR",
1172        "GIT_OBJECT_DIRECTORY",
1173        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1174        "GIT_NAMESPACE",
1175    ]
1176}
1177
1178impl<R: ProcessRunner> Git<R> {
1179    /// Retry **whole-repo lock-contention** failures (another process holds the
1180    /// repo's `index.lock`) per `policy` — opt-in, off by default. Safe even for
1181    /// mutating commands: that lock is acquired before any write, so a failure is
1182    /// pre-execution (git never ran) and a retry can't double-apply. Per-ref lock
1183    /// failures are *not* retried (a multi-ref op can fail a ref lock mid-way). See
1184    /// [`RetryPolicy`] and [`is_lock_contention`].
1185    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
1186        self.core = self.core.with_retry(policy);
1187        self
1188    }
1189
1190    /// Supply credentials for **HTTPS** remote operations (`fetch`/`push`/`clone`/
1191    /// `ls-remote`) via a [`CredentialProvider`] — opt-in, off by default (ambient
1192    /// git credential helpers / SSH agent). When the provider yields a credential,
1193    /// each remote op runs with an inline `credential.helper` that feeds the secret
1194    /// from an environment variable, so the token never appears in `argv`. Local
1195    /// operations are unaffected. This covers HTTPS only — an **SSH** remote ignores
1196    /// the helper and authenticates via the ambient SSH agent, as before.
1197    #[must_use]
1198    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1199        self.core = self.core.with_credentials(provider);
1200        self
1201    }
1202
1203    /// Convenience for the common case: authenticate HTTPS remotes with a single
1204    /// static `token` (a personal-access token; the default username
1205    /// `x-access-token` is used). Shorthand for
1206    /// `with_credentials(Arc::new(StaticCredential::token(token)))`. For a specific
1207    /// username, build a [`Credential::userpass`] and use
1208    /// [`with_credentials`](Git::with_credentials).
1209    #[must_use]
1210    pub fn with_token(self, token: impl Into<Secret>) -> Self {
1211        self.with_credentials(Arc::new(StaticCredential::token(token)))
1212    }
1213
1214    /// Convenience: read the HTTPS token from environment variable `var` at request
1215    /// time; if `var` is unset/empty, fall back to ambient auth. Shorthand for
1216    /// `with_credentials(Arc::new(EnvToken::new(var)))`.
1217    #[must_use]
1218    pub fn with_env_token(self, var: impl Into<String>) -> Self {
1219        self.with_credentials(Arc::new(EnvToken::new(var)))
1220    }
1221
1222    /// Resolve HTTPS credentials for a remote op into the leading `-c` config args
1223    /// (an inline `credential.helper`) and the secret env to set on the command.
1224    /// Both are empty when no provider is configured — ambient git auth, unchanged.
1225    /// The secret lives only in the returned env, never in the args.
1226    ///
1227    /// `expect_host` scopes the helper to a host (the secret is released only for
1228    /// that host, so a redirect/submodule to another host can't extract it).
1229    /// Callers that know the operation's target host — e.g. `clone` from its URL —
1230    /// pass it; the others pass `None` (the helper is ungated, as before).
1231    ///
1232    /// The same `expect_host` is **also** passed as the [`CredentialRequest`]'s host,
1233    /// so a **host-keyed** provider selects the secret for that host — one `Git`
1234    /// client cloning several hosts draws each host's own token, never a
1235    /// neighbour's. A `None` host lets such a provider defer to ambient auth rather
1236    /// than hand back the wrong host's secret (the fail-closed / ambient policy is
1237    /// on [`ManagedClient::resolve_credential`](vcs_cli_support::ManagedClient::resolve_credential)).
1238    async fn remote_credentials(
1239        &self,
1240        expect_host: Option<&str>,
1241    ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
1242        match self
1243            .core
1244            .resolve_credential(CredentialService::Git, expect_host)
1245            .await?
1246        {
1247            Some(cred) => {
1248                let helper = git_credential_helper(&cred, expect_host);
1249                Ok((helper.config_args, helper.env))
1250            }
1251            None => Ok((Vec::new(), Vec::new())),
1252        }
1253    }
1254}
1255
1256impl<R: ProcessRunner> Git<R> {
1257    /// [`diff_text`](GitApi::diff_text) with an explicit per-call
1258    /// [`OutputBudget`], instead of this client's
1259    /// [`default_output_budget`](Git::default_output_budget). Use it to read a
1260    /// legitimately large diff past a tighter client default
1261    /// ([`OutputBudget::unlimited`], or a higher byte cap), or to tighten the cap
1262    /// for one call. Past the ceiling the read errors with
1263    /// [`Error::OutputTooLarge`] (actual and
1264    /// allowed sizes) rather than buffering an unbounded diff.
1265    pub async fn diff_text_within(
1266        &self,
1267        dir: &Path,
1268        spec: DiffSpec,
1269        budget: OutputBudget,
1270    ) -> Result<String> {
1271        self.diff_text_budgeted(dir, spec, budget).await
1272    }
1273
1274    /// [`diff`](GitApi::diff) with an explicit per-call [`OutputBudget`] — the
1275    /// parsed-model counterpart of [`diff_text_within`](Git::diff_text_within).
1276    pub async fn diff_within(
1277        &self,
1278        dir: &Path,
1279        spec: DiffSpec,
1280        budget: OutputBudget,
1281    ) -> Result<Vec<FileDiff>> {
1282        let text = self.diff_text_budgeted(dir, spec, budget).await?;
1283        Ok(parse_diff(&text))
1284    }
1285
1286    /// Shared body of [`diff_text`](GitApi::diff_text) /
1287    /// [`diff_text_within`](Git::diff_text_within): builds the `git diff` and runs
1288    /// it under `budget` (a fail-loud byte ceiling; unbounded when the budget is
1289    /// [`OutputBudget::unlimited`]).
1290    async fn diff_text_budgeted(
1291        &self,
1292        dir: &Path,
1293        spec: DiffSpec,
1294        budget: OutputBudget,
1295    ) -> Result<String> {
1296        // The target is a single positional arg: `HEAD` for the working tree, or
1297        // the caller's revision/range. `-M` enables rename detection; `--no-color`
1298        // / `--no-ext-diff` keep the output stable and machine-parseable.
1299        let target = match spec {
1300            DiffSpec::WorkingTree => {
1301                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
1302                // diff against the empty tree so a pre-first-commit working tree
1303                // still yields its additions instead of a hard failure. The empty
1304                // tree's id depends on the repo's object format (the SHA-1
1305                // `EMPTY_TREE_SHA1` doesn't exist in a SHA-256 repo), so resolve it
1306                // from git rather than hard-coding — see `empty_tree_oid`.
1307                if self.is_unborn(dir).await? {
1308                    self.empty_tree_oid(dir).await?
1309                } else {
1310                    "HEAD".to_string()
1311                }
1312            }
1313            DiffSpec::Rev(rev) => {
1314                reject_flag_like("revision", &rev)?;
1315                rev
1316            }
1317        };
1318        // The explicit prefixes pin the `a/`…`b/` form the shared parser extracts
1319        // paths from — a user's `diff.noprefix` / `diff.mnemonicPrefix` config
1320        // would otherwise change the headers and make every file silently vanish
1321        // from the parse. (Command-line prefixes override both config options.)
1322        // `run_untrimmed_within`: trimming the diff would drop a trailing blank
1323        // context line, desyncing the last hunk from its `@@` line count for a
1324        // consumer that re-applies or re-parses it (H7); the budget bounds it.
1325        // Trailing `--`: pin `target` as a revision, never a pathspec — without it
1326        // a `Rev` that happens to name a tracked path would diff the working tree
1327        // for that path instead of the intended commit (the C2/M13 collision
1328        // class). `reject_flag_like` already blocks a leading `-`; `--` closes the
1329        // path-collision half.
1330        self.core
1331            .run_untrimmed_within(
1332                self.core.command_in(
1333                    dir,
1334                    [
1335                        "diff",
1336                        target.as_str(),
1337                        "--no-color",
1338                        "--no-ext-diff",
1339                        "-M",
1340                        "--src-prefix=a/",
1341                        "--dst-prefix=b/",
1342                        "--",
1343                    ],
1344                ),
1345                budget,
1346            )
1347            .await
1348    }
1349
1350    /// [`show_file`](GitApi::show_file) with an explicit per-call
1351    /// [`OutputBudget`], instead of this client's
1352    /// [`default_output_budget`](Git::default_output_budget). Reads a blob's bytes
1353    /// under `budget`: past the ceiling the read errors with
1354    /// [`Error::OutputTooLarge`] rather than
1355    /// buffering an unbounded file.
1356    pub async fn show_file_within(
1357        &self,
1358        dir: &Path,
1359        rev: &RevSpec,
1360        path: &str,
1361        budget: OutputBudget,
1362    ) -> Result<String> {
1363        let rev = rev.as_str();
1364        // git rejects backslash separators in the `<rev>:<path>` spec ("exists on
1365        // disk, but not in <rev>") — normalise for Windows callers. Only on Windows:
1366        // on Unix a backslash is a legal filename byte, and rewriting it would make
1367        // a literal `a\b.txt` unresolvable.
1368        #[cfg(windows)]
1369        let path = path.replace('\\', "/");
1370        let spec = format!("{rev}:{path}");
1371        // `run_untrimmed_within`: a blob's trailing newline(s) are part of its
1372        // content — trimming them corrupts a read-modify-write round-trip (H7); the
1373        // budget bounds it.
1374        self.core
1375            .run_untrimmed_within(self.core.command_in(dir, ["show", spec.as_str()]), budget)
1376            .await
1377    }
1378}
1379
1380/// Set each secret environment variable on `cmd` (the values from
1381/// [`Git::remote_credentials`]). A no-op when `envs` is empty.
1382fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
1383    envs.iter()
1384        .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
1385}
1386
1387#[async_trait::async_trait]
1388impl<R: ProcessRunner> GitApi for Git<R> {
1389    async fn run(&self, args: &[String]) -> Result<String> {
1390        self.core.run(args).await
1391    }
1392
1393    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1394        self.core.output_string(args).await
1395    }
1396
1397    async fn version(&self) -> Result<String> {
1398        self.core.run(["--version"]).await
1399    }
1400
1401    async fn capabilities(&self) -> Result<GitCapabilities> {
1402        let raw = self.version().await?;
1403        let version = parse::parse_git_version(&raw).ok_or_else(|| {
1404            Error::parse(
1405                BINARY,
1406                format!("unrecognisable `git --version` output: {raw:?}"),
1407            )
1408        })?;
1409        Ok(GitCapabilities { version })
1410    }
1411
1412    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1413        // `parse_bytes`: `-z` paths are raw bytes that may not be valid UTF-8 on
1414        // Unix, so parse from the byte stream — a lossy `String` decode would
1415        // corrupt a non-ASCII/non-UTF-8 filename before it reaches the caller.
1416        self.core
1417            .parse_bytes(
1418                self.core
1419                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1420                parse::parse_porcelain,
1421            )
1422            .await
1423    }
1424
1425    async fn status_text(&self, dir: &Path) -> Result<String> {
1426        self.core
1427            .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1428            .await
1429    }
1430
1431    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1432        // `GIT_OPTIONAL_LOCKS=0`: skip the opportunistic index refresh-write a
1433        // `status` may otherwise persist. This is the snapshot/poll primitive —
1434        // a filesystem watcher re-querying through it must not have the query
1435        // itself dirty `.git/index` and re-trigger the watch (verified: with
1436        // optional locks off, a re-query writes nothing).
1437        self.core
1438            .parse(
1439                self.core
1440                    .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1441                    .env("GIT_OPTIONAL_LOCKS", "0"),
1442                parse::parse_porcelain_v2,
1443            )
1444            .await
1445    }
1446
1447    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1448        self.core
1449            .parse_bytes(
1450                self.core.command_in(
1451                    dir,
1452                    ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1453                ),
1454                parse::parse_porcelain,
1455            )
1456            .await
1457    }
1458
1459    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>> {
1460        // `-z` keeps special-character paths literal (no C-style quoting); parse
1461        // from raw bytes so a non-UTF-8 conflicted path survives losslessly.
1462        self.core
1463            .parse_bytes(
1464                self.core
1465                    .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1466                parse::parse_nul_paths,
1467            )
1468            .await
1469    }
1470
1471    async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1472        // `symbolic-ref --quiet --short HEAD` is the one command that answers all
1473        // three head states correctly in a single spawn: it prints the branch name
1474        // (exit 0) for a normal **and an unborn** branch (a fresh `init`/`clone`
1475        // before the first commit — where `rev-parse --abbrev-ref HEAD` instead
1476        // *errors* with exit 128), and `--quiet` makes a detached HEAD a silent
1477        // exit 1 (HEAD isn't a symbolic ref) rather than a `fatal:`. So map exit
1478        // 0 → `Some(branch)`, exit 1 → `None` (detached), and anything else (e.g.
1479        // not a repository, exit 128) stays a real error.
1480        let res = self
1481            .core
1482            .output_string(
1483                self.core
1484                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1485            )
1486            .await?;
1487        match res.code() {
1488            Some(0) => Ok(Some(res.stdout().trim().to_string())),
1489            Some(1) => Ok(None), // detached HEAD: no named branch
1490            _ => {
1491                let _ = res.ensure_success()?;
1492                Ok(None) // unreachable: a non-zero exit always errors above
1493            }
1494        }
1495    }
1496
1497    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1498        // `--no-column` + `--no-color`: `column.ui = always` would columnate
1499        // several names onto one line and `color.{ui,branch} = always` would inject
1500        // ANSI escapes — both even when piped, corrupting the line parser and the
1501        // returned names.
1502        self.core
1503            .parse(
1504                self.core
1505                    .command_in(dir, ["branch", "--no-column", "--no-color"]),
1506                parse::parse_branches,
1507            )
1508            .await
1509    }
1510
1511    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>> {
1512        let n = format!("-n{max}");
1513        self.core
1514            .parse(
1515                self.core.command_in(
1516                    dir,
1517                    [
1518                        "log",
1519                        revspec.as_str(),
1520                        n.as_str(),
1521                        "-z",
1522                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1523                    ],
1524                ),
1525                parse::parse_log,
1526            )
1527            .await
1528    }
1529
1530    async fn log_paths(
1531        &self,
1532        dir: &Path,
1533        revspec: &RevSpec,
1534        max: usize,
1535        paths: &[String],
1536    ) -> Result<Vec<Commit>> {
1537        // An empty `paths` would build `git log <revspec> -n <max> -- ` — no
1538        // pathspecs after `--` is the same as no `--` at all, i.e. an
1539        // UNRESTRICTED log. That's the opposite of "scoped to these paths",
1540        // so refuse before spawning (mirrors `JjApi::commit_paths`'s
1541        // empty-fileset guard).
1542        if paths.is_empty() {
1543            return Err(Error::spawn(
1544                BINARY,
1545                std::io::Error::new(
1546                    std::io::ErrorKind::InvalidInput,
1547                    "log_paths requires at least one path — an empty list would log \
1548                     unrestricted history, not history scoped to the named paths",
1549                ),
1550            ));
1551        }
1552        // R-05: a single path this long can never be transmitted at all — `git
1553        // log` has no `--pathspec-from-file`/NUL-safe transport to fall back
1554        // to (unlike `add`/`commit_paths`, verified against real git: the flag
1555        // is rejected with "unrecognized argument"), so no chunking scheme can
1556        // help it. Reject up front, before `chunk_pathspecs` would otherwise
1557        // emit it as an over-budget singleton chunk that `git`/the OS would
1558        // then fail on anyway, with a much less legible spawn error.
1559        if let Some(oversized) = paths.iter().find(|p| p.len() + 1 > ARGV_PATHSPEC_BUDGET) {
1560            return Err(Error::spawn(
1561                BINARY,
1562                std::io::Error::new(
1563                    std::io::ErrorKind::InvalidInput,
1564                    format!(
1565                        "log_paths: a single path is {} bytes, exceeding the \
1566                         {ARGV_PATHSPEC_BUDGET}-byte argv pathspec budget on its own — \
1567                         `git log` has no NUL-safe pathspec-from-file transport (unlike \
1568                         `add`/`commit_paths`), so this path cannot be transmitted at all: \
1569                         {oversized:?}",
1570                        oversized.len() + 1,
1571                    ),
1572                ),
1573            ));
1574        }
1575        let n = format!("-n{max}");
1576        let chunks = chunk_pathspecs(paths);
1577        if chunks.len() <= 1 {
1578            // The common case: everything fits one call — byte-identical to the
1579            // pre-T-052 behavior (order included). A single invocation can't
1580            // observe a moving repository state mid-operation, so `revspec` is
1581            // forwarded as-is — no R-04 resolution needed here.
1582            let command = self.log_paths_command(
1583                dir,
1584                [revspec.as_str()],
1585                &n,
1586                paths.iter().map(String::as_str),
1587            );
1588            return self.core.parse(command, parse::parse_log).await;
1589        }
1590        // Large path set (T-052): `git log` has no `--pathspec-from-file`
1591        // support (unlike `add`/`commit_paths`), so split the pathspecs across
1592        // multiple argv-budget-sized calls and merge the results: dedup by hash
1593        // (a commit can touch paths spread across more than one chunk), then
1594        // reorder and cap at `max`. Requesting `-n max` per chunk is enough to
1595        // guarantee the merged top-`max` is correct: any commit within the true
1596        // (merged) top `max` has at most `max - 1` newer commits in the *entire*
1597        // union of chunk results, so it has at most that many newer commits
1598        // within its own chunk too — i.e. it always ranks within that chunk's
1599        // own top `max`. This bound is about *how many* qualifying commits can
1600        // precede another, not about how they are ordered, so it holds
1601        // regardless of the ordering mechanism below.
1602        //
1603        // R-04: this branch makes several independent `git` invocations (one
1604        // per chunk, plus the order oracle below), each of which would
1605        // otherwise re-resolve `revspec` on its own — a symbolic name like
1606        // `HEAD` can move (or a range's endpoints can) between any two of
1607        // them. Resolve it exactly once, up front, into the fixed set of
1608        // commit ids `git log` would internally expand it to (a plain rev
1609        // resolves to one id; a range like `A..B` resolves to two tokens, the
1610        // excluded side `^`-prefixed — `git rev-parse` performs the same
1611        // expansion `git log` does internally, so forwarding its output
1612        // verbatim is behavior-preserving for what a single, hypothetical
1613        // unchunked call would have seen). Every call below then reuses this
1614        // one fixed snapshot, so no ref movement during the operation can make
1615        // two of them disagree about what "`revspec`" names.
1616        let resolved_revspec: Vec<String> = self
1617            .core
1618            .run(self.core.command_in(dir, ["rev-parse", revspec.as_str()]))
1619            .await?
1620            .lines()
1621            .map(str::trim)
1622            .filter(|line| !line.is_empty())
1623            .map(str::to_string)
1624            .collect();
1625        let mut merged: Vec<Commit> = Vec::new();
1626        let mut seen = std::collections::HashSet::new();
1627        for chunk in &chunks {
1628            let command = self.log_paths_command(
1629                dir,
1630                resolved_revspec.iter().map(String::as_str),
1631                &n,
1632                chunk.iter().copied(),
1633            );
1634            let commits = self.core.parse(command, parse::parse_log).await?;
1635            for commit in commits {
1636                if seen.insert(commit.hash.clone()) {
1637                    merged.push(commit);
1638                }
1639            }
1640        }
1641        // Merging per-chunk call results loses git's own single-call order, so
1642        // restore it (R-03) via a hash-order oracle: one extra, pathless `git
1643        // log <revspec> --format=%H` call over the *same*, now-frozen revspec
1644        // (cheap — no per-path diff computation, just hashes) gives the exact
1645        // relative order a single unchunked call would have produced, since
1646        // pathspec filtering only drops non-matching commits without
1647        // reordering the ones that remain. A commit absent from the oracle
1648        // (should not happen — it always names a commit `log_paths` itself
1649        // just returned as reachable from `revspec`) sorts after every ranked
1650        // commit rather than panicking.
1651        let order_command = self.log_paths_order_command(dir, &resolved_revspec);
1652        let order = self.core.parse(order_command, parse_commit_order).await?;
1653        let rank: std::collections::HashMap<&str, usize> = order
1654            .iter()
1655            .enumerate()
1656            .map(|(index, hash)| (hash.as_str(), index))
1657            .collect();
1658        merged.sort_by_key(|commit| {
1659            rank.get(commit.hash.as_str())
1660                .copied()
1661                .unwrap_or(usize::MAX)
1662        });
1663        merged.truncate(max);
1664        Ok(merged)
1665    }
1666
1667    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1668        // `--verify`: without it, `git rev-parse Makefile` echoes the *filename* back
1669        // as a fake object id (exit 0), so a caller resolving an untrusted revision
1670        // could get a non-hash. `--verify` requires `rev` to name exactly one object,
1671        // erroring otherwise — a valid revision still resolves to the same full hash
1672        // (M13; matches `rev_parse_short`/`resolve_commit`, which already `--verify`).
1673        self.core
1674            .run(
1675                self.core
1676                    .command_in(dir, ["rev-parse", "--verify", rev.as_str()]),
1677            )
1678            .await
1679    }
1680
1681    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1682        // `--verify` (matching `rev_parse`/`resolve_commit`): require `rev` to name
1683        // exactly one object, erroring otherwise. Unlike bare `rev-parse` — which
1684        // echoes a filename back as a fake id (the M13 bug) — `--short` already
1685        // rejects a plain path (`Needed a single revision`), so this is
1686        // consistency / defense-in-depth: it pins the single-object contract
1687        // explicitly instead of leaning on `--short`'s incidental rejection. A real
1688        // revision still abbreviates the same.
1689        self.core
1690            .run(
1691                self.core
1692                    .command_in(dir, ["rev-parse", "--verify", "--short", rev.as_str()]),
1693            )
1694            .await
1695    }
1696
1697    async fn init(&self, dir: &Path) -> Result<()> {
1698        self.core
1699            .run_unit(self.core.command_in(dir, ["init"]))
1700            .await
1701    }
1702
1703    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1704        if pathspec_argv_len(paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1705            // Large path set (T-052): route over stdin instead of argv, so
1706            // there is no OS command-line limit left to exceed.
1707            // `--literal-pathspecs` matches each path byte-for-byte instead of
1708            // treating `*`/`?`/`[]` as pathspec glob magic.
1709            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1710                paths.iter().map(|p| p.as_os_str()),
1711            )?);
1712            let command = self
1713                .core
1714                .command_in(
1715                    dir,
1716                    [
1717                        "--literal-pathspecs",
1718                        "add",
1719                        "--pathspec-from-file=-",
1720                        "--pathspec-file-nul",
1721                    ],
1722                )
1723                .stdin(stdin);
1724            return self.core.run_unit(command).await;
1725        }
1726        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1727        // as the stdin branch above — without it, a path containing `*`/`?`/`[]`
1728        // would be read as pathspec glob magic instead of matched byte-for-byte
1729        // (R-01). `--` separates the pathspecs so a path can never be read as an
1730        // option.
1731        let mut command = self
1732            .core
1733            .command_in(dir, ["--literal-pathspecs", "add", "--"]);
1734        for path in paths {
1735            command = command.arg(path);
1736        }
1737        self.core.run_unit(command).await
1738    }
1739
1740    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1741        // C locale: a failure's output feeds `is_nothing_to_commit`.
1742        self.core
1743            .run_unit(c_locale(
1744                self.core.command_in(dir, ["commit", "-m", message]),
1745            ))
1746            .await
1747    }
1748
1749    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()> {
1750        self.core
1751            .run_unit(self.core.command_in(dir, ["branch", name.as_str()]))
1752            .await
1753    }
1754
1755    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
1756        // `target.as_arg()` is either a validated `RevSpec` or the fixed `-`
1757        // literal ([`CheckoutTarget::Previous`]) — never caller-controlled argv,
1758        // so no flag can be injected here. The trailing `--` marks the end of
1759        // revisions with no pathspecs following, so git resolves the target as a
1760        // ref *only*. Without it a target that doesn't name a ref but names a
1761        // tracked path silently falls into pathspec mode and restores that path
1762        // from the index, discarding unstaged edits (verified: `git checkout
1763        // notes.txt` → "Updated 1 path", exit 0; `git checkout notes.txt --` →
1764        // hard error).
1765        self.core
1766            .run_unit(
1767                self.core
1768                    .command_in(dir, ["checkout", target.as_arg(), "--"]),
1769            )
1770            .await
1771    }
1772
1773    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()> {
1774        self.core
1775            .run_unit(
1776                self.core
1777                    .command_in(dir, ["checkout", "--detach", commit.as_str()]),
1778            )
1779            .await
1780    }
1781
1782    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1783        // `--only -- <paths>` commits exactly these paths' working-tree content
1784        // regardless of the index; `--` keeps a path from being read as an option.
1785        // C locale: a failure's output feeds `is_nothing_to_commit`.
1786        if pathspec_argv_len(spec.paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1787            // Large path set (T-052): same NUL-safe stdin transport as `add`'s
1788            // twin branch. Still exactly one `git commit` invocation either
1789            // way — the atomic-commit contract never depends on the path
1790            // set's size, since no chunking is ever needed here.
1791            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1792                spec.paths.iter().map(|p| p.as_os_str()),
1793            )?);
1794            let mut command =
1795                c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1796            if spec.amend {
1797                command = command.arg("--amend");
1798            }
1799            command = command
1800                .arg("-m")
1801                .arg(spec.message)
1802                .arg("--only")
1803                .arg("--pathspec-from-file=-")
1804                .arg("--pathspec-file-nul")
1805                .stdin(stdin);
1806            return self.core.run_unit(command).await;
1807        }
1808        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1809        // as the stdin branch above — without it, a glob-magic character
1810        // (`*`/`?`/`[]`) in a path would be expanded as a pathspec pattern
1811        // instead of matched byte-for-byte, which could commit the wrong files
1812        // and violate `commit_paths`'s "exactly these paths" contract (R-01).
1813        let mut command = c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1814        if spec.amend {
1815            command = command.arg("--amend");
1816        }
1817        command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1818        for path in &spec.paths {
1819            command = command.arg(path);
1820        }
1821        self.core.run_unit(command).await
1822    }
1823
1824    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1825        self.core
1826            .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1827            .await
1828    }
1829
1830    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1831        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
1832        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
1833        // and surfaces anything else (e.g. 128, not a repo) as `Error::Exit`.
1834        Ok(!self
1835            .core
1836            .probe(
1837                self.core
1838                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1839            )
1840            .await?)
1841    }
1842
1843    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1844        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
1845        // `probe` errors on any other code / timeout / signal.
1846        self.core
1847            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1848            .await
1849    }
1850
1851    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1852        Ok(PathBuf::from(
1853            self.core
1854                .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1855                .await?,
1856        ))
1857    }
1858
1859    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1860        Ok(PathBuf::from(
1861            self.core
1862                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1863                .await?,
1864        ))
1865    }
1866
1867    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1868        // `^{commit}` peels an annotated tag down to the commit it points at.
1869        let spec = format!("{}^{{commit}}", rev.as_str());
1870        self.core
1871            .run(
1872                self.core
1873                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1874            )
1875            .await
1876    }
1877
1878    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1879        // `--quiet` makes an *unset* origin/HEAD a silent **exit 1** (no `fatal:`
1880        // on stderr); that's "no default branch", not an error. Map exit 0 → the
1881        // branch, exit 1 → `None`, and anything else (a real failure like "not a
1882        // repository" exit 128, or a timeout/signal with no exit code) surfaces via
1883        // `ensure_success` — mirroring `config_get`, rather than swallowing it.
1884        let res = self
1885            .core
1886            .output_string(
1887                self.core
1888                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1889            )
1890            .await?;
1891        match res.code() {
1892            Some(0) => {
1893                // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so
1894                // a slashed default branch (e.g. "release/v2") survives intact.
1895                let out = res.stdout().trim();
1896                Ok(Some(
1897                    out.strip_prefix("refs/remotes/origin/")
1898                        .unwrap_or(out)
1899                        .to_string(),
1900                ))
1901            }
1902            Some(1) => Ok(None), // unset origin/HEAD
1903            _ => {
1904                let _ = res.ensure_success()?;
1905                Ok(None) // unreachable: a non-zero/no-code exit always errors above
1906            }
1907        }
1908    }
1909
1910    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1911        let refname = format!("refs/heads/{}", name.as_str());
1912        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
1913        self.core
1914            .probe(
1915                self.core
1916                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1917            )
1918            .await
1919    }
1920
1921    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1922        // `RefName` already forbids the glob/control/`:` characters this probe
1923        // must exclude (its `check-ref-format` rules are a strict superset), so
1924        // the value is safe to interpolate into the `refs/heads/<name>` ref below.
1925        let name = name.as_str();
1926        // No credential prompt, bounded wait: a missing helper or a flaky network
1927        // must not hang the call. `output_string` reports a timeout as a flagged result
1928        // (non-zero exit) rather than erroring, so an unreachable remote reads as
1929        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
1930        // spawn failure (no `git`) still surfaces as an error.
1931        //
1932        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
1933        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
1934        // `refs/heads/<name>` matches only the exact branch.
1935        let refname = format!("refs/heads/{name}");
1936        let (pre, envs) = self.remote_credentials(None).await?;
1937        let mut args: Vec<String> = pre;
1938        args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1939        let cmd = apply_secret_env(
1940            self.core
1941                .command_in(dir, &args)
1942                .env("GIT_TERMINAL_PROMPT", "0")
1943                .timeout(Duration::from_secs(10)),
1944            &envs,
1945        );
1946        let res = self.core.output_string(cmd).await?;
1947        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1948    }
1949
1950    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1951        reject_flag_like("remote name", remote)?;
1952        self.core
1953            .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1954            .await
1955    }
1956
1957    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1958        // Validate that HEAD is attached before asking for `@{u}`. Git otherwise
1959        // uses exit 128 both for "no upstream" and for detached HEAD/not-a-repo,
1960        // so the upstream query alone cannot distinguish those states.
1961        let head = self
1962            .core
1963            .output_string(
1964                self.core
1965                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1966            )
1967            .await?;
1968        let _ = head.ensure_success()?;
1969
1970        // Once HEAD is known to be an attached branch, exit 128 is the documented
1971        // "no upstream configured" case. Every other failure, including a timeout
1972        // or signal (which has no exit code), remains a real error.
1973        let res = self
1974            .core
1975            .output_string(self.core.command_in(
1976                dir,
1977                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1978            ))
1979            .await?;
1980        match res.code() {
1981            Some(0) => {
1982                let name = res.stdout().trim();
1983                Ok((!name.is_empty()).then(|| name.to_string()))
1984            }
1985            Some(128) => Ok(None),
1986            _ => {
1987                let _ = res.ensure_success()?;
1988                Ok(None) // unreachable: every remaining outcome is unsuccessful
1989            }
1990        }
1991    }
1992
1993    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1994        reject_flag_like("remote name", remote)?;
1995        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
1996        // never block on an interactive auth prompt. A provider, if set, supplies
1997        // the credential via an inline helper (token kept out of argv).
1998        let (pre, envs) = self.remote_credentials(None).await?;
1999        let mut args: Vec<String> = pre;
2000        args.extend(["ls-remote", "--heads", remote].map(String::from));
2001        let cmd = apply_secret_env(
2002            self.core
2003                .command_in(dir, &args)
2004                .env("GIT_TERMINAL_PROMPT", "0"),
2005            &envs,
2006        );
2007        self.core.parse(cmd, parse::parse_ls_remote_heads).await
2008    }
2009
2010    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
2011        // `--no-column` + `--no-color`: under `column.ui = always` git would pack
2012        // several names per line and under `color.{ui,branch} = always` it would
2013        // inject ANSI escapes — both even when piped, so the marker-stripping
2014        // compare below would never match (a false "not merged").
2015        let out = self
2016            .core
2017            .run(self.core.command_in(
2018                dir,
2019                [
2020                    "branch",
2021                    "--merged",
2022                    spec.base.as_str(),
2023                    "--no-column",
2024                    "--no-color",
2025                ],
2026            ))
2027            .await?;
2028        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
2029        // drop exactly those two columns rather than trimming a char class (which
2030        // would over-strip a name that legitimately began with the marker char).
2031        Ok(out
2032            .lines()
2033            .filter_map(|line| line.get(2..))
2034            .any(|b| b == spec.branch.as_str()))
2035    }
2036
2037    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()> {
2038        let flag = format!("--set-upstream-to={}", upstream.as_str());
2039        self.core
2040            .run_unit(
2041                self.core
2042                    .command_in(dir, ["branch", flag.as_str(), branch.as_str()]),
2043            )
2044            .await
2045    }
2046
2047    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()> {
2048        let flag = if spec.force { "-D" } else { "-d" };
2049        self.core
2050            .run_unit(
2051                self.core
2052                    .command_in(dir, ["branch", flag, spec.name.as_str()]),
2053            )
2054            .await
2055    }
2056
2057    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()> {
2058        self.core
2059            .run_unit(
2060                self.core
2061                    .command_in(dir, ["branch", "-m", old.as_str(), new.as_str()]),
2062            )
2063            .await
2064    }
2065
2066    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize> {
2067        self.core
2068            .try_parse(
2069                self.core
2070                    .command_in(dir, ["rev-list", "--count", range.as_str()]),
2071                |s| {
2072                    s.trim()
2073                        .parse::<usize>()
2074                        .map_err(|e| Error::parse(BINARY, e.to_string()))
2075                },
2076            )
2077            .await
2078    }
2079
2080    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool> {
2081        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
2082        // The trailing `--` forces `range` to be read as a revision/range, not a
2083        // pathspec: without it `git diff --quiet Makefile` diffs the *working
2084        // tree* limited to that path (exit 1 = "has changes"), so a caller string
2085        // that names a file returns a plausible-but-wrong bool instead of erroring
2086        // (the C2/M13 pathspec-collision class). With `--`, an unresolvable
2087        // revision exits 128, which `probe` surfaces as an honest error.
2088        self.core
2089            .probe(
2090                self.core
2091                    .command_in(dir, ["diff", "--quiet", range.as_str(), "--"]),
2092            )
2093            .await
2094    }
2095
2096    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat> {
2097        // `LC_ALL=C`: git's `--shortstat` summary ("N file(s) changed, …") is
2098        // gettext-translated, but `parse_shortstat` keys on the English
2099        // "file"/"insertion"/"deletion" — without C locale a non-English git
2100        // returns an all-zero `DiffStat` rather than the real counts.
2101        // Trailing `--`: force `range` to resolve as a revision/range, never a
2102        // pathspec (see `diff_range_is_empty` — a path-named `range` would
2103        // otherwise stat the working tree for that path instead of erroring).
2104        self.core
2105            .parse(
2106                c_locale(
2107                    self.core
2108                        .command_in(dir, ["diff", "--shortstat", range.as_str(), "--"]),
2109                ),
2110                parse::parse_shortstat,
2111            )
2112            .await
2113    }
2114
2115    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
2116        self.diff_text_budgeted(dir, spec, self.core.output_budget())
2117            .await
2118    }
2119
2120    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
2121        let text = self.diff_text(dir, spec).await?;
2122        Ok(parse_diff(&text))
2123    }
2124
2125    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
2126        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
2127        self.core
2128            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
2129            .await
2130    }
2131
2132    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
2133        let git_dir = self.resolved_git_dir(dir).await?;
2134        // `rebase-merge/` is a merge-backend rebase. `rebase-apply/` is shared by an
2135        // apply-backend rebase AND `git am` — but `git am` marks it with an `applying`
2136        // file, so exclude that (it's an am, aborted with `am --abort`, not
2137        // `rebase --abort`; see `is_am_in_progress`). M20.
2138        let rebase_apply = git_dir.join("rebase-apply");
2139        let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
2140        Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
2141    }
2142
2143    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
2144        // `git am` uses `rebase-apply/` with an `applying` marker file (an
2145        // apply-backend rebase uses the same dir *without* it).
2146        Ok(self
2147            .resolved_git_dir(dir)
2148            .await?
2149            .join("rebase-apply")
2150            .join("applying")
2151            .exists())
2152    }
2153
2154    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
2155        Ok(self
2156            .resolved_git_dir(dir)
2157            .await?
2158            .join("MERGE_HEAD")
2159            .exists())
2160    }
2161
2162    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool> {
2163        Ok(self
2164            .resolved_git_dir(dir)
2165            .await?
2166            .join("CHERRY_PICK_HEAD")
2167            .exists())
2168    }
2169
2170    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool> {
2171        Ok(self
2172            .resolved_git_dir(dir)
2173            .await?
2174            .join("REVERT_HEAD")
2175            .exists())
2176    }
2177
2178    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool> {
2179        // `BISECT_LOG` is git's own canonical "a bisect is running" marker (it also
2180        // drives `git bisect log`); the other BISECT_* files are session details.
2181        Ok(self
2182            .resolved_git_dir(dir)
2183            .await?
2184            .join("BISECT_LOG")
2185            .exists())
2186    }
2187
2188    async fn fetch(&self, dir: &Path) -> Result<()> {
2189        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
2190        // rather than blocking on an interactive prompt — matching the other
2191        // remote ops (`fetch_branch`, `push`, `remote_branch_exists`).
2192        // Fetch is idempotent, so `retry` replays it on a transient failure
2193        // (DNS/timeout/dropped connection); a non-transient error fails at once.
2194        // C locale: the retry decision classifies the failure's message.
2195        // Leading `-c` credential.helper (+ secret env) when a provider is set.
2196        let (pre, envs) = self.remote_credentials(None).await?;
2197        let mut args: Vec<String> = pre;
2198        args.extend(["fetch", "--quiet"].map(String::from));
2199        // `budget_diagnostics`: bound the retained failure/progress output (a
2200        // drop-oldest tail — never `OutputTooLarge`, so `is_transient_fetch_error`
2201        // still classifies the tail-preserved message). Unbounded by default.
2202        let cmd = self.core.budget_diagnostics(apply_secret_env(
2203            c_locale(self.core.command_in(dir, &args))
2204                .env("GIT_TERMINAL_PROMPT", "0")
2205                // On a per-client timeout, terminate gracefully (then hard-kill
2206                // after a grace window) so a timed-out fetch closes cleanly.
2207                .timeout_grace(FETCH_TIMEOUT_GRACE)
2208                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2209            &envs,
2210        ));
2211        self.core.run_unit(cmd).await
2212    }
2213
2214    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
2215        // A leading-`-` remote is a bare positional here — and a flag like
2216        // `--upload-pack=<cmd>` would run an arbitrary local program for a
2217        // local/ext transport, so this guard is load-bearing for security.
2218        reject_flag_like("remote", remote)?;
2219        // Same containment as `fetch` (prompt off, C locale, transient retry,
2220        // optional credential helper), with the remote named explicitly.
2221        let (pre, envs) = self.remote_credentials(None).await?;
2222        let mut args: Vec<String> = pre;
2223        args.extend(["fetch", "--quiet", remote].map(String::from));
2224        let cmd = self.core.budget_diagnostics(apply_secret_env(
2225            c_locale(self.core.command_in(dir, &args))
2226                .env("GIT_TERMINAL_PROMPT", "0")
2227                .timeout_grace(FETCH_TIMEOUT_GRACE)
2228                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2229            &envs,
2230        ));
2231        self.core.run_unit(cmd).await
2232    }
2233
2234    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()> {
2235        // `RefName` already forbids the glob/control/`:` characters this refspec
2236        // must exclude (a strict superset), so both interpolations below are safe.
2237        let branch = branch.as_str();
2238        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
2239        let (pre, envs) = self.remote_credentials(None).await?;
2240        let mut args: Vec<String> = pre;
2241        args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
2242        let cmd = self.core.budget_diagnostics(apply_secret_env(
2243            c_locale(self.core.command_in(dir, &args))
2244                .env("GIT_TERMINAL_PROMPT", "0")
2245                .timeout_grace(FETCH_TIMEOUT_GRACE)
2246                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2247            &envs,
2248        ));
2249        self.core.run_unit(cmd).await
2250    }
2251
2252    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
2253        reject_flag_like("remote", &spec.remote)?;
2254        reject_flag_like("refspec", &spec.refspec)?;
2255        // M16: `reject_flag_like` catches a leading `-`/empty/NUL, but not the refspec
2256        // metacharacters that silently change what a push *does* — a leading `+`
2257        // (force-push, overwriting the remote non-fast-forward) or an extra `:` (push
2258        // to an unexpected remote ref). A valid refspec here is `branch` or
2259        // `local:remote_branch` (the single `:` is API-constructed by
2260        // `GitPush::refspec`), so allow at most one `:` and no leading `+` on either
2261        // side. A caller who genuinely needs a force-push must do it explicitly via
2262        // `run(["push", "--force", …])`, not smuggle a `+` through a branch name.
2263        let sides: Vec<&str> = spec.refspec.split(':').collect();
2264        if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
2265            return Err(processkit::Error::spawn(
2266                BINARY,
2267                std::io::Error::new(
2268                    std::io::ErrorKind::InvalidInput,
2269                    format!(
2270                        "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
2271                         metacharacter — pass a plain branch or `local:remote`, or use \
2272                         `run([\"push\", …])` for a force-push",
2273                        spec.refspec
2274                    ),
2275                ),
2276            ));
2277        }
2278        let (pre, envs) = self.remote_credentials(None).await?;
2279        let mut args: Vec<String> = pre;
2280        args.push("push".to_string());
2281        if spec.set_upstream {
2282            args.push("-u".to_string());
2283        }
2284        args.push(spec.remote.clone());
2285        args.push(spec.refspec.clone());
2286        let cmd = apply_secret_env(
2287            self.core
2288                .command_in(dir, &args)
2289                .env("GIT_TERMINAL_PROMPT", "0")
2290                // On a per-client timeout, terminate gracefully (then hard-kill
2291                // after a grace window) so a timed-out push releases its lock and
2292                // doesn't leave the remote ref half-updated. No-op without a
2293                // deadline (matches `fetch`).
2294                .timeout_grace(FETCH_TIMEOUT_GRACE),
2295            &envs,
2296        );
2297        self.core.run_unit(cmd).await
2298    }
2299
2300    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()> {
2301        // C locale: a conflict's output feeds `is_merge_conflict` (same reason as
2302        // `merge_commit`/`merge_no_commit`). `--squash` never commits, so no editor.
2303        self.core
2304            .run_unit(c_locale(
2305                self.core
2306                    .command_in(dir, ["merge", "--squash", branch.as_str()]),
2307            ))
2308            .await
2309    }
2310
2311    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
2312        let mut args: Vec<&str> = vec!["merge"];
2313        if spec.no_ff {
2314            args.push("--no-ff");
2315        }
2316        if let Some(msg) = spec.message.as_deref() {
2317            args.push("-m");
2318            args.push(msg);
2319        } else {
2320            // No message → take the default merge message non-interactively
2321            // instead of opening `$EDITOR` (which would hang a headless caller).
2322            args.push("--no-edit");
2323        }
2324        args.push(spec.branch.as_str());
2325        // C locale: a conflict's output feeds `is_merge_conflict`.
2326        self.core
2327            .run_unit(c_locale(self.core.command_in(dir, args)))
2328            .await
2329    }
2330
2331    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
2332        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
2333        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
2334        // a squash never fast-forwards anyway, so it takes precedence.
2335        if spec.squash {
2336            args.push("--squash");
2337        } else if spec.no_ff {
2338            args.push("--no-ff");
2339        }
2340        args.push(spec.branch.as_str());
2341        // C locale: a conflict's output feeds `is_merge_conflict`.
2342        self.core
2343            .run_unit(c_locale(self.core.command_in(dir, args)))
2344            .await
2345    }
2346
2347    async fn merge_abort(&self, dir: &Path) -> Result<()> {
2348        self.core
2349            .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
2350            .await
2351    }
2352
2353    async fn merge_continue(&self, dir: &Path) -> Result<()> {
2354        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
2355        // headless backstop so a commit hook re-opening the editor can't hang.
2356        // C locale: the failure output feeds the classifiers (a still-conflicted
2357        // tree reports "nothing to commit"-adjacent / conflict messages).
2358        self.core
2359            .run_unit(no_editor(c_locale(
2360                self.core.command_in(dir, ["commit", "--no-edit"]),
2361            )))
2362            .await
2363    }
2364
2365    async fn reset_merge(&self, dir: &Path) -> Result<()> {
2366        self.core
2367            .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
2368            .await
2369    }
2370
2371    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2372        self.core
2373            .run_unit(self.core.command_in(dir, ["reset", "--hard", rev.as_str()]))
2374            .await
2375    }
2376
2377    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()> {
2378        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
2379        // the message-confirm on `--continue`) never hangs a headless caller.
2380        // C locale: a conflict's output feeds `is_merge_conflict`.
2381        self.core
2382            .run_unit(no_editor(c_locale(
2383                self.core.command_in(dir, ["rebase", onto.as_str()]),
2384            )))
2385            .await
2386    }
2387
2388    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
2389        self.core
2390            .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
2391            .await
2392    }
2393
2394    async fn am_abort(&self, dir: &Path) -> Result<()> {
2395        self.core
2396            .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
2397            .await
2398    }
2399
2400    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
2401        self.core
2402            .run_unit(no_editor(c_locale(
2403                self.core.command_in(dir, ["rebase", "--continue"]),
2404            )))
2405            .await
2406    }
2407
2408    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()> {
2409        let mut command = self.core.command_in(dir, ["stash", "push"]);
2410        if spec.include_untracked {
2411            command = command.arg("--include-untracked");
2412        }
2413        self.core.run_unit(command).await
2414    }
2415
2416    async fn stash_pop(&self, dir: &Path) -> Result<()> {
2417        // C locale: a conflicting `stash pop` emits git's merge-machinery
2418        // `CONFLICT (...)` output, which feeds `is_merge_conflict` (e.g. via
2419        // `switch_with_stash`) — a translated message would defeat it.
2420        self.core
2421            .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
2422            .await
2423    }
2424
2425    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
2426        // `parse_bytes`: the porcelain `worktree <path>` value is a filesystem path
2427        // that need not be valid UTF-8 on Unix, so parse from raw stdout bytes — a
2428        // lossy `String` decode would corrupt a non-UTF-8 worktree name to `U+FFFD`
2429        // and leak a wrong path into the facade's `WorktreeInfo.path`. (Deliberately
2430        // no `-z`: `worktree list --porcelain -z` is git ≥ 2.36, above this crate's
2431        // 2.31 support floor; newline framing already covers the non-UTF-8 case.)
2432        self.core
2433            .parse_bytes(
2434                self.core
2435                    .command_in(dir, ["worktree", "list", "--porcelain"]),
2436                parse::parse_worktree_porcelain,
2437            )
2438            .await
2439    }
2440
2441    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
2442        let mut command = self.core.command_in(dir, ["worktree", "add"]);
2443        if let Some(name) = spec.new_branch.as_ref() {
2444            command = command.arg("-b").arg(name.as_str());
2445        }
2446        if spec.no_checkout {
2447            command = command.arg("--no-checkout");
2448        }
2449        command = command.arg(&spec.path);
2450        if let Some(commitish) = spec.commitish.as_ref() {
2451            command = command.arg(commitish.as_str());
2452        }
2453        self.core.run_unit(command).await
2454    }
2455
2456    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()> {
2457        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
2458        if spec.force {
2459            command = command.arg("--force");
2460        }
2461        command = command.arg(&spec.path);
2462        self.core.run_unit(command).await
2463    }
2464
2465    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
2466        let command = self
2467            .core
2468            .command_in(dir, ["worktree", "move"])
2469            .arg(from)
2470            .arg(to);
2471        self.core.run_unit(command).await
2472    }
2473
2474    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
2475        self.core
2476            .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
2477            .await
2478    }
2479
2480    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
2481        // A leading-`-` url is a bare positional — `git clone --upload-pack=<cmd>`
2482        // would run an arbitrary local program. A real URL never leads with `-`,
2483        // so this guard has no false positives.
2484        reject_flag_like("url", url)?;
2485        // No working directory: clone creates `dest` itself, so `dest` should
2486        // be absolute (a relative path would resolve against this process' cwd).
2487        // Leading `-c` credential.helper (+ secret env) when a provider is set,
2488        // scoped to the clone URL's host so a cross-host redirect/submodule during
2489        // the clone can't extract the token (the URL is often externally supplied).
2490        let (pre, envs) = self
2491            .remote_credentials(vcs_cli_support::https_host(url).as_deref())
2492            .await?;
2493        let mut initial: Vec<String> = pre;
2494        initial.push("clone".to_string());
2495        let mut command = self.core.command(&initial);
2496        if let Some(branch) = spec.branch.as_deref() {
2497            command = command.arg("--branch").arg(branch);
2498        }
2499        if let Some(depth) = spec.depth {
2500            command = command.arg("--depth").arg(depth.to_string());
2501        }
2502        if spec.bare {
2503            command = command.arg("--bare");
2504        }
2505        // `budget_diagnostics`: bound the retained clone progress/failure output
2506        // (a drop-oldest tail — never `OutputTooLarge`, so a real failure stays a
2507        // classifiable `Error::Exit`). Unbounded by default.
2508        let command = self.core.budget_diagnostics(apply_secret_env(
2509            command
2510                .arg(url)
2511                .arg(dest)
2512                .env("GIT_TERMINAL_PROMPT", "0")
2513                // On a per-client timeout, terminate gracefully (then hard-kill after
2514                // a grace window). No-op without a deadline (matches `fetch`).
2515                .timeout_grace(FETCH_TIMEOUT_GRACE),
2516            &envs,
2517        ));
2518
2519        // R7: git populates `dest` incrementally, so a failed clone (timeout, network,
2520        // auth) can leave a **partial, non-empty** `dest` that blocks a retry with
2521        // "destination path already exists and is not empty". `timeout_grace` alone
2522        // can't prevent it — Windows' job-kill is atomic (no graceful tier) and the
2523        // Unix grace is too short to delete a multi-GB partial. So clean it ourselves.
2524        //
2525        // Only clean a `dest` we could have *created*: absent, or an empty directory.
2526        // git refuses to clone into a **non-empty** existing dir, so a non-empty `dest`
2527        // means the failure was that refusal and the caller's data is untouched — never
2528        // delete that. (A best-effort blocking remove on the error path; a partial clone
2529        // may be large, but this path is rare.)
2530        let cleanable = match std::fs::read_dir(dest) {
2531            Err(_) => true,                              // absent/unreadable → clone creates it
2532            Ok(mut entries) => entries.next().is_none(), // an empty directory
2533        };
2534        let result = self.core.run_unit(command).await;
2535        if result.is_err() && cleanable {
2536            let _ = std::fs::remove_dir_all(dest);
2537        }
2538        result
2539    }
2540
2541    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()> {
2542        let mut args = vec!["tag", name.as_str()];
2543        if let Some(rev) = rev.as_ref() {
2544            args.push(rev.as_str());
2545        }
2546        self.core.run_unit(self.core.command_in(dir, args)).await
2547    }
2548
2549    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
2550        let mut args = vec!["tag", "-a", spec.name.as_str(), "-m", &spec.message];
2551        if let Some(rev) = spec.rev.as_ref() {
2552            args.push(rev.as_str());
2553        }
2554        self.core.run_unit(self.core.command_in(dir, args)).await
2555    }
2556
2557    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
2558        // `--no-column`: a user's `column.ui = always` would pack several tags
2559        // onto one line even when piped, corrupting the one-per-line split.
2560        let out = self
2561            .core
2562            .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
2563            .await?;
2564        Ok(out.lines().map(str::to_string).collect())
2565    }
2566
2567    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()> {
2568        self.core
2569            .run_unit(self.core.command_in(dir, ["tag", "-d", name.as_str()]))
2570            .await
2571    }
2572
2573    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String> {
2574        self.show_file_within(dir, rev, path, self.core.output_budget())
2575            .await
2576    }
2577
2578    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
2579        reject_flag_like("config key", key)?;
2580        let res = self
2581            .core
2582            .output_string(self.core.command_in(dir, ["config", "--get", key]))
2583            .await?;
2584        match res.code() {
2585            // Exit 1 = unset (git lumps "no such key/section" in here too).
2586            Some(1) => Ok(None),
2587            // Strip only git's trailing line terminator (`\n`, or `\r\n`), not all
2588            // trailing whitespace: a config value can legitimately end in spaces or
2589            // a tab (e.g. a templated prefix), and `--get` returns a single line, so
2590            // it never itself ends in a newline.
2591            Some(0) => Ok(Some(
2592                res.stdout().trim_end_matches(['\r', '\n']).to_string(),
2593            )),
2594            _ => {
2595                let _ = res.ensure_success()?;
2596                Ok(None) // unreachable: a non-zero exit always errors above.
2597            }
2598        }
2599    }
2600
2601    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
2602        reject_flag_like("config key", key)?;
2603        self.core
2604            .run_unit(self.core.command_in(dir, ["config", key, value]))
2605            .await
2606    }
2607
2608    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2609        reject_flag_like("remote name", name)?;
2610        reject_flag_like("url", url)?;
2611        self.core
2612            .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2613            .await
2614    }
2615
2616    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2617        reject_flag_like("remote name", name)?;
2618        reject_flag_like("url", url)?;
2619        self.core
2620            .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2621            .await
2622    }
2623
2624    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>> {
2625        let mut args = vec!["blame", "--line-porcelain"];
2626        if let Some(rev) = rev.as_ref() {
2627            args.push(rev.as_str());
2628        }
2629        args.push("--");
2630        args.push(path);
2631        self.core
2632            .parse(
2633                self.core.command_in(dir, args),
2634                parse::parse_blame_porcelain,
2635            )
2636            .await
2637    }
2638
2639    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2640        // No editor opens non-interactively, but keep the headless backstop.
2641        // C locale: a conflict's output feeds `is_merge_conflict`.
2642        self.core
2643            .run_unit(no_editor(c_locale(
2644                self.core.command_in(dir, ["cherry-pick", rev.as_str()]),
2645            )))
2646            .await
2647    }
2648
2649    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2650        self.core
2651            .run_unit(no_editor(c_locale(
2652                self.core
2653                    .command_in(dir, ["revert", "--no-edit", rev.as_str()]),
2654            )))
2655            .await
2656    }
2657
2658    async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2659        self.core
2660            .run_unit(no_editor(c_locale(
2661                self.core.command_in(dir, ["rebase", "--skip"]),
2662            )))
2663            .await
2664    }
2665
2666    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()> {
2667        // No editor on --abort, but keep the C locale so any failure output still
2668        // feeds the classifiers uniformly with the rest of the sequencer.
2669        self.core
2670            .run_unit(c_locale(
2671                self.core.command_in(dir, ["cherry-pick", "--abort"]),
2672            ))
2673            .await
2674    }
2675
2676    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()> {
2677        // `--continue` re-commits the resolved pick and may open the editor to
2678        // confirm the message — force a no-op editor so a headless caller can't
2679        // hang. C locale: a re-conflict's output feeds `is_merge_conflict`.
2680        self.core
2681            .run_unit(no_editor(c_locale(
2682                self.core.command_in(dir, ["cherry-pick", "--continue"]),
2683            )))
2684            .await
2685    }
2686
2687    async fn revert_abort(&self, dir: &Path) -> Result<()> {
2688        self.core
2689            .run_unit(c_locale(self.core.command_in(dir, ["revert", "--abort"])))
2690            .await
2691    }
2692
2693    async fn revert_continue(&self, dir: &Path) -> Result<()> {
2694        self.core
2695            .run_unit(no_editor(c_locale(
2696                self.core.command_in(dir, ["revert", "--continue"]),
2697            )))
2698            .await
2699    }
2700
2701    async fn bisect_reset(&self, dir: &Path) -> Result<()> {
2702        self.core
2703            .run_unit(c_locale(self.core.command_in(dir, ["bisect", "reset"])))
2704            .await
2705    }
2706}
2707
2708impl<R: ProcessRunner> Git<R> {
2709    /// Build one `git --literal-pathspecs log <revs...> -n<max> -z --format=…
2710    /// -- <paths>` call — used both for the common case (everything fits one
2711    /// invocation, the direct [`GitApi::log_paths`] call, where `revs` is the
2712    /// single, as-given `revspec.as_str()`) and for each chunk of its
2713    /// large-path-set fallback (T-052; the two need no different format,
2714    /// since chunked order is restored afterwards by
2715    /// [`Self::log_paths_order_command`], not by anything embedded in each
2716    /// chunk's own output — R-03). On the chunked path, `revs` is instead the
2717    /// caller's already-resolved, fixed commit-id tokens (T-052/R-04; see
2718    /// [`GitApi::log_paths`]) — one for a plain rev, two (tip + `^`-prefixed
2719    /// exclusion) for a range — reused verbatim across every chunk so none of
2720    /// them can observe a differently-moved ref than another.
2721    /// `--literal-pathspecs` matches a path containing `*`/`?`/`[]` literally
2722    /// rather than as pathspec glob magic (R-02).
2723    fn log_paths_command<'a>(
2724        &self,
2725        dir: &Path,
2726        revs: impl IntoIterator<Item = &'a str>,
2727        n: &str,
2728        paths: impl IntoIterator<Item = &'a str>,
2729    ) -> Command {
2730        let mut command = self.core.command_in(dir, ["--literal-pathspecs", "log"]);
2731        for rev in revs {
2732            command = command.arg(rev);
2733        }
2734        command = command
2735            .arg(n)
2736            .arg("-z")
2737            .arg("--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s")
2738            .arg("--");
2739        for path in paths {
2740            command = command.arg(path);
2741        }
2742        command
2743    }
2744
2745    /// Build the pathless `git log <revs...> -z --format=%H` commit-order
2746    /// oracle used to restore git's own order across `log_paths`'s merged
2747    /// chunk results (T-052/R-03; see [`GitApi::log_paths`]). `revs` is the
2748    /// same already-resolved, fixed commit-id tokens the chunk calls used
2749    /// (T-052/R-04) — resolving the revspec independently here, after the
2750    /// chunk calls already ran, would reopen exactly the race that resolving
2751    /// it once up front closes. No `-n` cap: a commit surviving
2752    /// path-filtering into the merged top-`max` can sit arbitrarily far back
2753    /// in the *unrestricted* history (many untouched commits between it and
2754    /// the tip), so the oracle must be able to rank it — capping this call
2755    /// risks an unranked commit outside the map. No paths, so no
2756    /// `--literal-pathspecs` is needed here. Parsed by [`parse_commit_order`].
2757    fn log_paths_order_command(&self, dir: &Path, revs: &[String]) -> Command {
2758        let mut command = self.core.command_in(dir, ["log"]);
2759        for rev in revs {
2760            command = command.arg(rev);
2761        }
2762        command.arg("-z").arg("--format=%H")
2763    }
2764}
2765
2766// --- Internal helpers --------------------------------------------------------
2767//
2768// The error classifiers (`is_merge_conflict`/`is_nothing_to_commit`/
2769// `is_transient_fetch_error`), the fetch-retry policy, and the argv injection
2770// guard now live in the shared `vcs-cli-support` crate (re-exported at the top of
2771// this module); what remains here is git-specific.
2772
2773/// Git's well-known **SHA-1** empty-tree object id. This value exists **only in a
2774/// SHA-1 repository**: a repo created with `extensions.objectFormat=sha256` has a
2775/// different empty-tree id (and this SHA-1 one resolves to no object there), so
2776/// this constant is *not* a universal stand-in for `HEAD` when diffing an unborn
2777/// working tree. For the id that matches a repository's active object format, use
2778/// [`Git::empty_tree_oid`], which asks git for it — that is what
2779/// [`diff_text`](GitApi::diff_text)`(DiffSpec::WorkingTree)` uses on an unborn repo.
2780pub const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2781
2782/// Total attempts / fixed backoff for a transient-retried `fetch` — the shared
2783/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
2784const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2785const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2786const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2787
2788/// Point git's editor at a no-op so any command that would open `$EDITOR`
2789/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
2790/// non-interactively instead of hanging a headless caller.
2791fn no_editor(cmd: processkit::Command) -> processkit::Command {
2792    cmd.env("GIT_EDITOR", "true")
2793        .env("GIT_SEQUENCE_EDITOR", "true")
2794}
2795
2796/// Force the C locale on a command whose output feeds the error classifiers
2797/// (`is_merge_conflict`, `is_nothing_to_commit`, `is_transient_fetch_error`):
2798/// they match untranslated English substrings, and a localized git would emit
2799/// translated messages, silently turning a classified failure (conflict /
2800/// clean-tree / transient) into an unclassified one.
2801fn c_locale(cmd: processkit::Command) -> processkit::Command {
2802    cmd.env("LC_ALL", "C")
2803}
2804
2805/// Injection guard for bare positional argv slots — delegates to the shared
2806/// [`vcs_cli_support::reject_flag_like`], naming this crate's binary so the
2807/// ~45 call sites stay `reject_flag_like(what, value)`.
2808fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2809    vcs_cli_support::reject_flag_like(BINARY, what, value)
2810}
2811
2812// --- Large path-set transport (T-052) ----------------------------------------
2813//
2814// `add`/`commit_paths` build one `git` argv per call whether their path set has
2815// three entries or three hundred thousand. Windows' `CreateProcess` rejects a
2816// command line longer than roughly 32,767 UTF-16 code units (`OS error 206`);
2817// POSIX's `ARG_MAX` is typically far larger but shared with the environment
2818// block. [`ARGV_PATHSPEC_BUDGET`] is a conservative byte budget for the *paths*
2819// portion of such a call (not counting the program name, subcommand, or
2820// flags — negligible next to this), chosen with a wide margin under the
2821// tighter Windows ceiling so an ordinary call (a handful of paths) never
2822// crosses it. Crossing it switches `add`/`commit_paths` to the NUL-safe
2823// `--pathspec-from-file=- --pathspec-file-nul` transport ([`pathspec_nul_bytes`])
2824// — unbounded, since the paths then never touch argv at all — and switches
2825// `log_paths` (for which git has no `--pathspec-from-file` support) to chunked
2826// invocations ([`chunk_pathspecs`]) merged back into one result.
2827
2828/// Conservative byte budget for the *pathspec* portion of a `git` argv — see
2829/// the module-level comment above this constant for the reasoning.
2830const ARGV_PATHSPEC_BUDGET: usize = 6_000;
2831
2832/// Sum of each path's encoded byte length plus one (a stand-in for the
2833/// separating argv-slot overhead), compared against [`ARGV_PATHSPEC_BUDGET`]
2834/// to decide whether a path set is too large for one plain-argv invocation.
2835fn pathspec_argv_len<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> usize {
2836    paths
2837        .into_iter()
2838        .map(|p| p.as_encoded_bytes().len() + 1)
2839        .sum()
2840}
2841
2842/// Build the NUL-delimited pathspec payload for `--pathspec-from-file=-
2843/// --pathspec-file-nul` from `paths`, entirely in memory before anything is
2844/// spawned. A path embedding a NUL byte — impossible on a real filesystem, but
2845/// checked anyway as defense-in-depth — would silently split into two
2846/// pathspecs on that separator, one of them possibly matching an unintended
2847/// file; refusing it here, before the command is built, keeps input
2848/// preparation atomic: either every path is valid and the one `git` invocation
2849/// runs, or none of it does (no partially-applied result to unwind). Returns
2850/// the raw bytes (rather than a [`processkit::Stdin`] directly) so this pure
2851/// step stays unit-testable — wrap the result in
2852/// [`processkit::Stdin::from_bytes`] at the call site.
2853fn pathspec_nul_bytes<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> Result<Vec<u8>> {
2854    let mut buf = Vec::new();
2855    for path in paths {
2856        let bytes = path.as_encoded_bytes();
2857        if bytes.contains(&0) {
2858            return Err(Error::spawn(
2859                BINARY,
2860                std::io::Error::new(
2861                    std::io::ErrorKind::InvalidInput,
2862                    "path contains an embedded NUL byte, which the \
2863                     --pathspec-file-nul transport uses as its separator — \
2864                     refusing before spawning rather than silently splitting \
2865                     it into two pathspecs",
2866                ),
2867            ));
2868        }
2869        buf.extend_from_slice(bytes);
2870        buf.push(0);
2871    }
2872    Ok(buf)
2873}
2874
2875/// Split `paths` into groups whose combined length (each entry's byte length
2876/// plus one, matching [`pathspec_argv_len`]) stays within
2877/// [`ARGV_PATHSPEC_BUDGET`] — every group gets at least one path (a single path
2878/// already over budget still gets its own singleton group; nothing shorter is
2879/// possible). Preserves `paths`' order, both within and across groups.
2880fn chunk_pathspecs(paths: &[String]) -> Vec<Vec<&str>> {
2881    let mut chunks: Vec<Vec<&str>> = Vec::new();
2882    let mut current: Vec<&str> = Vec::new();
2883    let mut current_len = 0usize;
2884    for path in paths {
2885        let len = path.len() + 1;
2886        if !current.is_empty() && current_len + len > ARGV_PATHSPEC_BUDGET {
2887            chunks.push(std::mem::take(&mut current));
2888            current_len = 0;
2889        }
2890        current.push(path.as_str());
2891        current_len += len;
2892    }
2893    if !current.is_empty() {
2894        chunks.push(current);
2895    }
2896    chunks
2897}
2898
2899/// Parse [`Git::log_paths_order_command`]'s `git log -z --format=%H` output
2900/// into an ordered list of hashes — git's own commit order for the queried
2901/// revspec, used as the ranking oracle that restores order across
2902/// `log_paths`'s merged chunk results (T-052/R-03; see [`GitApi::log_paths`]).
2903fn parse_commit_order(output: &str) -> Vec<String> {
2904    output
2905        .split('\0')
2906        .filter(|rec| !rec.is_empty())
2907        .map(str::to_string)
2908        .collect()
2909}
2910
2911impl<R: ProcessRunner> Git<R> {
2912    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
2913    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
2914    /// trait), so it can take `&[&str]`; forwards to the same path as
2915    /// [`GitApi::run`].
2916    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2917        self.core.run(args).await
2918    }
2919
2920    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
2921    /// (mirrors [`GitApi::run_raw`]).
2922    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2923        self.core.output_string(args).await
2924    }
2925
2926    /// Run `git <args>` **in `dir`** (the process is spawned with `dir` as its
2927    /// working directory), returning trimmed stdout — the dir-bound twin of the
2928    /// process-cwd [`run`](GitApi::run). This is what [`GitAt::run`] forwards to;
2929    /// call [`run`](GitApi::run) on the client for the process-cwd escape hatch.
2930    /// Argv is forwarded verbatim (the same unguarded escape hatch — only the
2931    /// working directory is bound, no `-C`/extra flag is injected).
2932    pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
2933        self.core.run(self.core.command_in(dir, args)).await
2934    }
2935
2936    /// Like [`run_in`](Git::run_in) but never errors on a non-zero exit — the
2937    /// dir-bound twin of [`run_raw`](GitApi::run_raw). What [`GitAt::run_raw`]
2938    /// forwards to.
2939    pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
2940        self.core
2941            .output_string(self.core.command_in(dir, args))
2942            .await
2943    }
2944
2945    /// Like [`run_args`](Git::run_args) but **bound to `dir`** — the `&[&str]` twin
2946    /// of [`run_in`](Git::run_in). What [`GitAt::run_args`] forwards to.
2947    pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
2948        self.core.run(self.core.command_in(dir, args)).await
2949    }
2950
2951    /// Like [`run_raw_args`](Git::run_raw_args) but **bound to `dir`** — the
2952    /// `&[&str]` twin of [`run_raw_in`](Git::run_raw_in). What
2953    /// [`GitAt::run_raw_args`] forwards to.
2954    pub async fn run_raw_args_in(
2955        &self,
2956        dir: &Path,
2957        args: &[&str],
2958    ) -> Result<ProcessResult<String>> {
2959        self.core
2960            .output_string(self.core.command_in(dir, args))
2961            .await
2962    }
2963
2964    /// The empty-tree object id for the repository at `dir`, matching its **active
2965    /// object format** — the format-correct stand-in for `HEAD` when diffing/stat-ing
2966    /// the working tree of an unborn (no-commits-yet) repository.
2967    ///
2968    /// Computed with `git hash-object -t tree --stdin` fed an empty stdin: an empty
2969    /// tree object is empty content, so git returns its id under whichever hash the
2970    /// repo uses (`4b825dc…` for SHA-1, a 64-hex digest for `extensions.objectFormat=
2971    /// sha256`). This asks git rather than hard-coding [`EMPTY_TREE_SHA1`], which is
2972    /// wrong in a SHA-256 repo. `--stdin` (not `-w`) only *computes* the id — nothing
2973    /// is written to the object database.
2974    pub async fn empty_tree_oid(&self, dir: &Path) -> Result<String> {
2975        self.core
2976            .run(
2977                self.core
2978                    .command_in(dir, ["hash-object", "-t", "tree", "--stdin"])
2979                    .stdin(processkit::Stdin::empty()),
2980            )
2981            .await
2982    }
2983
2984    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
2985    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
2986    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
2987    /// driving many directories (e.g. linked worktrees) from one client.
2988    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2989        GitAt { git: self, dir }
2990    }
2991
2992    /// Harden this client for driving repositories it didn't create: running
2993    /// `git` inside an untrusted checkout executes that repository's hooks and
2994    /// honours its config — arbitrary code execution by default. The profile
2995    /// (applied to **every** command this client runs):
2996    ///
2997    /// **⚠ Requires git ≥ 2.31.** The hook / `fsmonitor` / `sshCommand` pins ride
2998    /// git's env-based config (`GIT_CONFIG_COUNT`), which older git **silently
2999    /// ignores** — so on git < 2.31 `harden()` still scrubs the environment and
3000    /// turns prompts off, but repo-local hooks/fsmonitor/sshCommand are **not**
3001    /// disabled (no error is raised). [`capabilities().ensure_supported()`](GitCapabilities::ensure_supported)
3002    /// now enforces the **≥ 2.31 floor** (major.minor), so a too-old git is rejected
3003    /// up front with a clear message instead of silently no-op-ing the pins — call it
3004    /// before relying on `harden()` against a fully untrusted repo on a host you don't
3005    /// control, or add an OS-level sandbox. (`docs/audit-2026-07.md` H3, M29.)
3006    ///
3007    /// - **Disables hooks** — `core.hooksPath=/dev/null` pinned via git's
3008    ///   env-based config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, git ≥ 2.31;
3009    ///   verified to suppress hooks on Windows too) — and `core.fsmonitor`
3010    ///   (a config-driven daemon launch). Env-config overrides even the
3011    ///   *repo-local* `.git/config` for the keys it names, so these pins beat a
3012    ///   poisoned `.git/config`.
3013    /// - **Neutralizes `core.sshCommand`** (pinned empty) — the config-key twin of
3014    ///   the scrubbed `GIT_SSH_COMMAND`, an arbitrary program git would run for the
3015    ///   SSH transport. Empty is falsy to git, so the default `ssh` (ambient
3016    ///   `~/.ssh/config`/agent) still works; only the repo's override is dropped.
3017    /// - **Removes inherited repo redirectors** so a poisoned parent
3018    ///   environment can't point commands at another repository: `GIT_DIR`,
3019    ///   `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`,
3020    ///   `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`,
3021    ///   `GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
3022    ///   `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`. (The first seven are also
3023    ///   scrubbed by *every* client — see the type-level doc — not just here.)
3024    /// - **Removes inherited command hooks** that make git spawn an arbitrary
3025    ///   program from the *environment* (a second code-execution path besides
3026    ///   repo hooks): `GIT_SSH_COMMAND`/`GIT_SSH` (transport), `GIT_ASKPASS`
3027    ///   (credential prompt), `GIT_EXTERNAL_DIFF` (diff driver), `GIT_PAGER`,
3028    ///   `GIT_EDITOR`/`GIT_SEQUENCE_EDITOR`, `GIT_PROXY_COMMAND` (a program for a
3029    ///   `git://` connection), `GIT_EXEC_PATH` (relocates git's own sub-commands),
3030    ///   and `GIT_TEMPLATE_DIR` (seeds hooks/config on `init`/`clone`). It also drops
3031    ///   the pathspec-mode vars (`GIT_LITERAL_PATHSPECS` / `GIT_GLOB_PATHSPECS` /
3032    ///   `GIT_NOGLOB_PATHSPECS` / `GIT_ICASE_PATHSPECS`), which silently change which
3033    ///   paths a command matches. The library's own auth seam
3034    ///   ([`with_credentials`](Git::with_credentials)) injects credentials via a
3035    ///   git `credential.helper` / token env, **not** these variables, so it keeps
3036    ///   working through a hardened client; an operator who deliberately relies on
3037    ///   an ambient `GIT_SSH_COMMAND`/`GIT_ASKPASS` should inject it per-call
3038    ///   instead of inheriting it into an untrusted-repo run.
3039    /// - **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal
3040    ///   prompts off everywhere (`GIT_TERMINAL_PROMPT=0`).
3041    ///
3042    /// **Residual repo-local-config vectors (NOT neutralized).** `harden()` closes
3043    /// the *hooks*, `fsmonitor`, `core.sshCommand`, and the env redirector/command-
3044    /// hook paths — but a few **repo-local `.git/config` / `.gitattributes`** keys
3045    /// still run an arbitrary program and are not pinned: `filter.<drv>.clean`/
3046    /// `smudge` (run on any working-tree materialization — `checkout`, `stash pop`,
3047    /// `worktree add`), and `diff.<drv>.textconv` / `diff.external` (run when a diff
3048    /// is produced; [`diff_text`](GitApi::diff_text) defends itself with
3049    /// `--no-ext-diff`, but other diff/blame reads do not). So for a **fully
3050    /// untrusted** repo, do not materialize its working tree or run diffs through a
3051    /// hardened client without an OS-level sandbox — `harden()` is hardening, not a
3052    /// sandbox.
3053    ///
3054    /// What it does NOT do beyond that: sandbox the git binary itself, or stop the
3055    /// repo's *content* from being malicious. In a **colocated jj repo**, git hooks
3056    /// only run when *git* commands run — harden the `Git` client; `Jj` needs
3057    /// no equivalent (jj has no repo-local hooks; see the vcs-jj docs).
3058    ///
3059    /// Chainable — `Git::with_runner(rec).harden()` works in tests; use
3060    /// [`Git::hardened()`](Git::hardened) for the common case.
3061    pub fn harden(self) -> Self {
3062        let removed = [
3063            // Repo redirectors — point git at another repo/index/object store.
3064            // (`GIT_DIR`…`GIT_NAMESPACE` are also scrubbed by *every* client via the
3065            // `managed_client!` `scrub_env`; re-listed here so the hardened profile is
3066            // self-contained and its double-removal is harmless.)
3067            "GIT_DIR",
3068            "GIT_WORK_TREE",
3069            "GIT_INDEX_FILE",
3070            "GIT_COMMON_DIR",
3071            "GIT_OBJECT_DIRECTORY",
3072            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3073            "GIT_NAMESPACE",
3074            "GIT_CEILING_DIRECTORIES",
3075            "GIT_CONFIG_PARAMETERS",
3076            "GIT_CONFIG_GLOBAL",
3077            "GIT_CONFIG_SYSTEM",
3078            // Command hooks — make git spawn an arbitrary program from the env.
3079            "GIT_SSH_COMMAND",
3080            "GIT_SSH",
3081            "GIT_ASKPASS",
3082            "GIT_EXTERNAL_DIFF",
3083            "GIT_PAGER",
3084            "GIT_EDITOR",
3085            "GIT_SEQUENCE_EDITOR",
3086            // More env command-hooks (M14): `GIT_PROXY_COMMAND` runs an arbitrary
3087            // program for a `git://` connection; `GIT_EXEC_PATH` relocates where git
3088            // finds its own sub-commands (so `git-<x>` becomes attacker-chosen);
3089            // `GIT_TEMPLATE_DIR` seeds hooks/config into a repo on `init`/`clone`.
3090            "GIT_PROXY_COMMAND",
3091            "GIT_EXEC_PATH",
3092            "GIT_TEMPLATE_DIR",
3093            // Pathspec interpretation (M14) — not code-execution, but they silently
3094            // change which paths a command matches, so pin deterministic behavior.
3095            "GIT_LITERAL_PATHSPECS",
3096            "GIT_GLOB_PATHSPECS",
3097            "GIT_NOGLOB_PATHSPECS",
3098            "GIT_ICASE_PATHSPECS",
3099        ];
3100        let mut hardened = self;
3101        for key in removed {
3102            hardened = hardened.default_env_remove(key);
3103        }
3104        hardened
3105            .default_env("GIT_CONFIG_NOSYSTEM", "1")
3106            .default_env("GIT_TERMINAL_PROMPT", "0")
3107            // Env-config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`) overrides even the
3108            // *repo-local* `.git/config` for the keys it names — so these pins beat
3109            // a poisoned `.git/config`, which `GIT_CONFIG_NOSYSTEM` (system) and the
3110            // scrubbed `GIT_CONFIG_GLOBAL` (global) do not reach.
3111            .default_env("GIT_CONFIG_COUNT", "3")
3112            .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
3113            // `/dev/null` as the hooks dir disables hooks on every platform,
3114            // Windows included: git looks for `<hooksPath>/<hook-name>`, and no
3115            // such file can exist under `/dev/null` (it is not a directory), so the
3116            // lookup always misses and no hook runs. A literal POSIX path is fine
3117            // on Windows here — it is used as a path *prefix* to probe, never
3118            // opened — and it reads unambiguously as "nowhere."
3119            .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
3120            .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
3121            .default_env("GIT_CONFIG_VALUE_1", "false")
3122            // Neutralize a repo-local `core.sshCommand` (an arbitrary program git
3123            // runs for the SSH transport on fetch/push/clone) — the config-key twin
3124            // of the scrubbed `GIT_SSH_COMMAND` env var. An empty value is falsy to
3125            // git, so it falls back to the default `ssh` (ambient `~/.ssh/config` /
3126            // agent still work); only the repo's override is dropped.
3127            .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
3128            .default_env("GIT_CONFIG_VALUE_2", "")
3129    }
3130
3131    /// Switch to `branch`, carrying uncommitted changes (tracked *and*
3132    /// untracked) across via the stash: `stash push -u` → `checkout` →
3133    /// `stash pop --index`. `--index` restores the staged/unstaged split faithfully
3134    /// (a bare `pop` returns everything unstaged). A clean tree skips the round-trip;
3135    /// and because `stash push` can exit 0 having saved **nothing** (e.g. a
3136    /// submodule-only change), the stash-list depth is checked around the push so a
3137    /// no-op push doesn't leave the later pop grabbing an older, unrelated stash.
3138    ///
3139    /// **Single-actor contract:** this assumes no other process pushes or pops a
3140    /// stash in the same repository between this call's own `stash push` and `pop`.
3141    ///
3142    /// Failure behaviour:
3143    /// - `checkout` fails (atomic — the working copy stays on the original
3144    ///   branch): the stash is popped back to restore the original state, and
3145    ///   the checkout error is returned. If that restoring pop *also* fails,
3146    ///   the changes stay safe in the stash (`git stash list`).
3147    /// - `stash pop` on the target branch conflicts: the error is returned with
3148    ///   the target branch checked out; git keeps the stash entry, so the
3149    ///   changes can be resolved or re-applied manually.
3150    ///
3151    /// Inherent (not on the object-safe trait): a composed operation, not a 1:1
3152    /// CLI verb — mock the underlying `status`/`stash_*`/`checkout` instead.
3153    pub async fn switch_with_stash(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
3154        // Untracked-inclusive guard to match `stash push -u`: "dirty" must mean
3155        // the same thing to the guard and to the stash. Fast path for a clean tree.
3156        if self.status(dir).await?.is_empty() {
3157            return self.checkout(dir, target).await;
3158        }
3159        // `stash push` exits 0 having saved **nothing** when the only dirt is
3160        // unstashable (e.g. a submodule-only change that `status` still reports), so a
3161        // bare `stash pop` afterwards would splat an UNRELATED pre-existing stash — data
3162        // loss. Bracket the push with the stash-list depth to learn whether it actually
3163        // saved, and only pop when it did. (Single-actor contract: a concurrent
3164        // `stash push`/`pop` by another process between our two calls is out of scope.)
3165        let depth_before = self.stash_depth(dir).await?;
3166        self.stash_push(dir, StashPush::new().include_untracked())
3167            .await?;
3168        if self.stash_depth(dir).await? <= depth_before {
3169            // Nothing was stashed — switch as-is rather than pop someone else's entry.
3170            return self.checkout(dir, target).await;
3171        }
3172        // `--index` restores the staged/unstaged split faithfully; a bare `pop` would
3173        // bring everything back UNSTAGED, silently flattening the index.
3174        match self.checkout(dir, target).await {
3175            Ok(()) => self.stash_pop_index(dir).await,
3176            Err(err) => {
3177                // A failed checkout is atomic — we are still on the original branch, so
3178                // popping restores the exact pre-call state. If the pop fails too, the
3179                // stash entry is preserved for the caller.
3180                let _ = self.stash_pop_index(dir).await;
3181                Err(err)
3182            }
3183        }
3184    }
3185
3186    /// The number of entries in the stash list (`git stash list`) — used by
3187    /// [`switch_with_stash`](Git::switch_with_stash) to tell whether a `stash push`
3188    /// actually saved anything.
3189    async fn stash_depth(&self, dir: &Path) -> Result<usize> {
3190        let out = self
3191            .core
3192            .run(self.core.command_in(dir, ["stash", "list"]))
3193            .await?;
3194        Ok(out.lines().filter(|l| !l.is_empty()).count())
3195    }
3196
3197    /// `git stash pop --index` — restore the top stash *preserving* the staged/unstaged
3198    /// split (a bare `pop` returns everything unstaged). C locale so a conflicting pop's
3199    /// `CONFLICT (...)` output still feeds `is_merge_conflict`.
3200    async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
3201        self.core
3202            .run_unit(c_locale(
3203                self.core.command_in(dir, ["stash", "pop", "--index"]),
3204            ))
3205            .await
3206    }
3207
3208    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
3209    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
3210    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
3211        let git_dir = PathBuf::from(
3212            self.core
3213                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
3214                .await?,
3215        );
3216        Ok(if git_dir.is_absolute() {
3217            git_dir
3218        } else {
3219            dir.join(git_dir)
3220        })
3221    }
3222}
3223
3224impl Git {
3225    /// A hardened real (job-backed) client — `Git::new().harden()`; see
3226    /// [`harden`](Git::harden) for what the profile does.
3227    pub fn hardened() -> Self {
3228        Self::new().harden()
3229    }
3230}
3231
3232/// A [`Git`] client with a working directory bound, so calls drop the leading
3233/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
3234/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
3235/// copy: it only borrows the client and the path.
3236pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
3237    git: &'a Git<R>,
3238    dir: &'a Path,
3239}
3240
3241// Hand-written rather than derived: the view only holds two references, so it is
3242// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
3243// bound that the real default `JobRunner` doesn't satisfy, silently dropping
3244// `Copy` on the production `Repo::git_at()` handle.
3245impl<R: ProcessRunner> Clone for GitAt<'_, R> {
3246    fn clone(&self) -> Self {
3247        *self
3248    }
3249}
3250impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
3251
3252// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
3253// verbatim, `dir` methods inject `self.dir` as the first argument. The shared
3254// macro lives in `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
3255vcs_cli_support::at_forwarders! {
3256    GitAt, git, "Git",
3257    bare {
3258        fn version() -> Result<String>;
3259        fn capabilities() -> Result<GitCapabilities>;
3260        fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
3261    }
3262    dir {
3263        fn status() -> Result<Vec<StatusEntry>>;
3264        fn status_text() -> Result<String>;
3265        fn status_tracked() -> Result<Vec<StatusEntry>>;
3266        fn branch_status() -> Result<BranchStatus>;
3267        fn conflicted_files() -> Result<Vec<PathBuf>>;
3268        fn current_branch() -> Result<Option<String>>;
3269        fn branches() -> Result<Vec<Branch>>;
3270        fn log(revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
3271        fn log_paths(revspec: &RevSpec, max: usize, paths: &[String]) -> Result<Vec<Commit>>;
3272        fn rev_parse(rev: &RevSpec) -> Result<String>;
3273        fn rev_parse_short(rev: &RevSpec) -> Result<String>;
3274        fn init() -> Result<()>;
3275        fn add(paths: &[PathBuf]) -> Result<()>;
3276        fn commit(message: &str) -> Result<()>;
3277        fn create_branch(name: &RefName) -> Result<()>;
3278        fn checkout(target: &CheckoutTarget) -> Result<()>;
3279        fn checkout_detach(commit: &RevSpec) -> Result<()>;
3280        fn commit_paths(spec: CommitPaths) -> Result<()>;
3281        fn last_commit_message() -> Result<String>;
3282        fn is_unborn() -> Result<bool>;
3283        fn diff_is_empty() -> Result<bool>;
3284        fn common_dir() -> Result<PathBuf>;
3285        fn git_dir() -> Result<PathBuf>;
3286        fn resolve_commit(rev: &RevSpec) -> Result<String>;
3287        fn remote_head_branch() -> Result<Option<String>>;
3288        fn branch_exists(name: &RefName) -> Result<bool>;
3289        fn remote_branch_exists(name: &RefName) -> Result<bool>;
3290        fn remote_url(remote: &str) -> Result<String>;
3291        fn upstream() -> Result<Option<String>>;
3292        fn remote_branches(remote: &str) -> Result<Vec<String>>;
3293        fn is_merged(spec: MergeCheck) -> Result<bool>;
3294        fn set_upstream(branch: &RefName, upstream: &RefName) -> Result<()>;
3295        fn delete_branch(spec: BranchDelete) -> Result<()>;
3296        fn rename_branch(old: &RefName, new: &RefName) -> Result<()>;
3297        fn rev_list_count(range: &RevSpec) -> Result<usize>;
3298        fn diff_range_is_empty(range: &RevSpec) -> Result<bool>;
3299        fn diff_stat(range: &RevSpec) -> Result<DiffStat>;
3300        fn diff_text(spec: DiffSpec) -> Result<String>;
3301        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
3302        fn staged_is_empty() -> Result<bool>;
3303        fn is_rebase_in_progress() -> Result<bool>;
3304        fn is_merge_in_progress() -> Result<bool>;
3305        fn is_am_in_progress() -> Result<bool>;
3306        fn is_cherry_pick_in_progress() -> Result<bool>;
3307        fn is_revert_in_progress() -> Result<bool>;
3308        fn is_bisect_in_progress() -> Result<bool>;
3309        fn fetch() -> Result<()>;
3310        fn fetch_from(remote: &str) -> Result<()>;
3311        fn fetch_branch(branch: &RefName) -> Result<()>;
3312        fn push(spec: GitPush) -> Result<()>;
3313        fn merge_squash(branch: &RevSpec) -> Result<()>;
3314        fn merge_commit(spec: MergeCommit) -> Result<()>;
3315        fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
3316        fn merge_abort() -> Result<()>;
3317        fn merge_continue() -> Result<()>;
3318        fn reset_merge() -> Result<()>;
3319        fn reset_hard(rev: &RevSpec) -> Result<()>;
3320        fn rebase(onto: &RevSpec) -> Result<()>;
3321        fn rebase_abort() -> Result<()>;
3322        fn am_abort() -> Result<()>;
3323        fn rebase_continue() -> Result<()>;
3324        fn stash_push(spec: StashPush) -> Result<()>;
3325        fn stash_pop() -> Result<()>;
3326        fn switch_with_stash(target: &CheckoutTarget) -> Result<()>;
3327        fn worktree_list() -> Result<Vec<Worktree>>;
3328        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
3329        fn worktree_remove(spec: WorktreeRemove) -> Result<()>;
3330        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
3331        fn worktree_prune() -> Result<()>;
3332        fn tag_create(name: &RefName, rev: Option<RevSpec>) -> Result<()>;
3333        fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
3334        fn tag_list() -> Result<Vec<String>>;
3335        fn tag_delete(name: &RefName) -> Result<()>;
3336        fn show_file(rev: &RevSpec, path: &str) -> Result<String>;
3337        fn config_get(key: &str) -> Result<Option<String>>;
3338        fn config_set(key: &str, value: &str) -> Result<()>;
3339        fn remote_add(name: &str, url: &str) -> Result<()>;
3340        fn remote_set_url(name: &str, url: &str) -> Result<()>;
3341        fn blame(path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
3342        fn cherry_pick(rev: &RevSpec) -> Result<()>;
3343        fn revert(rev: &RevSpec) -> Result<()>;
3344        fn rebase_skip() -> Result<()>;
3345        fn cherry_pick_abort() -> Result<()>;
3346        fn cherry_pick_continue() -> Result<()>;
3347        fn revert_abort() -> Result<()>;
3348        fn revert_continue() -> Result<()>;
3349        fn bisect_reset() -> Result<()>;
3350    }
3351    // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
3352    // twins) so `git.at(dir).run(…)` runs in the bound repo, not the process cwd.
3353    // For the process-cwd hatch call `run`/`run_raw`/… on `Git` directly.
3354    raw {
3355        fn run(args: &[String]) -> Result<String> => run_in;
3356        fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
3357        fn run_args(args: &[&str]) -> Result<String> => run_args_in;
3358        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
3359    }
3360}
3361
3362/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
3363/// a `Drop` guard. They shell out through `std::process` directly (no async, no
3364/// job-containment), so reserve them for short-lived cleanup.
3365pub mod blocking {
3366    use std::path::Path;
3367    use std::process::Command;
3368
3369    /// Remove a worktree synchronously (`git worktree remove [--force] <path>`);
3370    /// see [`WorktreeRemove`](super::WorktreeRemove).
3371    pub fn worktree_remove(dir: &Path, spec: super::WorktreeRemove) -> std::io::Result<()> {
3372        let mut cmd = Command::new(super::BINARY);
3373        cmd.current_dir(dir).args(["worktree", "remove"]);
3374        if spec.force {
3375            cmd.arg("--force");
3376        }
3377        cmd.arg(&spec.path);
3378        let status = cmd.status()?;
3379        if status.success() {
3380            Ok(())
3381        } else {
3382            Err(std::io::Error::other(format!(
3383                "`git worktree remove` exited with {status}"
3384            )))
3385        }
3386    }
3387}
3388
3389#[cfg(test)]
3390mod tests {
3391    use super::*;
3392
3393    // Terse constructors for the validated newtypes in test call sites; the
3394    // literals here are always valid, so `unwrap` is fine in tests.
3395    fn rn(s: &str) -> RefName {
3396        RefName::new(s).unwrap()
3397    }
3398    fn rv(s: &str) -> RevSpec {
3399        RevSpec::new(s).unwrap()
3400    }
3401    fn ct(s: &str) -> CheckoutTarget {
3402        if s == "-" {
3403            CheckoutTarget::Previous
3404        } else {
3405            CheckoutTarget::Ref(rv(s))
3406        }
3407    }
3408    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
3409
3410    #[test]
3411    fn binary_name_is_git() {
3412        assert_eq!(BINARY, "git");
3413    }
3414
3415    // Compile-time guard: the bound view must stay `Copy` for the *default*
3416    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
3417    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
3418    #[allow(dead_code)]
3419    fn bound_view_is_copy_for_default_runner() {
3420        fn assert_copy<T: Copy>() {}
3421        assert_copy::<GitAt<'static, processkit::JobRunner>>();
3422    }
3423
3424    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
3425    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
3426    // in the right place and nothing else changes.
3427    #[tokio::test]
3428    async fn bound_view_matches_dir_taking_calls() {
3429        let dir = Path::new("/repo");
3430        let rec = RecordingRunner::replying(Reply::ok(""));
3431        let git = Git::with_runner(&rec);
3432
3433        // A method with trailing args (dir injected first).
3434        git.merge_commit(dir, MergeCommit::branch(rv("feat")).no_ff())
3435            .await
3436            .unwrap();
3437        git.at(dir)
3438            .merge_commit(MergeCommit::branch(rv("feat")).no_ff())
3439            .await
3440            .unwrap();
3441        // A method taking a path arg after dir.
3442        git.worktree_remove(dir, WorktreeRemove::new("/wt").force())
3443            .await
3444            .unwrap();
3445        git.at(dir)
3446            .worktree_remove(WorktreeRemove::new("/wt").force())
3447            .await
3448            .unwrap();
3449        // One of the new query methods.
3450        git.conflicted_files(dir).await.unwrap();
3451        git.at(dir).conflicted_files().await.unwrap();
3452        // One of the §4 additions.
3453        git.tag_delete(dir, &rn("v1")).await.unwrap();
3454        git.at(dir).tag_delete(&rn("v1")).await.unwrap();
3455
3456        let calls = rec.calls();
3457        assert_eq!(calls[0].args_str(), calls[1].args_str());
3458        assert_eq!(calls[2].args_str(), calls[3].args_str());
3459        assert_eq!(calls[4].args_str(), calls[5].args_str());
3460        assert_eq!(calls[6].args_str(), calls[7].args_str());
3461        // The bound calls also carried the bound dir as their working directory.
3462        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
3463        assert_eq!(calls[3].cwd.as_deref(), Some(dir));
3464    }
3465
3466    // T-035: the raw escape hatches reached *through* the bound view
3467    // (`git.at(dir).run…`) now run in the bound `dir`, while the same-named methods
3468    // on the client stay in the process cwd. Guards that a bound handle's raw call
3469    // can no longer silently target another repo, and that the explicit process-cwd
3470    // hatch is preserved.
3471    #[tokio::test]
3472    async fn bound_view_raw_hatch_runs_in_bound_dir() {
3473        let dir = Path::new("/repo");
3474        let rec = RecordingRunner::replying(Reply::ok(""));
3475        let git = Git::with_runner(&rec);
3476
3477        // Through the bound view: every raw form carries the bound dir as its cwd.
3478        git.at(dir).run(&["status".to_string()]).await.unwrap();
3479        let _ = git.at(dir).run_raw(&["status".to_string()]).await.unwrap();
3480        git.at(dir).run_args(&["status"]).await.unwrap();
3481        let _ = git.at(dir).run_raw_args(&["status"]).await.unwrap();
3482        // On the client directly: the process-cwd escape hatch (no bound dir).
3483        git.run(&["status".to_string()]).await.unwrap();
3484        let _ = git.run_raw(&["status".to_string()]).await.unwrap();
3485        git.run_args(&["status"]).await.unwrap();
3486        let _ = git.run_raw_args(&["status"]).await.unwrap();
3487
3488        let calls = rec.calls();
3489        for c in &calls[0..4] {
3490            assert_eq!(
3491                c.cwd.as_deref(),
3492                Some(dir),
3493                "raw call through the bound view runs in the bound dir"
3494            );
3495            assert_eq!(c.args_str(), ["status"]);
3496        }
3497        for c in &calls[4..8] {
3498            assert_eq!(
3499                c.cwd.as_deref(),
3500                None,
3501                "raw call on the client stays in the process cwd"
3502            );
3503            assert_eq!(c.args_str(), ["status"]);
3504        }
3505    }
3506
3507    // Hermetic: the real status() command-building + porcelain parsing run
3508    // against a scripted runner — no `git` binary needed, so this runs on CI.
3509    #[tokio::test]
3510    async fn status_parses_scripted_output() {
3511        // `-z` output: NUL-delimited records, raw paths.
3512        let git = Git::with_runner(
3513            ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
3514        );
3515        let entries = git.status(Path::new(".")).await.expect("status");
3516        assert_eq!(entries.len(), 2);
3517        assert_eq!(entries[0].code, " M");
3518        assert_eq!(entries[1].path, Path::new("b.rs"));
3519    }
3520
3521    // `status_tracked` is `status` minus untracked files — same parser, extra flag.
3522    #[tokio::test]
3523    async fn status_tracked_excludes_untracked_flag() {
3524        let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
3525        let git = Git::with_runner(&rec);
3526        let entries = git.status_tracked(Path::new(".")).await.expect("status");
3527        assert_eq!(entries.len(), 1);
3528        assert_eq!(entries[0].code, " M");
3529        assert_eq!(
3530            rec.only_call().args_str(),
3531            ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
3532        );
3533    }
3534
3535    // `branch_status` builds the porcelain v2 + branch + -z argv and parses the
3536    // combined header/entry output in one call.
3537    #[tokio::test]
3538    async fn branch_status_builds_v2_branch_args_and_parses() {
3539        let out = concat!(
3540            "# branch.oid abc\0",
3541            "# branch.head main\0",
3542            "# branch.upstream origin/main\0",
3543            "# branch.ab +1 -0\0",
3544            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
3545            "? new.txt\0",
3546        );
3547        let rec = RecordingRunner::replying(Reply::ok(out));
3548        let git = Git::with_runner(&rec);
3549        let s = git
3550            .branch_status(Path::new("."))
3551            .await
3552            .expect("branch_status");
3553        assert_eq!(
3554            rec.only_call().args_str(),
3555            ["status", "--porcelain=v2", "--branch", "-z"]
3556        );
3557        // The poll primitive must not itself write the index (and re-trigger a
3558        // filesystem watcher re-querying through it).
3559        assert!(rec.only_call().envs.iter().any(|(k, v)| {
3560            k.to_str() == Some("GIT_OPTIONAL_LOCKS")
3561                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3562        }));
3563        assert_eq!(s.branch.as_deref(), Some("main"));
3564        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
3565        assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
3566        assert_eq!(s.tracked_changes, 1);
3567        assert_eq!(s.untracked, 1);
3568        assert!(s.is_dirty());
3569    }
3570
3571    // `conflicted_files` lists unmerged paths NUL-delimited (no quoting).
3572    #[tokio::test]
3573    async fn conflicted_files_builds_args_and_parses_nul_list() {
3574        let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
3575        let git = Git::with_runner(&rec);
3576        let paths = git
3577            .conflicted_files(Path::new("."))
3578            .await
3579            .expect("conflicted_files");
3580        assert_eq!(
3581            paths,
3582            [PathBuf::from("a.rs"), PathBuf::from("sub/spaced name.rs")]
3583        );
3584        assert_eq!(
3585            rec.only_call().args_str(),
3586            ["diff", "--name-only", "--diff-filter=U", "-z"]
3587        );
3588    }
3589
3590    #[tokio::test]
3591    async fn rev_parse_short_builds_short_flag() {
3592        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
3593        let git = Git::with_runner(&rec);
3594        let out = git
3595            .rev_parse_short(Path::new("/r"), &rv("HEAD"))
3596            .await
3597            .unwrap();
3598        assert_eq!(out, "a1b2c3d");
3599        assert_eq!(
3600            rec.only_call().args_str(),
3601            ["rev-parse", "--verify", "--short", "HEAD"]
3602        );
3603    }
3604
3605    // M13: `rev_parse` passes `--verify` so a non-revision (a filename) errors
3606    // instead of being echoed back as a fake object id.
3607    #[tokio::test]
3608    async fn rev_parse_verifies_the_revision() {
3609        let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
3610        let git = Git::with_runner(&rec);
3611        let out = git.rev_parse(Path::new("/r"), &rv("HEAD")).await.unwrap();
3612        assert_eq!(out, "deadbeef");
3613        assert_eq!(
3614            rec.only_call().args_str(),
3615            ["rev-parse", "--verify", "HEAD"]
3616        );
3617    }
3618
3619    // M20: `git am` and an apply-backend rebase share the `rebase-apply/` dir, but am
3620    // marks it with an `applying` file. `is_am_in_progress` must fire only for the am,
3621    // and `is_rebase_in_progress` must NOT (so an am isn't aborted with `rebase --abort`).
3622    #[tokio::test]
3623    async fn distinguishes_git_am_from_an_apply_backend_rebase() {
3624        use vcs_testkit::TempDir;
3625        let gd = TempDir::new("m20-am");
3626        let git = Git::with_runner(ScriptedRunner::new().on(
3627            ["git", "rev-parse", "--git-dir"],
3628            Reply::ok(gd.path().to_str().unwrap()),
3629        ));
3630        let apply = gd.path().join("rebase-apply");
3631        std::fs::create_dir_all(&apply).unwrap();
3632
3633        // With the `applying` marker → a `git am`.
3634        std::fs::write(apply.join("applying"), b"").unwrap();
3635        assert!(
3636            git.is_am_in_progress(Path::new("/r")).await.unwrap(),
3637            "am detected"
3638        );
3639        assert!(
3640            !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3641            "a git am is NOT reported as a rebase"
3642        );
3643
3644        // Without it → an apply-backend rebase.
3645        std::fs::remove_file(apply.join("applying")).unwrap();
3646        assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
3647        assert!(
3648            git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3649            "a bare rebase-apply dir is a rebase"
3650        );
3651    }
3652
3653    // T-044: the sequencer states each key off their own git-dir marker, and a
3654    // cherry-pick/revert conflict does NOT write `MERGE_HEAD` — so none of them is
3655    // mistaken for a merge (which would dispatch `merge --abort` on a real repo).
3656    #[tokio::test]
3657    async fn detects_cherry_pick_revert_and_bisect_markers() {
3658        use vcs_testkit::TempDir;
3659        let gd = TempDir::new("t044-seq");
3660        let git = Git::with_runner(ScriptedRunner::new().on(
3661            ["git", "rev-parse", "--git-dir"],
3662            Reply::ok(gd.path().to_str().unwrap()),
3663        ));
3664        let d = Path::new("/r");
3665        let touch = |name: &str| std::fs::write(gd.path().join(name), b"x\n").unwrap();
3666        let rm = |name: &str| std::fs::remove_file(gd.path().join(name)).unwrap();
3667
3668        // A cherry-pick: CHERRY_PICK_HEAD present, and crucially NOT read as a merge.
3669        touch("CHERRY_PICK_HEAD");
3670        assert!(git.is_cherry_pick_in_progress(d).await.unwrap());
3671        assert!(!git.is_merge_in_progress(d).await.unwrap());
3672        assert!(!git.is_revert_in_progress(d).await.unwrap());
3673        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3674        rm("CHERRY_PICK_HEAD");
3675
3676        // A revert.
3677        touch("REVERT_HEAD");
3678        assert!(git.is_revert_in_progress(d).await.unwrap());
3679        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3680        assert!(!git.is_merge_in_progress(d).await.unwrap());
3681        rm("REVERT_HEAD");
3682
3683        // A bisect (keyed off BISECT_LOG).
3684        touch("BISECT_LOG");
3685        assert!(git.is_bisect_in_progress(d).await.unwrap());
3686        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3687        assert!(!git.is_revert_in_progress(d).await.unwrap());
3688        rm("BISECT_LOG");
3689
3690        // Clean git dir → none fire.
3691        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3692        assert!(!git.is_revert_in_progress(d).await.unwrap());
3693        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3694    }
3695
3696    // A non-zero exit surfaces as a structured `Error::Exit`.
3697    #[tokio::test]
3698    async fn nonzero_exit_is_structured_error() {
3699        let git = Git::with_runner(
3700            ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
3701        );
3702        match git.status(Path::new(".")).await.unwrap_err() {
3703            Error::Exit { code, stderr, .. } => {
3704                assert_eq!(code, 128);
3705                assert!(stderr.contains("not a git repository"), "{stderr}");
3706            }
3707            other => panic!("expected Exit, got {other:?}"),
3708        }
3709    }
3710
3711    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
3712    // anything else is a real failure surfaced as Error::Exit.
3713    #[tokio::test]
3714    async fn diff_is_empty_maps_exit_codes() {
3715        let clean =
3716            Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
3717        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
3718
3719        let dirty = Git::with_runner(
3720            ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
3721        );
3722        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
3723
3724        let broken = Git::with_runner(ScriptedRunner::new().on(
3725            ["git", "diff", "--quiet"],
3726            Reply::fail(128, "fatal: not a repo"),
3727        ));
3728        assert!(matches!(
3729            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
3730            Error::Exit { code: 128, .. }
3731        ));
3732    }
3733
3734    // `add` must insert `--` before the pathspecs so a path can never be parsed
3735    // as an option, and `--literal-pathspecs` so a glob-magic character in a
3736    // path matches literally (R-01). No fallback rule: the run only matches if
3737    // `--literal-pathspecs add --` was built.
3738    #[tokio::test]
3739    async fn add_inserts_pathspec_separator() {
3740        let git = Git::with_runner(
3741            ScriptedRunner::new().on(["git", "--literal-pathspecs", "add", "--"], Reply::ok("")),
3742        );
3743        git.add(Path::new("."), &[PathBuf::from("f.rs")])
3744            .await
3745            .expect("add should build `--literal-pathspecs add -- <paths>`");
3746    }
3747
3748    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` must route
3749    // through the NUL-safe `--pathspec-from-file=- --pathspec-file-nul`
3750    // transport instead of the plain `add -- <paths>` argv: no per-path argv
3751    // entries, the payload travels over stdin instead (T-052).
3752    #[tokio::test]
3753    async fn add_large_path_set_uses_pathspec_from_file_stdin() {
3754        let rec = RecordingRunner::replying(Reply::ok(""));
3755        let git = Git::with_runner(&rec);
3756        let paths: Vec<PathBuf> = (0..2_000)
3757            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
3758            .collect();
3759        git.add(Path::new("."), &paths).await.expect("add");
3760        let call = rec.only_call();
3761        assert_eq!(
3762            call.args_str(),
3763            [
3764                "--literal-pathspecs",
3765                "add",
3766                "--pathspec-from-file=-",
3767                "--pathspec-file-nul",
3768            ]
3769        );
3770        assert!(call.has_stdin, "paths must travel over stdin, not argv");
3771    }
3772
3773    #[tokio::test]
3774    async fn worktree_list_parses_porcelain() {
3775        let git = Git::with_runner(ScriptedRunner::new().on(
3776            ["git", "worktree", "list"],
3777            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
3778        ));
3779        let wts = git.worktree_list(Path::new(".")).await.expect("list");
3780        assert_eq!(wts.len(), 1);
3781        assert_eq!(wts[0].branch.as_deref(), Some("main"));
3782        assert_eq!(wts[0].head.as_deref(), Some("abc"));
3783    }
3784
3785    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
3786    // in that exact order; only the full argv is scripted (no fallback).
3787    #[tokio::test]
3788    async fn worktree_add_builds_branch_path_and_base() {
3789        let rec = RecordingRunner::replying(Reply::ok(""));
3790        let git = Git::with_runner(&rec);
3791        git.worktree_add(
3792            Path::new("/repo"),
3793            WorktreeAdd::create_branch("/wt", rn("feature"), rv("main")),
3794        )
3795        .await
3796        .expect("worktree add");
3797        assert_eq!(
3798            rec.only_call().args_str(),
3799            ["worktree", "add", "-b", "feature", "/wt", "main"]
3800        );
3801    }
3802
3803    #[tokio::test]
3804    async fn worktree_remove_passes_force_then_path() {
3805        let rec = RecordingRunner::replying(Reply::ok(""));
3806        let git = Git::with_runner(&rec);
3807        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt").force())
3808            .await
3809            .expect("remove");
3810        assert_eq!(
3811            rec.only_call().args_str(),
3812            ["worktree", "remove", "--force", "/wt"]
3813        );
3814    }
3815
3816    // The default (un-forced) spec omits `--force`.
3817    #[tokio::test]
3818    async fn worktree_remove_default_omits_force() {
3819        let rec = RecordingRunner::replying(Reply::ok(""));
3820        let git = Git::with_runner(&rec);
3821        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt"))
3822            .await
3823            .expect("remove");
3824        assert_eq!(rec.only_call().args_str(), ["worktree", "remove", "/wt"]);
3825    }
3826
3827    // `--no-checkout` must land between `-b <name>` and the path.
3828    #[tokio::test]
3829    async fn worktree_add_no_checkout_inserts_flag() {
3830        let rec = RecordingRunner::replying(Reply::ok(""));
3831        let git = Git::with_runner(&rec);
3832        git.worktree_add(
3833            Path::new("/repo"),
3834            WorktreeAdd::checkout("/wt", rv("main")).no_checkout(),
3835        )
3836        .await
3837        .expect("worktree add");
3838        assert_eq!(
3839            rec.only_call().args_str(),
3840            ["worktree", "add", "--no-checkout", "/wt", "main"]
3841        );
3842    }
3843
3844    #[tokio::test]
3845    async fn checkout_detach_builds_args() {
3846        let rec = RecordingRunner::replying(Reply::ok(""));
3847        let git = Git::with_runner(&rec);
3848        git.checkout_detach(Path::new("."), &rv("abc123"))
3849            .await
3850            .expect("detach");
3851        assert_eq!(
3852            rec.only_call().args_str(),
3853            ["checkout", "--detach", "abc123"]
3854        );
3855    }
3856
3857    // current_branch reads `symbolic-ref --quiet --short HEAD`: exit 0 → the branch
3858    // name (a normal *or* unborn branch), exit 1 → None (detached HEAD), and any
3859    // other non-zero (e.g. not a repository) stays a real error.
3860    #[tokio::test]
3861    async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
3862        // A normal branch (exit 0) — and the argv is pinned.
3863        let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
3864        let on_branch = Git::with_runner(&rec);
3865        assert_eq!(
3866            on_branch.current_branch(Path::new(".")).await.unwrap(),
3867            Some("feature/x".to_string())
3868        );
3869        assert_eq!(
3870            rec.only_call().args_str(),
3871            ["symbolic-ref", "--quiet", "--short", "HEAD"]
3872        );
3873        // An unborn branch also exits 0 with the branch name (the bug this fixes:
3874        // the old `rev-parse --abbrev-ref HEAD` errored with exit 128 here).
3875        let unborn = Git::with_runner(
3876            ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
3877        );
3878        assert_eq!(
3879            unborn.current_branch(Path::new(".")).await.unwrap(),
3880            Some("main".to_string())
3881        );
3882        // A detached HEAD exits 1 silently → None.
3883        let detached =
3884            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3885        assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
3886        // Any other non-zero (not a repository, exit 128) is a real error.
3887        let not_repo = Git::with_runner(ScriptedRunner::new().on(
3888            ["git", "symbolic-ref"],
3889            Reply::fail(128, "fatal: not a git repository"),
3890        ));
3891        assert!(not_repo.current_branch(Path::new(".")).await.is_err());
3892    }
3893
3894    // Partial amend commit must build `--literal-pathspecs commit --amend -m
3895    // <msg> --only -- <paths>` — `--literal-pathspecs` so a glob-magic
3896    // character in a path matches literally (R-01).
3897    #[tokio::test]
3898    async fn commit_paths_builds_only_amend_args() {
3899        let rec = RecordingRunner::replying(Reply::ok(""));
3900        let git = Git::with_runner(&rec);
3901        git.commit_paths(
3902            Path::new("."),
3903            CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
3904        )
3905        .await
3906        .expect("commit_paths");
3907        assert_eq!(
3908            rec.only_call().args_str(),
3909            [
3910                "--literal-pathspecs",
3911                "commit",
3912                "--amend",
3913                "-m",
3914                "msg",
3915                "--only",
3916                "--",
3917                "a.rs",
3918                "b.rs"
3919            ]
3920        );
3921    }
3922
3923    // Same transport switch as `add`'s twin test: a path set over
3924    // `ARGV_PATHSPEC_BUDGET` commits through `--pathspec-from-file=-
3925    // --pathspec-file-nul` (paths over stdin) instead of a plain `-- <paths>`
3926    // argv tail — and it is still exactly **one** `git commit` call (T-052).
3927    #[tokio::test]
3928    async fn commit_paths_large_path_set_uses_pathspec_from_file_stdin() {
3929        let rec = RecordingRunner::replying(Reply::ok(""));
3930        let git = Git::with_runner(&rec);
3931        let paths: Vec<PathBuf> = (0..2_000)
3932            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
3933            .collect();
3934        git.commit_paths(Path::new("."), CommitPaths::new(paths, "msg").amend())
3935            .await
3936            .expect("commit_paths");
3937        let calls = rec.calls();
3938        assert_eq!(calls.len(), 1, "must be a single atomic commit invocation");
3939        let call = &calls[0];
3940        assert_eq!(
3941            call.args_str(),
3942            [
3943                "--literal-pathspecs",
3944                "commit",
3945                "--amend",
3946                "-m",
3947                "msg",
3948                "--only",
3949                "--pathspec-from-file=-",
3950                "--pathspec-file-nul",
3951            ]
3952        );
3953        assert!(call.has_stdin, "paths must travel over stdin, not argv");
3954    }
3955
3956    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
3957    // unborn (true), anything else is a structured error.
3958    #[tokio::test]
3959    async fn is_unborn_maps_exit_codes() {
3960        let born =
3961            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
3962        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
3963        let unborn =
3964            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
3965        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
3966        let broken = Git::with_runner(
3967            ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
3968        );
3969        assert!(matches!(
3970            broken.is_unborn(Path::new(".")).await.unwrap_err(),
3971            Error::Exit { code: 128, .. }
3972        ));
3973    }
3974
3975    #[tokio::test]
3976    async fn log_builds_revspec_and_format() {
3977        let rec = RecordingRunner::replying(Reply::ok(""));
3978        let git = Git::with_runner(&rec);
3979        git.log(Path::new("."), &rv("main..HEAD"), 5)
3980            .await
3981            .expect("log");
3982        assert_eq!(
3983            rec.only_call().args_str(),
3984            [
3985                "log",
3986                "main..HEAD",
3987                "-n5",
3988                "-z",
3989                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
3990            ]
3991        );
3992    }
3993
3994    // `log_paths` must insert `--literal-pathspecs` (R-02) and `--` exactly
3995    // once, right before the pathspecs, after the same revspec/count/format
3996    // arguments `log` builds.
3997    #[tokio::test]
3998    async fn log_paths_builds_revspec_format_and_pathspec_separator() {
3999        let rec = RecordingRunner::replying(Reply::ok(""));
4000        let git = Git::with_runner(&rec);
4001        git.log_paths(
4002            Path::new("."),
4003            &rv("main..HEAD"),
4004            5,
4005            &["src/a.rs".to_string(), "src/b.rs".to_string()],
4006        )
4007        .await
4008        .expect("log_paths");
4009        let args = rec.only_call().args_str();
4010        assert_eq!(
4011            args,
4012            [
4013                "--literal-pathspecs",
4014                "log",
4015                "main..HEAD",
4016                "-n5",
4017                "-z",
4018                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4019                "--",
4020                "src/a.rs",
4021                "src/b.rs",
4022            ]
4023        );
4024        assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
4025    }
4026
4027    // An empty `paths` slice must NOT degrade to an unrestricted `git log` —
4028    // it's refused before any spawn (mirrors `commit_paths_refuses_empty_*` in
4029    // vcs-jj).
4030    #[tokio::test]
4031    async fn log_paths_refuses_empty_paths_without_spawning() {
4032        let rec = RecordingRunner::replying(Reply::ok(""));
4033        let git = Git::with_runner(&rec);
4034        let err = git
4035            .log_paths(Path::new("."), &rv("HEAD"), 5, &[])
4036            .await
4037            .expect_err("empty paths must be refused");
4038        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4039        assert!(rec.calls().is_empty(), "nothing may spawn");
4040    }
4041
4042    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` splits
4043    // `log` into per-chunk calls (`git log` has no `--pathspec-from-file`
4044    // support, unlike `add`/`commit_paths`; each chunk call also carries
4045    // `--literal-pathspecs`, R-02) and merges the results: dedup by hash (the
4046    // "shared" commit appears in both chunks' canned output), reordered to
4047    // match a separate, pathless oracle call's commit order, capped at `max`
4048    // (T-052/R-03).
4049    //
4050    // All three commits share the exact same (second-resolution) author date
4051    // — a date-based sort would have no signal to order them at all and
4052    // could only fall back to arbitrary/input order — yet the oracle still
4053    // produces a definite, correct order, because it comes from git's own
4054    // traversal rather than from parsed timestamps. This is exactly the case
4055    // R-03 flagged as unfixable by refining the date sort further.
4056    #[tokio::test]
4057    async fn log_paths_large_path_set_chunks_dedupes_and_reorders_by_oracle_order() {
4058        // Two paths, each already over budget alone once paired — `chunk_pathspecs`
4059        // puts each in its own singleton chunk.
4060        let path_a = "a".repeat(4_000);
4061        let path_b = "b".repeat(4_000);
4062        // R-04: the chunked path resolves `revspec` once via `git rev-parse`
4063        // before any chunk/oracle call, then reuses the resolved token
4064        // (deliberately distinct from the literal `"HEAD"` text) everywhere
4065        // below — proving every one of those calls used the frozen snapshot,
4066        // not the original symbolic name.
4067        let common = [
4068            "git",
4069            "--literal-pathspecs",
4070            "log",
4071            "resolved-head-sha",
4072            "-n5",
4073            "-z",
4074            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4075            "--",
4076        ];
4077        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4078        chunk_a_args.push(path_a.clone());
4079        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4080        chunk_b_args.push(path_b.clone());
4081
4082        // Chunk A: "newer-a" + "shared", both dated 2026-01-02.
4083        let reply_a = Reply::ok(
4084            "aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0\
4085             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4086                .to_string(),
4087        );
4088        // Chunk B: "newest-b" (also dated 2026-01-02) + the SAME "shared"
4089        // commit again (touches a path in both chunks).
4090        let reply_b = Reply::ok(
4091            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-02T00:00:00Z\u{1f}newest-b\0\
4092             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4093                .to_string(),
4094        );
4095        // The oracle: git's real, unrestricted commit order for the same
4096        // revspec — puts "shared" between the other two, an order no sort of
4097        // the (identical) merged timestamps could ever reproduce.
4098        let order_reply = Reply::ok("bbb1\0shar\0aaa1\0".to_string());
4099
4100        let git = Git::with_runner(
4101            ScriptedRunner::new()
4102                .on(
4103                    ["git", "rev-parse", "HEAD"],
4104                    Reply::ok("resolved-head-sha\n".to_string()),
4105                )
4106                .on(chunk_a_args, reply_a)
4107                .on(chunk_b_args, reply_b)
4108                .on(
4109                    ["git", "log", "resolved-head-sha", "-z", "--format=%H"],
4110                    order_reply,
4111                ),
4112        );
4113
4114        let commits = git
4115            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4116            .await
4117            .expect("log_paths");
4118
4119        assert_eq!(
4120            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
4121            ["bbb1", "shar", "aaa1"],
4122            "expected the oracle's commit order across chunks, with the shared \
4123             commit deduplicated"
4124        );
4125    }
4126
4127    // The single-call path (small path sets) must return byte-identical order
4128    // to what a chunked call over the same commits produces via the oracle —
4129    // i.e. chunking never changes which order callers see for a path set
4130    // that happens to be small (T-052/R-03 regression guard).
4131    #[tokio::test]
4132    async fn log_paths_single_call_and_chunked_call_agree_on_order() {
4133        let single_reply = Reply::ok(
4134            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0\
4135             aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0",
4136        );
4137        let single_call_git = Git::with_runner(ScriptedRunner::new().on(
4138            [
4139                "git",
4140                "--literal-pathspecs",
4141                "log",
4142                "HEAD",
4143                "-n5",
4144                "-z",
4145                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4146                "--",
4147                "src/a.rs",
4148                "src/b.rs",
4149            ],
4150            single_reply,
4151        ));
4152        let single_commits = single_call_git
4153            .log_paths(
4154                Path::new("."),
4155                &rv("HEAD"),
4156                5,
4157                &["src/a.rs".to_string(), "src/b.rs".to_string()],
4158            )
4159            .await
4160            .expect("log_paths");
4161
4162        let path_a = "a".repeat(4_000);
4163        let path_b = "b".repeat(4_000);
4164        let common = [
4165            "git",
4166            "--literal-pathspecs",
4167            "log",
4168            "HEAD",
4169            "-n5",
4170            "-z",
4171            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4172            "--",
4173        ];
4174        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4175        chunk_a_args.push(path_a.clone());
4176        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4177        chunk_b_args.push(path_b.clone());
4178        let reply_a =
4179            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4180        let reply_b =
4181            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4182        // The oracle agrees with the single-call order: "newest-b" before
4183        // "newer-a".
4184        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4185        let chunked_git = Git::with_runner(
4186            ScriptedRunner::new()
4187                // R-04: the chunked path resolves `revspec` once via `git
4188                // rev-parse` before the chunk/oracle calls below.
4189                .on(
4190                    ["git", "rev-parse", "HEAD"],
4191                    Reply::ok("HEAD\n".to_string()),
4192                )
4193                .on(chunk_a_args, reply_a)
4194                .on(chunk_b_args, reply_b)
4195                .on(["git", "log", "HEAD", "-z", "--format=%H"], order_reply),
4196        );
4197        let chunked_commits = chunked_git
4198            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4199            .await
4200            .expect("log_paths");
4201
4202        assert_eq!(
4203            single_commits
4204                .iter()
4205                .map(|c| c.hash.as_str())
4206                .collect::<Vec<_>>(),
4207            chunked_commits
4208                .iter()
4209                .map(|c| c.hash.as_str())
4210                .collect::<Vec<_>>(),
4211            "single-call and chunked-call order must agree when the oracle \
4212             agrees with the single-call order"
4213        );
4214    }
4215
4216    // R-04: a range revspec (`A..B`) resolves via `git rev-parse` to *two*
4217    // tokens — the tip and a `^`-prefixed exclusion — and both chunk calls
4218    // plus the oracle call must forward both tokens verbatim, not just the
4219    // original `"main..feature"` text. This is exactly the expansion `git
4220    // log` would perform internally, so it's behavior-preserving while also
4221    // fixing the moving-ref race (a concurrent `main` or `feature` move
4222    // between chunk/oracle calls can no longer change what any of them see,
4223    // since all of them now share the one resolution taken up front).
4224    #[tokio::test]
4225    async fn log_paths_range_revspec_is_resolved_once_and_reused_across_chunks() {
4226        let path_a = "a".repeat(4_000);
4227        let path_b = "b".repeat(4_000);
4228        let common = [
4229            "git",
4230            "--literal-pathspecs",
4231            "log",
4232            "feature-sha",
4233            "^main-sha",
4234            "-n5",
4235            "-z",
4236            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4237            "--",
4238        ];
4239        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4240        chunk_a_args.push(path_a.clone());
4241        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4242        chunk_b_args.push(path_b.clone());
4243        let reply_a =
4244            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4245        let reply_b =
4246            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4247        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4248
4249        let git = Git::with_runner(
4250            ScriptedRunner::new()
4251                .on(
4252                    ["git", "rev-parse", "main..feature"],
4253                    Reply::ok("feature-sha\n^main-sha\n".to_string()),
4254                )
4255                .on(chunk_a_args, reply_a)
4256                .on(chunk_b_args, reply_b)
4257                .on(
4258                    [
4259                        "git",
4260                        "log",
4261                        "feature-sha",
4262                        "^main-sha",
4263                        "-z",
4264                        "--format=%H",
4265                    ],
4266                    order_reply,
4267                ),
4268        );
4269
4270        let commits = git
4271            .log_paths(Path::new("."), &rv("main..feature"), 5, &[path_a, path_b])
4272            .await
4273            .expect("log_paths");
4274        assert_eq!(
4275            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
4276            ["bbb1", "aaa1"]
4277        );
4278    }
4279
4280    // R-05: `git log` has no NUL-safe fallback transport the way
4281    // `add`/`commit_paths` do, so a single path that alone exceeds the argv
4282    // budget must be refused up front — never spawned as an over-budget
4283    // singleton chunk.
4284    #[tokio::test]
4285    async fn log_paths_rejects_individually_oversized_path_without_spawning() {
4286        let rec = RecordingRunner::replying(Reply::ok(""));
4287        let git = Git::with_runner(&rec);
4288        let huge = "z".repeat(ARGV_PATHSPEC_BUDGET + 1);
4289        let err = git
4290            .log_paths(Path::new("."), &rv("HEAD"), 5, &[huge])
4291            .await
4292            .expect_err("an individually oversized path must be refused");
4293        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4294        assert!(rec.calls().is_empty(), "nothing may spawn");
4295    }
4296
4297    // --- T-052 pure-helper tests ----------------------------------------------
4298
4299    #[test]
4300    fn pathspec_argv_len_sums_bytes_plus_one_per_path() {
4301        use std::ffi::OsStr;
4302        let paths = [OsStr::new("a.rs"), OsStr::new("dir/b.rs")];
4303        // "a.rs" (4) + 1, "dir/b.rs" (8) + 1.
4304        assert_eq!(pathspec_argv_len(paths), 5 + 9);
4305        assert_eq!(pathspec_argv_len(std::iter::empty()), 0);
4306    }
4307
4308    // Each path lands verbatim between NUL separators, in order — including a
4309    // leading dash, embedded spaces, and a glob-magic character (the whole
4310    // point of the `--literal-pathspecs` + NUL transport: no argv/shell layer
4311    // to mis-parse them, and git is told not to treat them as pathspec magic
4312    // either).
4313    #[test]
4314    fn pathspec_nul_bytes_joins_paths_literally_in_order() {
4315        use std::ffi::OsStr;
4316        let paths = [
4317            OsStr::new("-weird.txt"),
4318            OsStr::new("has space.txt"),
4319            OsStr::new("glob[1].txt"),
4320        ];
4321        let bytes = pathspec_nul_bytes(paths).expect("no embedded NUL");
4322        assert_eq!(bytes, b"-weird.txt\0has space.txt\0glob[1].txt\0".to_vec());
4323    }
4324
4325    // An embedded NUL byte would silently truncate a pathspec-file-nul entry,
4326    // splitting one path into two on the very separator the transport relies
4327    // on — refused before anything is built, so `commit_paths`'s "no partial
4328    // result" contract holds even for this input-prep failure.
4329    #[test]
4330    fn pathspec_nul_bytes_rejects_embedded_nul() {
4331        use std::ffi::OsStr;
4332        // A real filesystem path can't contain a NUL byte, but the guard must
4333        // still catch a pathological caller-constructed one.
4334        let bad = unsafe { OsStr::from_encoded_bytes_unchecked(b"a\0b") };
4335        let err = pathspec_nul_bytes([bad]).expect_err("embedded NUL must be refused");
4336        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4337    }
4338
4339    #[test]
4340    fn chunk_pathspecs_packs_under_budget_and_splits_over_it() {
4341        let short = vec!["a".to_string(), "b".to_string(), "c".to_string()];
4342        assert_eq!(chunk_pathspecs(&short), vec![vec!["a", "b", "c"]]);
4343
4344        // Two paths that individually fit but together exceed the budget split
4345        // into two singleton chunks, in order.
4346        let big_a = "a".repeat(ARGV_PATHSPEC_BUDGET - 100);
4347        let big_b = "b".repeat(ARGV_PATHSPEC_BUDGET - 100);
4348        let big = vec![big_a.clone(), big_b.clone()];
4349        assert_eq!(
4350            chunk_pathspecs(&big),
4351            vec![vec![big_a.as_str()], vec![big_b.as_str()]]
4352        );
4353
4354        // A single path already over budget on its own still gets a (singleton)
4355        // chunk — nothing shorter is possible.
4356        let huge = vec!["z".repeat(ARGV_PATHSPEC_BUDGET * 2)];
4357        assert_eq!(chunk_pathspecs(&huge), vec![vec![huge[0].as_str()]]);
4358
4359        assert!(chunk_pathspecs(&[]).is_empty());
4360    }
4361
4362    #[test]
4363    fn parse_commit_order_splits_on_nul_and_drops_trailing_empty() {
4364        assert_eq!(
4365            parse_commit_order("aaa1\0bbb2\0ccc3\0"),
4366            vec!["aaa1", "bbb2", "ccc3"]
4367        );
4368        assert!(parse_commit_order("").is_empty());
4369    }
4370
4371    #[tokio::test]
4372    async fn stash_push_adds_include_untracked() {
4373        let rec = RecordingRunner::replying(Reply::ok(""));
4374        let git = Git::with_runner(&rec);
4375        git.stash_push(Path::new("."), StashPush::new().include_untracked())
4376            .await
4377            .expect("stash");
4378        assert_eq!(
4379            rec.only_call().args_str(),
4380            ["stash", "push", "--include-untracked"]
4381        );
4382    }
4383
4384    // The default spec stashes tracked changes only — no `--include-untracked`.
4385    #[tokio::test]
4386    async fn stash_push_default_omits_include_untracked() {
4387        let rec = RecordingRunner::replying(Reply::ok(""));
4388        let git = Git::with_runner(&rec);
4389        git.stash_push(Path::new("."), StashPush::new())
4390            .await
4391            .expect("stash");
4392        assert_eq!(rec.only_call().args_str(), ["stash", "push"]);
4393    }
4394
4395    // `diff_text` for the working tree must build `diff HEAD` plus the stable
4396    // machine-output flags, in order.
4397    #[tokio::test]
4398    async fn diff_text_builds_working_tree_args() {
4399        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
4400        // targets HEAD. The probe is the first call; the diff is the last.
4401        let rec = RecordingRunner::replying(Reply::ok(""));
4402        let git = Git::with_runner(&rec);
4403        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
4404            .await
4405            .expect("diff_text");
4406        assert_eq!(
4407            rec.calls().last().unwrap().args_str(),
4408            [
4409                "diff",
4410                "HEAD",
4411                "--no-color",
4412                "--no-ext-diff",
4413                "-M",
4414                // Pin the parser's `a/`…`b/` headers against a user's
4415                // `diff.noprefix`/`diff.mnemonicPrefix` config.
4416                "--src-prefix=a/",
4417                "--dst-prefix=b/",
4418                // End-of-revisions: `HEAD` is a revision, never a pathspec.
4419                "--",
4420            ]
4421        );
4422    }
4423
4424    // On an unborn repo the working-tree diff targets the empty tree instead of
4425    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
4426    // empty-tree id is resolved from git (`hash-object`, so it is object-format
4427    // correct — not the hard-coded SHA-1 id), and the diff rule only matches that
4428    // resolved argv, so a `HEAD` target would miss it.
4429    #[tokio::test]
4430    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
4431        // A stand-in id `empty_tree_oid` "computes"; the diff must target exactly it.
4432        let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
4433        let git = Git::with_runner(
4434            ScriptedRunner::new()
4435                .on(["git", "rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
4436                .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
4437                .on(["git", "diff", oid], Reply::ok("EMPTY")),
4438        );
4439        let out = git
4440            .diff_text(Path::new("."), DiffSpec::WorkingTree)
4441            .await
4442            .expect("diff_text");
4443        assert_eq!(out, "EMPTY");
4444    }
4445
4446    // `empty_tree_oid` asks git to hash an empty tree (`hash-object -t tree
4447    // --stdin`), so the id tracks the repo's object format instead of being a
4448    // hard-coded SHA-1 constant. The `--stdin` (not `-w`) form only computes it.
4449    #[tokio::test]
4450    async fn empty_tree_oid_hashes_an_empty_tree() {
4451        let rec = RecordingRunner::replying(Reply::ok(format!("{EMPTY_TREE_SHA1}\n")));
4452        let git = Git::with_runner(&rec);
4453        let oid = git
4454            .empty_tree_oid(Path::new("."))
4455            .await
4456            .expect("empty_tree_oid");
4457        assert_eq!(oid, EMPTY_TREE_SHA1);
4458        assert_eq!(
4459            rec.only_call().args_str(),
4460            ["hash-object", "-t", "tree", "--stdin"]
4461        );
4462    }
4463
4464    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
4465    // canned git-format output.
4466    #[tokio::test]
4467    async fn diff_parses_scripted_output() {
4468        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
4469        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
4470        let files = git
4471            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
4472            .await
4473            .expect("diff");
4474        assert_eq!(files.len(), 1);
4475        assert_eq!(files[0].path, Path::new("m"));
4476        assert_eq!(files[0].change, ChangeKind::Modified);
4477    }
4478
4479    #[tokio::test]
4480    async fn branch_exists_maps_exit_codes() {
4481        let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
4482        assert!(
4483            yes.branch_exists(Path::new("."), &rn("main"))
4484                .await
4485                .unwrap()
4486        );
4487        let no =
4488            Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
4489        assert!(!no.branch_exists(Path::new("."), &rn("nope")).await.unwrap());
4490    }
4491
4492    // The full ref prefix is stripped but a slashed default branch survives; an
4493    // unset origin/HEAD (non-zero exit) is `None`, not an error.
4494    #[tokio::test]
4495    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
4496        let simple = Git::with_runner(ScriptedRunner::new().on(
4497            ["git", "symbolic-ref"],
4498            Reply::ok("refs/remotes/origin/main\n"),
4499        ));
4500        assert_eq!(
4501            simple
4502                .remote_head_branch(Path::new("."))
4503                .await
4504                .unwrap()
4505                .as_deref(),
4506            Some("main")
4507        );
4508
4509        let slashed = Git::with_runner(ScriptedRunner::new().on(
4510            ["git", "symbolic-ref"],
4511            Reply::ok("refs/remotes/origin/release/v2\n"),
4512        ));
4513        assert_eq!(
4514            slashed
4515                .remote_head_branch(Path::new("."))
4516                .await
4517                .unwrap()
4518                .as_deref(),
4519            Some("release/v2")
4520        );
4521
4522        let unset =
4523            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
4524        assert!(
4525            unset
4526                .remote_head_branch(Path::new("."))
4527                .await
4528                .unwrap()
4529                .is_none()
4530        );
4531    }
4532
4533    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
4534    // stdout as "absent".
4535    #[tokio::test]
4536    async fn remote_branch_exists_sets_env_and_reads_stdout() {
4537        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
4538        let git = Git::with_runner(&rec);
4539        assert!(
4540            git.remote_branch_exists(Path::new("/repo"), &rn("main"))
4541                .await
4542                .unwrap()
4543        );
4544        let call = rec.only_call();
4545        assert!(call.envs.iter().any(|(k, v)| {
4546            k.to_str() == Some("GIT_TERMINAL_PROMPT")
4547                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4548        }));
4549        // Exact-ref query — a bare `main` would tail-match `bar/main`.
4550        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
4551
4552        let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
4553        assert!(
4554            !empty
4555                .remote_branch_exists(Path::new("."), &rn("x"))
4556                .await
4557                .unwrap()
4558        );
4559    }
4560
4561    // The glob/control/`:`/space names `remote_branch_exists` must exclude are now
4562    // refused at `RefName` construction — an invalid value can't reach the method
4563    // at all, so the exclusion is enforced by the type rather than an ad-hoc guard.
4564    #[test]
4565    fn remote_branch_invalid_names_rejected_at_refname() {
4566        for name in [
4567            "",
4568            "feature/*",
4569            "feature/?",
4570            "feature/[a]",
4571            "a:b",
4572            "two words",
4573            "bad\nname",
4574        ] {
4575            let err = RefName::new(name).expect_err("invalid remote branch name must be rejected");
4576            assert!(vcs_cli_support::is_invalid_input(&err), "{name:?}");
4577        }
4578    }
4579
4580    #[tokio::test]
4581    async fn remote_branch_exists_accepts_valid_names() {
4582        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/feature/T-010_fix\n"));
4583        let git = Git::with_runner(&rec);
4584
4585        assert!(
4586            git.remote_branch_exists(Path::new("/repo"), &rn("feature/T-010_fix"))
4587                .await
4588                .expect("valid remote branch name")
4589        );
4590        assert_eq!(
4591            rec.only_call().args_str(),
4592            ["ls-remote", "origin", "refs/heads/feature/T-010_fix"]
4593        );
4594    }
4595
4596    // `remote_branch_exists` sets a per-command `Command::timeout` (10 s) so an
4597    // unreachable or hung remote can't wedge the call — the "bounded wait" its own
4598    // doc-comment promises. That bound is only hermetically testable because of the
4599    // processkit 2.1 guarantee that a `ScriptedRunner` **pending** reply on a bulk
4600    // verb (`output_string`) now honors `Command::timeout`; under the old 1.2.x
4601    // semantics a pending reply parked forever regardless of the deadline. On a
4602    // paused clock the command's 10 s deadline elapses in virtual time, so a hung
4603    // `ls-remote` resolves as "absent" (`false`, empty output) instead of hanging.
4604    // The outer 1 h guard turns a regression (pending parking forever) into a clear
4605    // failure rather than a wedged suite; the command's own 10 s bound fires first.
4606    #[tokio::test(start_paused = true)]
4607    async fn remote_branch_exists_bounded_wait_resolves_a_hung_remote() {
4608        let git =
4609            Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::pending()));
4610        let name = rn("main");
4611        let probe = git.remote_branch_exists(Path::new("/r"), &name);
4612        let exists = tokio::time::timeout(std::time::Duration::from_secs(3600), probe)
4613            .await
4614            .expect("the per-command 10 s timeout must resolve a hung ls-remote")
4615            .expect("a timed-out best-effort probe is `Ok(false)`, not an error");
4616        assert!(!exists, "an unreachable remote reads as absent");
4617    }
4618
4619    #[tokio::test]
4620    async fn diff_stat_parses_counts() {
4621        let git = Git::with_runner(ScriptedRunner::new().on(
4622            ["git", "diff", "--shortstat"],
4623            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
4624        ));
4625        let stat = git
4626            .diff_stat(Path::new("."), &rv("main..HEAD"))
4627            .await
4628            .unwrap();
4629        assert_eq!(
4630            (stat.files_changed, stat.insertions, stat.deletions),
4631            (2, 5, 1)
4632        );
4633    }
4634
4635    // The range-taking diff verbs terminate their argv with `--` so a `range`
4636    // that names a tracked path resolves as a revision (and errors) rather than
4637    // silently degrading into a pathspec-scoped working-tree diff (C2/M13).
4638    #[tokio::test]
4639    async fn diff_range_verbs_terminate_revisions_with_dashes() {
4640        let rec = RecordingRunner::replying(Reply::ok(""));
4641        let git = Git::with_runner(&rec);
4642        git.diff_range_is_empty(Path::new("/r"), &rv("main..HEAD"))
4643            .await
4644            .expect("diff_range_is_empty");
4645        assert_eq!(
4646            rec.only_call().args_str(),
4647            ["diff", "--quiet", "main..HEAD", "--"]
4648        );
4649
4650        let rec = RecordingRunner::replying(Reply::ok(" 0 files changed\n"));
4651        let git = Git::with_runner(&rec);
4652        git.diff_stat(Path::new("/r"), &rv("main..HEAD"))
4653            .await
4654            .expect("diff_stat");
4655        assert_eq!(
4656            rec.only_call().args_str(),
4657            ["diff", "--shortstat", "main..HEAD", "--"]
4658        );
4659    }
4660
4661    #[tokio::test]
4662    async fn status_text_returns_raw_porcelain() {
4663        let git = Git::with_runner(ScriptedRunner::new().on(
4664            ["git", "status", "--porcelain=v1"],
4665            Reply::ok(" M a.rs\n?? b.rs\n"),
4666        ));
4667        let text = git.status_text(Path::new(".")).await.expect("status_text");
4668        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
4669    }
4670
4671    #[tokio::test]
4672    async fn run_args_forwards_str_slices() {
4673        let git =
4674            Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
4675        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
4676    }
4677
4678    #[tokio::test]
4679    async fn merge_commit_builds_no_ff_and_message() {
4680        let rec = RecordingRunner::replying(Reply::ok(""));
4681        let git = Git::with_runner(&rec);
4682        git.merge_commit(
4683            Path::new("/r"),
4684            MergeCommit::branch(rv("feature"))
4685                .no_ff()
4686                .message("merge it"),
4687        )
4688        .await
4689        .unwrap();
4690        assert_eq!(
4691            rec.only_call().args_str(),
4692            ["merge", "--no-ff", "-m", "merge it", "feature"]
4693        );
4694    }
4695
4696    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
4697    #[tokio::test]
4698    async fn merge_commit_without_message_uses_no_edit() {
4699        let rec = RecordingRunner::replying(Reply::ok(""));
4700        let git = Git::with_runner(&rec);
4701        git.merge_commit(Path::new("/r"), MergeCommit::branch(rv("feature")))
4702            .await
4703            .unwrap();
4704        assert_eq!(
4705            rec.only_call().args_str(),
4706            ["merge", "--no-edit", "feature"]
4707        );
4708    }
4709
4710    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
4711    #[tokio::test]
4712    async fn rebase_suppresses_editor() {
4713        let rec = RecordingRunner::replying(Reply::ok(""));
4714        let git = Git::with_runner(&rec);
4715        git.rebase(Path::new("/r"), &rv("main")).await.unwrap();
4716        let call = rec.only_call();
4717        assert_eq!(call.args_str(), ["rebase", "main"]);
4718        assert!(call.envs.iter().any(|(k, v)| {
4719            k.to_str() == Some("GIT_EDITOR")
4720                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
4721        }));
4722    }
4723
4724    #[tokio::test]
4725    async fn push_builds_set_upstream_remote_refspec() {
4726        let rec = RecordingRunner::replying(Reply::ok(""));
4727        let git = Git::with_runner(&rec);
4728        git.push(
4729            Path::new("/r"),
4730            GitPush::refspec(&rn("feat"), &rn("feature")).set_upstream(),
4731        )
4732        .await
4733        .unwrap();
4734        assert_eq!(
4735            rec.only_call().args_str(),
4736            ["push", "-u", "origin", "feat:feature"]
4737        );
4738    }
4739
4740    // The common bare-branch push: `push origin <branch>` (no `-u`), with prompts
4741    // off so a credential-needing remote fails fast instead of hanging.
4742    #[tokio::test]
4743    async fn push_bare_branch_builds_origin_branch_prompt_off() {
4744        let rec = RecordingRunner::replying(Reply::ok(""));
4745        let git = Git::with_runner(&rec);
4746        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4747            .await
4748            .unwrap();
4749        let call = rec.only_call();
4750        assert_eq!(call.args_str(), ["push", "origin", "feature"]);
4751        assert!(call.envs.iter().any(|(k, v)| {
4752            k.to_str() == Some("GIT_TERMINAL_PROMPT")
4753                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4754        }));
4755    }
4756
4757    // M16: a `+` (force-push) or an extra `:` (multi-ref) smuggled into a branch name
4758    // is refused before spawning — force-pushing must be explicit via `run`.
4759    #[tokio::test]
4760    async fn push_rejects_force_and_multiref_metacharacters() {
4761        let rec = RecordingRunner::replying(Reply::ok(""));
4762        let git = Git::with_runner(&rec);
4763        // A `:` (an extra ref) is not a legal `RefName` character, so a multi-ref
4764        // refspec is refused at construction — it can never reach `GitPush`.
4765        for bad in ["+main:main", "a:b:c", "main:prod"] {
4766            assert!(RefName::new(bad).is_err(), "{bad:?} carries a `:`");
4767        }
4768        // A leading `+` (force) IS a legal ref character, so it passes `RefName` —
4769        // but the push refspec guard still refuses it before spawning. A force-push
4770        // must be explicit via `run`.
4771        assert!(
4772            git.push(Path::new("/r"), GitPush::branch(rn("+main")))
4773                .await
4774                .is_err(),
4775            "a force refspec must be refused before spawning"
4776        );
4777        // A legitimate `local:remote` refspec still works (the single `:` is the
4778        // API-inserted separator between two validated `RefName`s).
4779        assert!(
4780            git.push(Path::new("/r"), GitPush::refspec(&rn("main"), &rn("prod")))
4781                .await
4782                .is_ok()
4783        );
4784        assert!(
4785            rec.calls()
4786                .iter()
4787                .all(|c| c.args_str().last().unwrap() != "+main")
4788        );
4789    }
4790
4791    // `.remote()` swaps the remote token in place.
4792    #[tokio::test]
4793    async fn push_remote_override_swaps_remote() {
4794        let rec = RecordingRunner::replying(Reply::ok(""));
4795        let git = Git::with_runner(&rec);
4796        git.push(
4797            Path::new("/r"),
4798            GitPush::branch(rn("feature")).remote("upstream"),
4799        )
4800        .await
4801        .unwrap();
4802        assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
4803    }
4804
4805    // With a credential provider, a remote op gets a leading `-c credential.helper`
4806    // pair (the secret referenced by env-var NAME) plus the secret in the env — and
4807    // the token value never appears in argv. Covers push (mutating) and fetch.
4808    #[tokio::test]
4809    async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
4810        let rec = RecordingRunner::replying(Reply::ok(""));
4811        let git = Git::with_runner(&rec)
4812            .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
4813        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4814            .await
4815            .unwrap();
4816        let call = rec.only_call();
4817        let args = call.args_str();
4818        // A leading helper-reset + inline helper precede the subcommand.
4819        assert_eq!(args[0], "-c", "config flag leads the argv");
4820        assert!(
4821            args.iter().any(|a| a == "credential.helper="),
4822            "inherited helpers are cleared first: {args:?}"
4823        );
4824        assert!(
4825            args.iter()
4826                .any(|a| a.contains("credential.helper=!f()")
4827                    && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
4828            "inline helper references the secret by env-var name: {args:?}"
4829        );
4830        assert!(
4831            args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
4832            "the real subcommand still runs: {args:?}"
4833        );
4834        // The secret value is NEVER in argv.
4835        assert!(
4836            !args.iter().any(|a| a.contains("ghp_secret123")),
4837            "secret leaked into argv: {args:?}"
4838        );
4839        // The secret lives in the env, under the helper's var name.
4840        let pw = call
4841            .envs
4842            .iter()
4843            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
4844            .and_then(|(_, v)| v.as_ref())
4845            .and_then(|v| v.to_str());
4846        assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
4847    }
4848
4849    // Without a provider, remote ops are byte-identical to before — no `-c`
4850    // credential helper, no secret env (ambient git auth, unchanged).
4851    #[tokio::test]
4852    async fn default_client_injects_no_credential_helper() {
4853        let rec = RecordingRunner::replying(Reply::ok(""));
4854        let git = Git::with_runner(&rec);
4855        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4856            .await
4857            .unwrap();
4858        let call = rec.only_call();
4859        assert_eq!(
4860            call.args_str(),
4861            ["push", "origin", "feature"],
4862            "no credential `-c` args without a provider"
4863        );
4864        assert!(
4865            !call
4866                .envs
4867                .iter()
4868                .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
4869            "no secret env without a provider"
4870        );
4871    }
4872
4873    // `clone_repo` builds its argv via a different path (`command()` + `.arg()`
4874    // chaining, not `command_in` + extend), so verify the `-c` credential args
4875    // still LEAD it and the real clone flags/url/dest follow the subcommand.
4876    #[tokio::test]
4877    async fn with_credentials_clone_puts_config_flags_before_subcommand() {
4878        let rec = RecordingRunner::replying(Reply::ok(""));
4879        let git =
4880            Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
4881        git.clone_repo(
4882            "https://example.com/r.git",
4883            Path::new("/dest"),
4884            CloneSpec::default().branch("main"),
4885        )
4886        .await
4887        .unwrap();
4888        let call = rec.only_call();
4889        let args = call.args_str();
4890        assert_eq!(args[0], "-c", "config flags lead the clone argv");
4891        let clone_at = args
4892            .iter()
4893            .position(|a| a == "clone")
4894            .expect("clone present");
4895        // Only credential `-c` flags precede the `clone` subcommand.
4896        assert!(
4897            args[..clone_at]
4898                .iter()
4899                .all(|a| a == "-c" || a.starts_with("credential.helper")),
4900            "only credential -c flags precede `clone`: {args:?}"
4901        );
4902        // The real clone flags/url/dest follow the subcommand.
4903        let tail = &args[clone_at..];
4904        assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
4905        assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
4906        assert!(
4907            !args.iter().any(|a| a.contains("s3cr3t")),
4908            "secret not in argv"
4909        );
4910        // H5: clone scopes the helper to the URL's host (in env, never argv), so a
4911        // cross-host redirect/submodule during the clone can't extract the token.
4912        let host = call
4913            .envs
4914            .iter()
4915            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
4916            .and_then(|(_, v)| v.as_ref())
4917            .and_then(|v| v.to_str());
4918        assert_eq!(
4919            host,
4920            Some("example.com"),
4921            "the clone URL's host scopes the credential helper"
4922        );
4923        // The host scoping travels in env; the credential `-c` flags that precede
4924        // `clone` must not bake the host into the helper config.
4925        assert!(
4926            args[..clone_at].iter().all(|a| !a.contains("example.com")),
4927            "host stays in env, not the credential config args: {:?}",
4928            &args[..clone_at]
4929        );
4930    }
4931
4932    // A `Credential::userpass` username threads through to the helper's env on a
4933    // remote op (here `fetch`) — the non-default-username path, end-to-end.
4934    #[tokio::test]
4935    async fn with_credentials_userpass_threads_username_through_env() {
4936        let rec = RecordingRunner::replying(Reply::ok(""));
4937        let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
4938            Credential::userpass("alice", "s3cr3t"),
4939        )));
4940        git.fetch(Path::new("/r")).await.unwrap();
4941        let call = rec.only_call();
4942        let user = call
4943            .envs
4944            .iter()
4945            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
4946            .and_then(|(_, v)| v.as_ref())
4947            .and_then(|v| v.to_str());
4948        assert_eq!(user, Some("alice"), "userpass username reaches the env");
4949        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
4950        assert!(call.args_str().contains(&"fetch".to_string()));
4951    }
4952
4953    // ONE client, several hosts: a host-keyed provider hands each clone only its OWN
4954    // host's secret — routed by the URL's host, which now reaches the
4955    // `CredentialRequest` — and the inline helper is gated to that host, so a
4956    // neighbouring instance's token can never leak into another host's clone. (T-045)
4957    #[tokio::test]
4958    async fn one_client_host_keyed_provider_isolates_tokens_across_hosts() {
4959        let provider = Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
4960            Ok(match r.host {
4961                Some("github.com") => Some(Credential::token("gh-secret")),
4962                Some("gitlab.example") => Some(Credential::token("gl-secret")),
4963                _ => None,
4964            })
4965        }));
4966        let rec = RecordingRunner::replying(Reply::ok(""));
4967        let git = Git::with_runner(&rec).with_credentials(provider);
4968
4969        // The same client clones two different hosts, back to back.
4970        git.clone_repo(
4971            "https://github.com/o/r.git",
4972            Path::new("/dest-gh"),
4973            CloneSpec::default(),
4974        )
4975        .await
4976        .unwrap();
4977        git.clone_repo(
4978            "https://gitlab.example/o/r.git",
4979            Path::new("/dest-gl"),
4980            CloneSpec::default(),
4981        )
4982        .await
4983        .unwrap();
4984
4985        let calls = rec.calls();
4986        assert_eq!(calls.len(), 2, "two clones recorded");
4987        // Clone #1 (github.com) → the github.com secret, gated to github.com.
4988        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
4989        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_HOST", "github.com"));
4990        // Clone #2 (gitlab.example) → the gitlab secret, gated to gitlab.example.
4991        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
4992        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_HOST", "gitlab.example"));
4993        // No cross-contamination: neither host's secret bleeds into the other's
4994        // clone, and no secret ever reaches argv.
4995        assert!(!calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
4996        assert!(!calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
4997        for c in calls.iter() {
4998            assert!(
4999                !c.args_str()
5000                    .iter()
5001                    .any(|a| a.contains("gh-secret") || a.contains("gl-secret")),
5002                "secrets stay out of argv"
5003            );
5004        }
5005    }
5006
5007    // Fallback policy on the git helper path, read (`fetch`) vs write (`push`):
5008    // `Ok(None)` → ambient (no inline credential helper, no secret env); `Err` →
5009    // fail-closed abort (git never spawns). (T-045)
5010    #[tokio::test]
5011    async fn git_credential_fallback_policy_for_read_and_write() {
5012        // Ok(None): ambient — no helper `-c` flags lead the argv, no secret env.
5013        let rec = RecordingRunner::replying(Reply::ok(""));
5014        let git = Git::with_runner(&rec)
5015            .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))));
5016        git.fetch(Path::new("/r")).await.unwrap();
5017        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5018            .await
5019            .unwrap();
5020        for c in rec.calls().iter() {
5021            assert!(
5022                !c.has_env("VCS_TOOLKIT_GIT_PASSWORD"),
5023                "ambient: no secret env on {:?}",
5024                c.args_str()
5025            );
5026            assert_ne!(
5027                c.args_str().first().map(String::as_str),
5028                Some("-c"),
5029                "ambient: no leading credential -c flags"
5030            );
5031        }
5032
5033        // Err: fail-closed — the op aborts and git is never spawned, for read & write.
5034        let rec = RecordingRunner::replying(Reply::ok(""));
5035        let git = Git::with_runner(&rec).with_credentials(Arc::new(provider_fn(
5036            |_r: &CredentialRequest<'_>| {
5037                Err(Error::spawn(
5038                    BINARY,
5039                    std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
5040                ))
5041            },
5042        )));
5043        assert!(
5044            git.fetch(Path::new("/r")).await.is_err(),
5045            "read aborts on provider error"
5046        );
5047        assert!(
5048            git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5049                .await
5050                .is_err(),
5051            "write aborts on provider error"
5052        );
5053        assert!(
5054            rec.calls().is_empty(),
5055            "git never spawns when the provider errored"
5056        );
5057    }
5058
5059    // No-provider is byte-identical for the read/clone arg-construction paths too,
5060    // not only `push` (fetch uses `command_in`+extend; clone uses `command`+chain).
5061    #[tokio::test]
5062    async fn default_client_no_helper_on_fetch_and_clone() {
5063        let rec = RecordingRunner::replying(Reply::ok(""));
5064        Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
5065        assert_eq!(
5066            rec.only_call().args_str(),
5067            ["fetch", "--quiet"],
5068            "fetch unchanged without a provider"
5069        );
5070
5071        let rec = RecordingRunner::replying(Reply::ok(""));
5072        Git::with_runner(&rec)
5073            .clone_repo(
5074                "https://example.com/r.git",
5075                Path::new("/dest"),
5076                CloneSpec::default(),
5077            )
5078            .await
5079            .unwrap();
5080        assert_eq!(
5081            rec.only_call().args_str()[0],
5082            "clone",
5083            "clone leads with the subcommand (no `-c`) without a provider"
5084        );
5085    }
5086
5087    // The `with_token` convenience drives the same HTTPS credential.helper path as
5088    // `with_credentials` (secret in env, helper `-c` leads, not in argv).
5089    #[tokio::test]
5090    async fn with_token_convenience_authenticates_https_remote() {
5091        let rec = RecordingRunner::replying(Reply::ok(""));
5092        let git = Git::with_runner(&rec).with_token("ghp_conv");
5093        git.fetch(Path::new("/r")).await.unwrap();
5094        let call = rec.only_call();
5095        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
5096        let pw = call
5097            .envs
5098            .iter()
5099            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
5100            .and_then(|(_, v)| v.as_ref())
5101            .and_then(|v| v.to_str());
5102        assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
5103        assert!(
5104            !call.args_str().iter().any(|a| a.contains("ghp_conv")),
5105            "secret not in argv"
5106        );
5107    }
5108
5109    #[tokio::test]
5110    async fn upstream_distinguishes_no_upstream_from_errors() {
5111        let set = Git::with_runner(
5112            ScriptedRunner::new()
5113                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5114                .on(["git", "rev-parse"], Reply::ok("origin/main\n")),
5115        );
5116        assert_eq!(
5117            set.upstream(Path::new(".")).await.unwrap().as_deref(),
5118            Some("origin/main")
5119        );
5120        // On a valid attached branch, exit 128 from `@{u}` means no upstream.
5121        let unset = Git::with_runner(
5122            ScriptedRunner::new()
5123                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5124                .on(["git", "rev-parse"], Reply::fail(128, "")),
5125        );
5126        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
5127
5128        // Detached HEAD is rejected by the attached-branch probe.
5129        let detached =
5130            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
5131        assert!(detached.upstream(Path::new(".")).await.is_err());
5132
5133        // A directory outside a repository is a real error too.
5134        let not_repo = Git::with_runner(ScriptedRunner::new().on(
5135            ["git", "symbolic-ref"],
5136            Reply::fail(128, "fatal: not a git repository"),
5137        ));
5138        assert!(not_repo.upstream(Path::new(".")).await.is_err());
5139
5140        // Other numeric failures and no-code outcomes must not read as "unset".
5141        let broken = Git::with_runner(
5142            ScriptedRunner::new()
5143                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5144                .on(["git", "rev-parse"], Reply::fail(1, "corrupt config")),
5145        );
5146        assert!(broken.upstream(Path::new(".")).await.is_err());
5147
5148        let timed_out = Git::with_runner(
5149            ScriptedRunner::new()
5150                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5151                .on(["git", "rev-parse"], Reply::timeout()),
5152        );
5153        assert!(timed_out.upstream(Path::new(".")).await.is_err());
5154
5155        let signalled = Git::with_runner(
5156            ScriptedRunner::new()
5157                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5158                .on(["git", "rev-parse"], Reply::signalled(Some(9))),
5159        );
5160        assert!(signalled.upstream(Path::new(".")).await.is_err());
5161    }
5162
5163    // remote_head_branch maps the `symbolic-ref --quiet` exit code: 0 → the branch
5164    // (ref prefix stripped), 1 → None (unset origin/HEAD), and anything else (a real
5165    // failure / timeout) surfaces rather than being swallowed as "no default branch".
5166    #[tokio::test]
5167    async fn remote_head_branch_maps_exit_codes() {
5168        let set = Git::with_runner(ScriptedRunner::new().on(
5169            ["git", "symbolic-ref"],
5170            Reply::ok("refs/remotes/origin/release/v2\n"),
5171        ));
5172        assert_eq!(
5173            set.remote_head_branch(Path::new("."))
5174                .await
5175                .unwrap()
5176                .as_deref(),
5177            Some("release/v2"),
5178            "the full ref prefix is stripped, slashes preserved"
5179        );
5180        let unset =
5181            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
5182        assert!(
5183            unset
5184                .remote_head_branch(Path::new("."))
5185                .await
5186                .unwrap()
5187                .is_none()
5188        );
5189        // A real failure (exit 128, not the silent --quiet exit 1) surfaces.
5190        let err = Git::with_runner(ScriptedRunner::new().on(
5191            ["git", "symbolic-ref"],
5192            Reply::fail(128, "fatal: not a git repository"),
5193        ));
5194        assert!(err.remote_head_branch(Path::new(".")).await.is_err());
5195        // A timeout surfaces too.
5196        let timed_out =
5197            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
5198        assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
5199    }
5200
5201    #[tokio::test]
5202    async fn set_upstream_builds_branch_flag() {
5203        let rec = RecordingRunner::replying(Reply::ok(""));
5204        let git = Git::with_runner(&rec);
5205        git.set_upstream(Path::new("/r"), &rn("feat"), &rn("origin/feature"))
5206            .await
5207            .unwrap();
5208        assert_eq!(
5209            rec.only_call().args_str(),
5210            ["branch", "--set-upstream-to=origin/feature", "feat"]
5211        );
5212    }
5213
5214    #[tokio::test]
5215    async fn remote_branches_parses_ls_remote() {
5216        let git = Git::with_runner(ScriptedRunner::new().on(
5217            ["git", "ls-remote"],
5218            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
5219        ));
5220        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
5221        assert_eq!(branches, ["main", "feat/x"]);
5222    }
5223
5224    #[tokio::test]
5225    async fn delete_branch_force_uses_capital_d() {
5226        let rec = RecordingRunner::replying(Reply::ok(""));
5227        let git = Git::with_runner(&rec);
5228        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")).force())
5229            .await
5230            .unwrap();
5231        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
5232    }
5233
5234    // The default (un-forced) spec uses lowercase `-d`.
5235    #[tokio::test]
5236    async fn delete_branch_default_uses_lowercase_d() {
5237        let rec = RecordingRunner::replying(Reply::ok(""));
5238        let git = Git::with_runner(&rec);
5239        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")))
5240            .await
5241            .unwrap();
5242        assert_eq!(rec.only_call().args_str(), ["branch", "-d", "old"]);
5243    }
5244
5245    // `branch --merged` marks the current branch with `*` and a branch checked out
5246    // in another worktree with `+`; both must still match after marker stripping.
5247    #[tokio::test]
5248    async fn is_merged_strips_branch_markers() {
5249        let git = Git::with_runner(ScriptedRunner::new().on(
5250            ["git", "branch", "--merged"],
5251            Reply::ok("  main\n* feature\n+ wt-branch\n"),
5252        ));
5253        for name in ["main", "feature", "wt-branch"] {
5254            assert!(
5255                git.is_merged(
5256                    Path::new("."),
5257                    MergeCheck::branch(rn(name)).into_base(rv("main"))
5258                )
5259                .await
5260                .unwrap(),
5261                "{name} should be reported merged"
5262            );
5263        }
5264        assert!(
5265            !git.is_merged(
5266                Path::new("."),
5267                MergeCheck::branch(rn("absent")).into_base(rv("main"))
5268            )
5269            .await
5270            .unwrap()
5271        );
5272    }
5273
5274    // A5: the `MergeCheck` builder lands branch/base in the right slots, and
5275    // `is_merged` queries `branch --merged <base>` — so a transposed pair would
5276    // change the emitted command, not silently invert a same-shaped call.
5277    #[tokio::test]
5278    async fn merge_check_names_branch_and_base_without_transposition() {
5279        use processkit::testing::RecordingRunner;
5280        let spec = MergeCheck::branch(rn("feature")).into_base(rv("main"));
5281        assert_eq!(spec.branch.as_str(), "feature");
5282        assert_eq!(spec.base.as_str(), "main");
5283
5284        let rec = RecordingRunner::replying(Reply::ok("  feature\n* main\n"));
5285        let merged = Git::with_runner(&rec)
5286            .is_merged(
5287                Path::new("/repo"),
5288                MergeCheck::branch(rn("feature")).into_base(rv("main")),
5289            )
5290            .await
5291            .unwrap();
5292        // `feature` appears under `branch --merged main`, so it reports merged — and
5293        // the emitted args put `base` (main) in the `--merged` slot, not `branch`.
5294        assert!(merged, "feature is listed as merged into main");
5295        assert_eq!(
5296            rec.only_call().args_str(),
5297            ["branch", "--merged", "main", "--no-column", "--no-color"]
5298        );
5299    }
5300
5301    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
5302    // a remote needing auth — matching the other remote ops.
5303    #[tokio::test]
5304    async fn fetch_disables_terminal_prompt() {
5305        let rec = RecordingRunner::replying(Reply::ok(""));
5306        let git = Git::with_runner(&rec);
5307        git.fetch(Path::new("/r")).await.unwrap();
5308        let call = rec.only_call();
5309        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
5310        assert!(call.envs.iter().any(|(k, v)| {
5311            k.to_str() == Some("GIT_TERMINAL_PROMPT")
5312                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
5313        }));
5314    }
5315
5316    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
5317    #[tokio::test]
5318    async fn fetch_retries_transient_failures() {
5319        let rec = RecordingRunner::replying(Reply::fail(
5320            128,
5321            "fatal: unable to access: Could not resolve host: example.com",
5322        ));
5323        let git = Git::with_runner(&rec);
5324        assert!(git.fetch(Path::new("/r")).await.is_err());
5325        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
5326    }
5327
5328    // R6 (a `fetch` timeout is NOT retried) is pinned at the unit level by
5329    // `vcs_cli_support`'s `classifies_nothing_to_commit_and_transient_fetch`
5330    // (`is_transient_fetch_error(&Timeout) == false`); together with
5331    // `fetch_retries_transient_failures` above (the loop retries exactly what the
5332    // predicate accepts) that proves the timeout is terminal for the fetch-retry. A
5333    // faithful end-to-end timeout is awkward to simulate hermetically (a paused-clock
5334    // `Reply::pending()` doesn't auto-fire the per-command deadline), so it isn't
5335    // duplicated here.
5336
5337    // Opt-in lock-contention retry: a mutation that fails because another process
5338    // holds `index.lock` is retried and succeeds — the command never ran, so the
5339    // retry is safe. `RetryPolicy::none().attempts(3)` keeps the backoff at zero so
5340    // the test never sleeps.
5341    #[tokio::test]
5342    async fn with_retry_retries_lock_contention_on_a_mutation() {
5343        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5344            ["git", "commit"],
5345            [
5346                Reply::fail(
5347                    128,
5348                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
5349                ),
5350                Reply::ok(""),
5351            ],
5352        ));
5353        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
5354        git.commit(Path::new("/r"), "msg")
5355            .await
5356            .expect("retried past the lock");
5357        assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
5358    }
5359
5360    // Retry is off by default — the same lock failure propagates without `with_retry`.
5361    #[tokio::test]
5362    async fn default_client_does_not_retry_lock_contention() {
5363        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5364            ["git", "commit"],
5365            [
5366                Reply::fail(
5367                    128,
5368                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
5369                ),
5370                Reply::ok(""),
5371            ],
5372        ));
5373        let git = Git::with_runner(&rec);
5374        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
5375        assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
5376    }
5377
5378    // Even with retry on, a real (non-lock) failure is returned immediately — only
5379    // lock contention is retried, so a genuine error is never silently repeated.
5380    #[tokio::test]
5381    async fn with_retry_does_not_retry_a_real_failure() {
5382        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5383            ["git", "commit"],
5384            [
5385                Reply::fail(1, "error: pathspec 'x' did not match"),
5386                Reply::ok(""),
5387            ],
5388        ));
5389        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
5390        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
5391        assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
5392    }
5393
5394    // A non-transient failure fails fast — no retry.
5395    #[tokio::test]
5396    async fn fetch_does_not_retry_permanent_failures() {
5397        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
5398        let git = Git::with_runner(&rec);
5399        assert!(git.fetch(Path::new("/r")).await.is_err());
5400        assert_eq!(rec.calls().len(), 1);
5401    }
5402
5403    // Client-level cancellation (processkit 0.8 `cancellation` feature) on a
5404    // *retried* op: a `fetch` built on a client with `default_cancel_on(token)`
5405    // parks until the token fires, then surfaces `Error::Cancelled` — and because
5406    // cancellation is **terminal** (not transient), the fetch-retry does NOT
5407    // replay it (one spawn, not FETCH_ATTEMPTS). Hermetic via `Reply::pending()`
5408    // on a paused clock.
5409    #[tokio::test(start_paused = true)]
5410    async fn fetch_cancels_and_does_not_retry() {
5411        use processkit::CancellationToken;
5412        let token = CancellationToken::new();
5413        let rec =
5414            RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
5415        let git = Git::with_runner(&rec).default_cancel_on(token.clone());
5416        let call = git.fetch(Path::new("/r"));
5417        tokio::pin!(call);
5418        assert!(
5419            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
5420                .await
5421                .is_err(),
5422            "fetch must park until the token fires"
5423        );
5424        token.cancel();
5425        assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
5426        assert_eq!(
5427            rec.calls().len(),
5428            1,
5429            "cancellation is terminal — the fetch-retry must not replay it"
5430        );
5431    }
5432
5433    // The injection barrier now has two tiers:
5434    //  1. ref-name / revision inputs are validated NEWTYPES, so a flag-like or
5435    //     malformed value is rejected at *construction* — it can never reach an
5436    //     argv slot (migration tests below); and
5437    //  2. the remaining bare-positional `&str` inputs that are not refs/revisions
5438    //     (remote names, URLs, config keys) keep the internal `reject_flag_like`
5439    //     guard, refused before anything spawns.
5440
5441    // Tier 1 — the newtypes reject the flag-like / malformed values the typed ops
5442    // would otherwise have received, as a classifiable invalid-input error.
5443    #[test]
5444    fn validated_ref_and_rev_newtypes_reject_bad_values() {
5445        // RefName: the load-bearing core of `check-ref-format`.
5446        for ok in ["main", "feature/x", "origin/main", "v1.2.3", "a-b_c"] {
5447            assert!(RefName::new(ok).is_ok(), "{ok}");
5448        }
5449        for bad in [
5450            "", "-evil", "--force", "-D", "-bad", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b",
5451            "a?b", "a*b", "a[b", "a\\b", "end/", "x.lock",
5452        ] {
5453            let err = RefName::new(bad).expect_err(&format!("{bad:?} must be rejected"));
5454            assert!(
5455                vcs_cli_support::is_invalid_input(&err),
5456                "{bad:?} must classify as invalid input"
5457            );
5458        }
5459        // RevSpec: non-empty and not flag-shaped (git's revision grammar is
5460        // otherwise too rich to validate here), so `-` special values are refused.
5461        for ok in ["HEAD", "HEAD~2", "main..feature", "@{-1}", "abc123"] {
5462            assert!(RevSpec::new(ok).is_ok(), "{ok}");
5463        }
5464        for bad in ["", "-evil", "-i", "-n", "-s"] {
5465            let err = RevSpec::new(bad).expect_err(&format!("{bad:?} must be rejected"));
5466            assert!(vcs_cli_support::is_invalid_input(&err), "{bad:?}");
5467        }
5468    }
5469
5470    // Tier 2 — the ops that still take a bare `&str` (remote names, URLs, config
5471    // keys) refuse a flag-like value BEFORE anything spawns. `DiffSpec::Rev` is a
5472    // shared cross-backend `String`, so it keeps the same internal guard.
5473    #[tokio::test]
5474    async fn str_positionals_are_rejected_before_spawning() {
5475        let rec = RecordingRunner::replying(Reply::ok(""));
5476        let git = Git::with_runner(&rec);
5477        let dir = Path::new("/r");
5478
5479        assert!(git.config_set(dir, "-evil", "v").await.is_err());
5480        assert!(git.config_get(dir, "-evil").await.is_err());
5481        assert!(git.remote_url(dir, "-evil").await.is_err());
5482        assert!(git.remote_branches(dir, "-evil").await.is_err());
5483        assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
5484        assert!(git.remote_add(dir, "-evil", "url").await.is_err());
5485        assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
5486        assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
5487        assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
5488        // A leading-`-` url is an RCE-class flag injection.
5489        assert!(
5490            git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
5491                .await
5492                .is_err()
5493        );
5494        // `DiffSpec::Rev` carries a raw (internally-guarded) revision string.
5495        assert!(
5496            git.diff_text(dir, DiffSpec::Rev("-evil".into()))
5497                .await
5498                .is_err()
5499        );
5500
5501        assert!(
5502            rec.calls().is_empty(),
5503            "nothing may spawn: {:?}",
5504            rec.calls()
5505        );
5506    }
5507
5508    // A legitimate ref/revision still flows through the typed path unchanged (with
5509    // the trailing `--` that keeps a path-like ref out of pathspec mode — C2), and
5510    // git's `-` "previous branch" is carried safely as `CheckoutTarget::Previous`
5511    // (a fixed literal, not caller-controlled argv).
5512    #[tokio::test]
5513    async fn typed_checkout_targets_pass_through() {
5514        let rec = RecordingRunner::replying(Reply::ok(""));
5515        let git = Git::with_runner(&rec);
5516        git.checkout(Path::new("/r"), &CheckoutTarget::Ref(rv("feature/x")))
5517            .await
5518            .expect("checkout");
5519        assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
5520
5521        let rec = RecordingRunner::replying(Reply::ok(""));
5522        let git = Git::with_runner(&rec);
5523        git.checkout(Path::new("/r"), &CheckoutTarget::Previous)
5524            .await
5525            .expect("checkout -");
5526        assert_eq!(rec.only_call().args_str(), ["checkout", "-", "--"]);
5527    }
5528
5529    // The hardened profile lands its env pairs/removals on EVERY command, and
5530    // composes with per-command env like GIT_TERMINAL_PROMPT.
5531    #[tokio::test]
5532    async fn harden_applies_env_profile_to_every_command() {
5533        let rec = RecordingRunner::replying(Reply::ok(""));
5534        let git = Git::with_runner(&rec).harden();
5535        git.status(Path::new("/r")).await.expect("status");
5536        git.fetch(Path::new("/r")).await.expect("fetch");
5537
5538        for call in rec.calls() {
5539            let has = |k: &str, v: &str| {
5540                call.envs.iter().any(|(key, val)| {
5541                    key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
5542                })
5543            };
5544            let removed = |k: &str| {
5545                call.envs
5546                    .iter()
5547                    .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
5548            };
5549            assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
5550            assert!(has("GIT_CONFIG_COUNT", "3"));
5551            assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
5552            assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
5553            assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
5554            // The repo-local core.sshCommand kill-switch (pinned empty).
5555            assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
5556            assert!(has("GIT_CONFIG_VALUE_2", ""));
5557            assert!(has("GIT_TERMINAL_PROMPT", "0"));
5558            assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
5559            assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
5560            // Command-hook env vectors are scrubbed too.
5561            assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
5562            assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
5563            assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
5564            assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
5565            // M14: the additional code-execution vectors + pathspec-mode vars.
5566            assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
5567            assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
5568            assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
5569            assert!(
5570                removed("GIT_ICASE_PATHSPECS"),
5571                "GIT_ICASE_PATHSPECS scrubbed"
5572            );
5573        }
5574    }
5575
5576    // H4: EVERY git client (not just `harden()`) scrubs the repo-**redirector** env
5577    // vars, so a `GIT_DIR`/`GIT_INDEX_FILE` leaking from the parent (e.g. running
5578    // inside a git hook, which exports them) can't silently retarget a command at a
5579    // different repository than the bound `dir`. The command-hook scrubs and config
5580    // pins stay `harden()`-only.
5581    #[tokio::test]
5582    async fn default_client_scrubs_repo_redirector_env() {
5583        let rec = RecordingRunner::replying(Reply::ok(""));
5584        let git = Git::with_runner(&rec); // NOT hardened
5585        git.status(Path::new("/r")).await.expect("status");
5586        let call = rec.only_call();
5587        let removed = |k: &str| {
5588            call.envs
5589                .iter()
5590                .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
5591        };
5592        let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
5593        for var in [
5594            "GIT_DIR",
5595            "GIT_WORK_TREE",
5596            "GIT_INDEX_FILE",
5597            "GIT_COMMON_DIR",
5598            "GIT_OBJECT_DIRECTORY",
5599            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
5600            "GIT_NAMESPACE",
5601        ] {
5602            assert!(removed(var), "{var} must be scrubbed on the default client");
5603        }
5604        // `harden()`-only surface is absent on a plain client.
5605        assert!(
5606            !has_key("GIT_SSH_COMMAND"),
5607            "command-hook scrub is harden()-only"
5608        );
5609        assert!(
5610            !has_key("GIT_CONFIG_NOSYSTEM"),
5611            "config pins are harden()-only"
5612        );
5613    }
5614
5615    // RefName/RevSpec accept/reject tables.
5616    #[test]
5617    fn ref_name_and_rev_spec_validate() {
5618        for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
5619            assert!(RefName::new(ok).is_ok(), "{ok}");
5620        }
5621        for bad in [
5622            "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
5623            "a\\b", "end/", "x.lock",
5624        ] {
5625            assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
5626        }
5627        assert!(RevSpec::new("HEAD~2").is_ok());
5628        assert!(RevSpec::new("main..feature").is_ok());
5629        assert!(RevSpec::new("-evil").is_err());
5630        assert!(RevSpec::new("").is_err());
5631    }
5632
5633    // capabilities parses real-world version shapes (incl. the Windows build
5634    // trailer) and gates on the real (2, 31) major.minor floor.
5635    #[tokio::test]
5636    async fn capabilities_parse_and_gate_versions() {
5637        let gh = Git::with_runner(ScriptedRunner::new().on(
5638            ["git", "--version"],
5639            Reply::ok("git version 2.54.0.windows.1\n"),
5640        ));
5641        let caps = gh.capabilities().await.expect("capabilities");
5642        assert_eq!(caps.version.to_string(), "2.54.0");
5643        assert!(caps.is_supported());
5644        caps.ensure_supported().expect("supported");
5645
5646        // Two-part versions parse (patch defaults to 0); an ancient major fails
5647        // the gate with a clear message.
5648        let old = Git::with_runner(
5649            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
5650        );
5651        let caps = old.capabilities().await.expect("capabilities");
5652        assert_eq!(
5653            caps.version,
5654            GitVersion {
5655                major: 1,
5656                minor: 9,
5657                patch: 0
5658            }
5659        );
5660        let err = caps.ensure_supported().expect_err("unsupported");
5661        // The message must name the floor and the found version.
5662        let Error::Spawn { source, .. } = &err else {
5663            panic!("expected Spawn, got {err:?}");
5664        };
5665        let message = source.to_string();
5666        assert!(message.contains(">= 2"), "names the floor: {message}");
5667        assert!(
5668            message.contains("1.9.0"),
5669            "names the found version: {message}"
5670        );
5671
5672        // M29: a 2.x git BELOW the 2.31 minor floor is now rejected too — the crate's
5673        // argv (harden's GIT_CONFIG_COUNT, porcelain=v2, stash push) needs ≥ 2.31, so a
5674        // major-only gate that passed 2.7 then failed later with a cryptic argv error.
5675        let mid = Git::with_runner(
5676            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 2.7.4\n")),
5677        );
5678        let caps = mid.capabilities().await.expect("capabilities");
5679        assert!(!caps.is_supported(), "2.7.4 is below the 2.31 floor");
5680        let err = caps.ensure_supported().expect_err("2.7.4 unsupported");
5681        let Error::Spawn { source, .. } = &err else {
5682            panic!("expected Spawn, got {err:?}");
5683        };
5684        assert!(
5685            source.to_string().contains(">= 2.31"),
5686            "names the 2.31 floor"
5687        );
5688
5689        // Garbage output is a parse error, not a silent zero version.
5690        let garbage = Git::with_runner(
5691            ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
5692        );
5693        assert!(matches!(
5694            garbage.capabilities().await.unwrap_err(),
5695            Error::Parse { .. }
5696        ));
5697    }
5698
5699    // clone_repo is dir-less and appends only the requested flags.
5700    #[tokio::test]
5701    async fn clone_repo_builds_flags_and_runs_dirless() {
5702        let rec = RecordingRunner::replying(Reply::ok(""));
5703        let git = Git::with_runner(&rec);
5704        git.clone_repo(
5705            "https://example.com/r.git",
5706            Path::new("/dest"),
5707            CloneSpec::new().branch("main").depth(1).bare(),
5708        )
5709        .await
5710        .expect("clone");
5711        let call = rec.only_call();
5712        assert_eq!(
5713            call.args_str(),
5714            [
5715                "clone",
5716                "--branch",
5717                "main",
5718                "--depth",
5719                "1",
5720                "--bare",
5721                "https://example.com/r.git",
5722                "/dest"
5723            ]
5724        );
5725        assert_eq!(call.cwd, None, "clone runs without a working directory");
5726
5727        let bare = RecordingRunner::replying(Reply::ok(""));
5728        let git = Git::with_runner(&bare);
5729        git.clone_repo("u", Path::new("/d"), CloneSpec::new())
5730            .await
5731            .expect("clone");
5732        assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
5733    }
5734
5735    // R7: a failed clone cleans a `dest` it could have *created* (absent or empty) so
5736    // a retry isn't blocked by "destination already exists and is not empty" — but it
5737    // must NEVER delete a non-empty pre-existing dir (git would have refused, so the
5738    // caller's data is untouched). Scripted-fail clone + real temp dirs (only the fs
5739    // cleanup is real; nothing spawns).
5740    #[tokio::test]
5741    async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
5742        use vcs_testkit::TempDir;
5743        let tmp = TempDir::new("r7-clone");
5744        let git = Git::with_runner(ScriptedRunner::new().on(
5745            ["git", "clone"],
5746            Reply::fail(
5747                128,
5748                "fatal: could not read Username for 'https://x': prompts disabled",
5749            ),
5750        ));
5751
5752        // A non-empty caller dir must survive a failed clone.
5753        let occupied = tmp.path().join("occupied");
5754        std::fs::create_dir(&occupied).unwrap();
5755        std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
5756        assert!(
5757            git.clone_repo("https://x/r", &occupied, CloneSpec::new())
5758                .await
5759                .is_err()
5760        );
5761        assert!(
5762            occupied.join("keep.txt").exists(),
5763            "a non-empty caller dir must survive a failed clone"
5764        );
5765
5766        // An empty dest we could have populated is removed on failure.
5767        let empty = tmp.path().join("empty");
5768        std::fs::create_dir(&empty).unwrap();
5769        assert!(
5770            git.clone_repo("https://x/r", &empty, CloneSpec::new())
5771                .await
5772                .is_err()
5773        );
5774        assert!(
5775            !empty.exists(),
5776            "an empty dest is cleaned so a retry isn't blocked"
5777        );
5778
5779        // A pre-existing FILE at `dest` must survive (read_dir errs → cleanable, but
5780        // remove_dir_all refuses a non-dir). Pins that a future "also remove a file"
5781        // change can't slip in unnoticed.
5782        let file_dest = tmp.path().join("a-file");
5783        std::fs::write(&file_dest, b"caller file").unwrap();
5784        assert!(
5785            git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
5786                .await
5787                .is_err()
5788        );
5789        assert!(
5790            file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
5791            "a caller's file at dest must survive a failed clone"
5792        );
5793
5794        // A symlink `dest` → an EMPTY dir the caller owns: `read_dir` follows the
5795        // link and sees empty, so `cleanable` is true and `remove_dir_all` DOES run on
5796        // the link — it must unlink only the symlink, never delete THROUGH it. The
5797        // target dir (and a sibling sentinel) must survive.
5798        #[cfg(unix)]
5799        {
5800            let target = tmp.path().join("link-target"); // stays empty → cleanable path
5801            std::fs::create_dir(&target).unwrap();
5802            let sentinel = tmp.path().join("sibling.txt");
5803            std::fs::write(&sentinel, b"untouched").unwrap();
5804            let link = tmp.path().join("a-symlink");
5805            std::os::unix::fs::symlink(&target, &link).unwrap();
5806            assert!(
5807                git.clone_repo("https://x/r", &link, CloneSpec::new())
5808                    .await
5809                    .is_err()
5810            );
5811            assert!(
5812                target.exists() && sentinel.exists(),
5813                "a failed clone must unlink at most the symlink, never delete through it"
5814            );
5815        }
5816    }
5817
5818    #[tokio::test]
5819    async fn tag_methods_build_args() {
5820        let rec = RecordingRunner::replying(Reply::ok(""));
5821        let git = Git::with_runner(&rec);
5822        git.tag_create(Path::new("/r"), &rn("v1"), None)
5823            .await
5824            .unwrap();
5825        git.tag_create(Path::new("/r"), &rn("v1"), Some(rv("abc")))
5826            .await
5827            .unwrap();
5828        git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new(rn("v2"), "notes"))
5829            .await
5830            .unwrap();
5831        git.tag_delete(Path::new("/r"), &rn("v1")).await.unwrap();
5832        let calls = rec.calls();
5833        assert_eq!(calls[0].args_str(), ["tag", "v1"]);
5834        assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
5835        assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
5836        assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
5837    }
5838
5839    #[tokio::test]
5840    async fn tag_list_splits_lines() {
5841        let git = Git::with_runner(
5842            ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
5843        );
5844        assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
5845    }
5846
5847    // The line-parsed list commands must pass `--no-column`: a user's
5848    // `column.ui = always` would pack several names per line, and
5849    // `color.{ui,branch} = always` would inject ANSI escapes — both even when
5850    // piped. Branch listings disable both; `git tag` isn't colorized, so it only
5851    // needs `--no-column`.
5852    #[tokio::test]
5853    async fn list_commands_disable_column_and_color() {
5854        let rec = RecordingRunner::replying(Reply::ok(""));
5855        let git = Git::with_runner(&rec);
5856        git.branches(Path::new(".")).await.unwrap();
5857        git.is_merged(
5858            Path::new("."),
5859            MergeCheck::branch(rn("b")).into_base(rv("main")),
5860        )
5861        .await
5862        .unwrap();
5863        git.tag_list(Path::new(".")).await.unwrap();
5864        let calls = rec.calls();
5865        assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
5866        assert_eq!(
5867            calls[1].args_str(),
5868            ["branch", "--merged", "main", "--no-column", "--no-color"]
5869        );
5870        assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
5871    }
5872
5873    // Commands whose failure output feeds the error classifiers must force the
5874    // C locale — a translated message would defeat the substring matching.
5875    #[tokio::test]
5876    async fn classified_commands_force_c_locale() {
5877        let rec = RecordingRunner::replying(Reply::ok(""));
5878        let git = Git::with_runner(&rec);
5879        git.commit(Path::new("."), "msg").await.unwrap();
5880        git.merge_commit(Path::new("."), MergeCommit::branch(rv("b")))
5881            .await
5882            .unwrap();
5883        git.merge_squash(Path::new("."), &rv("b")).await.unwrap();
5884        git.merge_no_commit(Path::new("."), MergeNoCommit::branch(rv("b")))
5885            .await
5886            .unwrap();
5887        git.cherry_pick(Path::new("."), &rv("abc")).await.unwrap();
5888        git.stash_pop(Path::new(".")).await.unwrap();
5889        git.fetch(Path::new(".")).await.unwrap();
5890        for call in rec.calls() {
5891            assert!(
5892                call.envs.iter().any(|(k, v)| {
5893                    k.to_str() == Some("LC_ALL")
5894                        && v.as_deref().and_then(|o| o.to_str()) == Some("C")
5895                }),
5896                "{:?} should force LC_ALL=C",
5897                call.args_str()
5898            );
5899        }
5900    }
5901
5902    // The `<rev>:<path>` spec requires forward slashes — Windows callers may
5903    // hand in backslashes. The normalisation is Windows-only.
5904    #[cfg(windows)]
5905    #[tokio::test]
5906    async fn show_file_normalises_path_separators() {
5907        let rec = RecordingRunner::replying(Reply::ok("content\n"));
5908        let git = Git::with_runner(&rec);
5909        let out = git
5910            .show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
5911            .await
5912            .expect("show_file");
5913        // The blob's trailing newline is preserved verbatim (H7) — not trimmed.
5914        assert_eq!(out, "content\n");
5915        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
5916    }
5917
5918    // H7: content verbs return git's output byte-for-byte — the round-trip-corrupting
5919    // cases are multiple trailing newlines and a missing final newline.
5920    #[tokio::test]
5921    async fn content_verbs_preserve_exact_trailing_bytes() {
5922        for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces   \n"] {
5923            let rec = RecordingRunner::replying(Reply::ok(raw));
5924            let git = Git::with_runner(&rec);
5925            let out = git
5926                .show_file(Path::new("/r"), &rv("HEAD"), "f.txt")
5927                .await
5928                .expect("show_file");
5929            assert_eq!(out, raw, "show_file returns bytes verbatim");
5930        }
5931        // diff_text is verbatim too (its trailing blank context line must survive so
5932        // the last hunk stays in sync with its `@@` count).
5933        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
5934        let rec = RecordingRunner::replying(Reply::ok(diff));
5935        let git = Git::with_runner(&rec);
5936        assert_eq!(
5937            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
5938                .await
5939                .expect("diff_text"),
5940            diff
5941        );
5942    }
5943
5944    // On Unix a backslash is a legal filename byte — the spec must pass through
5945    // verbatim so a literal `a\b.txt` stays resolvable.
5946    #[cfg(not(windows))]
5947    #[tokio::test]
5948    async fn show_file_keeps_backslashes_on_unix() {
5949        let rec = RecordingRunner::replying(Reply::ok("content\n"));
5950        let git = Git::with_runner(&rec);
5951        git.show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
5952            .await
5953            .expect("show_file");
5954        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
5955    }
5956
5957    // T-049: a content read (`diff_text`) whose output exceeds the client's default
5958    // OutputBudget is refused with a structured `OutputTooLarge` carrying the actual
5959    // (`total_bytes`) and allowed (`max_bytes`) sizes — never a silently truncated
5960    // diff handed back as if complete. The huge output is drained but NOT retained
5961    // (the error carries only counts, not the multi-KiB blob): the bounded-memory
5962    // contract.
5963    #[tokio::test]
5964    async fn diff_text_over_budget_errors_output_too_large() {
5965        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
5966        assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
5967        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
5968            .default_output_budget(OutputBudget::bytes(64 * 1024));
5969        match git
5970            .diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
5971            .await
5972        {
5973            Err(Error::OutputTooLarge {
5974                program,
5975                max_bytes,
5976                total_bytes,
5977                ..
5978            }) => {
5979                assert_eq!(program, "git");
5980                assert_eq!(max_bytes, Some(64 * 1024), "the allowed ceiling");
5981                assert!(
5982                    total_bytes > 64 * 1024,
5983                    "the actual size ({total_bytes}) exceeds the allowed cap"
5984                );
5985            }
5986            other => panic!("expected OutputTooLarge, got {other:?}"),
5987        }
5988    }
5989
5990    // Below the budget the full diff comes back verbatim — the ceiling only fires on
5991    // an over-cap read, so ordinary diffs are unaffected.
5992    #[tokio::test]
5993    async fn diff_text_under_budget_returns_full_output() {
5994        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
5995        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(diff)))
5996            .default_output_budget(OutputBudget::bytes(64 * 1024));
5997        assert_eq!(
5998            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
5999                .await
6000                .expect("under-budget diff_text"),
6001            diff
6002        );
6003    }
6004
6005    // The per-call override reads a legitimately large diff past a tight client
6006    // default: `diff_text_within(..., unlimited())` returns the full output the
6007    // default budget would have refused.
6008    #[tokio::test]
6009    async fn diff_text_within_override_reads_past_the_default() {
6010        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
6011        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
6012            .default_output_budget(OutputBudget::bytes(64 * 1024));
6013        // The default budget would refuse it…
6014        assert!(matches!(
6015            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6016                .await,
6017            Err(Error::OutputTooLarge { .. })
6018        ));
6019        // …but an explicit unlimited override reads it in full.
6020        let got = git
6021            .diff_text_within(
6022                Path::new("/r"),
6023                DiffSpec::Rev("HEAD".into()),
6024                OutputBudget::unlimited(),
6025            )
6026            .await
6027            .expect("override reads the large diff");
6028        assert_eq!(got, big);
6029    }
6030
6031    // A blob read (`show_file`) honours the same budget and its per-call override.
6032    #[tokio::test]
6033    async fn show_file_over_budget_errors_and_override_reads() {
6034        let big = "x".repeat(200_000);
6035        let git = Git::with_runner(ScriptedRunner::new().on(["git", "show"], Reply::ok(&big)))
6036            .default_output_budget(OutputBudget::bytes(64 * 1024));
6037        assert!(matches!(
6038            git.show_file(Path::new("/r"), &rv("HEAD"), "big.bin").await,
6039            Err(Error::OutputTooLarge { .. })
6040        ));
6041        let got = git
6042            .show_file_within(
6043                Path::new("/r"),
6044                &rv("HEAD"),
6045                "big.bin",
6046                OutputBudget::unlimited(),
6047            )
6048            .await
6049            .expect("override reads the large blob");
6050        assert_eq!(got, big);
6051    }
6052
6053    // A client with no budget set keeps the pre-budget behaviour: even a huge diff
6054    // is returned in full (the default is unlimited, never `OutputTooLarge`).
6055    #[tokio::test]
6056    async fn default_client_has_no_budget() {
6057        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
6058        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)));
6059        assert_eq!(
6060            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6061                .await
6062                .expect("unbudgeted client returns the full diff"),
6063            big
6064        );
6065    }
6066
6067    // config --get: exit 0 → Some(value), exit 1 → None (unset), other → error.
6068    #[tokio::test]
6069    async fn config_get_maps_exit_codes() {
6070        let set = Git::with_runner(
6071            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
6072        );
6073        assert_eq!(
6074            set.config_get(Path::new("."), "user.name").await.unwrap(),
6075            Some("Alice".to_string())
6076        );
6077        // Only git's trailing newline (here `\r\n`) is stripped — a value's own
6078        // trailing spaces are preserved (they can be meaningful).
6079        let spaced = Git::with_runner(
6080            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix:  \r\n")),
6081        );
6082        assert_eq!(
6083            spaced.config_get(Path::new("."), "x.y").await.unwrap(),
6084            Some("prefix:  ".to_string())
6085        );
6086        let unset = Git::with_runner(
6087            ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
6088        );
6089        assert_eq!(
6090            unset.config_get(Path::new("."), "user.name").await.unwrap(),
6091            None
6092        );
6093        // A multi-valued key (exit 2) or worse is a real error.
6094        let multi = Git::with_runner(ScriptedRunner::new().on(
6095            ["git", "config", "--get"],
6096            Reply::fail(2, "multiple values"),
6097        ));
6098        assert!(
6099            multi
6100                .config_get(Path::new("."), "remote.all")
6101                .await
6102                .is_err()
6103        );
6104    }
6105
6106    #[tokio::test]
6107    async fn blame_builds_rev_before_pathspec_separator() {
6108        let rec = RecordingRunner::replying(Reply::ok(""));
6109        let git = Git::with_runner(&rec);
6110        git.blame(Path::new("/r"), "src/lib.rs", Some(rv("HEAD~1")))
6111            .await
6112            .unwrap();
6113        git.blame(Path::new("/r"), "src/lib.rs", None)
6114            .await
6115            .unwrap();
6116        let calls = rec.calls();
6117        assert_eq!(
6118            calls[0].args_str(),
6119            ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
6120        );
6121        assert_eq!(
6122            calls[1].args_str(),
6123            ["blame", "--line-porcelain", "--", "src/lib.rs"]
6124        );
6125    }
6126
6127    // revert must never open an editor: --no-edit plus the env backstop.
6128    #[tokio::test]
6129    async fn sequencer_methods_suppress_editors() {
6130        let rec = RecordingRunner::replying(Reply::ok(""));
6131        let git = Git::with_runner(&rec);
6132        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
6133        git.cherry_pick(Path::new("/r"), &rv("abc")).await.unwrap();
6134        git.rebase_skip(Path::new("/r")).await.unwrap();
6135        let calls = rec.calls();
6136        assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
6137        assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
6138        assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
6139        for call in &calls {
6140            assert!(
6141                call.envs
6142                    .iter()
6143                    .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
6144                "editor suppressed on {:?}",
6145                call.args_str()
6146            );
6147        }
6148    }
6149
6150    // T-044: each sequencer abort/continue/reset issues its OWN git subcommand, and
6151    // the two `--continue` variants (which can re-open the commit-message editor)
6152    // suppress it so a headless caller never hangs.
6153    #[tokio::test]
6154    async fn sequencer_abort_continue_reset_commands() {
6155        let rec = RecordingRunner::replying(Reply::ok(""));
6156        let git = Git::with_runner(&rec);
6157        let d = Path::new("/r");
6158        git.cherry_pick_abort(d).await.unwrap();
6159        git.cherry_pick_continue(d).await.unwrap();
6160        git.revert_abort(d).await.unwrap();
6161        git.revert_continue(d).await.unwrap();
6162        git.bisect_reset(d).await.unwrap();
6163        let calls = rec.calls();
6164        assert_eq!(calls[0].args_str(), ["cherry-pick", "--abort"]);
6165        assert_eq!(calls[1].args_str(), ["cherry-pick", "--continue"]);
6166        assert_eq!(calls[2].args_str(), ["revert", "--abort"]);
6167        assert_eq!(calls[3].args_str(), ["revert", "--continue"]);
6168        assert_eq!(calls[4].args_str(), ["bisect", "reset"]);
6169        // Only the `--continue` commits can prompt an editor; those must suppress it.
6170        let has_editor = |idx: usize| {
6171            calls[idx]
6172                .envs
6173                .iter()
6174                .any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
6175        };
6176        assert!(has_editor(1), "cherry-pick --continue suppresses editor");
6177        assert!(has_editor(3), "revert --continue suppresses editor");
6178    }
6179
6180    // harden() scrubs GIT_EDITOR/GIT_SEQUENCE_EDITOR from the inherited
6181    // environment, but a sequencer command sets its own `GIT_EDITOR=true` per call
6182    // (no_editor). `command_in` applies the client-level removal eagerly, so the env
6183    // list ends up `[…, (GIT_EDITOR, None), …, (GIT_EDITOR, "true")]` — and at spawn
6184    // each op is applied in order (processkit's `Command` does `env_remove` then
6185    // `env`), so the LAST write wins. The effective value MUST be `true`, else a
6186    // hardened `revert`/`cherry-pick`/`rebase` would lose its no-op editor and hang
6187    // a headless caller. Pin that effective-precedence (fold in spawn order).
6188    #[tokio::test]
6189    async fn hardened_sequencer_keeps_its_no_op_editor() {
6190        let rec = RecordingRunner::replying(Reply::ok(""));
6191        let git = Git::with_runner(&rec).harden();
6192        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
6193        let call = rec.only_call();
6194        // Resolve each editor var the way the OS does: last op for the key wins.
6195        let effective = |var: &str| {
6196            call.envs
6197                .iter()
6198                .rfind(|(k, _)| k.to_str() == Some(var))
6199                .and_then(|(_, v)| v.as_deref())
6200                .and_then(|v| v.to_str())
6201        };
6202        // Both no-op editors must survive harden()'s scrub (symmetric precedence),
6203        // else a hardened sequencer command hangs a headless caller.
6204        assert_eq!(
6205            effective("GIT_EDITOR"),
6206            Some("true"),
6207            "the per-command no-op editor must survive harden()'s scrub"
6208        );
6209        assert_eq!(
6210            effective("GIT_SEQUENCE_EDITOR"),
6211            Some("true"),
6212            "the per-command no-op sequence editor must survive harden()'s scrub"
6213        );
6214    }
6215
6216    #[tokio::test]
6217    async fn remote_add_and_set_url_build_args() {
6218        let rec = RecordingRunner::replying(Reply::ok(""));
6219        let git = Git::with_runner(&rec);
6220        git.remote_add(Path::new("/r"), "up", "https://x/y.git")
6221            .await
6222            .unwrap();
6223        git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
6224            .await
6225            .unwrap();
6226        let calls = rec.calls();
6227        assert_eq!(
6228            calls[0].args_str(),
6229            ["remote", "add", "up", "https://x/y.git"]
6230        );
6231        assert_eq!(
6232            calls[1].args_str(),
6233            ["remote", "set-url", "up", "https://x/z.git"]
6234        );
6235    }
6236
6237    // Dirty tree that stashes: status → list(before) → push → list(after, deeper) →
6238    // checkout → pop --index, in that order.
6239    #[tokio::test]
6240    async fn switch_with_stash_round_trips_dirty_tree() {
6241        let rec = RecordingRunner::new(
6242            ScriptedRunner::new()
6243                .on(["git", "status"], Reply::ok(" M a.rs\0"))
6244                // Stash-list depth goes 0 → 1, so the push is known to have saved.
6245                .on_sequence(
6246                    ["git", "stash", "list"],
6247                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
6248                )
6249                .on(["git", "stash", "push"], Reply::ok(""))
6250                .on(["git", "checkout"], Reply::ok(""))
6251                .on(["git", "stash", "pop"], Reply::ok("")),
6252        );
6253        let git = Git::with_runner(&rec);
6254        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6255            .await
6256            .expect("switch");
6257        let calls = rec.calls();
6258        assert_eq!(calls.len(), 6);
6259        assert_eq!(
6260            calls[2].args_str(),
6261            ["stash", "push", "--include-untracked"]
6262        );
6263        assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
6264        // `--index` restores the staged/unstaged split (M12).
6265        assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
6266    }
6267
6268    // M12: a dirty tree whose dirt `stash push` can't save (e.g. a submodule-only
6269    // change) — the stash-list depth is unchanged, so we must NOT pop an unrelated
6270    // pre-existing stash. Switch as-is.
6271    #[tokio::test]
6272    async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
6273        let rec = RecordingRunner::new(
6274            ScriptedRunner::new()
6275                .on(["git", "status"], Reply::ok(" M sub\0"))
6276                // Depth stays 1 across the push → nothing was actually stashed.
6277                .on(
6278                    ["git", "stash", "list"],
6279                    Reply::ok("stash@{0}: someone else's WIP\n"),
6280                )
6281                .on(
6282                    ["git", "stash", "push"],
6283                    Reply::ok("No local changes to save\n"),
6284                )
6285                .on(["git", "checkout"], Reply::ok("")),
6286        );
6287        let git = Git::with_runner(&rec);
6288        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6289            .await
6290            .expect("switch");
6291        assert!(
6292            rec.calls()
6293                .iter()
6294                .all(|c| c.args_str() != ["stash", "pop", "--index"]
6295                    && c.args_str() != ["stash", "pop"]),
6296            "must not pop an unrelated stash when the push saved nothing"
6297        );
6298    }
6299
6300    // A clean tree skips the stash round-trip — a no-op `stash push` would make
6301    // the later pop grab an older, unrelated stash.
6302    #[tokio::test]
6303    async fn switch_with_stash_skips_stash_on_clean_tree() {
6304        let rec = RecordingRunner::new(
6305            ScriptedRunner::new()
6306                .on(["git", "status"], Reply::ok(""))
6307                .on(["git", "checkout"], Reply::ok("")),
6308        );
6309        let git = Git::with_runner(&rec);
6310        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6311            .await
6312            .expect("switch");
6313        let calls = rec.calls();
6314        assert_eq!(calls.len(), 2);
6315        assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
6316    }
6317
6318    // A failed checkout pops the stash back (we are still on the original
6319    // branch) and surfaces the checkout error.
6320    #[tokio::test]
6321    async fn switch_with_stash_restores_on_checkout_failure() {
6322        let rec = RecordingRunner::new(
6323            ScriptedRunner::new()
6324                .on(["git", "status"], Reply::ok(" M a.rs\0"))
6325                .on_sequence(
6326                    ["git", "stash", "list"],
6327                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
6328                )
6329                .on(["git", "stash", "push"], Reply::ok(""))
6330                .on(
6331                    ["git", "checkout"],
6332                    Reply::fail(1, "error: pathspec 'nope'"),
6333                )
6334                .on(["git", "stash", "pop"], Reply::ok("")),
6335        );
6336        let git = Git::with_runner(&rec);
6337        let err = git
6338            .switch_with_stash(Path::new("/r"), &ct("nope"))
6339            .await
6340            .expect_err("checkout error must surface");
6341        assert!(matches!(err, Error::Exit { .. }));
6342        let calls = rec.calls();
6343        assert_eq!(
6344            calls.last().unwrap().args_str(),
6345            ["stash", "pop", "--index"],
6346            "restoring pop ran with --index"
6347        );
6348    }
6349
6350    // `fetch_from` names the remote, keeps the prompt off, and shares the
6351    // transient retry.
6352    #[tokio::test]
6353    async fn fetch_from_builds_args_and_retries() {
6354        let rec = RecordingRunner::replying(Reply::ok(""));
6355        let git = Git::with_runner(&rec);
6356        git.fetch_from(Path::new("/r"), "upstream")
6357            .await
6358            .expect("fetch_from");
6359        let call = rec.only_call();
6360        assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
6361        assert!(call.envs.iter().any(|(k, v)| {
6362            k.to_str() == Some("GIT_TERMINAL_PROMPT")
6363                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
6364        }));
6365
6366        let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
6367        let git = Git::with_runner(&failing);
6368        assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
6369        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
6370    }
6371
6372    // As with `remote_branch_exists`, the names `fetch_branch` must exclude are now
6373    // refused at `RefName` construction, before the refspec is ever built.
6374    #[test]
6375    fn fetch_branch_invalid_names_rejected_at_refname() {
6376        for branch in [
6377            "",
6378            "feature/*",
6379            "feature/?",
6380            "feature/[a]",
6381            "a:b",
6382            "two words",
6383            "bad\tname",
6384        ] {
6385            let err = RefName::new(branch).expect_err("invalid fetch branch name must be rejected");
6386            assert!(vcs_cli_support::is_invalid_input(&err), "{branch:?}");
6387        }
6388    }
6389
6390    #[tokio::test]
6391    async fn fetch_branch_accepts_valid_names() {
6392        let rec = RecordingRunner::replying(Reply::ok(""));
6393        let git = Git::with_runner(&rec);
6394
6395        git.fetch_branch(Path::new("/repo"), &rn("feature/T-010_fix"))
6396            .await
6397            .expect("valid fetch branch name");
6398        assert_eq!(
6399            rec.only_call().args_str(),
6400            [
6401                "fetch",
6402                "--quiet",
6403                "origin",
6404                "refs/heads/feature/T-010_fix:refs/remotes/origin/feature/T-010_fix"
6405            ]
6406        );
6407    }
6408
6409    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
6410    // tested with a generated mock.
6411    #[cfg(feature = "mock")]
6412    #[tokio::test]
6413    async fn consumer_mocks_the_interface() {
6414        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
6415            git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
6416        }
6417        let mut mock = MockGitApi::new();
6418        mock.expect_current_branch()
6419            .returning(|_| Ok(Some("main".to_string())));
6420        assert!(on_branch(&mock, "main").await);
6421    }
6422}
6423
6424// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
6425#[doc = include_str!("../docs/git.md")]
6426#[allow(rustdoc::broken_intra_doc_links)]
6427pub mod guide {
6428    #[doc = include_str!("../docs/security.md")]
6429    #[allow(rustdoc::broken_intra_doc_links)]
6430    pub mod security {}
6431    #[doc = include_str!("../docs/conflicts.md")]
6432    #[allow(rustdoc::broken_intra_doc_links)]
6433    pub mod conflicts {}
6434}