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/// The Git operations this crate exposes — the interface consumers code against
106/// and mock in tests.
107#[cfg_attr(feature = "mock", mockall::automock)]
108#[async_trait::async_trait]
109pub trait GitApi: Send + Sync {
110    /// Run `git <args>` in the current directory, returning trimmed stdout
111    /// (throws on a non-zero exit). A raw escape hatch for unmodelled commands.
112    async fn run(&self, args: &[String]) -> Result<String>;
113    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
114    /// captured [`ProcessResult`].
115    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
116    /// Installed Git version (`git --version`).
117    async fn version(&self) -> Result<String>;
118    /// Working-tree status (`git status --porcelain=v1 -z`).
119    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
120    /// Raw porcelain status text (`git status --porcelain=v1`) — the unparsed
121    /// counterpart of [`status`](GitApi::status), mirroring `vcs_jj` `status_text`.
122    async fn status_text(&self, dir: &Path) -> Result<String>;
123    /// Current branch name (`git rev-parse --abbrev-ref HEAD`).
124    async fn current_branch(&self, dir: &Path) -> Result<String>;
125    /// Local branches, current one flagged (`git branch`).
126    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
127    /// Latest `max` commits, newest first (`git log`).
128    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
129    /// Commits in `range`, newest first, up to `max` (`git log <range>`).
130    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
131    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
132    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
133    /// Initialise a repository (`git init`).
134    async fn init(&self, dir: &Path) -> Result<()>;
135    /// Stage `paths` (`git add -- <paths>`).
136    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
137    /// Commit staged changes (`git commit -m`).
138    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
139    /// Create a branch without switching to it (`git branch <name>`).
140    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
141    /// Switch to a branch or revision (`git checkout <reference>`).
142    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
143    /// Check out a commit as a detached HEAD (`git checkout --detach <commit>`).
144    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
145    /// Commit exactly `paths`' working-tree content, ignoring the index
146    /// (`git commit [--amend] -m <message> --only -- <paths>`).
147    async fn commit_paths(
148        &self,
149        dir: &Path,
150        paths: &[PathBuf],
151        message: &str,
152        amend: bool,
153    ) -> Result<()>;
154    /// The last commit's full message (`git log -1 --format=%B`) — e.g. to
155    /// pre-fill an amend.
156    async fn last_commit_message(&self, dir: &Path) -> Result<String>;
157    /// Whether `HEAD` is unborn — a fresh repo with no commits yet
158    /// (`git rev-parse --verify -q HEAD`, exit-code mapped).
159    async fn is_unborn(&self, dir: &Path) -> Result<bool>;
160    /// Whether the working tree has no unstaged changes (`git diff --quiet`).
161    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
162
163    // --- Discovery / identity ------------------------------------------------
164
165    /// The repository's common git directory (`rev-parse --git-common-dir`) —
166    /// stable across linked worktrees.
167    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
168    /// This worktree's git directory (`rev-parse --git-dir`).
169    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
170    /// Resolve a revision to a commit hash, peeling tags
171    /// (`rev-parse --verify <rev>^{commit}`).
172    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
173    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
174    /// (short name only); `None` when `origin/HEAD` is unset.
175    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
176    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
177    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
178    /// Whether `origin` has `name`, without fetching (`ls-remote --heads origin
179    /// <name>`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
180    /// credential or a flaky network can't hang the call.
181    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
182    /// A remote's URL (`remote get-url <remote>`).
183    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
184
185    // --- Branches ------------------------------------------------------------
186
187    /// Whether `branch` is fully merged into `target` (`branch --merged <target>`).
188    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
189    /// Delete a local branch (`branch -d`, or `-D` when `force`).
190    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
191    /// Rename a local branch (`branch -m <old> <new>`).
192    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
193    /// Count commits in a range (`rev-list --count <range>`).
194    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
195    /// Whether a diff range is empty (`diff --quiet <range>`).
196    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
197    /// Aggregate change stats for a range (`diff --shortstat <range>`). Named to
198    /// match `vcs_jj::JjApi::diff_stat`.
199    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
200    /// Raw git-format unified diff text for `spec`
201    /// (`diff <spec> --no-color --no-ext-diff -M`) — stable machine output.
202    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
203    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](GitApi::diff_text).
204    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
205
206    // --- In-progress state ---------------------------------------------------
207
208    /// Whether the index has no staged changes (`diff --cached --quiet`).
209    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
210    /// Whether a rebase is in progress (a `rebase-merge`/`rebase-apply` dir exists
211    /// under the git dir).
212    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
213    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
214    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
215
216    // --- Mutations -----------------------------------------------------------
217
218    /// Fetch from the default remote (`fetch --quiet`).
219    async fn fetch(&self, dir: &Path) -> Result<()>;
220    /// Fetch a single branch from `origin` into its remote-tracking ref
221    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
222    /// `GIT_TERMINAL_PROMPT=0`.
223    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
224    /// Stage a branch's changes without committing (`merge --squash <branch>`).
225    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
226    /// Merge a branch (`merge [--no-ff] [-m <msg>] <branch>`).
227    async fn merge_commit(
228        &self,
229        dir: &Path,
230        branch: &str,
231        no_ff: bool,
232        message: Option<String>,
233    ) -> Result<()>;
234    /// Merge without committing, for a dry run
235    /// (`merge --no-commit [--squash|--no-ff] <branch>`).
236    async fn merge_no_commit(
237        &self,
238        dir: &Path,
239        branch: &str,
240        squash: bool,
241        no_ff: bool,
242    ) -> Result<()>;
243    /// Abort an in-progress merge (`merge --abort`).
244    async fn merge_abort(&self, dir: &Path) -> Result<()>;
245    /// Finish a merge after resolving conflicts (`commit --no-edit`).
246    async fn merge_continue(&self, dir: &Path) -> Result<()>;
247    /// Clear merge state, squash-safe (`reset --merge`).
248    async fn reset_merge(&self, dir: &Path) -> Result<()>;
249    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
250    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
251    /// Rebase the current branch onto `onto` (`rebase <onto>`).
252    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
253    /// Abort an in-progress rebase (`rebase --abort`).
254    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
255    /// Continue a rebase after resolving conflicts (`rebase --continue`).
256    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
257    /// Stash the working tree (`stash push`, `--include-untracked` when asked) —
258    /// e.g. to save state before a copy-on-write restore.
259    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
260    /// Restore the most recent stash and drop it (`stash pop`).
261    async fn stash_pop(&self, dir: &Path) -> Result<()>;
262
263    // --- Worktrees -----------------------------------------------------------
264
265    /// List worktrees (`worktree list --porcelain`).
266    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
267    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
268    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
269    /// Remove a worktree (`worktree remove [--force] <path>`).
270    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
271    /// Move a worktree (`worktree move <from> <to>`).
272    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
273    /// Prune stale worktree admin entries (`worktree prune`).
274    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
275}
276
277processkit::cli_client!(
278    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject
279    /// a fake process executor; `Git::new()` uses the real job-backed runner.
280    pub struct Git => BINARY
281);
282
283#[async_trait::async_trait]
284impl<R: ProcessRunner> GitApi for Git<R> {
285    async fn run(&self, args: &[String]) -> Result<String> {
286        self.core.text(self.core.command(args)).await
287    }
288
289    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
290        self.core.capture(self.core.command(args)).await
291    }
292
293    async fn version(&self) -> Result<String> {
294        self.core.text(self.core.command(["--version"])).await
295    }
296
297    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
298        self.core
299            .parse(
300                self.core
301                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
302                parse::parse_porcelain,
303            )
304            .await
305    }
306
307    async fn status_text(&self, dir: &Path) -> Result<String> {
308        self.core
309            .text(self.core.command_in(dir, ["status", "--porcelain=v1"]))
310            .await
311    }
312
313    async fn current_branch(&self, dir: &Path) -> Result<String> {
314        self.core
315            .text(
316                self.core
317                    .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
318            )
319            .await
320    }
321
322    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
323        self.core
324            .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
325            .await
326    }
327
328    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
329        let n = format!("-n{max}");
330        self.core
331            .parse(
332                self.core.command_in(
333                    dir,
334                    [
335                        "log",
336                        n.as_str(),
337                        "-z",
338                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
339                    ],
340                ),
341                parse::parse_log,
342            )
343            .await
344    }
345
346    async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
347        let n = format!("-n{max}");
348        self.core
349            .parse(
350                self.core.command_in(
351                    dir,
352                    [
353                        "log",
354                        range,
355                        n.as_str(),
356                        "-z",
357                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
358                    ],
359                ),
360                parse::parse_log,
361            )
362            .await
363    }
364
365    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
366        self.core
367            .text(self.core.command_in(dir, ["rev-parse", rev]))
368            .await
369    }
370
371    async fn init(&self, dir: &Path) -> Result<()> {
372        self.core.unit(self.core.command_in(dir, ["init"])).await
373    }
374
375    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
376        // `--` separates the pathspecs so a path can never be read as an option.
377        let mut command = self.core.command_in(dir, ["add", "--"]);
378        for path in paths {
379            command = command.arg(path);
380        }
381        self.core.unit(command).await
382    }
383
384    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
385        self.core
386            .unit(self.core.command_in(dir, ["commit", "-m", message]))
387            .await
388    }
389
390    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
391        self.core
392            .unit(self.core.command_in(dir, ["branch", name]))
393            .await
394    }
395
396    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
397        self.core
398            .unit(self.core.command_in(dir, ["checkout", reference]))
399            .await
400    }
401
402    async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
403        self.core
404            .unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
405            .await
406    }
407
408    async fn commit_paths(
409        &self,
410        dir: &Path,
411        paths: &[PathBuf],
412        message: &str,
413        amend: bool,
414    ) -> Result<()> {
415        // `--only -- <paths>` commits exactly these paths' working-tree content
416        // regardless of the index; `--` keeps a path from being read as an option.
417        let mut command = self.core.command_in(dir, ["commit"]);
418        if amend {
419            command = command.arg("--amend");
420        }
421        command = command.arg("-m").arg(message).arg("--only").arg("--");
422        for path in paths {
423            command = command.arg(path);
424        }
425        self.core.unit(command).await
426    }
427
428    async fn last_commit_message(&self, dir: &Path) -> Result<String> {
429        self.core
430            .text(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
431            .await
432    }
433
434    async fn is_unborn(&self, dir: &Path) -> Result<bool> {
435        // `rev-parse --verify -q HEAD` resolves HEAD quietly: 0 = a commit exists
436        // (not unborn), 1 = no commit yet (unborn). Anything else (e.g. 128, not a
437        // repo) is a real failure surfaced as `Error::Exit`.
438        match self
439            .core
440            .code(
441                self.core
442                    .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
443            )
444            .await?
445        {
446            0 => Ok(false),
447            1 => Ok(true),
448            other => Err(Error::Exit {
449                program: BINARY.to_string(),
450                code: other,
451                stdout: String::new(),
452                stderr: String::new(),
453            }),
454        }
455    }
456
457    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
458        // `git diff --quiet` is an exit-code answer: 0 = clean, 1 = dirty.
459        // `code` still surfaces spawn/timeout/signal failures for us.
460        match self
461            .core
462            .code(self.core.command_in(dir, ["diff", "--quiet"]))
463            .await?
464        {
465            0 => Ok(true),
466            1 => Ok(false),
467            other => Err(Error::Exit {
468                program: BINARY.to_string(),
469                code: other,
470                stdout: String::new(),
471                stderr: String::new(),
472            }),
473        }
474    }
475
476    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
477        Ok(PathBuf::from(
478            self.core
479                .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
480                .await?,
481        ))
482    }
483
484    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
485        Ok(PathBuf::from(
486            self.core
487                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
488                .await?,
489        ))
490    }
491
492    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
493        // `^{commit}` peels an annotated tag down to the commit it points at.
494        let spec = format!("{rev}^{{commit}}");
495        self.core
496            .text(
497                self.core
498                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
499            )
500            .await
501    }
502
503    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
504        // `--quiet` makes an unset origin/HEAD a silent non-zero exit (no `fatal:`
505        // on stderr); that's "no default branch", not an error — so inspect the
506        // code rather than `?`.
507        let res = self
508            .core
509            .capture(
510                self.core
511                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
512            )
513            .await?;
514        if res.code() == Some(0) {
515            // "refs/remotes/origin/main" → "main"; strip the whole ref prefix so a
516            // slashed default branch (e.g. "release/v2") survives intact.
517            let out = res.stdout().trim();
518            Ok(Some(
519                out.strip_prefix("refs/remotes/origin/")
520                    .unwrap_or(out)
521                    .to_string(),
522            ))
523        } else {
524            Ok(None)
525        }
526    }
527
528    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
529        let refname = format!("refs/heads/{name}");
530        match self
531            .core
532            .code(
533                self.core
534                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
535            )
536            .await?
537        {
538            0 => Ok(true),
539            1 => Ok(false),
540            other => Err(Error::Exit {
541                program: BINARY.to_string(),
542                code: other,
543                stdout: String::new(),
544                stderr: String::new(),
545            }),
546        }
547    }
548
549    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
550        // No credential prompt, bounded wait: a missing helper or a flaky network
551        // must not hang the call. `capture` reports a timeout as a flagged result
552        // (non-zero exit) rather than erroring, so an unreachable remote reads as
553        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
554        // spawn failure (no `git`) still surfaces as an error.
555        let cmd = self
556            .core
557            .command_in(dir, ["ls-remote", "--heads", "origin", name])
558            .env("GIT_TERMINAL_PROMPT", "0")
559            .timeout(Duration::from_secs(10));
560        let res = self.core.capture(cmd).await?;
561        Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
562    }
563
564    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
565        self.core
566            .text(self.core.command_in(dir, ["remote", "get-url", remote]))
567            .await
568    }
569
570    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
571        let out = self
572            .core
573            .text(self.core.command_in(dir, ["branch", "--merged", target]))
574            .await?;
575        // Each line is `  name` / `* name` (current) / `+ name` (checked out in
576        // another worktree); strip the marker before comparing.
577        Ok(out
578            .lines()
579            .map(|line| line.trim_start_matches(['*', '+', ' ']))
580            .any(|b| b == branch))
581    }
582
583    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
584        let flag = if force { "-D" } else { "-d" };
585        self.core
586            .unit(self.core.command_in(dir, ["branch", flag, name]))
587            .await
588    }
589
590    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
591        self.core
592            .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
593            .await
594    }
595
596    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
597        self.core
598            .try_parse(
599                self.core.command_in(dir, ["rev-list", "--count", range]),
600                |s| {
601                    s.trim().parse::<usize>().map_err(|e| Error::Parse {
602                        program: BINARY.to_string(),
603                        message: e.to_string(),
604                    })
605                },
606            )
607            .await
608    }
609
610    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
611        match self
612            .core
613            .code(self.core.command_in(dir, ["diff", "--quiet", range]))
614            .await?
615        {
616            0 => Ok(true),
617            1 => Ok(false),
618            other => Err(Error::Exit {
619                program: BINARY.to_string(),
620                code: other,
621                stdout: String::new(),
622                stderr: String::new(),
623            }),
624        }
625    }
626
627    async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
628        self.core
629            .parse(
630                self.core.command_in(dir, ["diff", "--shortstat", range]),
631                parse::parse_shortstat,
632            )
633            .await
634    }
635
636    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
637        // The target is a single positional arg: `HEAD` for the working tree, or
638        // the caller's revision/range. `-M` enables rename detection; `--no-color`
639        // / `--no-ext-diff` keep the output stable and machine-parseable.
640        let target = match spec {
641            DiffSpec::WorkingTree => "HEAD".to_string(),
642            DiffSpec::Rev(rev) => rev,
643        };
644        self.core
645            .text(self.core.command_in(
646                dir,
647                ["diff", target.as_str(), "--no-color", "--no-ext-diff", "-M"],
648            ))
649            .await
650    }
651
652    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
653        let text = self.diff_text(dir, spec).await?;
654        Ok(parse::parse_diff(&text))
655    }
656
657    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
658        match self
659            .core
660            .code(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
661            .await?
662        {
663            0 => Ok(true),
664            1 => Ok(false),
665            other => Err(Error::Exit {
666                program: BINARY.to_string(),
667                code: other,
668                stdout: String::new(),
669                stderr: String::new(),
670            }),
671        }
672    }
673
674    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
675        let git_dir = self.resolved_git_dir(dir).await?;
676        Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
677    }
678
679    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
680        Ok(self
681            .resolved_git_dir(dir)
682            .await?
683            .join("MERGE_HEAD")
684            .exists())
685    }
686
687    async fn fetch(&self, dir: &Path) -> Result<()> {
688        self.core
689            .unit(self.core.command_in(dir, ["fetch", "--quiet"]))
690            .await
691    }
692
693    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
694        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
695        let cmd = self
696            .core
697            .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
698            .env("GIT_TERMINAL_PROMPT", "0");
699        self.core.unit(cmd).await
700    }
701
702    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
703        self.core
704            .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
705            .await
706    }
707
708    async fn merge_commit(
709        &self,
710        dir: &Path,
711        branch: &str,
712        no_ff: bool,
713        message: Option<String>,
714    ) -> Result<()> {
715        let mut args: Vec<&str> = vec!["merge"];
716        if no_ff {
717            args.push("--no-ff");
718        }
719        if let Some(msg) = message.as_deref() {
720            args.push("-m");
721            args.push(msg);
722        }
723        args.push(branch);
724        self.core.unit(self.core.command_in(dir, args)).await
725    }
726
727    async fn merge_no_commit(
728        &self,
729        dir: &Path,
730        branch: &str,
731        squash: bool,
732        no_ff: bool,
733    ) -> Result<()> {
734        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
735        if squash {
736            args.push("--squash");
737        }
738        if no_ff {
739            args.push("--no-ff");
740        }
741        args.push(branch);
742        self.core.unit(self.core.command_in(dir, args)).await
743    }
744
745    async fn merge_abort(&self, dir: &Path) -> Result<()> {
746        self.core
747            .unit(self.core.command_in(dir, ["merge", "--abort"]))
748            .await
749    }
750
751    async fn merge_continue(&self, dir: &Path) -> Result<()> {
752        self.core
753            .unit(self.core.command_in(dir, ["commit", "--no-edit"]))
754            .await
755    }
756
757    async fn reset_merge(&self, dir: &Path) -> Result<()> {
758        self.core
759            .unit(self.core.command_in(dir, ["reset", "--merge"]))
760            .await
761    }
762
763    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
764        self.core
765            .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
766            .await
767    }
768
769    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
770        self.core
771            .unit(self.core.command_in(dir, ["rebase", onto]))
772            .await
773    }
774
775    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
776        self.core
777            .unit(self.core.command_in(dir, ["rebase", "--abort"]))
778            .await
779    }
780
781    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
782        self.core
783            .unit(self.core.command_in(dir, ["rebase", "--continue"]))
784            .await
785    }
786
787    async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
788        let mut command = self.core.command_in(dir, ["stash", "push"]);
789        if include_untracked {
790            command = command.arg("--include-untracked");
791        }
792        self.core.unit(command).await
793    }
794
795    async fn stash_pop(&self, dir: &Path) -> Result<()> {
796        self.core
797            .unit(self.core.command_in(dir, ["stash", "pop"]))
798            .await
799    }
800
801    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
802        self.core
803            .parse(
804                self.core
805                    .command_in(dir, ["worktree", "list", "--porcelain"]),
806                parse::parse_worktree_porcelain,
807            )
808            .await
809    }
810
811    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
812        let mut command = self.core.command_in(dir, ["worktree", "add"]);
813        if let Some(name) = spec.new_branch.as_deref() {
814            command = command.arg("-b").arg(name);
815        }
816        if spec.no_checkout {
817            command = command.arg("--no-checkout");
818        }
819        command = command.arg(&spec.path);
820        if let Some(commitish) = spec.commitish.as_deref() {
821            command = command.arg(commitish);
822        }
823        self.core.unit(command).await
824    }
825
826    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
827        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
828        if force {
829            command = command.arg("--force");
830        }
831        command = command.arg(path);
832        self.core.unit(command).await
833    }
834
835    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
836        let command = self
837            .core
838            .command_in(dir, ["worktree", "move"])
839            .arg(from)
840            .arg(to);
841        self.core.unit(command).await
842    }
843
844    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
845        self.core
846            .unit(self.core.command_in(dir, ["worktree", "prune"]))
847            .await
848    }
849}
850
851// --- Error classification ----------------------------------------------------
852//
853// git writes load-bearing diagnostics to *either* stream on failure — `CONFLICT
854// (content): …` to stdout, `Automatic merge failed …` to stderr — so these probe
855// both fields of `Error::Exit` (the `stdout` field is new in processkit 0.5).
856// Consumers call these instead of re-implementing the string-scraping themselves.
857
858/// Lower-case substrings marking a merge that stopped on conflicts.
859const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
860/// Lower-case substrings marking a commit that found nothing to record.
861const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
862/// Lower-case substrings marking a transient (retryable) network/fetch failure.
863const TRANSIENT_FETCH_MARKERS: &[&str] = &[
864    "could not resolve host",
865    "couldn't resolve host",
866    "temporary failure in name resolution",
867    "connection timed out",
868    "connection refused",
869    "operation timed out",
870    "timed out",
871    "network is unreachable",
872    "failed to connect",
873    "could not read from remote repository",
874    "the remote end hung up",
875    "early eof",
876    "rpc failed",
877];
878
879/// Whether `err` is an `Error::Exit` whose captured output contains any marker.
880fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
881    let Error::Exit { stdout, stderr, .. } = err else {
882        return false;
883    };
884    let out = stdout.to_ascii_lowercase();
885    let errt = stderr.to_ascii_lowercase();
886    markers.iter().any(|m| out.contains(m) || errt.contains(m))
887}
888
889/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict.
890pub fn is_merge_conflict(err: &Error) -> bool {
891    exit_output_matches(err, CONFLICT_MARKERS)
892}
893
894/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
895/// tree), as opposed to a real error.
896pub fn is_nothing_to_commit(err: &Error) -> bool {
897    exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
898}
899
900/// Whether a failed `fetch`/`fetch_remote_branch`/`remote_branch_exists` looks
901/// transient (DNS, timeout, dropped connection) and is worth retrying.
902pub fn is_transient_fetch_error(err: &Error) -> bool {
903    // A processkit-level timeout (a `.timeout()`-bounded run that expired) carries
904    // no captured output but is inherently transient; treat it as retryable too.
905    matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
906}
907
908impl<R: ProcessRunner> Git<R> {
909    /// Run `git <args>` over string slices — `git.run_args(&["status", "-s"])`
910    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
911    /// trait), so it can take `&[&str]`; forwards to the same path as
912    /// [`GitApi::run`].
913    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
914        self.core.text(self.core.command(args)).await
915    }
916
917    /// Like [`run_args`](Git::run_args) but never errors on a non-zero exit
918    /// (mirrors [`GitApi::run_raw`]).
919    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
920        self.core.capture(self.core.command(args)).await
921    }
922
923    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
924    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
925    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
926        let git_dir = PathBuf::from(
927            self.core
928                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
929                .await?,
930        );
931        Ok(if git_dir.is_absolute() {
932            git_dir
933        } else {
934            dir.join(git_dir)
935        })
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use processkit::{RecordingRunner, Reply, ScriptedRunner};
943
944    #[test]
945    fn binary_name_is_git() {
946        assert_eq!(BINARY, "git");
947    }
948
949    // Hermetic: the real status() command-building + porcelain parsing run
950    // against a scripted runner — no `git` binary needed, so this runs on CI.
951    #[tokio::test]
952    async fn status_parses_scripted_output() {
953        // `-z` output: NUL-delimited records, raw paths.
954        let git =
955            Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
956        let entries = git.status(Path::new(".")).await.expect("status");
957        assert_eq!(entries.len(), 2);
958        assert_eq!(entries[0].code, " M");
959        assert_eq!(entries[1].path, "b.rs");
960    }
961
962    // A non-zero exit surfaces as a structured `Error::Exit`.
963    #[tokio::test]
964    async fn nonzero_exit_is_structured_error() {
965        let git = Git::with_runner(
966            ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
967        );
968        match git.status(Path::new(".")).await.unwrap_err() {
969            Error::Exit { code, stderr, .. } => {
970                assert_eq!(code, 128);
971                assert!(stderr.contains("not a git repository"), "{stderr}");
972            }
973            other => panic!("expected Exit, got {other:?}"),
974        }
975    }
976
977    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
978    // anything else is a real failure surfaced as Error::Exit.
979    #[tokio::test]
980    async fn diff_is_empty_maps_exit_codes() {
981        let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
982        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
983
984        let dirty =
985            Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
986        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
987
988        let broken = Git::with_runner(
989            ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
990        );
991        assert!(matches!(
992            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
993            Error::Exit { code: 128, .. }
994        ));
995    }
996
997    // `add` must insert `--` before the pathspecs so a path can never be parsed
998    // as an option. No fallback rule: the run only matches if `add --` was built.
999    #[tokio::test]
1000    async fn add_inserts_pathspec_separator() {
1001        let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
1002        git.add(Path::new("."), &[PathBuf::from("f.rs")])
1003            .await
1004            .expect("add should build `add -- <paths>`");
1005    }
1006
1007    #[tokio::test]
1008    async fn worktree_list_parses_porcelain() {
1009        let git = Git::with_runner(ScriptedRunner::new().on(
1010            ["worktree", "list"],
1011            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
1012        ));
1013        let wts = git.worktree_list(Path::new(".")).await.expect("list");
1014        assert_eq!(wts.len(), 1);
1015        assert_eq!(wts[0].branch.as_deref(), Some("main"));
1016        assert_eq!(wts[0].head.as_deref(), Some("abc"));
1017    }
1018
1019    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
1020    // in that exact order; only the full argv is scripted (no fallback).
1021    #[tokio::test]
1022    async fn worktree_add_builds_branch_path_and_base() {
1023        let rec = RecordingRunner::replying(Reply::ok(""));
1024        let git = Git::with_runner(&rec);
1025        git.worktree_add(
1026            Path::new("/repo"),
1027            WorktreeAdd::create_branch("/wt", "feature", "main"),
1028        )
1029        .await
1030        .expect("worktree add");
1031        assert_eq!(
1032            rec.only_call().args_str(),
1033            ["worktree", "add", "-b", "feature", "/wt", "main"]
1034        );
1035    }
1036
1037    #[tokio::test]
1038    async fn worktree_remove_passes_force_then_path() {
1039        let rec = RecordingRunner::replying(Reply::ok(""));
1040        let git = Git::with_runner(&rec);
1041        git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
1042            .await
1043            .expect("remove");
1044        assert_eq!(
1045            rec.only_call().args_str(),
1046            ["worktree", "remove", "--force", "/wt"]
1047        );
1048    }
1049
1050    // `--no-checkout` must land between `-b <name>` and the path.
1051    #[tokio::test]
1052    async fn worktree_add_no_checkout_inserts_flag() {
1053        let rec = RecordingRunner::replying(Reply::ok(""));
1054        let git = Git::with_runner(&rec);
1055        git.worktree_add(
1056            Path::new("/repo"),
1057            WorktreeAdd::checkout("/wt", "main").no_checkout(),
1058        )
1059        .await
1060        .expect("worktree add");
1061        assert_eq!(
1062            rec.only_call().args_str(),
1063            ["worktree", "add", "--no-checkout", "/wt", "main"]
1064        );
1065    }
1066
1067    #[tokio::test]
1068    async fn checkout_detach_builds_args() {
1069        let rec = RecordingRunner::replying(Reply::ok(""));
1070        let git = Git::with_runner(&rec);
1071        git.checkout_detach(Path::new("."), "abc123")
1072            .await
1073            .expect("detach");
1074        assert_eq!(
1075            rec.only_call().args_str(),
1076            ["checkout", "--detach", "abc123"]
1077        );
1078    }
1079
1080    // Partial amend commit must build `commit --amend -m <msg> --only -- <paths>`.
1081    #[tokio::test]
1082    async fn commit_paths_builds_only_amend_args() {
1083        let rec = RecordingRunner::replying(Reply::ok(""));
1084        let git = Git::with_runner(&rec);
1085        git.commit_paths(
1086            Path::new("."),
1087            &[PathBuf::from("a.rs"), PathBuf::from("b.rs")],
1088            "msg",
1089            true,
1090        )
1091        .await
1092        .expect("commit_paths");
1093        assert_eq!(
1094            rec.only_call().args_str(),
1095            [
1096                "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
1097            ]
1098        );
1099    }
1100
1101    // is_unborn maps the rev-parse exit code: 0 → has commits (false), 1 →
1102    // unborn (true), anything else is a structured error.
1103    #[tokio::test]
1104    async fn is_unborn_maps_exit_codes() {
1105        let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
1106        assert!(!born.is_unborn(Path::new(".")).await.unwrap());
1107        let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
1108        assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
1109        let broken =
1110            Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
1111        assert!(matches!(
1112            broken.is_unborn(Path::new(".")).await.unwrap_err(),
1113            Error::Exit { code: 128, .. }
1114        ));
1115    }
1116
1117    #[tokio::test]
1118    async fn log_range_builds_range_and_format() {
1119        let rec = RecordingRunner::replying(Reply::ok(""));
1120        let git = Git::with_runner(&rec);
1121        git.log_range(Path::new("."), "main..HEAD", 5)
1122            .await
1123            .expect("log_range");
1124        assert_eq!(
1125            rec.only_call().args_str(),
1126            [
1127                "log",
1128                "main..HEAD",
1129                "-n5",
1130                "-z",
1131                "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
1132            ]
1133        );
1134    }
1135
1136    #[tokio::test]
1137    async fn stash_push_adds_include_untracked() {
1138        let rec = RecordingRunner::replying(Reply::ok(""));
1139        let git = Git::with_runner(&rec);
1140        git.stash_push(Path::new("."), true).await.expect("stash");
1141        assert_eq!(
1142            rec.only_call().args_str(),
1143            ["stash", "push", "--include-untracked"]
1144        );
1145    }
1146
1147    // `diff_text` for the working tree must build `diff HEAD` plus the stable
1148    // machine-output flags, in order.
1149    #[tokio::test]
1150    async fn diff_text_builds_working_tree_args() {
1151        let rec = RecordingRunner::replying(Reply::ok(""));
1152        let git = Git::with_runner(&rec);
1153        git.diff_text(Path::new("."), DiffSpec::WorkingTree)
1154            .await
1155            .expect("diff_text");
1156        assert_eq!(
1157            rec.only_call().args_str(),
1158            ["diff", "HEAD", "--no-color", "--no-ext-diff", "-M"]
1159        );
1160    }
1161
1162    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
1163    // canned git-format output.
1164    #[tokio::test]
1165    async fn diff_parses_scripted_output() {
1166        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
1167        let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
1168        let files = git
1169            .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
1170            .await
1171            .expect("diff");
1172        assert_eq!(files.len(), 1);
1173        assert_eq!(files[0].path, "m");
1174        assert_eq!(files[0].change, ChangeKind::Modified);
1175    }
1176
1177    #[tokio::test]
1178    async fn branch_exists_maps_exit_codes() {
1179        let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1180        assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
1181        let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1182        assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
1183    }
1184
1185    // The full ref prefix is stripped but a slashed default branch survives; an
1186    // unset origin/HEAD (non-zero exit) is `None`, not an error.
1187    #[tokio::test]
1188    async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
1189        let simple = Git::with_runner(
1190            ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
1191        );
1192        assert_eq!(
1193            simple
1194                .remote_head_branch(Path::new("."))
1195                .await
1196                .unwrap()
1197                .as_deref(),
1198            Some("main")
1199        );
1200
1201        let slashed = Git::with_runner(ScriptedRunner::new().on(
1202            ["symbolic-ref"],
1203            Reply::ok("refs/remotes/origin/release/v2\n"),
1204        ));
1205        assert_eq!(
1206            slashed
1207                .remote_head_branch(Path::new("."))
1208                .await
1209                .unwrap()
1210                .as_deref(),
1211            Some("release/v2")
1212        );
1213
1214        let unset =
1215            Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
1216        assert!(
1217            unset
1218                .remote_head_branch(Path::new("."))
1219                .await
1220                .unwrap()
1221                .is_none()
1222        );
1223    }
1224
1225    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
1226    // stdout as "absent".
1227    #[tokio::test]
1228    async fn remote_branch_exists_sets_env_and_reads_stdout() {
1229        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
1230        let git = Git::with_runner(&rec);
1231        assert!(
1232            git.remote_branch_exists(Path::new("/repo"), "main")
1233                .await
1234                .unwrap()
1235        );
1236        assert!(rec.only_call().envs.iter().any(|(k, v)| {
1237            k.to_str() == Some("GIT_TERMINAL_PROMPT")
1238                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1239        }));
1240
1241        let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
1242        assert!(
1243            !empty
1244                .remote_branch_exists(Path::new("."), "x")
1245                .await
1246                .unwrap()
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn diff_stat_parses_counts() {
1252        let git = Git::with_runner(ScriptedRunner::new().on(
1253            ["diff", "--shortstat"],
1254            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
1255        ));
1256        let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
1257        assert_eq!(
1258            (stat.files_changed, stat.insertions, stat.deletions),
1259            (2, 5, 1)
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn status_text_returns_raw_porcelain() {
1265        let git = Git::with_runner(ScriptedRunner::new().on(
1266            ["status", "--porcelain=v1"],
1267            Reply::ok(" M a.rs\n?? b.rs\n"),
1268        ));
1269        let text = git.status_text(Path::new(".")).await.expect("status_text");
1270        assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
1271    }
1272
1273    #[tokio::test]
1274    async fn run_args_forwards_str_slices() {
1275        let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
1276        assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
1277    }
1278
1279    // Conflict markers live on stdout (`CONFLICT (…)`) or stderr (`Automatic
1280    // merge failed`); either should classify. A plain error must not.
1281    #[test]
1282    fn classifies_merge_conflict() {
1283        let on_stdout = Error::Exit {
1284            program: "git".into(),
1285            code: 1,
1286            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1287            stderr: String::new(),
1288        };
1289        let on_stderr = Error::Exit {
1290            program: "git".into(),
1291            code: 1,
1292            stdout: String::new(),
1293            stderr: "Automatic merge failed; fix conflicts and then commit".into(),
1294        };
1295        let unrelated = Error::Exit {
1296            program: "git".into(),
1297            code: 128,
1298            stdout: String::new(),
1299            stderr: "fatal: not a git repository".into(),
1300        };
1301        assert!(is_merge_conflict(&on_stdout));
1302        assert!(is_merge_conflict(&on_stderr));
1303        assert!(!is_merge_conflict(&unrelated));
1304        assert!(!is_nothing_to_commit(&on_stdout));
1305    }
1306
1307    #[test]
1308    fn classifies_nothing_to_commit_and_transient_fetch() {
1309        let nothing = Error::Exit {
1310            program: "git".into(),
1311            code: 1,
1312            stdout: "nothing to commit, working tree clean".into(),
1313            stderr: String::new(),
1314        };
1315        assert!(is_nothing_to_commit(&nothing));
1316
1317        let dns = Error::Exit {
1318            program: "git".into(),
1319            code: 128,
1320            stdout: String::new(),
1321            stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
1322        };
1323        assert!(is_transient_fetch_error(&dns));
1324        assert!(!is_transient_fetch_error(&nothing));
1325
1326        // A processkit timeout (no captured output) is transient too.
1327        let timeout = Error::Timeout {
1328            program: "git".into(),
1329            timeout: Duration::from_secs(10),
1330        };
1331        assert!(is_transient_fetch_error(&timeout));
1332    }
1333
1334    #[tokio::test]
1335    async fn merge_commit_builds_no_ff_and_message() {
1336        let rec = RecordingRunner::replying(Reply::ok(""));
1337        let git = Git::with_runner(&rec);
1338        git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
1339            .await
1340            .unwrap();
1341        assert_eq!(
1342            rec.only_call().args_str(),
1343            ["merge", "--no-ff", "-m", "merge it", "feature"]
1344        );
1345    }
1346
1347    #[tokio::test]
1348    async fn delete_branch_force_uses_capital_d() {
1349        let rec = RecordingRunner::replying(Reply::ok(""));
1350        let git = Git::with_runner(&rec);
1351        git.delete_branch(Path::new("/r"), "old", true)
1352            .await
1353            .unwrap();
1354        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
1355    }
1356
1357    // `branch --merged` marks the current branch with `*` and a branch checked out
1358    // in another worktree with `+`; both must still match after marker stripping.
1359    #[tokio::test]
1360    async fn is_merged_strips_branch_markers() {
1361        let git = Git::with_runner(ScriptedRunner::new().on(
1362            ["branch", "--merged"],
1363            Reply::ok("  main\n* feature\n+ wt-branch\n"),
1364        ));
1365        for name in ["main", "feature", "wt-branch"] {
1366            assert!(
1367                git.is_merged(Path::new("."), name, "main").await.unwrap(),
1368                "{name} should be reported merged"
1369            );
1370        }
1371        assert!(
1372            !git.is_merged(Path::new("."), "absent", "main")
1373                .await
1374                .unwrap()
1375        );
1376    }
1377
1378    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
1379    // tested with a generated mock.
1380    #[cfg(feature = "mock")]
1381    #[tokio::test]
1382    async fn consumer_mocks_the_interface() {
1383        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
1384            git.current_branch(Path::new(".")).await.unwrap() == want
1385        }
1386        let mut mock = MockGitApi::new();
1387        mock.expect_current_branch()
1388            .returning(|_| Ok("main".to_string()));
1389        assert!(on_branch(&mock, "main").await);
1390    }
1391}