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