Skip to main content

vcs_git/
lib.rs

1//! `vcs-git` — automate Git from Rust through CLI process execution.
2//!
3//! Async, mockable, and structured-error: consumers depend on the [`GitApi`]
4//! trait and substitute a mock for the real [`Git`] client in tests. Commands
5//! run inside an OS job (via [`processkit`]) so a `git` subprocess is never
6//! orphaned, and honour an optional [timeout](Git::default_timeout).
7//!
8//! ```no_run
9//! use vcs_git::{Git, GitApi};
10//! use std::path::Path;
11//!
12//! # async fn run(git: &dyn GitApi) -> Result<(), processkit::Error> {
13//! let branch = git.current_branch(Path::new(".")).await?;
14//! # let _ = branch; Ok(()) }
15//! ```
16//!
17//! Two test seams: enable the `mock` feature for a `mockall`-generated
18//! `MockGitApi`, or inject a fake runner with
19//! `Git::with_runner(`[`ScriptedRunner`](processkit::ScriptedRunner)`)`.
20
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use processkit::ProcessRunner;
25// Re-export the processkit types that appear in this crate's public API, so
26// consumers needn't depend on processkit directly. (`Error`/`Result`/`ProcessResult`
27// are in scope here too via this `pub use`.)
28pub use processkit::{Error, ProcessResult, Result};
29
30mod parse;
31pub use parse::{
32    Branch, ChangeKind, Commit, DiffLine, DiffStat, FileDiff, Hunk, StatusEntry, Worktree,
33};
34
35/// Name of the underlying CLI binary this crate drives.
36pub const BINARY: &str = "git";
37
38/// What a [`GitApi::diff`] / [`GitApi::diff_text`] call compares.
39///
40/// `#[non_exhaustive]` so more comparison shapes can be added later.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub enum DiffSpec {
44    /// All tracked working-tree changes vs the last commit (`git diff HEAD`),
45    /// staged or not, excluding untracked files.
46    WorkingTree,
47    /// A specific revision or range, e.g. `main..HEAD` or `HEAD~1` (`git diff <rev>`).
48    Rev(String),
49}
50
51/// Options for [`GitApi::worktree_add`] (`git worktree add`).
52///
53/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
54/// [`WorktreeAdd::create_branch`] rather than a struct literal.
55#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub struct WorktreeAdd {
58    /// Filesystem path for the new worktree.
59    pub path: PathBuf,
60    /// Create and check out this new branch (`-b <name>`); `None` checks out an
61    /// existing ref.
62    pub new_branch: Option<String>,
63    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
64    pub commitish: Option<String>,
65    /// Register the worktree without populating its files (`--no-checkout`) — the
66    /// caller fills the working tree itself (e.g. a copy-on-write clone).
67    pub no_checkout: bool,
68}
69
70impl WorktreeAdd {
71    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
72    /// `git worktree add <path> <commitish>`.
73    pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
74        Self {
75            path: path.into(),
76            new_branch: None,
77            commitish: Some(commitish.into()),
78            no_checkout: false,
79        }
80    }
81
82    /// A worktree at `path` creating a new branch `name` based on `commitish`:
83    /// `git worktree add -b <name> <path> <commitish>`.
84    pub fn create_branch(
85        path: impl Into<PathBuf>,
86        name: impl Into<String>,
87        commitish: impl Into<String>,
88    ) -> Self {
89        Self {
90            path: path.into(),
91            new_branch: Some(name.into()),
92            commitish: Some(commitish.into()),
93            no_checkout: false,
94        }
95    }
96
97    /// Register the worktree without checking out its files (`--no-checkout`),
98    /// for a caller that populates the working tree itself.
99    pub fn no_checkout(mut self) -> Self {
100        self.no_checkout = true;
101        self
102    }
103}
104
105/// Options for [`GitApi::push`] (`git push`).
106///
107/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
108/// [`GitPush::refspec`] rather than a struct literal.
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub struct GitPush {
112    /// Remote to push to (defaults to `origin`).
113    pub remote: String,
114    /// The refspec — a bare branch name, or `local:remote_branch`.
115    pub refspec: String,
116    /// Set the pushed branch as the upstream (`-u`).
117    pub set_upstream: bool,
118}
119
120impl GitPush {
121    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
122    pub fn branch(name: impl Into<String>) -> Self {
123        Self {
124            remote: "origin".to_string(),
125            refspec: name.into(),
126            set_upstream: false,
127        }
128    }
129
130    /// Push `local` to a differently-named `remote_branch`
131    /// (`git push origin <local>:<remote_branch>`).
132    pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
133        Self {
134            remote: "origin".to_string(),
135            refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
136            set_upstream: false,
137        }
138    }
139
140    /// Push to a non-default remote.
141    pub fn remote(mut self, remote: impl Into<String>) -> Self {
142        self.remote = remote.into();
143        self
144    }
145
146    /// Record the pushed branch as the local branch's upstream (`-u`).
147    pub fn set_upstream(mut self) -> Self {
148        self.set_upstream = true;
149        self
150    }
151}
152
153/// The Git operations this crate exposes — the interface consumers code against
154/// and mock in tests.
155#[cfg_attr(feature = "mock", mockall::automock)]
156#[async_trait::async_trait]
157pub trait GitApi: Send + Sync {
158    /// Run `git <args>` in the current directory, returning trimmed stdout
159    /// (throws on a non-zero exit). A raw escape hatch for unmodelled commands.
160    async fn run(&self, args: &[String]) -> Result<String>;
161    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
162    /// captured [`ProcessResult`].
163    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
164    /// Installed Git version (`git --version`).
165    async fn version(&self) -> Result<String>;
166    /// Working-tree status (`git status --porcelain=v1 -z`).
167    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
168    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
169    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
170    async fn status_text(&self, dir: &Path) -> Result<String>;
171    /// Current branch name (`git rev-parse --abbrev-ref HEAD`).
172    async fn current_branch(&self, dir: &Path) -> Result<String>;
173    /// Local branches, current one flagged (`git branch`).
174    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
175    /// Latest `max` commits, newest first (`git log`).
176    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
177    /// Commits in `range`, newest first, up to `max` (`git log <range>`).
178    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
179    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
180    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
181    /// Resolve a revision to its abbreviated hash (`git rev-parse --short <rev>`) —
182    /// e.g. to label a detached HEAD.
183    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
184    /// Initialise a repository (`git init`).
185    async fn init(&self, dir: &Path) -> Result<()>;
186    /// Stage `paths` (`git add -- <paths>`).
187    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
188    /// Commit staged changes (`git commit -m`).
189    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
190    /// Create a branch without switching to it (`git branch <name>`).
191    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
192    /// Switch to a branch or revision (`git checkout <reference>`).
193    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
194    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
195    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
196    /// Commit exactly `paths`' working-tree content, ignoring the index
197    /// (`git commit [--amend] -m <message> --only -- <paths>`).
198    async fn commit_paths(
199        &self,
200        dir: &Path,
201        paths: &[PathBuf],
202        message: &str,
203        amend: bool,
204    ) -> Result<()>;
205    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
206    /// pre-fill an amend.
207    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
208    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
209    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
210    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
211    /// Whether the working tree has no unstaged modifications to **tracked** files
212    /// (`git diff --quiet`). Untracked files are *not* counted — this is not a full
213    /// "is the working tree clean?" check; use [`status`](GitApi::status) for that.
214    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
215
216    // --- Discovery / identity ------------------------------------------------
217
218    /// The repository's common git directory (`rev-parse --git-common-dir`) —
219    /// stable across linked worktrees.
220    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
221    /// This worktree's git directory (`rev-parse --git-dir`).
222    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
223    /// Resolve a revision to a commit hash, peeling tags
224    /// (`rev-parse --verify <rev>^{commit}`).
225    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
226    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
227    /// (short name only); `None` when `origin/HEAD` is unset.
228    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
229    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
230    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
231    /// Whether `origin` has `name`, without fetching (`ls-remote origin
232    /// refs/heads/<name>` — the fully-qualified ref, so `foo` can't tail-match
233    /// `bar/foo`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
234    /// credential or a flaky network can't hang the call.
235    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
236    /// A remote's URL (`remote get-url <remote>`).
237    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
238    /// The current branch's upstream, e.g. `Some("origin/main")`
239    /// (`rev-parse --abbrev-ref --symbolic-full-name @{u}`); `None` when unset.
240    async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
241    /// Branch names on `remote`, without fetching
242    /// (`ls-remote --heads <remote>`).
243    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
244
245    // --- Branches ------------------------------------------------------------
246
247    /// Whether `branch` is fully merged into `target` (`branch --merged <target>`).
248    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
249    /// Set `branch`'s upstream to `upstream` (e.g. `origin/main`)
250    /// (`branch --set-upstream-to=<upstream> <branch>`).
251    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
252    /// Delete a local branch (`branch -d`, or `-D` when `force`).
253    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
254    /// Rename a local branch (`branch -m <old> <new>`).
255    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
256    /// Count commits in a range (`rev-list --count <range>`).
257    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
258    /// Whether a diff range is empty (`diff --quiet <range>`).
259    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
260    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
261    /// match `vcs_jj::JjApi::diff_stat`.
262    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
263    /// Raw git-format unified diff text for `spec`
264    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output.
265    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
266    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
267    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
268
269    // --- In-progress state ---------------------------------------------------
270
271    /// Whether the index has no staged changes (`diff --cached --quiet`).
272    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
273    /// Whether a rebase is in progress (a `rebase-merge`/`rebase-apply` dir exists
274    /// under the git dir).
275    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
276    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
277    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
278
279    // --- Mutations -----------------------------------------------------------
280
281    /// Fetch from the default remote (`fetch --quiet`), with `GIT_TERMINAL_PROMPT=0`.
282    /// Transient (network) failures are retried (3 attempts, 500 ms backoff).
283    async fn fetch(&self, dir: &Path) -> Result<()>;
284    /// Fetch a single branch from `origin` into its remote-tracking ref
285    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
286    /// `GIT_TERMINAL_PROMPT=0`. Transient failures are retried (3×, 500 ms).
287    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
288    /// Push to a remote (`push [-u] <remote> <refspec>`); see [`GitPush`].
289    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
290    /// Stage a branch's changes without committing (`merge --squash <branch>`).
291    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
292    /// Merge a branch (`merge [--no-ff] [-m <msg> | --no-edit] <branch>`); with no
293    /// message it takes the default merge message non-interactively (`--no-edit`).
294    async fn merge_commit(
295        &self,
296        dir: &Path,
297        branch: &str,
298        no_ff: bool,
299        message: Option<String>,
300    ) -> Result<()>;
301    /// Merge without committing, for a dry run
302    /// (`merge --no-commit [--squash|--no-ff] <branch>`).
303    async fn merge_no_commit(
304        &self,
305        dir: &Path,
306        branch: &str,
307        squash: bool,
308        no_ff: bool,
309    ) -> Result<()>;
310    /// Abort an in-progress merge (`merge --abort`).
311    async fn merge_abort(&self, dir: &Path) -> Result<()>;
312    /// Finish a merge after resolving conflicts (`commit --no-edit`).
313    async fn merge_continue(&self, dir: &Path) -> Result<()>;
314    /// Clear merge state, squash-safe (`reset --merge`).
315    async fn reset_merge(&self, dir: &Path) -> Result<()>;
316    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
317    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
318    /// Rebase the current branch onto `onto` (`rebase <onto>`); the editor is
319    /// suppressed (`GIT_EDITOR=true`) so it never hangs a headless caller.
320    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
321    /// Abort an in-progress rebase (`rebase --abort`).
322    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
323    /// Continue a rebase after resolving conflicts (`rebase --continue`); the
324    /// editor is suppressed (`GIT_EDITOR=true`) so the message-confirm never hangs.
325    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
326    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
327    /// e.g. to save state before a copy-on-write restore.
328    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
329    /// Restore the most recent stash and drop it (`stash pop`).
330    async fn stash_pop(&self, dir: &Path) -> Result<()>;
331
332    // --- Worktrees -----------------------------------------------------------
333
334    /// List worktrees (`worktree list --porcelain`).
335    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
336    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
337    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
338    /// Remove a worktree (`worktree remove [--force] <path>`).
339    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
340    /// Move a worktree (`worktree move <from> <to>`).
341    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
342    /// Prune stale worktree admin entries (`worktree prune`).
343    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
344}
345
346processkit::cli_client!(
347    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject
348    /// a fake process executor; `Git::new()` uses the real job-backed runner.
349    pub struct Git => BINARY
350);
351
352#[async_trait::async_trait]
353impl<R: ProcessRunner> GitApi for Git<R> {
354    async fn run(&self, args: &[String]) -> Result<String> {
355        self.core.text(self.core.command(args)).await
356    }
357
358    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
359        self.core.capture(self.core.command(args)).await
360    }
361
362    async fn version(&self) -> Result<String> {
363        self.core.text(self.core.command(["--version"])).await
364    }
365
366    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
367        self.core
368            .parse(
369                self.core
370                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
371                parse::parse_porcelain,
372            )
373            .await
374    }
375
376    async fn status_text(&self, dir: &Path) -> Result<String> {
377        self.core
378            .text(self.core.command_in(dir, ["status", "--porcelain=v1"]))
379            .await
380    }
381
382    async fn current_branch(&self, dir: &Path) -> Result<String> {
383        self.core
384            .text(
385                self.core
386                    .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
387            )
388            .await
389    }
390
391    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
392        self.core
393            .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
394            .await
395    }
396
397    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
398        let n = format!("-n{max}");
399        self.core
400            .parse(
401                self.core.command_in(
402                    dir,
403                    [
404                        "log",
405                        n.as_str(),
406                        "-z",
407                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
408                    ],
409                ),
410                parse::parse_log,
411            )
412            .await
413    }
414
415    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
416        let n = format!("-n{max}");
417        self.core
418            .parse(
419                self.core.command_in(
420                    dir,
421                    [
422                        "log",
423                        range,
424                        n.as_str(),
425                        "-z",
426                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
427                    ],
428                ),
429                parse::parse_log,
430            )
431            .await
432    }
433
434    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
435        self.core
436            .text(self.core.command_in(dir, ["rev-parse", rev]))
437            .await
438    }
439
440    async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
441        self.core
442            .text(self.core.command_in(dir, ["rev-parse", "--short", rev]))
443            .await
444    }
445
446    async fn init(&self, dir: &Path) -> Result<()> {
447        self.core.unit(self.core.command_in(dir, ["init"])).await
448    }
449
450    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
451        // `--` separates the pathspecs so a path can never be read as an option.
452        let mut command = self.core.command_in(dir, ["add", "--"]);
453        for path in paths {
454            command = command.arg(path);
455        }
456        self.core.unit(command).await
457    }
458
459    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
460        self.core
461            .unit(self.core.command_in(dir, ["commit", "-m", message]))
462            .await
463    }
464
465    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
466        self.core
467            .unit(self.core.command_in(dir, ["branch", name]))
468            .await
469    }
470
471    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
472        self.core
473            .unit(self.core.command_in(dir, ["checkout", reference]))
474            .await
475    }
476
477    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
478        self.core
479            .unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
480            .await
481    }
482
483    async fn commit_paths(
484        &self,
485        dir: &Path,
486        paths: &[PathBuf],
487        message: &str,
488        amend: bool,
489    ) -> Result<()> {
490        // `--only -- <paths>` commits exactly these paths' working-tree content
491        // regardless of the index; `--` keeps a path from being read as an option.
492        let mut command = self.core.command_in(dir, ["commit"]);
493        if amend {
494            command = command.arg("--amend");
495        }
496        command = command.arg("-m").arg(message).arg("--only").arg("--");
497        for path in paths {
498            command = command.arg(path);
499        }
500        self.core.unit(command).await
501    }
502
503    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
504        self.core
505            .text(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
506            .await
507    }
508
509    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
510        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
511        // (not unborn), 1 = no commit yet (unborn). `probe` maps those to a bool
512        // and surfaces anything else (e.g. 128, not a repo) as `Error::Exit`.
513        Ok(!self
514            .core
515            .probe(
516                self.core
517                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
518            )
519            .await?)
520    }
521
522    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
523        // `git diff --quiet` is an exit-code answer: 0 = clean (empty), 1 = dirty;
524        // `probe` errors on any other code / timeout / signal.
525        self.core
526            .probe(self.core.command_in(dir, ["diff", "--quiet"]))
527            .await
528    }
529
530    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
531        Ok(PathBuf::from(
532            self.core
533                .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
534                .await?,
535        ))
536    }
537
538    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
539        Ok(PathBuf::from(
540            self.core
541                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
542                .await?,
543        ))
544    }
545
546    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
547        // `^{commit}` peels an annotated tag down to the commit it points at.
548        let spec = format!("{rev}^{{commit}}");
549        self.core
550            .text(
551                self.core
552                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
553            )
554            .await
555    }
556
557    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
558        // `--quiet` makes an unset origin/HEAD a silent non-zero exit (no `fatal:`
559        // on stderr); that's "no default branch", not an error — so inspect the
560        // code rather than `?`.
561        let res = self
562            .core
563            .capture(
564                self.core
565                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
566            )
567            .await?;
568        if res.code() == Some(0) {
569            // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so a
570            // slashed default branch (e.g. "release/v2") survives intact.
571            let out = res.stdout().trim();
572            Ok(Some(
573                out.strip_prefix("refs/remotes/origin/")
574                    .unwrap_or(out)
575                    .to_string(),
576            ))
577        } else {
578            Ok(None)
579        }
580    }
581
582    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
583        let refname = format!("refs/heads/{name}");
584        // `show-ref --verify --quiet` is an exit-code answer: 0 = exists, 1 = not.
585        self.core
586            .probe(
587                self.core
588                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
589            )
590            .await
591    }
592
593    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
594        // No credential prompt, bounded wait: a missing helper or a flaky network
595        // must not hang the call. `capture` reports a timeout as a flagged result
596        // (non-zero exit) rather than erroring, so an unreachable remote reads as
597        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
598        // spawn failure (no `git`) still surfaces as an error.
599        //
600        // Query the *fully-qualified* ref: `ls-remote origin <name>` tail-matches
601        // path components, so a bare `foo` would also match `refs/heads/bar/foo`.
602        // `refs/heads/<name>` matches only the exact branch.
603        let refname = format!("refs/heads/{name}");
604        let cmd = self
605            .core
606            .command_in(dir, ["ls-remote", "origin", refname.as_str()])
607            .env("GIT_TERMINAL_PROMPT", "0")
608            .timeout(Duration::from_secs(10));
609        let res = self.core.capture(cmd).await?;
610        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
611    }
612
613    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
614        self.core
615            .text(self.core.command_in(dir, ["remote", "get-url", remote]))
616            .await
617    }
618
619    async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
620        // `@{u}` resolves the configured upstream; with no upstream the command
621        // exits non-zero — surface that as `None` rather than an error.
622        match self
623            .core
624            .capture(self.core.command_in(
625                dir,
626                ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
627            ))
628            .await?
629        {
630            res if res.code() == Some(0) => {
631                let name = res.stdout().trim();
632                Ok((!name.is_empty()).then(|| name.to_string()))
633            }
634            _ => Ok(None),
635        }
636    }
637
638    async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
639        // `GIT_TERMINAL_PROMPT=0`: a remote needing credentials must fail fast,
640        // never block on an interactive auth prompt.
641        let cmd = self
642            .core
643            .command_in(dir, ["ls-remote", "--heads", remote])
644            .env("GIT_TERMINAL_PROMPT", "0");
645        self.core.parse(cmd, parse::parse_ls_remote_heads).await
646    }
647
648    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
649        let out = self
650            .core
651            .text(self.core.command_in(dir, ["branch", "--merged", target]))
652            .await?;
653        // Each line is a fixed 2-column marker (`  `/`* `/`+ `) then the name;
654        // drop exactly those two columns rather than trimming a char class (which
655        // would over-strip a name that legitimately began with the marker char).
656        Ok(out
657            .lines()
658            .filter_map(|line| line.get(2..))
659            .any(|b| b == branch))
660    }
661
662    async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
663        let flag = format!("--set-upstream-to={upstream}");
664        self.core
665            .unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
666            .await
667    }
668
669    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
670        let flag = if force { "-D" } else { "-d" };
671        self.core
672            .unit(self.core.command_in(dir, ["branch", flag, name]))
673            .await
674    }
675
676    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
677        self.core
678            .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
679            .await
680    }
681
682    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
683        self.core
684            .try_parse(
685                self.core.command_in(dir, ["rev-list", "--count", range]),
686                |s| {
687                    s.trim().parse::<usize>().map_err(|e| Error::Parse {
688                        program: BINARY.to_string(),
689                        message: e.to_string(),
690                    })
691                },
692            )
693            .await
694    }
695
696    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
697        // `diff --quiet <range>`: 0 = empty range, 1 = has changes.
698        self.core
699            .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
700            .await
701    }
702
703    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
704        self.core
705            .parse(
706                self.core.command_in(dir, ["diff", "--shortstat", range]),
707                parse::parse_shortstat,
708            )
709            .await
710    }
711
712    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
713        // The target is a single positional arg: `HEAD` for the working tree, or
714        // the caller's revision/range. `-M` enables rename detection; `--no-color`
715        // / `--no-ext-diff` keep the output stable and machine-parseable.
716        let target = match spec {
717            DiffSpec::WorkingTree => {
718                // On an unborn repo `HEAD` doesn't resolve (`git diff HEAD` errors);
719                // diff against the empty tree so a pre-first-commit working tree
720                // still yields its additions instead of a hard failure.
721                if self.is_unborn(dir).await? {
722                    EMPTY_TREE.to_string()
723                } else {
724                    "HEAD".to_string()
725                }
726            }
727            DiffSpec::Rev(rev) => rev,
728        };
729        self.core
730            .text(self.core.command_in(
731                dir,
732                ["diff", target.as_str(), "--no-color", "--no-ext-diff", "-M"],
733            ))
734            .await
735    }
736
737    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
738        let text = self.diff_text(dir, spec).await?;
739        Ok(parse::parse_diff(&text))
740    }
741
742    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
743        // `diff --cached --quiet`: 0 = nothing staged, 1 = staged changes.
744        self.core
745            .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
746            .await
747    }
748
749    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
750        let git_dir = self.resolved_git_dir(dir).await?;
751        Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
752    }
753
754    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
755        Ok(self
756            .resolved_git_dir(dir)
757            .await?
758            .join("MERGE_HEAD")
759            .exists())
760    }
761
762    async fn fetch(&self, dir: &Path) -> Result<()> {
763        // `GIT_TERMINAL_PROMPT=0` so a remote needing credentials fails fast
764        // rather than blocking on an interactive prompt — matching the other
765        // remote ops (`fetch_remote_branch`, `push`, `remote_branch_exists`).
766        // Fetch is idempotent, so `retry` replays it on a transient failure
767        // (DNS/timeout/dropped connection); a non-transient error fails at once.
768        let cmd = self
769            .core
770            .command_in(dir, ["fetch", "--quiet"])
771            .env("GIT_TERMINAL_PROMPT", "0")
772            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
773        self.core.unit(cmd).await
774    }
775
776    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
777        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
778        let cmd = self
779            .core
780            .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
781            .env("GIT_TERMINAL_PROMPT", "0")
782            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
783        self.core.unit(cmd).await
784    }
785
786    async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
787        let mut args: Vec<&str> = vec!["push"];
788        if spec.set_upstream {
789            args.push("-u");
790        }
791        args.push(spec.remote.as_str());
792        args.push(spec.refspec.as_str());
793        let cmd = self
794            .core
795            .command_in(dir, args)
796            .env("GIT_TERMINAL_PROMPT", "0");
797        self.core.unit(cmd).await
798    }
799
800    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
801        self.core
802            .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
803            .await
804    }
805
806    async fn merge_commit(
807        &self,
808        dir: &Path,
809        branch: &str,
810        no_ff: bool,
811        message: Option<String>,
812    ) -> Result<()> {
813        let mut args: Vec<&str> = vec!["merge"];
814        if no_ff {
815            args.push("--no-ff");
816        }
817        if let Some(msg) = message.as_deref() {
818            args.push("-m");
819            args.push(msg);
820        } else {
821            // No message → take the default merge message non-interactively
822            // instead of opening `$EDITOR` (which would hang a headless caller).
823            args.push("--no-edit");
824        }
825        args.push(branch);
826        self.core.unit(self.core.command_in(dir, args)).await
827    }
828
829    async fn merge_no_commit(
830        &self,
831        dir: &Path,
832        branch: &str,
833        squash: bool,
834        no_ff: bool,
835    ) -> Result<()> {
836        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
837        // `--squash` and `--no-ff` are mutually exclusive (git rejects the pair);
838        // a squash never fast-forwards anyway, so it takes precedence.
839        if squash {
840            args.push("--squash");
841        } else if no_ff {
842            args.push("--no-ff");
843        }
844        args.push(branch);
845        self.core.unit(self.core.command_in(dir, args)).await
846    }
847
848    async fn merge_abort(&self, dir: &Path) -> Result<()> {
849        self.core
850            .unit(self.core.command_in(dir, ["merge", "--abort"]))
851            .await
852    }
853
854    async fn merge_continue(&self, dir: &Path) -> Result<()> {
855        // `--no-edit` already reuses the prepared MERGE_MSG; `no_editor` is a
856        // headless backstop so a commit hook re-opening the editor can't hang.
857        self.core
858            .unit(no_editor(
859                self.core.command_in(dir, ["commit", "--no-edit"]),
860            ))
861            .await
862    }
863
864    async fn reset_merge(&self, dir: &Path) -> Result<()> {
865        self.core
866            .unit(self.core.command_in(dir, ["reset", "--merge"]))
867            .await
868    }
869
870    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
871        self.core
872            .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
873            .await
874    }
875
876    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
877        // Force a no-op editor so a rebase that would open `$EDITOR` (reword, or
878        // the message-confirm on `--continue`) never hangs a headless caller.
879        self.core
880            .unit(no_editor(self.core.command_in(dir, ["rebase", onto])))
881            .await
882    }
883
884    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
885        self.core
886            .unit(self.core.command_in(dir, ["rebase", "--abort"]))
887            .await
888    }
889
890    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
891        self.core
892            .unit(no_editor(
893                self.core.command_in(dir, ["rebase", "--continue"]),
894            ))
895            .await
896    }
897
898    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
899        let mut command = self.core.command_in(dir, ["stash", "push"]);
900        if include_untracked {
901            command = command.arg("--include-untracked");
902        }
903        self.core.unit(command).await
904    }
905
906    async fn stash_pop(&self, dir: &Path) -> Result<()> {
907        self.core
908            .unit(self.core.command_in(dir, ["stash", "pop"]))
909            .await
910    }
911
912    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
913        self.core
914            .parse(
915                self.core
916                    .command_in(dir, ["worktree", "list", "--porcelain"]),
917                parse::parse_worktree_porcelain,
918            )
919            .await
920    }
921
922    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
923        let mut command = self.core.command_in(dir, ["worktree", "add"]);
924        if let Some(name) = spec.new_branch.as_deref() {
925            command = command.arg("-b").arg(name);
926        }
927        if spec.no_checkout {
928            command = command.arg("--no-checkout");
929        }
930        command = command.arg(&spec.path);
931        if let Some(commitish) = spec.commitish.as_deref() {
932            command = command.arg(commitish);
933        }
934        self.core.unit(command).await
935    }
936
937    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
938        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
939        if force {
940            command = command.arg("--force");
941        }
942        command = command.arg(path);
943        self.core.unit(command).await
944    }
945
946    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
947        let command = self
948            .core
949            .command_in(dir, ["worktree", "move"])
950            .arg(from)
951            .arg(to);
952        self.core.unit(command).await
953    }
954
955    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
956        self.core
957            .unit(self.core.command_in(dir, ["worktree", "prune"]))
958            .await
959    }
960}
961
962// --- Error classification ----------------------------------------------------
963//
964// git writes load-bearing diagnostics to *either* stream on failure — `CONFLICT
965// (content): …` to stdout, `Automatic merge failed …` to stderr — so these probe
966// both `stdout` and `stderr` of `Error::Exit`. Consumers call these instead of
967// re-implementing the string-scraping themselves.
968
969/// Git's well-known empty-tree object id — a stable stand-in for `HEAD` when
970/// diffing the working tree of an unborn (no-commits-yet) repository.
971const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
972
973/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
974const FETCH_ATTEMPTS: u32 = 3;
975/// Fixed backoff between fetch retries.
976const FETCH_BACKOFF: Duration = Duration::from_millis(500);
977
978/// Lower-case substrings marking a merge that stopped on conflicts.
979const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
980/// Lower-case substrings marking a commit that found nothing to record.
981const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
982/// Lower-case substrings marking a transient (retryable) network/fetch failure.
983const TRANSIENT_FETCH_MARKERS: &[&str] = &[
984    "could not resolve host",
985    "couldn't resolve host",
986    "temporary failure in name resolution",
987    "connection timed out",
988    "connection refused",
989    "operation timed out",
990    "timed out",
991    "network is unreachable",
992    "failed to connect",
993    "could not read from remote repository",
994    "the remote end hung up",
995    "early eof",
996    "rpc failed",
997];
998
999/// Whether `err` is an `Error::Exit` whose captured output contains any marker.
1000fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
1001    let Error::Exit { stdout, stderr, .. } = err else {
1002        return false;
1003    };
1004    let out = stdout.to_ascii_lowercase();
1005    let errt = stderr.to_ascii_lowercase();
1006    markers.iter().any(|m| out.contains(m) || errt.contains(m))
1007}
1008
1009/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict.
1010pub fn is_merge_conflict(err: &Error) -> bool {
1011    exit_output_matches(err, CONFLICT_MARKERS)
1012}
1013
1014/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
1015/// tree), as opposed to a real error.
1016pub fn is_nothing_to_commit(err: &Error) -> bool {
1017    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
1018}
1019
1020/// Whether a failed `fetch`/`fetch_remote_branch`/`remote_branch_exists` looks
1021/// transient (DNS, timeout, dropped connection) and is worth retrying.
1022pub fn is_transient_fetch_error(err: &Error) -> bool {
1023    // A processkit-level timeout (a `.timeout()`-bounded run that expired) carries
1024    // no captured output but is inherently transient; treat it as retryable too.
1025    matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
1026}
1027
1028/// Point git's editor at a no-op so any command that would open `$EDITOR`
1029/// (a rebase reword, the message-confirm on `rebase --continue`) succeeds
1030/// non-interactively instead of hanging a headless caller.
1031fn no_editor(cmd: processkit::Command) -> processkit::Command {
1032    cmd.env("GIT_EDITOR", "true")
1033        .env("GIT_SEQUENCE_EDITOR", "true")
1034}
1035
1036impl<R: ProcessRunner> Git<R> {
1037    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
1038    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
1039    /// trait), so it can take `&[&str]`; forwards to the same path as
1040    /// [`GitApi::run`].
1041    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1042        self.core.text(self.core.command(args)).await
1043    }
1044
1045    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
1046    /// (mirrors [`GitApi::run_raw`]).
1047    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1048        self.core.capture(self.core.command(args)).await
1049    }
1050
1051    /// Bind this client to `dir`, returning a [`GitAt`] handle whose methods omit
1052    /// the `dir` argument: `git.at(dir).status()` runs [`status`](GitApi::status)
1053    /// against `dir`. The dir-taking [`GitApi`] methods stay on [`Git`] for
1054    /// driving many directories (e.g. linked worktrees) from one client.
1055    pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
1056        GitAt { git: self, dir }
1057    }
1058
1059    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
1060    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
1061    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
1062        let git_dir = PathBuf::from(
1063            self.core
1064                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1065                .await?,
1066        );
1067        Ok(if git_dir.is_absolute() {
1068            git_dir
1069        } else {
1070            dir.join(git_dir)
1071        })
1072    }
1073}
1074
1075/// A [`Git`] client with a working directory bound, so calls drop the leading
1076/// `dir` argument — `git.at(dir).status()` is `git.status(dir)`. Construct one
1077/// with [`Git::at`] (or, through the facade, `vcs_core::Repo::git_at`). Cheap to
1078/// copy: it only borrows the client and the path.
1079pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
1080    git: &'a Git<R>,
1081    dir: &'a Path,
1082}
1083
1084// Hand-written rather than derived: the view only holds two references, so it is
1085// `Copy` for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy`
1086// bound that the real default `JobRunner` doesn't satisfy, silently dropping
1087// `Copy` on the production `Repo::git_at()` handle.
1088impl<R: ProcessRunner> Clone for GitAt<'_, R> {
1089    fn clone(&self) -> Self {
1090        *self
1091    }
1092}
1093impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
1094
1095/// Generate [`GitAt`] forwarders from a method list: `bare` methods forward
1096/// verbatim, `dir` methods inject `self.dir` as the first argument.
1097macro_rules! git_at_forwarders {
1098    (
1099        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1100        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1101    ) => {
1102        impl<'a, R: ProcessRunner> GitAt<'a, R> {
1103            $(
1104                #[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
1105                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1106                    self.git.$bn($($ba),*).await
1107                }
1108            )*
1109            $(
1110                #[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1111                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1112                    self.git.$dn(self.dir, $($da),*).await
1113                }
1114            )*
1115        }
1116    };
1117}
1118
1119git_at_forwarders! {
1120    bare {
1121        fn run(args: &[String]) -> Result<String>;
1122        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1123        fn run_args(args: &[&str]) -> Result<String>;
1124        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1125        fn version() -> Result<String>;
1126    }
1127    dir {
1128        fn status() -> Result<Vec<StatusEntry>>;
1129        fn status_text() -> Result<String>;
1130        fn current_branch() -> Result<String>;
1131        fn branches() -> Result<Vec<Branch>>;
1132        fn log(max: usize) -> Result<Vec<Commit>>;
1133        fn log_range(range: &str, max: usize) -> Result<Vec<Commit>>;
1134        fn rev_parse(rev: &str) -> Result<String>;
1135        fn rev_parse_short(rev: &str) -> Result<String>;
1136        fn init() -> Result<()>;
1137        fn add(paths: &[PathBuf]) -> Result<()>;
1138        fn commit(message: &str) -> Result<()>;
1139        fn create_branch(name: &str) -> Result<()>;
1140        fn checkout(reference: &str) -> Result<()>;
1141        fn checkout_detach(commit: &str) -> Result<()>;
1142        fn commit_paths(paths: &[PathBuf], message: &str, amend: bool) -> Result<()>;
1143        fn last_commit_message() -> Result<String>;
1144        fn is_unborn() -> Result<bool>;
1145        fn diff_is_empty() -> Result<bool>;
1146        fn common_dir() -> Result<PathBuf>;
1147        fn git_dir() -> Result<PathBuf>;
1148        fn resolve_commit(rev: &str) -> Result<String>;
1149        fn remote_head_branch() -> Result<Option<String>>;
1150        fn branch_exists(name: &str) -> Result<bool>;
1151        fn remote_branch_exists(name: &str) -> Result<bool>;
1152        fn remote_url(remote: &str) -> Result<String>;
1153        fn upstream() -> Result<Option<String>>;
1154        fn remote_branches(remote: &str) -> Result<Vec<String>>;
1155        fn is_merged(branch: &str, target: &str) -> Result<bool>;
1156        fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
1157        fn delete_branch(name: &str, force: bool) -> Result<()>;
1158        fn rename_branch(old: &str, new: &str) -> Result<()>;
1159        fn rev_list_count(range: &str) -> Result<usize>;
1160        fn diff_range_is_empty(range: &str) -> Result<bool>;
1161        fn diff_stat(range: &str) -> Result<DiffStat>;
1162        fn diff_text(spec: DiffSpec) -> Result<String>;
1163        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1164        fn staged_is_empty() -> Result<bool>;
1165        fn is_rebase_in_progress() -> Result<bool>;
1166        fn is_merge_in_progress() -> Result<bool>;
1167        fn fetch() -> Result<()>;
1168        fn fetch_remote_branch(branch: &str) -> Result<()>;
1169        fn push(spec: GitPush) -> Result<()>;
1170        fn merge_squash(branch: &str) -> Result<()>;
1171        fn merge_commit(branch: &str, no_ff: bool, message: Option<String>) -> Result<()>;
1172        fn merge_no_commit(branch: &str, squash: bool, no_ff: bool) -> Result<()>;
1173        fn merge_abort() -> Result<()>;
1174        fn merge_continue() -> Result<()>;
1175        fn reset_merge() -> Result<()>;
1176        fn reset_hard(rev: &str) -> Result<()>;
1177        fn rebase(onto: &str) -> Result<()>;
1178        fn rebase_abort() -> Result<()>;
1179        fn rebase_continue() -> Result<()>;
1180        fn stash_push(include_untracked: bool) -> Result<()>;
1181        fn stash_pop() -> Result<()>;
1182        fn worktree_list() -> Result<Vec<Worktree>>;
1183        fn worktree_add(spec: WorktreeAdd) -> Result<()>;
1184        fn worktree_remove(path: &Path, force: bool) -> Result<()>;
1185        fn worktree_move(from: &Path, to: &Path) -> Result<()>;
1186        fn worktree_prune() -> Result<()>;
1187    }
1188}
1189
1190/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
1191/// a `Drop` guard. They shell out through `std::process` directly (no async, no
1192/// job-containment), so reserve them for short-lived cleanup.
1193pub mod blocking {
1194    use std::path::Path;
1195    use std::process::Command;
1196
1197    /// Remove a worktree synchronously (`git worktree remove [--force] <path>`).
1198    pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
1199        let mut cmd = Command::new(super::BINARY);
1200        cmd.current_dir(dir).args(["worktree", "remove"]);
1201        if force {
1202            cmd.arg("--force");
1203        }
1204        cmd.arg(path);
1205        let status = cmd.status()?;
1206        if status.success() {
1207            Ok(())
1208        } else {
1209            Err(std::io::Error::other(format!(
1210                "`git worktree remove` exited with {status}"
1211            )))
1212        }
1213    }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use super::*;
1219    use processkit::{RecordingRunner, Reply, ScriptedRunner};
1220
1221    #[test]
1222    fn binary_name_is_git() {
1223        assert_eq!(BINARY, "git");
1224    }
1225
1226    // Compile-time guard: the bound view must stay `Copy` for the *default*
1227    // `JobRunner` (the production `Repo::git_at()` handle), not just for the
1228    // `&RecordingRunner` the other tests use. A derived `Copy` would regress this.
1229    #[allow(dead_code)]
1230    fn bound_view_is_copy_for_default_runner() {
1231        fn assert_copy<T: Copy>() {}
1232        assert_copy::<GitAt<'static, processkit::JobRunner>>();
1233    }
1234
1235    // The bound view (`git.at(dir)`) must produce byte-identical argv to the
1236    // dir-taking call (`git.method(dir, …)`) — the forwarder injects `self.dir`
1237    // in the right place and nothing else changes.
1238    #[tokio::test]
1239    async fn bound_view_matches_dir_taking_calls() {
1240        let dir = Path::new("/repo");
1241        let rec = RecordingRunner::replying(Reply::ok(""));
1242        let git = Git::with_runner(&rec);
1243
1244        // A method with trailing args (dir injected first).
1245        git.merge_commit(dir, "feat", true, None).await.unwrap();
1246        git.at(dir).merge_commit("feat", true, None).await.unwrap();
1247        // A method taking a path arg after dir.
1248        git.worktree_remove(dir, Path::new("/wt"), true)
1249            .await
1250            .unwrap();
1251        git.at(dir)
1252            .worktree_remove(Path::new("/wt"), true)
1253            .await
1254            .unwrap();
1255
1256        let calls = rec.calls();
1257        assert_eq!(calls[0].args_str(), calls[1].args_str());
1258        assert_eq!(calls[2].args_str(), calls[3].args_str());
1259        // The bound calls also carried the bound dir as their working directory.
1260        assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
1261        assert_eq!(calls[3].cwd.as_deref(), Some(dir.as_os_str()));
1262    }
1263
1264    // Hermetic: the real status() command-building + porcelain parsing run
1265    // against a scripted runner — no `git` binary needed, so this runs on CI.
1266    #[tokio::test]
1267    async fn status_parses_scripted_output() {
1268        // `-z` output: NUL-delimited records, raw paths.
1269        let git =
1270            Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
1271        let entries = git.status(Path::new(".")).await.expect("status");
1272        assert_eq!(entries.len(), 2);
1273        assert_eq!(entries[0].code, " M");
1274        assert_eq!(entries[1].path, "b.rs");
1275    }
1276
1277    #[tokio::test]
1278    async fn rev_parse_short_builds_short_flag() {
1279        let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
1280        let git = Git::with_runner(&rec);
1281        let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
1282        assert_eq!(out, "a1b2c3d");
1283        assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
1284    }
1285
1286    // A non-zero exit surfaces as a structured `Error::Exit`.
1287    #[tokio::test]
1288    async fn nonzero_exit_is_structured_error() {
1289        let git = Git::with_runner(
1290            ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
1291        );
1292        match git.status(Path::new(".")).await.unwrap_err() {
1293            Error::Exit { code, stderr, .. } => {
1294                assert_eq!(code, 128);
1295                assert!(stderr.contains("not a git repository"), "{stderr}");
1296            }
1297            other => panic!("expected Exit, got {other:?}"),
1298        }
1299    }
1300
1301    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
1302    // anything else is a real failure surfaced as Error::Exit.
1303    #[tokio::test]
1304    async fn diff_is_empty_maps_exit_codes() {
1305        let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
1306        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
1307
1308        let dirty =
1309            Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
1310        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
1311
1312        let broken = Git::with_runner(
1313            ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
1314        );
1315        assert!(matches!(
1316            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
1317            Error::Exit { code: 128, .. }
1318        ));
1319    }
1320
1321    // `add` must insert `--` before the pathspecs so a path can never be parsed
1322    // as an option. No fallback rule: the run only matches if `add --` was built.
1323    #[tokio::test]
1324    async fn add_inserts_pathspec_separator() {
1325        let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
1326        git.add(Path::new("."), &[PathBuf::from("f.rs")])
1327            .await
1328            .expect("add should build `add -- <paths>`");
1329    }
1330
1331    #[tokio::test]
1332    async fn worktree_list_parses_porcelain() {
1333        let git = Git::with_runner(ScriptedRunner::new().on(
1334            ["worktree", "list"],
1335            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
1336        ));
1337        let wts = git.worktree_list(Path::new(".")).await.expect("list");
1338        assert_eq!(wts.len(), 1);
1339        assert_eq!(wts[0].branch.as_deref(), Some("main"));
1340        assert_eq!(wts[0].head.as_deref(), Some("abc"));
1341    }
1342
1343    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
1344    // in that exact order; only the full argv is scripted (no fallback).
1345    #[tokio::test]
1346    async fn worktree_add_builds_branch_path_and_base() {
1347        let rec = RecordingRunner::replying(Reply::ok(""));
1348        let git = Git::with_runner(&rec);
1349        git.worktree_add(
1350            Path::new("/repo"),
1351            WorktreeAdd::create_branch("/wt", "feature", "main"),
1352        )
1353        .await
1354        .expect("worktree add");
1355        assert_eq!(
1356            rec.only_call().args_str(),
1357            ["worktree", "add", "-b", "feature", "/wt", "main"]
1358        );
1359    }
1360
1361    #[tokio::test]
1362    async fn worktree_remove_passes_force_then_path() {
1363        let rec = RecordingRunner::replying(Reply::ok(""));
1364        let git = Git::with_runner(&rec);
1365        git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
1366            .await
1367            .expect("remove");
1368        assert_eq!(
1369            rec.only_call().args_str(),
1370            ["worktree", "remove", "--force", "/wt"]
1371        );
1372    }
1373
1374    // `--no-checkout` must land between `-b <name>` and the path.
1375    #[tokio::test]
1376    async fn worktree_add_no_checkout_inserts_flag() {
1377        let rec = RecordingRunner::replying(Reply::ok(""));
1378        let git = Git::with_runner(&rec);
1379        git.worktree_add(
1380            Path::new("/repo"),
1381            WorktreeAdd::checkout("/wt", "main").no_checkout(),
1382        )
1383        .await
1384        .expect("worktree add");
1385        assert_eq!(
1386            rec.only_call().args_str(),
1387            ["worktree", "add", "--no-checkout", "/wt", "main"]
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn checkout_detach_builds_args() {
1393        let rec = RecordingRunner::replying(Reply::ok(""));
1394        let git = Git::with_runner(&rec);
1395        git.checkout_detach(Path::new("."), "abc123")
1396            .await
1397            .expect("detach");
1398        assert_eq!(
1399            rec.only_call().args_str(),
1400            ["checkout", "--detach", "abc123"]
1401        );
1402    }
1403
1404    // Partial amend commit must build `commit --amend -m <msg> --only -- <paths>`.
1405    #[tokio::test]
1406    async fn commit_paths_builds_only_amend_args() {
1407        let rec = RecordingRunner::replying(Reply::ok(""));
1408        let git = Git::with_runner(&rec);
1409        git.commit_paths(
1410            Path::new("."),
1411            &[PathBuf::from("a.rs"), PathBuf::from("b.rs")],
1412            "msg",
1413            true,
1414        )
1415        .await
1416        .expect("commit_paths");
1417        assert_eq!(
1418            rec.only_call().args_str(),
1419            [
1420                "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
1421            ]
1422        );
1423    }
1424
1425    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
1426    // unborn (true), anything else is a structured error.
1427    #[tokio::test]
1428    async fn is_unborn_maps_exit_codes() {
1429        let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
1430        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
1431        let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
1432        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
1433        let broken =
1434            Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
1435        assert!(matches!(
1436            broken.is_unborn(Path::new(".")).await.unwrap_err(),
1437            Error::Exit { code: 128, .. }
1438        ));
1439    }
1440
1441    #[tokio::test]
1442    async fn log_range_builds_range_and_format() {
1443        let rec = RecordingRunner::replying(Reply::ok(""));
1444        let git = Git::with_runner(&rec);
1445        git.log_range(Path::new("."), "main..HEAD", 5)
1446            .await
1447            .expect("log_range");
1448        assert_eq!(
1449            rec.only_call().args_str(),
1450            [
1451                "log",
1452                "main..HEAD",
1453                "-n5",
1454                "-z",
1455                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
1456            ]
1457        );
1458    }
1459
1460    #[tokio::test]
1461    async fn stash_push_adds_include_untracked() {
1462        let rec = RecordingRunner::replying(Reply::ok(""));
1463        let git = Git::with_runner(&rec);
1464        git.stash_push(Path::new("."), true).await.expect("stash");
1465        assert_eq!(
1466            rec.only_call().args_str(),
1467            ["stash", "push", "--include-untracked"]
1468        );
1469    }
1470
1471    // `diff_text` for the working tree must build `diff HEAD` plus the stable
1472    // machine-output flags, in order.
1473    #[tokio::test]
1474    async fn diff_text_builds_working_tree_args() {
1475        // The `rev-parse` unborn probe replies exit 0 (HEAD resolves), so the diff
1476        // targets HEAD. The probe is the first call; the diff is the last.
1477        let rec = RecordingRunner::replying(Reply::ok(""));
1478        let git = Git::with_runner(&rec);
1479        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
1480            .await
1481            .expect("diff_text");
1482        assert_eq!(
1483            rec.calls().last().unwrap().args_str(),
1484            ["diff", "HEAD", "--no-color", "--no-ext-diff", "-M"]
1485        );
1486    }
1487
1488    // On an unborn repo the working-tree diff targets the empty tree instead of
1489    // the unresolvable `HEAD`, so it returns additions rather than erroring. The
1490    // diff rule only matches the empty-tree argv, so a `HEAD` target would miss it.
1491    #[tokio::test]
1492    async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
1493        let git = Git::with_runner(
1494            ScriptedRunner::new()
1495                .on(["rev-parse"], Reply::fail(1, "")) // unborn: HEAD doesn't resolve
1496                .on(["diff", EMPTY_TREE], Reply::ok("EMPTY")),
1497        );
1498        let out = git
1499            .diff_text(Path::new("."), DiffSpec::WorkingTree)
1500            .await
1501            .expect("diff_text");
1502        assert_eq!(out, "EMPTY");
1503    }
1504
1505    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
1506    // canned git-format output.
1507    #[tokio::test]
1508    async fn diff_parses_scripted_output() {
1509        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
1510        let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
1511        let files = git
1512            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
1513            .await
1514            .expect("diff");
1515        assert_eq!(files.len(), 1);
1516        assert_eq!(files[0].path, "m");
1517        assert_eq!(files[0].change, ChangeKind::Modified);
1518    }
1519
1520    #[tokio::test]
1521    async fn branch_exists_maps_exit_codes() {
1522        let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1523        assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
1524        let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1525        assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
1526    }
1527
1528    // The full ref prefix is stripped but a slashed default branch survives; an
1529    // unset origin/HEAD (non-zero exit) is `None`, not an error.
1530    #[tokio::test]
1531    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
1532        let simple = Git::with_runner(
1533            ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
1534        );
1535        assert_eq!(
1536            simple
1537                .remote_head_branch(Path::new("."))
1538                .await
1539                .unwrap()
1540                .as_deref(),
1541            Some("main")
1542        );
1543
1544        let slashed = Git::with_runner(ScriptedRunner::new().on(
1545            ["symbolic-ref"],
1546            Reply::ok("refs/remotes/origin/release/v2\n"),
1547        ));
1548        assert_eq!(
1549            slashed
1550                .remote_head_branch(Path::new("."))
1551                .await
1552                .unwrap()
1553                .as_deref(),
1554            Some("release/v2")
1555        );
1556
1557        let unset =
1558            Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
1559        assert!(
1560            unset
1561                .remote_head_branch(Path::new("."))
1562                .await
1563                .unwrap()
1564                .is_none()
1565        );
1566    }
1567
1568    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
1569    // stdout as "absent".
1570    #[tokio::test]
1571    async fn remote_branch_exists_sets_env_and_reads_stdout() {
1572        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
1573        let git = Git::with_runner(&rec);
1574        assert!(
1575            git.remote_branch_exists(Path::new("/repo"), "main")
1576                .await
1577                .unwrap()
1578        );
1579        let call = rec.only_call();
1580        assert!(call.envs.iter().any(|(k, v)| {
1581            k.to_str() == Some("GIT_TERMINAL_PROMPT")
1582                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1583        }));
1584        // Exact-ref query — a bare `main` would tail-match `bar/main`.
1585        assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
1586
1587        let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
1588        assert!(
1589            !empty
1590                .remote_branch_exists(Path::new("."), "x")
1591                .await
1592                .unwrap()
1593        );
1594    }
1595
1596    #[tokio::test]
1597    async fn diff_stat_parses_counts() {
1598        let git = Git::with_runner(ScriptedRunner::new().on(
1599            ["diff", "--shortstat"],
1600            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
1601        ));
1602        let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
1603        assert_eq!(
1604            (stat.files_changed, stat.insertions, stat.deletions),
1605            (2, 5, 1)
1606        );
1607    }
1608
1609    #[tokio::test]
1610    async fn status_text_returns_raw_porcelain() {
1611        let git = Git::with_runner(ScriptedRunner::new().on(
1612            ["status", "--porcelain=v1"],
1613            Reply::ok(" M a.rs\n?? b.rs\n"),
1614        ));
1615        let text = git.status_text(Path::new(".")).await.expect("status_text");
1616        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
1617    }
1618
1619    #[tokio::test]
1620    async fn run_args_forwards_str_slices() {
1621        let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
1622        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
1623    }
1624
1625    // Conflict markers live on stdout (`CONFLICT (…)`) or stderr (`Automatic
1626    // merge failed`); either should classify. A plain error must not.
1627    #[test]
1628    fn classifies_merge_conflict() {
1629        let on_stdout = Error::Exit {
1630            program: "git".into(),
1631            code: 1,
1632            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1633            stderr: String::new(),
1634        };
1635        let on_stderr = Error::Exit {
1636            program: "git".into(),
1637            code: 1,
1638            stdout: String::new(),
1639            stderr: "Automatic merge failed; fix conflicts and then commit".into(),
1640        };
1641        let unrelated = Error::Exit {
1642            program: "git".into(),
1643            code: 128,
1644            stdout: String::new(),
1645            stderr: "fatal: not a git repository".into(),
1646        };
1647        assert!(is_merge_conflict(&on_stdout));
1648        assert!(is_merge_conflict(&on_stderr));
1649        assert!(!is_merge_conflict(&unrelated));
1650        assert!(!is_nothing_to_commit(&on_stdout));
1651    }
1652
1653    #[test]
1654    fn classifies_nothing_to_commit_and_transient_fetch() {
1655        let nothing = Error::Exit {
1656            program: "git".into(),
1657            code: 1,
1658            stdout: "nothing to commit, working tree clean".into(),
1659            stderr: String::new(),
1660        };
1661        assert!(is_nothing_to_commit(&nothing));
1662
1663        let dns = Error::Exit {
1664            program: "git".into(),
1665            code: 128,
1666            stdout: String::new(),
1667            stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
1668        };
1669        assert!(is_transient_fetch_error(&dns));
1670        assert!(!is_transient_fetch_error(&nothing));
1671
1672        // A processkit timeout (no captured output) is transient too.
1673        let timeout = Error::Timeout {
1674            program: "git".into(),
1675            timeout: Duration::from_secs(10),
1676        };
1677        assert!(is_transient_fetch_error(&timeout));
1678    }
1679
1680    #[tokio::test]
1681    async fn merge_commit_builds_no_ff_and_message() {
1682        let rec = RecordingRunner::replying(Reply::ok(""));
1683        let git = Git::with_runner(&rec);
1684        git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
1685            .await
1686            .unwrap();
1687        assert_eq!(
1688            rec.only_call().args_str(),
1689            ["merge", "--no-ff", "-m", "merge it", "feature"]
1690        );
1691    }
1692
1693    // No message → `--no-edit` (default message, non-interactive) instead of `$EDITOR`.
1694    #[tokio::test]
1695    async fn merge_commit_without_message_uses_no_edit() {
1696        let rec = RecordingRunner::replying(Reply::ok(""));
1697        let git = Git::with_runner(&rec);
1698        git.merge_commit(Path::new("/r"), "feature", false, None)
1699            .await
1700            .unwrap();
1701        assert_eq!(
1702            rec.only_call().args_str(),
1703            ["merge", "--no-edit", "feature"]
1704        );
1705    }
1706
1707    // rebase/rebase_continue force a no-op editor so a headless caller never hangs.
1708    #[tokio::test]
1709    async fn rebase_suppresses_editor() {
1710        let rec = RecordingRunner::replying(Reply::ok(""));
1711        let git = Git::with_runner(&rec);
1712        git.rebase(Path::new("/r"), "main").await.unwrap();
1713        let call = rec.only_call();
1714        assert_eq!(call.args_str(), ["rebase", "main"]);
1715        assert!(call.envs.iter().any(|(k, v)| {
1716            k.to_str() == Some("GIT_EDITOR")
1717                && v.as_deref().and_then(|o| o.to_str()) == Some("true")
1718        }));
1719    }
1720
1721    #[tokio::test]
1722    async fn push_builds_set_upstream_remote_refspec() {
1723        let rec = RecordingRunner::replying(Reply::ok(""));
1724        let git = Git::with_runner(&rec);
1725        git.push(
1726            Path::new("/r"),
1727            GitPush::refspec("feat", "feature").set_upstream(),
1728        )
1729        .await
1730        .unwrap();
1731        assert_eq!(
1732            rec.only_call().args_str(),
1733            ["push", "-u", "origin", "feat:feature"]
1734        );
1735    }
1736
1737    #[tokio::test]
1738    async fn upstream_maps_unset_to_none() {
1739        let set =
1740            Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("origin/main\n")));
1741        assert_eq!(
1742            set.upstream(Path::new(".")).await.unwrap().as_deref(),
1743            Some("origin/main")
1744        );
1745        let unset = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "")));
1746        assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
1747    }
1748
1749    #[tokio::test]
1750    async fn set_upstream_builds_branch_flag() {
1751        let rec = RecordingRunner::replying(Reply::ok(""));
1752        let git = Git::with_runner(&rec);
1753        git.set_upstream(Path::new("/r"), "feat", "origin/feature")
1754            .await
1755            .unwrap();
1756        assert_eq!(
1757            rec.only_call().args_str(),
1758            ["branch", "--set-upstream-to=origin/feature", "feat"]
1759        );
1760    }
1761
1762    #[tokio::test]
1763    async fn remote_branches_parses_ls_remote() {
1764        let git = Git::with_runner(ScriptedRunner::new().on(
1765            ["ls-remote"],
1766            Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
1767        ));
1768        let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
1769        assert_eq!(branches, ["main", "feat/x"]);
1770    }
1771
1772    #[tokio::test]
1773    async fn delete_branch_force_uses_capital_d() {
1774        let rec = RecordingRunner::replying(Reply::ok(""));
1775        let git = Git::with_runner(&rec);
1776        git.delete_branch(Path::new("/r"), "old", true)
1777            .await
1778            .unwrap();
1779        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
1780    }
1781
1782    // `branch --merged` marks the current branch with `*` and a branch checked out
1783    // in another worktree with `+`; both must still match after marker stripping.
1784    #[tokio::test]
1785    async fn is_merged_strips_branch_markers() {
1786        let git = Git::with_runner(ScriptedRunner::new().on(
1787            ["branch", "--merged"],
1788            Reply::ok("  main\n* feature\n+ wt-branch\n"),
1789        ));
1790        for name in ["main", "feature", "wt-branch"] {
1791            assert!(
1792                git.is_merged(Path::new("."), name, "main").await.unwrap(),
1793                "{name} should be reported merged"
1794            );
1795        }
1796        assert!(
1797            !git.is_merged(Path::new("."), "absent", "main")
1798                .await
1799                .unwrap()
1800        );
1801    }
1802
1803    // `fetch` must disable the credential prompt so it fails fast (never hangs) on
1804    // a remote needing auth — matching the other remote ops.
1805    #[tokio::test]
1806    async fn fetch_disables_terminal_prompt() {
1807        let rec = RecordingRunner::replying(Reply::ok(""));
1808        let git = Git::with_runner(&rec);
1809        git.fetch(Path::new("/r")).await.unwrap();
1810        let call = rec.only_call();
1811        assert_eq!(call.args_str(), ["fetch", "--quiet"]);
1812        assert!(call.envs.iter().any(|(k, v)| {
1813            k.to_str() == Some("GIT_TERMINAL_PROMPT")
1814                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1815        }));
1816    }
1817
1818    // A transient failure (DNS/network) is retried up to FETCH_ATTEMPTS times.
1819    #[tokio::test]
1820    async fn fetch_retries_transient_failures() {
1821        let rec = RecordingRunner::replying(Reply::fail(
1822            128,
1823            "fatal: unable to access: Could not resolve host: example.com",
1824        ));
1825        let git = Git::with_runner(&rec);
1826        assert!(git.fetch(Path::new("/r")).await.is_err());
1827        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
1828    }
1829
1830    // A non-transient failure fails fast — no retry.
1831    #[tokio::test]
1832    async fn fetch_does_not_retry_permanent_failures() {
1833        let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
1834        let git = Git::with_runner(&rec);
1835        assert!(git.fetch(Path::new("/r")).await.is_err());
1836        assert_eq!(rec.calls().len(), 1);
1837    }
1838
1839    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
1840    // tested with a generated mock.
1841    #[cfg(feature = "mock")]
1842    #[tokio::test]
1843    async fn consumer_mocks_the_interface() {
1844        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
1845            git.current_branch(Path::new(".")).await.unwrap() == want
1846        }
1847        let mut mock = MockGitApi::new();
1848        mock.expect_current_branch()
1849            .returning(|_| Ok("main".to_string()));
1850        assert!(on_branch(&mock, "main").await);
1851    }
1852}