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//! It shells out to the installed `git` binary and parses its output into typed
6//! values — so you get *git's own* behaviour, config, and credential handling,
7//! not a reimplementation of the object format. Async throughout, structured
8//! errors, and mockable. Every command runs inside an OS **job** (via
9//! [`processkit`]) so a `git` subprocess tree can never be orphaned, and honours
10//! an optional per-client [timeout](Git::default_timeout).
11//!
12//! # The surface
13//!
14//! - **[`GitApi`]** — the object-safe trait every operation lives on. Depend on
15//!   `&dyn GitApi` (or generically on `impl GitApi`) so a test can swap the real
16//!   client for a double. Methods take the working directory as the first
17//!   argument and return typed results ([`StatusEntry`], [`Branch`], [`Commit`],
18//!   [`FileDiff`], [`BlameLine`], …) or a structured [`Error`].
19//! - **[`Git`]** — the real client. [`Git::new`] uses the job-backed runner;
20//!   [`Git::with_runner`] injects a fake one for tests. It is generic over the
21//!   [`ProcessRunner`] seam, defaulting to the production runner.
22//! - **[`GitAt`]** — a cwd-bound view ([`Git::at`]) whose methods drop the
23//!   leading `dir`, so `git.at(dir).status()` reads as `git.status(dir)` — handy
24//!   when one client drives one checkout.
25//! - **Builder specs** for the multi-option commands — [`CommitPaths`],
26//!   [`MergeCommit`] / [`MergeNoCommit`], [`GitPush`], [`CloneSpec`],
27//!   [`WorktreeAdd`], [`AnnotatedTag`] — each `#[non_exhaustive]`, built with a
28//!   constructor + chained setters, named after the flags they emit.
29//! - **[`conflict`]** — a typed conflict-marker model: parse marker soup into
30//!   structured regions, re-render byte-exact, and resolve to a chosen side.
31//! - **[`Git::hardened`]** — a profile for untrusted repositories (hooks off,
32//!   `GIT_*` scrubbed, system config skipped); see the [`guide::security`] guide.
33//!
34//! # Recipes
35//!
36//! Read state — depend on the trait so the same code takes a real client or a mock:
37//!
38//! ```no_run
39//! use std::path::Path;
40//! use vcs_git::{Git, GitApi};
41//! # async fn demo() -> Result<(), processkit::Error> {
42//! let git = Git::new();
43//! let dir = Path::new(".");
44//! let branch = git.current_branch(dir).await?;        // the checked-out branch
45//! let dirty = !git.status(dir).await?.is_empty();     // any uncommitted change?
46//! # let _ = (branch, dirty); Ok(()) }
47//! ```
48//!
49//! Mutate through the builder specs — `fetch` retries transient network failures:
50//!
51//! ```no_run
52//! use std::path::Path;
53//! use vcs_git::{CommitPaths, Git, GitApi, GitPush};
54//! # async fn demo(git: &Git) -> Result<(), processkit::Error> {
55//! let dir = Path::new(".");
56//! git.fetch(dir).await?;
57//! git.commit_paths(dir, CommitPaths::new(["src/a.rs"], "wip")).await?;
58//! git.push(dir, GitPush::branch("feature").set_upstream()).await?;
59//! # Ok(()) }
60//! ```
61//!
62//! # Testing
63//!
64//! Two seams: enable the **`mock`** feature for a `mockall`-generated
65//! `MockGitApi` (stub whole methods), or inject a
66//! [`ScriptedRunner`](processkit::ScriptedRunner) with [`Git::with_runner`] to
67//! exercise the *real* argv-building and parsing against canned output. The
68//! cross-cutting testing patterns live in
69//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
70//!
71//! # Safety
72//!
73//! Every caller value placed in a bare positional argv slot is refused before
74//! spawning if it is empty or starts with `-` (git would parse it as a flag);
75//! flag-value slots (`-b <name>`) are consumed verbatim and don't need it. For
76//! eager validation at an input boundary, the [`RefName`] / [`RevSpec`] newtypes
77//! validate up front. Paths always go through `--` / pathspec.
78//!
79//! # In-depth guide
80//!
81//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
82//! from `docs/`. See the [`guide`] module (and its
83//! [`security`](crate::guide::security) / [`conflicts`](crate::guide::conflicts)
84//! sub-guides).
85
86use std::path::{Path, PathBuf};
87use std::time::Duration;
88
89use processkit::ProcessRunner;
90// Re-export the processkit types that appear in this crate's public API, so
91// consumers needn't depend on processkit directly. (`Error`/`Result`/`ProcessResult`
92// are in scope here too via this `pub use`.)
93pub use processkit::{Error, ProcessResult, Result};
94// Re-exported under the `cancellation` feature so a consumer can name the token
95// for `default_cancel_on` without taking a direct `processkit` dependency.
96#[cfg(feature = "cancellation")]
97#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
98pub use processkit::CancellationToken;
99
100pub mod conflict;
101mod parse;
102pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
103// The git-format diff model + parser and the version type are shared with
104// `vcs-jj` (identical output) — re-exported so `vcs_git::FileDiff`,
105// `vcs_git::parse_diff`, `vcs_git::GitVersion`, … still resolve.
106pub use vcs_diff::{
107    ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
108};
109// The error classifiers live in the shared plumbing crate — re-exported so
110// `vcs_git::is_merge_conflict`, … still resolve.
111pub use vcs_cli_support::{is_merge_conflict, is_nothing_to_commit, is_transient_fetch_error};
112
113/// Name of the underlying CLI binary this crate drives.
114pub const BINARY: &str = "git";
115
116/// What a [`GitApi::diff`] / [`GitApi::diff_text`] call compares.
117///
118/// `#[non_exhaustive]` so more comparison shapes can be added later.
119#[derive(Debug, Clone)]
120#[non_exhaustive]
121pub enum DiffSpec {
122    /// All tracked working-tree changes vs the last commit (`git diff HEAD`),
123    /// staged or not, excluding untracked files.
124    WorkingTree,
125    /// A specific revision or range, e.g. `main..HEAD` or `HEAD~1` (`git diff <rev>`).
126    Rev(String),
127}
128
129/// Options for [`GitApi::worktree_add`] (`git worktree add`).
130///
131/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
132/// [`WorktreeAdd::create_branch`] rather than a struct literal.
133#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct WorktreeAdd {
136    /// Filesystem path for the new worktree.
137    pub path: PathBuf,
138    /// Create and check out this new branch (`-b <name>`); `None` checks out an
139    /// existing ref.
140    pub new_branch: Option<String>,
141    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
142    pub commitish: Option<String>,
143    /// Register the worktree without populating its files (`--no-checkout`) — the
144    /// caller fills the working tree itself (e.g. a copy-on-write clone).
145    pub no_checkout: bool,
146}
147
148impl WorktreeAdd {
149    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
150    /// `git worktree add <path> <commitish>`.
151    pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
152        Self {
153            path: path.into(),
154            new_branch: None,
155            commitish: Some(commitish.into()),
156            no_checkout: false,
157        }
158    }
159
160    /// A worktree at `path` creating a new branch `name` based on `commitish`:
161    /// `git worktree add -b <name> <path> <commitish>`.
162    pub fn create_branch(
163        path: impl Into<PathBuf>,
164        name: impl Into<String>,
165        commitish: impl Into<String>,
166    ) -> Self {
167        Self {
168            path: path.into(),
169            new_branch: Some(name.into()),
170            commitish: Some(commitish.into()),
171            no_checkout: false,
172        }
173    }
174
175    /// Register the worktree without checking out its files (`--no-checkout`),
176    /// for a caller that populates the working tree itself.
177    pub fn no_checkout(mut self) -> Self {
178        self.no_checkout = true;
179        self
180    }
181}
182
183/// Options for [`GitApi::push`] (`git push`).
184///
185/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
186/// [`GitPush::refspec`] rather than a struct literal.
187#[derive(Debug, Clone)]
188#[non_exhaustive]
189pub struct GitPush {
190    /// Remote to push to (defaults to `origin`).
191    pub remote: String,
192    /// The refspec — a bare branch name, or `local:remote_branch`.
193    pub refspec: String,
194    /// Set the pushed branch as the upstream (`-u`).
195    pub set_upstream: bool,
196}
197
198impl GitPush {
199    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
200    pub fn branch(name: impl Into<String>) -> Self {
201        Self {
202            remote: "origin".to_string(),
203            refspec: name.into(),
204            set_upstream: false,
205        }
206    }
207
208    /// Push `local` to a differently-named `remote_branch`
209    /// (`git push origin <local>:<remote_branch>`).
210    pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
211        Self {
212            remote: "origin".to_string(),
213            refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
214            set_upstream: false,
215        }
216    }
217
218    /// Push to a non-default remote.
219    pub fn remote(mut self, remote: impl Into<String>) -> Self {
220        self.remote = remote.into();
221        self
222    }
223
224    /// Record the pushed branch as the local branch's upstream (`-u`).
225    pub fn set_upstream(mut self) -> Self {
226        self.set_upstream = true;
227        self
228    }
229}
230
231/// Options for [`GitApi::clone_repo`] (`git clone`).
232///
233/// `#[non_exhaustive]`, so build it through [`CloneSpec::new`] and the chained
234/// setters rather than a struct literal.
235#[derive(Debug, Clone, Default)]
236#[non_exhaustive]
237pub struct CloneSpec {
238    /// Check out this branch instead of the remote's default (`--branch`).
239    pub branch: Option<String>,
240    /// Shallow-clone to this many commits (`--depth`). git silently ignores
241    /// the flag for a plain local-path source (warns, still clones fully);
242    /// use a `file://` URL to shallow-clone locally.
243    pub depth: Option<u32>,
244    /// Create a bare repository (`--bare`).
245    pub bare: bool,
246}
247
248impl CloneSpec {
249    /// A plain full clone of the remote's default branch.
250    pub fn new() -> Self {
251        Self::default()
252    }
253
254    /// Check out `branch` instead of the remote's default (`--branch`).
255    pub fn branch(mut self, branch: impl Into<String>) -> Self {
256        self.branch = Some(branch.into());
257        self
258    }
259
260    /// Shallow-clone to `depth` commits (`--depth`); see the field doc for the
261    /// local-path caveat.
262    pub fn depth(mut self, depth: u32) -> Self {
263        self.depth = Some(depth);
264        self
265    }
266
267    /// Clone as a bare repository (`--bare`).
268    pub fn bare(mut self) -> Self {
269        self.bare = true;
270        self
271    }
272}
273
274/// Options for [`GitApi::commit_paths`] (`git commit --only`).
275///
276/// `#[non_exhaustive]`, so build it through [`CommitPaths::new`] and the chained
277/// setters rather than a struct literal.
278#[derive(Debug, Clone)]
279#[non_exhaustive]
280pub struct CommitPaths {
281    /// The exact paths whose working-tree content to commit (`--only -- <paths>`).
282    pub paths: Vec<PathBuf>,
283    /// The commit message (`-m`).
284    pub message: String,
285    /// Amend the previous commit instead of creating a new one (`--amend`).
286    pub amend: bool,
287}
288
289impl CommitPaths {
290    /// Commit exactly `paths`' working-tree content with `message`
291    /// (`git commit -m <message> --only -- <paths>`).
292    pub fn new(
293        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
294        message: impl Into<String>,
295    ) -> Self {
296        Self {
297            paths: paths.into_iter().map(Into::into).collect(),
298            message: message.into(),
299            amend: false,
300        }
301    }
302
303    /// Amend the previous commit instead of creating a new one (`--amend`).
304    pub fn amend(mut self) -> Self {
305        self.amend = true;
306        self
307    }
308}
309
310/// Options for [`GitApi::merge_commit`] (`git merge` that commits the result).
311///
312/// `#[non_exhaustive]`, so build it through [`MergeCommit::branch`] and the
313/// chained setters rather than a struct literal.
314#[derive(Debug, Clone)]
315#[non_exhaustive]
316pub struct MergeCommit {
317    /// The branch to merge in.
318    pub branch: String,
319    /// Always create a merge commit, even when a fast-forward was possible
320    /// (`--no-ff`).
321    pub no_ff: bool,
322    /// The merge commit message (`-m`); `None` takes the default message
323    /// non-interactively (`--no-edit`).
324    pub message: Option<String>,
325}
326
327impl MergeCommit {
328    /// Merge `name` taking the default merge message non-interactively
329    /// (`git merge --no-edit <name>`).
330    pub fn branch(name: impl Into<String>) -> Self {
331        Self {
332            branch: name.into(),
333            no_ff: false,
334            message: None,
335        }
336    }
337
338    /// Always create a merge commit, even when a fast-forward was possible
339    /// (`--no-ff`).
340    pub fn no_ff(mut self) -> Self {
341        self.no_ff = true;
342        self
343    }
344
345    /// Use `m` as the merge commit message (`-m`).
346    pub fn message(mut self, m: impl Into<String>) -> Self {
347        self.message = Some(m.into());
348        self
349    }
350}
351
352/// Options for [`GitApi::merge_no_commit`] (`git merge --no-commit`).
353///
354/// `#[non_exhaustive]`, so build it through [`MergeNoCommit::branch`] and the
355/// chained setters rather than a struct literal.
356#[derive(Debug, Clone)]
357#[non_exhaustive]
358pub struct MergeNoCommit {
359    /// The branch to merge in.
360    pub branch: String,
361    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`);
362    /// takes precedence over `no_ff` (git rejects the pair).
363    pub squash: bool,
364    /// Always record a real (abortable) merge, even when a fast-forward was
365    /// possible (`--no-ff`).
366    pub no_ff: bool,
367}
368
369impl MergeNoCommit {
370    /// Merge `name` but stop before committing (`git merge --no-commit <name>`).
371    pub fn branch(name: impl Into<String>) -> Self {
372        Self {
373            branch: name.into(),
374            squash: false,
375            no_ff: false,
376        }
377    }
378
379    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`).
380    pub fn squash(mut self) -> Self {
381        self.squash = true;
382        self
383    }
384
385    /// Always record a real (abortable) merge, even when a fast-forward was
386    /// possible (`--no-ff`).
387    pub fn no_ff(mut self) -> Self {
388        self.no_ff = true;
389        self
390    }
391}
392
393/// Options for [`GitApi::tag_create_annotated`] (`git tag -a`).
394///
395/// `#[non_exhaustive]`, so build it through [`AnnotatedTag::new`] and the chained
396/// setter rather than a struct literal.
397#[derive(Debug, Clone)]
398#[non_exhaustive]
399pub struct AnnotatedTag {
400    /// The tag name.
401    pub name: String,
402    /// The tag message (`-m`).
403    pub message: String,
404    /// The revision to tag (`<rev>`); `None` tags `HEAD`.
405    pub rev: Option<String>,
406}
407
408impl AnnotatedTag {
409    /// An annotated tag `name` with `message` at `HEAD`
410    /// (`git tag -a <name> -m <message>`).
411    pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
412        Self {
413            name: name.into(),
414            message: message.into(),
415            rev: None,
416        }
417    }
418
419    /// Tag `r` instead of `HEAD`.
420    pub fn rev(mut self, r: impl Into<String>) -> Self {
421        self.rev = Some(r.into());
422        self
423    }
424}
425
426/// A pre-validated git reference name (branch/tag/remote), for callers that
427/// accept names from untrusted input (UIs, bots, agents) and want to fail
428/// early with a clear error. The dir-taking methods stay `&str` — they apply
429/// the same flag-injection guard internally — so this type is **optional**
430/// up-front validation, not a required wrapper.
431///
432/// Rules follow the load-bearing core of `git check-ref-format`: non-empty,
433/// no leading `-` or `.`, no `..`, no control characters or space, none of
434/// `~ ^ : ? * [ \`, no trailing `/` or `.lock`.
435#[derive(Debug, Clone, PartialEq, Eq, Hash)]
436pub struct RefName(String);
437
438impl RefName {
439    /// Validate `name` as a reference name.
440    pub fn new(name: impl Into<String>) -> Result<Self> {
441        let name = name.into();
442        let bad = name.is_empty()
443            || name.starts_with('-')
444            || name.starts_with('.')
445            || name.ends_with('/')
446            || name.ends_with(".lock")
447            || name.contains("..")
448            || name
449                .chars()
450                .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
451        if bad {
452            return Err(Error::Spawn {
453                program: BINARY.to_string(),
454                source: std::io::Error::new(
455                    std::io::ErrorKind::InvalidInput,
456                    format!("invalid git reference name: {name:?}"),
457                ),
458            });
459        }
460        Ok(RefName(name))
461    }
462
463    /// The validated name.
464    pub fn as_str(&self) -> &str {
465        &self.0
466    }
467}
468
469impl std::fmt::Display for RefName {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.write_str(&self.0)
472    }
473}
474
475/// A pre-validated revision/range expression (`HEAD~2`, `main..feature`).
476/// Deliberately *minimal* — git's revision grammar is too rich to validate
477/// here — it only guarantees the expression is non-empty and cannot be parsed
478/// as a flag (no leading `-`), matching the internal guard the dir-taking
479/// methods apply anyway. Optional up-front validation for untrusted input.
480#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub struct RevSpec(String);
482
483impl RevSpec {
484    /// Validate `rev` as a revision/range expression (non-empty, no leading `-`).
485    pub fn new(rev: impl Into<String>) -> Result<Self> {
486        let rev = rev.into();
487        reject_flag_like("revision", &rev)?;
488        Ok(RevSpec(rev))
489    }
490
491    /// The validated expression.
492    pub fn as_str(&self) -> &str {
493        &self.0
494    }
495}
496
497impl std::fmt::Display for RevSpec {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        f.write_str(&self.0)
500    }
501}
502
503/// What the installed `git` binary supports, probed via
504/// [`GitApi::capabilities`]. A value type — the client holds no state, so
505/// probe once and keep the result (callers cache it).
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507#[non_exhaustive]
508pub struct GitCapabilities {
509    /// The binary's parsed version.
510    pub version: GitVersion,
511}
512
513/// The oldest git major this crate is written against. Validated on 2.54;
514/// expected to work from ≥ 2.30 — but only the *major* is hard-gated, because
515/// a false "unsupported" on an untested-but-fine 2.2x would be worse than the
516/// argv error git itself would give. (Contrast vcs-jj, whose floor is precise:
517/// its parsers were empirically validated against one jj release.)
518const MIN_SUPPORTED_MAJOR: u64 = 2;
519
520impl GitCapabilities {
521    /// Whether the binary meets the supported floor (major ≥ 2).
522    pub fn is_supported(&self) -> bool {
523        self.version.major >= MIN_SUPPORTED_MAJOR
524    }
525
526    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs git
527    /// ≥ 2, found 1.9.5" instead of a cryptic argv failure later.
528    pub fn ensure_supported(&self) -> Result<()> {
529        if self.is_supported() {
530            return Ok(());
531        }
532        Err(Error::Spawn {
533            program: BINARY.to_string(),
534            source: std::io::Error::new(
535                std::io::ErrorKind::Unsupported,
536                format!(
537                    "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
538                     found {}",
539                    self.version
540                ),
541            ),
542        })
543    }
544}
545
546/// The Git operations this crate exposes — the interface consumers code against
547/// and mock in tests.
548///
549/// **Injection safety:** every method that places a caller-supplied name,
550/// revision, range, remote, or URL in a positional argv slot rejects a value
551/// that is empty or begins with `-` (it would be parsed as a flag) with an
552/// [`Error::Spawn`] *before* spawning. Flag-value slots (`-m <msg>`,
553/// `--branch <b>`), filesystem path arguments (`--`-separated pathspecs, plus
554/// worktree paths and clone destinations — typed `Path`, caller-trusted), and
555/// the `run`/`run_raw` escape hatches are not guarded. For eager validation at
556/// an input boundary, see [`RefName`] / [`RevSpec`].
557#[cfg_attr(feature = "mock", mockall::automock)]
558#[async_trait::async_trait]
559pub trait GitApi: Send + Sync {
560    /// Run `git <args>` in the current directory, returning trimmed stdout
561    /// (throws on a non-zero exit). A raw escape hatch for unmodelled commands.
562    async fn run(&self, args: &[String]) -> Result<String>;
563    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
564    /// captured [`ProcessResult`].
565    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
566    /// Installed Git version (`git --version`).
567    async fn version(&self) -> Result<String>;
568    /// The installed binary's parsed version, as [`GitCapabilities`]
569    /// (`git --version`). A value type — probe once and keep it; an
570    /// unrecognisable version string is an [`Error::Parse`].
571    async fn capabilities(&self) -> Result<GitCapabilities>;
572    /// Working-tree status (`git status --porcelain=v1 -z`).
573    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
574    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
575    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
576    async fn status_text(&self, dir: &Path) -> Result<String>;
577    /// Like [`status`](GitApi::status) but ignoring untracked files
578    /// (`git status --porcelain=v1 -z --untracked-files=no`) — "is the *tracked*
579    /// tree dirty", staged or not.
580    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
581    /// A combined branch + working-tree snapshot in **one** spawn
582    /// (`git status --porcelain=v2 --branch -z`): HEAD, branch, upstream,
583    /// ahead/behind, and change counts — the data a prompt/status-bar needs
584    /// without N round-trips. See [`BranchStatus`].
585    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
586    /// Paths with unresolved merge conflicts, repo-relative with `/` separators
587    /// (`git diff --name-only --diff-filter=U -z`). Empty when there are none.
588    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
589    /// Current branch name (`git rev-parse --abbrev-ref HEAD`).
590    async fn current_branch(&self, dir: &Path) -> Result<String>;
591    /// Local branches, current one flagged (`git branch`).
592    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
593    /// Latest `max` commits, newest first (`git log`).
594    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
595    /// Commits in `range`, newest first, up to `max` (`git log <range>`).
596    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
597    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
598    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
599    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
600    /// e.g. to label a detached HEAD.
601    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
602    /// Initialise a repository (`git init`).
603    async fn init(&self, dir: &Path) -> Result<()>;
604    /// Stage `paths` (`git add -- <paths>`).
605    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
606    /// Commit staged changes (`git commit -m`).
607    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
608    /// Create a branch without switching to it (`git branch <name>`).
609    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
610    /// Switch to a branch or revision (`git checkout <reference>`).
611    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
612    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
613    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
614    /// Commit exactly the spec's paths' working-tree content, ignoring the index
615    /// (`git commit [--amend] -m <message> --only -- <paths>`); see [`CommitPaths`].
616    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
617    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
618    /// pre-fill an amend.
619    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
620    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
621    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
622    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
623    /// Whether the working tree has no unstaged modifications to **tracked** files
624    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
625    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
626    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
627
628    // --- Discovery / identity ------------------------------------------------
629
630    /// The repository's common git directory (`rev-parse --git-common-dir`) —
631    /// stable across linked worktrees.
632    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
633    /// This worktree's git directory (`rev-parse --git-dir`).
634    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
635    /// Resolve a revision to a commit hash, peeling tags
636    /// (`rev-parse --verify <rev>^{commit}`).
637    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
638    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
639    /// (short name only); `None` when `origin/HEAD` is unset.
640    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
641    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
642    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
643    /// Whether `origin` has `name`, without fetching (`ls-remote origin
644    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
645    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
646    /// credential or a flaky network can't hang the call.
647    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
648    /// A remote's URL (`remote get-url <remote>`).
649    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
650    /// The current branch's upstream, e.g. `Some("origin/main")`
651    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
652    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
653    /// Branch names on `remote`, without fetching
654    /// (`ls-remote --heads <remote>`).
655    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
656
657    // --- Branches ------------------------------------------------------------
658
659    /// Whether `branch` is fully merged into `target` (`branch --merged <target>`).
660    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
661    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
662    /// (`branch --set-upstream-to=<upstream> <branch>`).
663    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
664    /// Delete a local branch (`branch -d`, or `-D` when `force`).
665    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
666    /// Rename a local branch (`branch -m <old> <new>`).
667    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
668    /// Count commits in a range (`rev-list --count <range>`).
669    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
670    /// Whether a diff range is empty (`diff --quiet <range>`).
671    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
672    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
673    /// match `vcs_jj::JjApi::diff_stat`.
674    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
675    /// Raw git-format unified diff text for `spec`
676    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output.
677    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
678    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
679    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
680
681    // --- In-progress state ---------------------------------------------------
682
683    /// Whether the index has no staged changes (`diff --cached --quiet`).
684    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
685    /// Whether a rebase is in progress (a `rebase-merge`/`rebase-apply` dir exists
686    /// under the git dir).
687    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
688    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
689    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
690
691    // --- Mutations -----------------------------------------------------------
692
693    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
694    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
695    async fn fetch(&self, dir: &Path) -> Result<()>;
696    /// Fetch from a *named* remote (`fetch --quiet <remote>`), with
697    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried like
698    /// [`fetch`](GitApi::fetch).
699    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
700    /// Fetch a single branch from `origin` into its remote-tracking ref
701    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
702    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
703    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
704    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
705    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
706    /// Stage a branch's changes without committing (`merge --squash <branch>`).
707    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
708    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
709    /// message it takes the default merge message non-interactively (`--no-edit`).
710    /// See [`MergeCommit`].
711    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
712    /// Merge a branch but stop before committing, so the result can be inspected
713    /// (`merge --no-commit [--squash | --no-ff] <branch>`). With `no_ff` (and not
714    /// `squash`) git records `MERGE_HEAD`, so the in-progress merge is abortable
715    /// via [`merge_abort`](GitApi::merge_abort) — the dry-run pattern. With
716    /// `squash`, git stages the squashed result but records **no** `MERGE_HEAD`,
717    /// so it is *not* an abortable merge: undo it with
718    /// [`reset_merge`](GitApi::reset_merge) / [`reset_hard`](GitApi::reset_hard),
719    /// not `merge_abort`. See [`MergeNoCommit`].
720    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
721    /// Abort an in-progress merge (`merge --abort`).
722    async fn merge_abort(&self, dir: &Path) -> Result<()>;
723    /// Finish a merge after resolving conflicts (`commit --no-edit`).
724    async fn merge_continue(&self, dir: &Path) -> Result<()>;
725    /// Undo an in-progress (or just-staged) merge: `reset --merge` resets the
726    /// index and the merge-touched working-tree files back to `HEAD` and drops
727    /// `MERGE_HEAD`, **discarding the merge's changes** while keeping unrelated
728    /// unstaged edits. Use it after `merge_squash` / `merge_no_commit(squash)`,
729    /// where there is no `MERGE_HEAD` for `merge_abort` to act on.
730    async fn reset_merge(&self, dir: &Path) -> Result<()>;
731    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
732    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
733    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
734    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
735    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
736    /// Abort an in-progress rebase (`rebase --abort`).
737    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
738    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
739    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
740    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
741    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
742    /// e.g. to save state before a copy-on-write restore.
743    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
744    /// Restore the most recent stash and drop it (`stash pop`).
745    async fn stash_pop(&self, dir: &Path) -> Result<()>;
746
747    // --- Worktrees -----------------------------------------------------------
748
749    /// List worktrees (`worktree list --porcelain`).
750    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
751    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
752    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
753    /// Remove a worktree (`worktree remove [--force] <path>`).
754    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
755    /// Move a worktree (`worktree move <from> <to>`).
756    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
757    /// Prune stale worktree admin entries (`worktree prune`).
758    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
759
760    // --- Clone / tags / inspection --------------------------------------------
761
762    /// Clone `url` into `dest` (`git clone <url> <dest>` + [`CloneSpec`] flags).
763    /// Runs without a working directory — pass an **absolute** `dest`.
764    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
765    /// Create a lightweight tag at `rev` (`tag <name> [<rev>]`; `None` = HEAD).
766    async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
767    /// Create an annotated tag (`tag -a <name> -m <message> [<rev>]`); see
768    /// [`AnnotatedTag`].
769    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
770    /// Tag names, sorted by git's default ordering (`tag --list`).
771    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
772    /// Delete a tag (`tag -d <name>`).
773    async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
774    /// A file's content at a revision (`git show <rev>:<path>`). `path` is
775    /// repo-relative; backslashes are normalised to `/` (git requires it).
776    /// Content is decoded **lossily** — binary files come back mangled rather
777    /// than erroring.
778    async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
779    /// The value of a config key, or `None` when unset (`config --get <key>`,
780    /// whose exit 1 covers both "unset" and "no such section" — git doesn't
781    /// distinguish). A multi-valued key errors; read those via `run`.
782    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
783    /// Set a config key in the repository's local config (`config <key> <value>`).
784    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
785    /// Add a remote (`remote add <name> <url>`).
786    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
787    /// Change a remote's URL (`remote set-url <name> <url>`).
788    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
789    /// Per-line authorship of `path` (`blame --line-porcelain [<rev>] -- <path>`;
790    /// `None` = the working tree's HEAD).
791    async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
792
793    // --- Sequencer -------------------------------------------------------------
794
795    /// Apply a commit onto the current branch (`cherry-pick <rev>`). A conflict
796    /// surfaces as an error classified by [`is_merge_conflict`].
797    async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
798    /// Revert a commit with the default message (`revert --no-edit <rev>`).
799    async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
800    /// Skip the current patch of a paused rebase (`rebase --skip`). Mainly for
801    /// the `apply` backend's "nothing to commit" stop — the default `merge`
802    /// backend auto-drops emptied patches on `--continue`.
803    async fn rebase_skip(&self, dir: &Path) -> Result<()>;
804}
805
806processkit::cli_client!(
807    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject
808    /// a fake process executor; `Git::new()` uses the real job-backed runner.
809    pub struct Git => BINARY
810);
811
812#[async_trait::async_trait]
813impl<R: ProcessRunner> GitApi for Git<R> {
814    async fn run(&self, args: &[String]) -> Result<String> {
815        self.core.run(self.core.command(args)).await
816    }
817
818    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
819        self.core.output(self.core.command(args)).await
820    }
821
822    async fn version(&self) -> Result<String> {
823        self.core.run(self.core.command(["--version"])).await
824    }
825
826    async fn capabilities(&self) -> Result<GitCapabilities> {
827        let raw = self.version().await?;
828        let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
829            program: BINARY.to_string(),
830            message: format!("unrecognisable `git --version` output: {raw:?}"),
831        })?;
832        Ok(GitCapabilities { version })
833    }
834
835    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
836        self.core
837            .parse(
838                self.core
839                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
840                parse::parse_porcelain,
841            )
842            .await
843    }
844
845    async fn status_text(&self, dir: &Path) -> Result<String> {
846        self.core
847            .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
848            .await
849    }
850
851    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
852        // `GIT_OPTIONAL_LOCKS=0`: skip the opportunistic index refresh-write a
853        // `status` may otherwise persist. This is the snapshot/poll primitive —
854        // a filesystem watcher re-querying through it must not have the query
855        // itself dirty `.git/index` and re-trigger the watch (verified: with
856        // optional locks off, a re-query writes nothing).
857        self.core
858            .parse(
859                self.core
860                    .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
861                    .env("GIT_OPTIONAL_LOCKS", "0"),
862                parse::parse_porcelain_v2,
863            )
864            .await
865    }
866
867    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
868        self.core
869            .parse(
870                self.core.command_in(
871                    dir,
872                    ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
873                ),
874                parse::parse_porcelain,
875            )
876            .await
877    }
878
879    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
880        // `-z` keeps special-character paths literal (no C-style quoting).
881        self.core
882            .parse(
883                self.core
884                    .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
885                parse::parse_nul_paths,
886            )
887            .await
888    }
889
890    async fn current_branch(&self, dir: &Path) -> Result<String> {
891        self.core
892            .run(
893                self.core
894                    .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
895            )
896            .await
897    }
898
899    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
900        // `--no-column`: a user's `column.ui = always` would columnate several
901        // names onto one line even when piped, corrupting the line parser.
902        self.core
903            .parse(
904                self.core.command_in(dir, ["branch", "--no-column"]),
905                parse::parse_branches,
906            )
907            .await
908    }
909
910    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
911        let n = format!("-n{max}");
912        self.core
913            .parse(
914                self.core.command_in(
915                    dir,
916                    [
917                        "log",
918                        n.as_str(),
919                        "-z",
920                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
921                    ],
922                ),
923                parse::parse_log,
924            )
925            .await
926    }
927
928    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
929        reject_flag_like("range", range)?;
930        let n = format!("-n{max}");
931        self.core
932            .parse(
933                self.core.command_in(
934                    dir,
935                    [
936                        "log",
937                        range,
938                        n.as_str(),
939                        "-z",
940                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
941                    ],
942                ),
943                parse::parse_log,
944            )
945            .await
946    }
947
948    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
949        reject_flag_like("revision", rev)?;
950        self.core
951            .run(self.core.command_in(dir, ["rev-parse", rev]))
952            .await
953    }
954
955    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
956        reject_flag_like("revision", rev)?;
957        self.core
958            .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
959            .await
960    }
961
962    async fn init(&self, dir: &Path) -> Result<()> {
963        self.core
964            .run_unit(self.core.command_in(dir, ["init"]))
965            .await
966    }
967
968    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
969        // `--` separates the pathspecs so a path can never be read as an option.
970        let mut command = self.core.command_in(dir, ["add", "--"]);
971        for path in paths {
972            command = command.arg(path);
973        }
974        self.core.run_unit(command).await
975    }
976
977    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
978        // C locale: a failure's output feeds `is_nothing_to_commit`.
979        self.core
980            .run_unit(c_locale(
981                self.core.command_in(dir, ["commit", "-m", message]),
982            ))
983            .await
984    }
985
986    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
987        reject_flag_like("branch name", name)?;
988        self.core
989            .run_unit(self.core.command_in(dir, ["branch", name]))
990            .await
991    }
992
993    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
994        reject_flag_like("reference", reference)?;
995        self.core
996            .run_unit(self.core.command_in(dir, ["checkout", reference]))
997            .await
998    }
999
1000    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1001        reject_flag_like("commit", commit)?;
1002        self.core
1003            .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1004            .await
1005    }
1006
1007    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1008        // `--only -- <paths>` commits exactly these paths' working-tree content
1009        // regardless of the index; `--` keeps a path from being read as an option.
1010        // C locale: a failure's output feeds `is_nothing_to_commit`.
1011        let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1012        if spec.amend {
1013            command = command.arg("--amend");
1014        }
1015        command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1016        for path in &spec.paths {
1017            command = command.arg(path);
1018        }
1019        self.core.run_unit(command).await
1020    }
1021
1022    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1023        self.core
1024            .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1025            .await
1026    }
1027
1028    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1029        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
1030        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
1031        // and surfaces anything else (e.g. 128, not a repo) as `Error::Exit`.
1032        Ok(!self
1033            .core
1034            .probe(
1035                self.core
1036                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1037            )
1038            .await?)
1039    }
1040
1041    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1042        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
1043        // `probe` errors on any other code / timeout / signal.
1044        self.core
1045            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1046            .await
1047    }
1048
1049    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1050        Ok(PathBuf::from(
1051            self.core
1052                .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1053                .await?,
1054        ))
1055    }
1056
1057    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1058        Ok(PathBuf::from(
1059            self.core
1060                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1061                .await?,
1062        ))
1063    }
1064
1065    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1066        reject_flag_like("revision", rev)?;
1067        // `^{commit}` peels an annotated tag down to the commit it points at.
1068        let spec = format!("{rev}^{{commit}}");
1069        self.core
1070            .run(
1071                self.core
1072                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1073            )
1074            .await
1075    }
1076
1077    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1078        // `--quiet` makes an unset origin/HEAD a silent non-zero exit (no `fatal:`
1079        // on stderr); that's "no default branch", not an error — so inspect the
1080        // code rather than `?`.
1081        let res = self
1082            .core
1083            .output(
1084                self.core
1085                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1086            )
1087            .await?;
1088        if res.code() == Some(0) {
1089            // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so a
1090            // slashed default branch (e.g. "release/v2") survives intact.
1091            let out = res.stdout().trim();
1092            Ok(Some(
1093                out.strip_prefix("refs/remotes/origin/")
1094                    .unwrap_or(out)
1095                    .to_string(),
1096            ))
1097        } else {
1098            Ok(None)
1099        }
1100    }
1101
1102    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1103        let refname = format!("refs/heads/{name}");
1104        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
1105        self.core
1106            .probe(
1107                self.core
1108                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1109            )
1110            .await
1111    }
1112
1113    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1114        // No credential prompt, bounded wait: a missing helper or a flaky network
1115        // must not hang the call. `output` reports a timeout as a flagged result
1116        // (non-zero exit) rather than erroring, so an unreachable remote reads as
1117        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
1118        // spawn failure (no `git`) still surfaces as an error.
1119        //
1120        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
1121        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
1122        // `refs/heads/<name>` matches only the exact branch.
1123        let refname = format!("refs/heads/{name}");
1124        let cmd = self
1125            .core
1126            .command_in(dir, ["ls-remote", "origin", refname.as_str()])
1127            .env("GIT_TERMINAL_PROMPT", "0")
1128            .timeout(Duration::from_secs(10));
1129        let res = self.core.output(cmd).await?;
1130        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1131    }
1132
1133    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1134        reject_flag_like("remote name", remote)?;
1135        self.core
1136            .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1137            .await
1138    }
1139
1140    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1141        // `@{u}` resolves the configured upstream; with no upstream the command
1142        // exits non-zero — surface that as `None` rather than an error.
1143        match self
1144            .core
1145            .output(self.core.command_in(
1146                dir,
1147                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1148            ))
1149            .await?
1150        {
1151            res if res.code() == Some(0) => {
1152                let name = res.stdout().trim();
1153                Ok((!name.is_empty()).then(|| name.to_string()))
1154            }
1155            _ => Ok(None),
1156        }
1157    }
1158
1159    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1160        reject_flag_like("remote name", remote)?;
1161        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
1162        // never block on an interactive auth prompt.
1163        let cmd = self
1164            .core
1165            .command_in(dir, ["ls-remote", "--heads", remote])
1166            .env("GIT_TERMINAL_PROMPT", "0");
1167        self.core.parse(cmd, parse::parse_ls_remote_heads).await
1168    }
1169
1170    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
1171        reject_flag_like("branch", branch)?;
1172        reject_flag_like("target", target)?;
1173        // `--no-column`: under `column.ui = always` git would pack several names
1174        // per line even when piped, and the marker-stripping compare below would
1175        // never match (a false "not merged").
1176        let out = self
1177            .core
1178            .run(
1179                self.core
1180                    .command_in(dir, ["branch", "--merged", target, "--no-column"]),
1181            )
1182            .await?;
1183        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
1184        // drop exactly those two columns rather than trimming a char class (which
1185        // would over-strip a name that legitimately began with the marker char).
1186        Ok(out
1187            .lines()
1188            .filter_map(|line| line.get(2..))
1189            .any(|b| b == branch))
1190    }
1191
1192    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1193        reject_flag_like("branch name", branch)?;
1194        let flag = format!("--set-upstream-to={upstream}");
1195        self.core
1196            .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1197            .await
1198    }
1199
1200    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1201        reject_flag_like("branch name", name)?;
1202        let flag = if force { "-D" } else { "-d" };
1203        self.core
1204            .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1205            .await
1206    }
1207
1208    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1209        reject_flag_like("branch name", old)?;
1210        reject_flag_like("branch name", new)?;
1211        self.core
1212            .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1213            .await
1214    }
1215
1216    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1217        reject_flag_like("range", range)?;
1218        self.core
1219            .try_parse(
1220                self.core.command_in(dir, ["rev-list", "--count", range]),
1221                |s| {
1222                    s.trim().parse::<usize>().map_err(|e| Error::Parse {
1223                        program: BINARY.to_string(),
1224                        message: e.to_string(),
1225                    })
1226                },
1227            )
1228            .await
1229    }
1230
1231    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1232        reject_flag_like("range", range)?;
1233        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
1234        self.core
1235            .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1236            .await
1237    }
1238
1239    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1240        reject_flag_like("range", range)?;
1241        self.core
1242            .parse(
1243                self.core.command_in(dir, ["diff", "--shortstat", range]),
1244                parse::parse_shortstat,
1245            )
1246            .await
1247    }
1248
1249    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1250        // The target is a single positional arg: `HEAD` for the working tree, or
1251        // the caller's revision/range. `-M` enables rename detection; `--no-color`
1252        // / `--no-ext-diff` keep the output stable and machine-parseable.
1253        let target = match spec {
1254            DiffSpec::WorkingTree => {
1255                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
1256                // diff against the empty tree so a pre-first-commit working tree
1257                // still yields its additions instead of a hard failure.
1258                if self.is_unborn(dir).await? {
1259                    EMPTY_TREE.to_string()
1260                } else {
1261                    "HEAD".to_string()
1262                }
1263            }
1264            DiffSpec::Rev(rev) => {
1265                reject_flag_like("revision", &rev)?;
1266                rev
1267            }
1268        };
1269        // The explicit prefixes pin the `a/`…`b/` form the shared parser extracts
1270        // paths from — a user's `diff.noprefix` / `diff.mnemonicPrefix` config
1271        // would otherwise change the headers and make every file silently vanish
1272        // from the parse. (Command-line prefixes override both config options.)
1273        self.core
1274            .run(self.core.command_in(
1275                dir,
1276                [
1277                    "diff",
1278                    target.as_str(),
1279                    "--no-color",
1280                    "--no-ext-diff",
1281                    "-M",
1282                    "--src-prefix=a/",
1283                    "--dst-prefix=b/",
1284                ],
1285            ))
1286            .await
1287    }
1288
1289    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1290        let text = self.diff_text(dir, spec).await?;
1291        Ok(parse_diff(&text))
1292    }
1293
1294    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1295        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
1296        self.core
1297            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1298            .await
1299    }
1300
1301    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1302        let git_dir = self.resolved_git_dir(dir).await?;
1303        Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
1304    }
1305
1306    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1307        Ok(self
1308            .resolved_git_dir(dir)
1309            .await?
1310            .join("MERGE_HEAD")
1311            .exists())
1312    }
1313
1314    async fn fetch(&self, dir: &Path) -> Result<()> {
1315        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
1316        // rather than blocking on an interactive prompt — matching the other
1317        // remote ops (`fetch_remote_branch`, `push`, `remote_branch_exists`).
1318        // Fetch is idempotent, so `retry` replays it on a transient failure
1319        // (DNS/timeout/dropped connection); a non-transient error fails at once.
1320        // C locale: the retry decision classifies the failure's message.
1321        let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet"]))
1322            .env("GIT_TERMINAL_PROMPT", "0")
1323            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1324        self.core.run_unit(cmd).await
1325    }
1326
1327    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1328        // A leading-`-` remote is a bare positional here — and a flag like
1329        // `--upload-pack=<cmd>` would run an arbitrary local program for a
1330        // local/ext transport, so this guard is load-bearing for security.
1331        reject_flag_like("remote", remote)?;
1332        // Same containment as `fetch` (prompt off, C locale, transient retry),
1333        // with the remote named explicitly.
1334        let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet", remote]))
1335            .env("GIT_TERMINAL_PROMPT", "0")
1336            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1337        self.core.run_unit(cmd).await
1338    }
1339
1340    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1341        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1342        let cmd = c_locale(
1343            self.core
1344                .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()]),
1345        )
1346        .env("GIT_TERMINAL_PROMPT", "0")
1347        .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1348        self.core.run_unit(cmd).await
1349    }
1350
1351    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1352        reject_flag_like("remote", &spec.remote)?;
1353        reject_flag_like("refspec", &spec.refspec)?;
1354        let mut args: Vec<&str> = vec!["push"];
1355        if spec.set_upstream {
1356            args.push("-u");
1357        }
1358        args.push(spec.remote.as_str());
1359        args.push(spec.refspec.as_str());
1360        let cmd = self
1361            .core
1362            .command_in(dir, args)
1363            .env("GIT_TERMINAL_PROMPT", "0");
1364        self.core.run_unit(cmd).await
1365    }
1366
1367    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1368        reject_flag_like("branch", branch)?;
1369        self.core
1370            .run_unit(self.core.command_in(dir, ["merge", "--squash", branch]))
1371            .await
1372    }
1373
1374    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1375        reject_flag_like("branch", &spec.branch)?;
1376        let mut args: Vec<&str> = vec!["merge"];
1377        if spec.no_ff {
1378            args.push("--no-ff");
1379        }
1380        if let Some(msg) = spec.message.as_deref() {
1381            args.push("-m");
1382            args.push(msg);
1383        } else {
1384            // No message → take the default merge message non-interactively
1385            // instead of opening `$EDITOR` (which would hang a headless caller).
1386            args.push("--no-edit");
1387        }
1388        args.push(&spec.branch);
1389        // C locale: a conflict's output feeds `is_merge_conflict`.
1390        self.core
1391            .run_unit(c_locale(self.core.command_in(dir, args)))
1392            .await
1393    }
1394
1395    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1396        reject_flag_like("branch", &spec.branch)?;
1397        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1398        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
1399        // a squash never fast-forwards anyway, so it takes precedence.
1400        if spec.squash {
1401            args.push("--squash");
1402        } else if spec.no_ff {
1403            args.push("--no-ff");
1404        }
1405        args.push(&spec.branch);
1406        // C locale: a conflict's output feeds `is_merge_conflict`.
1407        self.core
1408            .run_unit(c_locale(self.core.command_in(dir, args)))
1409            .await
1410    }
1411
1412    async fn merge_abort(&self, dir: &Path) -> Result<()> {
1413        self.core
1414            .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1415            .await
1416    }
1417
1418    async fn merge_continue(&self, dir: &Path) -> Result<()> {
1419        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
1420        // headless backstop so a commit hook re-opening the editor can't hang.
1421        // C locale: the failure output feeds the classifiers (a still-conflicted
1422        // tree reports "nothing to commit"-adjacent / conflict messages).
1423        self.core
1424            .run_unit(no_editor(c_locale(
1425                self.core.command_in(dir, ["commit", "--no-edit"]),
1426            )))
1427            .await
1428    }
1429
1430    async fn reset_merge(&self, dir: &Path) -> Result<()> {
1431        self.core
1432            .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1433            .await
1434    }
1435
1436    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1437        reject_flag_like("revision", rev)?;
1438        self.core
1439            .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1440            .await
1441    }
1442
1443    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1444        reject_flag_like("rebase target", onto)?;
1445        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
1446        // the message-confirm on `--continue`) never hangs a headless caller.
1447        // C locale: a conflict's output feeds `is_merge_conflict`.
1448        self.core
1449            .run_unit(no_editor(c_locale(
1450                self.core.command_in(dir, ["rebase", onto]),
1451            )))
1452            .await
1453    }
1454
1455    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1456        self.core
1457            .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1458            .await
1459    }
1460
1461    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1462        self.core
1463            .run_unit(no_editor(c_locale(
1464                self.core.command_in(dir, ["rebase", "--continue"]),
1465            )))
1466            .await
1467    }
1468
1469    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1470        let mut command = self.core.command_in(dir, ["stash", "push"]);
1471        if include_untracked {
1472            command = command.arg("--include-untracked");
1473        }
1474        self.core.run_unit(command).await
1475    }
1476
1477    async fn stash_pop(&self, dir: &Path) -> Result<()> {
1478        self.core
1479            .run_unit(self.core.command_in(dir, ["stash", "pop"]))
1480            .await
1481    }
1482
1483    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1484        self.core
1485            .parse(
1486                self.core
1487                    .command_in(dir, ["worktree", "list", "--porcelain"]),
1488                parse::parse_worktree_porcelain,
1489            )
1490            .await
1491    }
1492
1493    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1494        if let Some(name) = spec.new_branch.as_deref() {
1495            reject_flag_like("branch name", name)?;
1496        }
1497        if let Some(commitish) = spec.commitish.as_deref() {
1498            reject_flag_like("commit-ish", commitish)?;
1499        }
1500        let mut command = self.core.command_in(dir, ["worktree", "add"]);
1501        if let Some(name) = spec.new_branch.as_deref() {
1502            command = command.arg("-b").arg(name);
1503        }
1504        if spec.no_checkout {
1505            command = command.arg("--no-checkout");
1506        }
1507        command = command.arg(&spec.path);
1508        if let Some(commitish) = spec.commitish.as_deref() {
1509            command = command.arg(commitish);
1510        }
1511        self.core.run_unit(command).await
1512    }
1513
1514    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1515        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1516        if force {
1517            command = command.arg("--force");
1518        }
1519        command = command.arg(path);
1520        self.core.run_unit(command).await
1521    }
1522
1523    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1524        let command = self
1525            .core
1526            .command_in(dir, ["worktree", "move"])
1527            .arg(from)
1528            .arg(to);
1529        self.core.run_unit(command).await
1530    }
1531
1532    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1533        self.core
1534            .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1535            .await
1536    }
1537
1538    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1539        // A leading-`-` url is a bare positional — `git clone --upload-pack=<cmd>`
1540        // would run an arbitrary local program. A real URL never leads with `-`,
1541        // so this guard has no false positives.
1542        reject_flag_like("url", url)?;
1543        // No working directory: clone creates `dest` itself, so `dest` should
1544        // be absolute (a relative path would resolve against this process' cwd).
1545        let mut command = self.core.command(["clone"]);
1546        if let Some(branch) = spec.branch.as_deref() {
1547            command = command.arg("--branch").arg(branch);
1548        }
1549        if let Some(depth) = spec.depth {
1550            command = command.arg("--depth").arg(depth.to_string());
1551        }
1552        if spec.bare {
1553            command = command.arg("--bare");
1554        }
1555        let command = command.arg(url).arg(dest).env("GIT_TERMINAL_PROMPT", "0");
1556        self.core.run_unit(command).await
1557    }
1558
1559    async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1560        reject_flag_like("tag name", name)?;
1561        if let Some(rev) = rev.as_deref() {
1562            reject_flag_like("revision", rev)?;
1563        }
1564        let mut args = vec!["tag", name];
1565        if let Some(rev) = rev.as_deref() {
1566            args.push(rev);
1567        }
1568        self.core.run_unit(self.core.command_in(dir, args)).await
1569    }
1570
1571    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1572        reject_flag_like("tag name", &spec.name)?;
1573        if let Some(rev) = spec.rev.as_deref() {
1574            reject_flag_like("revision", rev)?;
1575        }
1576        let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1577        if let Some(rev) = spec.rev.as_deref() {
1578            args.push(rev);
1579        }
1580        self.core.run_unit(self.core.command_in(dir, args)).await
1581    }
1582
1583    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1584        // `--no-column`: a user's `column.ui = always` would pack several tags
1585        // onto one line even when piped, corrupting the one-per-line split.
1586        let out = self
1587            .core
1588            .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1589            .await?;
1590        Ok(out.lines().map(str::to_string).collect())
1591    }
1592
1593    async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1594        reject_flag_like("tag name", name)?;
1595        self.core
1596            .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1597            .await
1598    }
1599
1600    async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1601        // A leading-`-` rev makes the whole `<rev>:<path>` token start with `-`,
1602        // so git would parse it as a flag — guard it before building the spec.
1603        reject_flag_like("revision", rev)?;
1604        // git rejects backslash separators in the `<rev>:<path>` spec ("exists
1605        // on disk, but not in <rev>") — normalise for Windows callers. Only on
1606        // Windows: on Unix a backslash is a legal filename byte, and rewriting
1607        // it would make a literal `a\b.txt` unresolvable.
1608        #[cfg(windows)]
1609        let path = path.replace('\\', "/");
1610        let spec = format!("{rev}:{path}");
1611        self.core
1612            .run(self.core.command_in(dir, ["show", spec.as_str()]))
1613            .await
1614    }
1615
1616    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1617        reject_flag_like("config key", key)?;
1618        let res = self
1619            .core
1620            .output(self.core.command_in(dir, ["config", "--get", key]))
1621            .await?;
1622        match res.code() {
1623            // Exit 1 = unset (git lumps "no such key/section" in here too).
1624            Some(1) => Ok(None),
1625            Some(0) => Ok(Some(res.stdout().trim_end().to_string())),
1626            _ => {
1627                res.ensure_success()?;
1628                Ok(None) // unreachable: a non-zero exit always errors above.
1629            }
1630        }
1631    }
1632
1633    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1634        reject_flag_like("config key", key)?;
1635        self.core
1636            .run_unit(self.core.command_in(dir, ["config", key, value]))
1637            .await
1638    }
1639
1640    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1641        reject_flag_like("remote name", name)?;
1642        reject_flag_like("url", url)?;
1643        self.core
1644            .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
1645            .await
1646    }
1647
1648    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1649        reject_flag_like("remote name", name)?;
1650        reject_flag_like("url", url)?;
1651        self.core
1652            .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
1653            .await
1654    }
1655
1656    async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
1657        let mut args = vec!["blame", "--line-porcelain"];
1658        if let Some(rev) = rev.as_deref() {
1659            // A standalone positional rev with a leading `-` would be any blame
1660            // flag (`-s`, `--reverse`, `-L…`) — guard before the `--`.
1661            reject_flag_like("revision", rev)?;
1662            args.push(rev);
1663        }
1664        args.push("--");
1665        args.push(path);
1666        self.core
1667            .parse(
1668                self.core.command_in(dir, args),
1669                parse::parse_blame_porcelain,
1670            )
1671            .await
1672    }
1673
1674    async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
1675        reject_flag_like("revision", rev)?;
1676        // No editor opens non-interactively, but keep the headless backstop.
1677        // C locale: a conflict's output feeds `is_merge_conflict`.
1678        self.core
1679            .run_unit(no_editor(c_locale(
1680                self.core.command_in(dir, ["cherry-pick", rev]),
1681            )))
1682            .await
1683    }
1684
1685    async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
1686        reject_flag_like("revision", rev)?;
1687        self.core
1688            .run_unit(no_editor(c_locale(
1689                self.core.command_in(dir, ["revert", "--no-edit", rev]),
1690            )))
1691            .await
1692    }
1693
1694    async fn rebase_skip(&self, dir: &Path) -> Result<()> {
1695        self.core
1696            .run_unit(no_editor(c_locale(
1697                self.core.command_in(dir, ["rebase", "--skip"]),
1698            )))
1699            .await
1700    }
1701}
1702
1703// --- Internal helpers --------------------------------------------------------
1704//
1705// The error classifiers (`is_merge_conflict`/`is_nothing_to_commit`/
1706// `is_transient_fetch_error`), the fetch-retry policy, and the argv injection
1707// guard now live in the shared `vcs-cli-support` crate (re-exported at the top of
1708// this module); what remains here is git-specific.
1709
1710/// Git's well-known empty-tree object id — a stable stand-in for `HEAD` when
1711/// diffing the working tree of an unborn (no-commits-yet) repository. Public so a
1712/// caller can diff/stat a pre-first-commit working tree against it directly.
1713pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1714
1715/// Total attempts / fixed backoff for a transient-retried `fetch` — the shared
1716/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
1717const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
1718const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
1719
1720/// Point git's editor at a no-op so any command that would open `$EDITOR`
1721/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
1722/// non-interactively instead of hanging a headless caller.
1723fn no_editor(cmd: processkit::Command) -> processkit::Command {
1724    cmd.env("GIT_EDITOR", "true")
1725        .env("GIT_SEQUENCE_EDITOR", "true")
1726}
1727
1728/// Force the C locale on a command whose output feeds the error classifiers
1729/// (`is_merge_conflict`, `is_nothing_to_commit`, `is_transient_fetch_error`):
1730/// they match untranslated English substrings, and a localized git would emit
1731/// translated messages, silently turning a classified failure (conflict /
1732/// clean-tree / transient) into an unclassified one.
1733fn c_locale(cmd: processkit::Command) -> processkit::Command {
1734    cmd.env("LC_ALL", "C")
1735}
1736
1737/// Injection guard for bare positional argv slots — delegates to the shared
1738/// [`vcs_cli_support::reject_flag_like`], naming this crate's binary so the
1739/// ~45 call sites stay `reject_flag_like(what, value)`.
1740fn reject_flag_like(what: &str, value: &str) -> Result<()> {
1741    vcs_cli_support::reject_flag_like(BINARY, what, value)
1742}
1743
1744impl<R: ProcessRunner> Git<R> {
1745    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
1746    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
1747    /// trait), so it can take `&[&str]`; forwards to the same path as
1748    /// [`GitApi::run`].
1749    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1750        self.core.run(self.core.command(args)).await
1751    }
1752
1753    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
1754    /// (mirrors [`GitApi::run_raw`]).
1755    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1756        self.core.output(self.core.command(args)).await
1757    }
1758
1759    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
1760    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
1761    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
1762    /// driving many directories (e.g. linked worktrees) from one client.
1763    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
1764        GitAt { git: self, dir }
1765    }
1766
1767    /// Harden this client for driving repositories it didn't create: running
1768    /// `git` inside an untrusted checkout executes that repository's hooks and
1769    /// honours its config — arbitrary code execution by default. The profile
1770    /// (applied to **every** command this client runs):
1771    ///
1772    /// - **Disables hooks** — `core.hooksPath=/dev/null` pinned via git's
1773    ///   env-based config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, git ≥ 2.31;
1774    ///   verified to suppress hooks on Windows too) — and `core.fsmonitor`
1775    ///   (a config-driven daemon launch).
1776    /// - **Removes inherited repo redirectors** so a poisoned parent
1777    ///   environment can't point commands at another repository: `GIT_DIR`,
1778    ///   `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`,
1779    ///   `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_NAMESPACE`,
1780    ///   `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
1781    ///   `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`.
1782    /// - **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal
1783    ///   prompts off everywhere (`GIT_TERMINAL_PROMPT=0`).
1784    ///
1785    /// What it does NOT do: sandbox the git binary itself, or stop the repo's
1786    /// *content* from being malicious. In a **colocated jj repo**, git hooks
1787    /// only run when *git* commands run — harden the `Git` client; `Jj` needs
1788    /// no equivalent (jj has no repo-local hooks; see the vcs-jj docs).
1789    ///
1790    /// Chainable — `Git::with_runner(rec).harden()` works in tests; use
1791    /// [`Git::hardened()`](Git::hardened) for the common case.
1792    pub fn harden(self) -> Self {
1793        let removed = [
1794            "GIT_DIR",
1795            "GIT_WORK_TREE",
1796            "GIT_INDEX_FILE",
1797            "GIT_OBJECT_DIRECTORY",
1798            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1799            "GIT_NAMESPACE",
1800            "GIT_CEILING_DIRECTORIES",
1801            "GIT_CONFIG_PARAMETERS",
1802            "GIT_CONFIG_GLOBAL",
1803            "GIT_CONFIG_SYSTEM",
1804        ];
1805        let mut hardened = self;
1806        for key in removed {
1807            hardened = hardened.default_env_remove(key);
1808        }
1809        hardened
1810            .default_env("GIT_CONFIG_NOSYSTEM", "1")
1811            .default_env("GIT_TERMINAL_PROMPT", "0")
1812            .default_env("GIT_CONFIG_COUNT", "2")
1813            .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
1814            .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
1815            .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
1816            .default_env("GIT_CONFIG_VALUE_1", "false")
1817    }
1818
1819    /// Switch to `branch`, carrying uncommitted changes (tracked *and*
1820    /// untracked) across via the stash: `stash push -u` → `checkout` →
1821    /// `stash pop`. A clean tree skips the stash round-trip entirely — a
1822    /// `stash push` there would save nothing and the later pop would pop an
1823    /// older, unrelated stash.
1824    ///
1825    /// Failure behaviour:
1826    /// - `checkout` fails (atomic — the working copy stays on the original
1827    ///   branch): the stash is popped back to restore the original state, and
1828    ///   the checkout error is returned. If that restoring pop *also* fails,
1829    ///   the changes stay safe in the stash (`git stash list`).
1830    /// - `stash pop` on the target branch conflicts: the error is returned with
1831    ///   the target branch checked out; git keeps the stash entry, so the
1832    ///   changes can be resolved or re-applied manually.
1833    ///
1834    /// Inherent (not on the object-safe trait): a composed operation, not a 1:1
1835    /// CLI verb — mock the underlying `status`/`stash_*`/`checkout` instead.
1836    pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
1837        // Untracked-inclusive guard to match `stash push -u`: "dirty" must mean
1838        // the same thing to the guard and to the stash.
1839        if self.status(dir).await?.is_empty() {
1840            return self.checkout(dir, branch).await;
1841        }
1842        self.stash_push(dir, true).await?;
1843        match self.checkout(dir, branch).await {
1844            Ok(()) => self.stash_pop(dir).await,
1845            Err(err) => {
1846                // A failed checkout is atomic — we are still on the original
1847                // branch, so popping restores the exact pre-call state. If the
1848                // pop fails too, the stash entry is preserved for the caller.
1849                let _ = self.stash_pop(dir).await;
1850                Err(err)
1851            }
1852        }
1853    }
1854
1855    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
1856    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
1857    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
1858        let git_dir = PathBuf::from(
1859            self.core
1860                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1861                .await?,
1862        );
1863        Ok(if git_dir.is_absolute() {
1864            git_dir
1865        } else {
1866            dir.join(git_dir)
1867        })
1868    }
1869}
1870
1871impl Git {
1872    /// A hardened real (job-backed) client — `Git::new().harden()`; see
1873    /// [`harden`](Git::harden) for what the profile does.
1874    pub fn hardened() -> Self {
1875        Self::new().harden()
1876    }
1877}
1878
1879/// A [`Git`] client with a working directory bound, so calls drop the leading
1880/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
1881/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
1882/// copy: it only borrows the client and the path.
1883pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
1884    git: &'a Git<R>,
1885    dir: &'a Path,
1886}
1887
1888// Hand-written rather than derived: the view only holds two references, so it is
1889// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
1890// bound that the real default `JobRunner` doesn't satisfy, silently dropping
1891// `Copy` on the production `Repo::git_at()` handle.
1892impl<R: ProcessRunner> Clone for GitAt<'_, R> {
1893    fn clone(&self) -> Self {
1894        *self
1895    }
1896}
1897impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
1898
1899/// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
1900/// verbatim, `dir` methods inject `self.dir` as the first argument.
1901macro_rules! git_at_forwarders {
1902    (
1903        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1904        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1905    ) => {
1906        impl<'a, R: ProcessRunner> GitAt<'a, R> {
1907            $(
1908                #[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
1909                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1910                    self.git.$bn($($ba),*).await
1911                }
1912            )*
1913            $(
1914                #[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1915                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1916                    self.git.$dn(self.dir, $($da),*).await
1917                }
1918            )*
1919        }
1920    };
1921}
1922
1923git_at_forwarders! {
1924    bare {
1925        fn run(args: &[String]) -> Result<String>;
1926        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1927        fn run_args(args: &[&str]) -> Result<String>;
1928        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1929        fn version() -> Result<String>;
1930        fn capabilities() -> Result<GitCapabilities>;
1931        fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
1932    }
1933    dir {
1934        fn status() -> Result<Vec<StatusEntry>>;
1935        fn status_text() -> Result<String>;
1936        fn status_tracked() -> Result<Vec<StatusEntry>>;
1937        fn branch_status() -> Result<BranchStatus>;
1938        fn conflicted_files() -> Result<Vec<String>>;
1939        fn current_branch() -> Result<String>;
1940        fn branches() -> Result<Vec<Branch>>;
1941        fn log(max: usize) -> Result<Vec<Commit>>;
1942        fn log_range(range: &str, max: usize) -> Result<Vec<Commit>>;
1943        fn rev_parse(rev: &str) -> Result<String>;
1944        fn rev_parse_short(rev: &str) -> Result<String>;
1945        fn init() -> Result<()>;
1946        fn add(paths: &[PathBuf]) -> Result<()>;
1947        fn commit(message: &str) -> Result<()>;
1948        fn create_branch(name: &str) -> Result<()>;
1949        fn checkout(reference: &str) -> Result<()>;
1950        fn checkout_detach(commit: &str) -> Result<()>;
1951        fn commit_paths(spec: CommitPaths) -> Result<()>;
1952        fn last_commit_message() -> Result<String>;
1953        fn is_unborn() -> Result<bool>;
1954        fn diff_is_empty() -> Result<bool>;
1955        fn common_dir() -> Result<PathBuf>;
1956        fn git_dir() -> Result<PathBuf>;
1957        fn resolve_commit(rev: &str) -> Result<String>;
1958        fn remote_head_branch() -> Result<Option<String>>;
1959        fn branch_exists(name: &str) -> Result<bool>;
1960        fn remote_branch_exists(name: &str) -> Result<bool>;
1961        fn remote_url(remote: &str) -> Result<String>;
1962        fn upstream() -> Result<Option<String>>;
1963        fn remote_branches(remote: &str) -> Result<Vec<String>>;
1964        fn is_merged(branch: &str, target: &str) -> Result<bool>;
1965        fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
1966        fn delete_branch(name: &str, force: bool) -> Result<()>;
1967        fn rename_branch(old: &str, new: &str) -> Result<()>;
1968        fn rev_list_count(range: &str) -> Result<usize>;
1969        fn diff_range_is_empty(range: &str) -> Result<bool>;
1970        fn diff_stat(range: &str) -> Result<DiffStat>;
1971        fn diff_text(spec: DiffSpec) -> Result<String>;
1972        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1973        fn staged_is_empty() -> Result<bool>;
1974        fn is_rebase_in_progress() -> Result<bool>;
1975        fn is_merge_in_progress() -> Result<bool>;
1976        fn fetch() -> Result<()>;
1977        fn fetch_from(remote: &str) -> Result<()>;
1978        fn fetch_remote_branch(branch: &str) -> Result<()>;
1979        fn push(spec: GitPush) -> Result<()>;
1980        fn merge_squash(branch: &str) -> Result<()>;
1981        fn merge_commit(spec: MergeCommit) -> Result<()>;
1982        fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
1983        fn merge_abort() -> Result<()>;
1984        fn merge_continue() -> Result<()>;
1985        fn reset_merge() -> Result<()>;
1986        fn reset_hard(rev: &str) -> Result<()>;
1987        fn rebase(onto: &str) -> Result<()>;
1988        fn rebase_abort() -> Result<()>;
1989        fn rebase_continue() -> Result<()>;
1990        fn stash_push(include_untracked: bool) -> Result<()>;
1991        fn stash_pop() -> Result<()>;
1992        fn switch_with_stash(branch: &str) -> Result<()>;
1993        fn worktree_list() -> Result<Vec<Worktree>>;
1994        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
1995        fn worktree_remove(path: &Path, force: bool) -> Result<()>;
1996        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
1997        fn worktree_prune() -> Result<()>;
1998        fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
1999        fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2000        fn tag_list() -> Result<Vec<String>>;
2001        fn tag_delete(name: &str) -> Result<()>;
2002        fn show_file(rev: &str, path: &str) -> Result<String>;
2003        fn config_get(key: &str) -> Result<Option<String>>;
2004        fn config_set(key: &str, value: &str) -> Result<()>;
2005        fn remote_add(name: &str, url: &str) -> Result<()>;
2006        fn remote_set_url(name: &str, url: &str) -> Result<()>;
2007        fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2008        fn cherry_pick(rev: &str) -> Result<()>;
2009        fn revert(rev: &str) -> Result<()>;
2010        fn rebase_skip() -> Result<()>;
2011    }
2012}
2013
2014/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
2015/// a `Drop` guard. They shell out through `std::process` directly (no async, no
2016/// job-containment), so reserve them for short-lived cleanup.
2017pub mod blocking {
2018    use std::path::Path;
2019    use std::process::Command;
2020
2021    /// Remove a worktree synchronously (`git worktree remove [--force] <path>`).
2022    pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2023        let mut cmd = Command::new(super::BINARY);
2024        cmd.current_dir(dir).args(["worktree", "remove"]);
2025        if force {
2026            cmd.arg("--force");
2027        }
2028        cmd.arg(path);
2029        let status = cmd.status()?;
2030        if status.success() {
2031            Ok(())
2032        } else {
2033            Err(std::io::Error::other(format!(
2034                "`git worktree remove` exited with {status}"
2035            )))
2036        }
2037    }
2038}
2039
2040#[cfg(test)]
2041mod tests {
2042    use super::*;
2043    use processkit::{RecordingRunner, Reply, ScriptedRunner};
2044
2045    #[test]
2046    fn binary_name_is_git() {
2047        assert_eq!(BINARY, "git");
2048    }
2049
2050    // Compile-time guard: the bound view must stay `Copy` for the *default*
2051    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
2052    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
2053    #[allow(dead_code)]
2054    fn bound_view_is_copy_for_default_runner() {
2055        fn assert_copy<T: Copy>() {}
2056        assert_copy::<GitAt<'static, processkit::JobRunner>>();
2057    }
2058
2059    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
2060    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
2061    // in the right place and nothing else changes.
2062    #[tokio::test]
2063    async fn bound_view_matches_dir_taking_calls() {
2064        let dir = Path::new("/repo");
2065        let rec = RecordingRunner::replying(Reply::ok(""));
2066        let git = Git::with_runner(&rec);
2067
2068        // A method with trailing args (dir injected first).
2069        git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2070            .await
2071            .unwrap();
2072        git.at(dir)
2073            .merge_commit(MergeCommit::branch("feat").no_ff())
2074            .await
2075            .unwrap();
2076        // A method taking a path arg after dir.
2077        git.worktree_remove(dir, Path::new("/wt"), true)
2078            .await
2079            .unwrap();
2080        git.at(dir)
2081            .worktree_remove(Path::new("/wt"), true)
2082            .await
2083            .unwrap();
2084        // One of the new query methods.
2085        git.conflicted_files(dir).await.unwrap();
2086        git.at(dir).conflicted_files().await.unwrap();
2087        // One of the §4 additions.
2088        git.tag_delete(dir, "v1").await.unwrap();
2089        git.at(dir).tag_delete("v1").await.unwrap();
2090
2091        let calls = rec.calls();
2092        assert_eq!(calls[0].args_str(), calls[1].args_str());
2093        assert_eq!(calls[2].args_str(), calls[3].args_str());
2094        assert_eq!(calls[4].args_str(), calls[5].args_str());
2095        assert_eq!(calls[6].args_str(), calls[7].args_str());
2096        // The bound calls also carried the bound dir as their working directory.
2097        assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
2098        assert_eq!(calls[3].cwd.as_deref(), Some(dir.as_os_str()));
2099    }
2100
2101    // Hermetic: the real status() command-building + porcelain parsing run
2102    // against a scripted runner — no `git` binary needed, so this runs on CI.
2103    #[tokio::test]
2104    async fn status_parses_scripted_output() {
2105        // `-z` output: NUL-delimited records, raw paths.
2106        let git =
2107            Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
2108        let entries = git.status(Path::new(".")).await.expect("status");
2109        assert_eq!(entries.len(), 2);
2110        assert_eq!(entries[0].code, " M");
2111        assert_eq!(entries[1].path, "b.rs");
2112    }
2113
2114    // `status_tracked` is `status` minus untracked files — same parser, extra flag.
2115    #[tokio::test]
2116    async fn status_tracked_excludes_untracked_flag() {
2117        let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2118        let git = Git::with_runner(&rec);
2119        let entries = git.status_tracked(Path::new(".")).await.expect("status");
2120        assert_eq!(entries.len(), 1);
2121        assert_eq!(entries[0].code, " M");
2122        assert_eq!(
2123            rec.only_call().args_str(),
2124            ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2125        );
2126    }
2127
2128    // `branch_status` builds the porcelain v2 + branch + -z argv and parses the
2129    // combined header/entry output in one call.
2130    #[tokio::test]
2131    async fn branch_status_builds_v2_branch_args_and_parses() {
2132        let out = concat!(
2133            "# branch.oid abc\0",
2134            "# branch.head main\0",
2135            "# branch.upstream origin/main\0",
2136            "# branch.ab +1 -0\0",
2137            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2138            "? new.txt\0",
2139        );
2140        let rec = RecordingRunner::replying(Reply::ok(out));
2141        let git = Git::with_runner(&rec);
2142        let s = git
2143            .branch_status(Path::new("."))
2144            .await
2145            .expect("branch_status");
2146        assert_eq!(
2147            rec.only_call().args_str(),
2148            ["status", "--porcelain=v2", "--branch", "-z"]
2149        );
2150        // The poll primitive must not itself write the index (and re-trigger a
2151        // filesystem watcher re-querying through it).
2152        assert!(rec.only_call().envs.iter().any(|(k, v)| {
2153            k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2154                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2155        }));
2156        assert_eq!(s.branch.as_deref(), Some("main"));
2157        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2158        assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2159        assert_eq!(s.tracked_changes, 1);
2160        assert_eq!(s.untracked, 1);
2161        assert!(s.is_dirty());
2162    }
2163
2164    // `conflicted_files` lists unmerged paths NUL-delimited (no quoting).
2165    #[tokio::test]
2166    async fn conflicted_files_builds_args_and_parses_nul_list() {
2167        let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2168        let git = Git::with_runner(&rec);
2169        let paths = git
2170            .conflicted_files(Path::new("."))
2171            .await
2172            .expect("conflicted_files");
2173        assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2174        assert_eq!(
2175            rec.only_call().args_str(),
2176            ["diff", "--name-only", "--diff-filter=U", "-z"]
2177        );
2178    }
2179
2180    #[tokio::test]
2181    async fn rev_parse_short_builds_short_flag() {
2182        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2183        let git = Git::with_runner(&rec);
2184        let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2185        assert_eq!(out, "a1b2c3d");
2186        assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2187    }
2188
2189    // A non-zero exit surfaces as a structured `Error::Exit`.
2190    #[tokio::test]
2191    async fn nonzero_exit_is_structured_error() {
2192        let git = Git::with_runner(
2193            ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
2194        );
2195        match git.status(Path::new(".")).await.unwrap_err() {
2196            Error::Exit { code, stderr, .. } => {
2197                assert_eq!(code, 128);
2198                assert!(stderr.contains("not a git repository"), "{stderr}");
2199            }
2200            other => panic!("expected Exit, got {other:?}"),
2201        }
2202    }
2203
2204    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
2205    // anything else is a real failure surfaced as Error::Exit.
2206    #[tokio::test]
2207    async fn diff_is_empty_maps_exit_codes() {
2208        let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
2209        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2210
2211        let dirty =
2212            Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
2213        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2214
2215        let broken = Git::with_runner(
2216            ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
2217        );
2218        assert!(matches!(
2219            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2220            Error::Exit { code: 128, .. }
2221        ));
2222    }
2223
2224    // `add` must insert `--` before the pathspecs so a path can never be parsed
2225    // as an option. No fallback rule: the run only matches if `add --` was built.
2226    #[tokio::test]
2227    async fn add_inserts_pathspec_separator() {
2228        let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
2229        git.add(Path::new("."), &[PathBuf::from("f.rs")])
2230            .await
2231            .expect("add should build `add -- <paths>`");
2232    }
2233
2234    #[tokio::test]
2235    async fn worktree_list_parses_porcelain() {
2236        let git = Git::with_runner(ScriptedRunner::new().on(
2237            ["worktree", "list"],
2238            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2239        ));
2240        let wts = git.worktree_list(Path::new(".")).await.expect("list");
2241        assert_eq!(wts.len(), 1);
2242        assert_eq!(wts[0].branch.as_deref(), Some("main"));
2243        assert_eq!(wts[0].head.as_deref(), Some("abc"));
2244    }
2245
2246    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
2247    // in that exact order; only the full argv is scripted (no fallback).
2248    #[tokio::test]
2249    async fn worktree_add_builds_branch_path_and_base() {
2250        let rec = RecordingRunner::replying(Reply::ok(""));
2251        let git = Git::with_runner(&rec);
2252        git.worktree_add(
2253            Path::new("/repo"),
2254            WorktreeAdd::create_branch("/wt", "feature", "main"),
2255        )
2256        .await
2257        .expect("worktree add");
2258        assert_eq!(
2259            rec.only_call().args_str(),
2260            ["worktree", "add", "-b", "feature", "/wt", "main"]
2261        );
2262    }
2263
2264    #[tokio::test]
2265    async fn worktree_remove_passes_force_then_path() {
2266        let rec = RecordingRunner::replying(Reply::ok(""));
2267        let git = Git::with_runner(&rec);
2268        git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2269            .await
2270            .expect("remove");
2271        assert_eq!(
2272            rec.only_call().args_str(),
2273            ["worktree", "remove", "--force", "/wt"]
2274        );
2275    }
2276
2277    // `--no-checkout` must land between `-b <name>` and the path.
2278    #[tokio::test]
2279    async fn worktree_add_no_checkout_inserts_flag() {
2280        let rec = RecordingRunner::replying(Reply::ok(""));
2281        let git = Git::with_runner(&rec);
2282        git.worktree_add(
2283            Path::new("/repo"),
2284            WorktreeAdd::checkout("/wt", "main").no_checkout(),
2285        )
2286        .await
2287        .expect("worktree add");
2288        assert_eq!(
2289            rec.only_call().args_str(),
2290            ["worktree", "add", "--no-checkout", "/wt", "main"]
2291        );
2292    }
2293
2294    #[tokio::test]
2295    async fn checkout_detach_builds_args() {
2296        let rec = RecordingRunner::replying(Reply::ok(""));
2297        let git = Git::with_runner(&rec);
2298        git.checkout_detach(Path::new("."), "abc123")
2299            .await
2300            .expect("detach");
2301        assert_eq!(
2302            rec.only_call().args_str(),
2303            ["checkout", "--detach", "abc123"]
2304        );
2305    }
2306
2307    // Partial amend commit must build `commit --amend -m <msg> --only -- <paths>`.
2308    #[tokio::test]
2309    async fn commit_paths_builds_only_amend_args() {
2310        let rec = RecordingRunner::replying(Reply::ok(""));
2311        let git = Git::with_runner(&rec);
2312        git.commit_paths(
2313            Path::new("."),
2314            CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2315        )
2316        .await
2317        .expect("commit_paths");
2318        assert_eq!(
2319            rec.only_call().args_str(),
2320            [
2321                "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2322            ]
2323        );
2324    }
2325
2326    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
2327    // unborn (true), anything else is a structured error.
2328    #[tokio::test]
2329    async fn is_unborn_maps_exit_codes() {
2330        let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
2331        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2332        let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
2333        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2334        let broken =
2335            Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
2336        assert!(matches!(
2337            broken.is_unborn(Path::new(".")).await.unwrap_err(),
2338            Error::Exit { code: 128, .. }
2339        ));
2340    }
2341
2342    #[tokio::test]
2343    async fn log_range_builds_range_and_format() {
2344        let rec = RecordingRunner::replying(Reply::ok(""));
2345        let git = Git::with_runner(&rec);
2346        git.log_range(Path::new("."), "main..HEAD", 5)
2347            .await
2348            .expect("log_range");
2349        assert_eq!(
2350            rec.only_call().args_str(),
2351            [
2352                "log",
2353                "main..HEAD",
2354                "-n5",
2355                "-z",
2356                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2357            ]
2358        );
2359    }
2360
2361    #[tokio::test]
2362    async fn stash_push_adds_include_untracked() {
2363        let rec = RecordingRunner::replying(Reply::ok(""));
2364        let git = Git::with_runner(&rec);
2365        git.stash_push(Path::new("."), true).await.expect("stash");
2366        assert_eq!(
2367            rec.only_call().args_str(),
2368            ["stash", "push", "--include-untracked"]
2369        );
2370    }
2371
2372    // `diff_text` for the working tree must build `diff HEAD` plus the stable
2373    // machine-output flags, in order.
2374    #[tokio::test]
2375    async fn diff_text_builds_working_tree_args() {
2376        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
2377        // targets HEAD. The probe is the first call; the diff is the last.
2378        let rec = RecordingRunner::replying(Reply::ok(""));
2379        let git = Git::with_runner(&rec);
2380        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2381            .await
2382            .expect("diff_text");
2383        assert_eq!(
2384            rec.calls().last().unwrap().args_str(),
2385            [
2386                "diff",
2387                "HEAD",
2388                "--no-color",
2389                "--no-ext-diff",
2390                "-M",
2391                // Pin the parser's `a/`…`b/` headers against a user's
2392                // `diff.noprefix`/`diff.mnemonicPrefix` config.
2393                "--src-prefix=a/",
2394                "--dst-prefix=b/",
2395            ]
2396        );
2397    }
2398
2399    // On an unborn repo the working-tree diff targets the empty tree instead of
2400    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
2401    // diff rule only matches the empty-tree argv, so a `HEAD` target would miss it.
2402    #[tokio::test]
2403    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2404        let git = Git::with_runner(
2405            ScriptedRunner::new()
2406                .on(["rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
2407                .on(["diff", EMPTY_TREE], Reply::ok("EMPTY")),
2408        );
2409        let out = git
2410            .diff_text(Path::new("."), DiffSpec::WorkingTree)
2411            .await
2412            .expect("diff_text");
2413        assert_eq!(out, "EMPTY");
2414    }
2415
2416    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
2417    // canned git-format output.
2418    #[tokio::test]
2419    async fn diff_parses_scripted_output() {
2420        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2421        let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
2422        let files = git
2423            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2424            .await
2425            .expect("diff");
2426        assert_eq!(files.len(), 1);
2427        assert_eq!(files[0].path, "m");
2428        assert_eq!(files[0].change, ChangeKind::Modified);
2429    }
2430
2431    #[tokio::test]
2432    async fn branch_exists_maps_exit_codes() {
2433        let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
2434        assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
2435        let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
2436        assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
2437    }
2438
2439    // The full ref prefix is stripped but a slashed default branch survives; an
2440    // unset origin/HEAD (non-zero exit) is `None`, not an error.
2441    #[tokio::test]
2442    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
2443        let simple = Git::with_runner(
2444            ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
2445        );
2446        assert_eq!(
2447            simple
2448                .remote_head_branch(Path::new("."))
2449                .await
2450                .unwrap()
2451                .as_deref(),
2452            Some("main")
2453        );
2454
2455        let slashed = Git::with_runner(ScriptedRunner::new().on(
2456            ["symbolic-ref"],
2457            Reply::ok("refs/remotes/origin/release/v2\n"),
2458        ));
2459        assert_eq!(
2460            slashed
2461                .remote_head_branch(Path::new("."))
2462                .await
2463                .unwrap()
2464                .as_deref(),
2465            Some("release/v2")
2466        );
2467
2468        let unset =
2469            Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
2470        assert!(
2471            unset
2472                .remote_head_branch(Path::new("."))
2473                .await
2474                .unwrap()
2475                .is_none()
2476        );
2477    }
2478
2479    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
2480    // stdout as "absent".
2481    #[tokio::test]
2482    async fn remote_branch_exists_sets_env_and_reads_stdout() {
2483        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
2484        let git = Git::with_runner(&rec);
2485        assert!(
2486            git.remote_branch_exists(Path::new("/repo"), "main")
2487                .await
2488                .unwrap()
2489        );
2490        let call = rec.only_call();
2491        assert!(call.envs.iter().any(|(k, v)| {
2492            k.to_str() == Some("GIT_TERMINAL_PROMPT")
2493                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2494        }));
2495        // Exact-ref query — a bare `main` would tail-match `bar/main`.
2496        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
2497
2498        let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
2499        assert!(
2500            !empty
2501                .remote_branch_exists(Path::new("."), "x")
2502                .await
2503                .unwrap()
2504        );
2505    }
2506
2507    #[tokio::test]
2508    async fn diff_stat_parses_counts() {
2509        let git = Git::with_runner(ScriptedRunner::new().on(
2510            ["diff", "--shortstat"],
2511            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
2512        ));
2513        let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
2514        assert_eq!(
2515            (stat.files_changed, stat.insertions, stat.deletions),
2516            (2, 5, 1)
2517        );
2518    }
2519
2520    #[tokio::test]
2521    async fn status_text_returns_raw_porcelain() {
2522        let git = Git::with_runner(ScriptedRunner::new().on(
2523            ["status", "--porcelain=v1"],
2524            Reply::ok(" M a.rs\n?? b.rs\n"),
2525        ));
2526        let text = git.status_text(Path::new(".")).await.expect("status_text");
2527        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
2528    }
2529
2530    #[tokio::test]
2531    async fn run_args_forwards_str_slices() {
2532        let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
2533        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
2534    }
2535
2536    #[tokio::test]
2537    async fn merge_commit_builds_no_ff_and_message() {
2538        let rec = RecordingRunner::replying(Reply::ok(""));
2539        let git = Git::with_runner(&rec);
2540        git.merge_commit(
2541            Path::new("/r"),
2542            MergeCommit::branch("feature").no_ff().message("merge it"),
2543        )
2544        .await
2545        .unwrap();
2546        assert_eq!(
2547            rec.only_call().args_str(),
2548            ["merge", "--no-ff", "-m", "merge it", "feature"]
2549        );
2550    }
2551
2552    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
2553    #[tokio::test]
2554    async fn merge_commit_without_message_uses_no_edit() {
2555        let rec = RecordingRunner::replying(Reply::ok(""));
2556        let git = Git::with_runner(&rec);
2557        git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
2558            .await
2559            .unwrap();
2560        assert_eq!(
2561            rec.only_call().args_str(),
2562            ["merge", "--no-edit", "feature"]
2563        );
2564    }
2565
2566    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
2567    #[tokio::test]
2568    async fn rebase_suppresses_editor() {
2569        let rec = RecordingRunner::replying(Reply::ok(""));
2570        let git = Git::with_runner(&rec);
2571        git.rebase(Path::new("/r"), "main").await.unwrap();
2572        let call = rec.only_call();
2573        assert_eq!(call.args_str(), ["rebase", "main"]);
2574        assert!(call.envs.iter().any(|(k, v)| {
2575            k.to_str() == Some("GIT_EDITOR")
2576                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
2577        }));
2578    }
2579
2580    #[tokio::test]
2581    async fn push_builds_set_upstream_remote_refspec() {
2582        let rec = RecordingRunner::replying(Reply::ok(""));
2583        let git = Git::with_runner(&rec);
2584        git.push(
2585            Path::new("/r"),
2586            GitPush::refspec("feat", "feature").set_upstream(),
2587        )
2588        .await
2589        .unwrap();
2590        assert_eq!(
2591            rec.only_call().args_str(),
2592            ["push", "-u", "origin", "feat:feature"]
2593        );
2594    }
2595
2596    // The common bare-branch push: `push origin <branch>` (no `-u`), with prompts
2597    // off so a credential-needing remote fails fast instead of hanging.
2598    #[tokio::test]
2599    async fn push_bare_branch_builds_origin_branch_prompt_off() {
2600        let rec = RecordingRunner::replying(Reply::ok(""));
2601        let git = Git::with_runner(&rec);
2602        git.push(Path::new("/r"), GitPush::branch("feature"))
2603            .await
2604            .unwrap();
2605        let call = rec.only_call();
2606        assert_eq!(call.args_str(), ["push", "origin", "feature"]);
2607        assert!(call.envs.iter().any(|(k, v)| {
2608            k.to_str() == Some("GIT_TERMINAL_PROMPT")
2609                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2610        }));
2611    }
2612
2613    // `.remote()` swaps the remote token in place.
2614    #[tokio::test]
2615    async fn push_remote_override_swaps_remote() {
2616        let rec = RecordingRunner::replying(Reply::ok(""));
2617        let git = Git::with_runner(&rec);
2618        git.push(
2619            Path::new("/r"),
2620            GitPush::branch("feature").remote("upstream"),
2621        )
2622        .await
2623        .unwrap();
2624        assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
2625    }
2626
2627    #[tokio::test]
2628    async fn upstream_maps_unset_to_none() {
2629        let set =
2630            Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("origin/main\n")));
2631        assert_eq!(
2632            set.upstream(Path::new(".")).await.unwrap().as_deref(),
2633            Some("origin/main")
2634        );
2635        let unset = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "")));
2636        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
2637    }
2638
2639    #[tokio::test]
2640    async fn set_upstream_builds_branch_flag() {
2641        let rec = RecordingRunner::replying(Reply::ok(""));
2642        let git = Git::with_runner(&rec);
2643        git.set_upstream(Path::new("/r"), "feat", "origin/feature")
2644            .await
2645            .unwrap();
2646        assert_eq!(
2647            rec.only_call().args_str(),
2648            ["branch", "--set-upstream-to=origin/feature", "feat"]
2649        );
2650    }
2651
2652    #[tokio::test]
2653    async fn remote_branches_parses_ls_remote() {
2654        let git = Git::with_runner(ScriptedRunner::new().on(
2655            ["ls-remote"],
2656            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
2657        ));
2658        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
2659        assert_eq!(branches, ["main", "feat/x"]);
2660    }
2661
2662    #[tokio::test]
2663    async fn delete_branch_force_uses_capital_d() {
2664        let rec = RecordingRunner::replying(Reply::ok(""));
2665        let git = Git::with_runner(&rec);
2666        git.delete_branch(Path::new("/r"), "old", true)
2667            .await
2668            .unwrap();
2669        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
2670    }
2671
2672    // `branch --merged` marks the current branch with `*` and a branch checked out
2673    // in another worktree with `+`; both must still match after marker stripping.
2674    #[tokio::test]
2675    async fn is_merged_strips_branch_markers() {
2676        let git = Git::with_runner(ScriptedRunner::new().on(
2677            ["branch", "--merged"],
2678            Reply::ok("  main\n* feature\n+ wt-branch\n"),
2679        ));
2680        for name in ["main", "feature", "wt-branch"] {
2681            assert!(
2682                git.is_merged(Path::new("."), name, "main").await.unwrap(),
2683                "{name} should be reported merged"
2684            );
2685        }
2686        assert!(
2687            !git.is_merged(Path::new("."), "absent", "main")
2688                .await
2689                .unwrap()
2690        );
2691    }
2692
2693    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
2694    // a remote needing auth — matching the other remote ops.
2695    #[tokio::test]
2696    async fn fetch_disables_terminal_prompt() {
2697        let rec = RecordingRunner::replying(Reply::ok(""));
2698        let git = Git::with_runner(&rec);
2699        git.fetch(Path::new("/r")).await.unwrap();
2700        let call = rec.only_call();
2701        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
2702        assert!(call.envs.iter().any(|(k, v)| {
2703            k.to_str() == Some("GIT_TERMINAL_PROMPT")
2704                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2705        }));
2706    }
2707
2708    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
2709    #[tokio::test]
2710    async fn fetch_retries_transient_failures() {
2711        let rec = RecordingRunner::replying(Reply::fail(
2712            128,
2713            "fatal: unable to access: Could not resolve host: example.com",
2714        ));
2715        let git = Git::with_runner(&rec);
2716        assert!(git.fetch(Path::new("/r")).await.is_err());
2717        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
2718    }
2719
2720    // A non-transient failure fails fast — no retry.
2721    #[tokio::test]
2722    async fn fetch_does_not_retry_permanent_failures() {
2723        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
2724        let git = Git::with_runner(&rec);
2725        assert!(git.fetch(Path::new("/r")).await.is_err());
2726        assert_eq!(rec.calls().len(), 1);
2727    }
2728
2729    // Client-level cancellation (processkit 0.8 `cancellation` feature) on a
2730    // *retried* op: a `fetch` built on a client with `default_cancel_on(token)`
2731    // parks until the token fires, then surfaces `Error::Cancelled` — and because
2732    // cancellation is **terminal** (not transient), the fetch-retry does NOT
2733    // replay it (one spawn, not FETCH_ATTEMPTS). Hermetic via `Reply::pending()`
2734    // on a paused clock.
2735    #[cfg(feature = "cancellation")]
2736    #[tokio::test(start_paused = true)]
2737    async fn fetch_cancels_and_does_not_retry() {
2738        use processkit::CancellationToken;
2739        let token = CancellationToken::new();
2740        let rec = RecordingRunner::new(ScriptedRunner::new().on(["fetch"], Reply::pending()));
2741        let git = Git::with_runner(&rec).default_cancel_on(token.clone());
2742        let call = git.fetch(Path::new("/r"));
2743        tokio::pin!(call);
2744        assert!(
2745            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
2746                .await
2747                .is_err(),
2748            "fetch must park until the token fires"
2749        );
2750        token.cancel();
2751        assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
2752        assert_eq!(
2753            rec.calls().len(),
2754            1,
2755            "cancellation is terminal — the fetch-retry must not replay it"
2756        );
2757    }
2758
2759    // The injection guard: a flag-shaped value in any exposed positional slot
2760    // must be refused BEFORE anything spawns.
2761    #[tokio::test]
2762    async fn flag_like_positionals_are_rejected_before_spawning() {
2763        let rec = RecordingRunner::replying(Reply::ok(""));
2764        let git = Git::with_runner(&rec);
2765        let dir = Path::new("/r");
2766
2767        assert!(git.checkout(dir, "-evil").await.is_err());
2768        assert!(git.create_branch(dir, "--force").await.is_err());
2769        assert!(git.delete_branch(dir, "-D", false).await.is_err());
2770        assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
2771        assert!(
2772            git.merge_commit(dir, MergeCommit::branch("-evil"))
2773                .await
2774                .is_err()
2775        );
2776        assert!(
2777            git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
2778                .await
2779                .is_err()
2780        );
2781        assert!(git.merge_squash(dir, "-evil").await.is_err());
2782        assert!(git.rebase(dir, "-i").await.is_err());
2783        assert!(git.cherry_pick(dir, "-n").await.is_err());
2784        assert!(git.revert(dir, "-evil").await.is_err());
2785        assert!(git.tag_create(dir, "-d", None).await.is_err());
2786        assert!(
2787            git.tag_create(dir, "ok", Some("-evil".into()))
2788                .await
2789                .is_err()
2790        );
2791        assert!(git.tag_delete(dir, "-evil").await.is_err());
2792        assert!(git.remote_add(dir, "-evil", "url").await.is_err());
2793        assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
2794        assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
2795        assert!(git.log_range(dir, "-evil", 5).await.is_err());
2796        assert!(git.rev_list_count(dir, "-evil").await.is_err());
2797        assert!(git.diff_stat(dir, "-evil").await.is_err());
2798        assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
2799        assert!(
2800            git.diff_text(dir, DiffSpec::Rev("-evil".into()))
2801                .await
2802                .is_err()
2803        );
2804        assert!(git.rev_parse(dir, "-evil").await.is_err());
2805        assert!(git.rev_parse_short(dir, "-evil").await.is_err());
2806        assert!(git.resolve_commit(dir, "-evil").await.is_err());
2807        assert!(git.reset_hard(dir, "-evil").await.is_err());
2808        assert!(git.checkout_detach(dir, "-evil").await.is_err());
2809        assert!(git.config_set(dir, "-evil", "v").await.is_err());
2810        assert!(
2811            git.push(dir, GitPush::branch("-evil")).await.is_err(),
2812            "refspec guard"
2813        );
2814        // Embedded-token-prefix and standalone-rev positionals:
2815        assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
2816        assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
2817        assert!(git.remote_url(dir, "-evil").await.is_err());
2818        assert!(git.remote_branches(dir, "-evil").await.is_err());
2819        assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
2820        // URL positionals (a leading-`-` url is an RCE-class flag injection).
2821        assert!(
2822            git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
2823                .await
2824                .is_err()
2825        );
2826        assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
2827        assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
2828        assert!(git.is_merged(dir, "-evil", "main").await.is_err());
2829        assert!(git.config_get(dir, "-evil").await.is_err());
2830        assert!(
2831            git.worktree_add(
2832                dir,
2833                WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
2834            )
2835            .await
2836            .is_err()
2837        );
2838        // Empty values are refused too.
2839        assert!(git.checkout(dir, "").await.is_err());
2840
2841        assert!(
2842            rec.calls().is_empty(),
2843            "nothing may spawn: {:?}",
2844            rec.calls()
2845        );
2846
2847        // …and legitimate values still pass through unchanged.
2848        git.checkout(dir, "feature/x").await.expect("checkout");
2849        assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x"]);
2850    }
2851
2852    // The hardened profile lands its env pairs/removals on EVERY command, and
2853    // composes with per-command env like GIT_TERMINAL_PROMPT.
2854    #[tokio::test]
2855    async fn harden_applies_env_profile_to_every_command() {
2856        let rec = RecordingRunner::replying(Reply::ok(""));
2857        let git = Git::with_runner(&rec).harden();
2858        git.status(Path::new("/r")).await.expect("status");
2859        git.fetch(Path::new("/r")).await.expect("fetch");
2860
2861        for call in rec.calls() {
2862            let has = |k: &str, v: &str| {
2863                call.envs.iter().any(|(key, val)| {
2864                    key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
2865                })
2866            };
2867            let removed = |k: &str| {
2868                call.envs
2869                    .iter()
2870                    .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
2871            };
2872            assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
2873            assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
2874            assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
2875            assert!(has("GIT_TERMINAL_PROMPT", "0"));
2876            assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
2877            assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
2878        }
2879    }
2880
2881    // RefName/RevSpec accept/reject tables.
2882    #[test]
2883    fn ref_name_and_rev_spec_validate() {
2884        for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
2885            assert!(RefName::new(ok).is_ok(), "{ok}");
2886        }
2887        for bad in [
2888            "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
2889            "a\\b", "end/", "x.lock",
2890        ] {
2891            assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
2892        }
2893        assert!(RevSpec::new("HEAD~2").is_ok());
2894        assert!(RevSpec::new("main..feature").is_ok());
2895        assert!(RevSpec::new("-evil").is_err());
2896        assert!(RevSpec::new("").is_err());
2897    }
2898
2899    // capabilities parses real-world version shapes (incl. the Windows build
2900    // trailer) and gates on the major floor only.
2901    #[tokio::test]
2902    async fn capabilities_parse_and_gate_versions() {
2903        let gh = Git::with_runner(
2904            ScriptedRunner::new().on(["--version"], Reply::ok("git version 2.54.0.windows.1\n")),
2905        );
2906        let caps = gh.capabilities().await.expect("capabilities");
2907        assert_eq!(caps.version.to_string(), "2.54.0");
2908        assert!(caps.is_supported());
2909        caps.ensure_supported().expect("supported");
2910
2911        // Two-part versions parse (patch defaults to 0); an ancient major fails
2912        // the gate with a clear message.
2913        let old = Git::with_runner(
2914            ScriptedRunner::new().on(["--version"], Reply::ok("git version 1.9\n")),
2915        );
2916        let caps = old.capabilities().await.expect("capabilities");
2917        assert_eq!(
2918            caps.version,
2919            GitVersion {
2920                major: 1,
2921                minor: 9,
2922                patch: 0
2923            }
2924        );
2925        let err = caps.ensure_supported().expect_err("unsupported");
2926        // The message must name the floor and the found version.
2927        let Error::Spawn { source, .. } = &err else {
2928            panic!("expected Spawn, got {err:?}");
2929        };
2930        let message = source.to_string();
2931        assert!(message.contains(">= 2"), "names the floor: {message}");
2932        assert!(
2933            message.contains("1.9.0"),
2934            "names the found version: {message}"
2935        );
2936
2937        // Garbage output is a parse error, not a silent zero version.
2938        let garbage =
2939            Git::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("not a version")));
2940        assert!(matches!(
2941            garbage.capabilities().await.unwrap_err(),
2942            Error::Parse { .. }
2943        ));
2944    }
2945
2946    // clone_repo is dir-less and appends only the requested flags.
2947    #[tokio::test]
2948    async fn clone_repo_builds_flags_and_runs_dirless() {
2949        let rec = RecordingRunner::replying(Reply::ok(""));
2950        let git = Git::with_runner(&rec);
2951        git.clone_repo(
2952            "https://example.com/r.git",
2953            Path::new("/dest"),
2954            CloneSpec::new().branch("main").depth(1).bare(),
2955        )
2956        .await
2957        .expect("clone");
2958        let call = rec.only_call();
2959        assert_eq!(
2960            call.args_str(),
2961            [
2962                "clone",
2963                "--branch",
2964                "main",
2965                "--depth",
2966                "1",
2967                "--bare",
2968                "https://example.com/r.git",
2969                "/dest"
2970            ]
2971        );
2972        assert_eq!(call.cwd, None, "clone runs without a working directory");
2973
2974        let bare = RecordingRunner::replying(Reply::ok(""));
2975        let git = Git::with_runner(&bare);
2976        git.clone_repo("u", Path::new("/d"), CloneSpec::new())
2977            .await
2978            .expect("clone");
2979        assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
2980    }
2981
2982    #[tokio::test]
2983    async fn tag_methods_build_args() {
2984        let rec = RecordingRunner::replying(Reply::ok(""));
2985        let git = Git::with_runner(&rec);
2986        git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
2987        git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
2988            .await
2989            .unwrap();
2990        git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
2991            .await
2992            .unwrap();
2993        git.tag_delete(Path::new("/r"), "v1").await.unwrap();
2994        let calls = rec.calls();
2995        assert_eq!(calls[0].args_str(), ["tag", "v1"]);
2996        assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
2997        assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
2998        assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
2999    }
3000
3001    #[tokio::test]
3002    async fn tag_list_splits_lines() {
3003        let git =
3004            Git::with_runner(ScriptedRunner::new().on(["tag", "--list"], Reply::ok("v1\nv2.0\n")));
3005        assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
3006    }
3007
3008    // The line-parsed list commands must pass `--no-column`: a user's
3009    // `column.ui = always` would pack several names per line even when piped.
3010    #[tokio::test]
3011    async fn list_commands_disable_column_output() {
3012        let rec = RecordingRunner::replying(Reply::ok(""));
3013        let git = Git::with_runner(&rec);
3014        git.branches(Path::new(".")).await.unwrap();
3015        git.is_merged(Path::new("."), "b", "main").await.unwrap();
3016        git.tag_list(Path::new(".")).await.unwrap();
3017        let calls = rec.calls();
3018        assert_eq!(calls[0].args_str(), ["branch", "--no-column"]);
3019        assert_eq!(
3020            calls[1].args_str(),
3021            ["branch", "--merged", "main", "--no-column"]
3022        );
3023        assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
3024    }
3025
3026    // Commands whose failure output feeds the error classifiers must force the
3027    // C locale — a translated message would defeat the substring matching.
3028    #[tokio::test]
3029    async fn classified_commands_force_c_locale() {
3030        let rec = RecordingRunner::replying(Reply::ok(""));
3031        let git = Git::with_runner(&rec);
3032        git.commit(Path::new("."), "msg").await.unwrap();
3033        git.merge_commit(Path::new("."), MergeCommit::branch("b"))
3034            .await
3035            .unwrap();
3036        git.cherry_pick(Path::new("."), "abc").await.unwrap();
3037        git.fetch(Path::new(".")).await.unwrap();
3038        for call in rec.calls() {
3039            assert!(
3040                call.envs.iter().any(|(k, v)| {
3041                    k.to_str() == Some("LC_ALL")
3042                        && v.as_deref().and_then(|o| o.to_str()) == Some("C")
3043                }),
3044                "{:?} should force LC_ALL=C",
3045                call.args_str()
3046            );
3047        }
3048    }
3049
3050    // The `<rev>:<path>` spec requires forward slashes — Windows callers may
3051    // hand in backslashes. The normalisation is Windows-only.
3052    #[cfg(windows)]
3053    #[tokio::test]
3054    async fn show_file_normalises_path_separators() {
3055        let rec = RecordingRunner::replying(Reply::ok("content\n"));
3056        let git = Git::with_runner(&rec);
3057        let out = git
3058            .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3059            .await
3060            .expect("show_file");
3061        assert_eq!(out, "content");
3062        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
3063    }
3064
3065    // On Unix a backslash is a legal filename byte — the spec must pass through
3066    // verbatim so a literal `a\b.txt` stays resolvable.
3067    #[cfg(not(windows))]
3068    #[tokio::test]
3069    async fn show_file_keeps_backslashes_on_unix() {
3070        let rec = RecordingRunner::replying(Reply::ok("content\n"));
3071        let git = Git::with_runner(&rec);
3072        git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3073            .await
3074            .expect("show_file");
3075        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
3076    }
3077
3078    // config --get: exit 0 → Some(value), exit 1 → None (unset), other → error.
3079    #[tokio::test]
3080    async fn config_get_maps_exit_codes() {
3081        let set =
3082            Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::ok("Alice\n")));
3083        assert_eq!(
3084            set.config_get(Path::new("."), "user.name").await.unwrap(),
3085            Some("Alice".to_string())
3086        );
3087        let unset =
3088            Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::fail(1, "")));
3089        assert_eq!(
3090            unset.config_get(Path::new("."), "user.name").await.unwrap(),
3091            None
3092        );
3093        // A multi-valued key (exit 2) or worse is a real error.
3094        let multi = Git::with_runner(
3095            ScriptedRunner::new().on(["config", "--get"], Reply::fail(2, "multiple values")),
3096        );
3097        assert!(
3098            multi
3099                .config_get(Path::new("."), "remote.all")
3100                .await
3101                .is_err()
3102        );
3103    }
3104
3105    #[tokio::test]
3106    async fn blame_builds_rev_before_pathspec_separator() {
3107        let rec = RecordingRunner::replying(Reply::ok(""));
3108        let git = Git::with_runner(&rec);
3109        git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
3110            .await
3111            .unwrap();
3112        git.blame(Path::new("/r"), "src/lib.rs", None)
3113            .await
3114            .unwrap();
3115        let calls = rec.calls();
3116        assert_eq!(
3117            calls[0].args_str(),
3118            ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
3119        );
3120        assert_eq!(
3121            calls[1].args_str(),
3122            ["blame", "--line-porcelain", "--", "src/lib.rs"]
3123        );
3124    }
3125
3126    // revert must never open an editor: --no-edit plus the env backstop.
3127    #[tokio::test]
3128    async fn sequencer_methods_suppress_editors() {
3129        let rec = RecordingRunner::replying(Reply::ok(""));
3130        let git = Git::with_runner(&rec);
3131        git.revert(Path::new("/r"), "abc").await.unwrap();
3132        git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
3133        git.rebase_skip(Path::new("/r")).await.unwrap();
3134        let calls = rec.calls();
3135        assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
3136        assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
3137        assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
3138        for call in &calls {
3139            assert!(
3140                call.envs
3141                    .iter()
3142                    .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
3143                "editor suppressed on {:?}",
3144                call.args_str()
3145            );
3146        }
3147    }
3148
3149    #[tokio::test]
3150    async fn remote_add_and_set_url_build_args() {
3151        let rec = RecordingRunner::replying(Reply::ok(""));
3152        let git = Git::with_runner(&rec);
3153        git.remote_add(Path::new("/r"), "up", "https://x/y.git")
3154            .await
3155            .unwrap();
3156        git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
3157            .await
3158            .unwrap();
3159        let calls = rec.calls();
3160        assert_eq!(
3161            calls[0].args_str(),
3162            ["remote", "add", "up", "https://x/y.git"]
3163        );
3164        assert_eq!(
3165            calls[1].args_str(),
3166            ["remote", "set-url", "up", "https://x/z.git"]
3167        );
3168    }
3169
3170    // Dirty tree: stash -u → checkout → pop, in that order.
3171    #[tokio::test]
3172    async fn switch_with_stash_round_trips_dirty_tree() {
3173        let rec = RecordingRunner::new(
3174            ScriptedRunner::new()
3175                .on(["status"], Reply::ok(" M a.rs\0"))
3176                .on(["stash", "push"], Reply::ok(""))
3177                .on(["checkout"], Reply::ok(""))
3178                .on(["stash", "pop"], Reply::ok("")),
3179        );
3180        let git = Git::with_runner(&rec);
3181        git.switch_with_stash(Path::new("/r"), "feature")
3182            .await
3183            .expect("switch");
3184        let calls = rec.calls();
3185        assert_eq!(calls.len(), 4);
3186        assert_eq!(
3187            calls[1].args_str(),
3188            ["stash", "push", "--include-untracked"]
3189        );
3190        assert_eq!(calls[2].args_str(), ["checkout", "feature"]);
3191        assert_eq!(calls[3].args_str(), ["stash", "pop"]);
3192    }
3193
3194    // A clean tree skips the stash round-trip — a no-op `stash push` would make
3195    // the later pop grab an older, unrelated stash.
3196    #[tokio::test]
3197    async fn switch_with_stash_skips_stash_on_clean_tree() {
3198        let rec = RecordingRunner::new(
3199            ScriptedRunner::new()
3200                .on(["status"], Reply::ok(""))
3201                .on(["checkout"], Reply::ok("")),
3202        );
3203        let git = Git::with_runner(&rec);
3204        git.switch_with_stash(Path::new("/r"), "feature")
3205            .await
3206            .expect("switch");
3207        let calls = rec.calls();
3208        assert_eq!(calls.len(), 2);
3209        assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
3210    }
3211
3212    // A failed checkout pops the stash back (we are still on the original
3213    // branch) and surfaces the checkout error.
3214    #[tokio::test]
3215    async fn switch_with_stash_restores_on_checkout_failure() {
3216        let rec = RecordingRunner::new(
3217            ScriptedRunner::new()
3218                .on(["status"], Reply::ok(" M a.rs\0"))
3219                .on(["stash", "push"], Reply::ok(""))
3220                .on(["checkout"], Reply::fail(1, "error: pathspec 'nope'"))
3221                .on(["stash", "pop"], Reply::ok("")),
3222        );
3223        let git = Git::with_runner(&rec);
3224        let err = git
3225            .switch_with_stash(Path::new("/r"), "nope")
3226            .await
3227            .expect_err("checkout error must surface");
3228        assert!(matches!(err, Error::Exit { .. }));
3229        let calls = rec.calls();
3230        assert_eq!(calls.len(), 4);
3231        assert_eq!(calls[3].args_str(), ["stash", "pop"], "restoring pop ran");
3232    }
3233
3234    // `fetch_from` names the remote, keeps the prompt off, and shares the
3235    // transient retry.
3236    #[tokio::test]
3237    async fn fetch_from_builds_args_and_retries() {
3238        let rec = RecordingRunner::replying(Reply::ok(""));
3239        let git = Git::with_runner(&rec);
3240        git.fetch_from(Path::new("/r"), "upstream")
3241            .await
3242            .expect("fetch_from");
3243        let call = rec.only_call();
3244        assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
3245        assert!(call.envs.iter().any(|(k, v)| {
3246            k.to_str() == Some("GIT_TERMINAL_PROMPT")
3247                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3248        }));
3249
3250        let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
3251        let git = Git::with_runner(&failing);
3252        assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
3253        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
3254    }
3255
3256    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
3257    // tested with a generated mock.
3258    #[cfg(feature = "mock")]
3259    #[tokio::test]
3260    async fn consumer_mocks_the_interface() {
3261        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
3262            git.current_branch(Path::new(".")).await.unwrap() == want
3263        }
3264        let mut mock = MockGitApi::new();
3265        mock.expect_current_branch()
3266            .returning(|_| Ok("main".to_string()));
3267        assert!(on_branch(&mock, "main").await);
3268    }
3269}
3270
3271// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
3272#[doc = include_str!("../docs/git.md")]
3273#[allow(rustdoc::broken_intra_doc_links)]
3274pub mod guide {
3275    #[doc = include_str!("../docs/security.md")]
3276    #[allow(rustdoc::broken_intra_doc_links)]
3277    pub mod security {}
3278    #[doc = include_str!("../docs/conflicts.md")]
3279    #[allow(rustdoc::broken_intra_doc_links)]
3280    pub mod conflicts {}
3281}