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`] — each `#[non_exhaustive]`, built
49//!   with a constructor + chained setters, named after the flags they emit.
50//! - **[`conflict`]** — a typed conflict-marker model: parse marker soup into
51//!   structured regions, re-render byte-exact, and resolve to a chosen side.
52//! - **[`Git::hardened`]** — a profile for untrusted repositories (hooks off,
53//!   `GIT_*` scrubbed, system config skipped); see the [`guide::security`] guide.
54//!
55//! # Recipes
56//!
57//! Read state — depend on the trait so the same code takes a real client or a mock:
58//!
59//! ```no_run
60//! use std::path::Path;
61//! use vcs_git::{Git, GitApi};
62//! # async fn demo() -> Result<(), processkit::Error> {
63//! let git = Git::new();
64//! let dir = Path::new(".");
65//! let branch = git.current_branch(dir).await?;        // the checked-out branch
66//! let dirty = !git.status(dir).await?.is_empty();     // any uncommitted change?
67//! # let _ = (branch, dirty); Ok(()) }
68//! ```
69//!
70//! Mutate through the builder specs — `fetch` retries transient network failures:
71//!
72//! ```no_run
73//! use std::path::Path;
74//! use vcs_git::{CommitPaths, Git, GitApi, GitPush};
75//! # async fn demo(git: &Git) -> Result<(), processkit::Error> {
76//! let dir = Path::new(".");
77//! git.fetch(dir).await?;
78//! git.commit_paths(dir, CommitPaths::new(["src/a.rs"], "wip")).await?;
79//! git.push(dir, GitPush::branch("feature").set_upstream()).await?;
80//! # Ok(()) }
81//! ```
82//!
83//! # Testing
84//!
85//! Two seams: enable the **`mock`** feature for a `mockall`-generated
86//! `MockGitApi` (stub whole methods), or inject a
87//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`Git::with_runner`] to
88//! exercise the *real* argv-building and parsing against canned output. The
89//! cross-cutting testing patterns live in
90//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
91//!
92//! # Safety
93//!
94//! Every caller value placed in a bare positional argv slot is refused before
95//! spawning if it is empty or starts with `-` (git would parse it as a flag);
96//! flag-value slots (`-b <name>`) are consumed verbatim and don't need it. For
97//! eager validation at an input boundary, the [`RefName`] / [`RevSpec`] newtypes
98//! validate up front. Paths always go through `--` / pathspec.
99//!
100//! # In-depth guide
101//!
102//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
103//! from `docs/`. See the [`guide`] module (and its
104//! [`security`](crate::guide::security) / [`conflicts`](crate::guide::conflicts)
105//! sub-guides).
106
107use std::path::{Path, PathBuf};
108use std::sync::Arc;
109use std::time::Duration;
110
111use processkit::Command;
112// Re-export the processkit types that appear in this crate's public API, so
113// consumers needn't depend on processkit directly — incl. `ProcessRunner` (the
114// `with_runner`/`Git<R>` seam) and the `JobRunner` default. (`Error`/`Result`/
115// `ProcessResult`/`ProcessRunner` are in scope here too via this `pub use`.)
116pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
117// Re-exported so a consumer can name the token for `default_cancel_on` without
118// taking a direct `processkit` dependency.
119pub use processkit::CancellationToken;
120
121pub mod conflict;
122mod parse;
123pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
124// The git-format diff model + parser and the version type are shared with
125// `vcs-jj` (identical output) — re-exported so `vcs_git::FileDiff`,
126// `vcs_git::parse_diff`, `vcs_git::GitVersion`, … still resolve.
127pub use vcs_diff::{
128    ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
129};
130// The error classifiers live in the shared plumbing crate — re-exported so
131// `vcs_git::is_merge_conflict`, … still resolve.
132use vcs_cli_support::git_credential_helper;
133pub use vcs_cli_support::{
134    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, RetryPolicy,
135    Secret, StaticCredential, is_lock_contention, is_merge_conflict, is_nothing_to_commit,
136    is_transient_fetch_error, provider_fn,
137};
138
139/// Name of the underlying CLI binary this crate drives.
140pub const BINARY: &str = "git";
141
142/// Options for [`GitApi::worktree_add`] (`git worktree add`).
143///
144/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
145/// [`WorktreeAdd::create_branch`] rather than a struct literal.
146#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub struct WorktreeAdd {
149    /// Filesystem path for the new worktree.
150    pub path: PathBuf,
151    /// Create and check out this new branch (`-b <name>`); `None` checks out an
152    /// existing ref.
153    pub new_branch: Option<String>,
154    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
155    pub commitish: Option<String>,
156    /// Register the worktree without populating its files (`--no-checkout`) — the
157    /// caller fills the working tree itself (e.g. a copy-on-write clone).
158    pub no_checkout: bool,
159}
160
161impl WorktreeAdd {
162    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
163    /// `git worktree add <path> <commitish>`.
164    pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
165        Self {
166            path: path.into(),
167            new_branch: None,
168            commitish: Some(commitish.into()),
169            no_checkout: false,
170        }
171    }
172
173    /// A worktree at `path` creating a new branch `name` based on `commitish`:
174    /// `git worktree add -b <name> <path> <commitish>`.
175    pub fn create_branch(
176        path: impl Into<PathBuf>,
177        name: impl Into<String>,
178        commitish: impl Into<String>,
179    ) -> Self {
180        Self {
181            path: path.into(),
182            new_branch: Some(name.into()),
183            commitish: Some(commitish.into()),
184            no_checkout: false,
185        }
186    }
187
188    /// Register the worktree without checking out its files (`--no-checkout`),
189    /// for a caller that populates the working tree itself.
190    pub fn no_checkout(mut self) -> Self {
191        self.no_checkout = true;
192        self
193    }
194}
195
196/// Options for [`GitApi::push`] (`git push`).
197///
198/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
199/// [`GitPush::refspec`] rather than a struct literal.
200#[derive(Debug, Clone)]
201#[non_exhaustive]
202pub struct GitPush {
203    /// Remote to push to (defaults to `origin`).
204    pub remote: String,
205    /// The refspec — a bare branch name, or `local:remote_branch`.
206    pub refspec: String,
207    /// Set the pushed branch as the upstream (`-u`).
208    pub set_upstream: bool,
209}
210
211impl GitPush {
212    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
213    pub fn branch(name: impl Into<String>) -> Self {
214        Self {
215            remote: "origin".to_string(),
216            refspec: name.into(),
217            set_upstream: false,
218        }
219    }
220
221    /// Push `local` to a differently-named `remote_branch`
222    /// (`git push origin <local>:<remote_branch>`).
223    pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
224        Self {
225            remote: "origin".to_string(),
226            refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
227            set_upstream: false,
228        }
229    }
230
231    /// Push to a non-default remote.
232    pub fn remote(mut self, remote: impl Into<String>) -> Self {
233        self.remote = remote.into();
234        self
235    }
236
237    /// Record the pushed branch as the local branch's upstream (`-u`).
238    pub fn set_upstream(mut self) -> Self {
239        self.set_upstream = true;
240        self
241    }
242}
243
244/// Options for [`GitApi::clone_repo`] (`git clone`).
245///
246/// `#[non_exhaustive]`, so build it through [`CloneSpec::new`] and the chained
247/// setters rather than a struct literal.
248#[derive(Debug, Clone, Default)]
249#[non_exhaustive]
250pub struct CloneSpec {
251    /// Check out this branch instead of the remote's default (`--branch`).
252    pub branch: Option<String>,
253    /// Shallow-clone to this many commits (`--depth`). git silently ignores
254    /// the flag for a plain local-path source (warns, still clones fully);
255    /// use a `file://` URL to shallow-clone locally.
256    pub depth: Option<u32>,
257    /// Create a bare repository (`--bare`).
258    pub bare: bool,
259}
260
261impl CloneSpec {
262    /// A plain full clone of the remote's default branch.
263    pub fn new() -> Self {
264        Self::default()
265    }
266
267    /// Check out `branch` instead of the remote's default (`--branch`).
268    pub fn branch(mut self, branch: impl Into<String>) -> Self {
269        self.branch = Some(branch.into());
270        self
271    }
272
273    /// Shallow-clone to `depth` commits (`--depth`); see the field doc for the
274    /// local-path caveat.
275    pub fn depth(mut self, depth: u32) -> Self {
276        self.depth = Some(depth);
277        self
278    }
279
280    /// Clone as a bare repository (`--bare`).
281    pub fn bare(mut self) -> Self {
282        self.bare = true;
283        self
284    }
285}
286
287/// Options for [`GitApi::commit_paths`] (`git commit --only`).
288///
289/// `#[non_exhaustive]`, so build it through [`CommitPaths::new`] and the chained
290/// setters rather than a struct literal.
291#[derive(Debug, Clone)]
292#[non_exhaustive]
293pub struct CommitPaths {
294    /// The exact paths whose working-tree content to commit (`--only -- <paths>`).
295    pub paths: Vec<PathBuf>,
296    /// The commit message (`-m`).
297    pub message: String,
298    /// Amend the previous commit instead of creating a new one (`--amend`).
299    pub amend: bool,
300}
301
302impl CommitPaths {
303    /// Commit exactly `paths`' working-tree content with `message`
304    /// (`git commit -m <message> --only -- <paths>`).
305    pub fn new(
306        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
307        message: impl Into<String>,
308    ) -> Self {
309        Self {
310            paths: paths.into_iter().map(Into::into).collect(),
311            message: message.into(),
312            amend: false,
313        }
314    }
315
316    /// Amend the previous commit instead of creating a new one (`--amend`).
317    pub fn amend(mut self) -> Self {
318        self.amend = true;
319        self
320    }
321}
322
323/// Partial [`MergeCheck`] — names the branch being tested; chain
324/// [`into_base`](MergeCheckPartial::into_base) to name the base it must be merged into.
325#[derive(Debug, Clone)]
326pub struct MergeCheckPartial {
327    branch: String,
328}
329
330impl MergeCheckPartial {
331    /// The base branch/ref `branch` should be fully merged **into**.
332    pub fn into_base(self, base: impl Into<String>) -> MergeCheck {
333        MergeCheck {
334            branch: self.branch,
335            base: base.into(),
336        }
337    }
338}
339
340/// A "is `branch` fully merged into `base`?" check for [`GitApi::is_merged`].
341///
342/// Built as `MergeCheck::branch("feature").into_base("main")` — the two same-typed
343/// refs are named across **two** builder steps, so they can't be silently transposed
344/// (a swap would *invert* the answer). `#[non_exhaustive]`.
345#[derive(Debug, Clone, PartialEq, Eq)]
346#[non_exhaustive]
347pub struct MergeCheck {
348    /// The branch/ref being tested for having been merged.
349    pub branch: String,
350    /// The base branch/ref it should be fully merged into.
351    pub base: String,
352}
353
354impl MergeCheck {
355    /// Name the `branch` to test; chain [`into_base`](MergeCheckPartial::into_base).
356    pub fn branch(name: impl Into<String>) -> MergeCheckPartial {
357        MergeCheckPartial {
358            branch: name.into(),
359        }
360    }
361}
362
363/// Options for [`GitApi::merge_commit`] (`git merge` that commits the result).
364///
365/// `#[non_exhaustive]`, so build it through [`MergeCommit::branch`] and the
366/// chained setters rather than a struct literal.
367#[derive(Debug, Clone)]
368#[non_exhaustive]
369pub struct MergeCommit {
370    /// The branch to merge in.
371    pub branch: String,
372    /// Always create a merge commit, even when a fast-forward was possible
373    /// (`--no-ff`).
374    pub no_ff: bool,
375    /// The merge commit message (`-m`); `None` takes the default message
376    /// non-interactively (`--no-edit`).
377    pub message: Option<String>,
378}
379
380impl MergeCommit {
381    /// Merge `name` taking the default merge message non-interactively
382    /// (`git merge --no-edit <name>`).
383    pub fn branch(name: impl Into<String>) -> Self {
384        Self {
385            branch: name.into(),
386            no_ff: false,
387            message: None,
388        }
389    }
390
391    /// Always create a merge commit, even when a fast-forward was possible
392    /// (`--no-ff`).
393    pub fn no_ff(mut self) -> Self {
394        self.no_ff = true;
395        self
396    }
397
398    /// Use `m` as the merge commit message (`-m`).
399    pub fn message(mut self, m: impl Into<String>) -> Self {
400        self.message = Some(m.into());
401        self
402    }
403}
404
405/// Options for [`GitApi::merge_no_commit`] (`git merge --no-commit`).
406///
407/// `#[non_exhaustive]`, so build it through [`MergeNoCommit::branch`] and the
408/// chained setters rather than a struct literal.
409#[derive(Debug, Clone)]
410#[non_exhaustive]
411pub struct MergeNoCommit {
412    /// The branch to merge in.
413    pub branch: String,
414    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`);
415    /// takes precedence over `no_ff` (git rejects the pair).
416    pub squash: bool,
417    /// Always record a real (abortable) merge, even when a fast-forward was
418    /// possible (`--no-ff`).
419    pub no_ff: bool,
420}
421
422impl MergeNoCommit {
423    /// Merge `name` but stop before committing (`git merge --no-commit <name>`).
424    pub fn branch(name: impl Into<String>) -> Self {
425        Self {
426            branch: name.into(),
427            squash: false,
428            no_ff: false,
429        }
430    }
431
432    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`).
433    pub fn squash(mut self) -> Self {
434        self.squash = true;
435        self
436    }
437
438    /// Always record a real (abortable) merge, even when a fast-forward was
439    /// possible (`--no-ff`).
440    pub fn no_ff(mut self) -> Self {
441        self.no_ff = true;
442        self
443    }
444}
445
446/// Options for [`GitApi::tag_create_annotated`] (`git tag -a`).
447///
448/// `#[non_exhaustive]`, so build it through [`AnnotatedTag::new`] and the chained
449/// setter rather than a struct literal.
450#[derive(Debug, Clone)]
451#[non_exhaustive]
452pub struct AnnotatedTag {
453    /// The tag name.
454    pub name: String,
455    /// The tag message (`-m`).
456    pub message: String,
457    /// The revision to tag (`<rev>`); `None` tags `HEAD`.
458    pub rev: Option<String>,
459}
460
461impl AnnotatedTag {
462    /// An annotated tag `name` with `message` at `HEAD`
463    /// (`git tag -a <name> -m <message>`).
464    pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
465        Self {
466            name: name.into(),
467            message: message.into(),
468            rev: None,
469        }
470    }
471
472    /// Tag `r` instead of `HEAD`.
473    pub fn rev(mut self, r: impl Into<String>) -> Self {
474        self.rev = Some(r.into());
475        self
476    }
477}
478
479/// A pre-validated git reference name (branch/tag/remote), for callers that
480/// accept names from untrusted input (UIs, bots, agents) and want to fail
481/// early with a clear error. The dir-taking methods stay `&str` — they apply
482/// the same flag-injection guard internally — so this type is **optional**
483/// up-front validation, not a required wrapper.
484///
485/// Rules follow the load-bearing core of `git check-ref-format`: non-empty,
486/// no leading `-` or `.`, no `..`, no control characters or space, none of
487/// `~ ^ : ? * [ \`, no trailing `/` or `.lock`.
488#[derive(Debug, Clone, PartialEq, Eq, Hash)]
489pub struct RefName(String);
490
491impl RefName {
492    /// Validate `name` as a reference name.
493    pub fn new(name: impl Into<String>) -> Result<Self> {
494        let name = name.into();
495        let bad = name.is_empty()
496            || name.starts_with('-')
497            || name.starts_with('.')
498            || name.ends_with('/')
499            || name.ends_with(".lock")
500            || name.contains("..")
501            || name
502                .chars()
503                .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
504        if bad {
505            return Err(Error::Spawn {
506                program: BINARY.to_string(),
507                source: std::io::Error::new(
508                    std::io::ErrorKind::InvalidInput,
509                    format!("invalid git reference name: {name:?}"),
510                ),
511            });
512        }
513        Ok(RefName(name))
514    }
515
516    /// The validated name.
517    pub fn as_str(&self) -> &str {
518        &self.0
519    }
520}
521
522impl std::fmt::Display for RefName {
523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        f.write_str(&self.0)
525    }
526}
527
528/// A pre-validated revision/range expression (`HEAD~2`, `main..feature`).
529/// Deliberately *minimal* — git's revision grammar is too rich to validate
530/// here — it only guarantees the expression is non-empty and cannot be parsed
531/// as a flag (no leading `-`), matching the internal guard the dir-taking
532/// methods apply anyway. Optional up-front validation for untrusted input.
533#[derive(Debug, Clone, PartialEq, Eq, Hash)]
534pub struct RevSpec(String);
535
536impl RevSpec {
537    /// Validate `rev` as a revision/range expression (non-empty, no leading `-`).
538    pub fn new(rev: impl Into<String>) -> Result<Self> {
539        let rev = rev.into();
540        reject_flag_like("revision", &rev)?;
541        Ok(RevSpec(rev))
542    }
543
544    /// The validated expression.
545    pub fn as_str(&self) -> &str {
546        &self.0
547    }
548}
549
550impl std::fmt::Display for RevSpec {
551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        f.write_str(&self.0)
553    }
554}
555
556/// What the installed `git` binary supports, probed via
557/// [`GitApi::capabilities`]. A value type — the client holds no state, so
558/// probe once and keep the result (callers cache it).
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560#[non_exhaustive]
561pub struct GitCapabilities {
562    /// The binary's parsed version.
563    pub version: GitVersion,
564}
565
566/// The oldest git major this crate is written against. Validated on 2.54;
567/// expected to work from ≥ 2.30 — but only the *major* is hard-gated, because
568/// a false "unsupported" on an untested-but-fine 2.2x would be worse than the
569/// argv error git itself would give. (Contrast vcs-jj, whose floor is precise:
570/// its parsers were empirically validated against one jj release.)
571const MIN_SUPPORTED_MAJOR: u64 = 2;
572
573impl GitCapabilities {
574    /// Whether the binary meets the supported floor (major ≥ 2).
575    pub fn is_supported(&self) -> bool {
576        self.version.major >= MIN_SUPPORTED_MAJOR
577    }
578
579    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs git
580    /// ≥ 2, found 1.9.5" instead of a cryptic argv failure later.
581    pub fn ensure_supported(&self) -> Result<()> {
582        if self.is_supported() {
583            return Ok(());
584        }
585        Err(Error::Spawn {
586            program: BINARY.to_string(),
587            source: std::io::Error::new(
588                std::io::ErrorKind::Unsupported,
589                format!(
590                    "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
591                     found {}",
592                    self.version
593                ),
594            ),
595        })
596    }
597}
598
599/// The Git operations this crate exposes — the interface consumers code against
600/// and mock in tests.
601///
602/// **Injection safety:** every method that places a caller-supplied name,
603/// revision, range, remote, or URL in a positional argv slot rejects a value
604/// that is empty or begins with `-` (it would be parsed as a flag) with an
605/// [`Error::Spawn`] *before* spawning. Flag-value slots (`-m <msg>`,
606/// `--branch <b>`), filesystem path arguments (`--`-separated pathspecs, plus
607/// worktree paths and clone destinations — typed `Path`, caller-trusted), and
608/// the `run`/`run_raw` escape hatches are not guarded. For eager validation at
609/// an input boundary, see [`RefName`] / [`RevSpec`].
610#[cfg_attr(feature = "mock", mockall::automock)]
611#[async_trait::async_trait]
612pub trait GitApi: Send + Sync {
613    /// Run `git <args>` **in the process's current directory**, returning trimmed
614    /// stdout (throws on a non-zero exit). A raw escape hatch for unmodelled commands
615    /// — you supply the whole argv, so target a specific repo with `-C <dir>` in the
616    /// args. The `at(dir)` bound view does **not** re-bind `run`/`run_args` (they and
617    /// the other `run*` hatches stay process-cwd, unlike every modelled `GitAt`
618    /// method); pass `-C dir` explicitly if you need the bound repo (M15).
619    async fn run(&self, args: &[String]) -> Result<String>;
620    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
621    /// captured [`ProcessResult`].
622    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
623    /// Installed Git version (`git --version`).
624    async fn version(&self) -> Result<String>;
625    /// The installed binary's parsed version, as [`GitCapabilities`]
626    /// (`git --version`). A value type — probe once and keep it; an
627    /// unrecognisable version string is an [`Error::Parse`].
628    async fn capabilities(&self) -> Result<GitCapabilities>;
629    /// Working-tree status (`git status --porcelain=v1 -z`).
630    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
631    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
632    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
633    async fn status_text(&self, dir: &Path) -> Result<String>;
634    /// Like [`status`](GitApi::status) but ignoring untracked files
635    /// (`git status --porcelain=v1 -z --untracked-files=no`) — "is the *tracked*
636    /// tree dirty", staged or not.
637    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
638    /// A combined branch + working-tree snapshot in **one** spawn
639    /// (`git status --porcelain=v2 --branch -z`): HEAD, branch, upstream,
640    /// ahead/behind, and change counts — the data a prompt/status-bar needs
641    /// without N round-trips. See [`BranchStatus`].
642    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
643    /// Paths with unresolved merge conflicts, repo-relative with `/` separators
644    /// (`git diff --name-only --diff-filter=U -z`). Empty when there are none.
645    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
646    /// Current branch name, or `None` on a **detached HEAD**
647    /// (`git symbolic-ref --quiet --short HEAD`). Returns the branch name for a
648    /// normal branch **and for an unborn branch** (a fresh `init`/`clone` before the
649    /// first commit); `None` only when HEAD is detached. Mirrors
650    /// [`JjApi::current_bookmark`](../vcs_jj/trait.JjApi.html#tymethod.current_bookmark)'s
651    /// `Option` shape, so cross-backend code treats "no named branch/bookmark" the
652    /// same way on both wrappers.
653    async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
654    /// Local branches, current one flagged (`git branch`).
655    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
656    /// Up to `max` commits reachable from `revspec`, newest first
657    /// (`git log <revspec>`). Pass `"HEAD"` for the current branch's history, or
658    /// a range like `"main..HEAD"` / `"origin/main..HEAD"` to scope it. Mirrors
659    /// [`JjApi::log`](../vcs_jj/trait.JjApi.html#tymethod.log)'s revset argument,
660    /// so cross-backend code uses one signature. The `revspec` is guarded against
661    /// being parsed as a flag.
662    async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
663    /// Resolve a revision to a full hash (`git rev-parse --verify <rev>`). `--verify`
664    /// requires `rev` to name exactly one object, so a non-revision (e.g. a filename)
665    /// errors instead of being echoed back as a fake id.
666    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
667    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
668    /// e.g. to label a detached HEAD.
669    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
670    /// Initialise a repository (`git init`).
671    async fn init(&self, dir: &Path) -> Result<()>;
672    /// Stage `paths` (`git add -- <paths>`).
673    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
674    /// Commit staged changes (`git commit -m`).
675    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
676    /// Create a branch without switching to it (`git branch <name>`).
677    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
678    /// Switch to a branch or revision (`git checkout <reference>`).
679    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
680    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
681    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
682    /// Commit exactly the spec's paths' working-tree content, ignoring the index
683    /// (`git commit [--amend] -m <message> --only -- <paths>`); see [`CommitPaths`].
684    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
685    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
686    /// pre-fill an amend.
687    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
688    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
689    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
690    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
691    /// Whether the working tree has no unstaged modifications to **tracked** files
692    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
693    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
694    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
695
696    // --- Discovery / identity ------------------------------------------------
697
698    /// The repository's common git directory (`rev-parse --git-common-dir`) —
699    /// stable across linked worktrees.
700    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
701    /// This worktree's git directory (`rev-parse --git-dir`).
702    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
703    /// Resolve a revision to a commit hash, peeling tags
704    /// (`rev-parse --verify <rev>^{commit}`).
705    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
706    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
707    /// (short name only); `None` when `origin/HEAD` is unset.
708    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
709    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
710    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
711    /// Whether `origin` has `name`, without fetching (`ls-remote origin
712    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
713    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
714    /// credential or a flaky network can't hang the call.
715    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
716    /// A remote's URL (`remote get-url <remote>`).
717    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
718    /// The current branch's upstream, e.g. `Some("origin/main")`
719    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
720    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
721    /// Branch names on `remote`, without fetching
722    /// (`ls-remote --heads <remote>`).
723    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
724
725    // --- Branches ------------------------------------------------------------
726
727    /// Whether the [`MergeCheck`]'s `branch` is fully merged into its `base`
728    /// (`branch --merged <base>`). Build it as
729    /// `MergeCheck::branch("feature").into_base("main")` so the two refs can't be
730    /// transposed (a swap would invert the answer).
731    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
732    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
733    /// (`branch --set-upstream-to=<upstream> <branch>`).
734    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
735    /// Delete a local branch (`branch -d`, or `-D` when `force`).
736    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
737    /// Rename a local branch (`branch -m <old> <new>`).
738    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
739    /// Count commits in a range (`rev-list --count <range>`).
740    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
741    /// Whether a diff range is empty (`diff --quiet <range>`).
742    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
743    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
744    /// match `vcs_jj::JjApi::diff_stat`.
745    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
746    /// Raw git-format unified diff text for `spec`
747    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output, returned
748    /// **verbatim** (a trailing blank context line is preserved, so the last hunk
749    /// stays in sync with its `@@` line count for a re-parse/re-apply).
750    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
751    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
752    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
753
754    // --- In-progress state ---------------------------------------------------
755
756    /// Whether the index has no staged changes (`diff --cached --quiet`).
757    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
758    /// Whether a rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
759    /// **not** left by `git am`, exists under the git dir).
760    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
761    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
762    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
763    /// Whether a `git am` (mailbox apply) is in progress (`rebase-apply/applying`).
764    /// Distinct from a rebase, which shares the `rebase-apply` dir but without the
765    /// `applying` marker — aborting an am needs `am --abort`, not `rebase --abort`.
766    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
767
768    // --- Mutations -----------------------------------------------------------
769
770    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
771    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
772    async fn fetch(&self, dir: &Path) -> Result<()>;
773    /// Fetch from a *named* remote (`fetch --quiet <remote>`), with
774    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried like
775    /// [`fetch`](GitApi::fetch).
776    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
777    /// Fetch a single branch from `origin` into its remote-tracking ref
778    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
779    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
780    async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
781    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
782    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
783    /// Stage a branch's changes without committing (`merge --squash <branch>`).
784    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
785    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
786    /// message it takes the default merge message non-interactively (`--no-edit`).
787    /// See [`MergeCommit`].
788    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
789    /// Merge a branch but stop before committing, so the result can be inspected
790    /// (`merge --no-commit [--squash | --no-ff] <branch>`). With `no_ff` (and not
791    /// `squash`) git records `MERGE_HEAD`, so the in-progress merge is abortable
792    /// via [`merge_abort`](GitApi::merge_abort) — the dry-run pattern. With
793    /// `squash`, git stages the squashed result but records **no** `MERGE_HEAD`,
794    /// so it is *not* an abortable merge: undo it with
795    /// [`reset_merge`](GitApi::reset_merge) / [`reset_hard`](GitApi::reset_hard),
796    /// not `merge_abort`. See [`MergeNoCommit`].
797    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
798    /// Abort an in-progress merge (`merge --abort`).
799    async fn merge_abort(&self, dir: &Path) -> Result<()>;
800    /// Finish a merge after resolving conflicts (`commit --no-edit`).
801    async fn merge_continue(&self, dir: &Path) -> Result<()>;
802    /// Undo an in-progress (or just-staged) merge: `reset --merge` resets the
803    /// index and the merge-touched working-tree files back to `HEAD` and drops
804    /// `MERGE_HEAD`, **discarding the merge's changes** while keeping unrelated
805    /// unstaged edits. Use it after `merge_squash` / `merge_no_commit(squash)`,
806    /// where there is no `MERGE_HEAD` for `merge_abort` to act on.
807    async fn reset_merge(&self, dir: &Path) -> Result<()>;
808    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
809    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
810    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
811    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
812    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
813    /// Abort an in-progress rebase (`rebase --abort`).
814    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
815    /// Abort an in-progress `git am` (`am --abort`), restoring the pre-`am` HEAD.
816    async fn am_abort(&self, dir: &Path) -> Result<()>;
817    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
818    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
819    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
820    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
821    /// e.g. to save state before a copy-on-write restore.
822    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
823    /// Restore the most recent stash and drop it (`stash pop`).
824    async fn stash_pop(&self, dir: &Path) -> Result<()>;
825
826    // --- Worktrees -----------------------------------------------------------
827
828    /// List worktrees (`worktree list --porcelain`).
829    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
830    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
831    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
832    /// Remove a worktree (`worktree remove [--force] <path>`).
833    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
834    /// Move a worktree (`worktree move <from> <to>`).
835    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
836    /// Prune stale worktree admin entries (`worktree prune`).
837    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
838
839    // --- Clone / tags / inspection --------------------------------------------
840
841    /// Clone `url` into `dest` (`git clone <url> <dest>` + [`CloneSpec`] flags).
842    /// Runs without a working directory — pass an **absolute** `dest`.
843    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
844    /// Create a lightweight tag at `rev` (`tag <name> [<rev>]`; `None` = HEAD).
845    async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
846    /// Create an annotated tag (`tag -a <name> -m <message> [<rev>]`); see
847    /// [`AnnotatedTag`].
848    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
849    /// Tag names, sorted by git's default ordering (`tag --list`).
850    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
851    /// Delete a tag (`tag -d <name>`).
852    async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
853    /// A file's content at a revision (`git show <rev>:<path>`). `path` is
854    /// repo-relative; backslashes are normalised to `/` (git requires it).
855    /// Content is decoded **lossily** — binary files come back mangled rather
856    /// than erroring — and returned **verbatim**: the blob's trailing newline(s)
857    /// are preserved (not trimmed), so a read-modify-write round-trip is byte-exact.
858    async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
859    /// The value of a config key, or `None` when unset (`config --get <key>`,
860    /// whose exit 1 covers both "unset" and "no such section" — git doesn't
861    /// distinguish). A multi-valued key errors; read those via `run`.
862    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
863    /// Set a config key in the repository's local config (`config <key> <value>`).
864    ///
865    /// **Trusted-input sink.** `key` is guarded against a flag-shape, but this
866    /// writes whatever key/value it's given — including code-execution keys like
867    /// `core.sshCommand` or `filter.<drv>.clean`. Never wire untrusted input into
868    /// it; a `harden()`ed client does *not* protect against config *you* write.
869    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
870    /// Add a remote (`remote add <name> <url>`).
871    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
872    /// Change a remote's URL (`remote set-url <name> <url>`).
873    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
874    /// Per-line authorship of `path` (`blame --line-porcelain [<rev>] -- <path>`;
875    /// `None` = the working tree's HEAD).
876    async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
877
878    // --- Sequencer -------------------------------------------------------------
879
880    /// Apply a commit onto the current branch (`cherry-pick <rev>`). A conflict
881    /// surfaces as an error classified by [`is_merge_conflict`].
882    async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
883    /// Revert a commit with the default message (`revert --no-edit <rev>`).
884    async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
885    /// Skip the current patch of a paused rebase (`rebase --skip`). Mainly for
886    /// the `apply` backend's "nothing to commit" stop — the default `merge`
887    /// backend auto-drops emptied patches on `--continue`.
888    async fn rebase_skip(&self, dir: &Path) -> Result<()>;
889}
890
891vcs_cli_support::managed_client! {
892    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject a
893    /// fake process executor; [`Git::new`] uses the real job-backed runner.
894    ///
895    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient): enable lock-contention retry with
896    /// [`with_retry`](Git::with_retry) (opt-in; off by default).
897    ///
898    /// **Every** client (not just [`hardened`](Git::hardened)) scrubs the inherited
899    /// repo-**redirector** environment variables below, so a `GIT_DIR` (etc.) leaking
900    /// from the parent process — e.g. running inside a git hook, which exports
901    /// `GIT_DIR`/`GIT_INDEX_FILE` — can't silently redirect commands at a *different*
902    /// repository than the bound `dir`. (`harden()` additionally scrubs the
903    /// command-hook vars and pins hooks/fsmonitor/sshCommand off.)
904    pub struct Git => BINARY, scrub_env = [
905        "GIT_DIR",
906        "GIT_WORK_TREE",
907        "GIT_INDEX_FILE",
908        "GIT_COMMON_DIR",
909        "GIT_OBJECT_DIRECTORY",
910        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
911        "GIT_NAMESPACE",
912    ]
913}
914
915impl<R: ProcessRunner> Git<R> {
916    /// Retry **whole-repo lock-contention** failures (another process holds the
917    /// repo's `index.lock`) per `policy` — opt-in, off by default. Safe even for
918    /// mutating commands: that lock is acquired before any write, so a failure is
919    /// pre-execution (git never ran) and a retry can't double-apply. Per-ref lock
920    /// failures are *not* retried (a multi-ref op can fail a ref lock mid-way). See
921    /// [`RetryPolicy`] and [`is_lock_contention`].
922    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
923        self.core = self.core.with_retry(policy);
924        self
925    }
926
927    /// Supply credentials for **HTTPS** remote operations (`fetch`/`push`/`clone`/
928    /// `ls-remote`) via a [`CredentialProvider`] — opt-in, off by default (ambient
929    /// git credential helpers / SSH agent). When the provider yields a credential,
930    /// each remote op runs with an inline `credential.helper` that feeds the secret
931    /// from an environment variable, so the token never appears in `argv`. Local
932    /// operations are unaffected. This covers HTTPS only — an **SSH** remote ignores
933    /// the helper and authenticates via the ambient SSH agent, as before.
934    #[must_use]
935    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
936        self.core = self.core.with_credentials(provider);
937        self
938    }
939
940    /// Convenience for the common case: authenticate HTTPS remotes with a single
941    /// static `token` (a personal-access token; the default username
942    /// `x-access-token` is used). Shorthand for
943    /// `with_credentials(Arc::new(StaticCredential::token(token)))`. For a specific
944    /// username, build a [`Credential::userpass`] and use
945    /// [`with_credentials`](Git::with_credentials).
946    #[must_use]
947    pub fn with_token(self, token: impl Into<Secret>) -> Self {
948        self.with_credentials(Arc::new(StaticCredential::token(token)))
949    }
950
951    /// Convenience: read the HTTPS token from environment variable `var` at request
952    /// time; if `var` is unset/empty, fall back to ambient auth. Shorthand for
953    /// `with_credentials(Arc::new(EnvToken::new(var)))`.
954    #[must_use]
955    pub fn with_env_token(self, var: impl Into<String>) -> Self {
956        self.with_credentials(Arc::new(EnvToken::new(var)))
957    }
958
959    /// Resolve HTTPS credentials for a remote op into the leading `-c` config args
960    /// (an inline `credential.helper`) and the secret env to set on the command.
961    /// Both are empty when no provider is configured — ambient git auth, unchanged.
962    /// The secret lives only in the returned env, never in the args.
963    ///
964    /// `expect_host` scopes the helper to a host (the secret is released only for
965    /// that host, so a redirect/submodule to another host can't extract it).
966    /// Callers that know the operation's target host — e.g. `clone` from its URL —
967    /// pass it; the others pass `None` (the helper is ungated, as before).
968    async fn remote_credentials(
969        &self,
970        expect_host: Option<&str>,
971    ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
972        match self
973            .core
974            .resolve_credential(CredentialService::Git, None)
975            .await?
976        {
977            Some(cred) => {
978                let helper = git_credential_helper(&cred, expect_host);
979                Ok((helper.config_args, helper.env))
980            }
981            None => Ok((Vec::new(), Vec::new())),
982        }
983    }
984}
985
986/// Set each secret environment variable on `cmd` (the values from
987/// [`Git::remote_credentials`]). A no-op when `envs` is empty.
988fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
989    envs.iter()
990        .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
991}
992
993#[async_trait::async_trait]
994impl<R: ProcessRunner> GitApi for Git<R> {
995    async fn run(&self, args: &[String]) -> Result<String> {
996        self.core.run(args).await
997    }
998
999    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1000        self.core.output_string(args).await
1001    }
1002
1003    async fn version(&self) -> Result<String> {
1004        self.core.run(["--version"]).await
1005    }
1006
1007    async fn capabilities(&self) -> Result<GitCapabilities> {
1008        let raw = self.version().await?;
1009        let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
1010            program: BINARY.to_string(),
1011            message: format!("unrecognisable `git --version` output: {raw:?}"),
1012        })?;
1013        Ok(GitCapabilities { version })
1014    }
1015
1016    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1017        self.core
1018            .parse(
1019                self.core
1020                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1021                parse::parse_porcelain,
1022            )
1023            .await
1024    }
1025
1026    async fn status_text(&self, dir: &Path) -> Result<String> {
1027        self.core
1028            .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1029            .await
1030    }
1031
1032    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1033        // `GIT_OPTIONAL_LOCKS=0`: skip the opportunistic index refresh-write a
1034        // `status` may otherwise persist. This is the snapshot/poll primitive —
1035        // a filesystem watcher re-querying through it must not have the query
1036        // itself dirty `.git/index` and re-trigger the watch (verified: with
1037        // optional locks off, a re-query writes nothing).
1038        self.core
1039            .parse(
1040                self.core
1041                    .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1042                    .env("GIT_OPTIONAL_LOCKS", "0"),
1043                parse::parse_porcelain_v2,
1044            )
1045            .await
1046    }
1047
1048    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1049        self.core
1050            .parse(
1051                self.core.command_in(
1052                    dir,
1053                    ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1054                ),
1055                parse::parse_porcelain,
1056            )
1057            .await
1058    }
1059
1060    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
1061        // `-z` keeps special-character paths literal (no C-style quoting).
1062        self.core
1063            .parse(
1064                self.core
1065                    .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1066                parse::parse_nul_paths,
1067            )
1068            .await
1069    }
1070
1071    async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1072        // `symbolic-ref --quiet --short HEAD` is the one command that answers all
1073        // three head states correctly in a single spawn: it prints the branch name
1074        // (exit 0) for a normal **and an unborn** branch (a fresh `init`/`clone`
1075        // before the first commit — where `rev-parse --abbrev-ref HEAD` instead
1076        // *errors* with exit 128), and `--quiet` makes a detached HEAD a silent
1077        // exit 1 (HEAD isn't a symbolic ref) rather than a `fatal:`. So map exit
1078        // 0 → `Some(branch)`, exit 1 → `None` (detached), and anything else (e.g.
1079        // not a repository, exit 128) stays a real error.
1080        let res = self
1081            .core
1082            .output_string(
1083                self.core
1084                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1085            )
1086            .await?;
1087        match res.code() {
1088            Some(0) => Ok(Some(res.stdout().trim().to_string())),
1089            Some(1) => Ok(None), // detached HEAD: no named branch
1090            _ => {
1091                let _ = res.ensure_success()?;
1092                Ok(None) // unreachable: a non-zero exit always errors above
1093            }
1094        }
1095    }
1096
1097    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1098        // `--no-column` + `--no-color`: `column.ui = always` would columnate
1099        // several names onto one line and `color.{ui,branch} = always` would inject
1100        // ANSI escapes — both even when piped, corrupting the line parser and the
1101        // returned names.
1102        self.core
1103            .parse(
1104                self.core
1105                    .command_in(dir, ["branch", "--no-column", "--no-color"]),
1106                parse::parse_branches,
1107            )
1108            .await
1109    }
1110
1111    async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
1112        reject_flag_like("revspec", revspec)?;
1113        let n = format!("-n{max}");
1114        self.core
1115            .parse(
1116                self.core.command_in(
1117                    dir,
1118                    [
1119                        "log",
1120                        revspec,
1121                        n.as_str(),
1122                        "-z",
1123                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1124                    ],
1125                ),
1126                parse::parse_log,
1127            )
1128            .await
1129    }
1130
1131    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
1132        reject_flag_like("revision", rev)?;
1133        // `--verify`: without it, `git rev-parse Makefile` echoes the *filename* back
1134        // as a fake object id (exit 0), so a caller resolving an untrusted revision
1135        // could get a non-hash. `--verify` requires `rev` to name exactly one object,
1136        // erroring otherwise — a valid revision still resolves to the same full hash
1137        // (M13; matches `rev_parse_short`/`resolve_commit`, which already `--verify`).
1138        self.core
1139            .run(self.core.command_in(dir, ["rev-parse", "--verify", rev]))
1140            .await
1141    }
1142
1143    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
1144        reject_flag_like("revision", rev)?;
1145        self.core
1146            .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
1147            .await
1148    }
1149
1150    async fn init(&self, dir: &Path) -> Result<()> {
1151        self.core
1152            .run_unit(self.core.command_in(dir, ["init"]))
1153            .await
1154    }
1155
1156    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1157        // `--` separates the pathspecs so a path can never be read as an option.
1158        let mut command = self.core.command_in(dir, ["add", "--"]);
1159        for path in paths {
1160            command = command.arg(path);
1161        }
1162        self.core.run_unit(command).await
1163    }
1164
1165    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1166        // C locale: a failure's output feeds `is_nothing_to_commit`.
1167        self.core
1168            .run_unit(c_locale(
1169                self.core.command_in(dir, ["commit", "-m", message]),
1170            ))
1171            .await
1172    }
1173
1174    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
1175        reject_flag_like("branch name", name)?;
1176        self.core
1177            .run_unit(self.core.command_in(dir, ["branch", name]))
1178            .await
1179    }
1180
1181    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
1182        reject_flag_like("reference", reference)?;
1183        // The trailing `--` marks the end of revisions with no pathspecs
1184        // following, so git resolves `reference` as a ref *only*. Without it a
1185        // `reference` that doesn't name a ref but names a tracked path silently
1186        // falls into pathspec mode and restores that path from the index,
1187        // discarding unstaged edits (verified: `git checkout notes.txt` →
1188        // "Updated 1 path", exit 0; `git checkout notes.txt --` → hard error).
1189        self.core
1190            .run_unit(self.core.command_in(dir, ["checkout", reference, "--"]))
1191            .await
1192    }
1193
1194    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1195        reject_flag_like("commit", commit)?;
1196        self.core
1197            .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1198            .await
1199    }
1200
1201    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1202        // `--only -- <paths>` commits exactly these paths' working-tree content
1203        // regardless of the index; `--` keeps a path from being read as an option.
1204        // C locale: a failure's output feeds `is_nothing_to_commit`.
1205        let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1206        if spec.amend {
1207            command = command.arg("--amend");
1208        }
1209        command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1210        for path in &spec.paths {
1211            command = command.arg(path);
1212        }
1213        self.core.run_unit(command).await
1214    }
1215
1216    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1217        self.core
1218            .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1219            .await
1220    }
1221
1222    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1223        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
1224        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
1225        // and surfaces anything else (e.g. 128, not a repo) as `Error::Exit`.
1226        Ok(!self
1227            .core
1228            .probe(
1229                self.core
1230                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1231            )
1232            .await?)
1233    }
1234
1235    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1236        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
1237        // `probe` errors on any other code / timeout / signal.
1238        self.core
1239            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1240            .await
1241    }
1242
1243    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1244        Ok(PathBuf::from(
1245            self.core
1246                .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1247                .await?,
1248        ))
1249    }
1250
1251    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1252        Ok(PathBuf::from(
1253            self.core
1254                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1255                .await?,
1256        ))
1257    }
1258
1259    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1260        reject_flag_like("revision", rev)?;
1261        // `^{commit}` peels an annotated tag down to the commit it points at.
1262        let spec = format!("{rev}^{{commit}}");
1263        self.core
1264            .run(
1265                self.core
1266                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1267            )
1268            .await
1269    }
1270
1271    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1272        // `--quiet` makes an *unset* origin/HEAD a silent **exit 1** (no `fatal:`
1273        // on stderr); that's "no default branch", not an error. Map exit 0 → the
1274        // branch, exit 1 → `None`, and anything else (a real failure like "not a
1275        // repository" exit 128, or a timeout/signal with no exit code) surfaces via
1276        // `ensure_success` — mirroring `config_get`, rather than swallowing it.
1277        let res = self
1278            .core
1279            .output_string(
1280                self.core
1281                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1282            )
1283            .await?;
1284        match res.code() {
1285            Some(0) => {
1286                // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so
1287                // a slashed default branch (e.g. "release/v2") survives intact.
1288                let out = res.stdout().trim();
1289                Ok(Some(
1290                    out.strip_prefix("refs/remotes/origin/")
1291                        .unwrap_or(out)
1292                        .to_string(),
1293                ))
1294            }
1295            Some(1) => Ok(None), // unset origin/HEAD
1296            _ => {
1297                let _ = res.ensure_success()?;
1298                Ok(None) // unreachable: a non-zero/no-code exit always errors above
1299            }
1300        }
1301    }
1302
1303    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1304        let refname = format!("refs/heads/{name}");
1305        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
1306        self.core
1307            .probe(
1308                self.core
1309                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1310            )
1311            .await
1312    }
1313
1314    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1315        // No credential prompt, bounded wait: a missing helper or a flaky network
1316        // must not hang the call. `output_string` reports a timeout as a flagged result
1317        // (non-zero exit) rather than erroring, so an unreachable remote reads as
1318        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
1319        // spawn failure (no `git`) still surfaces as an error.
1320        //
1321        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
1322        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
1323        // `refs/heads/<name>` matches only the exact branch.
1324        let refname = format!("refs/heads/{name}");
1325        let (pre, envs) = self.remote_credentials(None).await?;
1326        let mut args: Vec<String> = pre;
1327        args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1328        let cmd = apply_secret_env(
1329            self.core
1330                .command_in(dir, &args)
1331                .env("GIT_TERMINAL_PROMPT", "0")
1332                .timeout(Duration::from_secs(10)),
1333            &envs,
1334        );
1335        let res = self.core.output_string(cmd).await?;
1336        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1337    }
1338
1339    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1340        reject_flag_like("remote name", remote)?;
1341        self.core
1342            .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1343            .await
1344    }
1345
1346    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1347        // `@{u}` resolves the configured upstream; with no upstream the command
1348        // exits **128** — but so does a genuine failure (detached HEAD, not a repo),
1349        // and git gives them all the same exit code, so a *non-zero exit* maps to
1350        // `None` (the documented "no upstream"). A **timeout/signal** (no exit code
1351        // at all), however, is a real failure and must surface — not be reported as
1352        // "no upstream" — so it goes through `ensure_success` like the other
1353        // exit-code-mapping sites.
1354        let res = self
1355            .core
1356            .output_string(self.core.command_in(
1357                dir,
1358                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1359            ))
1360            .await?;
1361        match res.code() {
1362            Some(0) => {
1363                let name = res.stdout().trim();
1364                Ok((!name.is_empty()).then(|| name.to_string()))
1365            }
1366            Some(_) => Ok(None), // any non-zero exit ⇒ no upstream configured
1367            None => {
1368                let _ = res.ensure_success()?; // timeout/signal ⇒ a real error
1369                Ok(None) // unreachable: ensure_success errors on a no-code outcome
1370            }
1371        }
1372    }
1373
1374    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1375        reject_flag_like("remote name", remote)?;
1376        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
1377        // never block on an interactive auth prompt. A provider, if set, supplies
1378        // the credential via an inline helper (token kept out of argv).
1379        let (pre, envs) = self.remote_credentials(None).await?;
1380        let mut args: Vec<String> = pre;
1381        args.extend(["ls-remote", "--heads", remote].map(String::from));
1382        let cmd = apply_secret_env(
1383            self.core
1384                .command_in(dir, &args)
1385                .env("GIT_TERMINAL_PROMPT", "0"),
1386            &envs,
1387        );
1388        self.core.parse(cmd, parse::parse_ls_remote_heads).await
1389    }
1390
1391    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
1392        reject_flag_like("branch", &spec.branch)?;
1393        reject_flag_like("base", &spec.base)?;
1394        // `--no-column` + `--no-color`: under `column.ui = always` git would pack
1395        // several names per line and under `color.{ui,branch} = always` it would
1396        // inject ANSI escapes — both even when piped, so the marker-stripping
1397        // compare below would never match (a false "not merged").
1398        let out = self
1399            .core
1400            .run(self.core.command_in(
1401                dir,
1402                [
1403                    "branch",
1404                    "--merged",
1405                    spec.base.as_str(),
1406                    "--no-column",
1407                    "--no-color",
1408                ],
1409            ))
1410            .await?;
1411        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
1412        // drop exactly those two columns rather than trimming a char class (which
1413        // would over-strip a name that legitimately began with the marker char).
1414        Ok(out
1415            .lines()
1416            .filter_map(|line| line.get(2..))
1417            .any(|b| b == spec.branch.as_str()))
1418    }
1419
1420    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1421        reject_flag_like("branch name", branch)?;
1422        let flag = format!("--set-upstream-to={upstream}");
1423        self.core
1424            .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1425            .await
1426    }
1427
1428    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1429        reject_flag_like("branch name", name)?;
1430        let flag = if force { "-D" } else { "-d" };
1431        self.core
1432            .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1433            .await
1434    }
1435
1436    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1437        reject_flag_like("branch name", old)?;
1438        reject_flag_like("branch name", new)?;
1439        self.core
1440            .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1441            .await
1442    }
1443
1444    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1445        reject_flag_like("range", range)?;
1446        self.core
1447            .try_parse(
1448                self.core.command_in(dir, ["rev-list", "--count", range]),
1449                |s| {
1450                    s.trim().parse::<usize>().map_err(|e| Error::Parse {
1451                        program: BINARY.to_string(),
1452                        message: e.to_string(),
1453                    })
1454                },
1455            )
1456            .await
1457    }
1458
1459    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1460        reject_flag_like("range", range)?;
1461        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
1462        self.core
1463            .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1464            .await
1465    }
1466
1467    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1468        reject_flag_like("range", range)?;
1469        // `LC_ALL=C`: git's `--shortstat` summary ("N file(s) changed, …") is
1470        // gettext-translated, but `parse_shortstat` keys on the English
1471        // "file"/"insertion"/"deletion" — without C locale a non-English git
1472        // returns an all-zero `DiffStat` rather than the real counts.
1473        self.core
1474            .parse(
1475                c_locale(self.core.command_in(dir, ["diff", "--shortstat", range])),
1476                parse::parse_shortstat,
1477            )
1478            .await
1479    }
1480
1481    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1482        // The target is a single positional arg: `HEAD` for the working tree, or
1483        // the caller's revision/range. `-M` enables rename detection; `--no-color`
1484        // / `--no-ext-diff` keep the output stable and machine-parseable.
1485        let target = match spec {
1486            DiffSpec::WorkingTree => {
1487                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
1488                // diff against the empty tree so a pre-first-commit working tree
1489                // still yields its additions instead of a hard failure.
1490                if self.is_unborn(dir).await? {
1491                    EMPTY_TREE.to_string()
1492                } else {
1493                    "HEAD".to_string()
1494                }
1495            }
1496            DiffSpec::Rev(rev) => {
1497                reject_flag_like("revision", &rev)?;
1498                rev
1499            }
1500        };
1501        // The explicit prefixes pin the `a/`…`b/` form the shared parser extracts
1502        // paths from — a user's `diff.noprefix` / `diff.mnemonicPrefix` config
1503        // would otherwise change the headers and make every file silently vanish
1504        // from the parse. (Command-line prefixes override both config options.)
1505        // `run_untrimmed`: trimming the diff would drop a trailing blank context
1506        // line, desyncing the last hunk from its `@@` line count for a consumer
1507        // that re-applies or re-parses it (H7).
1508        self.core
1509            .run_untrimmed(self.core.command_in(
1510                dir,
1511                [
1512                    "diff",
1513                    target.as_str(),
1514                    "--no-color",
1515                    "--no-ext-diff",
1516                    "-M",
1517                    "--src-prefix=a/",
1518                    "--dst-prefix=b/",
1519                ],
1520            ))
1521            .await
1522    }
1523
1524    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1525        let text = self.diff_text(dir, spec).await?;
1526        Ok(parse_diff(&text))
1527    }
1528
1529    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1530        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
1531        self.core
1532            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1533            .await
1534    }
1535
1536    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1537        let git_dir = self.resolved_git_dir(dir).await?;
1538        // `rebase-merge/` is a merge-backend rebase. `rebase-apply/` is shared by an
1539        // apply-backend rebase AND `git am` — but `git am` marks it with an `applying`
1540        // file, so exclude that (it's an am, aborted with `am --abort`, not
1541        // `rebase --abort`; see `is_am_in_progress`). M20.
1542        let rebase_apply = git_dir.join("rebase-apply");
1543        let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
1544        Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
1545    }
1546
1547    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
1548        // `git am` uses `rebase-apply/` with an `applying` marker file (an
1549        // apply-backend rebase uses the same dir *without* it).
1550        Ok(self
1551            .resolved_git_dir(dir)
1552            .await?
1553            .join("rebase-apply")
1554            .join("applying")
1555            .exists())
1556    }
1557
1558    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1559        Ok(self
1560            .resolved_git_dir(dir)
1561            .await?
1562            .join("MERGE_HEAD")
1563            .exists())
1564    }
1565
1566    async fn fetch(&self, dir: &Path) -> Result<()> {
1567        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
1568        // rather than blocking on an interactive prompt — matching the other
1569        // remote ops (`fetch_branch`, `push`, `remote_branch_exists`).
1570        // Fetch is idempotent, so `retry` replays it on a transient failure
1571        // (DNS/timeout/dropped connection); a non-transient error fails at once.
1572        // C locale: the retry decision classifies the failure's message.
1573        // Leading `-c` credential.helper (+ secret env) when a provider is set.
1574        let (pre, envs) = self.remote_credentials(None).await?;
1575        let mut args: Vec<String> = pre;
1576        args.extend(["fetch", "--quiet"].map(String::from));
1577        let cmd = apply_secret_env(
1578            c_locale(self.core.command_in(dir, &args))
1579                .env("GIT_TERMINAL_PROMPT", "0")
1580                // On a per-client timeout, terminate gracefully (then hard-kill
1581                // after a grace window) so a timed-out fetch closes cleanly.
1582                .timeout_grace(FETCH_TIMEOUT_GRACE)
1583                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1584            &envs,
1585        );
1586        self.core.run_unit(cmd).await
1587    }
1588
1589    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1590        // A leading-`-` remote is a bare positional here — and a flag like
1591        // `--upload-pack=<cmd>` would run an arbitrary local program for a
1592        // local/ext transport, so this guard is load-bearing for security.
1593        reject_flag_like("remote", remote)?;
1594        // Same containment as `fetch` (prompt off, C locale, transient retry,
1595        // optional credential helper), with the remote named explicitly.
1596        let (pre, envs) = self.remote_credentials(None).await?;
1597        let mut args: Vec<String> = pre;
1598        args.extend(["fetch", "--quiet", remote].map(String::from));
1599        let cmd = apply_secret_env(
1600            c_locale(self.core.command_in(dir, &args))
1601                .env("GIT_TERMINAL_PROMPT", "0")
1602                .timeout_grace(FETCH_TIMEOUT_GRACE)
1603                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1604            &envs,
1605        );
1606        self.core.run_unit(cmd).await
1607    }
1608
1609    async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1610        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1611        let (pre, envs) = self.remote_credentials(None).await?;
1612        let mut args: Vec<String> = pre;
1613        args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
1614        let cmd = apply_secret_env(
1615            c_locale(self.core.command_in(dir, &args))
1616                .env("GIT_TERMINAL_PROMPT", "0")
1617                .timeout_grace(FETCH_TIMEOUT_GRACE)
1618                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1619            &envs,
1620        );
1621        self.core.run_unit(cmd).await
1622    }
1623
1624    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1625        reject_flag_like("remote", &spec.remote)?;
1626        reject_flag_like("refspec", &spec.refspec)?;
1627        // M16: `reject_flag_like` catches a leading `-`/empty/NUL, but not the refspec
1628        // metacharacters that silently change what a push *does* — a leading `+`
1629        // (force-push, overwriting the remote non-fast-forward) or an extra `:` (push
1630        // to an unexpected remote ref). A valid refspec here is `branch` or
1631        // `local:remote_branch` (the single `:` is API-constructed by
1632        // `GitPush::refspec`), so allow at most one `:` and no leading `+` on either
1633        // side. A caller who genuinely needs a force-push must do it explicitly via
1634        // `run(["push", "--force", …])`, not smuggle a `+` through a branch name.
1635        let sides: Vec<&str> = spec.refspec.split(':').collect();
1636        if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
1637            return Err(processkit::Error::Spawn {
1638                program: BINARY.to_string(),
1639                source: std::io::Error::new(
1640                    std::io::ErrorKind::InvalidInput,
1641                    format!(
1642                        "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
1643                         metacharacter — pass a plain branch or `local:remote`, or use \
1644                         `run([\"push\", …])` for a force-push",
1645                        spec.refspec
1646                    ),
1647                ),
1648            });
1649        }
1650        let (pre, envs) = self.remote_credentials(None).await?;
1651        let mut args: Vec<String> = pre;
1652        args.push("push".to_string());
1653        if spec.set_upstream {
1654            args.push("-u".to_string());
1655        }
1656        args.push(spec.remote.clone());
1657        args.push(spec.refspec.clone());
1658        let cmd = apply_secret_env(
1659            self.core
1660                .command_in(dir, &args)
1661                .env("GIT_TERMINAL_PROMPT", "0")
1662                // On a per-client timeout, terminate gracefully (then hard-kill
1663                // after a grace window) so a timed-out push releases its lock and
1664                // doesn't leave the remote ref half-updated. No-op without a
1665                // deadline (matches `fetch`).
1666                .timeout_grace(FETCH_TIMEOUT_GRACE),
1667            &envs,
1668        );
1669        self.core.run_unit(cmd).await
1670    }
1671
1672    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1673        reject_flag_like("branch", branch)?;
1674        // C locale: a conflict's output feeds `is_merge_conflict` (same reason as
1675        // `merge_commit`/`merge_no_commit`). `--squash` never commits, so no editor.
1676        self.core
1677            .run_unit(c_locale(
1678                self.core.command_in(dir, ["merge", "--squash", branch]),
1679            ))
1680            .await
1681    }
1682
1683    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1684        reject_flag_like("branch", &spec.branch)?;
1685        let mut args: Vec<&str> = vec!["merge"];
1686        if spec.no_ff {
1687            args.push("--no-ff");
1688        }
1689        if let Some(msg) = spec.message.as_deref() {
1690            args.push("-m");
1691            args.push(msg);
1692        } else {
1693            // No message → take the default merge message non-interactively
1694            // instead of opening `$EDITOR` (which would hang a headless caller).
1695            args.push("--no-edit");
1696        }
1697        args.push(&spec.branch);
1698        // C locale: a conflict's output feeds `is_merge_conflict`.
1699        self.core
1700            .run_unit(c_locale(self.core.command_in(dir, args)))
1701            .await
1702    }
1703
1704    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1705        reject_flag_like("branch", &spec.branch)?;
1706        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1707        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
1708        // a squash never fast-forwards anyway, so it takes precedence.
1709        if spec.squash {
1710            args.push("--squash");
1711        } else if spec.no_ff {
1712            args.push("--no-ff");
1713        }
1714        args.push(&spec.branch);
1715        // C locale: a conflict's output feeds `is_merge_conflict`.
1716        self.core
1717            .run_unit(c_locale(self.core.command_in(dir, args)))
1718            .await
1719    }
1720
1721    async fn merge_abort(&self, dir: &Path) -> Result<()> {
1722        self.core
1723            .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1724            .await
1725    }
1726
1727    async fn merge_continue(&self, dir: &Path) -> Result<()> {
1728        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
1729        // headless backstop so a commit hook re-opening the editor can't hang.
1730        // C locale: the failure output feeds the classifiers (a still-conflicted
1731        // tree reports "nothing to commit"-adjacent / conflict messages).
1732        self.core
1733            .run_unit(no_editor(c_locale(
1734                self.core.command_in(dir, ["commit", "--no-edit"]),
1735            )))
1736            .await
1737    }
1738
1739    async fn reset_merge(&self, dir: &Path) -> Result<()> {
1740        self.core
1741            .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1742            .await
1743    }
1744
1745    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1746        reject_flag_like("revision", rev)?;
1747        self.core
1748            .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1749            .await
1750    }
1751
1752    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1753        reject_flag_like("rebase target", onto)?;
1754        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
1755        // the message-confirm on `--continue`) never hangs a headless caller.
1756        // C locale: a conflict's output feeds `is_merge_conflict`.
1757        self.core
1758            .run_unit(no_editor(c_locale(
1759                self.core.command_in(dir, ["rebase", onto]),
1760            )))
1761            .await
1762    }
1763
1764    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1765        self.core
1766            .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1767            .await
1768    }
1769
1770    async fn am_abort(&self, dir: &Path) -> Result<()> {
1771        self.core
1772            .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
1773            .await
1774    }
1775
1776    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1777        self.core
1778            .run_unit(no_editor(c_locale(
1779                self.core.command_in(dir, ["rebase", "--continue"]),
1780            )))
1781            .await
1782    }
1783
1784    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1785        let mut command = self.core.command_in(dir, ["stash", "push"]);
1786        if include_untracked {
1787            command = command.arg("--include-untracked");
1788        }
1789        self.core.run_unit(command).await
1790    }
1791
1792    async fn stash_pop(&self, dir: &Path) -> Result<()> {
1793        // C locale: a conflicting `stash pop` emits git's merge-machinery
1794        // `CONFLICT (...)` output, which feeds `is_merge_conflict` (e.g. via
1795        // `switch_with_stash`) — a translated message would defeat it.
1796        self.core
1797            .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
1798            .await
1799    }
1800
1801    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1802        self.core
1803            .parse(
1804                self.core
1805                    .command_in(dir, ["worktree", "list", "--porcelain"]),
1806                parse::parse_worktree_porcelain,
1807            )
1808            .await
1809    }
1810
1811    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1812        if let Some(name) = spec.new_branch.as_deref() {
1813            reject_flag_like("branch name", name)?;
1814        }
1815        if let Some(commitish) = spec.commitish.as_deref() {
1816            reject_flag_like("commit-ish", commitish)?;
1817        }
1818        let mut command = self.core.command_in(dir, ["worktree", "add"]);
1819        if let Some(name) = spec.new_branch.as_deref() {
1820            command = command.arg("-b").arg(name);
1821        }
1822        if spec.no_checkout {
1823            command = command.arg("--no-checkout");
1824        }
1825        command = command.arg(&spec.path);
1826        if let Some(commitish) = spec.commitish.as_deref() {
1827            command = command.arg(commitish);
1828        }
1829        self.core.run_unit(command).await
1830    }
1831
1832    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1833        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1834        if force {
1835            command = command.arg("--force");
1836        }
1837        command = command.arg(path);
1838        self.core.run_unit(command).await
1839    }
1840
1841    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1842        let command = self
1843            .core
1844            .command_in(dir, ["worktree", "move"])
1845            .arg(from)
1846            .arg(to);
1847        self.core.run_unit(command).await
1848    }
1849
1850    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1851        self.core
1852            .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1853            .await
1854    }
1855
1856    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1857        // A leading-`-` url is a bare positional — `git clone --upload-pack=<cmd>`
1858        // would run an arbitrary local program. A real URL never leads with `-`,
1859        // so this guard has no false positives.
1860        reject_flag_like("url", url)?;
1861        // No working directory: clone creates `dest` itself, so `dest` should
1862        // be absolute (a relative path would resolve against this process' cwd).
1863        // Leading `-c` credential.helper (+ secret env) when a provider is set,
1864        // scoped to the clone URL's host so a cross-host redirect/submodule during
1865        // the clone can't extract the token (the URL is often externally supplied).
1866        let (pre, envs) = self
1867            .remote_credentials(vcs_cli_support::https_host(url).as_deref())
1868            .await?;
1869        let mut initial: Vec<String> = pre;
1870        initial.push("clone".to_string());
1871        let mut command = self.core.command(&initial);
1872        if let Some(branch) = spec.branch.as_deref() {
1873            command = command.arg("--branch").arg(branch);
1874        }
1875        if let Some(depth) = spec.depth {
1876            command = command.arg("--depth").arg(depth.to_string());
1877        }
1878        if spec.bare {
1879            command = command.arg("--bare");
1880        }
1881        let command = apply_secret_env(
1882            command
1883                .arg(url)
1884                .arg(dest)
1885                .env("GIT_TERMINAL_PROMPT", "0")
1886                // On a per-client timeout, terminate gracefully (then hard-kill after
1887                // a grace window). No-op without a deadline (matches `fetch`).
1888                .timeout_grace(FETCH_TIMEOUT_GRACE),
1889            &envs,
1890        );
1891
1892        // R7: git populates `dest` incrementally, so a failed clone (timeout, network,
1893        // auth) can leave a **partial, non-empty** `dest` that blocks a retry with
1894        // "destination path already exists and is not empty". `timeout_grace` alone
1895        // can't prevent it — Windows' job-kill is atomic (no graceful tier) and the
1896        // Unix grace is too short to delete a multi-GB partial. So clean it ourselves.
1897        //
1898        // Only clean a `dest` we could have *created*: absent, or an empty directory.
1899        // git refuses to clone into a **non-empty** existing dir, so a non-empty `dest`
1900        // means the failure was that refusal and the caller's data is untouched — never
1901        // delete that. (A best-effort blocking remove on the error path; a partial clone
1902        // may be large, but this path is rare.)
1903        let cleanable = match std::fs::read_dir(dest) {
1904            Err(_) => true,                              // absent/unreadable → clone creates it
1905            Ok(mut entries) => entries.next().is_none(), // an empty directory
1906        };
1907        let result = self.core.run_unit(command).await;
1908        if result.is_err() && cleanable {
1909            let _ = std::fs::remove_dir_all(dest);
1910        }
1911        result
1912    }
1913
1914    async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1915        reject_flag_like("tag name", name)?;
1916        if let Some(rev) = rev.as_deref() {
1917            reject_flag_like("revision", rev)?;
1918        }
1919        let mut args = vec!["tag", name];
1920        if let Some(rev) = rev.as_deref() {
1921            args.push(rev);
1922        }
1923        self.core.run_unit(self.core.command_in(dir, args)).await
1924    }
1925
1926    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1927        reject_flag_like("tag name", &spec.name)?;
1928        if let Some(rev) = spec.rev.as_deref() {
1929            reject_flag_like("revision", rev)?;
1930        }
1931        let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1932        if let Some(rev) = spec.rev.as_deref() {
1933            args.push(rev);
1934        }
1935        self.core.run_unit(self.core.command_in(dir, args)).await
1936    }
1937
1938    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1939        // `--no-column`: a user's `column.ui = always` would pack several tags
1940        // onto one line even when piped, corrupting the one-per-line split.
1941        let out = self
1942            .core
1943            .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1944            .await?;
1945        Ok(out.lines().map(str::to_string).collect())
1946    }
1947
1948    async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1949        reject_flag_like("tag name", name)?;
1950        self.core
1951            .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1952            .await
1953    }
1954
1955    async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1956        // A leading-`-` rev makes the whole `<rev>:<path>` token start with `-`,
1957        // so git would parse it as a flag — guard it before building the spec.
1958        reject_flag_like("revision", rev)?;
1959        // git rejects backslash separators in the `<rev>:<path>` spec ("exists
1960        // on disk, but not in <rev>") — normalise for Windows callers. Only on
1961        // Windows: on Unix a backslash is a legal filename byte, and rewriting
1962        // it would make a literal `a\b.txt` unresolvable.
1963        #[cfg(windows)]
1964        let path = path.replace('\\', "/");
1965        let spec = format!("{rev}:{path}");
1966        // `run_untrimmed`: a blob's trailing newline(s) are part of its content —
1967        // trimming them corrupts a read-modify-write round-trip (H7).
1968        self.core
1969            .run_untrimmed(self.core.command_in(dir, ["show", spec.as_str()]))
1970            .await
1971    }
1972
1973    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1974        reject_flag_like("config key", key)?;
1975        let res = self
1976            .core
1977            .output_string(self.core.command_in(dir, ["config", "--get", key]))
1978            .await?;
1979        match res.code() {
1980            // Exit 1 = unset (git lumps "no such key/section" in here too).
1981            Some(1) => Ok(None),
1982            // Strip only git's trailing line terminator (`\n`, or `\r\n`), not all
1983            // trailing whitespace: a config value can legitimately end in spaces or
1984            // a tab (e.g. a templated prefix), and `--get` returns a single line, so
1985            // it never itself ends in a newline.
1986            Some(0) => Ok(Some(
1987                res.stdout().trim_end_matches(['\r', '\n']).to_string(),
1988            )),
1989            _ => {
1990                let _ = res.ensure_success()?;
1991                Ok(None) // unreachable: a non-zero exit always errors above.
1992            }
1993        }
1994    }
1995
1996    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1997        reject_flag_like("config key", key)?;
1998        self.core
1999            .run_unit(self.core.command_in(dir, ["config", key, value]))
2000            .await
2001    }
2002
2003    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2004        reject_flag_like("remote name", name)?;
2005        reject_flag_like("url", url)?;
2006        self.core
2007            .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2008            .await
2009    }
2010
2011    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2012        reject_flag_like("remote name", name)?;
2013        reject_flag_like("url", url)?;
2014        self.core
2015            .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2016            .await
2017    }
2018
2019    async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
2020        let mut args = vec!["blame", "--line-porcelain"];
2021        if let Some(rev) = rev.as_deref() {
2022            // A standalone positional rev with a leading `-` would be any blame
2023            // flag (`-s`, `--reverse`, `-L…`) — guard before the `--`.
2024            reject_flag_like("revision", rev)?;
2025            args.push(rev);
2026        }
2027        args.push("--");
2028        args.push(path);
2029        self.core
2030            .parse(
2031                self.core.command_in(dir, args),
2032                parse::parse_blame_porcelain,
2033            )
2034            .await
2035    }
2036
2037    async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
2038        reject_flag_like("revision", rev)?;
2039        // No editor opens non-interactively, but keep the headless backstop.
2040        // C locale: a conflict's output feeds `is_merge_conflict`.
2041        self.core
2042            .run_unit(no_editor(c_locale(
2043                self.core.command_in(dir, ["cherry-pick", rev]),
2044            )))
2045            .await
2046    }
2047
2048    async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
2049        reject_flag_like("revision", rev)?;
2050        self.core
2051            .run_unit(no_editor(c_locale(
2052                self.core.command_in(dir, ["revert", "--no-edit", rev]),
2053            )))
2054            .await
2055    }
2056
2057    async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2058        self.core
2059            .run_unit(no_editor(c_locale(
2060                self.core.command_in(dir, ["rebase", "--skip"]),
2061            )))
2062            .await
2063    }
2064}
2065
2066// --- Internal helpers --------------------------------------------------------
2067//
2068// The error classifiers (`is_merge_conflict`/`is_nothing_to_commit`/
2069// `is_transient_fetch_error`), the fetch-retry policy, and the argv injection
2070// guard now live in the shared `vcs-cli-support` crate (re-exported at the top of
2071// this module); what remains here is git-specific.
2072
2073/// Git's well-known empty-tree object id — a stable stand-in for `HEAD` when
2074/// diffing the working tree of an unborn (no-commits-yet) repository. Public so a
2075/// caller can diff/stat a pre-first-commit working tree against it directly.
2076pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2077
2078/// Total attempts / fixed backoff for a transient-retried `fetch` — the shared
2079/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
2080const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2081const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2082const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2083
2084/// Point git's editor at a no-op so any command that would open `$EDITOR`
2085/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
2086/// non-interactively instead of hanging a headless caller.
2087fn no_editor(cmd: processkit::Command) -> processkit::Command {
2088    cmd.env("GIT_EDITOR", "true")
2089        .env("GIT_SEQUENCE_EDITOR", "true")
2090}
2091
2092/// Force the C locale on a command whose output feeds the error classifiers
2093/// (`is_merge_conflict`, `is_nothing_to_commit`, `is_transient_fetch_error`):
2094/// they match untranslated English substrings, and a localized git would emit
2095/// translated messages, silently turning a classified failure (conflict /
2096/// clean-tree / transient) into an unclassified one.
2097fn c_locale(cmd: processkit::Command) -> processkit::Command {
2098    cmd.env("LC_ALL", "C")
2099}
2100
2101/// Injection guard for bare positional argv slots — delegates to the shared
2102/// [`vcs_cli_support::reject_flag_like`], naming this crate's binary so the
2103/// ~45 call sites stay `reject_flag_like(what, value)`.
2104fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2105    vcs_cli_support::reject_flag_like(BINARY, what, value)
2106}
2107
2108impl<R: ProcessRunner> Git<R> {
2109    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
2110    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
2111    /// trait), so it can take `&[&str]`; forwards to the same path as
2112    /// [`GitApi::run`].
2113    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2114        self.core.run(args).await
2115    }
2116
2117    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
2118    /// (mirrors [`GitApi::run_raw`]).
2119    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2120        self.core.output_string(args).await
2121    }
2122
2123    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
2124    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
2125    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
2126    /// driving many directories (e.g. linked worktrees) from one client.
2127    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2128        GitAt { git: self, dir }
2129    }
2130
2131    /// Harden this client for driving repositories it didn't create: running
2132    /// `git` inside an untrusted checkout executes that repository's hooks and
2133    /// honours its config — arbitrary code execution by default. The profile
2134    /// (applied to **every** command this client runs):
2135    ///
2136    /// **⚠ Requires git ≥ 2.31.** The hook / `fsmonitor` / `sshCommand` pins ride
2137    /// git's env-based config (`GIT_CONFIG_COUNT`), which older git **silently
2138    /// ignores** — so on git < 2.31 `harden()` still scrubs the environment and
2139    /// turns prompts off, but repo-local hooks/fsmonitor/sshCommand are **not**
2140    /// disabled (no error is raised). There is **no built-in 2.31 gate yet**
2141    /// ([`capabilities().ensure_supported()`](GitCapabilities::ensure_supported)
2142    /// only checks the major version, so it passes on 2.0–2.30). Before relying on
2143    /// `harden()` against a fully untrusted repo on a host you don't control, check
2144    /// the version yourself — `Git::new().capabilities().await?.version` exposes
2145    /// `major`/`minor` — and require ≥ 2.31, or add an OS-level sandbox. (A
2146    /// machine-checked minor-version floor is tracked for a future release; see
2147    /// `docs/audit-2026-07.md` H3.)
2148    ///
2149    /// - **Disables hooks** — `core.hooksPath=/dev/null` pinned via git's
2150    ///   env-based config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, git ≥ 2.31;
2151    ///   verified to suppress hooks on Windows too) — and `core.fsmonitor`
2152    ///   (a config-driven daemon launch). Env-config overrides even the
2153    ///   *repo-local* `.git/config` for the keys it names, so these pins beat a
2154    ///   poisoned `.git/config`.
2155    /// - **Neutralizes `core.sshCommand`** (pinned empty) — the config-key twin of
2156    ///   the scrubbed `GIT_SSH_COMMAND`, an arbitrary program git would run for the
2157    ///   SSH transport. Empty is falsy to git, so the default `ssh` (ambient
2158    ///   `~/.ssh/config`/agent) still works; only the repo's override is dropped.
2159    /// - **Removes inherited repo redirectors** so a poisoned parent
2160    ///   environment can't point commands at another repository: `GIT_DIR`,
2161    ///   `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`,
2162    ///   `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`,
2163    ///   `GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
2164    ///   `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`. (The first seven are also
2165    ///   scrubbed by *every* client — see the type-level doc — not just here.)
2166    /// - **Removes inherited command hooks** that make git spawn an arbitrary
2167    ///   program from the *environment* (a second code-execution path besides
2168    ///   repo hooks): `GIT_SSH_COMMAND`/`GIT_SSH` (transport), `GIT_ASKPASS`
2169    ///   (credential prompt), `GIT_EXTERNAL_DIFF` (diff driver), `GIT_PAGER`,
2170    ///   `GIT_EDITOR`/`GIT_SEQUENCE_EDITOR`, `GIT_PROXY_COMMAND` (a program for a
2171    ///   `git://` connection), `GIT_EXEC_PATH` (relocates git's own sub-commands),
2172    ///   and `GIT_TEMPLATE_DIR` (seeds hooks/config on `init`/`clone`). It also drops
2173    ///   the pathspec-mode vars (`GIT_LITERAL_PATHSPECS` / `GIT_GLOB_PATHSPECS` /
2174    ///   `GIT_NOGLOB_PATHSPECS` / `GIT_ICASE_PATHSPECS`), which silently change which
2175    ///   paths a command matches. The library's own auth seam
2176    ///   ([`with_credentials`](Git::with_credentials)) injects credentials via a
2177    ///   git `credential.helper` / token env, **not** these variables, so it keeps
2178    ///   working through a hardened client; an operator who deliberately relies on
2179    ///   an ambient `GIT_SSH_COMMAND`/`GIT_ASKPASS` should inject it per-call
2180    ///   instead of inheriting it into an untrusted-repo run.
2181    /// - **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal
2182    ///   prompts off everywhere (`GIT_TERMINAL_PROMPT=0`).
2183    ///
2184    /// **Residual repo-local-config vectors (NOT neutralized).** `harden()` closes
2185    /// the *hooks*, `fsmonitor`, `core.sshCommand`, and the env redirector/command-
2186    /// hook paths — but a few **repo-local `.git/config` / `.gitattributes`** keys
2187    /// still run an arbitrary program and are not pinned: `filter.<drv>.clean`/
2188    /// `smudge` (run on any working-tree materialization — `checkout`, `stash pop`,
2189    /// `worktree add`), and `diff.<drv>.textconv` / `diff.external` (run when a diff
2190    /// is produced; [`diff_text`](GitApi::diff_text) defends itself with
2191    /// `--no-ext-diff`, but other diff/blame reads do not). So for a **fully
2192    /// untrusted** repo, do not materialize its working tree or run diffs through a
2193    /// hardened client without an OS-level sandbox — `harden()` is hardening, not a
2194    /// sandbox.
2195    ///
2196    /// What it does NOT do beyond that: sandbox the git binary itself, or stop the
2197    /// repo's *content* from being malicious. In a **colocated jj repo**, git hooks
2198    /// only run when *git* commands run — harden the `Git` client; `Jj` needs
2199    /// no equivalent (jj has no repo-local hooks; see the vcs-jj docs).
2200    ///
2201    /// Chainable — `Git::with_runner(rec).harden()` works in tests; use
2202    /// [`Git::hardened()`](Git::hardened) for the common case.
2203    pub fn harden(self) -> Self {
2204        let removed = [
2205            // Repo redirectors — point git at another repo/index/object store.
2206            // (`GIT_DIR`…`GIT_NAMESPACE` are also scrubbed by *every* client via the
2207            // `managed_client!` `scrub_env`; re-listed here so the hardened profile is
2208            // self-contained and its double-removal is harmless.)
2209            "GIT_DIR",
2210            "GIT_WORK_TREE",
2211            "GIT_INDEX_FILE",
2212            "GIT_COMMON_DIR",
2213            "GIT_OBJECT_DIRECTORY",
2214            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2215            "GIT_NAMESPACE",
2216            "GIT_CEILING_DIRECTORIES",
2217            "GIT_CONFIG_PARAMETERS",
2218            "GIT_CONFIG_GLOBAL",
2219            "GIT_CONFIG_SYSTEM",
2220            // Command hooks — make git spawn an arbitrary program from the env.
2221            "GIT_SSH_COMMAND",
2222            "GIT_SSH",
2223            "GIT_ASKPASS",
2224            "GIT_EXTERNAL_DIFF",
2225            "GIT_PAGER",
2226            "GIT_EDITOR",
2227            "GIT_SEQUENCE_EDITOR",
2228            // More env command-hooks (M14): `GIT_PROXY_COMMAND` runs an arbitrary
2229            // program for a `git://` connection; `GIT_EXEC_PATH` relocates where git
2230            // finds its own sub-commands (so `git-<x>` becomes attacker-chosen);
2231            // `GIT_TEMPLATE_DIR` seeds hooks/config into a repo on `init`/`clone`.
2232            "GIT_PROXY_COMMAND",
2233            "GIT_EXEC_PATH",
2234            "GIT_TEMPLATE_DIR",
2235            // Pathspec interpretation (M14) — not code-execution, but they silently
2236            // change which paths a command matches, so pin deterministic behavior.
2237            "GIT_LITERAL_PATHSPECS",
2238            "GIT_GLOB_PATHSPECS",
2239            "GIT_NOGLOB_PATHSPECS",
2240            "GIT_ICASE_PATHSPECS",
2241        ];
2242        let mut hardened = self;
2243        for key in removed {
2244            hardened = hardened.default_env_remove(key);
2245        }
2246        hardened
2247            .default_env("GIT_CONFIG_NOSYSTEM", "1")
2248            .default_env("GIT_TERMINAL_PROMPT", "0")
2249            // Env-config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`) overrides even the
2250            // *repo-local* `.git/config` for the keys it names — so these pins beat
2251            // a poisoned `.git/config`, which `GIT_CONFIG_NOSYSTEM` (system) and the
2252            // scrubbed `GIT_CONFIG_GLOBAL` (global) do not reach.
2253            .default_env("GIT_CONFIG_COUNT", "3")
2254            .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
2255            // `/dev/null` as the hooks dir disables hooks on every platform,
2256            // Windows included: git looks for `<hooksPath>/<hook-name>`, and no
2257            // such file can exist under `/dev/null` (it is not a directory), so the
2258            // lookup always misses and no hook runs. A literal POSIX path is fine
2259            // on Windows here — it is used as a path *prefix* to probe, never
2260            // opened — and it reads unambiguously as "nowhere."
2261            .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
2262            .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
2263            .default_env("GIT_CONFIG_VALUE_1", "false")
2264            // Neutralize a repo-local `core.sshCommand` (an arbitrary program git
2265            // runs for the SSH transport on fetch/push/clone) — the config-key twin
2266            // of the scrubbed `GIT_SSH_COMMAND` env var. An empty value is falsy to
2267            // git, so it falls back to the default `ssh` (ambient `~/.ssh/config` /
2268            // agent still work); only the repo's override is dropped.
2269            .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
2270            .default_env("GIT_CONFIG_VALUE_2", "")
2271    }
2272
2273    /// Switch to `branch`, carrying uncommitted changes (tracked *and*
2274    /// untracked) across via the stash: `stash push -u` → `checkout` →
2275    /// `stash pop --index`. `--index` restores the staged/unstaged split faithfully
2276    /// (a bare `pop` returns everything unstaged). A clean tree skips the round-trip;
2277    /// and because `stash push` can exit 0 having saved **nothing** (e.g. a
2278    /// submodule-only change), the stash-list depth is checked around the push so a
2279    /// no-op push doesn't leave the later pop grabbing an older, unrelated stash.
2280    ///
2281    /// **Single-actor contract:** this assumes no other process pushes or pops a
2282    /// stash in the same repository between this call's own `stash push` and `pop`.
2283    ///
2284    /// Failure behaviour:
2285    /// - `checkout` fails (atomic — the working copy stays on the original
2286    ///   branch): the stash is popped back to restore the original state, and
2287    ///   the checkout error is returned. If that restoring pop *also* fails,
2288    ///   the changes stay safe in the stash (`git stash list`).
2289    /// - `stash pop` on the target branch conflicts: the error is returned with
2290    ///   the target branch checked out; git keeps the stash entry, so the
2291    ///   changes can be resolved or re-applied manually.
2292    ///
2293    /// Inherent (not on the object-safe trait): a composed operation, not a 1:1
2294    /// CLI verb — mock the underlying `status`/`stash_*`/`checkout` instead.
2295    pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
2296        // Untracked-inclusive guard to match `stash push -u`: "dirty" must mean
2297        // the same thing to the guard and to the stash. Fast path for a clean tree.
2298        if self.status(dir).await?.is_empty() {
2299            return self.checkout(dir, branch).await;
2300        }
2301        // `stash push` exits 0 having saved **nothing** when the only dirt is
2302        // unstashable (e.g. a submodule-only change that `status` still reports), so a
2303        // bare `stash pop` afterwards would splat an UNRELATED pre-existing stash — data
2304        // loss. Bracket the push with the stash-list depth to learn whether it actually
2305        // saved, and only pop when it did. (Single-actor contract: a concurrent
2306        // `stash push`/`pop` by another process between our two calls is out of scope.)
2307        let depth_before = self.stash_depth(dir).await?;
2308        self.stash_push(dir, true).await?;
2309        if self.stash_depth(dir).await? <= depth_before {
2310            // Nothing was stashed — switch as-is rather than pop someone else's entry.
2311            return self.checkout(dir, branch).await;
2312        }
2313        // `--index` restores the staged/unstaged split faithfully; a bare `pop` would
2314        // bring everything back UNSTAGED, silently flattening the index.
2315        match self.checkout(dir, branch).await {
2316            Ok(()) => self.stash_pop_index(dir).await,
2317            Err(err) => {
2318                // A failed checkout is atomic — we are still on the original branch, so
2319                // popping restores the exact pre-call state. If the pop fails too, the
2320                // stash entry is preserved for the caller.
2321                let _ = self.stash_pop_index(dir).await;
2322                Err(err)
2323            }
2324        }
2325    }
2326
2327    /// The number of entries in the stash list (`git stash list`) — used by
2328    /// [`switch_with_stash`](Git::switch_with_stash) to tell whether a `stash push`
2329    /// actually saved anything.
2330    async fn stash_depth(&self, dir: &Path) -> Result<usize> {
2331        let out = self
2332            .core
2333            .run(self.core.command_in(dir, ["stash", "list"]))
2334            .await?;
2335        Ok(out.lines().filter(|l| !l.is_empty()).count())
2336    }
2337
2338    /// `git stash pop --index` — restore the top stash *preserving* the staged/unstaged
2339    /// split (a bare `pop` returns everything unstaged). C locale so a conflicting pop's
2340    /// `CONFLICT (...)` output still feeds `is_merge_conflict`.
2341    async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
2342        self.core
2343            .run_unit(c_locale(
2344                self.core.command_in(dir, ["stash", "pop", "--index"]),
2345            ))
2346            .await
2347    }
2348
2349    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
2350    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
2351    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
2352        let git_dir = PathBuf::from(
2353            self.core
2354                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
2355                .await?,
2356        );
2357        Ok(if git_dir.is_absolute() {
2358            git_dir
2359        } else {
2360            dir.join(git_dir)
2361        })
2362    }
2363}
2364
2365impl Git {
2366    /// A hardened real (job-backed) client — `Git::new().harden()`; see
2367    /// [`harden`](Git::harden) for what the profile does.
2368    pub fn hardened() -> Self {
2369        Self::new().harden()
2370    }
2371}
2372
2373/// A [`Git`] client with a working directory bound, so calls drop the leading
2374/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
2375/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
2376/// copy: it only borrows the client and the path.
2377pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
2378    git: &'a Git<R>,
2379    dir: &'a Path,
2380}
2381
2382// Hand-written rather than derived: the view only holds two references, so it is
2383// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
2384// bound that the real default `JobRunner` doesn't satisfy, silently dropping
2385// `Copy` on the production `Repo::git_at()` handle.
2386impl<R: ProcessRunner> Clone for GitAt<'_, R> {
2387    fn clone(&self) -> Self {
2388        *self
2389    }
2390}
2391impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
2392
2393// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
2394// verbatim, `dir` methods inject `self.dir` as the first argument. The shared
2395// macro lives in `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
2396vcs_cli_support::at_forwarders! {
2397    GitAt, git, "Git",
2398    bare {
2399        fn run(args: &[String]) -> Result<String>;
2400        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
2401        fn run_args(args: &[&str]) -> Result<String>;
2402        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
2403        fn version() -> Result<String>;
2404        fn capabilities() -> Result<GitCapabilities>;
2405        fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
2406    }
2407    dir {
2408        fn status() -> Result<Vec<StatusEntry>>;
2409        fn status_text() -> Result<String>;
2410        fn status_tracked() -> Result<Vec<StatusEntry>>;
2411        fn branch_status() -> Result<BranchStatus>;
2412        fn conflicted_files() -> Result<Vec<String>>;
2413        fn current_branch() -> Result<Option<String>>;
2414        fn branches() -> Result<Vec<Branch>>;
2415        fn log(revspec: &str, max: usize) -> Result<Vec<Commit>>;
2416        fn rev_parse(rev: &str) -> Result<String>;
2417        fn rev_parse_short(rev: &str) -> Result<String>;
2418        fn init() -> Result<()>;
2419        fn add(paths: &[PathBuf]) -> Result<()>;
2420        fn commit(message: &str) -> Result<()>;
2421        fn create_branch(name: &str) -> Result<()>;
2422        fn checkout(reference: &str) -> Result<()>;
2423        fn checkout_detach(commit: &str) -> Result<()>;
2424        fn commit_paths(spec: CommitPaths) -> Result<()>;
2425        fn last_commit_message() -> Result<String>;
2426        fn is_unborn() -> Result<bool>;
2427        fn diff_is_empty() -> Result<bool>;
2428        fn common_dir() -> Result<PathBuf>;
2429        fn git_dir() -> Result<PathBuf>;
2430        fn resolve_commit(rev: &str) -> Result<String>;
2431        fn remote_head_branch() -> Result<Option<String>>;
2432        fn branch_exists(name: &str) -> Result<bool>;
2433        fn remote_branch_exists(name: &str) -> Result<bool>;
2434        fn remote_url(remote: &str) -> Result<String>;
2435        fn upstream() -> Result<Option<String>>;
2436        fn remote_branches(remote: &str) -> Result<Vec<String>>;
2437        fn is_merged(spec: MergeCheck) -> Result<bool>;
2438        fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
2439        fn delete_branch(name: &str, force: bool) -> Result<()>;
2440        fn rename_branch(old: &str, new: &str) -> Result<()>;
2441        fn rev_list_count(range: &str) -> Result<usize>;
2442        fn diff_range_is_empty(range: &str) -> Result<bool>;
2443        fn diff_stat(range: &str) -> Result<DiffStat>;
2444        fn diff_text(spec: DiffSpec) -> Result<String>;
2445        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2446        fn staged_is_empty() -> Result<bool>;
2447        fn is_rebase_in_progress() -> Result<bool>;
2448        fn is_merge_in_progress() -> Result<bool>;
2449        fn is_am_in_progress() -> Result<bool>;
2450        fn fetch() -> Result<()>;
2451        fn fetch_from(remote: &str) -> Result<()>;
2452        fn fetch_branch(branch: &str) -> Result<()>;
2453        fn push(spec: GitPush) -> Result<()>;
2454        fn merge_squash(branch: &str) -> Result<()>;
2455        fn merge_commit(spec: MergeCommit) -> Result<()>;
2456        fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
2457        fn merge_abort() -> Result<()>;
2458        fn merge_continue() -> Result<()>;
2459        fn reset_merge() -> Result<()>;
2460        fn reset_hard(rev: &str) -> Result<()>;
2461        fn rebase(onto: &str) -> Result<()>;
2462        fn rebase_abort() -> Result<()>;
2463        fn am_abort() -> Result<()>;
2464        fn rebase_continue() -> Result<()>;
2465        fn stash_push(include_untracked: bool) -> Result<()>;
2466        fn stash_pop() -> Result<()>;
2467        fn switch_with_stash(branch: &str) -> Result<()>;
2468        fn worktree_list() -> Result<Vec<Worktree>>;
2469        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
2470        fn worktree_remove(path: &Path, force: bool) -> Result<()>;
2471        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
2472        fn worktree_prune() -> Result<()>;
2473        fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
2474        fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2475        fn tag_list() -> Result<Vec<String>>;
2476        fn tag_delete(name: &str) -> Result<()>;
2477        fn show_file(rev: &str, path: &str) -> Result<String>;
2478        fn config_get(key: &str) -> Result<Option<String>>;
2479        fn config_set(key: &str, value: &str) -> Result<()>;
2480        fn remote_add(name: &str, url: &str) -> Result<()>;
2481        fn remote_set_url(name: &str, url: &str) -> Result<()>;
2482        fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2483        fn cherry_pick(rev: &str) -> Result<()>;
2484        fn revert(rev: &str) -> Result<()>;
2485        fn rebase_skip() -> Result<()>;
2486    }
2487}
2488
2489/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
2490/// a `Drop` guard. They shell out through `std::process` directly (no async, no
2491/// job-containment), so reserve them for short-lived cleanup.
2492pub mod blocking {
2493    use std::path::Path;
2494    use std::process::Command;
2495
2496    /// Remove a worktree synchronously (`git worktree remove [--force] <path>`).
2497    pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2498        let mut cmd = Command::new(super::BINARY);
2499        cmd.current_dir(dir).args(["worktree", "remove"]);
2500        if force {
2501            cmd.arg("--force");
2502        }
2503        cmd.arg(path);
2504        let status = cmd.status()?;
2505        if status.success() {
2506            Ok(())
2507        } else {
2508            Err(std::io::Error::other(format!(
2509                "`git worktree remove` exited with {status}"
2510            )))
2511        }
2512    }
2513}
2514
2515#[cfg(test)]
2516mod tests {
2517    use super::*;
2518    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2519
2520    #[test]
2521    fn binary_name_is_git() {
2522        assert_eq!(BINARY, "git");
2523    }
2524
2525    // Compile-time guard: the bound view must stay `Copy` for the *default*
2526    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
2527    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
2528    #[allow(dead_code)]
2529    fn bound_view_is_copy_for_default_runner() {
2530        fn assert_copy<T: Copy>() {}
2531        assert_copy::<GitAt<'static, processkit::JobRunner>>();
2532    }
2533
2534    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
2535    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
2536    // in the right place and nothing else changes.
2537    #[tokio::test]
2538    async fn bound_view_matches_dir_taking_calls() {
2539        let dir = Path::new("/repo");
2540        let rec = RecordingRunner::replying(Reply::ok(""));
2541        let git = Git::with_runner(&rec);
2542
2543        // A method with trailing args (dir injected first).
2544        git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2545            .await
2546            .unwrap();
2547        git.at(dir)
2548            .merge_commit(MergeCommit::branch("feat").no_ff())
2549            .await
2550            .unwrap();
2551        // A method taking a path arg after dir.
2552        git.worktree_remove(dir, Path::new("/wt"), true)
2553            .await
2554            .unwrap();
2555        git.at(dir)
2556            .worktree_remove(Path::new("/wt"), true)
2557            .await
2558            .unwrap();
2559        // One of the new query methods.
2560        git.conflicted_files(dir).await.unwrap();
2561        git.at(dir).conflicted_files().await.unwrap();
2562        // One of the §4 additions.
2563        git.tag_delete(dir, "v1").await.unwrap();
2564        git.at(dir).tag_delete("v1").await.unwrap();
2565
2566        let calls = rec.calls();
2567        assert_eq!(calls[0].args_str(), calls[1].args_str());
2568        assert_eq!(calls[2].args_str(), calls[3].args_str());
2569        assert_eq!(calls[4].args_str(), calls[5].args_str());
2570        assert_eq!(calls[6].args_str(), calls[7].args_str());
2571        // The bound calls also carried the bound dir as their working directory.
2572        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2573        assert_eq!(calls[3].cwd.as_deref(), Some(dir));
2574    }
2575
2576    // Hermetic: the real status() command-building + porcelain parsing run
2577    // against a scripted runner — no `git` binary needed, so this runs on CI.
2578    #[tokio::test]
2579    async fn status_parses_scripted_output() {
2580        // `-z` output: NUL-delimited records, raw paths.
2581        let git = Git::with_runner(
2582            ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
2583        );
2584        let entries = git.status(Path::new(".")).await.expect("status");
2585        assert_eq!(entries.len(), 2);
2586        assert_eq!(entries[0].code, " M");
2587        assert_eq!(entries[1].path, "b.rs");
2588    }
2589
2590    // `status_tracked` is `status` minus untracked files — same parser, extra flag.
2591    #[tokio::test]
2592    async fn status_tracked_excludes_untracked_flag() {
2593        let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2594        let git = Git::with_runner(&rec);
2595        let entries = git.status_tracked(Path::new(".")).await.expect("status");
2596        assert_eq!(entries.len(), 1);
2597        assert_eq!(entries[0].code, " M");
2598        assert_eq!(
2599            rec.only_call().args_str(),
2600            ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2601        );
2602    }
2603
2604    // `branch_status` builds the porcelain v2 + branch + -z argv and parses the
2605    // combined header/entry output in one call.
2606    #[tokio::test]
2607    async fn branch_status_builds_v2_branch_args_and_parses() {
2608        let out = concat!(
2609            "# branch.oid abc\0",
2610            "# branch.head main\0",
2611            "# branch.upstream origin/main\0",
2612            "# branch.ab +1 -0\0",
2613            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2614            "? new.txt\0",
2615        );
2616        let rec = RecordingRunner::replying(Reply::ok(out));
2617        let git = Git::with_runner(&rec);
2618        let s = git
2619            .branch_status(Path::new("."))
2620            .await
2621            .expect("branch_status");
2622        assert_eq!(
2623            rec.only_call().args_str(),
2624            ["status", "--porcelain=v2", "--branch", "-z"]
2625        );
2626        // The poll primitive must not itself write the index (and re-trigger a
2627        // filesystem watcher re-querying through it).
2628        assert!(rec.only_call().envs.iter().any(|(k, v)| {
2629            k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2630                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2631        }));
2632        assert_eq!(s.branch.as_deref(), Some("main"));
2633        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2634        assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2635        assert_eq!(s.tracked_changes, 1);
2636        assert_eq!(s.untracked, 1);
2637        assert!(s.is_dirty());
2638    }
2639
2640    // `conflicted_files` lists unmerged paths NUL-delimited (no quoting).
2641    #[tokio::test]
2642    async fn conflicted_files_builds_args_and_parses_nul_list() {
2643        let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2644        let git = Git::with_runner(&rec);
2645        let paths = git
2646            .conflicted_files(Path::new("."))
2647            .await
2648            .expect("conflicted_files");
2649        assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2650        assert_eq!(
2651            rec.only_call().args_str(),
2652            ["diff", "--name-only", "--diff-filter=U", "-z"]
2653        );
2654    }
2655
2656    #[tokio::test]
2657    async fn rev_parse_short_builds_short_flag() {
2658        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2659        let git = Git::with_runner(&rec);
2660        let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2661        assert_eq!(out, "a1b2c3d");
2662        assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2663    }
2664
2665    // M13: `rev_parse` passes `--verify` so a non-revision (a filename) errors
2666    // instead of being echoed back as a fake object id.
2667    #[tokio::test]
2668    async fn rev_parse_verifies_the_revision() {
2669        let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
2670        let git = Git::with_runner(&rec);
2671        let out = git.rev_parse(Path::new("/r"), "HEAD").await.unwrap();
2672        assert_eq!(out, "deadbeef");
2673        assert_eq!(
2674            rec.only_call().args_str(),
2675            ["rev-parse", "--verify", "HEAD"]
2676        );
2677    }
2678
2679    // M20: `git am` and an apply-backend rebase share the `rebase-apply/` dir, but am
2680    // marks it with an `applying` file. `is_am_in_progress` must fire only for the am,
2681    // and `is_rebase_in_progress` must NOT (so an am isn't aborted with `rebase --abort`).
2682    #[tokio::test]
2683    async fn distinguishes_git_am_from_an_apply_backend_rebase() {
2684        use vcs_testkit::TempDir;
2685        let gd = TempDir::new("m20-am");
2686        let git = Git::with_runner(ScriptedRunner::new().on(
2687            ["git", "rev-parse", "--git-dir"],
2688            Reply::ok(gd.path().to_str().unwrap()),
2689        ));
2690        let apply = gd.path().join("rebase-apply");
2691        std::fs::create_dir_all(&apply).unwrap();
2692
2693        // With the `applying` marker → a `git am`.
2694        std::fs::write(apply.join("applying"), b"").unwrap();
2695        assert!(
2696            git.is_am_in_progress(Path::new("/r")).await.unwrap(),
2697            "am detected"
2698        );
2699        assert!(
2700            !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2701            "a git am is NOT reported as a rebase"
2702        );
2703
2704        // Without it → an apply-backend rebase.
2705        std::fs::remove_file(apply.join("applying")).unwrap();
2706        assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
2707        assert!(
2708            git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2709            "a bare rebase-apply dir is a rebase"
2710        );
2711    }
2712
2713    // A non-zero exit surfaces as a structured `Error::Exit`.
2714    #[tokio::test]
2715    async fn nonzero_exit_is_structured_error() {
2716        let git = Git::with_runner(
2717            ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
2718        );
2719        match git.status(Path::new(".")).await.unwrap_err() {
2720            Error::Exit { code, stderr, .. } => {
2721                assert_eq!(code, 128);
2722                assert!(stderr.contains("not a git repository"), "{stderr}");
2723            }
2724            other => panic!("expected Exit, got {other:?}"),
2725        }
2726    }
2727
2728    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
2729    // anything else is a real failure surfaced as Error::Exit.
2730    #[tokio::test]
2731    async fn diff_is_empty_maps_exit_codes() {
2732        let clean =
2733            Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
2734        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2735
2736        let dirty = Git::with_runner(
2737            ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
2738        );
2739        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2740
2741        let broken = Git::with_runner(ScriptedRunner::new().on(
2742            ["git", "diff", "--quiet"],
2743            Reply::fail(128, "fatal: not a repo"),
2744        ));
2745        assert!(matches!(
2746            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2747            Error::Exit { code: 128, .. }
2748        ));
2749    }
2750
2751    // `add` must insert `--` before the pathspecs so a path can never be parsed
2752    // as an option. No fallback rule: the run only matches if `add --` was built.
2753    #[tokio::test]
2754    async fn add_inserts_pathspec_separator() {
2755        let git = Git::with_runner(ScriptedRunner::new().on(["git", "add", "--"], Reply::ok("")));
2756        git.add(Path::new("."), &[PathBuf::from("f.rs")])
2757            .await
2758            .expect("add should build `add -- <paths>`");
2759    }
2760
2761    #[tokio::test]
2762    async fn worktree_list_parses_porcelain() {
2763        let git = Git::with_runner(ScriptedRunner::new().on(
2764            ["git", "worktree", "list"],
2765            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2766        ));
2767        let wts = git.worktree_list(Path::new(".")).await.expect("list");
2768        assert_eq!(wts.len(), 1);
2769        assert_eq!(wts[0].branch.as_deref(), Some("main"));
2770        assert_eq!(wts[0].head.as_deref(), Some("abc"));
2771    }
2772
2773    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
2774    // in that exact order; only the full argv is scripted (no fallback).
2775    #[tokio::test]
2776    async fn worktree_add_builds_branch_path_and_base() {
2777        let rec = RecordingRunner::replying(Reply::ok(""));
2778        let git = Git::with_runner(&rec);
2779        git.worktree_add(
2780            Path::new("/repo"),
2781            WorktreeAdd::create_branch("/wt", "feature", "main"),
2782        )
2783        .await
2784        .expect("worktree add");
2785        assert_eq!(
2786            rec.only_call().args_str(),
2787            ["worktree", "add", "-b", "feature", "/wt", "main"]
2788        );
2789    }
2790
2791    #[tokio::test]
2792    async fn worktree_remove_passes_force_then_path() {
2793        let rec = RecordingRunner::replying(Reply::ok(""));
2794        let git = Git::with_runner(&rec);
2795        git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2796            .await
2797            .expect("remove");
2798        assert_eq!(
2799            rec.only_call().args_str(),
2800            ["worktree", "remove", "--force", "/wt"]
2801        );
2802    }
2803
2804    // `--no-checkout` must land between `-b <name>` and the path.
2805    #[tokio::test]
2806    async fn worktree_add_no_checkout_inserts_flag() {
2807        let rec = RecordingRunner::replying(Reply::ok(""));
2808        let git = Git::with_runner(&rec);
2809        git.worktree_add(
2810            Path::new("/repo"),
2811            WorktreeAdd::checkout("/wt", "main").no_checkout(),
2812        )
2813        .await
2814        .expect("worktree add");
2815        assert_eq!(
2816            rec.only_call().args_str(),
2817            ["worktree", "add", "--no-checkout", "/wt", "main"]
2818        );
2819    }
2820
2821    #[tokio::test]
2822    async fn checkout_detach_builds_args() {
2823        let rec = RecordingRunner::replying(Reply::ok(""));
2824        let git = Git::with_runner(&rec);
2825        git.checkout_detach(Path::new("."), "abc123")
2826            .await
2827            .expect("detach");
2828        assert_eq!(
2829            rec.only_call().args_str(),
2830            ["checkout", "--detach", "abc123"]
2831        );
2832    }
2833
2834    // current_branch reads `symbolic-ref --quiet --short HEAD`: exit 0 → the branch
2835    // name (a normal *or* unborn branch), exit 1 → None (detached HEAD), and any
2836    // other non-zero (e.g. not a repository) stays a real error.
2837    #[tokio::test]
2838    async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
2839        // A normal branch (exit 0) — and the argv is pinned.
2840        let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
2841        let on_branch = Git::with_runner(&rec);
2842        assert_eq!(
2843            on_branch.current_branch(Path::new(".")).await.unwrap(),
2844            Some("feature/x".to_string())
2845        );
2846        assert_eq!(
2847            rec.only_call().args_str(),
2848            ["symbolic-ref", "--quiet", "--short", "HEAD"]
2849        );
2850        // An unborn branch also exits 0 with the branch name (the bug this fixes:
2851        // the old `rev-parse --abbrev-ref HEAD` errored with exit 128 here).
2852        let unborn = Git::with_runner(
2853            ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
2854        );
2855        assert_eq!(
2856            unborn.current_branch(Path::new(".")).await.unwrap(),
2857            Some("main".to_string())
2858        );
2859        // A detached HEAD exits 1 silently → None.
2860        let detached =
2861            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2862        assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
2863        // Any other non-zero (not a repository, exit 128) is a real error.
2864        let not_repo = Git::with_runner(ScriptedRunner::new().on(
2865            ["git", "symbolic-ref"],
2866            Reply::fail(128, "fatal: not a git repository"),
2867        ));
2868        assert!(not_repo.current_branch(Path::new(".")).await.is_err());
2869    }
2870
2871    // Partial amend commit must build `commit --amend -m <msg> --only -- <paths>`.
2872    #[tokio::test]
2873    async fn commit_paths_builds_only_amend_args() {
2874        let rec = RecordingRunner::replying(Reply::ok(""));
2875        let git = Git::with_runner(&rec);
2876        git.commit_paths(
2877            Path::new("."),
2878            CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2879        )
2880        .await
2881        .expect("commit_paths");
2882        assert_eq!(
2883            rec.only_call().args_str(),
2884            [
2885                "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2886            ]
2887        );
2888    }
2889
2890    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
2891    // unborn (true), anything else is a structured error.
2892    #[tokio::test]
2893    async fn is_unborn_maps_exit_codes() {
2894        let born =
2895            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
2896        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2897        let unborn =
2898            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
2899        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2900        let broken = Git::with_runner(
2901            ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
2902        );
2903        assert!(matches!(
2904            broken.is_unborn(Path::new(".")).await.unwrap_err(),
2905            Error::Exit { code: 128, .. }
2906        ));
2907    }
2908
2909    #[tokio::test]
2910    async fn log_builds_revspec_and_format() {
2911        let rec = RecordingRunner::replying(Reply::ok(""));
2912        let git = Git::with_runner(&rec);
2913        git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
2914        assert_eq!(
2915            rec.only_call().args_str(),
2916            [
2917                "log",
2918                "main..HEAD",
2919                "-n5",
2920                "-z",
2921                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2922            ]
2923        );
2924    }
2925
2926    #[tokio::test]
2927    async fn stash_push_adds_include_untracked() {
2928        let rec = RecordingRunner::replying(Reply::ok(""));
2929        let git = Git::with_runner(&rec);
2930        git.stash_push(Path::new("."), true).await.expect("stash");
2931        assert_eq!(
2932            rec.only_call().args_str(),
2933            ["stash", "push", "--include-untracked"]
2934        );
2935    }
2936
2937    // `diff_text` for the working tree must build `diff HEAD` plus the stable
2938    // machine-output flags, in order.
2939    #[tokio::test]
2940    async fn diff_text_builds_working_tree_args() {
2941        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
2942        // targets HEAD. The probe is the first call; the diff is the last.
2943        let rec = RecordingRunner::replying(Reply::ok(""));
2944        let git = Git::with_runner(&rec);
2945        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2946            .await
2947            .expect("diff_text");
2948        assert_eq!(
2949            rec.calls().last().unwrap().args_str(),
2950            [
2951                "diff",
2952                "HEAD",
2953                "--no-color",
2954                "--no-ext-diff",
2955                "-M",
2956                // Pin the parser's `a/`…`b/` headers against a user's
2957                // `diff.noprefix`/`diff.mnemonicPrefix` config.
2958                "--src-prefix=a/",
2959                "--dst-prefix=b/",
2960            ]
2961        );
2962    }
2963
2964    // On an unborn repo the working-tree diff targets the empty tree instead of
2965    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
2966    // diff rule only matches the empty-tree argv, so a `HEAD` target would miss it.
2967    #[tokio::test]
2968    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2969        let git = Git::with_runner(
2970            ScriptedRunner::new()
2971                .on(["git", "rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
2972                .on(["git", "diff", EMPTY_TREE], Reply::ok("EMPTY")),
2973        );
2974        let out = git
2975            .diff_text(Path::new("."), DiffSpec::WorkingTree)
2976            .await
2977            .expect("diff_text");
2978        assert_eq!(out, "EMPTY");
2979    }
2980
2981    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
2982    // canned git-format output.
2983    #[tokio::test]
2984    async fn diff_parses_scripted_output() {
2985        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2986        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
2987        let files = git
2988            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2989            .await
2990            .expect("diff");
2991        assert_eq!(files.len(), 1);
2992        assert_eq!(files[0].path, "m");
2993        assert_eq!(files[0].change, ChangeKind::Modified);
2994    }
2995
2996    #[tokio::test]
2997    async fn branch_exists_maps_exit_codes() {
2998        let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2999        assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
3000        let no =
3001            Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
3002        assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
3003    }
3004
3005    // The full ref prefix is stripped but a slashed default branch survives; an
3006    // unset origin/HEAD (non-zero exit) is `None`, not an error.
3007    #[tokio::test]
3008    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
3009        let simple = Git::with_runner(ScriptedRunner::new().on(
3010            ["git", "symbolic-ref"],
3011            Reply::ok("refs/remotes/origin/main\n"),
3012        ));
3013        assert_eq!(
3014            simple
3015                .remote_head_branch(Path::new("."))
3016                .await
3017                .unwrap()
3018                .as_deref(),
3019            Some("main")
3020        );
3021
3022        let slashed = Git::with_runner(ScriptedRunner::new().on(
3023            ["git", "symbolic-ref"],
3024            Reply::ok("refs/remotes/origin/release/v2\n"),
3025        ));
3026        assert_eq!(
3027            slashed
3028                .remote_head_branch(Path::new("."))
3029                .await
3030                .unwrap()
3031                .as_deref(),
3032            Some("release/v2")
3033        );
3034
3035        let unset =
3036            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3037        assert!(
3038            unset
3039                .remote_head_branch(Path::new("."))
3040                .await
3041                .unwrap()
3042                .is_none()
3043        );
3044    }
3045
3046    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
3047    // stdout as "absent".
3048    #[tokio::test]
3049    async fn remote_branch_exists_sets_env_and_reads_stdout() {
3050        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
3051        let git = Git::with_runner(&rec);
3052        assert!(
3053            git.remote_branch_exists(Path::new("/repo"), "main")
3054                .await
3055                .unwrap()
3056        );
3057        let call = rec.only_call();
3058        assert!(call.envs.iter().any(|(k, v)| {
3059            k.to_str() == Some("GIT_TERMINAL_PROMPT")
3060                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3061        }));
3062        // Exact-ref query — a bare `main` would tail-match `bar/main`.
3063        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
3064
3065        let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
3066        assert!(
3067            !empty
3068                .remote_branch_exists(Path::new("."), "x")
3069                .await
3070                .unwrap()
3071        );
3072    }
3073
3074    #[tokio::test]
3075    async fn diff_stat_parses_counts() {
3076        let git = Git::with_runner(ScriptedRunner::new().on(
3077            ["git", "diff", "--shortstat"],
3078            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
3079        ));
3080        let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
3081        assert_eq!(
3082            (stat.files_changed, stat.insertions, stat.deletions),
3083            (2, 5, 1)
3084        );
3085    }
3086
3087    #[tokio::test]
3088    async fn status_text_returns_raw_porcelain() {
3089        let git = Git::with_runner(ScriptedRunner::new().on(
3090            ["git", "status", "--porcelain=v1"],
3091            Reply::ok(" M a.rs\n?? b.rs\n"),
3092        ));
3093        let text = git.status_text(Path::new(".")).await.expect("status_text");
3094        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
3095    }
3096
3097    #[tokio::test]
3098    async fn run_args_forwards_str_slices() {
3099        let git =
3100            Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
3101        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
3102    }
3103
3104    #[tokio::test]
3105    async fn merge_commit_builds_no_ff_and_message() {
3106        let rec = RecordingRunner::replying(Reply::ok(""));
3107        let git = Git::with_runner(&rec);
3108        git.merge_commit(
3109            Path::new("/r"),
3110            MergeCommit::branch("feature").no_ff().message("merge it"),
3111        )
3112        .await
3113        .unwrap();
3114        assert_eq!(
3115            rec.only_call().args_str(),
3116            ["merge", "--no-ff", "-m", "merge it", "feature"]
3117        );
3118    }
3119
3120    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
3121    #[tokio::test]
3122    async fn merge_commit_without_message_uses_no_edit() {
3123        let rec = RecordingRunner::replying(Reply::ok(""));
3124        let git = Git::with_runner(&rec);
3125        git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
3126            .await
3127            .unwrap();
3128        assert_eq!(
3129            rec.only_call().args_str(),
3130            ["merge", "--no-edit", "feature"]
3131        );
3132    }
3133
3134    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
3135    #[tokio::test]
3136    async fn rebase_suppresses_editor() {
3137        let rec = RecordingRunner::replying(Reply::ok(""));
3138        let git = Git::with_runner(&rec);
3139        git.rebase(Path::new("/r"), "main").await.unwrap();
3140        let call = rec.only_call();
3141        assert_eq!(call.args_str(), ["rebase", "main"]);
3142        assert!(call.envs.iter().any(|(k, v)| {
3143            k.to_str() == Some("GIT_EDITOR")
3144                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
3145        }));
3146    }
3147
3148    #[tokio::test]
3149    async fn push_builds_set_upstream_remote_refspec() {
3150        let rec = RecordingRunner::replying(Reply::ok(""));
3151        let git = Git::with_runner(&rec);
3152        git.push(
3153            Path::new("/r"),
3154            GitPush::refspec("feat", "feature").set_upstream(),
3155        )
3156        .await
3157        .unwrap();
3158        assert_eq!(
3159            rec.only_call().args_str(),
3160            ["push", "-u", "origin", "feat:feature"]
3161        );
3162    }
3163
3164    // The common bare-branch push: `push origin <branch>` (no `-u`), with prompts
3165    // off so a credential-needing remote fails fast instead of hanging.
3166    #[tokio::test]
3167    async fn push_bare_branch_builds_origin_branch_prompt_off() {
3168        let rec = RecordingRunner::replying(Reply::ok(""));
3169        let git = Git::with_runner(&rec);
3170        git.push(Path::new("/r"), GitPush::branch("feature"))
3171            .await
3172            .unwrap();
3173        let call = rec.only_call();
3174        assert_eq!(call.args_str(), ["push", "origin", "feature"]);
3175        assert!(call.envs.iter().any(|(k, v)| {
3176            k.to_str() == Some("GIT_TERMINAL_PROMPT")
3177                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3178        }));
3179    }
3180
3181    // M16: a `+` (force-push) or an extra `:` (multi-ref) smuggled into a branch name
3182    // is refused before spawning — force-pushing must be explicit via `run`.
3183    #[tokio::test]
3184    async fn push_rejects_force_and_multiref_metacharacters() {
3185        let rec = RecordingRunner::replying(Reply::ok(""));
3186        let git = Git::with_runner(&rec);
3187        for bad in ["+main", "+main:main", "a:b:c"] {
3188            assert!(
3189                git.push(Path::new("/r"), GitPush::branch(bad))
3190                    .await
3191                    .is_err(),
3192                "{bad:?} must be rejected"
3193            );
3194        }
3195        // A legitimate `local:remote` refspec still works (one `:`, no `+`).
3196        assert!(
3197            git.push(Path::new("/r"), GitPush::refspec("main", "prod"))
3198                .await
3199                .is_ok()
3200        );
3201        assert!(
3202            rec.calls()
3203                .iter()
3204                .all(|c| c.args_str().last().unwrap() != "+main")
3205        );
3206    }
3207
3208    // `.remote()` swaps the remote token in place.
3209    #[tokio::test]
3210    async fn push_remote_override_swaps_remote() {
3211        let rec = RecordingRunner::replying(Reply::ok(""));
3212        let git = Git::with_runner(&rec);
3213        git.push(
3214            Path::new("/r"),
3215            GitPush::branch("feature").remote("upstream"),
3216        )
3217        .await
3218        .unwrap();
3219        assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
3220    }
3221
3222    // With a credential provider, a remote op gets a leading `-c credential.helper`
3223    // pair (the secret referenced by env-var NAME) plus the secret in the env — and
3224    // the token value never appears in argv. Covers push (mutating) and fetch.
3225    #[tokio::test]
3226    async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
3227        let rec = RecordingRunner::replying(Reply::ok(""));
3228        let git = Git::with_runner(&rec)
3229            .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
3230        git.push(Path::new("/r"), GitPush::branch("feature"))
3231            .await
3232            .unwrap();
3233        let call = rec.only_call();
3234        let args = call.args_str();
3235        // A leading helper-reset + inline helper precede the subcommand.
3236        assert_eq!(args[0], "-c", "config flag leads the argv");
3237        assert!(
3238            args.iter().any(|a| a == "credential.helper="),
3239            "inherited helpers are cleared first: {args:?}"
3240        );
3241        assert!(
3242            args.iter()
3243                .any(|a| a.contains("credential.helper=!f()")
3244                    && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
3245            "inline helper references the secret by env-var name: {args:?}"
3246        );
3247        assert!(
3248            args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
3249            "the real subcommand still runs: {args:?}"
3250        );
3251        // The secret value is NEVER in argv.
3252        assert!(
3253            !args.iter().any(|a| a.contains("ghp_secret123")),
3254            "secret leaked into argv: {args:?}"
3255        );
3256        // The secret lives in the env, under the helper's var name.
3257        let pw = call
3258            .envs
3259            .iter()
3260            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3261            .and_then(|(_, v)| v.as_ref())
3262            .and_then(|v| v.to_str());
3263        assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
3264    }
3265
3266    // Without a provider, remote ops are byte-identical to before — no `-c`
3267    // credential helper, no secret env (ambient git auth, unchanged).
3268    #[tokio::test]
3269    async fn default_client_injects_no_credential_helper() {
3270        let rec = RecordingRunner::replying(Reply::ok(""));
3271        let git = Git::with_runner(&rec);
3272        git.push(Path::new("/r"), GitPush::branch("feature"))
3273            .await
3274            .unwrap();
3275        let call = rec.only_call();
3276        assert_eq!(
3277            call.args_str(),
3278            ["push", "origin", "feature"],
3279            "no credential `-c` args without a provider"
3280        );
3281        assert!(
3282            !call
3283                .envs
3284                .iter()
3285                .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
3286            "no secret env without a provider"
3287        );
3288    }
3289
3290    // `clone_repo` builds its argv via a different path (`command()` + `.arg()`
3291    // chaining, not `command_in` + extend), so verify the `-c` credential args
3292    // still LEAD it and the real clone flags/url/dest follow the subcommand.
3293    #[tokio::test]
3294    async fn with_credentials_clone_puts_config_flags_before_subcommand() {
3295        let rec = RecordingRunner::replying(Reply::ok(""));
3296        let git =
3297            Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
3298        git.clone_repo(
3299            "https://example.com/r.git",
3300            Path::new("/dest"),
3301            CloneSpec::default().branch("main"),
3302        )
3303        .await
3304        .unwrap();
3305        let call = rec.only_call();
3306        let args = call.args_str();
3307        assert_eq!(args[0], "-c", "config flags lead the clone argv");
3308        let clone_at = args
3309            .iter()
3310            .position(|a| a == "clone")
3311            .expect("clone present");
3312        // Only credential `-c` flags precede the `clone` subcommand.
3313        assert!(
3314            args[..clone_at]
3315                .iter()
3316                .all(|a| a == "-c" || a.starts_with("credential.helper")),
3317            "only credential -c flags precede `clone`: {args:?}"
3318        );
3319        // The real clone flags/url/dest follow the subcommand.
3320        let tail = &args[clone_at..];
3321        assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
3322        assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
3323        assert!(
3324            !args.iter().any(|a| a.contains("s3cr3t")),
3325            "secret not in argv"
3326        );
3327        // H5: clone scopes the helper to the URL's host (in env, never argv), so a
3328        // cross-host redirect/submodule during the clone can't extract the token.
3329        let host = call
3330            .envs
3331            .iter()
3332            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
3333            .and_then(|(_, v)| v.as_ref())
3334            .and_then(|v| v.to_str());
3335        assert_eq!(
3336            host,
3337            Some("example.com"),
3338            "the clone URL's host scopes the credential helper"
3339        );
3340        // The host scoping travels in env; the credential `-c` flags that precede
3341        // `clone` must not bake the host into the helper config.
3342        assert!(
3343            args[..clone_at].iter().all(|a| !a.contains("example.com")),
3344            "host stays in env, not the credential config args: {:?}",
3345            &args[..clone_at]
3346        );
3347    }
3348
3349    // A `Credential::userpass` username threads through to the helper's env on a
3350    // remote op (here `fetch`) — the non-default-username path, end-to-end.
3351    #[tokio::test]
3352    async fn with_credentials_userpass_threads_username_through_env() {
3353        let rec = RecordingRunner::replying(Reply::ok(""));
3354        let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
3355            Credential::userpass("alice", "s3cr3t"),
3356        )));
3357        git.fetch(Path::new("/r")).await.unwrap();
3358        let call = rec.only_call();
3359        let user = call
3360            .envs
3361            .iter()
3362            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
3363            .and_then(|(_, v)| v.as_ref())
3364            .and_then(|v| v.to_str());
3365        assert_eq!(user, Some("alice"), "userpass username reaches the env");
3366        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
3367        assert!(call.args_str().contains(&"fetch".to_string()));
3368    }
3369
3370    // No-provider is byte-identical for the read/clone arg-construction paths too,
3371    // not only `push` (fetch uses `command_in`+extend; clone uses `command`+chain).
3372    #[tokio::test]
3373    async fn default_client_no_helper_on_fetch_and_clone() {
3374        let rec = RecordingRunner::replying(Reply::ok(""));
3375        Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
3376        assert_eq!(
3377            rec.only_call().args_str(),
3378            ["fetch", "--quiet"],
3379            "fetch unchanged without a provider"
3380        );
3381
3382        let rec = RecordingRunner::replying(Reply::ok(""));
3383        Git::with_runner(&rec)
3384            .clone_repo(
3385                "https://example.com/r.git",
3386                Path::new("/dest"),
3387                CloneSpec::default(),
3388            )
3389            .await
3390            .unwrap();
3391        assert_eq!(
3392            rec.only_call().args_str()[0],
3393            "clone",
3394            "clone leads with the subcommand (no `-c`) without a provider"
3395        );
3396    }
3397
3398    // The `with_token` convenience drives the same HTTPS credential.helper path as
3399    // `with_credentials` (secret in env, helper `-c` leads, not in argv).
3400    #[tokio::test]
3401    async fn with_token_convenience_authenticates_https_remote() {
3402        let rec = RecordingRunner::replying(Reply::ok(""));
3403        let git = Git::with_runner(&rec).with_token("ghp_conv");
3404        git.fetch(Path::new("/r")).await.unwrap();
3405        let call = rec.only_call();
3406        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
3407        let pw = call
3408            .envs
3409            .iter()
3410            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3411            .and_then(|(_, v)| v.as_ref())
3412            .and_then(|v| v.to_str());
3413        assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
3414        assert!(
3415            !call.args_str().iter().any(|a| a.contains("ghp_conv")),
3416            "secret not in argv"
3417        );
3418    }
3419
3420    #[tokio::test]
3421    async fn upstream_maps_unset_to_none() {
3422        let set = Git::with_runner(
3423            ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("origin/main\n")),
3424        );
3425        assert_eq!(
3426            set.upstream(Path::new(".")).await.unwrap().as_deref(),
3427            Some("origin/main")
3428        );
3429        // No upstream configured exits 128 (indistinguishable from a real failure by
3430        // code, since git uses 128 for both) → None.
3431        let unset =
3432            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "")));
3433        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
3434        // A timeout (no exit code) is a real failure — it must surface, not read as
3435        // "no upstream".
3436        let timed_out =
3437            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
3438        assert!(timed_out.upstream(Path::new(".")).await.is_err());
3439    }
3440
3441    // remote_head_branch maps the `symbolic-ref --quiet` exit code: 0 → the branch
3442    // (ref prefix stripped), 1 → None (unset origin/HEAD), and anything else (a real
3443    // failure / timeout) surfaces rather than being swallowed as "no default branch".
3444    #[tokio::test]
3445    async fn remote_head_branch_maps_exit_codes() {
3446        let set = Git::with_runner(ScriptedRunner::new().on(
3447            ["git", "symbolic-ref"],
3448            Reply::ok("refs/remotes/origin/release/v2\n"),
3449        ));
3450        assert_eq!(
3451            set.remote_head_branch(Path::new("."))
3452                .await
3453                .unwrap()
3454                .as_deref(),
3455            Some("release/v2"),
3456            "the full ref prefix is stripped, slashes preserved"
3457        );
3458        let unset =
3459            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3460        assert!(
3461            unset
3462                .remote_head_branch(Path::new("."))
3463                .await
3464                .unwrap()
3465                .is_none()
3466        );
3467        // A real failure (exit 128, not the silent --quiet exit 1) surfaces.
3468        let err = Git::with_runner(ScriptedRunner::new().on(
3469            ["git", "symbolic-ref"],
3470            Reply::fail(128, "fatal: not a git repository"),
3471        ));
3472        assert!(err.remote_head_branch(Path::new(".")).await.is_err());
3473        // A timeout surfaces too.
3474        let timed_out =
3475            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
3476        assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
3477    }
3478
3479    #[tokio::test]
3480    async fn set_upstream_builds_branch_flag() {
3481        let rec = RecordingRunner::replying(Reply::ok(""));
3482        let git = Git::with_runner(&rec);
3483        git.set_upstream(Path::new("/r"), "feat", "origin/feature")
3484            .await
3485            .unwrap();
3486        assert_eq!(
3487            rec.only_call().args_str(),
3488            ["branch", "--set-upstream-to=origin/feature", "feat"]
3489        );
3490    }
3491
3492    #[tokio::test]
3493    async fn remote_branches_parses_ls_remote() {
3494        let git = Git::with_runner(ScriptedRunner::new().on(
3495            ["git", "ls-remote"],
3496            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
3497        ));
3498        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
3499        assert_eq!(branches, ["main", "feat/x"]);
3500    }
3501
3502    #[tokio::test]
3503    async fn delete_branch_force_uses_capital_d() {
3504        let rec = RecordingRunner::replying(Reply::ok(""));
3505        let git = Git::with_runner(&rec);
3506        git.delete_branch(Path::new("/r"), "old", true)
3507            .await
3508            .unwrap();
3509        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
3510    }
3511
3512    // `branch --merged` marks the current branch with `*` and a branch checked out
3513    // in another worktree with `+`; both must still match after marker stripping.
3514    #[tokio::test]
3515    async fn is_merged_strips_branch_markers() {
3516        let git = Git::with_runner(ScriptedRunner::new().on(
3517            ["git", "branch", "--merged"],
3518            Reply::ok("  main\n* feature\n+ wt-branch\n"),
3519        ));
3520        for name in ["main", "feature", "wt-branch"] {
3521            assert!(
3522                git.is_merged(Path::new("."), MergeCheck::branch(name).into_base("main"))
3523                    .await
3524                    .unwrap(),
3525                "{name} should be reported merged"
3526            );
3527        }
3528        assert!(
3529            !git.is_merged(
3530                Path::new("."),
3531                MergeCheck::branch("absent").into_base("main")
3532            )
3533            .await
3534            .unwrap()
3535        );
3536    }
3537
3538    // A5: the `MergeCheck` builder lands branch/base in the right slots, and
3539    // `is_merged` queries `branch --merged <base>` — so a transposed pair would
3540    // change the emitted command, not silently invert a same-shaped call.
3541    #[tokio::test]
3542    async fn merge_check_names_branch_and_base_without_transposition() {
3543        use processkit::testing::RecordingRunner;
3544        let spec = MergeCheck::branch("feature").into_base("main");
3545        assert_eq!(spec.branch, "feature");
3546        assert_eq!(spec.base, "main");
3547
3548        let rec = RecordingRunner::replying(Reply::ok("  feature\n* main\n"));
3549        let merged = Git::with_runner(&rec)
3550            .is_merged(
3551                Path::new("/repo"),
3552                MergeCheck::branch("feature").into_base("main"),
3553            )
3554            .await
3555            .unwrap();
3556        // `feature` appears under `branch --merged main`, so it reports merged — and
3557        // the emitted args put `base` (main) in the `--merged` slot, not `branch`.
3558        assert!(merged, "feature is listed as merged into main");
3559        assert_eq!(
3560            rec.only_call().args_str(),
3561            ["branch", "--merged", "main", "--no-column", "--no-color"]
3562        );
3563    }
3564
3565    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
3566    // a remote needing auth — matching the other remote ops.
3567    #[tokio::test]
3568    async fn fetch_disables_terminal_prompt() {
3569        let rec = RecordingRunner::replying(Reply::ok(""));
3570        let git = Git::with_runner(&rec);
3571        git.fetch(Path::new("/r")).await.unwrap();
3572        let call = rec.only_call();
3573        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
3574        assert!(call.envs.iter().any(|(k, v)| {
3575            k.to_str() == Some("GIT_TERMINAL_PROMPT")
3576                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3577        }));
3578    }
3579
3580    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
3581    #[tokio::test]
3582    async fn fetch_retries_transient_failures() {
3583        let rec = RecordingRunner::replying(Reply::fail(
3584            128,
3585            "fatal: unable to access: Could not resolve host: example.com",
3586        ));
3587        let git = Git::with_runner(&rec);
3588        assert!(git.fetch(Path::new("/r")).await.is_err());
3589        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3590    }
3591
3592    // R6 (a `fetch` timeout is NOT retried) is pinned at the unit level by
3593    // `vcs_cli_support`'s `classifies_nothing_to_commit_and_transient_fetch`
3594    // (`is_transient_fetch_error(&Timeout) == false`); together with
3595    // `fetch_retries_transient_failures` above (the loop retries exactly what the
3596    // predicate accepts) that proves the timeout is terminal for the fetch-retry. A
3597    // faithful end-to-end timeout is awkward to simulate hermetically (a paused-clock
3598    // `Reply::pending()` doesn't auto-fire the per-command deadline), so it isn't
3599    // duplicated here.
3600
3601    // Opt-in lock-contention retry: a mutation that fails because another process
3602    // holds `index.lock` is retried and succeeds — the command never ran, so the
3603    // retry is safe. `RetryPolicy::none().attempts(3)` keeps the backoff at zero so
3604    // the test never sleeps.
3605    #[tokio::test]
3606    async fn with_retry_retries_lock_contention_on_a_mutation() {
3607        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3608            ["git", "commit"],
3609            [
3610                Reply::fail(
3611                    128,
3612                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
3613                ),
3614                Reply::ok(""),
3615            ],
3616        ));
3617        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3618        git.commit(Path::new("/r"), "msg")
3619            .await
3620            .expect("retried past the lock");
3621        assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3622    }
3623
3624    // Retry is off by default — the same lock failure propagates without `with_retry`.
3625    #[tokio::test]
3626    async fn default_client_does_not_retry_lock_contention() {
3627        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3628            ["git", "commit"],
3629            [
3630                Reply::fail(
3631                    128,
3632                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
3633                ),
3634                Reply::ok(""),
3635            ],
3636        ));
3637        let git = Git::with_runner(&rec);
3638        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3639        assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3640    }
3641
3642    // Even with retry on, a real (non-lock) failure is returned immediately — only
3643    // lock contention is retried, so a genuine error is never silently repeated.
3644    #[tokio::test]
3645    async fn with_retry_does_not_retry_a_real_failure() {
3646        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3647            ["git", "commit"],
3648            [
3649                Reply::fail(1, "error: pathspec 'x' did not match"),
3650                Reply::ok(""),
3651            ],
3652        ));
3653        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3654        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3655        assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
3656    }
3657
3658    // A non-transient failure fails fast — no retry.
3659    #[tokio::test]
3660    async fn fetch_does_not_retry_permanent_failures() {
3661        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
3662        let git = Git::with_runner(&rec);
3663        assert!(git.fetch(Path::new("/r")).await.is_err());
3664        assert_eq!(rec.calls().len(), 1);
3665    }
3666
3667    // Client-level cancellation (processkit 0.8 `cancellation` feature) on a
3668    // *retried* op: a `fetch` built on a client with `default_cancel_on(token)`
3669    // parks until the token fires, then surfaces `Error::Cancelled` — and because
3670    // cancellation is **terminal** (not transient), the fetch-retry does NOT
3671    // replay it (one spawn, not FETCH_ATTEMPTS). Hermetic via `Reply::pending()`
3672    // on a paused clock.
3673    #[tokio::test(start_paused = true)]
3674    async fn fetch_cancels_and_does_not_retry() {
3675        use processkit::CancellationToken;
3676        let token = CancellationToken::new();
3677        let rec =
3678            RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
3679        let git = Git::with_runner(&rec).default_cancel_on(token.clone());
3680        let call = git.fetch(Path::new("/r"));
3681        tokio::pin!(call);
3682        assert!(
3683            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
3684                .await
3685                .is_err(),
3686            "fetch must park until the token fires"
3687        );
3688        token.cancel();
3689        assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
3690        assert_eq!(
3691            rec.calls().len(),
3692            1,
3693            "cancellation is terminal — the fetch-retry must not replay it"
3694        );
3695    }
3696
3697    // The injection guard: a flag-shaped value in any exposed positional slot
3698    // must be refused BEFORE anything spawns.
3699    #[tokio::test]
3700    async fn flag_like_positionals_are_rejected_before_spawning() {
3701        let rec = RecordingRunner::replying(Reply::ok(""));
3702        let git = Git::with_runner(&rec);
3703        let dir = Path::new("/r");
3704
3705        assert!(git.checkout(dir, "-evil").await.is_err());
3706        assert!(git.create_branch(dir, "--force").await.is_err());
3707        assert!(git.delete_branch(dir, "-D", false).await.is_err());
3708        assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
3709        assert!(
3710            git.merge_commit(dir, MergeCommit::branch("-evil"))
3711                .await
3712                .is_err()
3713        );
3714        assert!(
3715            git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
3716                .await
3717                .is_err()
3718        );
3719        assert!(git.merge_squash(dir, "-evil").await.is_err());
3720        assert!(git.rebase(dir, "-i").await.is_err());
3721        assert!(git.cherry_pick(dir, "-n").await.is_err());
3722        assert!(git.revert(dir, "-evil").await.is_err());
3723        assert!(git.tag_create(dir, "-d", None).await.is_err());
3724        assert!(
3725            git.tag_create(dir, "ok", Some("-evil".into()))
3726                .await
3727                .is_err()
3728        );
3729        assert!(git.tag_delete(dir, "-evil").await.is_err());
3730        assert!(git.remote_add(dir, "-evil", "url").await.is_err());
3731        assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
3732        assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
3733        assert!(git.log(dir, "-evil", 5).await.is_err());
3734        assert!(git.rev_list_count(dir, "-evil").await.is_err());
3735        assert!(git.diff_stat(dir, "-evil").await.is_err());
3736        assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
3737        assert!(
3738            git.diff_text(dir, DiffSpec::Rev("-evil".into()))
3739                .await
3740                .is_err()
3741        );
3742        assert!(git.rev_parse(dir, "-evil").await.is_err());
3743        assert!(git.rev_parse_short(dir, "-evil").await.is_err());
3744        assert!(git.resolve_commit(dir, "-evil").await.is_err());
3745        assert!(git.reset_hard(dir, "-evil").await.is_err());
3746        assert!(git.checkout_detach(dir, "-evil").await.is_err());
3747        assert!(git.config_set(dir, "-evil", "v").await.is_err());
3748        assert!(
3749            git.push(dir, GitPush::branch("-evil")).await.is_err(),
3750            "refspec guard"
3751        );
3752        // Embedded-token-prefix and standalone-rev positionals:
3753        assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
3754        assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
3755        assert!(git.remote_url(dir, "-evil").await.is_err());
3756        assert!(git.remote_branches(dir, "-evil").await.is_err());
3757        assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
3758        // URL positionals (a leading-`-` url is an RCE-class flag injection).
3759        assert!(
3760            git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
3761                .await
3762                .is_err()
3763        );
3764        assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
3765        assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
3766        assert!(
3767            git.is_merged(dir, MergeCheck::branch("-evil").into_base("main"))
3768                .await
3769                .is_err()
3770        );
3771        assert!(git.config_get(dir, "-evil").await.is_err());
3772        assert!(
3773            git.worktree_add(
3774                dir,
3775                WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
3776            )
3777            .await
3778            .is_err()
3779        );
3780        // Empty values are refused too.
3781        assert!(git.checkout(dir, "").await.is_err());
3782
3783        assert!(
3784            rec.calls().is_empty(),
3785            "nothing may spawn: {:?}",
3786            rec.calls()
3787        );
3788
3789        // …and legitimate values still pass through unchanged (with the trailing
3790        // `--` that keeps a path-like ref out of pathspec mode — C2).
3791        git.checkout(dir, "feature/x").await.expect("checkout");
3792        assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
3793    }
3794
3795    // The hardened profile lands its env pairs/removals on EVERY command, and
3796    // composes with per-command env like GIT_TERMINAL_PROMPT.
3797    #[tokio::test]
3798    async fn harden_applies_env_profile_to_every_command() {
3799        let rec = RecordingRunner::replying(Reply::ok(""));
3800        let git = Git::with_runner(&rec).harden();
3801        git.status(Path::new("/r")).await.expect("status");
3802        git.fetch(Path::new("/r")).await.expect("fetch");
3803
3804        for call in rec.calls() {
3805            let has = |k: &str, v: &str| {
3806                call.envs.iter().any(|(key, val)| {
3807                    key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
3808                })
3809            };
3810            let removed = |k: &str| {
3811                call.envs
3812                    .iter()
3813                    .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3814            };
3815            assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
3816            assert!(has("GIT_CONFIG_COUNT", "3"));
3817            assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
3818            assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
3819            assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
3820            // The repo-local core.sshCommand kill-switch (pinned empty).
3821            assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
3822            assert!(has("GIT_CONFIG_VALUE_2", ""));
3823            assert!(has("GIT_TERMINAL_PROMPT", "0"));
3824            assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
3825            assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
3826            // Command-hook env vectors are scrubbed too.
3827            assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
3828            assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
3829            assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
3830            assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
3831            // M14: the additional code-execution vectors + pathspec-mode vars.
3832            assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
3833            assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
3834            assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
3835            assert!(
3836                removed("GIT_ICASE_PATHSPECS"),
3837                "GIT_ICASE_PATHSPECS scrubbed"
3838            );
3839        }
3840    }
3841
3842    // H4: EVERY git client (not just `harden()`) scrubs the repo-**redirector** env
3843    // vars, so a `GIT_DIR`/`GIT_INDEX_FILE` leaking from the parent (e.g. running
3844    // inside a git hook, which exports them) can't silently retarget a command at a
3845    // different repository than the bound `dir`. The command-hook scrubs and config
3846    // pins stay `harden()`-only.
3847    #[tokio::test]
3848    async fn default_client_scrubs_repo_redirector_env() {
3849        let rec = RecordingRunner::replying(Reply::ok(""));
3850        let git = Git::with_runner(&rec); // NOT hardened
3851        git.status(Path::new("/r")).await.expect("status");
3852        let call = rec.only_call();
3853        let removed = |k: &str| {
3854            call.envs
3855                .iter()
3856                .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3857        };
3858        let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
3859        for var in [
3860            "GIT_DIR",
3861            "GIT_WORK_TREE",
3862            "GIT_INDEX_FILE",
3863            "GIT_COMMON_DIR",
3864            "GIT_OBJECT_DIRECTORY",
3865            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3866            "GIT_NAMESPACE",
3867        ] {
3868            assert!(removed(var), "{var} must be scrubbed on the default client");
3869        }
3870        // `harden()`-only surface is absent on a plain client.
3871        assert!(
3872            !has_key("GIT_SSH_COMMAND"),
3873            "command-hook scrub is harden()-only"
3874        );
3875        assert!(
3876            !has_key("GIT_CONFIG_NOSYSTEM"),
3877            "config pins are harden()-only"
3878        );
3879    }
3880
3881    // RefName/RevSpec accept/reject tables.
3882    #[test]
3883    fn ref_name_and_rev_spec_validate() {
3884        for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
3885            assert!(RefName::new(ok).is_ok(), "{ok}");
3886        }
3887        for bad in [
3888            "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
3889            "a\\b", "end/", "x.lock",
3890        ] {
3891            assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
3892        }
3893        assert!(RevSpec::new("HEAD~2").is_ok());
3894        assert!(RevSpec::new("main..feature").is_ok());
3895        assert!(RevSpec::new("-evil").is_err());
3896        assert!(RevSpec::new("").is_err());
3897    }
3898
3899    // capabilities parses real-world version shapes (incl. the Windows build
3900    // trailer) and gates on the major floor only.
3901    #[tokio::test]
3902    async fn capabilities_parse_and_gate_versions() {
3903        let gh = Git::with_runner(ScriptedRunner::new().on(
3904            ["git", "--version"],
3905            Reply::ok("git version 2.54.0.windows.1\n"),
3906        ));
3907        let caps = gh.capabilities().await.expect("capabilities");
3908        assert_eq!(caps.version.to_string(), "2.54.0");
3909        assert!(caps.is_supported());
3910        caps.ensure_supported().expect("supported");
3911
3912        // Two-part versions parse (patch defaults to 0); an ancient major fails
3913        // the gate with a clear message.
3914        let old = Git::with_runner(
3915            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
3916        );
3917        let caps = old.capabilities().await.expect("capabilities");
3918        assert_eq!(
3919            caps.version,
3920            GitVersion {
3921                major: 1,
3922                minor: 9,
3923                patch: 0
3924            }
3925        );
3926        let err = caps.ensure_supported().expect_err("unsupported");
3927        // The message must name the floor and the found version.
3928        let Error::Spawn { source, .. } = &err else {
3929            panic!("expected Spawn, got {err:?}");
3930        };
3931        let message = source.to_string();
3932        assert!(message.contains(">= 2"), "names the floor: {message}");
3933        assert!(
3934            message.contains("1.9.0"),
3935            "names the found version: {message}"
3936        );
3937
3938        // Garbage output is a parse error, not a silent zero version.
3939        let garbage = Git::with_runner(
3940            ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
3941        );
3942        assert!(matches!(
3943            garbage.capabilities().await.unwrap_err(),
3944            Error::Parse { .. }
3945        ));
3946    }
3947
3948    // clone_repo is dir-less and appends only the requested flags.
3949    #[tokio::test]
3950    async fn clone_repo_builds_flags_and_runs_dirless() {
3951        let rec = RecordingRunner::replying(Reply::ok(""));
3952        let git = Git::with_runner(&rec);
3953        git.clone_repo(
3954            "https://example.com/r.git",
3955            Path::new("/dest"),
3956            CloneSpec::new().branch("main").depth(1).bare(),
3957        )
3958        .await
3959        .expect("clone");
3960        let call = rec.only_call();
3961        assert_eq!(
3962            call.args_str(),
3963            [
3964                "clone",
3965                "--branch",
3966                "main",
3967                "--depth",
3968                "1",
3969                "--bare",
3970                "https://example.com/r.git",
3971                "/dest"
3972            ]
3973        );
3974        assert_eq!(call.cwd, None, "clone runs without a working directory");
3975
3976        let bare = RecordingRunner::replying(Reply::ok(""));
3977        let git = Git::with_runner(&bare);
3978        git.clone_repo("u", Path::new("/d"), CloneSpec::new())
3979            .await
3980            .expect("clone");
3981        assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
3982    }
3983
3984    // R7: a failed clone cleans a `dest` it could have *created* (absent or empty) so
3985    // a retry isn't blocked by "destination already exists and is not empty" — but it
3986    // must NEVER delete a non-empty pre-existing dir (git would have refused, so the
3987    // caller's data is untouched). Scripted-fail clone + real temp dirs (only the fs
3988    // cleanup is real; nothing spawns).
3989    #[tokio::test]
3990    async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
3991        use vcs_testkit::TempDir;
3992        let tmp = TempDir::new("r7-clone");
3993        let git = Git::with_runner(ScriptedRunner::new().on(
3994            ["git", "clone"],
3995            Reply::fail(
3996                128,
3997                "fatal: could not read Username for 'https://x': prompts disabled",
3998            ),
3999        ));
4000
4001        // A non-empty caller dir must survive a failed clone.
4002        let occupied = tmp.path().join("occupied");
4003        std::fs::create_dir(&occupied).unwrap();
4004        std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
4005        assert!(
4006            git.clone_repo("https://x/r", &occupied, CloneSpec::new())
4007                .await
4008                .is_err()
4009        );
4010        assert!(
4011            occupied.join("keep.txt").exists(),
4012            "a non-empty caller dir must survive a failed clone"
4013        );
4014
4015        // An empty dest we could have populated is removed on failure.
4016        let empty = tmp.path().join("empty");
4017        std::fs::create_dir(&empty).unwrap();
4018        assert!(
4019            git.clone_repo("https://x/r", &empty, CloneSpec::new())
4020                .await
4021                .is_err()
4022        );
4023        assert!(
4024            !empty.exists(),
4025            "an empty dest is cleaned so a retry isn't blocked"
4026        );
4027
4028        // A pre-existing FILE at `dest` must survive (read_dir errs → cleanable, but
4029        // remove_dir_all refuses a non-dir). Pins that a future "also remove a file"
4030        // change can't slip in unnoticed.
4031        let file_dest = tmp.path().join("a-file");
4032        std::fs::write(&file_dest, b"caller file").unwrap();
4033        assert!(
4034            git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
4035                .await
4036                .is_err()
4037        );
4038        assert!(
4039            file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
4040            "a caller's file at dest must survive a failed clone"
4041        );
4042
4043        // A symlink `dest` → an EMPTY dir the caller owns: `read_dir` follows the
4044        // link and sees empty, so `cleanable` is true and `remove_dir_all` DOES run on
4045        // the link — it must unlink only the symlink, never delete THROUGH it. The
4046        // target dir (and a sibling sentinel) must survive.
4047        #[cfg(unix)]
4048        {
4049            let target = tmp.path().join("link-target"); // stays empty → cleanable path
4050            std::fs::create_dir(&target).unwrap();
4051            let sentinel = tmp.path().join("sibling.txt");
4052            std::fs::write(&sentinel, b"untouched").unwrap();
4053            let link = tmp.path().join("a-symlink");
4054            std::os::unix::fs::symlink(&target, &link).unwrap();
4055            assert!(
4056                git.clone_repo("https://x/r", &link, CloneSpec::new())
4057                    .await
4058                    .is_err()
4059            );
4060            assert!(
4061                target.exists() && sentinel.exists(),
4062                "a failed clone must unlink at most the symlink, never delete through it"
4063            );
4064        }
4065    }
4066
4067    #[tokio::test]
4068    async fn tag_methods_build_args() {
4069        let rec = RecordingRunner::replying(Reply::ok(""));
4070        let git = Git::with_runner(&rec);
4071        git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
4072        git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
4073            .await
4074            .unwrap();
4075        git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
4076            .await
4077            .unwrap();
4078        git.tag_delete(Path::new("/r"), "v1").await.unwrap();
4079        let calls = rec.calls();
4080        assert_eq!(calls[0].args_str(), ["tag", "v1"]);
4081        assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
4082        assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
4083        assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
4084    }
4085
4086    #[tokio::test]
4087    async fn tag_list_splits_lines() {
4088        let git = Git::with_runner(
4089            ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
4090        );
4091        assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
4092    }
4093
4094    // The line-parsed list commands must pass `--no-column`: a user's
4095    // `column.ui = always` would pack several names per line, and
4096    // `color.{ui,branch} = always` would inject ANSI escapes — both even when
4097    // piped. Branch listings disable both; `git tag` isn't colorized, so it only
4098    // needs `--no-column`.
4099    #[tokio::test]
4100    async fn list_commands_disable_column_and_color() {
4101        let rec = RecordingRunner::replying(Reply::ok(""));
4102        let git = Git::with_runner(&rec);
4103        git.branches(Path::new(".")).await.unwrap();
4104        git.is_merged(Path::new("."), MergeCheck::branch("b").into_base("main"))
4105            .await
4106            .unwrap();
4107        git.tag_list(Path::new(".")).await.unwrap();
4108        let calls = rec.calls();
4109        assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
4110        assert_eq!(
4111            calls[1].args_str(),
4112            ["branch", "--merged", "main", "--no-column", "--no-color"]
4113        );
4114        assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
4115    }
4116
4117    // Commands whose failure output feeds the error classifiers must force the
4118    // C locale — a translated message would defeat the substring matching.
4119    #[tokio::test]
4120    async fn classified_commands_force_c_locale() {
4121        let rec = RecordingRunner::replying(Reply::ok(""));
4122        let git = Git::with_runner(&rec);
4123        git.commit(Path::new("."), "msg").await.unwrap();
4124        git.merge_commit(Path::new("."), MergeCommit::branch("b"))
4125            .await
4126            .unwrap();
4127        git.merge_squash(Path::new("."), "b").await.unwrap();
4128        git.merge_no_commit(Path::new("."), MergeNoCommit::branch("b"))
4129            .await
4130            .unwrap();
4131        git.cherry_pick(Path::new("."), "abc").await.unwrap();
4132        git.stash_pop(Path::new(".")).await.unwrap();
4133        git.fetch(Path::new(".")).await.unwrap();
4134        for call in rec.calls() {
4135            assert!(
4136                call.envs.iter().any(|(k, v)| {
4137                    k.to_str() == Some("LC_ALL")
4138                        && v.as_deref().and_then(|o| o.to_str()) == Some("C")
4139                }),
4140                "{:?} should force LC_ALL=C",
4141                call.args_str()
4142            );
4143        }
4144    }
4145
4146    // The `<rev>:<path>` spec requires forward slashes — Windows callers may
4147    // hand in backslashes. The normalisation is Windows-only.
4148    #[cfg(windows)]
4149    #[tokio::test]
4150    async fn show_file_normalises_path_separators() {
4151        let rec = RecordingRunner::replying(Reply::ok("content\n"));
4152        let git = Git::with_runner(&rec);
4153        let out = git
4154            .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4155            .await
4156            .expect("show_file");
4157        // The blob's trailing newline is preserved verbatim (H7) — not trimmed.
4158        assert_eq!(out, "content\n");
4159        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
4160    }
4161
4162    // H7: content verbs return git's output byte-for-byte — the round-trip-corrupting
4163    // cases are multiple trailing newlines and a missing final newline.
4164    #[tokio::test]
4165    async fn content_verbs_preserve_exact_trailing_bytes() {
4166        for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces   \n"] {
4167            let rec = RecordingRunner::replying(Reply::ok(raw));
4168            let git = Git::with_runner(&rec);
4169            let out = git
4170                .show_file(Path::new("/r"), "HEAD", "f.txt")
4171                .await
4172                .expect("show_file");
4173            assert_eq!(out, raw, "show_file returns bytes verbatim");
4174        }
4175        // diff_text is verbatim too (its trailing blank context line must survive so
4176        // the last hunk stays in sync with its `@@` count).
4177        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
4178        let rec = RecordingRunner::replying(Reply::ok(diff));
4179        let git = Git::with_runner(&rec);
4180        assert_eq!(
4181            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
4182                .await
4183                .expect("diff_text"),
4184            diff
4185        );
4186    }
4187
4188    // On Unix a backslash is a legal filename byte — the spec must pass through
4189    // verbatim so a literal `a\b.txt` stays resolvable.
4190    #[cfg(not(windows))]
4191    #[tokio::test]
4192    async fn show_file_keeps_backslashes_on_unix() {
4193        let rec = RecordingRunner::replying(Reply::ok("content\n"));
4194        let git = Git::with_runner(&rec);
4195        git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4196            .await
4197            .expect("show_file");
4198        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
4199    }
4200
4201    // config --get: exit 0 → Some(value), exit 1 → None (unset), other → error.
4202    #[tokio::test]
4203    async fn config_get_maps_exit_codes() {
4204        let set = Git::with_runner(
4205            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
4206        );
4207        assert_eq!(
4208            set.config_get(Path::new("."), "user.name").await.unwrap(),
4209            Some("Alice".to_string())
4210        );
4211        // Only git's trailing newline (here `\r\n`) is stripped — a value's own
4212        // trailing spaces are preserved (they can be meaningful).
4213        let spaced = Git::with_runner(
4214            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix:  \r\n")),
4215        );
4216        assert_eq!(
4217            spaced.config_get(Path::new("."), "x.y").await.unwrap(),
4218            Some("prefix:  ".to_string())
4219        );
4220        let unset = Git::with_runner(
4221            ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
4222        );
4223        assert_eq!(
4224            unset.config_get(Path::new("."), "user.name").await.unwrap(),
4225            None
4226        );
4227        // A multi-valued key (exit 2) or worse is a real error.
4228        let multi = Git::with_runner(ScriptedRunner::new().on(
4229            ["git", "config", "--get"],
4230            Reply::fail(2, "multiple values"),
4231        ));
4232        assert!(
4233            multi
4234                .config_get(Path::new("."), "remote.all")
4235                .await
4236                .is_err()
4237        );
4238    }
4239
4240    #[tokio::test]
4241    async fn blame_builds_rev_before_pathspec_separator() {
4242        let rec = RecordingRunner::replying(Reply::ok(""));
4243        let git = Git::with_runner(&rec);
4244        git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
4245            .await
4246            .unwrap();
4247        git.blame(Path::new("/r"), "src/lib.rs", None)
4248            .await
4249            .unwrap();
4250        let calls = rec.calls();
4251        assert_eq!(
4252            calls[0].args_str(),
4253            ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
4254        );
4255        assert_eq!(
4256            calls[1].args_str(),
4257            ["blame", "--line-porcelain", "--", "src/lib.rs"]
4258        );
4259    }
4260
4261    // revert must never open an editor: --no-edit plus the env backstop.
4262    #[tokio::test]
4263    async fn sequencer_methods_suppress_editors() {
4264        let rec = RecordingRunner::replying(Reply::ok(""));
4265        let git = Git::with_runner(&rec);
4266        git.revert(Path::new("/r"), "abc").await.unwrap();
4267        git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
4268        git.rebase_skip(Path::new("/r")).await.unwrap();
4269        let calls = rec.calls();
4270        assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
4271        assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
4272        assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
4273        for call in &calls {
4274            assert!(
4275                call.envs
4276                    .iter()
4277                    .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
4278                "editor suppressed on {:?}",
4279                call.args_str()
4280            );
4281        }
4282    }
4283
4284    // harden() scrubs GIT_EDITOR/GIT_SEQUENCE_EDITOR from the inherited
4285    // environment, but a sequencer command sets its own `GIT_EDITOR=true` per call
4286    // (no_editor). `command_in` applies the client-level removal eagerly, so the env
4287    // list ends up `[…, (GIT_EDITOR, None), …, (GIT_EDITOR, "true")]` — and at spawn
4288    // each op is applied in order (processkit's `Command` does `env_remove` then
4289    // `env`), so the LAST write wins. The effective value MUST be `true`, else a
4290    // hardened `revert`/`cherry-pick`/`rebase` would lose its no-op editor and hang
4291    // a headless caller. Pin that effective-precedence (fold in spawn order).
4292    #[tokio::test]
4293    async fn hardened_sequencer_keeps_its_no_op_editor() {
4294        let rec = RecordingRunner::replying(Reply::ok(""));
4295        let git = Git::with_runner(&rec).harden();
4296        git.revert(Path::new("/r"), "abc").await.unwrap();
4297        let call = rec.only_call();
4298        // Resolve each editor var the way the OS does: last op for the key wins.
4299        let effective = |var: &str| {
4300            call.envs
4301                .iter()
4302                .rfind(|(k, _)| k.to_str() == Some(var))
4303                .and_then(|(_, v)| v.as_deref())
4304                .and_then(|v| v.to_str())
4305        };
4306        // Both no-op editors must survive harden()'s scrub (symmetric precedence),
4307        // else a hardened sequencer command hangs a headless caller.
4308        assert_eq!(
4309            effective("GIT_EDITOR"),
4310            Some("true"),
4311            "the per-command no-op editor must survive harden()'s scrub"
4312        );
4313        assert_eq!(
4314            effective("GIT_SEQUENCE_EDITOR"),
4315            Some("true"),
4316            "the per-command no-op sequence editor must survive harden()'s scrub"
4317        );
4318    }
4319
4320    #[tokio::test]
4321    async fn remote_add_and_set_url_build_args() {
4322        let rec = RecordingRunner::replying(Reply::ok(""));
4323        let git = Git::with_runner(&rec);
4324        git.remote_add(Path::new("/r"), "up", "https://x/y.git")
4325            .await
4326            .unwrap();
4327        git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
4328            .await
4329            .unwrap();
4330        let calls = rec.calls();
4331        assert_eq!(
4332            calls[0].args_str(),
4333            ["remote", "add", "up", "https://x/y.git"]
4334        );
4335        assert_eq!(
4336            calls[1].args_str(),
4337            ["remote", "set-url", "up", "https://x/z.git"]
4338        );
4339    }
4340
4341    // Dirty tree that stashes: status → list(before) → push → list(after, deeper) →
4342    // checkout → pop --index, in that order.
4343    #[tokio::test]
4344    async fn switch_with_stash_round_trips_dirty_tree() {
4345        let rec = RecordingRunner::new(
4346            ScriptedRunner::new()
4347                .on(["git", "status"], Reply::ok(" M a.rs\0"))
4348                // Stash-list depth goes 0 → 1, so the push is known to have saved.
4349                .on_sequence(
4350                    ["git", "stash", "list"],
4351                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4352                )
4353                .on(["git", "stash", "push"], Reply::ok(""))
4354                .on(["git", "checkout"], Reply::ok(""))
4355                .on(["git", "stash", "pop"], Reply::ok("")),
4356        );
4357        let git = Git::with_runner(&rec);
4358        git.switch_with_stash(Path::new("/r"), "feature")
4359            .await
4360            .expect("switch");
4361        let calls = rec.calls();
4362        assert_eq!(calls.len(), 6);
4363        assert_eq!(
4364            calls[2].args_str(),
4365            ["stash", "push", "--include-untracked"]
4366        );
4367        assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
4368        // `--index` restores the staged/unstaged split (M12).
4369        assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
4370    }
4371
4372    // M12: a dirty tree whose dirt `stash push` can't save (e.g. a submodule-only
4373    // change) — the stash-list depth is unchanged, so we must NOT pop an unrelated
4374    // pre-existing stash. Switch as-is.
4375    #[tokio::test]
4376    async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
4377        let rec = RecordingRunner::new(
4378            ScriptedRunner::new()
4379                .on(["git", "status"], Reply::ok(" M sub\0"))
4380                // Depth stays 1 across the push → nothing was actually stashed.
4381                .on(
4382                    ["git", "stash", "list"],
4383                    Reply::ok("stash@{0}: someone else's WIP\n"),
4384                )
4385                .on(
4386                    ["git", "stash", "push"],
4387                    Reply::ok("No local changes to save\n"),
4388                )
4389                .on(["git", "checkout"], Reply::ok("")),
4390        );
4391        let git = Git::with_runner(&rec);
4392        git.switch_with_stash(Path::new("/r"), "feature")
4393            .await
4394            .expect("switch");
4395        assert!(
4396            rec.calls()
4397                .iter()
4398                .all(|c| c.args_str() != ["stash", "pop", "--index"]
4399                    && c.args_str() != ["stash", "pop"]),
4400            "must not pop an unrelated stash when the push saved nothing"
4401        );
4402    }
4403
4404    // A clean tree skips the stash round-trip — a no-op `stash push` would make
4405    // the later pop grab an older, unrelated stash.
4406    #[tokio::test]
4407    async fn switch_with_stash_skips_stash_on_clean_tree() {
4408        let rec = RecordingRunner::new(
4409            ScriptedRunner::new()
4410                .on(["git", "status"], Reply::ok(""))
4411                .on(["git", "checkout"], Reply::ok("")),
4412        );
4413        let git = Git::with_runner(&rec);
4414        git.switch_with_stash(Path::new("/r"), "feature")
4415            .await
4416            .expect("switch");
4417        let calls = rec.calls();
4418        assert_eq!(calls.len(), 2);
4419        assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
4420    }
4421
4422    // A failed checkout pops the stash back (we are still on the original
4423    // branch) and surfaces the checkout error.
4424    #[tokio::test]
4425    async fn switch_with_stash_restores_on_checkout_failure() {
4426        let rec = RecordingRunner::new(
4427            ScriptedRunner::new()
4428                .on(["git", "status"], Reply::ok(" M a.rs\0"))
4429                .on_sequence(
4430                    ["git", "stash", "list"],
4431                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4432                )
4433                .on(["git", "stash", "push"], Reply::ok(""))
4434                .on(
4435                    ["git", "checkout"],
4436                    Reply::fail(1, "error: pathspec 'nope'"),
4437                )
4438                .on(["git", "stash", "pop"], Reply::ok("")),
4439        );
4440        let git = Git::with_runner(&rec);
4441        let err = git
4442            .switch_with_stash(Path::new("/r"), "nope")
4443            .await
4444            .expect_err("checkout error must surface");
4445        assert!(matches!(err, Error::Exit { .. }));
4446        let calls = rec.calls();
4447        assert_eq!(
4448            calls.last().unwrap().args_str(),
4449            ["stash", "pop", "--index"],
4450            "restoring pop ran with --index"
4451        );
4452    }
4453
4454    // `fetch_from` names the remote, keeps the prompt off, and shares the
4455    // transient retry.
4456    #[tokio::test]
4457    async fn fetch_from_builds_args_and_retries() {
4458        let rec = RecordingRunner::replying(Reply::ok(""));
4459        let git = Git::with_runner(&rec);
4460        git.fetch_from(Path::new("/r"), "upstream")
4461            .await
4462            .expect("fetch_from");
4463        let call = rec.only_call();
4464        assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
4465        assert!(call.envs.iter().any(|(k, v)| {
4466            k.to_str() == Some("GIT_TERMINAL_PROMPT")
4467                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4468        }));
4469
4470        let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
4471        let git = Git::with_runner(&failing);
4472        assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
4473        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
4474    }
4475
4476    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
4477    // tested with a generated mock.
4478    #[cfg(feature = "mock")]
4479    #[tokio::test]
4480    async fn consumer_mocks_the_interface() {
4481        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
4482            git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
4483        }
4484        let mut mock = MockGitApi::new();
4485        mock.expect_current_branch()
4486            .returning(|_| Ok(Some("main".to_string())));
4487        assert!(on_branch(&mock, "main").await);
4488    }
4489}
4490
4491// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
4492#[doc = include_str!("../docs/git.md")]
4493#[allow(rustdoc::broken_intra_doc_links)]
4494pub mod guide {
4495    #[doc = include_str!("../docs/security.md")]
4496    #[allow(rustdoc::broken_intra_doc_links)]
4497    pub mod security {}
4498    #[doc = include_str!("../docs/conflicts.md")]
4499    #[allow(rustdoc::broken_intra_doc_links)]
4500    pub mod conflicts {}
4501}