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