Skip to main content

vcs_git/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-git` — automate Git from Rust by driving the `git` CLI.
4//!
5//! You call typed `async` methods; `vcs-git` runs the real `git`, parses its
6//! output, and hands you structured values — so you get *git's own* behaviour,
7//! config, and credentials, not a reimplementation of the object format. Async,
8//! structured errors, mockable. Every command runs inside an OS **job** (an
9//! OS-level container that kills the whole process tree if your program exits, via
10//! [`processkit`]) so a `git` subprocess is never orphaned, with an optional
11//! per-client [timeout](Git::default_timeout).
12//!
13//! # What you can do
14//!
15//! Status & branches · stage, commit, checkout · diff & log · merge / rebase /
16//! reset · worktrees · tags · blame · clone · config · cherry-pick / revert · parse
17//! & resolve conflict markers · a hardened (hooks-off) profile for untrusted repos.
18//! One tiny call to start:
19//!
20//! ```no_run
21//! use std::path::Path;
22//! use vcs_git::{Git, GitApi};
23//! # async fn demo() -> Result<(), processkit::Error> {
24//! let git = Git::new();
25//! // `current_branch` is `Option` — `None` on a detached HEAD.
26//! println!("{:?}", git.current_branch(Path::new(".")).await?); // e.g. Some("main")
27//! # Ok(()) }
28//! ```
29//!
30//! # The surface (engineering reference)
31//!
32//! - **[`GitApi`]** — the object-safe trait every operation lives on. Depend on
33//!   `&dyn GitApi` (or generically on `impl GitApi`) so a test can swap the real
34//!   client for a double. Methods take the working directory as the first
35//!   argument and return typed results ([`StatusEntry`], [`Branch`], [`Commit`],
36//!   [`FileDiff`], [`BlameLine`], …) or a structured [`Error`].
37//! - **[`Git`]** — the real client. [`Git::new`] uses the job-backed runner;
38//!   [`Git::with_runner`] injects a fake one for tests. It is generic over the
39//!   [`ProcessRunner`] seam, defaulting to the production runner.
40//!   [`with_credentials`](Git::with_credentials) attaches a [`CredentialProvider`]
41//!   to authenticate HTTPS remote ops (fetch/push/clone/ls-remote) with a token
42//!   kept out of `argv` — opt-in, off by default (ambient helpers / SSH agent).
43//! - **[`GitAt`]** — a cwd-bound view ([`Git::at`]) whose methods drop the
44//!   leading `dir`, so `git.at(dir).status()` reads as `git.status(dir)` — handy
45//!   when one client drives one checkout.
46//! - **Builder specs** for the multi-option commands — [`CommitPaths`],
47//!   [`MergeCommit`] / [`MergeNoCommit`], [`GitPush`], [`CloneSpec`],
48//!   [`WorktreeAdd`], [`AnnotatedTag`], [`MergeCheck`], [`BranchDelete`],
49//!   [`StashPush`], [`WorktreeRemove`] — each `#[non_exhaustive]`, built
50//!   with a constructor + chained setters, named after the flags they emit.
51//! - **[`conflict`]** — a typed conflict-marker model: parse marker soup into
52//!   structured regions, re-render byte-exact, and resolve to a chosen side.
53//! - **[`Git::hardened`]** — a profile for untrusted repositories (hooks off,
54//!   `GIT_*` scrubbed, system config skipped); see the [`guide::security`] guide.
55//!
56//! # Recipes
57//!
58//! Read state — depend on the trait so the same code takes a real client or a mock:
59//!
60//! ```no_run
61//! use std::path::Path;
62//! use vcs_git::{Git, GitApi};
63//! # async fn demo() -> Result<(), processkit::Error> {
64//! let git = Git::new();
65//! let dir = Path::new(".");
66//! let branch = git.current_branch(dir).await?;        // the checked-out branch
67//! let dirty = !git.status(dir).await?.is_empty();     // any uncommitted change?
68//! # let _ = (branch, dirty); Ok(()) }
69//! ```
70//!
71//! Mutate through the builder specs — `fetch` retries transient network failures:
72//!
73//! ```no_run
74//! use std::path::Path;
75//! use vcs_git::{CommitPaths, Git, GitApi, GitPush, RefName};
76//! # async fn demo(git: &Git) -> Result<(), processkit::Error> {
77//! let dir = Path::new(".");
78//! git.fetch(dir).await?;
79//! git.commit_paths(dir, CommitPaths::new(["src/a.rs"], "wip")).await?;
80//! // Ref/revision inputs are validated newtypes — build them at the boundary.
81//! git.push(dir, GitPush::branch(RefName::new("feature")?).set_upstream()).await?;
82//! # Ok(()) }
83//! ```
84//!
85//! # Testing
86//!
87//! Two seams: enable the **`mock`** feature for a `mockall`-generated
88//! `MockGitApi` (stub whole methods), or inject a
89//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`Git::with_runner`] to
90//! exercise the *real* argv-building and parsing against canned output. The
91//! cross-cutting testing patterns live in
92//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
93//!
94//! # Safety
95//!
96//! Every operation that takes a caller-supplied **reference name** or **revision
97//! expression** now does so through a validated newtype — [`RefName`] for
98//! branch/tag/ref names, [`RevSpec`] for revisions/ranges — so a flag-like or
99//! malformed value is rejected at construction, *before* it can reach an argv
100//! slot (a classifiable [`vcs_cli_support::is_invalid_input`] failure). The one
101//! context-dependent special value, git's `-` "previous branch", is modelled
102//! explicitly as [`CheckoutTarget::Previous`] rather than smuggled through a
103//! newtype. Remaining bare-positional inputs that are **not** refs/revisions
104//! (remote names, URLs, config keys) keep an internal
105//! [`reject_flag_like`](vcs_cli_support::reject_flag_like) guard — refused before
106//! spawning if empty or starting with `-`. Flag-value slots (`-b <name>`) are
107//! consumed verbatim; paths always go through `--` / pathspec.
108//!
109//! One named exception to the "revisions go through `RevSpec`" rule:
110//! [`DiffSpec::Rev`] — the diff target on
111//! [`GitApi::diff_text`]/[`GitApi::diff`] and [`Git::diff_text_within`]/
112//! [`Git::diff_within`] — is a bare `String` from the shared, backend-agnostic
113//! `vcs-diff` crate, not a `RevSpec`. It is still guarded, just per-call rather
114//! than by the type: `diff_text_budgeted` runs the same
115//! [`reject_flag_like`](vcs_cli_support::reject_flag_like) check inline before
116//! using it, and a trailing `--` pins it as a revision rather than a pathspec.
117//! Behaviourally equivalent to `RevSpec::new`'s guarantee, just enforced at the
118//! call site because `vcs-diff` is intentionally a plain-data, dependency-free
119//! crate with no newtype of its own to construct through.
120//!
121//! # In-depth guide
122//!
123//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
124//! from `docs/`. See the [`guide`] module (and its
125//! [`security`](crate::guide::security) / [`conflicts`](crate::guide::conflicts)
126//! sub-guides).
127
128use std::path::{Path, PathBuf};
129use std::sync::Arc;
130use std::time::Duration;
131
132use processkit::Command;
133// Re-export the processkit types that appear in this crate's public API, so
134// consumers needn't depend on processkit directly — incl. `ProcessRunner` (the
135// `with_runner`/`Git<R>` seam) and the `JobRunner` default. (`Error`/`Result`/
136// `ProcessResult`/`ProcessRunner` are in scope here too via this `pub use`.)
137pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
138// Re-exported so a consumer can name the token for `default_cancel_on` without
139// taking a direct `processkit` dependency.
140pub use processkit::CancellationToken;
141
142pub mod conflict;
143mod parse;
144#[doc(hidden)]
145pub use parse::parse_porcelain_v2;
146pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
147// The git-format diff model + parser and the version type are shared with
148// `vcs-jj` (identical output) — re-exported so `vcs_git::FileDiff`,
149// `vcs_git::parse_diff`, `vcs_git::GitVersion`, … still resolve.
150pub use vcs_diff::{
151    ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
152};
153// The error classifiers live in the shared plumbing crate — re-exported so
154// `vcs_git::is_merge_conflict`, … still resolve.
155use vcs_cli_support::git_credential_helper;
156pub use vcs_cli_support::{
157    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, OutputBudget,
158    RetryPolicy, Secret, StaticCredential, is_lock_contention, is_merge_conflict,
159    is_nothing_to_commit, is_transient_fetch_error, provider_fn,
160};
161
162/// Name of the underlying CLI binary this crate drives.
163pub const BINARY: &str = "git";
164
165/// Options for [`GitApi::worktree_add`] (`git worktree add`).
166///
167/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
168/// [`WorktreeAdd::create_branch`] rather than a struct literal.
169#[derive(Debug, Clone)]
170#[non_exhaustive]
171pub struct WorktreeAdd {
172    /// Filesystem path for the new worktree.
173    pub path: PathBuf,
174    /// Create and check out this new branch (`-b <name>`); `None` checks out an
175    /// existing ref.
176    pub new_branch: Option<RefName>,
177    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
178    pub commitish: Option<RevSpec>,
179    /// Register the worktree without populating its files (`--no-checkout`) — the
180    /// caller fills the working tree itself (e.g. a copy-on-write clone).
181    pub no_checkout: bool,
182}
183
184impl WorktreeAdd {
185    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
186    /// `git worktree add <path> <commitish>`.
187    pub fn checkout(path: impl Into<PathBuf>, commitish: RevSpec) -> Self {
188        Self {
189            path: path.into(),
190            new_branch: None,
191            commitish: Some(commitish),
192            no_checkout: false,
193        }
194    }
195
196    /// A worktree at `path` creating a new branch `name` based on `commitish`:
197    /// `git worktree add -b <name> <path> <commitish>`.
198    pub fn create_branch(path: impl Into<PathBuf>, name: RefName, commitish: RevSpec) -> Self {
199        Self {
200            path: path.into(),
201            new_branch: Some(name),
202            commitish: Some(commitish),
203            no_checkout: false,
204        }
205    }
206
207    /// Register the worktree without checking out its files (`--no-checkout`),
208    /// for a caller that populates the working tree itself.
209    pub fn no_checkout(mut self) -> Self {
210        self.no_checkout = true;
211        self
212    }
213}
214
215/// Options for [`GitApi::push`] (`git push`).
216///
217/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
218/// [`GitPush::refspec`] rather than a struct literal.
219#[derive(Debug, Clone)]
220#[non_exhaustive]
221pub struct GitPush {
222    /// Remote to push to (defaults to `origin`).
223    pub remote: String,
224    /// The refspec — a bare branch name, or `local:remote_branch`.
225    pub refspec: String,
226    /// Set the pushed branch as the upstream (`-u`).
227    pub set_upstream: bool,
228}
229
230impl GitPush {
231    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
232    pub fn branch(name: RefName) -> Self {
233        Self {
234            remote: "origin".to_string(),
235            refspec: name.as_str().to_string(),
236            set_upstream: false,
237        }
238    }
239
240    /// Push `local` to a differently-named `remote_branch`
241    /// (`git push origin <local>:<remote_branch>`). Both sides are validated
242    /// [`RefName`]s, so the single `:` is always the API-inserted separator — a
243    /// caller cannot smuggle an extra ref or a force (`+`) through them.
244    pub fn refspec(local: &RefName, remote_branch: &RefName) -> Self {
245        Self {
246            remote: "origin".to_string(),
247            refspec: format!("{}:{}", local.as_str(), remote_branch.as_str()),
248            set_upstream: false,
249        }
250    }
251
252    /// Push to a non-default remote.
253    pub fn remote(mut self, remote: impl Into<String>) -> Self {
254        self.remote = remote.into();
255        self
256    }
257
258    /// Record the pushed branch as the local branch's upstream (`-u`).
259    pub fn set_upstream(mut self) -> Self {
260        self.set_upstream = true;
261        self
262    }
263}
264
265/// Options for [`GitApi::clone_repo`] (`git clone`).
266///
267/// `#[non_exhaustive]`, so build it through [`CloneSpec::new`] and the chained
268/// setters rather than a struct literal.
269#[derive(Debug, Clone, Default)]
270#[non_exhaustive]
271pub struct CloneSpec {
272    /// Check out this branch instead of the remote's default (`--branch`).
273    pub branch: Option<String>,
274    /// Shallow-clone to this many commits (`--depth`). git silently ignores
275    /// the flag for a plain local-path source (warns, still clones fully);
276    /// use a `file://` URL to shallow-clone locally.
277    pub depth: Option<u32>,
278    /// Create a bare repository (`--bare`).
279    pub bare: bool,
280}
281
282impl CloneSpec {
283    /// A plain full clone of the remote's default branch.
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    /// Check out `branch` instead of the remote's default (`--branch`).
289    pub fn branch(mut self, branch: impl Into<String>) -> Self {
290        self.branch = Some(branch.into());
291        self
292    }
293
294    /// Shallow-clone to `depth` commits (`--depth`); see the field doc for the
295    /// local-path caveat.
296    pub fn depth(mut self, depth: u32) -> Self {
297        self.depth = Some(depth);
298        self
299    }
300
301    /// Clone as a bare repository (`--bare`).
302    pub fn bare(mut self) -> Self {
303        self.bare = true;
304        self
305    }
306}
307
308/// Options for [`GitApi::commit_paths`] (`git commit --only`).
309///
310/// `#[non_exhaustive]`, so build it through [`CommitPaths::new`] and the chained
311/// setters rather than a struct literal.
312#[derive(Debug, Clone)]
313#[non_exhaustive]
314pub struct CommitPaths {
315    /// The exact paths whose working-tree content to commit (`--only -- <paths>`).
316    pub paths: Vec<PathBuf>,
317    /// The commit message (`-m`).
318    pub message: String,
319    /// Amend the previous commit instead of creating a new one (`--amend`).
320    pub amend: bool,
321}
322
323impl CommitPaths {
324    /// Commit exactly `paths`' working-tree content with `message`
325    /// (`git commit -m <message> --only -- <paths>`).
326    pub fn new(
327        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
328        message: impl Into<String>,
329    ) -> Self {
330        Self {
331            paths: paths.into_iter().map(Into::into).collect(),
332            message: message.into(),
333            amend: false,
334        }
335    }
336
337    /// Amend the previous commit instead of creating a new one (`--amend`).
338    pub fn amend(mut self) -> Self {
339        self.amend = true;
340        self
341    }
342}
343
344/// Partial [`MergeCheck`] — names the branch being tested; chain
345/// [`into_base`](MergeCheckPartial::into_base) to name the base it must be merged into.
346#[derive(Debug, Clone)]
347pub struct MergeCheckPartial {
348    branch: RefName,
349}
350
351impl MergeCheckPartial {
352    /// The base commit-ish `branch` should be fully merged **into**.
353    pub fn into_base(self, base: RevSpec) -> MergeCheck {
354        MergeCheck {
355            branch: self.branch,
356            base,
357        }
358    }
359}
360
361/// A "is `branch` fully merged into `base`?" check for [`GitApi::is_merged`].
362///
363/// Built as `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)` — the two same-typed
364/// refs are named across **two** builder steps, so they can't be silently transposed
365/// (a swap would *invert* the answer). `#[non_exhaustive]`.
366#[derive(Debug, Clone, PartialEq, Eq)]
367#[non_exhaustive]
368pub struct MergeCheck {
369    /// The branch/ref being tested for having been merged.
370    pub branch: RefName,
371    /// The base commit-ish it should be fully merged into.
372    pub base: RevSpec,
373}
374
375impl MergeCheck {
376    /// Name the `branch` to test; chain [`into_base`](MergeCheckPartial::into_base).
377    pub fn branch(name: RefName) -> MergeCheckPartial {
378        MergeCheckPartial { branch: name }
379    }
380}
381
382/// Options for [`GitApi::merge_commit`] (`git merge` that commits the result).
383///
384/// `#[non_exhaustive]`, so build it through [`MergeCommit::branch`] and the
385/// chained setters rather than a struct literal.
386#[derive(Debug, Clone)]
387#[non_exhaustive]
388pub struct MergeCommit {
389    /// The commit-ish to merge in.
390    pub branch: RevSpec,
391    /// Always create a merge commit, even when a fast-forward was possible
392    /// (`--no-ff`).
393    pub no_ff: bool,
394    /// The merge commit message (`-m`); `None` takes the default message
395    /// non-interactively (`--no-edit`).
396    pub message: Option<String>,
397}
398
399impl MergeCommit {
400    /// Merge `target` taking the default merge message non-interactively
401    /// (`git merge --no-edit <target>`).
402    pub fn branch(target: RevSpec) -> Self {
403        Self {
404            branch: target,
405            no_ff: false,
406            message: None,
407        }
408    }
409
410    /// Always create a merge commit, even when a fast-forward was possible
411    /// (`--no-ff`).
412    pub fn no_ff(mut self) -> Self {
413        self.no_ff = true;
414        self
415    }
416
417    /// Use `m` as the merge commit message (`-m`).
418    pub fn message(mut self, m: impl Into<String>) -> Self {
419        self.message = Some(m.into());
420        self
421    }
422}
423
424/// Options for [`GitApi::merge_no_commit`] (`git merge --no-commit`).
425///
426/// `#[non_exhaustive]`, so build it through [`MergeNoCommit::branch`] and the
427/// chained setters rather than a struct literal.
428#[derive(Debug, Clone)]
429#[non_exhaustive]
430pub struct MergeNoCommit {
431    /// The commit-ish to merge in.
432    pub branch: RevSpec,
433    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`);
434    /// takes precedence over `no_ff` (git rejects the pair).
435    pub squash: bool,
436    /// Always record a real (abortable) merge, even when a fast-forward was
437    /// possible (`--no-ff`).
438    pub no_ff: bool,
439}
440
441impl MergeNoCommit {
442    /// Merge `target` but stop before committing (`git merge --no-commit <target>`).
443    pub fn branch(target: RevSpec) -> Self {
444        Self {
445            branch: target,
446            squash: false,
447            no_ff: false,
448        }
449    }
450
451    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`).
452    pub fn squash(mut self) -> Self {
453        self.squash = true;
454        self
455    }
456
457    /// Always record a real (abortable) merge, even when a fast-forward was
458    /// possible (`--no-ff`).
459    pub fn no_ff(mut self) -> Self {
460        self.no_ff = true;
461        self
462    }
463}
464
465/// Options for [`GitApi::tag_create_annotated`] (`git tag -a`).
466///
467/// `#[non_exhaustive]`, so build it through [`AnnotatedTag::new`] and the chained
468/// setter rather than a struct literal.
469#[derive(Debug, Clone)]
470#[non_exhaustive]
471pub struct AnnotatedTag {
472    /// The tag name.
473    pub name: RefName,
474    /// The tag message (`-m`).
475    pub message: String,
476    /// The revision to tag (`<rev>`); `None` tags `HEAD`.
477    pub rev: Option<RevSpec>,
478}
479
480impl AnnotatedTag {
481    /// An annotated tag `name` with `message` at `HEAD`
482    /// (`git tag -a <name> -m <message>`).
483    pub fn new(name: RefName, message: impl Into<String>) -> Self {
484        Self {
485            name,
486            message: message.into(),
487            rev: None,
488        }
489    }
490
491    /// Tag `r` instead of `HEAD`.
492    pub fn rev(mut self, r: RevSpec) -> Self {
493        self.rev = Some(r);
494        self
495    }
496}
497
498/// Options for [`GitApi::delete_branch`] (`git branch -d`/`-D`).
499///
500/// `#[non_exhaustive]`, so build it through [`BranchDelete::new`] and the chained
501/// [`force`](BranchDelete::force) setter rather than a struct literal — a bare
502/// `bool` at the call site (`delete_branch(name, true)`) doesn't say what `true`
503/// means, and this leaves room to add options without a breaking signature change.
504#[derive(Debug, Clone, PartialEq, Eq)]
505#[non_exhaustive]
506pub struct BranchDelete {
507    /// The local branch name to delete.
508    pub name: RefName,
509    /// Delete even if not fully merged — `git branch -D` vs `-d`.
510    pub force: bool,
511}
512
513impl BranchDelete {
514    /// Delete branch `name`; not forced (git refuses an unmerged branch).
515    pub fn new(name: RefName) -> Self {
516        Self { name, force: false }
517    }
518
519    /// Delete even if not fully merged (`-D`).
520    pub fn force(mut self) -> Self {
521        self.force = true;
522        self
523    }
524}
525
526/// Options for [`GitApi::stash_push`] (`git stash push`).
527///
528/// `#[non_exhaustive]`, so build it through [`StashPush::new`] and the chained
529/// [`include_untracked`](StashPush::include_untracked) setter rather than a bare
530/// `bool` (`stash_push(dir, true)` doesn't say what `true` selects).
531#[derive(Debug, Clone, Default, PartialEq, Eq)]
532#[non_exhaustive]
533pub struct StashPush {
534    /// Also stash untracked files (`--include-untracked`).
535    pub include_untracked: bool,
536}
537
538impl StashPush {
539    /// Stash the tracked working-tree changes only.
540    pub fn new() -> Self {
541        Self::default()
542    }
543
544    /// Also stash untracked files (`--include-untracked`).
545    pub fn include_untracked(mut self) -> Self {
546        self.include_untracked = true;
547        self
548    }
549}
550
551/// Options for [`GitApi::worktree_remove`] (`git worktree remove`).
552///
553/// `#[non_exhaustive]`, so build it through [`WorktreeRemove::new`] and the chained
554/// [`force`](WorktreeRemove::force) setter rather than a struct literal — a bare
555/// `bool` (`worktree_remove(path, true)`) doesn't say what `true` means.
556#[derive(Debug, Clone, PartialEq, Eq)]
557#[non_exhaustive]
558pub struct WorktreeRemove {
559    /// The attached worktree path to remove.
560    pub path: PathBuf,
561    /// Remove even when the worktree has uncommitted changes (`--force`).
562    pub force: bool,
563}
564
565impl WorktreeRemove {
566    /// Remove the worktree at `path`; not forced (git refuses a dirty one).
567    pub fn new(path: impl Into<PathBuf>) -> Self {
568        Self {
569            path: path.into(),
570            force: false,
571        }
572    }
573
574    /// Remove even when the worktree has uncommitted changes (`--force`).
575    pub fn force(mut self) -> Self {
576        self.force = true;
577        self
578    }
579}
580
581/// A validated git reference name (branch/tag/remote-tracking ref). Every
582/// [`GitApi`] operation that names a branch, tag, or ref to **create, delete,
583/// rename, or look up by exact name** takes a `RefName` (directly or inside its
584/// options struct), so a name from untrusted input (UIs, bots, agents) is
585/// validated once, at construction, and the type — not an internal guard — is
586/// the argv-injection barrier from then on. For a general commit-ish or range
587/// (`checkout`, `reset_hard`, `log`, `diff` ranges, …) use the more permissive
588/// [`RevSpec`] instead.
589///
590/// Rules follow the load-bearing core of `git check-ref-format`: non-empty,
591/// no leading `-` or `.`, no `..`, no control characters or space, none of
592/// `~ ^ : ? * [ \`, no trailing `/` or `.lock`. A rejected name is an
593/// [`vcs_cli_support::is_invalid_input`] failure.
594#[derive(Debug, Clone, PartialEq, Eq, Hash)]
595pub struct RefName(String);
596
597impl RefName {
598    /// Validate `name` as a reference name.
599    pub fn new(name: impl Into<String>) -> Result<Self> {
600        let name = name.into();
601        let bad = name.is_empty()
602            || name.starts_with('-')
603            || name.starts_with('.')
604            || name.ends_with('/')
605            || name.ends_with(".lock")
606            || name.contains("..")
607            || name
608                .chars()
609                .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
610        if bad {
611            return Err(Error::spawn(
612                BINARY,
613                std::io::Error::new(
614                    std::io::ErrorKind::InvalidInput,
615                    format!("invalid git reference name: {name:?}"),
616                ),
617            ));
618        }
619        Ok(RefName(name))
620    }
621
622    /// The validated name.
623    pub fn as_str(&self) -> &str {
624        &self.0
625    }
626}
627
628impl std::fmt::Display for RefName {
629    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630        f.write_str(&self.0)
631    }
632}
633
634/// A validated revision/range expression (`HEAD~2`, `main..feature`). Every
635/// [`GitApi`] operation that resolves a general **commit-ish or range** takes a
636/// `RevSpec`, so an untrusted revision is validated once, at construction.
637/// Deliberately *minimal* — git's revision grammar is too rich to validate
638/// here — it only guarantees the expression is non-empty and cannot be parsed
639/// as a flag (no leading `-`). For a value that must be a genuine ref **name**
640/// (to create/delete/rename a branch or tag) use the stricter [`RefName`]. A
641/// rejected expression is an [`vcs_cli_support::is_invalid_input`] failure.
642#[derive(Debug, Clone, PartialEq, Eq, Hash)]
643pub struct RevSpec(String);
644
645impl RevSpec {
646    /// Validate `rev` as a revision/range expression (non-empty, no leading `-`).
647    pub fn new(rev: impl Into<String>) -> Result<Self> {
648        let rev = rev.into();
649        reject_flag_like("revision", &rev)?;
650        Ok(RevSpec(rev))
651    }
652
653    /// The validated expression.
654    pub fn as_str(&self) -> &str {
655        &self.0
656    }
657}
658
659impl std::fmt::Display for RevSpec {
660    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661        f.write_str(&self.0)
662    }
663}
664
665impl std::str::FromStr for RefName {
666    type Err = Error;
667    fn from_str(s: &str) -> Result<Self> {
668        Self::new(s)
669    }
670}
671
672impl std::str::FromStr for RevSpec {
673    type Err = Error;
674    fn from_str(s: &str) -> Result<Self> {
675        Self::new(s)
676    }
677}
678
679/// What [`GitApi::checkout`] switches to: a validated ref/revision, or git's `-`
680/// "previous branch" shortcut.
681///
682/// `-` is the one place a leading-`-` token is legitimate — it is git's
683/// `@{-1}` shorthand, not caller-controlled argv — so it is modelled as a
684/// distinct [`Previous`](CheckoutTarget::Previous) variant emitting a fixed
685/// literal, rather than punching a hole in [`RevSpec`]'s no-leading-`-`
686/// invariant (which the other commit-ish operations rely on).
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub enum CheckoutTarget {
689    /// Check out this validated ref or revision.
690    Ref(RevSpec),
691    /// Check out the previous branch (`git checkout -`).
692    Previous,
693}
694
695impl CheckoutTarget {
696    /// Check out a validated ref/revision.
697    pub fn rev(rev: RevSpec) -> Self {
698        Self::Ref(rev)
699    }
700
701    /// Check out the previous branch (`git checkout -`).
702    pub fn previous() -> Self {
703        Self::Previous
704    }
705
706    /// The single argv token this target expands to.
707    fn as_arg(&self) -> &str {
708        match self {
709            Self::Ref(rev) => rev.as_str(),
710            Self::Previous => "-",
711        }
712    }
713}
714
715impl From<RevSpec> for CheckoutTarget {
716    fn from(rev: RevSpec) -> Self {
717        Self::Ref(rev)
718    }
719}
720
721/// What the installed `git` binary supports, probed via
722/// [`GitApi::capabilities`]. A value type — the client holds no state, so
723/// probe once and keep the result (callers cache it).
724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725#[non_exhaustive]
726pub struct GitCapabilities {
727    /// The binary's parsed version.
728    pub version: GitVersion,
729}
730
731/// The oldest git this crate is written against — **2.31**, the highest version its
732/// own argv actually requires (validated on 2.54). `harden()` pins config through
733/// `GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n` (added in **2.31**); `branch_status`/`snapshot`
734/// read `status --porcelain=v2` (2.11) and `switch_with_stash` uses `stash push` (2.13),
735/// all below 2.31. Gating on the real minor floor makes [`ensure_supported`] catch a
736/// too-old git with a clear message rather than letting it pass and then fail later with
737/// a cryptic argv error — the M29 fix (the previous gate was major-only, so 2.7 "passed"
738/// then broke). (Contrast vcs-jj, whose floor is precise per its empirically-validated
739/// parser release.)
740const MIN_SUPPORTED_MAJOR: u64 = 2;
741const MIN_SUPPORTED_MINOR: u64 = 31;
742
743impl GitCapabilities {
744    /// Whether the binary meets the supported floor (git ≥ 2.31).
745    pub fn is_supported(&self) -> bool {
746        (self.version.major, self.version.minor) >= (MIN_SUPPORTED_MAJOR, MIN_SUPPORTED_MINOR)
747    }
748
749    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs git
750    /// ≥ 2.31, found 2.7.4" instead of a cryptic argv failure later.
751    pub fn ensure_supported(&self) -> Result<()> {
752        if self.is_supported() {
753            return Ok(());
754        }
755        Err(Error::spawn(
756            BINARY,
757            std::io::Error::new(
758                std::io::ErrorKind::Unsupported,
759                format!(
760                    "vcs-git requires git >= {MIN_SUPPORTED_MAJOR}.{MIN_SUPPORTED_MINOR} \
761                     (validated on 2.54), found {}",
762                    self.version
763                ),
764            ),
765        ))
766    }
767}
768
769/// The Git operations this crate exposes — the interface consumers code against
770/// and mock in tests.
771///
772/// **Injection safety:** reference names and revision expressions are taken as
773/// the validated [`RefName`] / [`RevSpec`] newtypes (directly or inside an
774/// options struct), so a flag-like or malformed value is rejected at
775/// construction, before it can reach an argv slot. The remaining
776/// caller-supplied bare positionals that are *not* refs/revisions — remote
777/// names and URLs — keep an internal `reject_flag_like` guard: a value that is
778/// empty or begins with `-` is rejected with an [`Error::Spawn`] *before*
779/// spawning. Flag-value slots (`-m <msg>`, `--branch <b>`), filesystem path
780/// arguments (`--`-separated pathspecs, plus worktree paths and clone
781/// destinations — typed `Path`, caller-trusted), and the `run`/`run_raw`
782/// escape hatches are not guarded. The one context-dependent special value,
783/// git's `-` "previous branch", is [`CheckoutTarget::Previous`].
784#[cfg_attr(feature = "mock", mockall::automock)]
785#[async_trait::async_trait]
786pub trait GitApi: Send + Sync {
787    /// Run `git <args>` **in the process's current directory**, returning trimmed
788    /// stdout (throws on a non-zero exit). A raw escape hatch for unmodelled commands
789    /// — you supply the whole argv, so target a specific repo with `-C <dir>` in the
790    /// args. This method on the client is the **process-cwd** escape hatch; the
791    /// `at(dir)` bound view's [`run`](GitAt::run) is instead **bound to `dir`** (it
792    /// forwards to [`Git::run_in`], so `git.at(dir).run(…)` runs in the bound repo).
793    /// Use `git.at(dir).run(…)` (or [`Git::run_in`]) for the bound repo; use this for
794    /// the process cwd (T-035, was M15).
795    async fn run(&self, args: &[String]) -> Result<String>;
796    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
797    /// captured [`ProcessResult`].
798    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
799    /// Installed Git version (`git --version`).
800    async fn version(&self) -> Result<String>;
801    /// The installed binary's parsed version, as [`GitCapabilities`]
802    /// (`git --version`). A value type — probe once and keep it; an
803    /// unrecognisable version string is an [`Error::Parse`].
804    async fn capabilities(&self) -> Result<GitCapabilities>;
805    /// Working-tree status (`git status --porcelain=v1 -z`).
806    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
807    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
808    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
809    async fn status_text(&self, dir: &Path) -> Result<String>;
810    /// Like [`status`](GitApi::status) but ignoring untracked files
811    /// (`git status --porcelain=v1 -z --untracked-files=no`) — "is the *tracked*
812    /// tree dirty", staged or not.
813    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
814    /// A combined branch + working-tree snapshot in **one** spawn
815    /// (`git status --porcelain=v2 --branch -z`): HEAD, branch, upstream,
816    /// ahead/behind, and change counts — the data a prompt/status-bar needs
817    /// without N round-trips. See [`BranchStatus`].
818    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
819    /// Paths with unresolved merge conflicts, repo-relative with `/` separators
820    /// (`git diff --name-only --diff-filter=U -z`). Empty when there are none.
821    /// Returns [`PathBuf`]s built from the raw `-z` bytes, so a non-UTF-8
822    /// conflicted path survives losslessly.
823    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>>;
824    /// Current branch name, or `None` on a **detached HEAD**
825    /// (`git symbolic-ref --quiet --short HEAD`). Returns the branch name for a
826    /// normal branch **and for an unborn branch** (a fresh `init`/`clone` before the
827    /// first commit); `None` only when HEAD is detached. Mirrors
828    /// [`JjApi::current_bookmark`](../vcs_jj/trait.JjApi.html#tymethod.current_bookmark)'s
829    /// `Option` shape, so cross-backend code treats "no named branch/bookmark" the
830    /// same way on both wrappers.
831    async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
832    /// Local branches, current one flagged (`git branch`).
833    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
834    /// Up to `max` commits reachable from `revspec`, newest first
835    /// (`git log <revspec>`). Pass `"HEAD"` for the current branch's history, or
836    /// a range like `"main..HEAD"` / `"origin/main..HEAD"` to scope it. Mirrors
837    /// [`JjApi::log`](../vcs_jj/trait.JjApi.html#tymethod.log)'s revset argument,
838    /// so cross-backend code uses one signature. The `revspec` is a validated
839    /// [`RevSpec`], so it can never be parsed as a flag.
840    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
841    /// Like [`log`](GitApi::log), but scoped to commits that touched `paths`
842    /// (`git --literal-pathspecs log <revspec> -n <max> -- <paths>`) — e.g. "who
843    /// changed this module". `--literal-pathspecs` matches a path containing
844    /// `*`/`?`/`[]` literally rather than as pathspec glob magic (R-02); the `--`
845    /// separator keeps a path from being read as a flag (same convention as
846    /// [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths)). An empty
847    /// `paths` is refused *before spawning*: silently falling back to
848    /// [`log`](GitApi::log)'s unrestricted history would defeat the "scoped to
849    /// these paths" contract. Mirrors
850    /// [`JjApi::log_paths`](../vcs_jj/trait.JjApi.html#tymethod.log_paths), which
851    /// takes filesets instead of pathspecs.
852    ///
853    /// Unlike [`add`](GitApi::add)/[`commit_paths`](GitApi::commit_paths), git's
854    /// `log` has no `--pathspec-from-file` support, so a `paths` set that would
855    /// risk exceeding the OS argv limit is instead split into multiple `git log`
856    /// calls, each within budget; the per-call results are merged (deduplicated
857    /// by hash — a commit can touch paths spread across more than one chunk)
858    /// and restored to git's own commit order using a separate, pathless `git
859    /// log <revspec> --format=%H` oracle call (T-052/R-03): pathspec filtering
860    /// only drops non-matching commits, it never reorders the ones that
861    /// remain, so the oracle's order — over the *same* revspec, unrestricted
862    /// by paths — gives the exact relative order a single, hypothetical
863    /// unchunked call would have produced. Unlike sorting by a parsed date
864    /// field, this needs no assumption about author-vs-committer timestamps or
865    /// same-second ties (git log dates have no sub-second precision). A
866    /// `paths` set that fits in one call is unaffected — same single
867    /// invocation, same order, as before.
868    ///
869    /// Before any of that, `revspec` (which may be symbolic, e.g. `HEAD`, or a
870    /// range, e.g. `main..feature`) is resolved exactly once via `git
871    /// rev-parse` into a fixed set of commit ids that every chunk call and the
872    /// oracle call then reuse verbatim (T-052/R-04): without this, each of
873    /// those several independent invocations would re-resolve the same
874    /// symbolic text on its own, so a concurrent commit/reset/ref-move landing
875    /// between any two of them could make them see different repository
876    /// snapshots, silently omitting a newer matching commit, including one no
877    /// longer reachable, or interleaving two different histories into the
878    /// merged result. This resolution only happens on the chunked path (more
879    /// than one invocation); the single-call fast path is unaffected. Also
880    /// before any of that, every individual path is checked against the argv
881    /// budget on its own (T-052/R-05): `git log` has no NUL-safe transport to
882    /// fall back to the way `add`/`commit_paths` do, so a single path that by
883    /// itself cannot fit in argv is rejected up front with a clear error
884    /// rather than silently forwarded as an over-budget singleton chunk.
885    async fn log_paths(
886        &self,
887        dir: &Path,
888        revspec: &RevSpec,
889        max: usize,
890        paths: &[String],
891    ) -> Result<Vec<Commit>>;
892    /// Resolve a revision to a full hash (`git rev-parse --verify <rev>`). `--verify`
893    /// requires `rev` to name exactly one object, so a non-revision (e.g. a filename)
894    /// errors instead of being echoed back as a fake id.
895    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
896    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
897    /// e.g. to label a detached HEAD.
898    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
899    /// Initialise a repository (`git init`).
900    async fn init(&self, dir: &Path) -> Result<()>;
901    /// Stage `paths` (`git --literal-pathspecs add -- <paths>`) —
902    /// `--literal-pathspecs` applies regardless of path-set size, so a path
903    /// containing `*`/`?`/`[]` always matches literally rather than as pathspec
904    /// glob magic (R-01). A path set whose combined length would risk exceeding
905    /// the OS command-line limit (`ARGV_PATHSPEC_BUDGET`; Windows' `CreateProcess`
906    /// caps out around 32,767 characters) instead goes over stdin via
907    /// `--pathspec-from-file=- --pathspec-file-nul` — the paths never touch argv
908    /// at all in that case, so there is no upper bound left to exceed (T-052).
909    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
910    /// Commit staged changes (`git commit -m`).
911    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
912    /// Create a branch without switching to it (`git branch <name>`).
913    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()>;
914    /// Switch to a branch/revision, or the previous branch (`git checkout
915    /// <target>`); see [`CheckoutTarget`].
916    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()>;
917    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
918    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()>;
919    /// Commit exactly the spec's paths' working-tree content, ignoring the index
920    /// (`git --literal-pathspecs commit [--amend] -m <message> --only -- <paths>`);
921    /// see [`CommitPaths`]. `--literal-pathspecs` applies regardless of path-set
922    /// size, so a glob-magic character (`*`/`?`/`[]`) in a path is matched
923    /// literally rather than expanded — otherwise `commit_paths`'s "exactly these
924    /// paths" contract could be violated (R-01). Like [`add`](GitApi::add), a
925    /// path set that would risk exceeding the OS argv limit is instead routed
926    /// over stdin (`--pathspec-from-file=- --pathspec-file-nul`) — always as a
927    /// **single** `git commit` invocation either way, so the one-atomic-commit
928    /// contract is unaffected by the path set's size (T-052).
929    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
930    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
931    /// pre-fill an amend.
932    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
933    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
934    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
935    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
936    /// Whether the working tree has no unstaged modifications to **tracked** files
937    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
938    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
939    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
940
941    // --- Discovery / identity ------------------------------------------------
942
943    /// The repository's common git directory (`rev-parse --git-common-dir`) —
944    /// stable across linked worktrees.
945    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
946    /// This worktree's git directory (`rev-parse --git-dir`).
947    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
948    /// Resolve a revision to a commit hash, peeling tags
949    /// (`rev-parse --verify <rev>^{commit}`).
950    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
951    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
952    /// (short name only); `None` when `origin/HEAD` is unset.
953    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
954    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
955    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
956    /// Whether `origin` has `name`, without fetching (`ls-remote origin
957    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
958    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
959    /// credential or a flaky network can't hang the call.
960    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
961    /// A remote's URL (`remote get-url <remote>`).
962    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
963    /// The current attached branch's upstream, e.g. `Some("origin/main")`
964    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
965    /// A detached HEAD or a directory outside a repository is an error.
966    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
967    /// Branch names on `remote`, without fetching
968    /// (`ls-remote --heads <remote>`).
969    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
970
971    // --- Branches ------------------------------------------------------------
972
973    /// Whether the [`MergeCheck`]'s `branch` is fully merged into its `base`
974    /// (`branch --merged <base>`). Build it as
975    /// `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)`
976    /// so the two refs can't be transposed (a swap would invert the answer).
977    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
978    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
979    /// (`branch --set-upstream-to=<upstream> <branch>`).
980    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()>;
981    /// Delete a local branch (`branch -d`, or `-D` when forced); see [`BranchDelete`].
982    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()>;
983    /// Rename a local branch (`branch -m <old> <new>`).
984    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()>;
985    /// Count commits in a range (`rev-list --count <range>`).
986    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize>;
987    /// Whether a diff range is empty (`diff --quiet <range>`).
988    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool>;
989    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
990    /// match `vcs_jj::JjApi::diff_stat`.
991    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat>;
992    /// Raw git-format unified diff text for `spec`
993    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output, returned
994    /// **verbatim** (a trailing blank context line is preserved, so the last hunk
995    /// stays in sync with its `@@` line count for a re-parse/re-apply).
996    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
997    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
998    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
999
1000    // --- In-progress state ---------------------------------------------------
1001
1002    /// Whether the index has no staged changes (`diff --cached --quiet`).
1003    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
1004    /// Whether a rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
1005    /// **not** left by `git am`, exists under the git dir).
1006    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
1007    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
1008    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
1009    /// Whether a `git am` (mailbox apply) is in progress (`rebase-apply/applying`).
1010    /// Distinct from a rebase, which shares the `rebase-apply` dir but without the
1011    /// `applying` marker — aborting an am needs `am --abort`, not `rebase --abort`.
1012    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
1013    /// Whether a cherry-pick is in progress (`CHERRY_PICK_HEAD` under the git dir).
1014    /// A cherry-pick conflict writes `CHERRY_PICK_HEAD`, **not** `MERGE_HEAD`, so
1015    /// this is distinct from a merge and is aborted/continued with
1016    /// `cherry-pick --abort` / `--continue`, not `merge --abort`.
1017    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool>;
1018    /// Whether a revert is in progress (`REVERT_HEAD` under the git dir). Like a
1019    /// cherry-pick, a revert conflict writes its own head file, not `MERGE_HEAD`;
1020    /// it is driven with `revert --abort` / `--continue`.
1021    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool>;
1022    /// Whether a `git bisect` session is in progress (`BISECT_LOG` under the git
1023    /// dir). Ended with `bisect reset` (there is no `--continue`).
1024    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool>;
1025
1026    // --- Mutations -----------------------------------------------------------
1027
1028    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
1029    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
1030    async fn fetch(&self, dir: &Path) -> Result<()>;
1031    /// Fetch from a *named* remote (`fetch --quiet <remote>`), with
1032    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried like
1033    /// [`fetch`](GitApi::fetch).
1034    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
1035    /// Fetch a single branch from `origin` into its remote-tracking ref
1036    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
1037    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
1038    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()>;
1039    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
1040    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
1041    /// Stage a branch's changes without committing (`merge --squash <branch>`).
1042    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()>;
1043    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
1044    /// message it takes the default merge message non-interactively (`--no-edit`).
1045    /// See [`MergeCommit`].
1046    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
1047    /// Merge a branch but stop before committing, so the result can be inspected
1048    /// (`merge --no-commit [--squash | --no-ff] <branch>`). With `no_ff` (and not
1049    /// `squash`) git records `MERGE_HEAD`, so the in-progress merge is abortable
1050    /// via [`merge_abort`](GitApi::merge_abort) — the dry-run pattern. With
1051    /// `squash`, git stages the squashed result but records **no** `MERGE_HEAD`,
1052    /// so it is *not* an abortable merge: undo it with
1053    /// [`reset_merge`](GitApi::reset_merge) / [`reset_hard`](GitApi::reset_hard),
1054    /// not `merge_abort`. See [`MergeNoCommit`].
1055    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
1056    /// Abort an in-progress merge (`merge --abort`).
1057    async fn merge_abort(&self, dir: &Path) -> Result<()>;
1058    /// Finish a merge after resolving conflicts (`commit --no-edit`).
1059    async fn merge_continue(&self, dir: &Path) -> Result<()>;
1060    /// Undo an in-progress (or just-staged) merge: `reset --merge` resets the
1061    /// index and the merge-touched working-tree files back to `HEAD` and drops
1062    /// `MERGE_HEAD`, **discarding the merge's changes** while keeping unrelated
1063    /// unstaged edits. Use it after `merge_squash` / `merge_no_commit(squash)`,
1064    /// where there is no `MERGE_HEAD` for `merge_abort` to act on.
1065    async fn reset_merge(&self, dir: &Path) -> Result<()>;
1066    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
1067    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1068    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
1069    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
1070    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()>;
1071    /// Abort an in-progress rebase (`rebase --abort`).
1072    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
1073    /// Abort an in-progress `git am` (`am --abort`), restoring the pre-`am` HEAD.
1074    async fn am_abort(&self, dir: &Path) -> Result<()>;
1075    /// Continue a `git am` after resolving conflicts (`am --continue`); the editor
1076    /// is suppressed (`GIT_EDITOR=true`) so the patch's message-confirm never hangs
1077    /// a headless caller. On a multi-patch mailbox it can stop again on the next
1078    /// patch's conflict (exit non-zero) — a conflict, not a hard error, like the
1079    /// other sequencer `--continue`s.
1080    async fn am_continue(&self, dir: &Path) -> Result<()>;
1081    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
1082    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
1083    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
1084    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
1085    /// e.g. to save state before a copy-on-write restore. See [`StashPush`].
1086    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()>;
1087    /// Restore the most recent stash and drop it (`stash pop`).
1088    async fn stash_pop(&self, dir: &Path) -> Result<()>;
1089
1090    // --- Worktrees -----------------------------------------------------------
1091
1092    /// List worktrees (`worktree list --porcelain`).
1093    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
1094    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
1095    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
1096    /// Remove a worktree (`worktree remove [--force] <path>`); see [`WorktreeRemove`].
1097    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()>;
1098    /// Move a worktree (`worktree move <from> <to>`).
1099    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
1100    /// Prune stale worktree admin entries (`worktree prune`).
1101    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
1102
1103    // --- Clone / tags / inspection --------------------------------------------
1104
1105    /// Clone `url` into `dest` (`git clone <url> <dest>` + [`CloneSpec`] flags).
1106    /// Runs without a working directory — pass an **absolute** `dest`.
1107    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
1108    /// Create a lightweight tag at `rev` (`tag <name> [<rev>]`; `None` = HEAD).
1109    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()>;
1110    /// Create an annotated tag (`tag -a <name> -m <message> [<rev>]`); see
1111    /// [`AnnotatedTag`].
1112    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
1113    /// Tag names, sorted by git's default ordering (`tag --list`).
1114    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
1115    /// Delete a tag (`tag -d <name>`).
1116    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()>;
1117    /// A file's content at a revision (`git show <rev>:<path>`). `path` is
1118    /// repo-relative; backslashes are normalised to `/` (git requires it).
1119    /// Content is decoded **lossily** — binary files come back mangled rather
1120    /// than erroring — and returned **verbatim**: the blob's trailing newline(s)
1121    /// are preserved (not trimmed), so a read-modify-write round-trip is byte-exact.
1122    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String>;
1123    /// The value of a config key, or `None` when unset (`config --get <key>`,
1124    /// whose exit 1 covers both "unset" and "no such section" — git doesn't
1125    /// distinguish). A multi-valued key errors; read those via `run`.
1126    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
1127    /// Set a config key in the repository's local config (`config <key> <value>`).
1128    ///
1129    /// **Trusted-input sink.** `key` is guarded against a flag-shape, but this
1130    /// writes whatever key/value it's given — including code-execution keys like
1131    /// `core.sshCommand` or `filter.<drv>.clean`. Never wire untrusted input into
1132    /// it; a `harden()`ed client does *not* protect against config *you* write.
1133    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
1134    /// Add a remote (`remote add <name> <url>`).
1135    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
1136    /// Change a remote's URL (`remote set-url <name> <url>`).
1137    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
1138    /// Per-line authorship of `path` (`blame --line-porcelain [<rev>] -- <path>`;
1139    /// `None` = the working tree's HEAD).
1140    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
1141
1142    // --- Sequencer -------------------------------------------------------------
1143
1144    /// Apply a commit onto the current branch (`cherry-pick <rev>`). A conflict
1145    /// surfaces as an error classified by [`is_merge_conflict`].
1146    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1147    /// Revert a commit with the default message (`revert --no-edit <rev>`).
1148    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
1149    /// Skip the current patch of a paused rebase (`rebase --skip`). Mainly for
1150    /// the `apply` backend's "nothing to commit" stop — the default `merge`
1151    /// backend auto-drops emptied patches on `--continue`.
1152    async fn rebase_skip(&self, dir: &Path) -> Result<()>;
1153    /// Abort an in-progress cherry-pick (`cherry-pick --abort`), restoring the
1154    /// pre-cherry-pick state.
1155    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()>;
1156    /// Continue a cherry-pick after resolving conflicts (`cherry-pick --continue`);
1157    /// the editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never
1158    /// hangs a headless caller. On a multi-commit pick it can stop again on the
1159    /// next commit's conflict (exit non-zero) — a conflict, not a hard error.
1160    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()>;
1161    /// Abort an in-progress revert (`revert --abort`), restoring the pre-revert
1162    /// state.
1163    async fn revert_abort(&self, dir: &Path) -> Result<()>;
1164    /// Continue a revert after resolving conflicts (`revert --continue`); the
1165    /// editor is suppressed like [`cherry_pick_continue`](GitApi::cherry_pick_continue),
1166    /// and it too can stop on the next commit's conflict.
1167    async fn revert_continue(&self, dir: &Path) -> Result<()>;
1168    /// End a `git bisect` session (`bisect reset`), returning to the branch/commit
1169    /// that was checked out before it started. This is the "abort" for a bisect;
1170    /// bisect has no `--continue`.
1171    async fn bisect_reset(&self, dir: &Path) -> Result<()>;
1172}
1173
1174vcs_cli_support::managed_client! {
1175    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject a
1176    /// fake process executor; [`Git::new`] uses the real job-backed runner.
1177    ///
1178    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient): enable lock-contention retry with
1179    /// [`with_retry`](Git::with_retry) (opt-in; off by default).
1180    ///
1181    /// **Every** client (not just [`hardened`](Git::hardened)) scrubs the inherited
1182    /// repo-**redirector** environment variables below, so a `GIT_DIR` (etc.) leaking
1183    /// from the parent process — e.g. running inside a git hook, which exports
1184    /// `GIT_DIR`/`GIT_INDEX_FILE` — can't silently redirect commands at a *different*
1185    /// repository than the bound `dir`. (`harden()` additionally scrubs the
1186    /// command-hook vars and pins hooks/fsmonitor/sshCommand off.)
1187    pub struct Git => BINARY, scrub_env = [
1188        "GIT_DIR",
1189        "GIT_WORK_TREE",
1190        "GIT_INDEX_FILE",
1191        "GIT_COMMON_DIR",
1192        "GIT_OBJECT_DIRECTORY",
1193        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1194        "GIT_NAMESPACE",
1195    ]
1196}
1197
1198impl<R: ProcessRunner> Git<R> {
1199    /// Retry **whole-repo lock-contention** failures (another process holds the
1200    /// repo's `index.lock`) per `policy` — opt-in, off by default. Safe even for
1201    /// mutating commands: that lock is acquired before any write, so a failure is
1202    /// pre-execution (git never ran) and a retry can't double-apply. Per-ref lock
1203    /// failures are *not* retried (a multi-ref op can fail a ref lock mid-way). See
1204    /// [`RetryPolicy`] and [`is_lock_contention`].
1205    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
1206        self.core = self.core.with_retry(policy);
1207        self
1208    }
1209
1210    /// Supply credentials for **HTTPS** remote operations (`fetch`/`push`/`clone`/
1211    /// `ls-remote`) via a [`CredentialProvider`] — opt-in, off by default (ambient
1212    /// git credential helpers / SSH agent). When the provider yields a credential,
1213    /// each remote op runs with an inline `credential.helper` that feeds the secret
1214    /// from an environment variable, so the token never appears in `argv`. Local
1215    /// operations are unaffected. This covers HTTPS only — an **SSH** remote ignores
1216    /// the helper and authenticates via the ambient SSH agent, as before.
1217    #[must_use]
1218    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1219        self.core = self.core.with_credentials(provider);
1220        self
1221    }
1222
1223    /// Convenience for the common case: authenticate HTTPS remotes with a single
1224    /// static `token` (a personal-access token; the default username
1225    /// `x-access-token` is used). Shorthand for
1226    /// `with_credentials(Arc::new(StaticCredential::token(token)))`. For a specific
1227    /// username, build a [`Credential::userpass`] and use
1228    /// [`with_credentials`](Git::with_credentials).
1229    #[must_use]
1230    pub fn with_token(self, token: impl Into<Secret>) -> Self {
1231        self.with_credentials(Arc::new(StaticCredential::token(token)))
1232    }
1233
1234    /// Convenience: read the HTTPS token from environment variable `var` at request
1235    /// time; if `var` is unset/empty, fall back to ambient auth. Shorthand for
1236    /// `with_credentials(Arc::new(EnvToken::new(var)))`.
1237    #[must_use]
1238    pub fn with_env_token(self, var: impl Into<String>) -> Self {
1239        self.with_credentials(Arc::new(EnvToken::new(var)))
1240    }
1241
1242    /// Resolve HTTPS credentials for a remote op into the leading `-c` config args
1243    /// (an inline `credential.helper`) and the secret env to set on the command.
1244    /// Both are empty when no provider is configured — ambient git auth, unchanged.
1245    /// The secret lives only in the returned env, never in the args.
1246    ///
1247    /// `expect_host` scopes the helper to a host (the secret is released only for
1248    /// that host, so a redirect/submodule to another host can't extract it).
1249    /// Callers that know the operation's target host — e.g. `clone` from its URL —
1250    /// pass it; the others pass `None` (the helper is ungated, as before).
1251    ///
1252    /// The same `expect_host` is **also** passed as the [`CredentialRequest`]'s host,
1253    /// so a **host-keyed** provider selects the secret for that host — one `Git`
1254    /// client cloning several hosts draws each host's own token, never a
1255    /// neighbour's. A `None` host lets such a provider defer to ambient auth rather
1256    /// than hand back the wrong host's secret (the fail-closed / ambient policy is
1257    /// on [`ManagedClient::resolve_credential`](vcs_cli_support::ManagedClient::resolve_credential)).
1258    async fn remote_credentials(
1259        &self,
1260        expect_host: Option<&str>,
1261    ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
1262        match self
1263            .core
1264            .resolve_credential(CredentialService::Git, expect_host)
1265            .await?
1266        {
1267            Some(cred) => {
1268                let helper = git_credential_helper(&cred, expect_host);
1269                Ok((helper.config_args, helper.env))
1270            }
1271            None => Ok((Vec::new(), Vec::new())),
1272        }
1273    }
1274}
1275
1276impl<R: ProcessRunner> Git<R> {
1277    /// [`diff_text`](GitApi::diff_text) with an explicit per-call
1278    /// [`OutputBudget`], instead of this client's
1279    /// [`default_output_budget`](Git::default_output_budget). Use it to read a
1280    /// legitimately large diff past a tighter client default
1281    /// ([`OutputBudget::unlimited`], or a higher byte cap), or to tighten the cap
1282    /// for one call. Past the ceiling the read errors with
1283    /// [`Error::OutputTooLarge`] (actual and
1284    /// allowed sizes) rather than buffering an unbounded diff.
1285    pub async fn diff_text_within(
1286        &self,
1287        dir: &Path,
1288        spec: DiffSpec,
1289        budget: OutputBudget,
1290    ) -> Result<String> {
1291        self.diff_text_budgeted(dir, spec, budget).await
1292    }
1293
1294    /// [`diff`](GitApi::diff) with an explicit per-call [`OutputBudget`] — the
1295    /// parsed-model counterpart of [`diff_text_within`](Git::diff_text_within).
1296    pub async fn diff_within(
1297        &self,
1298        dir: &Path,
1299        spec: DiffSpec,
1300        budget: OutputBudget,
1301    ) -> Result<Vec<FileDiff>> {
1302        let text = self.diff_text_budgeted(dir, spec, budget).await?;
1303        Ok(parse_diff(&text))
1304    }
1305
1306    /// Shared body of [`diff_text`](GitApi::diff_text) /
1307    /// [`diff_text_within`](Git::diff_text_within): builds the `git diff` and runs
1308    /// it under `budget` (a fail-loud byte ceiling; unbounded when the budget is
1309    /// [`OutputBudget::unlimited`]).
1310    async fn diff_text_budgeted(
1311        &self,
1312        dir: &Path,
1313        spec: DiffSpec,
1314        budget: OutputBudget,
1315    ) -> Result<String> {
1316        // The target is a single positional arg: `HEAD` for the working tree, or
1317        // the caller's revision/range. `-M` enables rename detection; `--no-color`
1318        // / `--no-ext-diff` keep the output stable and machine-parseable.
1319        let target = match spec {
1320            DiffSpec::WorkingTree => {
1321                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
1322                // diff against the empty tree so a pre-first-commit working tree
1323                // still yields its additions instead of a hard failure. The empty
1324                // tree's id depends on the repo's object format (the SHA-1
1325                // `EMPTY_TREE_SHA1` doesn't exist in a SHA-256 repo), so resolve it
1326                // from git rather than hard-coding — see `empty_tree_oid`.
1327                if self.is_unborn(dir).await? {
1328                    self.empty_tree_oid(dir).await?
1329                } else {
1330                    "HEAD".to_string()
1331                }
1332            }
1333            DiffSpec::Rev(rev) => {
1334                reject_flag_like("revision", &rev)?;
1335                rev
1336            }
1337        };
1338        // The explicit prefixes pin the `a/`…`b/` form the shared parser extracts
1339        // paths from — a user's `diff.noprefix` / `diff.mnemonicPrefix` config
1340        // would otherwise change the headers and make every file silently vanish
1341        // from the parse. (Command-line prefixes override both config options.)
1342        // `run_untrimmed_within`: trimming the diff would drop a trailing blank
1343        // context line, desyncing the last hunk from its `@@` line count for a
1344        // consumer that re-applies or re-parses it (H7); the budget bounds it.
1345        // Trailing `--`: pin `target` as a revision, never a pathspec — without it
1346        // a `Rev` that happens to name a tracked path would diff the working tree
1347        // for that path instead of the intended commit (the C2/M13 collision
1348        // class). `reject_flag_like` already blocks a leading `-`; `--` closes the
1349        // path-collision half.
1350        self.core
1351            .run_untrimmed_within(
1352                self.core.command_in(
1353                    dir,
1354                    [
1355                        "diff",
1356                        target.as_str(),
1357                        "--no-color",
1358                        "--no-ext-diff",
1359                        "-M",
1360                        "--src-prefix=a/",
1361                        "--dst-prefix=b/",
1362                        "--",
1363                    ],
1364                ),
1365                budget,
1366            )
1367            .await
1368    }
1369
1370    /// [`show_file`](GitApi::show_file) with an explicit per-call
1371    /// [`OutputBudget`], instead of this client's
1372    /// [`default_output_budget`](Git::default_output_budget). Reads a blob's bytes
1373    /// under `budget`: past the ceiling the read errors with
1374    /// [`Error::OutputTooLarge`] rather than
1375    /// buffering an unbounded file.
1376    pub async fn show_file_within(
1377        &self,
1378        dir: &Path,
1379        rev: &RevSpec,
1380        path: &str,
1381        budget: OutputBudget,
1382    ) -> Result<String> {
1383        let rev = rev.as_str();
1384        // git rejects backslash separators in the `<rev>:<path>` spec ("exists on
1385        // disk, but not in <rev>") — normalise for Windows callers. Only on Windows:
1386        // on Unix a backslash is a legal filename byte, and rewriting it would make
1387        // a literal `a\b.txt` unresolvable.
1388        #[cfg(windows)]
1389        let path = path.replace('\\', "/");
1390        let spec = format!("{rev}:{path}");
1391        // `run_untrimmed_within`: a blob's trailing newline(s) are part of its
1392        // content — trimming them corrupts a read-modify-write round-trip (H7); the
1393        // budget bounds it.
1394        self.core
1395            .run_untrimmed_within(self.core.command_in(dir, ["show", spec.as_str()]), budget)
1396            .await
1397    }
1398}
1399
1400/// Set each secret environment variable on `cmd` (the values from
1401/// [`Git::remote_credentials`]). A no-op when `envs` is empty.
1402fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
1403    envs.iter()
1404        .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
1405}
1406
1407#[async_trait::async_trait]
1408impl<R: ProcessRunner> GitApi for Git<R> {
1409    async fn run(&self, args: &[String]) -> Result<String> {
1410        self.core.run(args).await
1411    }
1412
1413    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1414        self.core.output_string(args).await
1415    }
1416
1417    async fn version(&self) -> Result<String> {
1418        self.core.run(["--version"]).await
1419    }
1420
1421    async fn capabilities(&self) -> Result<GitCapabilities> {
1422        let raw = self.version().await?;
1423        let version = parse::parse_git_version(&raw).ok_or_else(|| {
1424            Error::parse(
1425                BINARY,
1426                format!("unrecognisable `git --version` output: {raw:?}"),
1427            )
1428        })?;
1429        Ok(GitCapabilities { version })
1430    }
1431
1432    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1433        // `parse_bytes`: `-z` paths are raw bytes that may not be valid UTF-8 on
1434        // Unix, so parse from the byte stream — a lossy `String` decode would
1435        // corrupt a non-ASCII/non-UTF-8 filename before it reaches the caller.
1436        self.core
1437            .parse_bytes(
1438                self.core
1439                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1440                parse::parse_porcelain,
1441            )
1442            .await
1443    }
1444
1445    async fn status_text(&self, dir: &Path) -> Result<String> {
1446        self.core
1447            .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1448            .await
1449    }
1450
1451    async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1452        // `GIT_OPTIONAL_LOCKS=0`: skip the opportunistic index refresh-write a
1453        // `status` may otherwise persist. This is the snapshot/poll primitive —
1454        // a filesystem watcher re-querying through it must not have the query
1455        // itself dirty `.git/index` and re-trigger the watch (verified: with
1456        // optional locks off, a re-query writes nothing).
1457        self.core
1458            .parse(
1459                self.core
1460                    .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1461                    .env("GIT_OPTIONAL_LOCKS", "0"),
1462                parse::parse_porcelain_v2,
1463            )
1464            .await
1465    }
1466
1467    async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1468        self.core
1469            .parse_bytes(
1470                self.core.command_in(
1471                    dir,
1472                    ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1473                ),
1474                parse::parse_porcelain,
1475            )
1476            .await
1477    }
1478
1479    async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>> {
1480        // `-z` keeps special-character paths literal (no C-style quoting); parse
1481        // from raw bytes so a non-UTF-8 conflicted path survives losslessly.
1482        self.core
1483            .parse_bytes(
1484                self.core
1485                    .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1486                parse::parse_nul_paths,
1487            )
1488            .await
1489    }
1490
1491    async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1492        // `symbolic-ref --quiet --short HEAD` is the one command that answers all
1493        // three head states correctly in a single spawn: it prints the branch name
1494        // (exit 0) for a normal **and an unborn** branch (a fresh `init`/`clone`
1495        // before the first commit — where `rev-parse --abbrev-ref HEAD` instead
1496        // *errors* with exit 128), and `--quiet` makes a detached HEAD a silent
1497        // exit 1 (HEAD isn't a symbolic ref) rather than a `fatal:`. So map exit
1498        // 0 → `Some(branch)`, exit 1 → `None` (detached), and anything else (e.g.
1499        // not a repository, exit 128) stays a real error.
1500        let res = self
1501            .core
1502            .output_string(
1503                self.core
1504                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1505            )
1506            .await?;
1507        match res.code() {
1508            Some(0) => Ok(Some(res.stdout().trim().to_string())),
1509            Some(1) => Ok(None), // detached HEAD: no named branch
1510            _ => {
1511                let _ = res.ensure_success()?;
1512                Ok(None) // unreachable: a non-zero exit always errors above
1513            }
1514        }
1515    }
1516
1517    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1518        // `--no-column` + `--no-color`: `column.ui = always` would columnate
1519        // several names onto one line and `color.{ui,branch} = always` would inject
1520        // ANSI escapes — both even when piped, corrupting the line parser and the
1521        // returned names.
1522        self.core
1523            .parse(
1524                self.core
1525                    .command_in(dir, ["branch", "--no-column", "--no-color"]),
1526                parse::parse_branches,
1527            )
1528            .await
1529    }
1530
1531    async fn log(&self, dir: &Path, revspec: &RevSpec, max: usize) -> Result<Vec<Commit>> {
1532        let n = format!("-n{max}");
1533        self.core
1534            .parse(
1535                self.core.command_in(
1536                    dir,
1537                    [
1538                        "log",
1539                        revspec.as_str(),
1540                        n.as_str(),
1541                        "-z",
1542                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1543                    ],
1544                ),
1545                parse::parse_log,
1546            )
1547            .await
1548    }
1549
1550    async fn log_paths(
1551        &self,
1552        dir: &Path,
1553        revspec: &RevSpec,
1554        max: usize,
1555        paths: &[String],
1556    ) -> Result<Vec<Commit>> {
1557        // An empty `paths` would build `git log <revspec> -n <max> -- ` — no
1558        // pathspecs after `--` is the same as no `--` at all, i.e. an
1559        // UNRESTRICTED log. That's the opposite of "scoped to these paths",
1560        // so refuse before spawning (mirrors `JjApi::commit_paths`'s
1561        // empty-fileset guard).
1562        if paths.is_empty() {
1563            return Err(Error::spawn(
1564                BINARY,
1565                std::io::Error::new(
1566                    std::io::ErrorKind::InvalidInput,
1567                    "log_paths requires at least one path — an empty list would log \
1568                     unrestricted history, not history scoped to the named paths",
1569                ),
1570            ));
1571        }
1572        // R-05: a single path this long can never be transmitted at all — `git
1573        // log` has no `--pathspec-from-file`/NUL-safe transport to fall back
1574        // to (unlike `add`/`commit_paths`, verified against real git: the flag
1575        // is rejected with "unrecognized argument"), so no chunking scheme can
1576        // help it. Reject up front, before `chunk_pathspecs` would otherwise
1577        // emit it as an over-budget singleton chunk that `git`/the OS would
1578        // then fail on anyway, with a much less legible spawn error.
1579        if let Some(oversized) = paths.iter().find(|p| p.len() + 1 > ARGV_PATHSPEC_BUDGET) {
1580            return Err(Error::spawn(
1581                BINARY,
1582                std::io::Error::new(
1583                    std::io::ErrorKind::InvalidInput,
1584                    format!(
1585                        "log_paths: a single path is {} bytes, exceeding the \
1586                         {ARGV_PATHSPEC_BUDGET}-byte argv pathspec budget on its own — \
1587                         `git log` has no NUL-safe pathspec-from-file transport (unlike \
1588                         `add`/`commit_paths`), so this path cannot be transmitted at all: \
1589                         {oversized:?}",
1590                        oversized.len() + 1,
1591                    ),
1592                ),
1593            ));
1594        }
1595        let n = format!("-n{max}");
1596        let chunks = chunk_pathspecs(paths);
1597        if chunks.len() <= 1 {
1598            // The common case: everything fits one call — byte-identical to the
1599            // pre-T-052 behavior (order included). A single invocation can't
1600            // observe a moving repository state mid-operation, so `revspec` is
1601            // forwarded as-is — no R-04 resolution needed here.
1602            let command = self.log_paths_command(
1603                dir,
1604                [revspec.as_str()],
1605                &n,
1606                paths.iter().map(String::as_str),
1607            );
1608            return self.core.parse(command, parse::parse_log).await;
1609        }
1610        // Large path set (T-052): `git log` has no `--pathspec-from-file`
1611        // support (unlike `add`/`commit_paths`), so split the pathspecs across
1612        // multiple argv-budget-sized calls and merge the results: dedup by hash
1613        // (a commit can touch paths spread across more than one chunk), then
1614        // reorder and cap at `max`. Requesting `-n max` per chunk is enough to
1615        // guarantee the merged top-`max` is correct: any commit within the true
1616        // (merged) top `max` has at most `max - 1` newer commits in the *entire*
1617        // union of chunk results, so it has at most that many newer commits
1618        // within its own chunk too — i.e. it always ranks within that chunk's
1619        // own top `max`. This bound is about *how many* qualifying commits can
1620        // precede another, not about how they are ordered, so it holds
1621        // regardless of the ordering mechanism below.
1622        //
1623        // R-04: this branch makes several independent `git` invocations (one
1624        // per chunk, plus the order oracle below), each of which would
1625        // otherwise re-resolve `revspec` on its own — a symbolic name like
1626        // `HEAD` can move (or a range's endpoints can) between any two of
1627        // them. Resolve it exactly once, up front, into the fixed set of
1628        // commit ids `git log` would internally expand it to (a plain rev
1629        // resolves to one id; a range like `A..B` resolves to two tokens, the
1630        // excluded side `^`-prefixed — `git rev-parse` performs the same
1631        // expansion `git log` does internally, so forwarding its output
1632        // verbatim is behavior-preserving for what a single, hypothetical
1633        // unchunked call would have seen). Every call below then reuses this
1634        // one fixed snapshot, so no ref movement during the operation can make
1635        // two of them disagree about what "`revspec`" names.
1636        let resolved_revspec: Vec<String> = self
1637            .core
1638            .run(self.core.command_in(dir, ["rev-parse", revspec.as_str()]))
1639            .await?
1640            .lines()
1641            .map(str::trim)
1642            .filter(|line| !line.is_empty())
1643            .map(str::to_string)
1644            .collect();
1645        let mut merged: Vec<Commit> = Vec::new();
1646        let mut seen = std::collections::HashSet::new();
1647        for chunk in &chunks {
1648            let command = self.log_paths_command(
1649                dir,
1650                resolved_revspec.iter().map(String::as_str),
1651                &n,
1652                chunk.iter().copied(),
1653            );
1654            let commits = self.core.parse(command, parse::parse_log).await?;
1655            for commit in commits {
1656                if seen.insert(commit.hash.clone()) {
1657                    merged.push(commit);
1658                }
1659            }
1660        }
1661        // Merging per-chunk call results loses git's own single-call order, so
1662        // restore it (R-03) via a hash-order oracle: one extra, pathless `git
1663        // log <revspec> --format=%H` call over the *same*, now-frozen revspec
1664        // (cheap — no per-path diff computation, just hashes) gives the exact
1665        // relative order a single unchunked call would have produced, since
1666        // pathspec filtering only drops non-matching commits without
1667        // reordering the ones that remain. A commit absent from the oracle
1668        // (should not happen — it always names a commit `log_paths` itself
1669        // just returned as reachable from `revspec`) sorts after every ranked
1670        // commit rather than panicking.
1671        let order_command = self.log_paths_order_command(dir, &resolved_revspec);
1672        let order = self.core.parse(order_command, parse_commit_order).await?;
1673        let rank: std::collections::HashMap<&str, usize> = order
1674            .iter()
1675            .enumerate()
1676            .map(|(index, hash)| (hash.as_str(), index))
1677            .collect();
1678        merged.sort_by_key(|commit| {
1679            rank.get(commit.hash.as_str())
1680                .copied()
1681                .unwrap_or(usize::MAX)
1682        });
1683        merged.truncate(max);
1684        Ok(merged)
1685    }
1686
1687    async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1688        // `--verify`: without it, `git rev-parse Makefile` echoes the *filename* back
1689        // as a fake object id (exit 0), so a caller resolving an untrusted revision
1690        // could get a non-hash. `--verify` requires `rev` to name exactly one object,
1691        // erroring otherwise — a valid revision still resolves to the same full hash
1692        // (M13; matches `rev_parse_short`/`resolve_commit`, which already `--verify`).
1693        self.core
1694            .run(
1695                self.core
1696                    .command_in(dir, ["rev-parse", "--verify", rev.as_str()]),
1697            )
1698            .await
1699    }
1700
1701    async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1702        // `--verify` (matching `rev_parse`/`resolve_commit`): require `rev` to name
1703        // exactly one object, erroring otherwise. Unlike bare `rev-parse` — which
1704        // echoes a filename back as a fake id (the M13 bug) — `--short` already
1705        // rejects a plain path (`Needed a single revision`), so this is
1706        // consistency / defense-in-depth: it pins the single-object contract
1707        // explicitly instead of leaning on `--short`'s incidental rejection. A real
1708        // revision still abbreviates the same.
1709        self.core
1710            .run(
1711                self.core
1712                    .command_in(dir, ["rev-parse", "--verify", "--short", rev.as_str()]),
1713            )
1714            .await
1715    }
1716
1717    async fn init(&self, dir: &Path) -> Result<()> {
1718        self.core
1719            .run_unit(self.core.command_in(dir, ["init"]))
1720            .await
1721    }
1722
1723    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1724        if pathspec_argv_len(paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1725            // Large path set (T-052): route over stdin instead of argv, so
1726            // there is no OS command-line limit left to exceed.
1727            // `--literal-pathspecs` matches each path byte-for-byte instead of
1728            // treating `*`/`?`/`[]` as pathspec glob magic.
1729            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1730                paths.iter().map(|p| p.as_os_str()),
1731            )?);
1732            let command = self
1733                .core
1734                .command_in(
1735                    dir,
1736                    [
1737                        "--literal-pathspecs",
1738                        "add",
1739                        "--pathspec-from-file=-",
1740                        "--pathspec-file-nul",
1741                    ],
1742                )
1743                .stdin(stdin);
1744            return self.core.run_unit(command).await;
1745        }
1746        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1747        // as the stdin branch above — without it, a path containing `*`/`?`/`[]`
1748        // would be read as pathspec glob magic instead of matched byte-for-byte
1749        // (R-01). `--` separates the pathspecs so a path can never be read as an
1750        // option.
1751        let mut command = self
1752            .core
1753            .command_in(dir, ["--literal-pathspecs", "add", "--"]);
1754        for path in paths {
1755            command = command.arg(path);
1756        }
1757        self.core.run_unit(command).await
1758    }
1759
1760    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1761        // C locale: a failure's output feeds `is_nothing_to_commit`.
1762        self.core
1763            .run_unit(c_locale(
1764                self.core.command_in(dir, ["commit", "-m", message]),
1765            ))
1766            .await
1767    }
1768
1769    async fn create_branch(&self, dir: &Path, name: &RefName) -> Result<()> {
1770        self.core
1771            .run_unit(self.core.command_in(dir, ["branch", name.as_str()]))
1772            .await
1773    }
1774
1775    async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
1776        // `target.as_arg()` is either a validated `RevSpec` or the fixed `-`
1777        // literal ([`CheckoutTarget::Previous`]) — never caller-controlled argv,
1778        // so no flag can be injected here. The trailing `--` marks the end of
1779        // revisions with no pathspecs following, so git resolves the target as a
1780        // ref *only*. Without it a target that doesn't name a ref but names a
1781        // tracked path silently falls into pathspec mode and restores that path
1782        // from the index, discarding unstaged edits (verified: `git checkout
1783        // notes.txt` → "Updated 1 path", exit 0; `git checkout notes.txt --` →
1784        // hard error).
1785        self.core
1786            .run_unit(
1787                self.core
1788                    .command_in(dir, ["checkout", target.as_arg(), "--"]),
1789            )
1790            .await
1791    }
1792
1793    async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()> {
1794        self.core
1795            .run_unit(
1796                self.core
1797                    .command_in(dir, ["checkout", "--detach", commit.as_str()]),
1798            )
1799            .await
1800    }
1801
1802    async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1803        // `--only -- <paths>` commits exactly these paths' working-tree content
1804        // regardless of the index; `--` keeps a path from being read as an option.
1805        // C locale: a failure's output feeds `is_nothing_to_commit`.
1806        if pathspec_argv_len(spec.paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
1807            // Large path set (T-052): same NUL-safe stdin transport as `add`'s
1808            // twin branch. Still exactly one `git commit` invocation either
1809            // way — the atomic-commit contract never depends on the path
1810            // set's size, since no chunking is ever needed here.
1811            let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
1812                spec.paths.iter().map(|p| p.as_os_str()),
1813            )?);
1814            let mut command =
1815                c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1816            if spec.amend {
1817                command = command.arg("--amend");
1818            }
1819            command = command
1820                .arg("-m")
1821                .arg(spec.message)
1822                .arg("--only")
1823                .arg("--pathspec-from-file=-")
1824                .arg("--pathspec-file-nul")
1825                .stdin(stdin);
1826            return self.core.run_unit(command).await;
1827        }
1828        // `--literal-pathspecs`: same "exactly these paths, literally" contract
1829        // as the stdin branch above — without it, a glob-magic character
1830        // (`*`/`?`/`[]`) in a path would be expanded as a pathspec pattern
1831        // instead of matched byte-for-byte, which could commit the wrong files
1832        // and violate `commit_paths`'s "exactly these paths" contract (R-01).
1833        let mut command = c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
1834        if spec.amend {
1835            command = command.arg("--amend");
1836        }
1837        command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1838        for path in &spec.paths {
1839            command = command.arg(path);
1840        }
1841        self.core.run_unit(command).await
1842    }
1843
1844    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1845        self.core
1846            .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1847            .await
1848    }
1849
1850    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1851        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
1852        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
1853        // and surfaces anything else (e.g. 128, not a repo) as `Error::Exit`.
1854        Ok(!self
1855            .core
1856            .probe(
1857                self.core
1858                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1859            )
1860            .await?)
1861    }
1862
1863    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1864        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
1865        // `probe` errors on any other code / timeout / signal.
1866        self.core
1867            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1868            .await
1869    }
1870
1871    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1872        Ok(PathBuf::from(
1873            self.core
1874                .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1875                .await?,
1876        ))
1877    }
1878
1879    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1880        Ok(PathBuf::from(
1881            self.core
1882                .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1883                .await?,
1884        ))
1885    }
1886
1887    async fn resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
1888        // `^{commit}` peels an annotated tag down to the commit it points at.
1889        let spec = format!("{}^{{commit}}", rev.as_str());
1890        self.core
1891            .run(
1892                self.core
1893                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1894            )
1895            .await
1896    }
1897
1898    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1899        // `--quiet` makes an *unset* origin/HEAD a silent **exit 1** (no `fatal:`
1900        // on stderr); that's "no default branch", not an error. Map exit 0 → the
1901        // branch, exit 1 → `None`, and anything else (a real failure like "not a
1902        // repository" exit 128, or a timeout/signal with no exit code) surfaces via
1903        // `ensure_success` — mirroring `config_get`, rather than swallowing it.
1904        let res = self
1905            .core
1906            .output_string(
1907                self.core
1908                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1909            )
1910            .await?;
1911        match res.code() {
1912            Some(0) => {
1913                // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so
1914                // a slashed default branch (e.g. "release/v2") survives intact.
1915                let out = res.stdout().trim();
1916                Ok(Some(
1917                    out.strip_prefix("refs/remotes/origin/")
1918                        .unwrap_or(out)
1919                        .to_string(),
1920                ))
1921            }
1922            Some(1) => Ok(None), // unset origin/HEAD
1923            _ => {
1924                let _ = res.ensure_success()?;
1925                Ok(None) // unreachable: a non-zero/no-code exit always errors above
1926            }
1927        }
1928    }
1929
1930    async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1931        let refname = format!("refs/heads/{}", name.as_str());
1932        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
1933        self.core
1934            .probe(
1935                self.core
1936                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1937            )
1938            .await
1939    }
1940
1941    async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool> {
1942        // `RefName` already forbids the glob/control/`:` characters this probe
1943        // must exclude (its `check-ref-format` rules are a strict superset), so
1944        // the value is safe to interpolate into the `refs/heads/<name>` ref below.
1945        let name = name.as_str();
1946        // No credential prompt, bounded wait: a missing helper or a flaky network
1947        // must not hang the call. `output_string` reports a timeout as a flagged result
1948        // (non-zero exit) rather than erroring, so an unreachable remote reads as
1949        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
1950        // spawn failure (no `git`) still surfaces as an error.
1951        //
1952        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
1953        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
1954        // `refs/heads/<name>` matches only the exact branch.
1955        let refname = format!("refs/heads/{name}");
1956        let (pre, envs) = self.remote_credentials(None).await?;
1957        let mut args: Vec<String> = pre;
1958        args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1959        let cmd = apply_secret_env(
1960            self.core
1961                .command_in(dir, &args)
1962                .env("GIT_TERMINAL_PROMPT", "0")
1963                .timeout(Duration::from_secs(10)),
1964            &envs,
1965        );
1966        let res = self.core.output_string(cmd).await?;
1967        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1968    }
1969
1970    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1971        reject_flag_like("remote name", remote)?;
1972        self.core
1973            .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1974            .await
1975    }
1976
1977    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1978        // Validate that HEAD is attached before asking for `@{u}`. Git otherwise
1979        // uses exit 128 both for "no upstream" and for detached HEAD/not-a-repo,
1980        // so the upstream query alone cannot distinguish those states.
1981        let head = self
1982            .core
1983            .output_string(
1984                self.core
1985                    .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1986            )
1987            .await?;
1988        let _ = head.ensure_success()?;
1989
1990        // Once HEAD is known to be an attached branch, exit 128 is the documented
1991        // "no upstream configured" case. Every other failure, including a timeout
1992        // or signal (which has no exit code), remains a real error.
1993        let res = self
1994            .core
1995            .output_string(self.core.command_in(
1996                dir,
1997                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1998            ))
1999            .await?;
2000        match res.code() {
2001            Some(0) => {
2002                let name = res.stdout().trim();
2003                Ok((!name.is_empty()).then(|| name.to_string()))
2004            }
2005            Some(128) => Ok(None),
2006            _ => {
2007                let _ = res.ensure_success()?;
2008                Ok(None) // unreachable: every remaining outcome is unsuccessful
2009            }
2010        }
2011    }
2012
2013    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
2014        reject_flag_like("remote name", remote)?;
2015        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
2016        // never block on an interactive auth prompt. A provider, if set, supplies
2017        // the credential via an inline helper (token kept out of argv).
2018        let (pre, envs) = self.remote_credentials(None).await?;
2019        let mut args: Vec<String> = pre;
2020        args.extend(["ls-remote", "--heads", remote].map(String::from));
2021        let cmd = apply_secret_env(
2022            self.core
2023                .command_in(dir, &args)
2024                .env("GIT_TERMINAL_PROMPT", "0"),
2025            &envs,
2026        );
2027        self.core.parse(cmd, parse::parse_ls_remote_heads).await
2028    }
2029
2030    async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
2031        // `--no-column` + `--no-color`: under `column.ui = always` git would pack
2032        // several names per line and under `color.{ui,branch} = always` it would
2033        // inject ANSI escapes — both even when piped, so the marker-stripping
2034        // compare below would never match (a false "not merged").
2035        let out = self
2036            .core
2037            .run(self.core.command_in(
2038                dir,
2039                [
2040                    "branch",
2041                    "--merged",
2042                    spec.base.as_str(),
2043                    "--no-column",
2044                    "--no-color",
2045                ],
2046            ))
2047            .await?;
2048        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
2049        // drop exactly those two columns rather than trimming a char class (which
2050        // would over-strip a name that legitimately began with the marker char).
2051        Ok(out
2052            .lines()
2053            .filter_map(|line| line.get(2..))
2054            .any(|b| b == spec.branch.as_str()))
2055    }
2056
2057    async fn set_upstream(&self, dir: &Path, branch: &RefName, upstream: &RefName) -> Result<()> {
2058        let flag = format!("--set-upstream-to={}", upstream.as_str());
2059        self.core
2060            .run_unit(
2061                self.core
2062                    .command_in(dir, ["branch", flag.as_str(), branch.as_str()]),
2063            )
2064            .await
2065    }
2066
2067    async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()> {
2068        let flag = if spec.force { "-D" } else { "-d" };
2069        self.core
2070            .run_unit(
2071                self.core
2072                    .command_in(dir, ["branch", flag, spec.name.as_str()]),
2073            )
2074            .await
2075    }
2076
2077    async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()> {
2078        self.core
2079            .run_unit(
2080                self.core
2081                    .command_in(dir, ["branch", "-m", old.as_str(), new.as_str()]),
2082            )
2083            .await
2084    }
2085
2086    async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize> {
2087        self.core
2088            .try_parse(
2089                self.core
2090                    .command_in(dir, ["rev-list", "--count", range.as_str()]),
2091                |s| {
2092                    s.trim()
2093                        .parse::<usize>()
2094                        .map_err(|e| Error::parse(BINARY, e.to_string()))
2095                },
2096            )
2097            .await
2098    }
2099
2100    async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool> {
2101        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
2102        // The trailing `--` forces `range` to be read as a revision/range, not a
2103        // pathspec: without it `git diff --quiet Makefile` diffs the *working
2104        // tree* limited to that path (exit 1 = "has changes"), so a caller string
2105        // that names a file returns a plausible-but-wrong bool instead of erroring
2106        // (the C2/M13 pathspec-collision class). With `--`, an unresolvable
2107        // revision exits 128, which `probe` surfaces as an honest error.
2108        self.core
2109            .probe(
2110                self.core
2111                    .command_in(dir, ["diff", "--quiet", range.as_str(), "--"]),
2112            )
2113            .await
2114    }
2115
2116    async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat> {
2117        // `LC_ALL=C`: git's `--shortstat` summary ("N file(s) changed, …") is
2118        // gettext-translated, but `parse_shortstat` keys on the English
2119        // "file"/"insertion"/"deletion" — without C locale a non-English git
2120        // returns an all-zero `DiffStat` rather than the real counts.
2121        // Trailing `--`: force `range` to resolve as a revision/range, never a
2122        // pathspec (see `diff_range_is_empty` — a path-named `range` would
2123        // otherwise stat the working tree for that path instead of erroring).
2124        self.core
2125            .parse(
2126                c_locale(
2127                    self.core
2128                        .command_in(dir, ["diff", "--shortstat", range.as_str(), "--"]),
2129                ),
2130                parse::parse_shortstat,
2131            )
2132            .await
2133    }
2134
2135    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
2136        self.diff_text_budgeted(dir, spec, self.core.output_budget())
2137            .await
2138    }
2139
2140    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
2141        let text = self.diff_text(dir, spec).await?;
2142        Ok(parse_diff(&text))
2143    }
2144
2145    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
2146        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
2147        self.core
2148            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
2149            .await
2150    }
2151
2152    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
2153        let git_dir = self.resolved_git_dir(dir).await?;
2154        // `rebase-merge/` is a merge-backend rebase. `rebase-apply/` is shared by an
2155        // apply-backend rebase AND `git am` — but `git am` marks it with an `applying`
2156        // file, so exclude that (it's an am, aborted with `am --abort`, not
2157        // `rebase --abort`; see `is_am_in_progress`). M20.
2158        let rebase_apply = git_dir.join("rebase-apply");
2159        let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
2160        Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
2161    }
2162
2163    async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
2164        // `git am` uses `rebase-apply/` with an `applying` marker file (an
2165        // apply-backend rebase uses the same dir *without* it).
2166        Ok(self
2167            .resolved_git_dir(dir)
2168            .await?
2169            .join("rebase-apply")
2170            .join("applying")
2171            .exists())
2172    }
2173
2174    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
2175        Ok(self
2176            .resolved_git_dir(dir)
2177            .await?
2178            .join("MERGE_HEAD")
2179            .exists())
2180    }
2181
2182    async fn is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool> {
2183        Ok(self
2184            .resolved_git_dir(dir)
2185            .await?
2186            .join("CHERRY_PICK_HEAD")
2187            .exists())
2188    }
2189
2190    async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool> {
2191        Ok(self
2192            .resolved_git_dir(dir)
2193            .await?
2194            .join("REVERT_HEAD")
2195            .exists())
2196    }
2197
2198    async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool> {
2199        // `BISECT_LOG` is git's own canonical "a bisect is running" marker (it also
2200        // drives `git bisect log`); the other BISECT_* files are session details.
2201        Ok(self
2202            .resolved_git_dir(dir)
2203            .await?
2204            .join("BISECT_LOG")
2205            .exists())
2206    }
2207
2208    async fn fetch(&self, dir: &Path) -> Result<()> {
2209        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
2210        // rather than blocking on an interactive prompt — matching the other
2211        // remote ops (`fetch_branch`, `push`, `remote_branch_exists`).
2212        // Fetch is idempotent, so `retry` replays it on a transient failure
2213        // (DNS/timeout/dropped connection); a non-transient error fails at once.
2214        // C locale: the retry decision classifies the failure's message.
2215        // Leading `-c` credential.helper (+ secret env) when a provider is set.
2216        let (pre, envs) = self.remote_credentials(None).await?;
2217        let mut args: Vec<String> = pre;
2218        args.extend(["fetch", "--quiet"].map(String::from));
2219        // `budget_diagnostics`: bound the retained failure/progress output (a
2220        // drop-oldest tail — never `OutputTooLarge`, so `is_transient_fetch_error`
2221        // still classifies the tail-preserved message). Unbounded by default.
2222        let cmd = self.core.budget_diagnostics(apply_secret_env(
2223            c_locale(self.core.command_in(dir, &args))
2224                .env("GIT_TERMINAL_PROMPT", "0")
2225                // On a per-client timeout, terminate gracefully (then hard-kill
2226                // after a grace window) so a timed-out fetch closes cleanly.
2227                .timeout_grace(FETCH_TIMEOUT_GRACE)
2228                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2229            &envs,
2230        ));
2231        self.core.run_unit(cmd).await
2232    }
2233
2234    async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
2235        // A leading-`-` remote is a bare positional here — and a flag like
2236        // `--upload-pack=<cmd>` would run an arbitrary local program for a
2237        // local/ext transport, so this guard is load-bearing for security.
2238        reject_flag_like("remote", remote)?;
2239        // Same containment as `fetch` (prompt off, C locale, transient retry,
2240        // optional credential helper), with the remote named explicitly.
2241        let (pre, envs) = self.remote_credentials(None).await?;
2242        let mut args: Vec<String> = pre;
2243        args.extend(["fetch", "--quiet", remote].map(String::from));
2244        let cmd = self.core.budget_diagnostics(apply_secret_env(
2245            c_locale(self.core.command_in(dir, &args))
2246                .env("GIT_TERMINAL_PROMPT", "0")
2247                .timeout_grace(FETCH_TIMEOUT_GRACE)
2248                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2249            &envs,
2250        ));
2251        self.core.run_unit(cmd).await
2252    }
2253
2254    async fn fetch_branch(&self, dir: &Path, branch: &RefName) -> Result<()> {
2255        // `RefName` already forbids the glob/control/`:` characters this refspec
2256        // must exclude (a strict superset), so both interpolations below are safe.
2257        let branch = branch.as_str();
2258        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
2259        let (pre, envs) = self.remote_credentials(None).await?;
2260        let mut args: Vec<String> = pre;
2261        args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
2262        let cmd = self.core.budget_diagnostics(apply_secret_env(
2263            c_locale(self.core.command_in(dir, &args))
2264                .env("GIT_TERMINAL_PROMPT", "0")
2265                .timeout_grace(FETCH_TIMEOUT_GRACE)
2266                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
2267            &envs,
2268        ));
2269        self.core.run_unit(cmd).await
2270    }
2271
2272    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
2273        reject_flag_like("remote", &spec.remote)?;
2274        reject_flag_like("refspec", &spec.refspec)?;
2275        // M16: `reject_flag_like` catches a leading `-`/empty/NUL, but not the refspec
2276        // metacharacters that silently change what a push *does* — a leading `+`
2277        // (force-push, overwriting the remote non-fast-forward) or an extra `:` (push
2278        // to an unexpected remote ref). A valid refspec here is `branch` or
2279        // `local:remote_branch` (the single `:` is API-constructed by
2280        // `GitPush::refspec`), so allow at most one `:` and no leading `+` on either
2281        // side. A caller who genuinely needs a force-push must do it explicitly via
2282        // `run(["push", "--force", …])`, not smuggle a `+` through a branch name.
2283        let sides: Vec<&str> = spec.refspec.split(':').collect();
2284        if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
2285            return Err(processkit::Error::spawn(
2286                BINARY,
2287                std::io::Error::new(
2288                    std::io::ErrorKind::InvalidInput,
2289                    format!(
2290                        "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
2291                         metacharacter — pass a plain branch or `local:remote`, or use \
2292                         `run([\"push\", …])` for a force-push",
2293                        spec.refspec
2294                    ),
2295                ),
2296            ));
2297        }
2298        let (pre, envs) = self.remote_credentials(None).await?;
2299        let mut args: Vec<String> = pre;
2300        args.push("push".to_string());
2301        if spec.set_upstream {
2302            args.push("-u".to_string());
2303        }
2304        args.push(spec.remote.clone());
2305        args.push(spec.refspec.clone());
2306        let cmd = apply_secret_env(
2307            self.core
2308                .command_in(dir, &args)
2309                .env("GIT_TERMINAL_PROMPT", "0")
2310                // On a per-client timeout, terminate gracefully (then hard-kill
2311                // after a grace window) so a timed-out push releases its lock and
2312                // doesn't leave the remote ref half-updated. No-op without a
2313                // deadline (matches `fetch`).
2314                .timeout_grace(FETCH_TIMEOUT_GRACE),
2315            &envs,
2316        );
2317        self.core.run_unit(cmd).await
2318    }
2319
2320    async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> Result<()> {
2321        // C locale: a conflict's output feeds `is_merge_conflict` (same reason as
2322        // `merge_commit`/`merge_no_commit`). `--squash` never commits, so no editor.
2323        self.core
2324            .run_unit(c_locale(
2325                self.core
2326                    .command_in(dir, ["merge", "--squash", branch.as_str()]),
2327            ))
2328            .await
2329    }
2330
2331    async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
2332        let mut args: Vec<&str> = vec!["merge"];
2333        if spec.no_ff {
2334            args.push("--no-ff");
2335        }
2336        if let Some(msg) = spec.message.as_deref() {
2337            args.push("-m");
2338            args.push(msg);
2339        } else {
2340            // No message → take the default merge message non-interactively
2341            // instead of opening `$EDITOR` (which would hang a headless caller).
2342            args.push("--no-edit");
2343        }
2344        args.push(spec.branch.as_str());
2345        // C locale: a conflict's output feeds `is_merge_conflict`.
2346        self.core
2347            .run_unit(c_locale(self.core.command_in(dir, args)))
2348            .await
2349    }
2350
2351    async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
2352        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
2353        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
2354        // a squash never fast-forwards anyway, so it takes precedence.
2355        if spec.squash {
2356            args.push("--squash");
2357        } else if spec.no_ff {
2358            args.push("--no-ff");
2359        }
2360        args.push(spec.branch.as_str());
2361        // C locale: a conflict's output feeds `is_merge_conflict`.
2362        self.core
2363            .run_unit(c_locale(self.core.command_in(dir, args)))
2364            .await
2365    }
2366
2367    async fn merge_abort(&self, dir: &Path) -> Result<()> {
2368        self.core.run_unit(self.merge_abort_command(dir)).await
2369    }
2370
2371    async fn merge_continue(&self, dir: &Path) -> Result<()> {
2372        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
2373        // headless backstop so a commit hook re-opening the editor can't hang.
2374        // C locale: the failure output feeds the classifiers (a still-conflicted
2375        // tree reports "nothing to commit"-adjacent / conflict messages).
2376        self.core
2377            .run_unit(no_editor(c_locale(
2378                self.core.command_in(dir, ["commit", "--no-edit"]),
2379            )))
2380            .await
2381    }
2382
2383    async fn reset_merge(&self, dir: &Path) -> Result<()> {
2384        self.core
2385            .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
2386            .await
2387    }
2388
2389    async fn reset_hard(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2390        self.core
2391            .run_unit(self.core.command_in(dir, ["reset", "--hard", rev.as_str()]))
2392            .await
2393    }
2394
2395    async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()> {
2396        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
2397        // the message-confirm on `--continue`) never hangs a headless caller.
2398        // C locale: a conflict's output feeds `is_merge_conflict`.
2399        self.core
2400            .run_unit(no_editor(c_locale(
2401                self.core.command_in(dir, ["rebase", onto.as_str()]),
2402            )))
2403            .await
2404    }
2405
2406    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
2407        self.core
2408            .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
2409            .await
2410    }
2411
2412    async fn am_abort(&self, dir: &Path) -> Result<()> {
2413        self.core
2414            .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
2415            .await
2416    }
2417
2418    async fn am_continue(&self, dir: &Path) -> Result<()> {
2419        // `am --continue` re-applies the resolved patch and commits it; a patch's
2420        // message-confirm could open the editor — force a no-op editor so a headless
2421        // caller can't hang. C locale: a re-conflict's output feeds `is_merge_conflict`.
2422        self.core
2423            .run_unit(no_editor(c_locale(
2424                self.core.command_in(dir, ["am", "--continue"]),
2425            )))
2426            .await
2427    }
2428
2429    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
2430        self.core
2431            .run_unit(no_editor(c_locale(
2432                self.core.command_in(dir, ["rebase", "--continue"]),
2433            )))
2434            .await
2435    }
2436
2437    async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()> {
2438        let mut command = self.core.command_in(dir, ["stash", "push"]);
2439        if spec.include_untracked {
2440            command = command.arg("--include-untracked");
2441        }
2442        self.core.run_unit(command).await
2443    }
2444
2445    async fn stash_pop(&self, dir: &Path) -> Result<()> {
2446        // C locale: a conflicting `stash pop` emits git's merge-machinery
2447        // `CONFLICT (...)` output, which feeds `is_merge_conflict` (e.g. via
2448        // `switch_with_stash`) — a translated message would defeat it.
2449        self.core
2450            .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
2451            .await
2452    }
2453
2454    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
2455        // `parse_bytes`: the porcelain `worktree <path>` value is a filesystem path
2456        // that need not be valid UTF-8 on Unix, so parse from raw stdout bytes — a
2457        // lossy `String` decode would corrupt a non-UTF-8 worktree name to `U+FFFD`
2458        // and leak a wrong path into the facade's `WorktreeInfo.path`. (Deliberately
2459        // no `-z`: `worktree list --porcelain -z` is git ≥ 2.36, above this crate's
2460        // 2.31 support floor; newline framing already covers the non-UTF-8 case.)
2461        self.core
2462            .parse_bytes(
2463                self.core
2464                    .command_in(dir, ["worktree", "list", "--porcelain"]),
2465                parse::parse_worktree_porcelain,
2466            )
2467            .await
2468    }
2469
2470    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
2471        let mut command = self.core.command_in(dir, ["worktree", "add"]);
2472        if let Some(name) = spec.new_branch.as_ref() {
2473            command = command.arg("-b").arg(name.as_str());
2474        }
2475        if spec.no_checkout {
2476            command = command.arg("--no-checkout");
2477        }
2478        command = command.arg(&spec.path);
2479        if let Some(commitish) = spec.commitish.as_ref() {
2480            command = command.arg(commitish.as_str());
2481        }
2482        self.core.run_unit(command).await
2483    }
2484
2485    async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()> {
2486        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
2487        if spec.force {
2488            command = command.arg("--force");
2489        }
2490        command = command.arg(&spec.path);
2491        self.core.run_unit(command).await
2492    }
2493
2494    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
2495        let command = self
2496            .core
2497            .command_in(dir, ["worktree", "move"])
2498            .arg(from)
2499            .arg(to);
2500        self.core.run_unit(command).await
2501    }
2502
2503    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
2504        self.core
2505            .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
2506            .await
2507    }
2508
2509    async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
2510        // A leading-`-` url is a bare positional — `git clone --upload-pack=<cmd>`
2511        // would run an arbitrary local program. A real URL never leads with `-`,
2512        // so this guard has no false positives.
2513        reject_flag_like("url", url)?;
2514        // No working directory: clone creates `dest` itself, so `dest` should
2515        // be absolute (a relative path would resolve against this process' cwd).
2516        // Leading `-c` credential.helper (+ secret env) when a provider is set,
2517        // scoped to the clone URL's host so a cross-host redirect/submodule during
2518        // the clone can't extract the token (the URL is often externally supplied).
2519        let (pre, envs) = self
2520            .remote_credentials(vcs_cli_support::https_host(url).as_deref())
2521            .await?;
2522        let mut initial: Vec<String> = pre;
2523        initial.push("clone".to_string());
2524        let mut command = self.core.command(&initial);
2525        if let Some(branch) = spec.branch.as_deref() {
2526            command = command.arg("--branch").arg(branch);
2527        }
2528        if let Some(depth) = spec.depth {
2529            command = command.arg("--depth").arg(depth.to_string());
2530        }
2531        if spec.bare {
2532            command = command.arg("--bare");
2533        }
2534        // `budget_diagnostics`: bound the retained clone progress/failure output
2535        // (a drop-oldest tail — never `OutputTooLarge`, so a real failure stays a
2536        // classifiable `Error::Exit`). Unbounded by default.
2537        let command = self.core.budget_diagnostics(apply_secret_env(
2538            command
2539                .arg(url)
2540                .arg(dest)
2541                .env("GIT_TERMINAL_PROMPT", "0")
2542                // On a per-client timeout, terminate gracefully (then hard-kill after
2543                // a grace window). No-op without a deadline (matches `fetch`).
2544                .timeout_grace(FETCH_TIMEOUT_GRACE),
2545            &envs,
2546        ));
2547
2548        // R7: git populates `dest` incrementally, so a failed clone (timeout, network,
2549        // auth) can leave a **partial, non-empty** `dest` that blocks a retry with
2550        // "destination path already exists and is not empty". `timeout_grace` alone
2551        // can't prevent it — Windows' job-kill is atomic (no graceful tier) and the
2552        // Unix grace is too short to delete a multi-GB partial. So clean it ourselves,
2553        // via the shared `vcs_cli_support` helper (also used by `vcs_jj::git_clone`) —
2554        // see its docs for the "never touch a non-empty pre-existing dest" contract and
2555        // why `cleanable` must be computed before the clone runs.
2556        let cleanable = vcs_cli_support::clone_dest_cleanable(dest);
2557        let result = self.core.run_unit(command).await;
2558        if result.is_err() {
2559            vcs_cli_support::cleanup_failed_clone_dest(dest, cleanable);
2560        }
2561        result
2562    }
2563
2564    async fn tag_create(&self, dir: &Path, name: &RefName, rev: Option<RevSpec>) -> Result<()> {
2565        let mut args = vec!["tag", name.as_str()];
2566        if let Some(rev) = rev.as_ref() {
2567            args.push(rev.as_str());
2568        }
2569        self.core.run_unit(self.core.command_in(dir, args)).await
2570    }
2571
2572    async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
2573        let mut args = vec!["tag", "-a", spec.name.as_str(), "-m", &spec.message];
2574        if let Some(rev) = spec.rev.as_ref() {
2575            args.push(rev.as_str());
2576        }
2577        self.core.run_unit(self.core.command_in(dir, args)).await
2578    }
2579
2580    async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
2581        // `--no-column`: a user's `column.ui = always` would pack several tags
2582        // onto one line even when piped, corrupting the one-per-line split.
2583        let out = self
2584            .core
2585            .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
2586            .await?;
2587        Ok(out.lines().map(str::to_string).collect())
2588    }
2589
2590    async fn tag_delete(&self, dir: &Path, name: &RefName) -> Result<()> {
2591        self.core
2592            .run_unit(self.core.command_in(dir, ["tag", "-d", name.as_str()]))
2593            .await
2594    }
2595
2596    async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String> {
2597        self.show_file_within(dir, rev, path, self.core.output_budget())
2598            .await
2599    }
2600
2601    async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
2602        reject_flag_like("config key", key)?;
2603        let res = self
2604            .core
2605            .output_string(self.core.command_in(dir, ["config", "--get", key]))
2606            .await?;
2607        match res.code() {
2608            // Exit 1 = unset (git lumps "no such key/section" in here too).
2609            Some(1) => Ok(None),
2610            // Strip only git's trailing line terminator (`\n`, or `\r\n`), not all
2611            // trailing whitespace: a config value can legitimately end in spaces or
2612            // a tab (e.g. a templated prefix), and `--get` returns a single line, so
2613            // it never itself ends in a newline.
2614            Some(0) => Ok(Some(
2615                res.stdout().trim_end_matches(['\r', '\n']).to_string(),
2616            )),
2617            _ => {
2618                let _ = res.ensure_success()?;
2619                Ok(None) // unreachable: a non-zero exit always errors above.
2620            }
2621        }
2622    }
2623
2624    async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
2625        reject_flag_like("config key", key)?;
2626        self.core
2627            .run_unit(self.core.command_in(dir, ["config", key, value]))
2628            .await
2629    }
2630
2631    async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2632        reject_flag_like("remote name", name)?;
2633        reject_flag_like("url", url)?;
2634        self.core
2635            .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2636            .await
2637    }
2638
2639    async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2640        reject_flag_like("remote name", name)?;
2641        reject_flag_like("url", url)?;
2642        self.core
2643            .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2644            .await
2645    }
2646
2647    async fn blame(&self, dir: &Path, path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>> {
2648        let mut args = vec!["blame", "--line-porcelain"];
2649        if let Some(rev) = rev.as_ref() {
2650            args.push(rev.as_str());
2651        }
2652        args.push("--");
2653        args.push(path);
2654        self.core
2655            .parse(
2656                self.core.command_in(dir, args),
2657                parse::parse_blame_porcelain,
2658            )
2659            .await
2660    }
2661
2662    async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2663        // No editor opens non-interactively, but keep the headless backstop.
2664        // C locale: a conflict's output feeds `is_merge_conflict`.
2665        self.core
2666            .run_unit(no_editor(c_locale(
2667                self.core.command_in(dir, ["cherry-pick", rev.as_str()]),
2668            )))
2669            .await
2670    }
2671
2672    async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
2673        self.core
2674            .run_unit(no_editor(c_locale(
2675                self.core
2676                    .command_in(dir, ["revert", "--no-edit", rev.as_str()]),
2677            )))
2678            .await
2679    }
2680
2681    async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2682        self.core
2683            .run_unit(no_editor(c_locale(
2684                self.core.command_in(dir, ["rebase", "--skip"]),
2685            )))
2686            .await
2687    }
2688
2689    async fn cherry_pick_abort(&self, dir: &Path) -> Result<()> {
2690        // No editor on --abort, but keep the C locale so any failure output still
2691        // feeds the classifiers uniformly with the rest of the sequencer.
2692        self.core
2693            .run_unit(c_locale(
2694                self.core.command_in(dir, ["cherry-pick", "--abort"]),
2695            ))
2696            .await
2697    }
2698
2699    async fn cherry_pick_continue(&self, dir: &Path) -> Result<()> {
2700        // `--continue` re-commits the resolved pick and may open the editor to
2701        // confirm the message — force a no-op editor so a headless caller can't
2702        // hang. C locale: a re-conflict's output feeds `is_merge_conflict`.
2703        self.core
2704            .run_unit(no_editor(c_locale(
2705                self.core.command_in(dir, ["cherry-pick", "--continue"]),
2706            )))
2707            .await
2708    }
2709
2710    async fn revert_abort(&self, dir: &Path) -> Result<()> {
2711        self.core
2712            .run_unit(c_locale(self.core.command_in(dir, ["revert", "--abort"])))
2713            .await
2714    }
2715
2716    async fn revert_continue(&self, dir: &Path) -> Result<()> {
2717        self.core
2718            .run_unit(no_editor(c_locale(
2719                self.core.command_in(dir, ["revert", "--continue"]),
2720            )))
2721            .await
2722    }
2723
2724    async fn bisect_reset(&self, dir: &Path) -> Result<()> {
2725        self.core
2726            .run_unit(c_locale(self.core.command_in(dir, ["bisect", "reset"])))
2727            .await
2728    }
2729}
2730
2731impl<R: ProcessRunner> Git<R> {
2732    /// Build one `git --literal-pathspecs log <revs...> -n<max> -z --format=…
2733    /// -- <paths>` call — used both for the common case (everything fits one
2734    /// invocation, the direct [`GitApi::log_paths`] call, where `revs` is the
2735    /// single, as-given `revspec.as_str()`) and for each chunk of its
2736    /// large-path-set fallback (T-052; the two need no different format,
2737    /// since chunked order is restored afterwards by
2738    /// [`Self::log_paths_order_command`], not by anything embedded in each
2739    /// chunk's own output — R-03). On the chunked path, `revs` is instead the
2740    /// caller's already-resolved, fixed commit-id tokens (T-052/R-04; see
2741    /// [`GitApi::log_paths`]) — one for a plain rev, two (tip + `^`-prefixed
2742    /// exclusion) for a range — reused verbatim across every chunk so none of
2743    /// them can observe a differently-moved ref than another.
2744    /// `--literal-pathspecs` matches a path containing `*`/`?`/`[]` literally
2745    /// rather than as pathspec glob magic (R-02).
2746    fn log_paths_command<'a>(
2747        &self,
2748        dir: &Path,
2749        revs: impl IntoIterator<Item = &'a str>,
2750        n: &str,
2751        paths: impl IntoIterator<Item = &'a str>,
2752    ) -> Command {
2753        let mut command = self.core.command_in(dir, ["--literal-pathspecs", "log"]);
2754        for rev in revs {
2755            command = command.arg(rev);
2756        }
2757        command = command
2758            .arg(n)
2759            .arg("-z")
2760            .arg("--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s")
2761            .arg("--");
2762        for path in paths {
2763            command = command.arg(path);
2764        }
2765        command
2766    }
2767
2768    /// Build the pathless `git log <revs...> -z --format=%H` commit-order
2769    /// oracle used to restore git's own order across `log_paths`'s merged
2770    /// chunk results (T-052/R-03; see [`GitApi::log_paths`]). `revs` is the
2771    /// same already-resolved, fixed commit-id tokens the chunk calls used
2772    /// (T-052/R-04) — resolving the revspec independently here, after the
2773    /// chunk calls already ran, would reopen exactly the race that resolving
2774    /// it once up front closes. No `-n` cap: a commit surviving
2775    /// path-filtering into the merged top-`max` can sit arbitrarily far back
2776    /// in the *unrestricted* history (many untouched commits between it and
2777    /// the tip), so the oracle must be able to rank it — capping this call
2778    /// risks an unranked commit outside the map. No paths, so no
2779    /// `--literal-pathspecs` is needed here. Parsed by [`parse_commit_order`].
2780    fn log_paths_order_command(&self, dir: &Path, revs: &[String]) -> Command {
2781        let mut command = self.core.command_in(dir, ["log"]);
2782        for rev in revs {
2783            command = command.arg(rev);
2784        }
2785        command.arg("-z").arg("--format=%H")
2786    }
2787
2788    /// Build the `merge --abort` command with the C locale forced (a failed
2789    /// abort's output feeds the same classifiers as the merge it undoes). Shared
2790    /// by the token-inheriting [`merge_abort`](GitApi::merge_abort) and the
2791    /// detached [`merge_abort_detached`](Self::merge_abort_detached) so the two
2792    /// argv/locale forms cannot drift.
2793    fn merge_abort_command(&self, dir: &Path) -> Command {
2794        c_locale(self.core.command_in(dir, ["merge", "--abort"]))
2795    }
2796
2797    /// Turn a repo-scoped `command` into a rollback **cleanup** step: it drops
2798    /// this client's [`default_cancel_on`](Git::default_cancel_on) token for a
2799    /// fresh, never-fired one and applies the bounded
2800    /// [`MERGE_ABORT_CLEANUP_TIMEOUT`] deadline — the git analogue of jj's
2801    /// `rollback_cmd_in`. A command built this way runs even after the client
2802    /// token that governed the probe has already fired, so `Repo::try_merge`'s
2803    /// Ok/Err cleanup can both **decide** to roll back (the
2804    /// [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached)
2805    /// probe) and **perform** it
2806    /// ([`merge_abort_detached`](Self::merge_abort_detached)) despite a cancelled
2807    /// or timed-out probe merge — the *whole* decision-plus-command rollback path
2808    /// is detached, matching jj rather than only the final abort command.
2809    fn detached_cleanup(&self, command: Command) -> Command {
2810        command
2811            .cancel_on(CancellationToken::new())
2812            .timeout(MERGE_ABORT_CLEANUP_TIMEOUT)
2813    }
2814
2815    /// Abort an in-progress merge (`merge --abort`) as a **rollback cleanup** that
2816    /// deliberately does **not** inherit this client's
2817    /// [`default_cancel_on`](Git::default_cancel_on) token and runs under its own
2818    /// bounded `MERGE_ABORT_CLEANUP_TIMEOUT` deadline — mirroring jj's
2819    /// `Jj::rollback_to`.
2820    ///
2821    /// The trait [`merge_abort`](GitApi::merge_abort) inherits the client's cancel
2822    /// token, so once that token has already fired — a cancelled or timed-out probe
2823    /// merge — the abort that should undo the half-staged trial merge would itself
2824    /// be cancelled, leaving the merge in the working tree. Building it through the
2825    /// internal `detached_cleanup` (a fresh, never-fired [`CancellationToken`] and a
2826    /// full fresh timeout budget) lets it complete regardless, so a probe's own
2827    /// cancellation no longer disables its rollback. Same `merge --abort` argv as
2828    /// [`merge_abort`](GitApi::merge_abort); used by the facade `Repo::try_merge`'s
2829    /// error-branch cleanup — paired with
2830    /// [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached) so the
2831    /// *decision* to abort is detached too, not just this command.
2832    pub async fn merge_abort_detached(&self, dir: &Path) -> Result<()> {
2833        self.core
2834            .run_unit(self.detached_cleanup(self.merge_abort_command(dir)))
2835            .await
2836    }
2837
2838    /// [`is_merge_in_progress`](GitApi::is_merge_in_progress) on the detached
2839    /// rollback-cleanup context (the internal `detached_cleanup`), so the
2840    /// **decision** of whether a trial merge is still staged survives an
2841    /// already-fired client [`default_cancel_on`](Git::default_cancel_on) token.
2842    ///
2843    /// The trait probe's underlying `rev-parse --git-dir` inherits the client
2844    /// token; once a probe merge's cancellation has fired, that probe's `?` would
2845    /// propagate `Error::Cancelled` **before** the abort that undoes the trial
2846    /// merge is ever reached, leaving it staged in the working tree. Resolving the
2847    /// git dir on a fresh token instead lets the decision complete, so it pairs
2848    /// with [`merge_abort_detached`](Self::merge_abort_detached) to make **both**
2849    /// halves of `Repo::try_merge`'s Ok/Err cleanup cancellation-safe — matching
2850    /// jj, whose op-log rollback probe also runs on the detached context.
2851    pub async fn is_merge_in_progress_detached(&self, dir: &Path) -> Result<bool> {
2852        Ok(self
2853            .resolved_git_dir_detached(dir)
2854            .await?
2855            .join("MERGE_HEAD")
2856            .exists())
2857    }
2858}
2859
2860// --- Internal helpers --------------------------------------------------------
2861//
2862// The error classifiers (`is_merge_conflict`/`is_nothing_to_commit`/
2863// `is_transient_fetch_error`), the fetch-retry policy, and the argv injection
2864// guard now live in the shared `vcs-cli-support` crate (re-exported at the top of
2865// this module); what remains here is git-specific.
2866
2867/// Git's well-known **SHA-1** empty-tree object id. This value exists **only in a
2868/// SHA-1 repository**: a repo created with `extensions.objectFormat=sha256` has a
2869/// different empty-tree id (and this SHA-1 one resolves to no object there), so
2870/// this constant is *not* a universal stand-in for `HEAD` when diffing an unborn
2871/// working tree. For the id that matches a repository's active object format, use
2872/// [`Git::empty_tree_oid`], which asks git for it — that is what
2873/// [`diff_text`](GitApi::diff_text)`(DiffSpec::WorkingTree)` uses on an unborn repo.
2874pub const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2875
2876/// Total attempts / fixed backoff for a transient-retried `fetch` — the shared
2877/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
2878const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2879const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2880const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2881
2882/// Bounded deadline for the detached rollback abort
2883/// ([`Git::merge_abort_detached`]) — the git analogue of jj's `ROLLBACK_TIMEOUT`.
2884/// Because that cleanup deliberately runs on a FRESH cancel token (so a cancelled
2885/// or timed-out probe merge cannot also cancel it), it needs its own explicit
2886/// deadline to stay bounded rather than inheriting one from the main operation.
2887const MERGE_ABORT_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
2888
2889/// Point git's editor at a no-op so any command that would open `$EDITOR`
2890/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
2891/// non-interactively instead of hanging a headless caller.
2892fn no_editor(cmd: processkit::Command) -> processkit::Command {
2893    cmd.env("GIT_EDITOR", "true")
2894        .env("GIT_SEQUENCE_EDITOR", "true")
2895}
2896
2897/// Force the C locale on a command whose output feeds the error classifiers
2898/// (`is_merge_conflict`, `is_nothing_to_commit`, `is_transient_fetch_error`):
2899/// they match untranslated English substrings, and a localized git would emit
2900/// translated messages, silently turning a classified failure (conflict /
2901/// clean-tree / transient) into an unclassified one.
2902fn c_locale(cmd: processkit::Command) -> processkit::Command {
2903    cmd.env("LC_ALL", "C")
2904}
2905
2906/// Injection guard for bare positional argv slots — delegates to the shared
2907/// [`vcs_cli_support::reject_flag_like`], naming this crate's binary so the
2908/// ~45 call sites stay `reject_flag_like(what, value)`.
2909fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2910    vcs_cli_support::reject_flag_like(BINARY, what, value)
2911}
2912
2913// --- Large path-set transport (T-052) ----------------------------------------
2914//
2915// `add`/`commit_paths` build one `git` argv per call whether their path set has
2916// three entries or three hundred thousand. Windows' `CreateProcess` rejects a
2917// command line longer than roughly 32,767 UTF-16 code units (`OS error 206`);
2918// POSIX's `ARG_MAX` is typically far larger but shared with the environment
2919// block. [`ARGV_PATHSPEC_BUDGET`] is a conservative byte budget for the *paths*
2920// portion of such a call (not counting the program name, subcommand, or
2921// flags — negligible next to this), chosen with a wide margin under the
2922// tighter Windows ceiling so an ordinary call (a handful of paths) never
2923// crosses it. Crossing it switches `add`/`commit_paths` to the NUL-safe
2924// `--pathspec-from-file=- --pathspec-file-nul` transport ([`pathspec_nul_bytes`])
2925// — unbounded, since the paths then never touch argv at all — and switches
2926// `log_paths` (for which git has no `--pathspec-from-file` support) to chunked
2927// invocations ([`chunk_pathspecs`]) merged back into one result.
2928
2929/// Conservative byte budget for the *pathspec* portion of a `git` argv — see
2930/// the module-level comment above this constant for the reasoning.
2931const ARGV_PATHSPEC_BUDGET: usize = 6_000;
2932
2933/// Sum of each path's encoded byte length plus one (a stand-in for the
2934/// separating argv-slot overhead), compared against [`ARGV_PATHSPEC_BUDGET`]
2935/// to decide whether a path set is too large for one plain-argv invocation.
2936fn pathspec_argv_len<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> usize {
2937    paths
2938        .into_iter()
2939        .map(|p| p.as_encoded_bytes().len() + 1)
2940        .sum()
2941}
2942
2943/// Build the NUL-delimited pathspec payload for `--pathspec-from-file=-
2944/// --pathspec-file-nul` from `paths`, entirely in memory before anything is
2945/// spawned. A path embedding a NUL byte — impossible on a real filesystem, but
2946/// checked anyway as defense-in-depth — would silently split into two
2947/// pathspecs on that separator, one of them possibly matching an unintended
2948/// file; refusing it here, before the command is built, keeps input
2949/// preparation atomic: either every path is valid and the one `git` invocation
2950/// runs, or none of it does (no partially-applied result to unwind). Returns
2951/// the raw bytes (rather than a [`processkit::Stdin`] directly) so this pure
2952/// step stays unit-testable — wrap the result in
2953/// [`processkit::Stdin::from_bytes`] at the call site.
2954fn pathspec_nul_bytes<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> Result<Vec<u8>> {
2955    let mut buf = Vec::new();
2956    for path in paths {
2957        let bytes = path.as_encoded_bytes();
2958        if bytes.contains(&0) {
2959            return Err(Error::spawn(
2960                BINARY,
2961                std::io::Error::new(
2962                    std::io::ErrorKind::InvalidInput,
2963                    "path contains an embedded NUL byte, which the \
2964                     --pathspec-file-nul transport uses as its separator — \
2965                     refusing before spawning rather than silently splitting \
2966                     it into two pathspecs",
2967                ),
2968            ));
2969        }
2970        buf.extend_from_slice(bytes);
2971        buf.push(0);
2972    }
2973    Ok(buf)
2974}
2975
2976/// Split `paths` into groups whose combined length (each entry's byte length
2977/// plus one, matching [`pathspec_argv_len`]) stays within
2978/// [`ARGV_PATHSPEC_BUDGET`] — every group gets at least one path (a single path
2979/// already over budget still gets its own singleton group; nothing shorter is
2980/// possible). Preserves `paths`' order, both within and across groups.
2981fn chunk_pathspecs(paths: &[String]) -> Vec<Vec<&str>> {
2982    let mut chunks: Vec<Vec<&str>> = Vec::new();
2983    let mut current: Vec<&str> = Vec::new();
2984    let mut current_len = 0usize;
2985    for path in paths {
2986        let len = path.len() + 1;
2987        if !current.is_empty() && current_len + len > ARGV_PATHSPEC_BUDGET {
2988            chunks.push(std::mem::take(&mut current));
2989            current_len = 0;
2990        }
2991        current.push(path.as_str());
2992        current_len += len;
2993    }
2994    if !current.is_empty() {
2995        chunks.push(current);
2996    }
2997    chunks
2998}
2999
3000/// Parse [`Git::log_paths_order_command`]'s `git log -z --format=%H` output
3001/// into an ordered list of hashes — git's own commit order for the queried
3002/// revspec, used as the ranking oracle that restores order across
3003/// `log_paths`'s merged chunk results (T-052/R-03; see [`GitApi::log_paths`]).
3004fn parse_commit_order(output: &str) -> Vec<String> {
3005    output
3006        .split('\0')
3007        .filter(|rec| !rec.is_empty())
3008        .map(str::to_string)
3009        .collect()
3010}
3011
3012impl<R: ProcessRunner> Git<R> {
3013    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
3014    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
3015    /// trait), so it can take `&[&str]`; forwards to the same path as
3016    /// [`GitApi::run`].
3017    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
3018        self.core.run(args).await
3019    }
3020
3021    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
3022    /// (mirrors [`GitApi::run_raw`]).
3023    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
3024        self.core.output_string(args).await
3025    }
3026
3027    /// Run `git <args>` **in `dir`** (the process is spawned with `dir` as its
3028    /// working directory), returning trimmed stdout — the dir-bound twin of the
3029    /// process-cwd [`run`](GitApi::run). This is what [`GitAt::run`] forwards to;
3030    /// call [`run`](GitApi::run) on the client for the process-cwd escape hatch.
3031    /// Argv is forwarded verbatim (the same unguarded escape hatch — only the
3032    /// working directory is bound, no `-C`/extra flag is injected).
3033    pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
3034        self.core.run(self.core.command_in(dir, args)).await
3035    }
3036
3037    /// Like [`run_in`](Git::run_in) but never errors on a non-zero exit — the
3038    /// dir-bound twin of [`run_raw`](GitApi::run_raw). What [`GitAt::run_raw`]
3039    /// forwards to.
3040    pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
3041        self.core
3042            .output_string(self.core.command_in(dir, args))
3043            .await
3044    }
3045
3046    /// Like [`run_args`](Git::run_args) but **bound to `dir`** — the `&[&str]` twin
3047    /// of [`run_in`](Git::run_in). What [`GitAt::run_args`] forwards to.
3048    pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
3049        self.core.run(self.core.command_in(dir, args)).await
3050    }
3051
3052    /// Like [`run_raw_args`](Git::run_raw_args) but **bound to `dir`** — the
3053    /// `&[&str]` twin of [`run_raw_in`](Git::run_raw_in). What
3054    /// [`GitAt::run_raw_args`] forwards to.
3055    pub async fn run_raw_args_in(
3056        &self,
3057        dir: &Path,
3058        args: &[&str],
3059    ) -> Result<ProcessResult<String>> {
3060        self.core
3061            .output_string(self.core.command_in(dir, args))
3062            .await
3063    }
3064
3065    /// The empty-tree object id for the repository at `dir`, matching its **active
3066    /// object format** — the format-correct stand-in for `HEAD` when diffing/stat-ing
3067    /// the working tree of an unborn (no-commits-yet) repository.
3068    ///
3069    /// Computed with `git hash-object -t tree --stdin` fed an empty stdin: an empty
3070    /// tree object is empty content, so git returns its id under whichever hash the
3071    /// repo uses (`4b825dc…` for SHA-1, a 64-hex digest for `extensions.objectFormat=
3072    /// sha256`). This asks git rather than hard-coding [`EMPTY_TREE_SHA1`], which is
3073    /// wrong in a SHA-256 repo. `--stdin` (not `-w`) only *computes* the id — nothing
3074    /// is written to the object database.
3075    pub async fn empty_tree_oid(&self, dir: &Path) -> Result<String> {
3076        self.core
3077            .run(
3078                self.core
3079                    .command_in(dir, ["hash-object", "-t", "tree", "--stdin"])
3080                    .stdin(processkit::Stdin::empty()),
3081            )
3082            .await
3083    }
3084
3085    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
3086    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
3087    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
3088    /// driving many directories (e.g. linked worktrees) from one client.
3089    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
3090        GitAt { git: self, dir }
3091    }
3092
3093    /// Harden this client for driving repositories it didn't create: running
3094    /// `git` inside an untrusted checkout executes that repository's hooks and
3095    /// honours its config — arbitrary code execution by default. The profile
3096    /// (applied to **every** command this client runs):
3097    ///
3098    /// **⚠ Requires git ≥ 2.31.** The hook / `fsmonitor` / `sshCommand` pins ride
3099    /// git's env-based config (`GIT_CONFIG_COUNT`), which older git **silently
3100    /// ignores** — so on git < 2.31 `harden()` still scrubs the environment and
3101    /// turns prompts off, but repo-local hooks/fsmonitor/sshCommand are **not**
3102    /// disabled (no error is raised). [`capabilities().ensure_supported()`](GitCapabilities::ensure_supported)
3103    /// now enforces the **≥ 2.31 floor** (major.minor), so a too-old git is rejected
3104    /// up front with a clear message instead of silently no-op-ing the pins — call it
3105    /// before relying on `harden()` against a fully untrusted repo on a host you don't
3106    /// control, or add an OS-level sandbox. (`docs/audit-2026-07.md` H3, M29.)
3107    ///
3108    /// - **Disables hooks** — `core.hooksPath=/dev/null` pinned via git's
3109    ///   env-based config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, git ≥ 2.31;
3110    ///   verified to suppress hooks on Windows too) — and `core.fsmonitor`
3111    ///   (a config-driven daemon launch). Env-config overrides even the
3112    ///   *repo-local* `.git/config` for the keys it names, so these pins beat a
3113    ///   poisoned `.git/config`.
3114    /// - **Neutralizes `core.sshCommand`** (pinned empty) — the config-key twin of
3115    ///   the scrubbed `GIT_SSH_COMMAND`, an arbitrary program git would run for the
3116    ///   SSH transport. Empty is falsy to git, so the default `ssh` (ambient
3117    ///   `~/.ssh/config`/agent) still works; only the repo's override is dropped.
3118    /// - **Removes inherited repo redirectors** so a poisoned parent
3119    ///   environment can't point commands at another repository: `GIT_DIR`,
3120    ///   `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`,
3121    ///   `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`,
3122    ///   `GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_PARAMETERS`,
3123    ///   `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`. (The first seven are also
3124    ///   scrubbed by *every* client — see the type-level doc — not just here.)
3125    /// - **Removes inherited command hooks** that make git spawn an arbitrary
3126    ///   program from the *environment* (a second code-execution path besides
3127    ///   repo hooks): `GIT_SSH_COMMAND`/`GIT_SSH` (transport), `GIT_ASKPASS`
3128    ///   (credential prompt), `GIT_EXTERNAL_DIFF` (diff driver), `GIT_PAGER`,
3129    ///   `GIT_EDITOR`/`GIT_SEQUENCE_EDITOR`, `GIT_PROXY_COMMAND` (a program for a
3130    ///   `git://` connection), `GIT_EXEC_PATH` (relocates git's own sub-commands),
3131    ///   and `GIT_TEMPLATE_DIR` (seeds hooks/config on `init`/`clone`). It also drops
3132    ///   the pathspec-mode vars (`GIT_LITERAL_PATHSPECS` / `GIT_GLOB_PATHSPECS` /
3133    ///   `GIT_NOGLOB_PATHSPECS` / `GIT_ICASE_PATHSPECS`), which silently change which
3134    ///   paths a command matches. The library's own auth seam
3135    ///   ([`with_credentials`](Git::with_credentials)) injects credentials via a
3136    ///   git `credential.helper` / token env, **not** these variables, so it keeps
3137    ///   working through a hardened client; an operator who deliberately relies on
3138    ///   an ambient `GIT_SSH_COMMAND`/`GIT_ASKPASS` should inject it per-call
3139    ///   instead of inheriting it into an untrusted-repo run.
3140    /// - **Skips system config** (`GIT_CONFIG_NOSYSTEM=1`) and keeps terminal
3141    ///   prompts off everywhere (`GIT_TERMINAL_PROMPT=0`).
3142    ///
3143    /// **Residual repo-local-config vectors (NOT neutralized).** `harden()` closes
3144    /// the *hooks*, `fsmonitor`, `core.sshCommand`, and the env redirector/command-
3145    /// hook paths — but a few **repo-local `.git/config` / `.gitattributes`** keys
3146    /// still run an arbitrary program and are not pinned: `filter.<drv>.clean`/
3147    /// `smudge` (run on any working-tree materialization — `checkout`, `stash pop`,
3148    /// `worktree add`), and `diff.<drv>.textconv` / `diff.external` (run when a diff
3149    /// is produced; [`diff_text`](GitApi::diff_text) defends itself with
3150    /// `--no-ext-diff`, but other diff/blame reads do not). So for a **fully
3151    /// untrusted** repo, do not materialize its working tree or run diffs through a
3152    /// hardened client without an OS-level sandbox — `harden()` is hardening, not a
3153    /// sandbox.
3154    ///
3155    /// What it does NOT do beyond that: sandbox the git binary itself, or stop the
3156    /// repo's *content* from being malicious. In a **colocated jj repo**, git hooks
3157    /// only run when *git* commands run — harden the `Git` client; `Jj` needs
3158    /// no equivalent (jj has no repo-local hooks; see the vcs-jj docs).
3159    ///
3160    /// Chainable — `Git::with_runner(rec).harden()` works in tests; use
3161    /// [`Git::hardened()`](Git::hardened) for the common case.
3162    pub fn harden(self) -> Self {
3163        let removed = [
3164            // Repo redirectors — point git at another repo/index/object store.
3165            // (`GIT_DIR`…`GIT_NAMESPACE` are also scrubbed by *every* client via the
3166            // `managed_client!` `scrub_env`; re-listed here so the hardened profile is
3167            // self-contained and its double-removal is harmless.)
3168            "GIT_DIR",
3169            "GIT_WORK_TREE",
3170            "GIT_INDEX_FILE",
3171            "GIT_COMMON_DIR",
3172            "GIT_OBJECT_DIRECTORY",
3173            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3174            "GIT_NAMESPACE",
3175            "GIT_CEILING_DIRECTORIES",
3176            "GIT_CONFIG_PARAMETERS",
3177            "GIT_CONFIG_GLOBAL",
3178            "GIT_CONFIG_SYSTEM",
3179            // Command hooks — make git spawn an arbitrary program from the env.
3180            "GIT_SSH_COMMAND",
3181            "GIT_SSH",
3182            "GIT_ASKPASS",
3183            "GIT_EXTERNAL_DIFF",
3184            "GIT_PAGER",
3185            "GIT_EDITOR",
3186            "GIT_SEQUENCE_EDITOR",
3187            // More env command-hooks (M14): `GIT_PROXY_COMMAND` runs an arbitrary
3188            // program for a `git://` connection; `GIT_EXEC_PATH` relocates where git
3189            // finds its own sub-commands (so `git-<x>` becomes attacker-chosen);
3190            // `GIT_TEMPLATE_DIR` seeds hooks/config into a repo on `init`/`clone`.
3191            "GIT_PROXY_COMMAND",
3192            "GIT_EXEC_PATH",
3193            "GIT_TEMPLATE_DIR",
3194            // Pathspec interpretation (M14) — not code-execution, but they silently
3195            // change which paths a command matches, so pin deterministic behavior.
3196            "GIT_LITERAL_PATHSPECS",
3197            "GIT_GLOB_PATHSPECS",
3198            "GIT_NOGLOB_PATHSPECS",
3199            "GIT_ICASE_PATHSPECS",
3200        ];
3201        let mut hardened = self;
3202        for key in removed {
3203            hardened = hardened.default_env_remove(key);
3204        }
3205        hardened
3206            .default_env("GIT_CONFIG_NOSYSTEM", "1")
3207            .default_env("GIT_TERMINAL_PROMPT", "0")
3208            // Env-config (`GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`) overrides even the
3209            // *repo-local* `.git/config` for the keys it names — so these pins beat
3210            // a poisoned `.git/config`, which `GIT_CONFIG_NOSYSTEM` (system) and the
3211            // scrubbed `GIT_CONFIG_GLOBAL` (global) do not reach.
3212            .default_env("GIT_CONFIG_COUNT", "3")
3213            .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
3214            // `/dev/null` as the hooks dir disables hooks on every platform,
3215            // Windows included: git looks for `<hooksPath>/<hook-name>`, and no
3216            // such file can exist under `/dev/null` (it is not a directory), so the
3217            // lookup always misses and no hook runs. A literal POSIX path is fine
3218            // on Windows here — it is used as a path *prefix* to probe, never
3219            // opened — and it reads unambiguously as "nowhere."
3220            .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
3221            .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
3222            .default_env("GIT_CONFIG_VALUE_1", "false")
3223            // Neutralize a repo-local `core.sshCommand` (an arbitrary program git
3224            // runs for the SSH transport on fetch/push/clone) — the config-key twin
3225            // of the scrubbed `GIT_SSH_COMMAND` env var. An empty value is falsy to
3226            // git, so it falls back to the default `ssh` (ambient `~/.ssh/config` /
3227            // agent still work); only the repo's override is dropped.
3228            .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
3229            .default_env("GIT_CONFIG_VALUE_2", "")
3230    }
3231
3232    /// Switch to `branch`, carrying uncommitted changes (tracked *and*
3233    /// untracked) across via the stash: `stash push -u` → `checkout` →
3234    /// `stash pop --index`. `--index` restores the staged/unstaged split faithfully
3235    /// (a bare `pop` returns everything unstaged). A clean tree skips the round-trip;
3236    /// and because `stash push` can exit 0 having saved **nothing** (e.g. a
3237    /// submodule-only change), the stash-list depth is checked around the push so a
3238    /// no-op push doesn't leave the later pop grabbing an older, unrelated stash.
3239    ///
3240    /// **Single-actor contract:** this assumes no other process pushes or pops a
3241    /// stash in the same repository between this call's own `stash push` and `pop`.
3242    ///
3243    /// Failure behaviour:
3244    /// - `checkout` fails (atomic — the working copy stays on the original
3245    ///   branch): the stash is popped back to restore the original state, and
3246    ///   the checkout error is returned. If that restoring pop *also* fails,
3247    ///   the changes stay safe in the stash (`git stash list`).
3248    /// - `stash pop` on the target branch conflicts: the error is returned with
3249    ///   the target branch checked out; git keeps the stash entry, so the
3250    ///   changes can be resolved or re-applied manually.
3251    ///
3252    /// Inherent (not on the object-safe trait): a composed operation, not a 1:1
3253    /// CLI verb — mock the underlying `status`/`stash_*`/`checkout` instead.
3254    pub async fn switch_with_stash(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
3255        // Untracked-inclusive guard to match `stash push -u`: "dirty" must mean
3256        // the same thing to the guard and to the stash. Fast path for a clean tree.
3257        if self.status(dir).await?.is_empty() {
3258            return self.checkout(dir, target).await;
3259        }
3260        // `stash push` exits 0 having saved **nothing** when the only dirt is
3261        // unstashable (e.g. a submodule-only change that `status` still reports), so a
3262        // bare `stash pop` afterwards would splat an UNRELATED pre-existing stash — data
3263        // loss. Bracket the push with the stash-list depth to learn whether it actually
3264        // saved, and only pop when it did. (Single-actor contract: a concurrent
3265        // `stash push`/`pop` by another process between our two calls is out of scope.)
3266        let depth_before = self.stash_depth(dir).await?;
3267        self.stash_push(dir, StashPush::new().include_untracked())
3268            .await?;
3269        if self.stash_depth(dir).await? <= depth_before {
3270            // Nothing was stashed — switch as-is rather than pop someone else's entry.
3271            return self.checkout(dir, target).await;
3272        }
3273        // `--index` restores the staged/unstaged split faithfully; a bare `pop` would
3274        // bring everything back UNSTAGED, silently flattening the index.
3275        match self.checkout(dir, target).await {
3276            Ok(()) => self.stash_pop_index(dir).await,
3277            Err(err) => {
3278                // A failed checkout is atomic — we are still on the original branch, so
3279                // popping restores the exact pre-call state. If the pop fails too, the
3280                // stash entry is preserved for the caller.
3281                let _ = self.stash_pop_index(dir).await;
3282                Err(err)
3283            }
3284        }
3285    }
3286
3287    /// The number of entries in the stash list (`git stash list`) — used by
3288    /// [`switch_with_stash`](Git::switch_with_stash) to tell whether a `stash push`
3289    /// actually saved anything.
3290    async fn stash_depth(&self, dir: &Path) -> Result<usize> {
3291        let out = self
3292            .core
3293            .run(self.core.command_in(dir, ["stash", "list"]))
3294            .await?;
3295        Ok(out.lines().filter(|l| !l.is_empty()).count())
3296    }
3297
3298    /// `git stash pop --index` — restore the top stash *preserving* the staged/unstaged
3299    /// split (a bare `pop` returns everything unstaged). C locale so a conflicting pop's
3300    /// `CONFLICT (...)` output still feeds `is_merge_conflict`.
3301    async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
3302        self.core
3303            .run_unit(c_locale(
3304                self.core.command_in(dir, ["stash", "pop", "--index"]),
3305            ))
3306            .await
3307    }
3308
3309    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
3310    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
3311    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
3312        self.resolve_git_dir(dir, self.core.command_in(dir, ["rev-parse", "--git-dir"]))
3313            .await
3314    }
3315
3316    /// [`resolved_git_dir`](Self::resolved_git_dir) on the detached
3317    /// rollback-cleanup context (see [`detached_cleanup`](Self::detached_cleanup)):
3318    /// the `rev-parse --git-dir` behind the MERGE_HEAD probe runs on a fresh,
3319    /// never-fired cancel token, so a client cancellation that already fired during
3320    /// the probe merge cannot short-circuit the *decision* half of `try_merge`'s
3321    /// Ok/Err cleanup. Backs [`is_merge_in_progress_detached`](Self::is_merge_in_progress_detached).
3322    async fn resolved_git_dir_detached(&self, dir: &Path) -> Result<PathBuf> {
3323        self.resolve_git_dir(
3324            dir,
3325            self.detached_cleanup(self.core.command_in(dir, ["rev-parse", "--git-dir"])),
3326        )
3327        .await
3328    }
3329
3330    /// Run a `rev-parse --git-dir` `command` and absolutise a relative git dir
3331    /// against `dir`. Shared by [`resolved_git_dir`](Self::resolved_git_dir) and
3332    /// its detached variant so the token-inheriting and detached probes cannot
3333    /// drift in how they resolve the path.
3334    async fn resolve_git_dir(&self, dir: &Path, command: Command) -> Result<PathBuf> {
3335        let git_dir = PathBuf::from(self.core.run(command).await?);
3336        Ok(if git_dir.is_absolute() {
3337            git_dir
3338        } else {
3339            dir.join(git_dir)
3340        })
3341    }
3342}
3343
3344impl Git {
3345    /// A hardened real (job-backed) client — `Git::new().harden()`; see
3346    /// [`harden`](Git::harden) for what the profile does.
3347    pub fn hardened() -> Self {
3348        Self::new().harden()
3349    }
3350}
3351
3352/// A [`Git`] client with a working directory bound, so calls drop the leading
3353/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
3354/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
3355/// copy: it only borrows the client and the path.
3356pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
3357    git: &'a Git<R>,
3358    dir: &'a Path,
3359}
3360
3361// Hand-written rather than derived: the view only holds two references, so it is
3362// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
3363// bound that the real default `JobRunner` doesn't satisfy, silently dropping
3364// `Copy` on the production `Repo::git_at()` handle.
3365impl<R: ProcessRunner> Clone for GitAt<'_, R> {
3366    fn clone(&self) -> Self {
3367        *self
3368    }
3369}
3370impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
3371
3372// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
3373// verbatim, `dir` methods inject `self.dir` as the first argument. The shared
3374// macro lives in `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
3375vcs_cli_support::at_forwarders! {
3376    GitAt, git, "Git",
3377    bare {
3378        fn version() -> Result<String>;
3379        fn capabilities() -> Result<GitCapabilities>;
3380        fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
3381    }
3382    dir {
3383        fn status() -> Result<Vec<StatusEntry>>;
3384        fn status_text() -> Result<String>;
3385        fn status_tracked() -> Result<Vec<StatusEntry>>;
3386        fn branch_status() -> Result<BranchStatus>;
3387        fn conflicted_files() -> Result<Vec<PathBuf>>;
3388        fn current_branch() -> Result<Option<String>>;
3389        fn branches() -> Result<Vec<Branch>>;
3390        fn log(revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
3391        fn log_paths(revspec: &RevSpec, max: usize, paths: &[String]) -> Result<Vec<Commit>>;
3392        fn rev_parse(rev: &RevSpec) -> Result<String>;
3393        fn rev_parse_short(rev: &RevSpec) -> Result<String>;
3394        fn init() -> Result<()>;
3395        fn add(paths: &[PathBuf]) -> Result<()>;
3396        fn commit(message: &str) -> Result<()>;
3397        fn create_branch(name: &RefName) -> Result<()>;
3398        fn checkout(target: &CheckoutTarget) -> Result<()>;
3399        fn checkout_detach(commit: &RevSpec) -> Result<()>;
3400        fn commit_paths(spec: CommitPaths) -> Result<()>;
3401        fn last_commit_message() -> Result<String>;
3402        fn is_unborn() -> Result<bool>;
3403        fn diff_is_empty() -> Result<bool>;
3404        fn common_dir() -> Result<PathBuf>;
3405        fn git_dir() -> Result<PathBuf>;
3406        fn resolve_commit(rev: &RevSpec) -> Result<String>;
3407        fn remote_head_branch() -> Result<Option<String>>;
3408        fn branch_exists(name: &RefName) -> Result<bool>;
3409        fn remote_branch_exists(name: &RefName) -> Result<bool>;
3410        fn remote_url(remote: &str) -> Result<String>;
3411        fn upstream() -> Result<Option<String>>;
3412        fn remote_branches(remote: &str) -> Result<Vec<String>>;
3413        fn is_merged(spec: MergeCheck) -> Result<bool>;
3414        fn set_upstream(branch: &RefName, upstream: &RefName) -> Result<()>;
3415        fn delete_branch(spec: BranchDelete) -> Result<()>;
3416        fn rename_branch(old: &RefName, new: &RefName) -> Result<()>;
3417        fn rev_list_count(range: &RevSpec) -> Result<usize>;
3418        fn diff_range_is_empty(range: &RevSpec) -> Result<bool>;
3419        fn diff_stat(range: &RevSpec) -> Result<DiffStat>;
3420        fn diff_text(spec: DiffSpec) -> Result<String>;
3421        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
3422        fn staged_is_empty() -> Result<bool>;
3423        fn is_rebase_in_progress() -> Result<bool>;
3424        fn is_merge_in_progress() -> Result<bool>;
3425        fn is_am_in_progress() -> Result<bool>;
3426        fn is_cherry_pick_in_progress() -> Result<bool>;
3427        fn is_revert_in_progress() -> Result<bool>;
3428        fn is_bisect_in_progress() -> Result<bool>;
3429        fn fetch() -> Result<()>;
3430        fn fetch_from(remote: &str) -> Result<()>;
3431        fn fetch_branch(branch: &RefName) -> Result<()>;
3432        fn push(spec: GitPush) -> Result<()>;
3433        fn merge_squash(branch: &RevSpec) -> Result<()>;
3434        fn merge_commit(spec: MergeCommit) -> Result<()>;
3435        fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
3436        fn merge_abort() -> Result<()>;
3437        fn merge_continue() -> Result<()>;
3438        fn reset_merge() -> Result<()>;
3439        fn reset_hard(rev: &RevSpec) -> Result<()>;
3440        fn rebase(onto: &RevSpec) -> Result<()>;
3441        fn rebase_abort() -> Result<()>;
3442        fn am_abort() -> Result<()>;
3443        fn am_continue() -> Result<()>;
3444        fn rebase_continue() -> Result<()>;
3445        fn stash_push(spec: StashPush) -> Result<()>;
3446        fn stash_pop() -> Result<()>;
3447        fn switch_with_stash(target: &CheckoutTarget) -> Result<()>;
3448        fn worktree_list() -> Result<Vec<Worktree>>;
3449        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
3450        fn worktree_remove(spec: WorktreeRemove) -> Result<()>;
3451        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
3452        fn worktree_prune() -> Result<()>;
3453        fn tag_create(name: &RefName, rev: Option<RevSpec>) -> Result<()>;
3454        fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
3455        fn tag_list() -> Result<Vec<String>>;
3456        fn tag_delete(name: &RefName) -> Result<()>;
3457        fn show_file(rev: &RevSpec, path: &str) -> Result<String>;
3458        fn config_get(key: &str) -> Result<Option<String>>;
3459        fn config_set(key: &str, value: &str) -> Result<()>;
3460        fn remote_add(name: &str, url: &str) -> Result<()>;
3461        fn remote_set_url(name: &str, url: &str) -> Result<()>;
3462        fn blame(path: &str, rev: Option<RevSpec>) -> Result<Vec<BlameLine>>;
3463        fn cherry_pick(rev: &RevSpec) -> Result<()>;
3464        fn revert(rev: &RevSpec) -> Result<()>;
3465        fn rebase_skip() -> Result<()>;
3466        fn cherry_pick_abort() -> Result<()>;
3467        fn cherry_pick_continue() -> Result<()>;
3468        fn revert_abort() -> Result<()>;
3469        fn revert_continue() -> Result<()>;
3470        fn bisect_reset() -> Result<()>;
3471    }
3472    // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
3473    // twins) so `git.at(dir).run(…)` runs in the bound repo, not the process cwd.
3474    // For the process-cwd hatch call `run`/`run_raw`/… on `Git` directly.
3475    raw {
3476        fn run(args: &[String]) -> Result<String> => run_in;
3477        fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
3478        fn run_args(args: &[&str]) -> Result<String> => run_args_in;
3479        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
3480    }
3481}
3482
3483/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
3484/// a `Drop` guard. They shell out through `std::process` directly (no async, no
3485/// job-containment), so reserve them for short-lived cleanup.
3486pub mod blocking {
3487    use std::path::Path;
3488    use std::process::Command;
3489
3490    /// Remove a worktree synchronously (`git worktree remove [--force] <path>`);
3491    /// see [`WorktreeRemove`](super::WorktreeRemove).
3492    pub fn worktree_remove(dir: &Path, spec: super::WorktreeRemove) -> std::io::Result<()> {
3493        let mut cmd = Command::new(super::BINARY);
3494        cmd.current_dir(dir).args(["worktree", "remove"]);
3495        if spec.force {
3496            cmd.arg("--force");
3497        }
3498        cmd.arg(&spec.path);
3499        let status = cmd.status()?;
3500        if status.success() {
3501            Ok(())
3502        } else {
3503            Err(std::io::Error::other(format!(
3504                "`git worktree remove` exited with {status}"
3505            )))
3506        }
3507    }
3508}
3509
3510#[cfg(test)]
3511mod tests {
3512    use super::*;
3513
3514    // Terse constructors for the validated newtypes in test call sites; the
3515    // literals here are always valid, so `unwrap` is fine in tests.
3516    fn rn(s: &str) -> RefName {
3517        RefName::new(s).unwrap()
3518    }
3519    fn rv(s: &str) -> RevSpec {
3520        RevSpec::new(s).unwrap()
3521    }
3522    fn ct(s: &str) -> CheckoutTarget {
3523        if s == "-" {
3524            CheckoutTarget::Previous
3525        } else {
3526            CheckoutTarget::Ref(rv(s))
3527        }
3528    }
3529    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
3530
3531    #[test]
3532    fn binary_name_is_git() {
3533        assert_eq!(BINARY, "git");
3534    }
3535
3536    // Compile-time guard: the bound view must stay `Copy` for the *default*
3537    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
3538    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
3539    #[allow(dead_code)]
3540    fn bound_view_is_copy_for_default_runner() {
3541        fn assert_copy<T: Copy>() {}
3542        assert_copy::<GitAt<'static, processkit::JobRunner>>();
3543    }
3544
3545    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
3546    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
3547    // in the right place and nothing else changes.
3548    #[tokio::test]
3549    async fn bound_view_matches_dir_taking_calls() {
3550        let dir = Path::new("/repo");
3551        let rec = RecordingRunner::replying(Reply::ok(""));
3552        let git = Git::with_runner(&rec);
3553
3554        // A method with trailing args (dir injected first).
3555        git.merge_commit(dir, MergeCommit::branch(rv("feat")).no_ff())
3556            .await
3557            .unwrap();
3558        git.at(dir)
3559            .merge_commit(MergeCommit::branch(rv("feat")).no_ff())
3560            .await
3561            .unwrap();
3562        // A method taking a path arg after dir.
3563        git.worktree_remove(dir, WorktreeRemove::new("/wt").force())
3564            .await
3565            .unwrap();
3566        git.at(dir)
3567            .worktree_remove(WorktreeRemove::new("/wt").force())
3568            .await
3569            .unwrap();
3570        // One of the new query methods.
3571        git.conflicted_files(dir).await.unwrap();
3572        git.at(dir).conflicted_files().await.unwrap();
3573        // One of the §4 additions.
3574        git.tag_delete(dir, &rn("v1")).await.unwrap();
3575        git.at(dir).tag_delete(&rn("v1")).await.unwrap();
3576
3577        let calls = rec.calls();
3578        assert_eq!(calls[0].args_str(), calls[1].args_str());
3579        assert_eq!(calls[2].args_str(), calls[3].args_str());
3580        assert_eq!(calls[4].args_str(), calls[5].args_str());
3581        assert_eq!(calls[6].args_str(), calls[7].args_str());
3582        // The bound calls also carried the bound dir as their working directory.
3583        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
3584        assert_eq!(calls[3].cwd.as_deref(), Some(dir));
3585    }
3586
3587    // T-035: the raw escape hatches reached *through* the bound view
3588    // (`git.at(dir).run…`) now run in the bound `dir`, while the same-named methods
3589    // on the client stay in the process cwd. Guards that a bound handle's raw call
3590    // can no longer silently target another repo, and that the explicit process-cwd
3591    // hatch is preserved.
3592    #[tokio::test]
3593    async fn bound_view_raw_hatch_runs_in_bound_dir() {
3594        let dir = Path::new("/repo");
3595        let rec = RecordingRunner::replying(Reply::ok(""));
3596        let git = Git::with_runner(&rec);
3597
3598        // Through the bound view: every raw form carries the bound dir as its cwd.
3599        git.at(dir).run(&["status".to_string()]).await.unwrap();
3600        let _ = git.at(dir).run_raw(&["status".to_string()]).await.unwrap();
3601        git.at(dir).run_args(&["status"]).await.unwrap();
3602        let _ = git.at(dir).run_raw_args(&["status"]).await.unwrap();
3603        // On the client directly: the process-cwd escape hatch (no bound dir).
3604        git.run(&["status".to_string()]).await.unwrap();
3605        let _ = git.run_raw(&["status".to_string()]).await.unwrap();
3606        git.run_args(&["status"]).await.unwrap();
3607        let _ = git.run_raw_args(&["status"]).await.unwrap();
3608
3609        let calls = rec.calls();
3610        for c in &calls[0..4] {
3611            assert_eq!(
3612                c.cwd.as_deref(),
3613                Some(dir),
3614                "raw call through the bound view runs in the bound dir"
3615            );
3616            assert_eq!(c.args_str(), ["status"]);
3617        }
3618        for c in &calls[4..8] {
3619            assert_eq!(
3620                c.cwd.as_deref(),
3621                None,
3622                "raw call on the client stays in the process cwd"
3623            );
3624            assert_eq!(c.args_str(), ["status"]);
3625        }
3626    }
3627
3628    // Hermetic: the real status() command-building + porcelain parsing run
3629    // against a scripted runner — no `git` binary needed, so this runs on CI.
3630    #[tokio::test]
3631    async fn status_parses_scripted_output() {
3632        // `-z` output: NUL-delimited records, raw paths.
3633        let git = Git::with_runner(
3634            ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
3635        );
3636        let entries = git.status(Path::new(".")).await.expect("status");
3637        assert_eq!(entries.len(), 2);
3638        assert_eq!(entries[0].code, " M");
3639        assert_eq!(entries[1].path, Path::new("b.rs"));
3640    }
3641
3642    // `status_tracked` is `status` minus untracked files — same parser, extra flag.
3643    #[tokio::test]
3644    async fn status_tracked_excludes_untracked_flag() {
3645        let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
3646        let git = Git::with_runner(&rec);
3647        let entries = git.status_tracked(Path::new(".")).await.expect("status");
3648        assert_eq!(entries.len(), 1);
3649        assert_eq!(entries[0].code, " M");
3650        assert_eq!(
3651            rec.only_call().args_str(),
3652            ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
3653        );
3654    }
3655
3656    // `branch_status` builds the porcelain v2 + branch + -z argv and parses the
3657    // combined header/entry output in one call.
3658    #[tokio::test]
3659    async fn branch_status_builds_v2_branch_args_and_parses() {
3660        let out = concat!(
3661            "# branch.oid abc\0",
3662            "# branch.head main\0",
3663            "# branch.upstream origin/main\0",
3664            "# branch.ab +1 -0\0",
3665            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
3666            "? new.txt\0",
3667        );
3668        let rec = RecordingRunner::replying(Reply::ok(out));
3669        let git = Git::with_runner(&rec);
3670        let s = git
3671            .branch_status(Path::new("."))
3672            .await
3673            .expect("branch_status");
3674        assert_eq!(
3675            rec.only_call().args_str(),
3676            ["status", "--porcelain=v2", "--branch", "-z"]
3677        );
3678        // The poll primitive must not itself write the index (and re-trigger a
3679        // filesystem watcher re-querying through it).
3680        assert!(rec.only_call().envs.iter().any(|(k, v)| {
3681            k.to_str() == Some("GIT_OPTIONAL_LOCKS")
3682                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3683        }));
3684        assert_eq!(s.branch.as_deref(), Some("main"));
3685        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
3686        assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
3687        assert_eq!(s.tracked_changes, 1);
3688        assert_eq!(s.untracked, 1);
3689        assert!(s.is_dirty());
3690    }
3691
3692    // `conflicted_files` lists unmerged paths NUL-delimited (no quoting).
3693    #[tokio::test]
3694    async fn conflicted_files_builds_args_and_parses_nul_list() {
3695        let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
3696        let git = Git::with_runner(&rec);
3697        let paths = git
3698            .conflicted_files(Path::new("."))
3699            .await
3700            .expect("conflicted_files");
3701        assert_eq!(
3702            paths,
3703            [PathBuf::from("a.rs"), PathBuf::from("sub/spaced name.rs")]
3704        );
3705        assert_eq!(
3706            rec.only_call().args_str(),
3707            ["diff", "--name-only", "--diff-filter=U", "-z"]
3708        );
3709    }
3710
3711    #[tokio::test]
3712    async fn rev_parse_short_builds_short_flag() {
3713        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
3714        let git = Git::with_runner(&rec);
3715        let out = git
3716            .rev_parse_short(Path::new("/r"), &rv("HEAD"))
3717            .await
3718            .unwrap();
3719        assert_eq!(out, "a1b2c3d");
3720        assert_eq!(
3721            rec.only_call().args_str(),
3722            ["rev-parse", "--verify", "--short", "HEAD"]
3723        );
3724    }
3725
3726    // M13: `rev_parse` passes `--verify` so a non-revision (a filename) errors
3727    // instead of being echoed back as a fake object id.
3728    #[tokio::test]
3729    async fn rev_parse_verifies_the_revision() {
3730        let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
3731        let git = Git::with_runner(&rec);
3732        let out = git.rev_parse(Path::new("/r"), &rv("HEAD")).await.unwrap();
3733        assert_eq!(out, "deadbeef");
3734        assert_eq!(
3735            rec.only_call().args_str(),
3736            ["rev-parse", "--verify", "HEAD"]
3737        );
3738    }
3739
3740    // M20: `git am` and an apply-backend rebase share the `rebase-apply/` dir, but am
3741    // marks it with an `applying` file. `is_am_in_progress` must fire only for the am,
3742    // and `is_rebase_in_progress` must NOT (so an am isn't aborted with `rebase --abort`).
3743    #[tokio::test]
3744    async fn distinguishes_git_am_from_an_apply_backend_rebase() {
3745        use vcs_testkit::TempDir;
3746        let gd = TempDir::new("m20-am");
3747        let git = Git::with_runner(ScriptedRunner::new().on(
3748            ["git", "rev-parse", "--git-dir"],
3749            Reply::ok(gd.path().to_str().unwrap()),
3750        ));
3751        let apply = gd.path().join("rebase-apply");
3752        std::fs::create_dir_all(&apply).unwrap();
3753
3754        // With the `applying` marker → a `git am`.
3755        std::fs::write(apply.join("applying"), b"").unwrap();
3756        assert!(
3757            git.is_am_in_progress(Path::new("/r")).await.unwrap(),
3758            "am detected"
3759        );
3760        assert!(
3761            !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3762            "a git am is NOT reported as a rebase"
3763        );
3764
3765        // Without it → an apply-backend rebase.
3766        std::fs::remove_file(apply.join("applying")).unwrap();
3767        assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
3768        assert!(
3769            git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
3770            "a bare rebase-apply dir is a rebase"
3771        );
3772    }
3773
3774    // T-044: the sequencer states each key off their own git-dir marker, and a
3775    // cherry-pick/revert conflict does NOT write `MERGE_HEAD` — so none of them is
3776    // mistaken for a merge (which would dispatch `merge --abort` on a real repo).
3777    #[tokio::test]
3778    async fn detects_cherry_pick_revert_and_bisect_markers() {
3779        use vcs_testkit::TempDir;
3780        let gd = TempDir::new("t044-seq");
3781        let git = Git::with_runner(ScriptedRunner::new().on(
3782            ["git", "rev-parse", "--git-dir"],
3783            Reply::ok(gd.path().to_str().unwrap()),
3784        ));
3785        let d = Path::new("/r");
3786        let touch = |name: &str| std::fs::write(gd.path().join(name), b"x\n").unwrap();
3787        let rm = |name: &str| std::fs::remove_file(gd.path().join(name)).unwrap();
3788
3789        // A cherry-pick: CHERRY_PICK_HEAD present, and crucially NOT read as a merge.
3790        touch("CHERRY_PICK_HEAD");
3791        assert!(git.is_cherry_pick_in_progress(d).await.unwrap());
3792        assert!(!git.is_merge_in_progress(d).await.unwrap());
3793        assert!(!git.is_revert_in_progress(d).await.unwrap());
3794        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3795        rm("CHERRY_PICK_HEAD");
3796
3797        // A revert.
3798        touch("REVERT_HEAD");
3799        assert!(git.is_revert_in_progress(d).await.unwrap());
3800        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3801        assert!(!git.is_merge_in_progress(d).await.unwrap());
3802        rm("REVERT_HEAD");
3803
3804        // A bisect (keyed off BISECT_LOG).
3805        touch("BISECT_LOG");
3806        assert!(git.is_bisect_in_progress(d).await.unwrap());
3807        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3808        assert!(!git.is_revert_in_progress(d).await.unwrap());
3809        rm("BISECT_LOG");
3810
3811        // Clean git dir → none fire.
3812        assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
3813        assert!(!git.is_revert_in_progress(d).await.unwrap());
3814        assert!(!git.is_bisect_in_progress(d).await.unwrap());
3815    }
3816
3817    // A non-zero exit surfaces as a structured `Error::Exit`.
3818    #[tokio::test]
3819    async fn nonzero_exit_is_structured_error() {
3820        let git = Git::with_runner(
3821            ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
3822        );
3823        match git.status(Path::new(".")).await.unwrap_err() {
3824            Error::Exit { code, stderr, .. } => {
3825                assert_eq!(code, 128);
3826                assert!(stderr.contains("not a git repository"), "{stderr}");
3827            }
3828            other => panic!("expected Exit, got {other:?}"),
3829        }
3830    }
3831
3832    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
3833    // anything else is a real failure surfaced as Error::Exit.
3834    #[tokio::test]
3835    async fn diff_is_empty_maps_exit_codes() {
3836        let clean =
3837            Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
3838        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
3839
3840        let dirty = Git::with_runner(
3841            ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
3842        );
3843        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
3844
3845        let broken = Git::with_runner(ScriptedRunner::new().on(
3846            ["git", "diff", "--quiet"],
3847            Reply::fail(128, "fatal: not a repo"),
3848        ));
3849        assert!(matches!(
3850            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
3851            Error::Exit { code: 128, .. }
3852        ));
3853    }
3854
3855    // `add` must insert `--` before the pathspecs so a path can never be parsed
3856    // as an option, and `--literal-pathspecs` so a glob-magic character in a
3857    // path matches literally (R-01). No fallback rule: the run only matches if
3858    // `--literal-pathspecs add --` was built.
3859    #[tokio::test]
3860    async fn add_inserts_pathspec_separator() {
3861        let git = Git::with_runner(
3862            ScriptedRunner::new().on(["git", "--literal-pathspecs", "add", "--"], Reply::ok("")),
3863        );
3864        git.add(Path::new("."), &[PathBuf::from("f.rs")])
3865            .await
3866            .expect("add should build `--literal-pathspecs add -- <paths>`");
3867    }
3868
3869    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` must route
3870    // through the NUL-safe `--pathspec-from-file=- --pathspec-file-nul`
3871    // transport instead of the plain `add -- <paths>` argv: no per-path argv
3872    // entries, the payload travels over stdin instead (T-052).
3873    #[tokio::test]
3874    async fn add_large_path_set_uses_pathspec_from_file_stdin() {
3875        let rec = RecordingRunner::replying(Reply::ok(""));
3876        let git = Git::with_runner(&rec);
3877        let paths: Vec<PathBuf> = (0..2_000)
3878            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
3879            .collect();
3880        git.add(Path::new("."), &paths).await.expect("add");
3881        let call = rec.only_call();
3882        assert_eq!(
3883            call.args_str(),
3884            [
3885                "--literal-pathspecs",
3886                "add",
3887                "--pathspec-from-file=-",
3888                "--pathspec-file-nul",
3889            ]
3890        );
3891        assert!(call.has_stdin, "paths must travel over stdin, not argv");
3892    }
3893
3894    #[tokio::test]
3895    async fn worktree_list_parses_porcelain() {
3896        let git = Git::with_runner(ScriptedRunner::new().on(
3897            ["git", "worktree", "list"],
3898            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
3899        ));
3900        let wts = git.worktree_list(Path::new(".")).await.expect("list");
3901        assert_eq!(wts.len(), 1);
3902        assert_eq!(wts[0].branch.as_deref(), Some("main"));
3903        assert_eq!(wts[0].head.as_deref(), Some("abc"));
3904    }
3905
3906    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
3907    // in that exact order; only the full argv is scripted (no fallback).
3908    #[tokio::test]
3909    async fn worktree_add_builds_branch_path_and_base() {
3910        let rec = RecordingRunner::replying(Reply::ok(""));
3911        let git = Git::with_runner(&rec);
3912        git.worktree_add(
3913            Path::new("/repo"),
3914            WorktreeAdd::create_branch("/wt", rn("feature"), rv("main")),
3915        )
3916        .await
3917        .expect("worktree add");
3918        assert_eq!(
3919            rec.only_call().args_str(),
3920            ["worktree", "add", "-b", "feature", "/wt", "main"]
3921        );
3922    }
3923
3924    #[tokio::test]
3925    async fn worktree_remove_passes_force_then_path() {
3926        let rec = RecordingRunner::replying(Reply::ok(""));
3927        let git = Git::with_runner(&rec);
3928        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt").force())
3929            .await
3930            .expect("remove");
3931        assert_eq!(
3932            rec.only_call().args_str(),
3933            ["worktree", "remove", "--force", "/wt"]
3934        );
3935    }
3936
3937    // The default (un-forced) spec omits `--force`.
3938    #[tokio::test]
3939    async fn worktree_remove_default_omits_force() {
3940        let rec = RecordingRunner::replying(Reply::ok(""));
3941        let git = Git::with_runner(&rec);
3942        git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt"))
3943            .await
3944            .expect("remove");
3945        assert_eq!(rec.only_call().args_str(), ["worktree", "remove", "/wt"]);
3946    }
3947
3948    // `--no-checkout` must land between `-b <name>` and the path.
3949    #[tokio::test]
3950    async fn worktree_add_no_checkout_inserts_flag() {
3951        let rec = RecordingRunner::replying(Reply::ok(""));
3952        let git = Git::with_runner(&rec);
3953        git.worktree_add(
3954            Path::new("/repo"),
3955            WorktreeAdd::checkout("/wt", rv("main")).no_checkout(),
3956        )
3957        .await
3958        .expect("worktree add");
3959        assert_eq!(
3960            rec.only_call().args_str(),
3961            ["worktree", "add", "--no-checkout", "/wt", "main"]
3962        );
3963    }
3964
3965    #[tokio::test]
3966    async fn checkout_detach_builds_args() {
3967        let rec = RecordingRunner::replying(Reply::ok(""));
3968        let git = Git::with_runner(&rec);
3969        git.checkout_detach(Path::new("."), &rv("abc123"))
3970            .await
3971            .expect("detach");
3972        assert_eq!(
3973            rec.only_call().args_str(),
3974            ["checkout", "--detach", "abc123"]
3975        );
3976    }
3977
3978    // current_branch reads `symbolic-ref --quiet --short HEAD`: exit 0 → the branch
3979    // name (a normal *or* unborn branch), exit 1 → None (detached HEAD), and any
3980    // other non-zero (e.g. not a repository) stays a real error.
3981    #[tokio::test]
3982    async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
3983        // A normal branch (exit 0) — and the argv is pinned.
3984        let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
3985        let on_branch = Git::with_runner(&rec);
3986        assert_eq!(
3987            on_branch.current_branch(Path::new(".")).await.unwrap(),
3988            Some("feature/x".to_string())
3989        );
3990        assert_eq!(
3991            rec.only_call().args_str(),
3992            ["symbolic-ref", "--quiet", "--short", "HEAD"]
3993        );
3994        // An unborn branch also exits 0 with the branch name (the bug this fixes:
3995        // the old `rev-parse --abbrev-ref HEAD` errored with exit 128 here).
3996        let unborn = Git::with_runner(
3997            ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
3998        );
3999        assert_eq!(
4000            unborn.current_branch(Path::new(".")).await.unwrap(),
4001            Some("main".to_string())
4002        );
4003        // A detached HEAD exits 1 silently → None.
4004        let detached =
4005            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
4006        assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
4007        // Any other non-zero (not a repository, exit 128) is a real error.
4008        let not_repo = Git::with_runner(ScriptedRunner::new().on(
4009            ["git", "symbolic-ref"],
4010            Reply::fail(128, "fatal: not a git repository"),
4011        ));
4012        assert!(not_repo.current_branch(Path::new(".")).await.is_err());
4013    }
4014
4015    // Partial amend commit must build `--literal-pathspecs commit --amend -m
4016    // <msg> --only -- <paths>` — `--literal-pathspecs` so a glob-magic
4017    // character in a path matches literally (R-01).
4018    #[tokio::test]
4019    async fn commit_paths_builds_only_amend_args() {
4020        let rec = RecordingRunner::replying(Reply::ok(""));
4021        let git = Git::with_runner(&rec);
4022        git.commit_paths(
4023            Path::new("."),
4024            CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
4025        )
4026        .await
4027        .expect("commit_paths");
4028        assert_eq!(
4029            rec.only_call().args_str(),
4030            [
4031                "--literal-pathspecs",
4032                "commit",
4033                "--amend",
4034                "-m",
4035                "msg",
4036                "--only",
4037                "--",
4038                "a.rs",
4039                "b.rs"
4040            ]
4041        );
4042    }
4043
4044    // Same transport switch as `add`'s twin test: a path set over
4045    // `ARGV_PATHSPEC_BUDGET` commits through `--pathspec-from-file=-
4046    // --pathspec-file-nul` (paths over stdin) instead of a plain `-- <paths>`
4047    // argv tail — and it is still exactly **one** `git commit` call (T-052).
4048    #[tokio::test]
4049    async fn commit_paths_large_path_set_uses_pathspec_from_file_stdin() {
4050        let rec = RecordingRunner::replying(Reply::ok(""));
4051        let git = Git::with_runner(&rec);
4052        let paths: Vec<PathBuf> = (0..2_000)
4053            .map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
4054            .collect();
4055        git.commit_paths(Path::new("."), CommitPaths::new(paths, "msg").amend())
4056            .await
4057            .expect("commit_paths");
4058        let calls = rec.calls();
4059        assert_eq!(calls.len(), 1, "must be a single atomic commit invocation");
4060        let call = &calls[0];
4061        assert_eq!(
4062            call.args_str(),
4063            [
4064                "--literal-pathspecs",
4065                "commit",
4066                "--amend",
4067                "-m",
4068                "msg",
4069                "--only",
4070                "--pathspec-from-file=-",
4071                "--pathspec-file-nul",
4072            ]
4073        );
4074        assert!(call.has_stdin, "paths must travel over stdin, not argv");
4075    }
4076
4077    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
4078    // unborn (true), anything else is a structured error.
4079    #[tokio::test]
4080    async fn is_unborn_maps_exit_codes() {
4081        let born =
4082            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
4083        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
4084        let unborn =
4085            Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
4086        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
4087        let broken = Git::with_runner(
4088            ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
4089        );
4090        assert!(matches!(
4091            broken.is_unborn(Path::new(".")).await.unwrap_err(),
4092            Error::Exit { code: 128, .. }
4093        ));
4094    }
4095
4096    #[tokio::test]
4097    async fn log_builds_revspec_and_format() {
4098        let rec = RecordingRunner::replying(Reply::ok(""));
4099        let git = Git::with_runner(&rec);
4100        git.log(Path::new("."), &rv("main..HEAD"), 5)
4101            .await
4102            .expect("log");
4103        assert_eq!(
4104            rec.only_call().args_str(),
4105            [
4106                "log",
4107                "main..HEAD",
4108                "-n5",
4109                "-z",
4110                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
4111            ]
4112        );
4113    }
4114
4115    // `log_paths` must insert `--literal-pathspecs` (R-02) and `--` exactly
4116    // once, right before the pathspecs, after the same revspec/count/format
4117    // arguments `log` builds.
4118    #[tokio::test]
4119    async fn log_paths_builds_revspec_format_and_pathspec_separator() {
4120        let rec = RecordingRunner::replying(Reply::ok(""));
4121        let git = Git::with_runner(&rec);
4122        git.log_paths(
4123            Path::new("."),
4124            &rv("main..HEAD"),
4125            5,
4126            &["src/a.rs".to_string(), "src/b.rs".to_string()],
4127        )
4128        .await
4129        .expect("log_paths");
4130        let args = rec.only_call().args_str();
4131        assert_eq!(
4132            args,
4133            [
4134                "--literal-pathspecs",
4135                "log",
4136                "main..HEAD",
4137                "-n5",
4138                "-z",
4139                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4140                "--",
4141                "src/a.rs",
4142                "src/b.rs",
4143            ]
4144        );
4145        assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
4146    }
4147
4148    // An empty `paths` slice must NOT degrade to an unrestricted `git log` —
4149    // it's refused before any spawn (mirrors `commit_paths_refuses_empty_*` in
4150    // vcs-jj).
4151    #[tokio::test]
4152    async fn log_paths_refuses_empty_paths_without_spawning() {
4153        let rec = RecordingRunner::replying(Reply::ok(""));
4154        let git = Git::with_runner(&rec);
4155        let err = git
4156            .log_paths(Path::new("."), &rv("HEAD"), 5, &[])
4157            .await
4158            .expect_err("empty paths must be refused");
4159        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4160        assert!(rec.calls().is_empty(), "nothing may spawn");
4161    }
4162
4163    // A path set whose combined length exceeds `ARGV_PATHSPEC_BUDGET` splits
4164    // `log` into per-chunk calls (`git log` has no `--pathspec-from-file`
4165    // support, unlike `add`/`commit_paths`; each chunk call also carries
4166    // `--literal-pathspecs`, R-02) and merges the results: dedup by hash (the
4167    // "shared" commit appears in both chunks' canned output), reordered to
4168    // match a separate, pathless oracle call's commit order, capped at `max`
4169    // (T-052/R-03).
4170    //
4171    // All three commits share the exact same (second-resolution) author date
4172    // — a date-based sort would have no signal to order them at all and
4173    // could only fall back to arbitrary/input order — yet the oracle still
4174    // produces a definite, correct order, because it comes from git's own
4175    // traversal rather than from parsed timestamps. This is exactly the case
4176    // R-03 flagged as unfixable by refining the date sort further.
4177    #[tokio::test]
4178    async fn log_paths_large_path_set_chunks_dedupes_and_reorders_by_oracle_order() {
4179        // Two paths, each already over budget alone once paired — `chunk_pathspecs`
4180        // puts each in its own singleton chunk.
4181        let path_a = "a".repeat(4_000);
4182        let path_b = "b".repeat(4_000);
4183        // R-04: the chunked path resolves `revspec` once via `git rev-parse`
4184        // before any chunk/oracle call, then reuses the resolved token
4185        // (deliberately distinct from the literal `"HEAD"` text) everywhere
4186        // below — proving every one of those calls used the frozen snapshot,
4187        // not the original symbolic name.
4188        let common = [
4189            "git",
4190            "--literal-pathspecs",
4191            "log",
4192            "resolved-head-sha",
4193            "-n5",
4194            "-z",
4195            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4196            "--",
4197        ];
4198        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4199        chunk_a_args.push(path_a.clone());
4200        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4201        chunk_b_args.push(path_b.clone());
4202
4203        // Chunk A: "newer-a" + "shared", both dated 2026-01-02.
4204        let reply_a = Reply::ok(
4205            "aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0\
4206             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4207                .to_string(),
4208        );
4209        // Chunk B: "newest-b" (also dated 2026-01-02) + the SAME "shared"
4210        // commit again (touches a path in both chunks).
4211        let reply_b = Reply::ok(
4212            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-02T00:00:00Z\u{1f}newest-b\0\
4213             shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
4214                .to_string(),
4215        );
4216        // The oracle: git's real, unrestricted commit order for the same
4217        // revspec — puts "shared" between the other two, an order no sort of
4218        // the (identical) merged timestamps could ever reproduce.
4219        let order_reply = Reply::ok("bbb1\0shar\0aaa1\0".to_string());
4220
4221        let git = Git::with_runner(
4222            ScriptedRunner::new()
4223                .on(
4224                    ["git", "rev-parse", "HEAD"],
4225                    Reply::ok("resolved-head-sha\n".to_string()),
4226                )
4227                .on(chunk_a_args, reply_a)
4228                .on(chunk_b_args, reply_b)
4229                .on(
4230                    ["git", "log", "resolved-head-sha", "-z", "--format=%H"],
4231                    order_reply,
4232                ),
4233        );
4234
4235        let commits = git
4236            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4237            .await
4238            .expect("log_paths");
4239
4240        assert_eq!(
4241            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
4242            ["bbb1", "shar", "aaa1"],
4243            "expected the oracle's commit order across chunks, with the shared \
4244             commit deduplicated"
4245        );
4246    }
4247
4248    // The single-call path (small path sets) must return byte-identical order
4249    // to what a chunked call over the same commits produces via the oracle —
4250    // i.e. chunking never changes which order callers see for a path set
4251    // that happens to be small (T-052/R-03 regression guard).
4252    #[tokio::test]
4253    async fn log_paths_single_call_and_chunked_call_agree_on_order() {
4254        let single_reply = Reply::ok(
4255            "bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0\
4256             aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0",
4257        );
4258        let single_call_git = Git::with_runner(ScriptedRunner::new().on(
4259            [
4260                "git",
4261                "--literal-pathspecs",
4262                "log",
4263                "HEAD",
4264                "-n5",
4265                "-z",
4266                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4267                "--",
4268                "src/a.rs",
4269                "src/b.rs",
4270            ],
4271            single_reply,
4272        ));
4273        let single_commits = single_call_git
4274            .log_paths(
4275                Path::new("."),
4276                &rv("HEAD"),
4277                5,
4278                &["src/a.rs".to_string(), "src/b.rs".to_string()],
4279            )
4280            .await
4281            .expect("log_paths");
4282
4283        let path_a = "a".repeat(4_000);
4284        let path_b = "b".repeat(4_000);
4285        let common = [
4286            "git",
4287            "--literal-pathspecs",
4288            "log",
4289            "HEAD",
4290            "-n5",
4291            "-z",
4292            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4293            "--",
4294        ];
4295        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4296        chunk_a_args.push(path_a.clone());
4297        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4298        chunk_b_args.push(path_b.clone());
4299        let reply_a =
4300            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4301        let reply_b =
4302            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4303        // The oracle agrees with the single-call order: "newest-b" before
4304        // "newer-a".
4305        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4306        let chunked_git = Git::with_runner(
4307            ScriptedRunner::new()
4308                // R-04: the chunked path resolves `revspec` once via `git
4309                // rev-parse` before the chunk/oracle calls below.
4310                .on(
4311                    ["git", "rev-parse", "HEAD"],
4312                    Reply::ok("HEAD\n".to_string()),
4313                )
4314                .on(chunk_a_args, reply_a)
4315                .on(chunk_b_args, reply_b)
4316                .on(["git", "log", "HEAD", "-z", "--format=%H"], order_reply),
4317        );
4318        let chunked_commits = chunked_git
4319            .log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
4320            .await
4321            .expect("log_paths");
4322
4323        assert_eq!(
4324            single_commits
4325                .iter()
4326                .map(|c| c.hash.as_str())
4327                .collect::<Vec<_>>(),
4328            chunked_commits
4329                .iter()
4330                .map(|c| c.hash.as_str())
4331                .collect::<Vec<_>>(),
4332            "single-call and chunked-call order must agree when the oracle \
4333             agrees with the single-call order"
4334        );
4335    }
4336
4337    // R-04: a range revspec (`A..B`) resolves via `git rev-parse` to *two*
4338    // tokens — the tip and a `^`-prefixed exclusion — and both chunk calls
4339    // plus the oracle call must forward both tokens verbatim, not just the
4340    // original `"main..feature"` text. This is exactly the expansion `git
4341    // log` would perform internally, so it's behavior-preserving while also
4342    // fixing the moving-ref race (a concurrent `main` or `feature` move
4343    // between chunk/oracle calls can no longer change what any of them see,
4344    // since all of them now share the one resolution taken up front).
4345    #[tokio::test]
4346    async fn log_paths_range_revspec_is_resolved_once_and_reused_across_chunks() {
4347        let path_a = "a".repeat(4_000);
4348        let path_b = "b".repeat(4_000);
4349        let common = [
4350            "git",
4351            "--literal-pathspecs",
4352            "log",
4353            "feature-sha",
4354            "^main-sha",
4355            "-n5",
4356            "-z",
4357            "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
4358            "--",
4359        ];
4360        let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4361        chunk_a_args.push(path_a.clone());
4362        let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
4363        chunk_b_args.push(path_b.clone());
4364        let reply_a =
4365            Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
4366        let reply_b =
4367            Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
4368        let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
4369
4370        let git = Git::with_runner(
4371            ScriptedRunner::new()
4372                .on(
4373                    ["git", "rev-parse", "main..feature"],
4374                    Reply::ok("feature-sha\n^main-sha\n".to_string()),
4375                )
4376                .on(chunk_a_args, reply_a)
4377                .on(chunk_b_args, reply_b)
4378                .on(
4379                    [
4380                        "git",
4381                        "log",
4382                        "feature-sha",
4383                        "^main-sha",
4384                        "-z",
4385                        "--format=%H",
4386                    ],
4387                    order_reply,
4388                ),
4389        );
4390
4391        let commits = git
4392            .log_paths(Path::new("."), &rv("main..feature"), 5, &[path_a, path_b])
4393            .await
4394            .expect("log_paths");
4395        assert_eq!(
4396            commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
4397            ["bbb1", "aaa1"]
4398        );
4399    }
4400
4401    // R-05: `git log` has no NUL-safe fallback transport the way
4402    // `add`/`commit_paths` do, so a single path that alone exceeds the argv
4403    // budget must be refused up front — never spawned as an over-budget
4404    // singleton chunk.
4405    #[tokio::test]
4406    async fn log_paths_rejects_individually_oversized_path_without_spawning() {
4407        let rec = RecordingRunner::replying(Reply::ok(""));
4408        let git = Git::with_runner(&rec);
4409        let huge = "z".repeat(ARGV_PATHSPEC_BUDGET + 1);
4410        let err = git
4411            .log_paths(Path::new("."), &rv("HEAD"), 5, &[huge])
4412            .await
4413            .expect_err("an individually oversized path must be refused");
4414        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4415        assert!(rec.calls().is_empty(), "nothing may spawn");
4416    }
4417
4418    // --- T-052 pure-helper tests ----------------------------------------------
4419
4420    #[test]
4421    fn pathspec_argv_len_sums_bytes_plus_one_per_path() {
4422        use std::ffi::OsStr;
4423        let paths = [OsStr::new("a.rs"), OsStr::new("dir/b.rs")];
4424        // "a.rs" (4) + 1, "dir/b.rs" (8) + 1.
4425        assert_eq!(pathspec_argv_len(paths), 5 + 9);
4426        assert_eq!(pathspec_argv_len(std::iter::empty()), 0);
4427    }
4428
4429    // Each path lands verbatim between NUL separators, in order — including a
4430    // leading dash, embedded spaces, and a glob-magic character (the whole
4431    // point of the `--literal-pathspecs` + NUL transport: no argv/shell layer
4432    // to mis-parse them, and git is told not to treat them as pathspec magic
4433    // either).
4434    #[test]
4435    fn pathspec_nul_bytes_joins_paths_literally_in_order() {
4436        use std::ffi::OsStr;
4437        let paths = [
4438            OsStr::new("-weird.txt"),
4439            OsStr::new("has space.txt"),
4440            OsStr::new("glob[1].txt"),
4441        ];
4442        let bytes = pathspec_nul_bytes(paths).expect("no embedded NUL");
4443        assert_eq!(bytes, b"-weird.txt\0has space.txt\0glob[1].txt\0".to_vec());
4444    }
4445
4446    // An embedded NUL byte would silently truncate a pathspec-file-nul entry,
4447    // splitting one path into two on the very separator the transport relies
4448    // on — refused before anything is built, so `commit_paths`'s "no partial
4449    // result" contract holds even for this input-prep failure.
4450    #[test]
4451    fn pathspec_nul_bytes_rejects_embedded_nul() {
4452        use std::ffi::OsStr;
4453        // A real filesystem path can't contain a NUL byte, but the guard must
4454        // still catch a pathological caller-constructed one.
4455        let bad = unsafe { OsStr::from_encoded_bytes_unchecked(b"a\0b") };
4456        let err = pathspec_nul_bytes([bad]).expect_err("embedded NUL must be refused");
4457        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4458    }
4459
4460    #[test]
4461    fn chunk_pathspecs_packs_under_budget_and_splits_over_it() {
4462        let short = vec!["a".to_string(), "b".to_string(), "c".to_string()];
4463        assert_eq!(chunk_pathspecs(&short), vec![vec!["a", "b", "c"]]);
4464
4465        // Two paths that individually fit but together exceed the budget split
4466        // into two singleton chunks, in order.
4467        let big_a = "a".repeat(ARGV_PATHSPEC_BUDGET - 100);
4468        let big_b = "b".repeat(ARGV_PATHSPEC_BUDGET - 100);
4469        let big = vec![big_a.clone(), big_b.clone()];
4470        assert_eq!(
4471            chunk_pathspecs(&big),
4472            vec![vec![big_a.as_str()], vec![big_b.as_str()]]
4473        );
4474
4475        // A single path already over budget on its own still gets a (singleton)
4476        // chunk — nothing shorter is possible.
4477        let huge = vec!["z".repeat(ARGV_PATHSPEC_BUDGET * 2)];
4478        assert_eq!(chunk_pathspecs(&huge), vec![vec![huge[0].as_str()]]);
4479
4480        assert!(chunk_pathspecs(&[]).is_empty());
4481    }
4482
4483    #[test]
4484    fn parse_commit_order_splits_on_nul_and_drops_trailing_empty() {
4485        assert_eq!(
4486            parse_commit_order("aaa1\0bbb2\0ccc3\0"),
4487            vec!["aaa1", "bbb2", "ccc3"]
4488        );
4489        assert!(parse_commit_order("").is_empty());
4490    }
4491
4492    #[tokio::test]
4493    async fn stash_push_adds_include_untracked() {
4494        let rec = RecordingRunner::replying(Reply::ok(""));
4495        let git = Git::with_runner(&rec);
4496        git.stash_push(Path::new("."), StashPush::new().include_untracked())
4497            .await
4498            .expect("stash");
4499        assert_eq!(
4500            rec.only_call().args_str(),
4501            ["stash", "push", "--include-untracked"]
4502        );
4503    }
4504
4505    // The default spec stashes tracked changes only — no `--include-untracked`.
4506    #[tokio::test]
4507    async fn stash_push_default_omits_include_untracked() {
4508        let rec = RecordingRunner::replying(Reply::ok(""));
4509        let git = Git::with_runner(&rec);
4510        git.stash_push(Path::new("."), StashPush::new())
4511            .await
4512            .expect("stash");
4513        assert_eq!(rec.only_call().args_str(), ["stash", "push"]);
4514    }
4515
4516    // `diff_text` for the working tree must build `diff HEAD` plus the stable
4517    // machine-output flags, in order.
4518    #[tokio::test]
4519    async fn diff_text_builds_working_tree_args() {
4520        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
4521        // targets HEAD. The probe is the first call; the diff is the last.
4522        let rec = RecordingRunner::replying(Reply::ok(""));
4523        let git = Git::with_runner(&rec);
4524        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
4525            .await
4526            .expect("diff_text");
4527        assert_eq!(
4528            rec.calls().last().unwrap().args_str(),
4529            [
4530                "diff",
4531                "HEAD",
4532                "--no-color",
4533                "--no-ext-diff",
4534                "-M",
4535                // Pin the parser's `a/`…`b/` headers against a user's
4536                // `diff.noprefix`/`diff.mnemonicPrefix` config.
4537                "--src-prefix=a/",
4538                "--dst-prefix=b/",
4539                // End-of-revisions: `HEAD` is a revision, never a pathspec.
4540                "--",
4541            ]
4542        );
4543    }
4544
4545    // On an unborn repo the working-tree diff targets the empty tree instead of
4546    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
4547    // empty-tree id is resolved from git (`hash-object`, so it is object-format
4548    // correct — not the hard-coded SHA-1 id), and the diff rule only matches that
4549    // resolved argv, so a `HEAD` target would miss it.
4550    #[tokio::test]
4551    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
4552        // A stand-in id `empty_tree_oid` "computes"; the diff must target exactly it.
4553        let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
4554        let git = Git::with_runner(
4555            ScriptedRunner::new()
4556                .on(["git", "rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
4557                .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
4558                .on(["git", "diff", oid], Reply::ok("EMPTY")),
4559        );
4560        let out = git
4561            .diff_text(Path::new("."), DiffSpec::WorkingTree)
4562            .await
4563            .expect("diff_text");
4564        assert_eq!(out, "EMPTY");
4565    }
4566
4567    // `empty_tree_oid` asks git to hash an empty tree (`hash-object -t tree
4568    // --stdin`), so the id tracks the repo's object format instead of being a
4569    // hard-coded SHA-1 constant. The `--stdin` (not `-w`) form only computes it.
4570    #[tokio::test]
4571    async fn empty_tree_oid_hashes_an_empty_tree() {
4572        let rec = RecordingRunner::replying(Reply::ok(format!("{EMPTY_TREE_SHA1}\n")));
4573        let git = Git::with_runner(&rec);
4574        let oid = git
4575            .empty_tree_oid(Path::new("."))
4576            .await
4577            .expect("empty_tree_oid");
4578        assert_eq!(oid, EMPTY_TREE_SHA1);
4579        assert_eq!(
4580            rec.only_call().args_str(),
4581            ["hash-object", "-t", "tree", "--stdin"]
4582        );
4583    }
4584
4585    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
4586    // canned git-format output.
4587    #[tokio::test]
4588    async fn diff_parses_scripted_output() {
4589        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
4590        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
4591        let files = git
4592            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
4593            .await
4594            .expect("diff");
4595        assert_eq!(files.len(), 1);
4596        assert_eq!(files[0].path, Path::new("m"));
4597        assert_eq!(files[0].change, ChangeKind::Modified);
4598    }
4599
4600    #[tokio::test]
4601    async fn branch_exists_maps_exit_codes() {
4602        let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
4603        assert!(
4604            yes.branch_exists(Path::new("."), &rn("main"))
4605                .await
4606                .unwrap()
4607        );
4608        let no =
4609            Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
4610        assert!(!no.branch_exists(Path::new("."), &rn("nope")).await.unwrap());
4611    }
4612
4613    // The full ref prefix is stripped but a slashed default branch survives; an
4614    // unset origin/HEAD (non-zero exit) is `None`, not an error.
4615    #[tokio::test]
4616    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
4617        let simple = Git::with_runner(ScriptedRunner::new().on(
4618            ["git", "symbolic-ref"],
4619            Reply::ok("refs/remotes/origin/main\n"),
4620        ));
4621        assert_eq!(
4622            simple
4623                .remote_head_branch(Path::new("."))
4624                .await
4625                .unwrap()
4626                .as_deref(),
4627            Some("main")
4628        );
4629
4630        let slashed = Git::with_runner(ScriptedRunner::new().on(
4631            ["git", "symbolic-ref"],
4632            Reply::ok("refs/remotes/origin/release/v2\n"),
4633        ));
4634        assert_eq!(
4635            slashed
4636                .remote_head_branch(Path::new("."))
4637                .await
4638                .unwrap()
4639                .as_deref(),
4640            Some("release/v2")
4641        );
4642
4643        let unset =
4644            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
4645        assert!(
4646            unset
4647                .remote_head_branch(Path::new("."))
4648                .await
4649                .unwrap()
4650                .is_none()
4651        );
4652    }
4653
4654    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
4655    // stdout as "absent".
4656    #[tokio::test]
4657    async fn remote_branch_exists_sets_env_and_reads_stdout() {
4658        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
4659        let git = Git::with_runner(&rec);
4660        assert!(
4661            git.remote_branch_exists(Path::new("/repo"), &rn("main"))
4662                .await
4663                .unwrap()
4664        );
4665        let call = rec.only_call();
4666        assert!(call.envs.iter().any(|(k, v)| {
4667            k.to_str() == Some("GIT_TERMINAL_PROMPT")
4668                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4669        }));
4670        // Exact-ref query — a bare `main` would tail-match `bar/main`.
4671        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
4672
4673        let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
4674        assert!(
4675            !empty
4676                .remote_branch_exists(Path::new("."), &rn("x"))
4677                .await
4678                .unwrap()
4679        );
4680    }
4681
4682    // The glob/control/`:`/space names `remote_branch_exists` must exclude are now
4683    // refused at `RefName` construction — an invalid value can't reach the method
4684    // at all, so the exclusion is enforced by the type rather than an ad-hoc guard.
4685    #[test]
4686    fn remote_branch_invalid_names_rejected_at_refname() {
4687        for name in [
4688            "",
4689            "feature/*",
4690            "feature/?",
4691            "feature/[a]",
4692            "a:b",
4693            "two words",
4694            "bad\nname",
4695        ] {
4696            let err = RefName::new(name).expect_err("invalid remote branch name must be rejected");
4697            assert!(vcs_cli_support::is_invalid_input(&err), "{name:?}");
4698        }
4699    }
4700
4701    #[tokio::test]
4702    async fn remote_branch_exists_accepts_valid_names() {
4703        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/feature/T-010_fix\n"));
4704        let git = Git::with_runner(&rec);
4705
4706        assert!(
4707            git.remote_branch_exists(Path::new("/repo"), &rn("feature/T-010_fix"))
4708                .await
4709                .expect("valid remote branch name")
4710        );
4711        assert_eq!(
4712            rec.only_call().args_str(),
4713            ["ls-remote", "origin", "refs/heads/feature/T-010_fix"]
4714        );
4715    }
4716
4717    // `remote_branch_exists` sets a per-command `Command::timeout` (10 s) so an
4718    // unreachable or hung remote can't wedge the call — the "bounded wait" its own
4719    // doc-comment promises. That bound is only hermetically testable because of the
4720    // processkit 2.1 guarantee that a `ScriptedRunner` **pending** reply on a bulk
4721    // verb (`output_string`) now honors `Command::timeout`; under the old 1.2.x
4722    // semantics a pending reply parked forever regardless of the deadline. On a
4723    // paused clock the command's 10 s deadline elapses in virtual time, so a hung
4724    // `ls-remote` resolves as "absent" (`false`, empty output) instead of hanging.
4725    // The outer 1 h guard turns a regression (pending parking forever) into a clear
4726    // failure rather than a wedged suite; the command's own 10 s bound fires first.
4727    #[tokio::test(start_paused = true)]
4728    async fn remote_branch_exists_bounded_wait_resolves_a_hung_remote() {
4729        let git =
4730            Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::pending()));
4731        let name = rn("main");
4732        let probe = git.remote_branch_exists(Path::new("/r"), &name);
4733        let exists = tokio::time::timeout(std::time::Duration::from_secs(3600), probe)
4734            .await
4735            .expect("the per-command 10 s timeout must resolve a hung ls-remote")
4736            .expect("a timed-out best-effort probe is `Ok(false)`, not an error");
4737        assert!(!exists, "an unreachable remote reads as absent");
4738    }
4739
4740    #[tokio::test]
4741    async fn diff_stat_parses_counts() {
4742        let git = Git::with_runner(ScriptedRunner::new().on(
4743            ["git", "diff", "--shortstat"],
4744            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
4745        ));
4746        let stat = git
4747            .diff_stat(Path::new("."), &rv("main..HEAD"))
4748            .await
4749            .unwrap();
4750        assert_eq!(
4751            (stat.files_changed, stat.insertions, stat.deletions),
4752            (2, 5, 1)
4753        );
4754    }
4755
4756    // The range-taking diff verbs terminate their argv with `--` so a `range`
4757    // that names a tracked path resolves as a revision (and errors) rather than
4758    // silently degrading into a pathspec-scoped working-tree diff (C2/M13).
4759    #[tokio::test]
4760    async fn diff_range_verbs_terminate_revisions_with_dashes() {
4761        let rec = RecordingRunner::replying(Reply::ok(""));
4762        let git = Git::with_runner(&rec);
4763        git.diff_range_is_empty(Path::new("/r"), &rv("main..HEAD"))
4764            .await
4765            .expect("diff_range_is_empty");
4766        assert_eq!(
4767            rec.only_call().args_str(),
4768            ["diff", "--quiet", "main..HEAD", "--"]
4769        );
4770
4771        let rec = RecordingRunner::replying(Reply::ok(" 0 files changed\n"));
4772        let git = Git::with_runner(&rec);
4773        git.diff_stat(Path::new("/r"), &rv("main..HEAD"))
4774            .await
4775            .expect("diff_stat");
4776        assert_eq!(
4777            rec.only_call().args_str(),
4778            ["diff", "--shortstat", "main..HEAD", "--"]
4779        );
4780    }
4781
4782    #[tokio::test]
4783    async fn status_text_returns_raw_porcelain() {
4784        let git = Git::with_runner(ScriptedRunner::new().on(
4785            ["git", "status", "--porcelain=v1"],
4786            Reply::ok(" M a.rs\n?? b.rs\n"),
4787        ));
4788        let text = git.status_text(Path::new(".")).await.expect("status_text");
4789        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
4790    }
4791
4792    #[tokio::test]
4793    async fn run_args_forwards_str_slices() {
4794        let git =
4795            Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
4796        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
4797    }
4798
4799    #[tokio::test]
4800    async fn merge_commit_builds_no_ff_and_message() {
4801        let rec = RecordingRunner::replying(Reply::ok(""));
4802        let git = Git::with_runner(&rec);
4803        git.merge_commit(
4804            Path::new("/r"),
4805            MergeCommit::branch(rv("feature"))
4806                .no_ff()
4807                .message("merge it"),
4808        )
4809        .await
4810        .unwrap();
4811        assert_eq!(
4812            rec.only_call().args_str(),
4813            ["merge", "--no-ff", "-m", "merge it", "feature"]
4814        );
4815    }
4816
4817    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
4818    #[tokio::test]
4819    async fn merge_commit_without_message_uses_no_edit() {
4820        let rec = RecordingRunner::replying(Reply::ok(""));
4821        let git = Git::with_runner(&rec);
4822        git.merge_commit(Path::new("/r"), MergeCommit::branch(rv("feature")))
4823            .await
4824            .unwrap();
4825        assert_eq!(
4826            rec.only_call().args_str(),
4827            ["merge", "--no-edit", "feature"]
4828        );
4829    }
4830
4831    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
4832    #[tokio::test]
4833    async fn rebase_suppresses_editor() {
4834        let rec = RecordingRunner::replying(Reply::ok(""));
4835        let git = Git::with_runner(&rec);
4836        git.rebase(Path::new("/r"), &rv("main")).await.unwrap();
4837        let call = rec.only_call();
4838        assert_eq!(call.args_str(), ["rebase", "main"]);
4839        assert!(call.envs.iter().any(|(k, v)| {
4840            k.to_str() == Some("GIT_EDITOR")
4841                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
4842        }));
4843    }
4844
4845    #[tokio::test]
4846    async fn push_builds_set_upstream_remote_refspec() {
4847        let rec = RecordingRunner::replying(Reply::ok(""));
4848        let git = Git::with_runner(&rec);
4849        git.push(
4850            Path::new("/r"),
4851            GitPush::refspec(&rn("feat"), &rn("feature")).set_upstream(),
4852        )
4853        .await
4854        .unwrap();
4855        assert_eq!(
4856            rec.only_call().args_str(),
4857            ["push", "-u", "origin", "feat:feature"]
4858        );
4859    }
4860
4861    // The common bare-branch push: `push origin <branch>` (no `-u`), with prompts
4862    // off so a credential-needing remote fails fast instead of hanging.
4863    #[tokio::test]
4864    async fn push_bare_branch_builds_origin_branch_prompt_off() {
4865        let rec = RecordingRunner::replying(Reply::ok(""));
4866        let git = Git::with_runner(&rec);
4867        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4868            .await
4869            .unwrap();
4870        let call = rec.only_call();
4871        assert_eq!(call.args_str(), ["push", "origin", "feature"]);
4872        assert!(call.envs.iter().any(|(k, v)| {
4873            k.to_str() == Some("GIT_TERMINAL_PROMPT")
4874                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4875        }));
4876    }
4877
4878    // M16: a `+` (force-push) or an extra `:` (multi-ref) smuggled into a branch name
4879    // is refused before spawning — force-pushing must be explicit via `run`.
4880    #[tokio::test]
4881    async fn push_rejects_force_and_multiref_metacharacters() {
4882        let rec = RecordingRunner::replying(Reply::ok(""));
4883        let git = Git::with_runner(&rec);
4884        // A `:` (an extra ref) is not a legal `RefName` character, so a multi-ref
4885        // refspec is refused at construction — it can never reach `GitPush`.
4886        for bad in ["+main:main", "a:b:c", "main:prod"] {
4887            assert!(RefName::new(bad).is_err(), "{bad:?} carries a `:`");
4888        }
4889        // A leading `+` (force) IS a legal ref character, so it passes `RefName` —
4890        // but the push refspec guard still refuses it before spawning. A force-push
4891        // must be explicit via `run`.
4892        assert!(
4893            git.push(Path::new("/r"), GitPush::branch(rn("+main")))
4894                .await
4895                .is_err(),
4896            "a force refspec must be refused before spawning"
4897        );
4898        // A legitimate `local:remote` refspec still works (the single `:` is the
4899        // API-inserted separator between two validated `RefName`s).
4900        assert!(
4901            git.push(Path::new("/r"), GitPush::refspec(&rn("main"), &rn("prod")))
4902                .await
4903                .is_ok()
4904        );
4905        assert!(
4906            rec.calls()
4907                .iter()
4908                .all(|c| c.args_str().last().unwrap() != "+main")
4909        );
4910    }
4911
4912    // `.remote()` swaps the remote token in place.
4913    #[tokio::test]
4914    async fn push_remote_override_swaps_remote() {
4915        let rec = RecordingRunner::replying(Reply::ok(""));
4916        let git = Git::with_runner(&rec);
4917        git.push(
4918            Path::new("/r"),
4919            GitPush::branch(rn("feature")).remote("upstream"),
4920        )
4921        .await
4922        .unwrap();
4923        assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
4924    }
4925
4926    // With a credential provider, a remote op gets a leading `-c credential.helper`
4927    // pair (the secret referenced by env-var NAME) plus the secret in the env — and
4928    // the token value never appears in argv. Covers push (mutating) and fetch.
4929    #[tokio::test]
4930    async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
4931        let rec = RecordingRunner::replying(Reply::ok(""));
4932        let git = Git::with_runner(&rec)
4933            .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
4934        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4935            .await
4936            .unwrap();
4937        let call = rec.only_call();
4938        let args = call.args_str();
4939        // A leading helper-reset + inline helper precede the subcommand.
4940        assert_eq!(args[0], "-c", "config flag leads the argv");
4941        assert!(
4942            args.iter().any(|a| a == "credential.helper="),
4943            "inherited helpers are cleared first: {args:?}"
4944        );
4945        assert!(
4946            args.iter()
4947                .any(|a| a.contains("credential.helper=!f()")
4948                    && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
4949            "inline helper references the secret by env-var name: {args:?}"
4950        );
4951        assert!(
4952            args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
4953            "the real subcommand still runs: {args:?}"
4954        );
4955        // The secret value is NEVER in argv.
4956        assert!(
4957            !args.iter().any(|a| a.contains("ghp_secret123")),
4958            "secret leaked into argv: {args:?}"
4959        );
4960        // The secret lives in the env, under the helper's var name.
4961        let pw = call
4962            .envs
4963            .iter()
4964            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
4965            .and_then(|(_, v)| v.as_ref())
4966            .and_then(|v| v.to_str());
4967        assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
4968    }
4969
4970    // Without a provider, remote ops are byte-identical to before — no `-c`
4971    // credential helper, no secret env (ambient git auth, unchanged).
4972    #[tokio::test]
4973    async fn default_client_injects_no_credential_helper() {
4974        let rec = RecordingRunner::replying(Reply::ok(""));
4975        let git = Git::with_runner(&rec);
4976        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
4977            .await
4978            .unwrap();
4979        let call = rec.only_call();
4980        assert_eq!(
4981            call.args_str(),
4982            ["push", "origin", "feature"],
4983            "no credential `-c` args without a provider"
4984        );
4985        assert!(
4986            !call
4987                .envs
4988                .iter()
4989                .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
4990            "no secret env without a provider"
4991        );
4992    }
4993
4994    // `clone_repo` builds its argv via a different path (`command()` + `.arg()`
4995    // chaining, not `command_in` + extend), so verify the `-c` credential args
4996    // still LEAD it and the real clone flags/url/dest follow the subcommand.
4997    #[tokio::test]
4998    async fn with_credentials_clone_puts_config_flags_before_subcommand() {
4999        let rec = RecordingRunner::replying(Reply::ok(""));
5000        let git =
5001            Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
5002        git.clone_repo(
5003            "https://example.com/r.git",
5004            Path::new("/dest"),
5005            CloneSpec::default().branch("main"),
5006        )
5007        .await
5008        .unwrap();
5009        let call = rec.only_call();
5010        let args = call.args_str();
5011        assert_eq!(args[0], "-c", "config flags lead the clone argv");
5012        let clone_at = args
5013            .iter()
5014            .position(|a| a == "clone")
5015            .expect("clone present");
5016        // Only credential `-c` flags precede the `clone` subcommand.
5017        assert!(
5018            args[..clone_at]
5019                .iter()
5020                .all(|a| a == "-c" || a.starts_with("credential.helper")),
5021            "only credential -c flags precede `clone`: {args:?}"
5022        );
5023        // The real clone flags/url/dest follow the subcommand.
5024        let tail = &args[clone_at..];
5025        assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
5026        assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
5027        assert!(
5028            !args.iter().any(|a| a.contains("s3cr3t")),
5029            "secret not in argv"
5030        );
5031        // H5: clone scopes the helper to the URL's host (in env, never argv), so a
5032        // cross-host redirect/submodule during the clone can't extract the token.
5033        let host = call
5034            .envs
5035            .iter()
5036            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
5037            .and_then(|(_, v)| v.as_ref())
5038            .and_then(|v| v.to_str());
5039        assert_eq!(
5040            host,
5041            Some("example.com"),
5042            "the clone URL's host scopes the credential helper"
5043        );
5044        // The host scoping travels in env; the credential `-c` flags that precede
5045        // `clone` must not bake the host into the helper config.
5046        assert!(
5047            args[..clone_at].iter().all(|a| !a.contains("example.com")),
5048            "host stays in env, not the credential config args: {:?}",
5049            &args[..clone_at]
5050        );
5051    }
5052
5053    // A `Credential::userpass` username threads through to the helper's env on a
5054    // remote op (here `fetch`) — the non-default-username path, end-to-end.
5055    #[tokio::test]
5056    async fn with_credentials_userpass_threads_username_through_env() {
5057        let rec = RecordingRunner::replying(Reply::ok(""));
5058        let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
5059            Credential::userpass("alice", "s3cr3t"),
5060        )));
5061        git.fetch(Path::new("/r")).await.unwrap();
5062        let call = rec.only_call();
5063        let user = call
5064            .envs
5065            .iter()
5066            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
5067            .and_then(|(_, v)| v.as_ref())
5068            .and_then(|v| v.to_str());
5069        assert_eq!(user, Some("alice"), "userpass username reaches the env");
5070        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
5071        assert!(call.args_str().contains(&"fetch".to_string()));
5072    }
5073
5074    // ONE client, several hosts: a host-keyed provider hands each clone only its OWN
5075    // host's secret — routed by the URL's host, which now reaches the
5076    // `CredentialRequest` — and the inline helper is gated to that host, so a
5077    // neighbouring instance's token can never leak into another host's clone. (T-045)
5078    #[tokio::test]
5079    async fn one_client_host_keyed_provider_isolates_tokens_across_hosts() {
5080        let provider = Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
5081            Ok(match r.host {
5082                Some("github.com") => Some(Credential::token("gh-secret")),
5083                Some("gitlab.example") => Some(Credential::token("gl-secret")),
5084                _ => None,
5085            })
5086        }));
5087        let rec = RecordingRunner::replying(Reply::ok(""));
5088        let git = Git::with_runner(&rec).with_credentials(provider);
5089
5090        // The same client clones two different hosts, back to back.
5091        git.clone_repo(
5092            "https://github.com/o/r.git",
5093            Path::new("/dest-gh"),
5094            CloneSpec::default(),
5095        )
5096        .await
5097        .unwrap();
5098        git.clone_repo(
5099            "https://gitlab.example/o/r.git",
5100            Path::new("/dest-gl"),
5101            CloneSpec::default(),
5102        )
5103        .await
5104        .unwrap();
5105
5106        let calls = rec.calls();
5107        assert_eq!(calls.len(), 2, "two clones recorded");
5108        // Clone #1 (github.com) → the github.com secret, gated to github.com.
5109        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
5110        assert!(calls[0].env_is("VCS_TOOLKIT_GIT_HOST", "github.com"));
5111        // Clone #2 (gitlab.example) → the gitlab secret, gated to gitlab.example.
5112        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
5113        assert!(calls[1].env_is("VCS_TOOLKIT_GIT_HOST", "gitlab.example"));
5114        // No cross-contamination: neither host's secret bleeds into the other's
5115        // clone, and no secret ever reaches argv.
5116        assert!(!calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
5117        assert!(!calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
5118        for c in calls.iter() {
5119            assert!(
5120                !c.args_str()
5121                    .iter()
5122                    .any(|a| a.contains("gh-secret") || a.contains("gl-secret")),
5123                "secrets stay out of argv"
5124            );
5125        }
5126    }
5127
5128    // Fallback policy on the git helper path, read (`fetch`) vs write (`push`):
5129    // `Ok(None)` → ambient (no inline credential helper, no secret env); `Err` →
5130    // fail-closed abort (git never spawns). (T-045)
5131    #[tokio::test]
5132    async fn git_credential_fallback_policy_for_read_and_write() {
5133        // Ok(None): ambient — no helper `-c` flags lead the argv, no secret env.
5134        let rec = RecordingRunner::replying(Reply::ok(""));
5135        let git = Git::with_runner(&rec)
5136            .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))));
5137        git.fetch(Path::new("/r")).await.unwrap();
5138        git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5139            .await
5140            .unwrap();
5141        for c in rec.calls().iter() {
5142            assert!(
5143                !c.has_env("VCS_TOOLKIT_GIT_PASSWORD"),
5144                "ambient: no secret env on {:?}",
5145                c.args_str()
5146            );
5147            assert_ne!(
5148                c.args_str().first().map(String::as_str),
5149                Some("-c"),
5150                "ambient: no leading credential -c flags"
5151            );
5152        }
5153
5154        // Err: fail-closed — the op aborts and git is never spawned, for read & write.
5155        let rec = RecordingRunner::replying(Reply::ok(""));
5156        let git = Git::with_runner(&rec).with_credentials(Arc::new(provider_fn(
5157            |_r: &CredentialRequest<'_>| {
5158                Err(Error::spawn(
5159                    BINARY,
5160                    std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
5161                ))
5162            },
5163        )));
5164        assert!(
5165            git.fetch(Path::new("/r")).await.is_err(),
5166            "read aborts on provider error"
5167        );
5168        assert!(
5169            git.push(Path::new("/r"), GitPush::branch(rn("feature")))
5170                .await
5171                .is_err(),
5172            "write aborts on provider error"
5173        );
5174        assert!(
5175            rec.calls().is_empty(),
5176            "git never spawns when the provider errored"
5177        );
5178    }
5179
5180    // No-provider is byte-identical for the read/clone arg-construction paths too,
5181    // not only `push` (fetch uses `command_in`+extend; clone uses `command`+chain).
5182    #[tokio::test]
5183    async fn default_client_no_helper_on_fetch_and_clone() {
5184        let rec = RecordingRunner::replying(Reply::ok(""));
5185        Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
5186        assert_eq!(
5187            rec.only_call().args_str(),
5188            ["fetch", "--quiet"],
5189            "fetch unchanged without a provider"
5190        );
5191
5192        let rec = RecordingRunner::replying(Reply::ok(""));
5193        Git::with_runner(&rec)
5194            .clone_repo(
5195                "https://example.com/r.git",
5196                Path::new("/dest"),
5197                CloneSpec::default(),
5198            )
5199            .await
5200            .unwrap();
5201        assert_eq!(
5202            rec.only_call().args_str()[0],
5203            "clone",
5204            "clone leads with the subcommand (no `-c`) without a provider"
5205        );
5206    }
5207
5208    // The `with_token` convenience drives the same HTTPS credential.helper path as
5209    // `with_credentials` (secret in env, helper `-c` leads, not in argv).
5210    #[tokio::test]
5211    async fn with_token_convenience_authenticates_https_remote() {
5212        let rec = RecordingRunner::replying(Reply::ok(""));
5213        let git = Git::with_runner(&rec).with_token("ghp_conv");
5214        git.fetch(Path::new("/r")).await.unwrap();
5215        let call = rec.only_call();
5216        assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
5217        let pw = call
5218            .envs
5219            .iter()
5220            .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
5221            .and_then(|(_, v)| v.as_ref())
5222            .and_then(|v| v.to_str());
5223        assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
5224        assert!(
5225            !call.args_str().iter().any(|a| a.contains("ghp_conv")),
5226            "secret not in argv"
5227        );
5228    }
5229
5230    #[tokio::test]
5231    async fn upstream_distinguishes_no_upstream_from_errors() {
5232        let set = Git::with_runner(
5233            ScriptedRunner::new()
5234                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5235                .on(["git", "rev-parse"], Reply::ok("origin/main\n")),
5236        );
5237        assert_eq!(
5238            set.upstream(Path::new(".")).await.unwrap().as_deref(),
5239            Some("origin/main")
5240        );
5241        // On a valid attached branch, exit 128 from `@{u}` means no upstream.
5242        let unset = Git::with_runner(
5243            ScriptedRunner::new()
5244                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5245                .on(["git", "rev-parse"], Reply::fail(128, "")),
5246        );
5247        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
5248
5249        // Detached HEAD is rejected by the attached-branch probe.
5250        let detached =
5251            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
5252        assert!(detached.upstream(Path::new(".")).await.is_err());
5253
5254        // A directory outside a repository is a real error too.
5255        let not_repo = Git::with_runner(ScriptedRunner::new().on(
5256            ["git", "symbolic-ref"],
5257            Reply::fail(128, "fatal: not a git repository"),
5258        ));
5259        assert!(not_repo.upstream(Path::new(".")).await.is_err());
5260
5261        // Other numeric failures and no-code outcomes must not read as "unset".
5262        let broken = Git::with_runner(
5263            ScriptedRunner::new()
5264                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5265                .on(["git", "rev-parse"], Reply::fail(1, "corrupt config")),
5266        );
5267        assert!(broken.upstream(Path::new(".")).await.is_err());
5268
5269        let timed_out = Git::with_runner(
5270            ScriptedRunner::new()
5271                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5272                .on(["git", "rev-parse"], Reply::timeout()),
5273        );
5274        assert!(timed_out.upstream(Path::new(".")).await.is_err());
5275
5276        let signalled = Git::with_runner(
5277            ScriptedRunner::new()
5278                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
5279                .on(["git", "rev-parse"], Reply::signalled(Some(9))),
5280        );
5281        assert!(signalled.upstream(Path::new(".")).await.is_err());
5282    }
5283
5284    // remote_head_branch maps the `symbolic-ref --quiet` exit code: 0 → the branch
5285    // (ref prefix stripped), 1 → None (unset origin/HEAD), and anything else (a real
5286    // failure / timeout) surfaces rather than being swallowed as "no default branch".
5287    #[tokio::test]
5288    async fn remote_head_branch_maps_exit_codes() {
5289        let set = Git::with_runner(ScriptedRunner::new().on(
5290            ["git", "symbolic-ref"],
5291            Reply::ok("refs/remotes/origin/release/v2\n"),
5292        ));
5293        assert_eq!(
5294            set.remote_head_branch(Path::new("."))
5295                .await
5296                .unwrap()
5297                .as_deref(),
5298            Some("release/v2"),
5299            "the full ref prefix is stripped, slashes preserved"
5300        );
5301        let unset =
5302            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
5303        assert!(
5304            unset
5305                .remote_head_branch(Path::new("."))
5306                .await
5307                .unwrap()
5308                .is_none()
5309        );
5310        // A real failure (exit 128, not the silent --quiet exit 1) surfaces.
5311        let err = Git::with_runner(ScriptedRunner::new().on(
5312            ["git", "symbolic-ref"],
5313            Reply::fail(128, "fatal: not a git repository"),
5314        ));
5315        assert!(err.remote_head_branch(Path::new(".")).await.is_err());
5316        // A timeout surfaces too.
5317        let timed_out =
5318            Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
5319        assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
5320    }
5321
5322    #[tokio::test]
5323    async fn set_upstream_builds_branch_flag() {
5324        let rec = RecordingRunner::replying(Reply::ok(""));
5325        let git = Git::with_runner(&rec);
5326        git.set_upstream(Path::new("/r"), &rn("feat"), &rn("origin/feature"))
5327            .await
5328            .unwrap();
5329        assert_eq!(
5330            rec.only_call().args_str(),
5331            ["branch", "--set-upstream-to=origin/feature", "feat"]
5332        );
5333    }
5334
5335    #[tokio::test]
5336    async fn remote_branches_parses_ls_remote() {
5337        let git = Git::with_runner(ScriptedRunner::new().on(
5338            ["git", "ls-remote"],
5339            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
5340        ));
5341        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
5342        assert_eq!(branches, ["main", "feat/x"]);
5343    }
5344
5345    #[tokio::test]
5346    async fn delete_branch_force_uses_capital_d() {
5347        let rec = RecordingRunner::replying(Reply::ok(""));
5348        let git = Git::with_runner(&rec);
5349        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")).force())
5350            .await
5351            .unwrap();
5352        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
5353    }
5354
5355    // The default (un-forced) spec uses lowercase `-d`.
5356    #[tokio::test]
5357    async fn delete_branch_default_uses_lowercase_d() {
5358        let rec = RecordingRunner::replying(Reply::ok(""));
5359        let git = Git::with_runner(&rec);
5360        git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")))
5361            .await
5362            .unwrap();
5363        assert_eq!(rec.only_call().args_str(), ["branch", "-d", "old"]);
5364    }
5365
5366    // `branch --merged` marks the current branch with `*` and a branch checked out
5367    // in another worktree with `+`; both must still match after marker stripping.
5368    #[tokio::test]
5369    async fn is_merged_strips_branch_markers() {
5370        let git = Git::with_runner(ScriptedRunner::new().on(
5371            ["git", "branch", "--merged"],
5372            Reply::ok("  main\n* feature\n+ wt-branch\n"),
5373        ));
5374        for name in ["main", "feature", "wt-branch"] {
5375            assert!(
5376                git.is_merged(
5377                    Path::new("."),
5378                    MergeCheck::branch(rn(name)).into_base(rv("main"))
5379                )
5380                .await
5381                .unwrap(),
5382                "{name} should be reported merged"
5383            );
5384        }
5385        assert!(
5386            !git.is_merged(
5387                Path::new("."),
5388                MergeCheck::branch(rn("absent")).into_base(rv("main"))
5389            )
5390            .await
5391            .unwrap()
5392        );
5393    }
5394
5395    // A5: the `MergeCheck` builder lands branch/base in the right slots, and
5396    // `is_merged` queries `branch --merged <base>` — so a transposed pair would
5397    // change the emitted command, not silently invert a same-shaped call.
5398    #[tokio::test]
5399    async fn merge_check_names_branch_and_base_without_transposition() {
5400        use processkit::testing::RecordingRunner;
5401        let spec = MergeCheck::branch(rn("feature")).into_base(rv("main"));
5402        assert_eq!(spec.branch.as_str(), "feature");
5403        assert_eq!(spec.base.as_str(), "main");
5404
5405        let rec = RecordingRunner::replying(Reply::ok("  feature\n* main\n"));
5406        let merged = Git::with_runner(&rec)
5407            .is_merged(
5408                Path::new("/repo"),
5409                MergeCheck::branch(rn("feature")).into_base(rv("main")),
5410            )
5411            .await
5412            .unwrap();
5413        // `feature` appears under `branch --merged main`, so it reports merged — and
5414        // the emitted args put `base` (main) in the `--merged` slot, not `branch`.
5415        assert!(merged, "feature is listed as merged into main");
5416        assert_eq!(
5417            rec.only_call().args_str(),
5418            ["branch", "--merged", "main", "--no-column", "--no-color"]
5419        );
5420    }
5421
5422    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
5423    // a remote needing auth — matching the other remote ops.
5424    #[tokio::test]
5425    async fn fetch_disables_terminal_prompt() {
5426        let rec = RecordingRunner::replying(Reply::ok(""));
5427        let git = Git::with_runner(&rec);
5428        git.fetch(Path::new("/r")).await.unwrap();
5429        let call = rec.only_call();
5430        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
5431        assert!(call.envs.iter().any(|(k, v)| {
5432            k.to_str() == Some("GIT_TERMINAL_PROMPT")
5433                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
5434        }));
5435    }
5436
5437    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
5438    #[tokio::test]
5439    async fn fetch_retries_transient_failures() {
5440        let rec = RecordingRunner::replying(Reply::fail(
5441            128,
5442            "fatal: unable to access: Could not resolve host: example.com",
5443        ));
5444        let git = Git::with_runner(&rec);
5445        assert!(git.fetch(Path::new("/r")).await.is_err());
5446        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
5447    }
5448
5449    // R6 (a `fetch` timeout is NOT retried) is pinned at the unit level by
5450    // `vcs_cli_support`'s `classifies_nothing_to_commit_and_transient_fetch`
5451    // (`is_transient_fetch_error(&Timeout) == false`); together with
5452    // `fetch_retries_transient_failures` above (the loop retries exactly what the
5453    // predicate accepts) that proves the timeout is terminal for the fetch-retry. A
5454    // faithful end-to-end timeout is awkward to simulate hermetically (a paused-clock
5455    // `Reply::pending()` doesn't auto-fire the per-command deadline), so it isn't
5456    // duplicated here.
5457
5458    // Opt-in lock-contention retry: a mutation that fails because another process
5459    // holds `index.lock` is retried and succeeds — the command never ran, so the
5460    // retry is safe. `RetryPolicy::none().attempts(3)` keeps the backoff at zero so
5461    // the test never sleeps.
5462    #[tokio::test]
5463    async fn with_retry_retries_lock_contention_on_a_mutation() {
5464        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5465            ["git", "commit"],
5466            [
5467                Reply::fail(
5468                    128,
5469                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
5470                ),
5471                Reply::ok(""),
5472            ],
5473        ));
5474        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
5475        git.commit(Path::new("/r"), "msg")
5476            .await
5477            .expect("retried past the lock");
5478        assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
5479    }
5480
5481    // Retry is off by default — the same lock failure propagates without `with_retry`.
5482    #[tokio::test]
5483    async fn default_client_does_not_retry_lock_contention() {
5484        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5485            ["git", "commit"],
5486            [
5487                Reply::fail(
5488                    128,
5489                    "fatal: Unable to create '/r/.git/index.lock': File exists.",
5490                ),
5491                Reply::ok(""),
5492            ],
5493        ));
5494        let git = Git::with_runner(&rec);
5495        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
5496        assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
5497    }
5498
5499    // Even with retry on, a real (non-lock) failure is returned immediately — only
5500    // lock contention is retried, so a genuine error is never silently repeated.
5501    #[tokio::test]
5502    async fn with_retry_does_not_retry_a_real_failure() {
5503        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
5504            ["git", "commit"],
5505            [
5506                Reply::fail(1, "error: pathspec 'x' did not match"),
5507                Reply::ok(""),
5508            ],
5509        ));
5510        let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
5511        assert!(git.commit(Path::new("/r"), "msg").await.is_err());
5512        assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
5513    }
5514
5515    // A non-transient failure fails fast — no retry.
5516    #[tokio::test]
5517    async fn fetch_does_not_retry_permanent_failures() {
5518        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
5519        let git = Git::with_runner(&rec);
5520        assert!(git.fetch(Path::new("/r")).await.is_err());
5521        assert_eq!(rec.calls().len(), 1);
5522    }
5523
5524    // Client-level cancellation (processkit 0.8 `cancellation` feature) on a
5525    // *retried* op: a `fetch` built on a client with `default_cancel_on(token)`
5526    // parks until the token fires, then surfaces `Error::Cancelled` — and because
5527    // cancellation is **terminal** (not transient), the fetch-retry does NOT
5528    // replay it (one spawn, not FETCH_ATTEMPTS). Hermetic via `Reply::pending()`
5529    // on a paused clock.
5530    #[tokio::test(start_paused = true)]
5531    async fn fetch_cancels_and_does_not_retry() {
5532        use processkit::CancellationToken;
5533        let token = CancellationToken::new();
5534        let rec =
5535            RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
5536        let git = Git::with_runner(&rec).default_cancel_on(token.clone());
5537        let call = git.fetch(Path::new("/r"));
5538        tokio::pin!(call);
5539        assert!(
5540            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
5541                .await
5542                .is_err(),
5543            "fetch must park until the token fires"
5544        );
5545        token.cancel();
5546        assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
5547        assert_eq!(
5548            rec.calls().len(),
5549            1,
5550            "cancellation is terminal — the fetch-retry must not replay it"
5551        );
5552    }
5553
5554    // The injection barrier now has two tiers:
5555    //  1. ref-name / revision inputs are validated NEWTYPES, so a flag-like or
5556    //     malformed value is rejected at *construction* — it can never reach an
5557    //     argv slot (migration tests below); and
5558    //  2. the remaining bare-positional `&str` inputs that are not refs/revisions
5559    //     (remote names, URLs, config keys) keep the internal `reject_flag_like`
5560    //     guard, refused before anything spawns.
5561
5562    // Tier 1 — the newtypes reject the flag-like / malformed values the typed ops
5563    // would otherwise have received, as a classifiable invalid-input error.
5564    #[test]
5565    fn validated_ref_and_rev_newtypes_reject_bad_values() {
5566        // RefName: the load-bearing core of `check-ref-format`.
5567        for ok in ["main", "feature/x", "origin/main", "v1.2.3", "a-b_c"] {
5568            assert!(RefName::new(ok).is_ok(), "{ok}");
5569        }
5570        for bad in [
5571            "", "-evil", "--force", "-D", "-bad", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b",
5572            "a?b", "a*b", "a[b", "a\\b", "end/", "x.lock",
5573        ] {
5574            let err = RefName::new(bad).expect_err(&format!("{bad:?} must be rejected"));
5575            assert!(
5576                vcs_cli_support::is_invalid_input(&err),
5577                "{bad:?} must classify as invalid input"
5578            );
5579        }
5580        // RevSpec: non-empty and not flag-shaped (git's revision grammar is
5581        // otherwise too rich to validate here), so `-` special values are refused.
5582        for ok in ["HEAD", "HEAD~2", "main..feature", "@{-1}", "abc123"] {
5583            assert!(RevSpec::new(ok).is_ok(), "{ok}");
5584        }
5585        for bad in ["", "-evil", "-i", "-n", "-s"] {
5586            let err = RevSpec::new(bad).expect_err(&format!("{bad:?} must be rejected"));
5587            assert!(vcs_cli_support::is_invalid_input(&err), "{bad:?}");
5588        }
5589    }
5590
5591    // Tier 2 — the ops that still take a bare `&str` (remote names, URLs, config
5592    // keys) refuse a flag-like value BEFORE anything spawns. `DiffSpec::Rev` is a
5593    // shared cross-backend `String`, so it keeps the same internal guard.
5594    #[tokio::test]
5595    async fn str_positionals_are_rejected_before_spawning() {
5596        let rec = RecordingRunner::replying(Reply::ok(""));
5597        let git = Git::with_runner(&rec);
5598        let dir = Path::new("/r");
5599
5600        assert!(git.config_set(dir, "-evil", "v").await.is_err());
5601        assert!(git.config_get(dir, "-evil").await.is_err());
5602        assert!(git.remote_url(dir, "-evil").await.is_err());
5603        assert!(git.remote_branches(dir, "-evil").await.is_err());
5604        assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
5605        assert!(git.remote_add(dir, "-evil", "url").await.is_err());
5606        assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
5607        assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
5608        assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
5609        // A leading-`-` url is an RCE-class flag injection.
5610        assert!(
5611            git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
5612                .await
5613                .is_err()
5614        );
5615        // `DiffSpec::Rev` carries a raw (internally-guarded) revision string.
5616        assert!(
5617            git.diff_text(dir, DiffSpec::Rev("-evil".into()))
5618                .await
5619                .is_err()
5620        );
5621
5622        assert!(
5623            rec.calls().is_empty(),
5624            "nothing may spawn: {:?}",
5625            rec.calls()
5626        );
5627    }
5628
5629    // A legitimate ref/revision still flows through the typed path unchanged (with
5630    // the trailing `--` that keeps a path-like ref out of pathspec mode — C2), and
5631    // git's `-` "previous branch" is carried safely as `CheckoutTarget::Previous`
5632    // (a fixed literal, not caller-controlled argv).
5633    #[tokio::test]
5634    async fn typed_checkout_targets_pass_through() {
5635        let rec = RecordingRunner::replying(Reply::ok(""));
5636        let git = Git::with_runner(&rec);
5637        git.checkout(Path::new("/r"), &CheckoutTarget::Ref(rv("feature/x")))
5638            .await
5639            .expect("checkout");
5640        assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
5641
5642        let rec = RecordingRunner::replying(Reply::ok(""));
5643        let git = Git::with_runner(&rec);
5644        git.checkout(Path::new("/r"), &CheckoutTarget::Previous)
5645            .await
5646            .expect("checkout -");
5647        assert_eq!(rec.only_call().args_str(), ["checkout", "-", "--"]);
5648    }
5649
5650    // The hardened profile lands its env pairs/removals on EVERY command, and
5651    // composes with per-command env like GIT_TERMINAL_PROMPT.
5652    #[tokio::test]
5653    async fn harden_applies_env_profile_to_every_command() {
5654        let rec = RecordingRunner::replying(Reply::ok(""));
5655        let git = Git::with_runner(&rec).harden();
5656        git.status(Path::new("/r")).await.expect("status");
5657        git.fetch(Path::new("/r")).await.expect("fetch");
5658
5659        for call in rec.calls() {
5660            let has = |k: &str, v: &str| {
5661                call.envs.iter().any(|(key, val)| {
5662                    key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
5663                })
5664            };
5665            let removed = |k: &str| {
5666                call.envs
5667                    .iter()
5668                    .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
5669            };
5670            assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
5671            assert!(has("GIT_CONFIG_COUNT", "3"));
5672            assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
5673            assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
5674            assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
5675            // The repo-local core.sshCommand kill-switch (pinned empty).
5676            assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
5677            assert!(has("GIT_CONFIG_VALUE_2", ""));
5678            assert!(has("GIT_TERMINAL_PROMPT", "0"));
5679            assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
5680            assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
5681            // Command-hook env vectors are scrubbed too.
5682            assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
5683            assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
5684            assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
5685            assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
5686            // M14: the additional code-execution vectors + pathspec-mode vars.
5687            assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
5688            assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
5689            assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
5690            assert!(
5691                removed("GIT_ICASE_PATHSPECS"),
5692                "GIT_ICASE_PATHSPECS scrubbed"
5693            );
5694        }
5695    }
5696
5697    // H4: EVERY git client (not just `harden()`) scrubs the repo-**redirector** env
5698    // vars, so a `GIT_DIR`/`GIT_INDEX_FILE` leaking from the parent (e.g. running
5699    // inside a git hook, which exports them) can't silently retarget a command at a
5700    // different repository than the bound `dir`. The command-hook scrubs and config
5701    // pins stay `harden()`-only.
5702    #[tokio::test]
5703    async fn default_client_scrubs_repo_redirector_env() {
5704        let rec = RecordingRunner::replying(Reply::ok(""));
5705        let git = Git::with_runner(&rec); // NOT hardened
5706        git.status(Path::new("/r")).await.expect("status");
5707        let call = rec.only_call();
5708        let removed = |k: &str| {
5709            call.envs
5710                .iter()
5711                .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
5712        };
5713        let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
5714        for var in [
5715            "GIT_DIR",
5716            "GIT_WORK_TREE",
5717            "GIT_INDEX_FILE",
5718            "GIT_COMMON_DIR",
5719            "GIT_OBJECT_DIRECTORY",
5720            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
5721            "GIT_NAMESPACE",
5722        ] {
5723            assert!(removed(var), "{var} must be scrubbed on the default client");
5724        }
5725        // `harden()`-only surface is absent on a plain client.
5726        assert!(
5727            !has_key("GIT_SSH_COMMAND"),
5728            "command-hook scrub is harden()-only"
5729        );
5730        assert!(
5731            !has_key("GIT_CONFIG_NOSYSTEM"),
5732            "config pins are harden()-only"
5733        );
5734    }
5735
5736    // RefName/RevSpec accept/reject tables.
5737    #[test]
5738    fn ref_name_and_rev_spec_validate() {
5739        for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
5740            assert!(RefName::new(ok).is_ok(), "{ok}");
5741        }
5742        for bad in [
5743            "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
5744            "a\\b", "end/", "x.lock",
5745        ] {
5746            assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
5747        }
5748        assert!(RevSpec::new("HEAD~2").is_ok());
5749        assert!(RevSpec::new("main..feature").is_ok());
5750        assert!(RevSpec::new("-evil").is_err());
5751        assert!(RevSpec::new("").is_err());
5752    }
5753
5754    // capabilities parses real-world version shapes (incl. the Windows build
5755    // trailer) and gates on the real (2, 31) major.minor floor.
5756    #[tokio::test]
5757    async fn capabilities_parse_and_gate_versions() {
5758        let gh = Git::with_runner(ScriptedRunner::new().on(
5759            ["git", "--version"],
5760            Reply::ok("git version 2.54.0.windows.1\n"),
5761        ));
5762        let caps = gh.capabilities().await.expect("capabilities");
5763        assert_eq!(caps.version.to_string(), "2.54.0");
5764        assert!(caps.is_supported());
5765        caps.ensure_supported().expect("supported");
5766
5767        // Two-part versions parse (patch defaults to 0); an ancient major fails
5768        // the gate with a clear message.
5769        let old = Git::with_runner(
5770            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
5771        );
5772        let caps = old.capabilities().await.expect("capabilities");
5773        assert_eq!(
5774            caps.version,
5775            GitVersion {
5776                major: 1,
5777                minor: 9,
5778                patch: 0
5779            }
5780        );
5781        let err = caps.ensure_supported().expect_err("unsupported");
5782        // The message must name the floor and the found version.
5783        let Error::Spawn { source, .. } = &err else {
5784            panic!("expected Spawn, got {err:?}");
5785        };
5786        let message = source.to_string();
5787        assert!(message.contains(">= 2"), "names the floor: {message}");
5788        assert!(
5789            message.contains("1.9.0"),
5790            "names the found version: {message}"
5791        );
5792
5793        // M29: a 2.x git BELOW the 2.31 minor floor is now rejected too — the crate's
5794        // argv (harden's GIT_CONFIG_COUNT, porcelain=v2, stash push) needs ≥ 2.31, so a
5795        // major-only gate that passed 2.7 then failed later with a cryptic argv error.
5796        let mid = Git::with_runner(
5797            ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 2.7.4\n")),
5798        );
5799        let caps = mid.capabilities().await.expect("capabilities");
5800        assert!(!caps.is_supported(), "2.7.4 is below the 2.31 floor");
5801        let err = caps.ensure_supported().expect_err("2.7.4 unsupported");
5802        let Error::Spawn { source, .. } = &err else {
5803            panic!("expected Spawn, got {err:?}");
5804        };
5805        assert!(
5806            source.to_string().contains(">= 2.31"),
5807            "names the 2.31 floor"
5808        );
5809
5810        // Garbage output is a parse error, not a silent zero version.
5811        let garbage = Git::with_runner(
5812            ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
5813        );
5814        assert!(matches!(
5815            garbage.capabilities().await.unwrap_err(),
5816            Error::Parse { .. }
5817        ));
5818    }
5819
5820    // clone_repo is dir-less and appends only the requested flags.
5821    #[tokio::test]
5822    async fn clone_repo_builds_flags_and_runs_dirless() {
5823        let rec = RecordingRunner::replying(Reply::ok(""));
5824        let git = Git::with_runner(&rec);
5825        git.clone_repo(
5826            "https://example.com/r.git",
5827            Path::new("/dest"),
5828            CloneSpec::new().branch("main").depth(1).bare(),
5829        )
5830        .await
5831        .expect("clone");
5832        let call = rec.only_call();
5833        assert_eq!(
5834            call.args_str(),
5835            [
5836                "clone",
5837                "--branch",
5838                "main",
5839                "--depth",
5840                "1",
5841                "--bare",
5842                "https://example.com/r.git",
5843                "/dest"
5844            ]
5845        );
5846        assert_eq!(call.cwd, None, "clone runs without a working directory");
5847
5848        let bare = RecordingRunner::replying(Reply::ok(""));
5849        let git = Git::with_runner(&bare);
5850        git.clone_repo("u", Path::new("/d"), CloneSpec::new())
5851            .await
5852            .expect("clone");
5853        assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
5854    }
5855
5856    // R7: a failed clone cleans a `dest` it could have *created* (absent or empty) so
5857    // a retry isn't blocked by "destination already exists and is not empty" — but it
5858    // must NEVER delete a non-empty pre-existing dir (git would have refused, so the
5859    // caller's data is untouched). Scripted-fail clone + real temp dirs (only the fs
5860    // cleanup is real; nothing spawns).
5861    #[tokio::test]
5862    async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
5863        use vcs_testkit::TempDir;
5864        let tmp = TempDir::new("r7-clone");
5865        let git = Git::with_runner(ScriptedRunner::new().on(
5866            ["git", "clone"],
5867            Reply::fail(
5868                128,
5869                "fatal: could not read Username for 'https://x': prompts disabled",
5870            ),
5871        ));
5872
5873        // A non-empty caller dir must survive a failed clone.
5874        let occupied = tmp.path().join("occupied");
5875        std::fs::create_dir(&occupied).unwrap();
5876        std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
5877        assert!(
5878            git.clone_repo("https://x/r", &occupied, CloneSpec::new())
5879                .await
5880                .is_err()
5881        );
5882        assert!(
5883            occupied.join("keep.txt").exists(),
5884            "a non-empty caller dir must survive a failed clone"
5885        );
5886
5887        // An empty dest we could have populated is removed on failure.
5888        let empty = tmp.path().join("empty");
5889        std::fs::create_dir(&empty).unwrap();
5890        assert!(
5891            git.clone_repo("https://x/r", &empty, CloneSpec::new())
5892                .await
5893                .is_err()
5894        );
5895        assert!(
5896            !empty.exists(),
5897            "an empty dest is cleaned so a retry isn't blocked"
5898        );
5899
5900        // A pre-existing FILE at `dest` must survive (read_dir errs → cleanable, but
5901        // remove_dir_all refuses a non-dir). Pins that a future "also remove a file"
5902        // change can't slip in unnoticed.
5903        let file_dest = tmp.path().join("a-file");
5904        std::fs::write(&file_dest, b"caller file").unwrap();
5905        assert!(
5906            git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
5907                .await
5908                .is_err()
5909        );
5910        assert!(
5911            file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
5912            "a caller's file at dest must survive a failed clone"
5913        );
5914
5915        // A symlink `dest` → an EMPTY dir the caller owns: `read_dir` follows the
5916        // link and sees empty, so `cleanable` is true and `remove_dir_all` DOES run on
5917        // the link — it must unlink only the symlink, never delete THROUGH it. The
5918        // target dir (and a sibling sentinel) must survive.
5919        #[cfg(unix)]
5920        {
5921            let target = tmp.path().join("link-target"); // stays empty → cleanable path
5922            std::fs::create_dir(&target).unwrap();
5923            let sentinel = tmp.path().join("sibling.txt");
5924            std::fs::write(&sentinel, b"untouched").unwrap();
5925            let link = tmp.path().join("a-symlink");
5926            std::os::unix::fs::symlink(&target, &link).unwrap();
5927            assert!(
5928                git.clone_repo("https://x/r", &link, CloneSpec::new())
5929                    .await
5930                    .is_err()
5931            );
5932            assert!(
5933                target.exists() && sentinel.exists(),
5934                "a failed clone must unlink at most the symlink, never delete through it"
5935            );
5936        }
5937    }
5938
5939    #[tokio::test]
5940    async fn tag_methods_build_args() {
5941        let rec = RecordingRunner::replying(Reply::ok(""));
5942        let git = Git::with_runner(&rec);
5943        git.tag_create(Path::new("/r"), &rn("v1"), None)
5944            .await
5945            .unwrap();
5946        git.tag_create(Path::new("/r"), &rn("v1"), Some(rv("abc")))
5947            .await
5948            .unwrap();
5949        git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new(rn("v2"), "notes"))
5950            .await
5951            .unwrap();
5952        git.tag_delete(Path::new("/r"), &rn("v1")).await.unwrap();
5953        let calls = rec.calls();
5954        assert_eq!(calls[0].args_str(), ["tag", "v1"]);
5955        assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
5956        assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
5957        assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
5958    }
5959
5960    #[tokio::test]
5961    async fn tag_list_splits_lines() {
5962        let git = Git::with_runner(
5963            ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
5964        );
5965        assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
5966    }
5967
5968    // The line-parsed list commands must pass `--no-column`: a user's
5969    // `column.ui = always` would pack several names per line, and
5970    // `color.{ui,branch} = always` would inject ANSI escapes — both even when
5971    // piped. Branch listings disable both; `git tag` isn't colorized, so it only
5972    // needs `--no-column`.
5973    #[tokio::test]
5974    async fn list_commands_disable_column_and_color() {
5975        let rec = RecordingRunner::replying(Reply::ok(""));
5976        let git = Git::with_runner(&rec);
5977        git.branches(Path::new(".")).await.unwrap();
5978        git.is_merged(
5979            Path::new("."),
5980            MergeCheck::branch(rn("b")).into_base(rv("main")),
5981        )
5982        .await
5983        .unwrap();
5984        git.tag_list(Path::new(".")).await.unwrap();
5985        let calls = rec.calls();
5986        assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
5987        assert_eq!(
5988            calls[1].args_str(),
5989            ["branch", "--merged", "main", "--no-column", "--no-color"]
5990        );
5991        assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
5992    }
5993
5994    // Commands whose failure output feeds the error classifiers must force the
5995    // C locale — a translated message would defeat the substring matching.
5996    #[tokio::test]
5997    async fn classified_commands_force_c_locale() {
5998        let rec = RecordingRunner::replying(Reply::ok(""));
5999        let git = Git::with_runner(&rec);
6000        git.commit(Path::new("."), "msg").await.unwrap();
6001        git.merge_commit(Path::new("."), MergeCommit::branch(rv("b")))
6002            .await
6003            .unwrap();
6004        git.merge_squash(Path::new("."), &rv("b")).await.unwrap();
6005        git.merge_no_commit(Path::new("."), MergeNoCommit::branch(rv("b")))
6006            .await
6007            .unwrap();
6008        git.cherry_pick(Path::new("."), &rv("abc")).await.unwrap();
6009        git.stash_pop(Path::new(".")).await.unwrap();
6010        git.fetch(Path::new(".")).await.unwrap();
6011        for call in rec.calls() {
6012            assert!(
6013                call.envs.iter().any(|(k, v)| {
6014                    k.to_str() == Some("LC_ALL")
6015                        && v.as_deref().and_then(|o| o.to_str()) == Some("C")
6016                }),
6017                "{:?} should force LC_ALL=C",
6018                call.args_str()
6019            );
6020        }
6021    }
6022
6023    // `merge_abort_detached` is the rollback-cleanup abort the facade's `try_merge`
6024    // uses: it emits the same `merge --abort` as `merge_abort`, but on a FRESH
6025    // cancel token, so an already-fired client `default_cancel_on` (a cancelled or
6026    // timed-out probe merge) cannot also cancel the cleanup. The bare `merge_abort`
6027    // on the same client IS cancelled — proving the fired token is genuinely live
6028    // and the detached path deliberately steps around it, not that the token is
6029    // inert. The git mirror of jj's `rollback_to_survives_fired_cancellation`.
6030    #[tokio::test]
6031    async fn merge_abort_detached_survives_fired_cancellation() {
6032        use processkit::CancellationToken;
6033        let token = CancellationToken::new();
6034        token.cancel(); // as a cancelled/deadline-hit probe merge would leave it
6035        let rec = RecordingRunner::replying(Reply::ok(""));
6036        let git = Git::with_runner(&rec).default_cancel_on(token);
6037        let dir = Path::new("/r");
6038
6039        git.merge_abort_detached(dir)
6040            .await
6041            .expect("the detached cleanup must run despite the fired client token");
6042        assert_eq!(rec.only_call().args_str(), ["merge", "--abort"]);
6043
6044        // Sanity: the token-inheriting `merge_abort` on the same (fired) client IS
6045        // cancelled, so the detached path really did side-step a live token.
6046        let bare = git.merge_abort(dir).await;
6047        assert!(
6048            matches!(bare, Err(Error::Cancelled { .. })),
6049            "a bare merge_abort must inherit the fired token: {bare:?}"
6050        );
6051    }
6052
6053    // `is_merge_in_progress_detached` is the rollback-cleanup DECISION probe the
6054    // facade's `try_merge` gates its abort on: it answers "is a trial merge still
6055    // staged?" via `rev-parse --git-dir` on a FRESH cancel token, so an
6056    // already-fired client `default_cancel_on` (a cancelled/timed-out probe merge)
6057    // cannot short-circuit the decision and thereby skip the abort. The bare
6058    // token-inheriting `is_merge_in_progress` on the same client IS cancelled —
6059    // proving the fired token is live and the detached probe deliberately steps
6060    // around it. Pairs with `merge_abort_detached_survives_fired_cancellation`: both
6061    // halves of the git cleanup survive, matching jj's op-log-probe-plus-restore.
6062    #[tokio::test]
6063    async fn is_merge_in_progress_detached_survives_fired_cancellation() {
6064        use processkit::CancellationToken;
6065        use vcs_testkit::TempDir;
6066        let gd = TempDir::new("merge-in-progress-detached");
6067        std::fs::write(gd.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
6068        let token = CancellationToken::new();
6069        token.cancel(); // as a cancelled/deadline-hit probe merge would leave it
6070        let git = Git::with_runner(ScriptedRunner::new().on(
6071            ["git", "rev-parse", "--git-dir"],
6072            Reply::ok(gd.path().to_str().unwrap()),
6073        ))
6074        .default_cancel_on(token);
6075        let dir = Path::new("/r");
6076
6077        assert!(
6078            git.is_merge_in_progress_detached(dir)
6079                .await
6080                .expect("the detached probe must resolve the git dir despite the fired token"),
6081            "MERGE_HEAD exists, so the detached probe must report a merge in progress"
6082        );
6083
6084        // Sanity: the token-inheriting `is_merge_in_progress` on the same (fired)
6085        // client IS cancelled, so the detached probe really did side-step a live
6086        // token rather than the token being inert.
6087        let bare = git.is_merge_in_progress(dir).await;
6088        assert!(
6089            matches!(bare, Err(Error::Cancelled { .. })),
6090            "a bare is_merge_in_progress must inherit the fired token: {bare:?}"
6091        );
6092    }
6093
6094    // The `<rev>:<path>` spec requires forward slashes — Windows callers may
6095    // hand in backslashes. The normalisation is Windows-only.
6096    #[cfg(windows)]
6097    #[tokio::test]
6098    async fn show_file_normalises_path_separators() {
6099        let rec = RecordingRunner::replying(Reply::ok("content\n"));
6100        let git = Git::with_runner(&rec);
6101        let out = git
6102            .show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
6103            .await
6104            .expect("show_file");
6105        // The blob's trailing newline is preserved verbatim (H7) — not trimmed.
6106        assert_eq!(out, "content\n");
6107        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
6108    }
6109
6110    // H7: content verbs return git's output byte-for-byte — the round-trip-corrupting
6111    // cases are multiple trailing newlines and a missing final newline.
6112    #[tokio::test]
6113    async fn content_verbs_preserve_exact_trailing_bytes() {
6114        for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces   \n"] {
6115            let rec = RecordingRunner::replying(Reply::ok(raw));
6116            let git = Git::with_runner(&rec);
6117            let out = git
6118                .show_file(Path::new("/r"), &rv("HEAD"), "f.txt")
6119                .await
6120                .expect("show_file");
6121            assert_eq!(out, raw, "show_file returns bytes verbatim");
6122        }
6123        // diff_text is verbatim too (its trailing blank context line must survive so
6124        // the last hunk stays in sync with its `@@` count).
6125        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
6126        let rec = RecordingRunner::replying(Reply::ok(diff));
6127        let git = Git::with_runner(&rec);
6128        assert_eq!(
6129            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6130                .await
6131                .expect("diff_text"),
6132            diff
6133        );
6134    }
6135
6136    // On Unix a backslash is a legal filename byte — the spec must pass through
6137    // verbatim so a literal `a\b.txt` stays resolvable.
6138    #[cfg(not(windows))]
6139    #[tokio::test]
6140    async fn show_file_keeps_backslashes_on_unix() {
6141        let rec = RecordingRunner::replying(Reply::ok("content\n"));
6142        let git = Git::with_runner(&rec);
6143        git.show_file(Path::new("/r"), &rv("HEAD"), "sub\\dir\\f.txt")
6144            .await
6145            .expect("show_file");
6146        assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
6147    }
6148
6149    // T-049: a content read (`diff_text`) whose output exceeds the client's default
6150    // OutputBudget is refused with a structured `OutputTooLarge` carrying the actual
6151    // (`total_bytes`) and allowed (`max_bytes`) sizes — never a silently truncated
6152    // diff handed back as if complete. The huge output is drained but NOT retained
6153    // (the error carries only counts, not the multi-KiB blob): the bounded-memory
6154    // contract.
6155    #[tokio::test]
6156    async fn diff_text_over_budget_errors_output_too_large() {
6157        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
6158        assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
6159        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
6160            .default_output_budget(OutputBudget::bytes(64 * 1024));
6161        match git
6162            .diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6163            .await
6164        {
6165            Err(Error::OutputTooLarge {
6166                program,
6167                max_bytes,
6168                total_bytes,
6169                ..
6170            }) => {
6171                assert_eq!(program, "git");
6172                assert_eq!(max_bytes, Some(64 * 1024), "the allowed ceiling");
6173                assert!(
6174                    total_bytes > 64 * 1024,
6175                    "the actual size ({total_bytes}) exceeds the allowed cap"
6176                );
6177            }
6178            other => panic!("expected OutputTooLarge, got {other:?}"),
6179        }
6180    }
6181
6182    // Below the budget the full diff comes back verbatim — the ceiling only fires on
6183    // an over-cap read, so ordinary diffs are unaffected.
6184    #[tokio::test]
6185    async fn diff_text_under_budget_returns_full_output() {
6186        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
6187        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(diff)))
6188            .default_output_budget(OutputBudget::bytes(64 * 1024));
6189        assert_eq!(
6190            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6191                .await
6192                .expect("under-budget diff_text"),
6193            diff
6194        );
6195    }
6196
6197    // The per-call override reads a legitimately large diff past a tight client
6198    // default: `diff_text_within(..., unlimited())` returns the full output the
6199    // default budget would have refused.
6200    #[tokio::test]
6201    async fn diff_text_within_override_reads_past_the_default() {
6202        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
6203        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
6204            .default_output_budget(OutputBudget::bytes(64 * 1024));
6205        // The default budget would refuse it…
6206        assert!(matches!(
6207            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6208                .await,
6209            Err(Error::OutputTooLarge { .. })
6210        ));
6211        // …but an explicit unlimited override reads it in full.
6212        let got = git
6213            .diff_text_within(
6214                Path::new("/r"),
6215                DiffSpec::Rev("HEAD".into()),
6216                OutputBudget::unlimited(),
6217            )
6218            .await
6219            .expect("override reads the large diff");
6220        assert_eq!(got, big);
6221    }
6222
6223    // A blob read (`show_file`) honours the same budget and its per-call override.
6224    #[tokio::test]
6225    async fn show_file_over_budget_errors_and_override_reads() {
6226        let big = "x".repeat(200_000);
6227        let git = Git::with_runner(ScriptedRunner::new().on(["git", "show"], Reply::ok(&big)))
6228            .default_output_budget(OutputBudget::bytes(64 * 1024));
6229        assert!(matches!(
6230            git.show_file(Path::new("/r"), &rv("HEAD"), "big.bin").await,
6231            Err(Error::OutputTooLarge { .. })
6232        ));
6233        let got = git
6234            .show_file_within(
6235                Path::new("/r"),
6236                &rv("HEAD"),
6237                "big.bin",
6238                OutputBudget::unlimited(),
6239            )
6240            .await
6241            .expect("override reads the large blob");
6242        assert_eq!(got, big);
6243    }
6244
6245    // A client with no budget set keeps the pre-budget behaviour: even a huge diff
6246    // is returned in full (the default is unlimited, never `OutputTooLarge`).
6247    #[tokio::test]
6248    async fn default_client_has_no_budget() {
6249        let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
6250        let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)));
6251        assert_eq!(
6252            git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
6253                .await
6254                .expect("unbudgeted client returns the full diff"),
6255            big
6256        );
6257    }
6258
6259    // config --get: exit 0 → Some(value), exit 1 → None (unset), other → error.
6260    #[tokio::test]
6261    async fn config_get_maps_exit_codes() {
6262        let set = Git::with_runner(
6263            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
6264        );
6265        assert_eq!(
6266            set.config_get(Path::new("."), "user.name").await.unwrap(),
6267            Some("Alice".to_string())
6268        );
6269        // Only git's trailing newline (here `\r\n`) is stripped — a value's own
6270        // trailing spaces are preserved (they can be meaningful).
6271        let spaced = Git::with_runner(
6272            ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix:  \r\n")),
6273        );
6274        assert_eq!(
6275            spaced.config_get(Path::new("."), "x.y").await.unwrap(),
6276            Some("prefix:  ".to_string())
6277        );
6278        let unset = Git::with_runner(
6279            ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
6280        );
6281        assert_eq!(
6282            unset.config_get(Path::new("."), "user.name").await.unwrap(),
6283            None
6284        );
6285        // A multi-valued key (exit 2) or worse is a real error.
6286        let multi = Git::with_runner(ScriptedRunner::new().on(
6287            ["git", "config", "--get"],
6288            Reply::fail(2, "multiple values"),
6289        ));
6290        assert!(
6291            multi
6292                .config_get(Path::new("."), "remote.all")
6293                .await
6294                .is_err()
6295        );
6296    }
6297
6298    #[tokio::test]
6299    async fn blame_builds_rev_before_pathspec_separator() {
6300        let rec = RecordingRunner::replying(Reply::ok(""));
6301        let git = Git::with_runner(&rec);
6302        git.blame(Path::new("/r"), "src/lib.rs", Some(rv("HEAD~1")))
6303            .await
6304            .unwrap();
6305        git.blame(Path::new("/r"), "src/lib.rs", None)
6306            .await
6307            .unwrap();
6308        let calls = rec.calls();
6309        assert_eq!(
6310            calls[0].args_str(),
6311            ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
6312        );
6313        assert_eq!(
6314            calls[1].args_str(),
6315            ["blame", "--line-porcelain", "--", "src/lib.rs"]
6316        );
6317    }
6318
6319    // revert must never open an editor: --no-edit plus the env backstop.
6320    #[tokio::test]
6321    async fn sequencer_methods_suppress_editors() {
6322        let rec = RecordingRunner::replying(Reply::ok(""));
6323        let git = Git::with_runner(&rec);
6324        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
6325        git.cherry_pick(Path::new("/r"), &rv("abc")).await.unwrap();
6326        git.rebase_skip(Path::new("/r")).await.unwrap();
6327        let calls = rec.calls();
6328        assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
6329        assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
6330        assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
6331        for call in &calls {
6332            assert!(
6333                call.envs
6334                    .iter()
6335                    .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
6336                "editor suppressed on {:?}",
6337                call.args_str()
6338            );
6339        }
6340    }
6341
6342    // T-044: each sequencer abort/continue/reset issues its OWN git subcommand, and
6343    // the two `--continue` variants (which can re-open the commit-message editor)
6344    // suppress it so a headless caller never hangs.
6345    #[tokio::test]
6346    async fn sequencer_abort_continue_reset_commands() {
6347        let rec = RecordingRunner::replying(Reply::ok(""));
6348        let git = Git::with_runner(&rec);
6349        let d = Path::new("/r");
6350        git.cherry_pick_abort(d).await.unwrap();
6351        git.cherry_pick_continue(d).await.unwrap();
6352        git.revert_abort(d).await.unwrap();
6353        git.revert_continue(d).await.unwrap();
6354        git.bisect_reset(d).await.unwrap();
6355        let calls = rec.calls();
6356        assert_eq!(calls[0].args_str(), ["cherry-pick", "--abort"]);
6357        assert_eq!(calls[1].args_str(), ["cherry-pick", "--continue"]);
6358        assert_eq!(calls[2].args_str(), ["revert", "--abort"]);
6359        assert_eq!(calls[3].args_str(), ["revert", "--continue"]);
6360        assert_eq!(calls[4].args_str(), ["bisect", "reset"]);
6361        // Only the `--continue` commits can prompt an editor; those must suppress it.
6362        let has_editor = |idx: usize| {
6363            calls[idx]
6364                .envs
6365                .iter()
6366                .any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
6367        };
6368        assert!(has_editor(1), "cherry-pick --continue suppresses editor");
6369        assert!(has_editor(3), "revert --continue suppresses editor");
6370    }
6371
6372    // T-065: `git am` gets both drivers — `am --abort` and `am --continue`. The
6373    // continue re-applies the resolved patch and may prompt to confirm the message,
6374    // so it suppresses the editor like the other sequencer `--continue`s; the abort
6375    // does not prompt, so it does not.
6376    #[tokio::test]
6377    async fn am_abort_and_continue_commands() {
6378        let rec = RecordingRunner::replying(Reply::ok(""));
6379        let git = Git::with_runner(&rec);
6380        let d = Path::new("/r");
6381        git.am_abort(d).await.unwrap();
6382        git.am_continue(d).await.unwrap();
6383        let calls = rec.calls();
6384        assert_eq!(calls[0].args_str(), ["am", "--abort"]);
6385        assert_eq!(calls[1].args_str(), ["am", "--continue"]);
6386        let has_editor = |idx: usize| {
6387            calls[idx]
6388                .envs
6389                .iter()
6390                .any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
6391        };
6392        assert!(
6393            !has_editor(0),
6394            "am --abort does not prompt, no editor override"
6395        );
6396        assert!(has_editor(1), "am --continue suppresses editor");
6397    }
6398
6399    // harden() scrubs GIT_EDITOR/GIT_SEQUENCE_EDITOR from the inherited
6400    // environment, but a sequencer command sets its own `GIT_EDITOR=true` per call
6401    // (no_editor). `command_in` applies the client-level removal eagerly, so the env
6402    // list ends up `[…, (GIT_EDITOR, None), …, (GIT_EDITOR, "true")]` — and at spawn
6403    // each op is applied in order (processkit's `Command` does `env_remove` then
6404    // `env`), so the LAST write wins. The effective value MUST be `true`, else a
6405    // hardened `revert`/`cherry-pick`/`rebase` would lose its no-op editor and hang
6406    // a headless caller. Pin that effective-precedence (fold in spawn order).
6407    #[tokio::test]
6408    async fn hardened_sequencer_keeps_its_no_op_editor() {
6409        let rec = RecordingRunner::replying(Reply::ok(""));
6410        let git = Git::with_runner(&rec).harden();
6411        git.revert(Path::new("/r"), &rv("abc")).await.unwrap();
6412        let call = rec.only_call();
6413        // Resolve each editor var the way the OS does: last op for the key wins.
6414        let effective = |var: &str| {
6415            call.envs
6416                .iter()
6417                .rfind(|(k, _)| k.to_str() == Some(var))
6418                .and_then(|(_, v)| v.as_deref())
6419                .and_then(|v| v.to_str())
6420        };
6421        // Both no-op editors must survive harden()'s scrub (symmetric precedence),
6422        // else a hardened sequencer command hangs a headless caller.
6423        assert_eq!(
6424            effective("GIT_EDITOR"),
6425            Some("true"),
6426            "the per-command no-op editor must survive harden()'s scrub"
6427        );
6428        assert_eq!(
6429            effective("GIT_SEQUENCE_EDITOR"),
6430            Some("true"),
6431            "the per-command no-op sequence editor must survive harden()'s scrub"
6432        );
6433    }
6434
6435    #[tokio::test]
6436    async fn remote_add_and_set_url_build_args() {
6437        let rec = RecordingRunner::replying(Reply::ok(""));
6438        let git = Git::with_runner(&rec);
6439        git.remote_add(Path::new("/r"), "up", "https://x/y.git")
6440            .await
6441            .unwrap();
6442        git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
6443            .await
6444            .unwrap();
6445        let calls = rec.calls();
6446        assert_eq!(
6447            calls[0].args_str(),
6448            ["remote", "add", "up", "https://x/y.git"]
6449        );
6450        assert_eq!(
6451            calls[1].args_str(),
6452            ["remote", "set-url", "up", "https://x/z.git"]
6453        );
6454    }
6455
6456    // Dirty tree that stashes: status → list(before) → push → list(after, deeper) →
6457    // checkout → pop --index, in that order.
6458    #[tokio::test]
6459    async fn switch_with_stash_round_trips_dirty_tree() {
6460        let rec = RecordingRunner::new(
6461            ScriptedRunner::new()
6462                .on(["git", "status"], Reply::ok(" M a.rs\0"))
6463                // Stash-list depth goes 0 → 1, so the push is known to have saved.
6464                .on_sequence(
6465                    ["git", "stash", "list"],
6466                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
6467                )
6468                .on(["git", "stash", "push"], Reply::ok(""))
6469                .on(["git", "checkout"], Reply::ok(""))
6470                .on(["git", "stash", "pop"], Reply::ok("")),
6471        );
6472        let git = Git::with_runner(&rec);
6473        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6474            .await
6475            .expect("switch");
6476        let calls = rec.calls();
6477        assert_eq!(calls.len(), 6);
6478        assert_eq!(
6479            calls[2].args_str(),
6480            ["stash", "push", "--include-untracked"]
6481        );
6482        assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
6483        // `--index` restores the staged/unstaged split (M12).
6484        assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
6485    }
6486
6487    // M12: a dirty tree whose dirt `stash push` can't save (e.g. a submodule-only
6488    // change) — the stash-list depth is unchanged, so we must NOT pop an unrelated
6489    // pre-existing stash. Switch as-is.
6490    #[tokio::test]
6491    async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
6492        let rec = RecordingRunner::new(
6493            ScriptedRunner::new()
6494                .on(["git", "status"], Reply::ok(" M sub\0"))
6495                // Depth stays 1 across the push → nothing was actually stashed.
6496                .on(
6497                    ["git", "stash", "list"],
6498                    Reply::ok("stash@{0}: someone else's WIP\n"),
6499                )
6500                .on(
6501                    ["git", "stash", "push"],
6502                    Reply::ok("No local changes to save\n"),
6503                )
6504                .on(["git", "checkout"], Reply::ok("")),
6505        );
6506        let git = Git::with_runner(&rec);
6507        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6508            .await
6509            .expect("switch");
6510        assert!(
6511            rec.calls()
6512                .iter()
6513                .all(|c| c.args_str() != ["stash", "pop", "--index"]
6514                    && c.args_str() != ["stash", "pop"]),
6515            "must not pop an unrelated stash when the push saved nothing"
6516        );
6517    }
6518
6519    // A clean tree skips the stash round-trip — a no-op `stash push` would make
6520    // the later pop grab an older, unrelated stash.
6521    #[tokio::test]
6522    async fn switch_with_stash_skips_stash_on_clean_tree() {
6523        let rec = RecordingRunner::new(
6524            ScriptedRunner::new()
6525                .on(["git", "status"], Reply::ok(""))
6526                .on(["git", "checkout"], Reply::ok("")),
6527        );
6528        let git = Git::with_runner(&rec);
6529        git.switch_with_stash(Path::new("/r"), &ct("feature"))
6530            .await
6531            .expect("switch");
6532        let calls = rec.calls();
6533        assert_eq!(calls.len(), 2);
6534        assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
6535    }
6536
6537    // A failed checkout pops the stash back (we are still on the original
6538    // branch) and surfaces the checkout error.
6539    #[tokio::test]
6540    async fn switch_with_stash_restores_on_checkout_failure() {
6541        let rec = RecordingRunner::new(
6542            ScriptedRunner::new()
6543                .on(["git", "status"], Reply::ok(" M a.rs\0"))
6544                .on_sequence(
6545                    ["git", "stash", "list"],
6546                    [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
6547                )
6548                .on(["git", "stash", "push"], Reply::ok(""))
6549                .on(
6550                    ["git", "checkout"],
6551                    Reply::fail(1, "error: pathspec 'nope'"),
6552                )
6553                .on(["git", "stash", "pop"], Reply::ok("")),
6554        );
6555        let git = Git::with_runner(&rec);
6556        let err = git
6557            .switch_with_stash(Path::new("/r"), &ct("nope"))
6558            .await
6559            .expect_err("checkout error must surface");
6560        assert!(matches!(err, Error::Exit { .. }));
6561        let calls = rec.calls();
6562        assert_eq!(
6563            calls.last().unwrap().args_str(),
6564            ["stash", "pop", "--index"],
6565            "restoring pop ran with --index"
6566        );
6567    }
6568
6569    // `fetch_from` names the remote, keeps the prompt off, and shares the
6570    // transient retry.
6571    #[tokio::test]
6572    async fn fetch_from_builds_args_and_retries() {
6573        let rec = RecordingRunner::replying(Reply::ok(""));
6574        let git = Git::with_runner(&rec);
6575        git.fetch_from(Path::new("/r"), "upstream")
6576            .await
6577            .expect("fetch_from");
6578        let call = rec.only_call();
6579        assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
6580        assert!(call.envs.iter().any(|(k, v)| {
6581            k.to_str() == Some("GIT_TERMINAL_PROMPT")
6582                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
6583        }));
6584
6585        let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
6586        let git = Git::with_runner(&failing);
6587        assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
6588        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
6589    }
6590
6591    // As with `remote_branch_exists`, the names `fetch_branch` must exclude are now
6592    // refused at `RefName` construction, before the refspec is ever built.
6593    #[test]
6594    fn fetch_branch_invalid_names_rejected_at_refname() {
6595        for branch in [
6596            "",
6597            "feature/*",
6598            "feature/?",
6599            "feature/[a]",
6600            "a:b",
6601            "two words",
6602            "bad\tname",
6603        ] {
6604            let err = RefName::new(branch).expect_err("invalid fetch branch name must be rejected");
6605            assert!(vcs_cli_support::is_invalid_input(&err), "{branch:?}");
6606        }
6607    }
6608
6609    #[tokio::test]
6610    async fn fetch_branch_accepts_valid_names() {
6611        let rec = RecordingRunner::replying(Reply::ok(""));
6612        let git = Git::with_runner(&rec);
6613
6614        git.fetch_branch(Path::new("/repo"), &rn("feature/T-010_fix"))
6615            .await
6616            .expect("valid fetch branch name");
6617        assert_eq!(
6618            rec.only_call().args_str(),
6619            [
6620                "fetch",
6621                "--quiet",
6622                "origin",
6623                "refs/heads/feature/T-010_fix:refs/remotes/origin/feature/T-010_fix"
6624            ]
6625        );
6626    }
6627
6628    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
6629    // tested with a generated mock.
6630    #[cfg(feature = "mock")]
6631    #[tokio::test]
6632    async fn consumer_mocks_the_interface() {
6633        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
6634            git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
6635        }
6636        let mut mock = MockGitApi::new();
6637        mock.expect_current_branch()
6638            .returning(|_| Ok(Some("main".to_string())));
6639        assert!(on_branch(&mock, "main").await);
6640    }
6641}
6642
6643// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
6644#[doc = include_str!("../docs/git.md")]
6645#[allow(rustdoc::broken_intra_doc_links)]
6646pub mod guide {
6647    #[doc = include_str!("../docs/security.md")]
6648    #[allow(rustdoc::broken_intra_doc_links)]
6649    pub mod security {}
6650    #[doc = include_str!("../docs/conflicts.md")]
6651    #[allow(rustdoc::broken_intra_doc_links)]
6652    pub mod conflicts {}
6653}