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