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