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