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