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::{Branch, Commit, DiffStat, StatusEntry, Worktree};
32
33/// Name of the underlying CLI binary this crate drives.
34pub const BINARY: &str = "git";
35
36/// Options for [`GitApi::worktree_add`] (`git worktree add`).
37///
38/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
39/// [`WorktreeAdd::create_branch`] rather than a struct literal.
40#[derive(Debug, Clone)]
41#[non_exhaustive]
42pub struct WorktreeAdd {
43    /// Filesystem path for the new worktree.
44    pub path: PathBuf,
45    /// Create and check out this new branch (`-b <name>`); `None` checks out an
46    /// existing ref.
47    pub new_branch: Option<String>,
48    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
49    pub commitish: Option<String>,
50}
51
52impl WorktreeAdd {
53    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
54    /// `git worktree add <path> <commitish>`.
55    pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
56        Self {
57            path: path.into(),
58            new_branch: None,
59            commitish: Some(commitish.into()),
60        }
61    }
62
63    /// A worktree at `path` creating a new branch `name` based on `commitish`:
64    /// `git worktree add -b <name> <path> <commitish>`.
65    pub fn create_branch(
66        path: impl Into<PathBuf>,
67        name: impl Into<String>,
68        commitish: impl Into<String>,
69    ) -> Self {
70        Self {
71            path: path.into(),
72            new_branch: Some(name.into()),
73            commitish: Some(commitish.into()),
74        }
75    }
76}
77
78/// The Git operations this crate exposes — the interface consumers code against
79/// and mock in tests.
80#[cfg_attr(feature = "mock", mockall::automock)]
81#[async_trait::async_trait]
82pub trait GitApi: Send + Sync {
83    /// Run `git <args>` in the current directory, returning trimmed stdout
84    /// (throws on a non-zero exit). A raw escape hatch for unmodelled commands.
85    async fn run(&self, args: &[String]) -> Result<String>;
86    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
87    /// captured [`ProcessResult`].
88    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
89    /// Installed Git version (`git --version`).
90    async fn version(&self) -> Result<String>;
91    /// Working-tree status (`git status --porcelain=v1 -z`).
92    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
93    /// Current branch name (`git rev-parse --abbrev-ref HEAD`).
94    async fn current_branch(&self, dir: &Path) -> Result<String>;
95    /// Local branches, current one flagged (`git branch`).
96    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
97    /// Latest `max` commits, newest first (`git log`).
98    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
99    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
100    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
101    /// Initialise a repository (`git init`).
102    async fn init(&self, dir: &Path) -> Result<()>;
103    /// Stage `paths` (`git add -- <paths>`).
104    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
105    /// Commit staged changes (`git commit -m`).
106    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
107    /// Create a branch without switching to it (`git branch <name>`).
108    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
109    /// Switch to a branch or revision (`git checkout <reference>`).
110    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
111    /// Whether the working tree has no unstaged changes (`git diff --quiet`).
112    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
113
114    // --- Discovery / identity ------------------------------------------------
115
116    /// The repository's common git directory (`rev-parse --git-common-dir`) —
117    /// stable across linked worktrees.
118    async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
119    /// This worktree's git directory (`rev-parse --git-dir`).
120    async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
121    /// Resolve a revision to a commit hash, peeling tags
122    /// (`rev-parse --verify <rev>^{commit}`).
123    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
124    /// The remote's default branch from `symbolic-ref refs/remotes/origin/HEAD`
125    /// (short name only); `None` when `origin/HEAD` is unset.
126    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
127    /// Whether a local branch exists (`show-ref --verify --quiet refs/heads/<name>`).
128    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
129    /// Whether `origin` has `name`, without fetching (`ls-remote --heads origin
130    /// <name>`). Runs with `GIT_TERMINAL_PROMPT=0` and a 10s timeout so a missing
131    /// credential or a flaky network can't hang the call.
132    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
133    /// A remote's URL (`remote get-url <remote>`).
134    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
135
136    // --- Branches ------------------------------------------------------------
137
138    /// Whether `branch` is fully merged into `target` (`branch --merged <target>`).
139    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
140    /// Delete a local branch (`branch -d`, or `-D` when `force`).
141    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
142    /// Rename a local branch (`branch -m <old> <new>`).
143    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
144    /// Count commits in a range (`rev-list --count <range>`).
145    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
146    /// Whether a diff range is empty (`diff --quiet <range>`).
147    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
148    /// Aggregate change stats for a range (`diff --shortstat <range>`).
149    async fn diff_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
150
151    // --- In-progress state ---------------------------------------------------
152
153    /// Whether the index has no staged changes (`diff --cached --quiet`).
154    async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
155    /// Whether a rebase is in progress (a `rebase-merge`/`rebase-apply` dir exists
156    /// under the git dir).
157    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
158    /// Whether a merge is in progress (a `MERGE_HEAD` exists under the git dir).
159    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
160
161    // --- Mutations -----------------------------------------------------------
162
163    /// Fetch from the default remote (`fetch --quiet`).
164    async fn fetch(&self, dir: &Path) -> Result<()>;
165    /// Fetch a single branch from `origin` into its remote-tracking ref
166    /// (`fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>`), with
167    /// `GIT_TERMINAL_PROMPT=0`.
168    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
169    /// Stage a branch's changes without committing (`merge --squash <branch>`).
170    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
171    /// Merge a branch (`merge [--no-ff] [-m <msg>] <branch>`).
172    async fn merge_commit(
173        &self,
174        dir: &Path,
175        branch: &str,
176        no_ff: bool,
177        message: Option<String>,
178    ) -> Result<()>;
179    /// Merge without committing, for a dry run
180    /// (`merge --no-commit [--squash|--no-ff] <branch>`).
181    async fn merge_no_commit(
182        &self,
183        dir: &Path,
184        branch: &str,
185        squash: bool,
186        no_ff: bool,
187    ) -> Result<()>;
188    /// Abort an in-progress merge (`merge --abort`).
189    async fn merge_abort(&self, dir: &Path) -> Result<()>;
190    /// Finish a merge after resolving conflicts (`commit --no-edit`).
191    async fn merge_continue(&self, dir: &Path) -> Result<()>;
192    /// Clear merge state, squash-safe (`reset --merge`).
193    async fn reset_merge(&self, dir: &Path) -> Result<()>;
194    /// Hard-reset the working tree to a revision (`reset --hard <rev>`).
195    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
196    /// Rebase the current branch onto `onto` (`rebase <onto>`).
197    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
198    /// Abort an in-progress rebase (`rebase --abort`).
199    async fn rebase_abort(&self, dir: &Path) -> Result<()>;
200    /// Continue a rebase after resolving conflicts (`rebase --continue`).
201    async fn rebase_continue(&self, dir: &Path) -> Result<()>;
202
203    // --- Worktrees -----------------------------------------------------------
204
205    /// List worktrees (`worktree list --porcelain`).
206    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
207    /// Add a worktree (`worktree add [-b <branch>] <path> [<commitish>]`).
208    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
209    /// Remove a worktree (`worktree remove [--force] <path>`).
210    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
211    /// Move a worktree (`worktree move <from> <to>`).
212    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
213    /// Prune stale worktree admin entries (`worktree prune`).
214    async fn worktree_prune(&self, dir: &Path) -> Result<()>;
215}
216
217processkit::cli_client!(
218    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject
219    /// a fake process executor; `Git::new()` uses the real job-backed runner.
220    pub struct Git => BINARY
221);
222
223#[async_trait::async_trait]
224impl<R: ProcessRunner> GitApi for Git<R> {
225    async fn run(&self, args: &[String]) -> Result<String> {
226        self.core.text(self.core.command(args)).await
227    }
228
229    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
230        self.core.capture(self.core.command(args)).await
231    }
232
233    async fn version(&self) -> Result<String> {
234        self.core.text(self.core.command(["--version"])).await
235    }
236
237    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
238        self.core
239            .parse(
240                self.core
241                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
242                parse::parse_porcelain,
243            )
244            .await
245    }
246
247    async fn current_branch(&self, dir: &Path) -> Result<String> {
248        self.core
249            .text(
250                self.core
251                    .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
252            )
253            .await
254    }
255
256    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
257        self.core
258            .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
259            .await
260    }
261
262    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
263        let n = format!("-n{max}");
264        self.core
265            .parse(
266                self.core.command_in(
267                    dir,
268                    [
269                        "log",
270                        n.as_str(),
271                        "-z",
272                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
273                    ],
274                ),
275                parse::parse_log,
276            )
277            .await
278    }
279
280    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
281        self.core
282            .text(self.core.command_in(dir, ["rev-parse", rev]))
283            .await
284    }
285
286    async fn init(&self, dir: &Path) -> Result<()> {
287        self.core.unit(self.core.command_in(dir, ["init"])).await
288    }
289
290    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
291        // `--` separates the pathspecs so a path can never be read as an option.
292        let mut command = self.core.command_in(dir, ["add", "--"]);
293        for path in paths {
294            command = command.arg(path);
295        }
296        self.core.unit(command).await
297    }
298
299    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
300        self.core
301            .unit(self.core.command_in(dir, ["commit", "-m", message]))
302            .await
303    }
304
305    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
306        self.core
307            .unit(self.core.command_in(dir, ["branch", name]))
308            .await
309    }
310
311    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
312        self.core
313            .unit(self.core.command_in(dir, ["checkout", reference]))
314            .await
315    }
316
317    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
318        // `git diff --quiet` is an exit-code answer: 0 = clean, 1 = dirty.
319        // `code` still surfaces spawn/timeout/signal failures for us.
320        match self
321            .core
322            .code(self.core.command_in(dir, ["diff", "--quiet"]))
323            .await?
324        {
325            0 => Ok(true),
326            1 => Ok(false),
327            other => Err(Error::Exit {
328                program: BINARY.to_string(),
329                code: other,
330                stderr: String::new(),
331            }),
332        }
333    }
334
335    async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
336        Ok(PathBuf::from(
337            self.core
338                .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
339                .await?,
340        ))
341    }
342
343    async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
344        Ok(PathBuf::from(
345            self.core
346                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
347                .await?,
348        ))
349    }
350
351    async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
352        // `^{commit}` peels an annotated tag down to the commit it points at.
353        let spec = format!("{rev}^{{commit}}");
354        self.core
355            .text(
356                self.core
357                    .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
358            )
359            .await
360    }
361
362    async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
363        // `--quiet` makes an unset origin/HEAD a silent non-zero exit (no `fatal:`
364        // on stderr); that's "no default branch", not an error — so inspect the
365        // code rather than `?`.
366        let res = self
367            .core
368            .capture(
369                self.core
370                    .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
371            )
372            .await?;
373        if res.exit_code() == 0 {
374            // e.g. "refs/remotes/origin/main" → "main".
375            Ok(res.stdout().trim().rsplit('/').next().map(str::to_string))
376        } else {
377            Ok(None)
378        }
379    }
380
381    async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
382        let refname = format!("refs/heads/{name}");
383        match self
384            .core
385            .code(
386                self.core
387                    .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
388            )
389            .await?
390        {
391            0 => Ok(true),
392            1 => Ok(false),
393            other => Err(Error::Exit {
394                program: BINARY.to_string(),
395                code: other,
396                stderr: String::new(),
397            }),
398        }
399    }
400
401    async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
402        // No credential prompt, bounded wait: a missing helper or a flaky network
403        // must not hang the call. `capture` reports a timeout as a flagged result
404        // (non-zero exit) rather than erroring, so an unreachable remote reads as
405        // "absent" (`false`) — the best-effort answer a probe wants. A genuine
406        // spawn failure (no `git`) still surfaces as an error.
407        let cmd = self
408            .core
409            .command_in(dir, ["ls-remote", "--heads", "origin", name])
410            .env("GIT_TERMINAL_PROMPT", "0")
411            .timeout(Duration::from_secs(10));
412        let res = self.core.capture(cmd).await?;
413        Ok(res.exit_code() == 0 && !res.stdout().trim().is_empty())
414    }
415
416    async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
417        self.core
418            .text(self.core.command_in(dir, ["remote", "get-url", remote]))
419            .await
420    }
421
422    async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
423        let out = self
424            .core
425            .text(self.core.command_in(dir, ["branch", "--merged", target]))
426            .await?;
427        // Each line is `  name` / `* name` (current) / `+ name` (checked out in
428        // another worktree); strip the marker before comparing.
429        Ok(out
430            .lines()
431            .map(|line| line.trim_start_matches(['*', '+', ' ']))
432            .any(|b| b == branch))
433    }
434
435    async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
436        let flag = if force { "-D" } else { "-d" };
437        self.core
438            .unit(self.core.command_in(dir, ["branch", flag, name]))
439            .await
440    }
441
442    async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
443        self.core
444            .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
445            .await
446    }
447
448    async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
449        self.core
450            .try_parse(
451                self.core.command_in(dir, ["rev-list", "--count", range]),
452                |s| {
453                    s.trim().parse::<usize>().map_err(|e| Error::Parse {
454                        program: BINARY.to_string(),
455                        message: e.to_string(),
456                    })
457                },
458            )
459            .await
460    }
461
462    async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
463        match self
464            .core
465            .code(self.core.command_in(dir, ["diff", "--quiet", range]))
466            .await?
467        {
468            0 => Ok(true),
469            1 => Ok(false),
470            other => Err(Error::Exit {
471                program: BINARY.to_string(),
472                code: other,
473                stderr: String::new(),
474            }),
475        }
476    }
477
478    async fn diff_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
479        self.core
480            .parse(
481                self.core.command_in(dir, ["diff", "--shortstat", range]),
482                parse::parse_shortstat,
483            )
484            .await
485    }
486
487    async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
488        match self
489            .core
490            .code(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
491            .await?
492        {
493            0 => Ok(true),
494            1 => Ok(false),
495            other => Err(Error::Exit {
496                program: BINARY.to_string(),
497                code: other,
498                stderr: String::new(),
499            }),
500        }
501    }
502
503    async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
504        let git_dir = self.resolved_git_dir(dir).await?;
505        Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
506    }
507
508    async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
509        Ok(self
510            .resolved_git_dir(dir)
511            .await?
512            .join("MERGE_HEAD")
513            .exists())
514    }
515
516    async fn fetch(&self, dir: &Path) -> Result<()> {
517        self.core
518            .unit(self.core.command_in(dir, ["fetch", "--quiet"]))
519            .await
520    }
521
522    async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
523        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
524        let cmd = self
525            .core
526            .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
527            .env("GIT_TERMINAL_PROMPT", "0");
528        self.core.unit(cmd).await
529    }
530
531    async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
532        self.core
533            .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
534            .await
535    }
536
537    async fn merge_commit(
538        &self,
539        dir: &Path,
540        branch: &str,
541        no_ff: bool,
542        message: Option<String>,
543    ) -> Result<()> {
544        let mut args: Vec<&str> = vec!["merge"];
545        if no_ff {
546            args.push("--no-ff");
547        }
548        if let Some(msg) = message.as_deref() {
549            args.push("-m");
550            args.push(msg);
551        }
552        args.push(branch);
553        self.core.unit(self.core.command_in(dir, args)).await
554    }
555
556    async fn merge_no_commit(
557        &self,
558        dir: &Path,
559        branch: &str,
560        squash: bool,
561        no_ff: bool,
562    ) -> Result<()> {
563        let mut args: Vec<&str> = vec!["merge", "--no-commit"];
564        if squash {
565            args.push("--squash");
566        }
567        if no_ff {
568            args.push("--no-ff");
569        }
570        args.push(branch);
571        self.core.unit(self.core.command_in(dir, args)).await
572    }
573
574    async fn merge_abort(&self, dir: &Path) -> Result<()> {
575        self.core
576            .unit(self.core.command_in(dir, ["merge", "--abort"]))
577            .await
578    }
579
580    async fn merge_continue(&self, dir: &Path) -> Result<()> {
581        self.core
582            .unit(self.core.command_in(dir, ["commit", "--no-edit"]))
583            .await
584    }
585
586    async fn reset_merge(&self, dir: &Path) -> Result<()> {
587        self.core
588            .unit(self.core.command_in(dir, ["reset", "--merge"]))
589            .await
590    }
591
592    async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
593        self.core
594            .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
595            .await
596    }
597
598    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
599        self.core
600            .unit(self.core.command_in(dir, ["rebase", onto]))
601            .await
602    }
603
604    async fn rebase_abort(&self, dir: &Path) -> Result<()> {
605        self.core
606            .unit(self.core.command_in(dir, ["rebase", "--abort"]))
607            .await
608    }
609
610    async fn rebase_continue(&self, dir: &Path) -> Result<()> {
611        self.core
612            .unit(self.core.command_in(dir, ["rebase", "--continue"]))
613            .await
614    }
615
616    async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
617        self.core
618            .parse(
619                self.core
620                    .command_in(dir, ["worktree", "list", "--porcelain"]),
621                parse::parse_worktree_porcelain,
622            )
623            .await
624    }
625
626    async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
627        let mut command = self.core.command_in(dir, ["worktree", "add"]);
628        if let Some(name) = spec.new_branch.as_deref() {
629            command = command.arg("-b").arg(name);
630        }
631        command = command.arg(&spec.path);
632        if let Some(commitish) = spec.commitish.as_deref() {
633            command = command.arg(commitish);
634        }
635        self.core.unit(command).await
636    }
637
638    async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
639        let mut command = self.core.command_in(dir, ["worktree", "remove"]);
640        if force {
641            command = command.arg("--force");
642        }
643        command = command.arg(path);
644        self.core.unit(command).await
645    }
646
647    async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
648        let command = self
649            .core
650            .command_in(dir, ["worktree", "move"])
651            .arg(from)
652            .arg(to);
653        self.core.unit(command).await
654    }
655
656    async fn worktree_prune(&self, dir: &Path) -> Result<()> {
657        self.core
658            .unit(self.core.command_in(dir, ["worktree", "prune"]))
659            .await
660    }
661}
662
663impl<R: ProcessRunner> Git<R> {
664    /// `git_dir` resolved to an absolute path — `rev-parse --git-dir` may report
665    /// it relative to `dir` (e.g. `.git`), which the filesystem probes need joined.
666    async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
667        let git_dir = PathBuf::from(
668            self.core
669                .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
670                .await?,
671        );
672        Ok(if git_dir.is_absolute() {
673            git_dir
674        } else {
675            dir.join(git_dir)
676        })
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use processkit::{RecordingRunner, Reply, ScriptedRunner};
684
685    #[test]
686    fn binary_name_is_git() {
687        assert_eq!(BINARY, "git");
688    }
689
690    // Hermetic: the real status() command-building + porcelain parsing run
691    // against a scripted runner — no `git` binary needed, so this runs on CI.
692    #[tokio::test]
693    async fn status_parses_scripted_output() {
694        // `-z` output: NUL-delimited records, raw paths.
695        let git =
696            Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
697        let entries = git.status(Path::new(".")).await.expect("status");
698        assert_eq!(entries.len(), 2);
699        assert_eq!(entries[0].code, " M");
700        assert_eq!(entries[1].path, "b.rs");
701    }
702
703    // A non-zero exit surfaces as a structured `Error::Exit`.
704    #[tokio::test]
705    async fn nonzero_exit_is_structured_error() {
706        let git = Git::with_runner(
707            ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
708        );
709        match git.status(Path::new(".")).await.unwrap_err() {
710            Error::Exit { code, stderr, .. } => {
711                assert_eq!(code, 128);
712                assert!(stderr.contains("not a git repository"), "{stderr}");
713            }
714            other => panic!("expected Exit, got {other:?}"),
715        }
716    }
717
718    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
719    // anything else is a real failure surfaced as Error::Exit.
720    #[tokio::test]
721    async fn diff_is_empty_maps_exit_codes() {
722        let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
723        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
724
725        let dirty =
726            Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
727        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
728
729        let broken = Git::with_runner(
730            ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
731        );
732        assert!(matches!(
733            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
734            Error::Exit { code: 128, .. }
735        ));
736    }
737
738    // `add` must insert `--` before the pathspecs so a path can never be parsed
739    // as an option. No fallback rule: the run only matches if `add --` was built.
740    #[tokio::test]
741    async fn add_inserts_pathspec_separator() {
742        let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
743        git.add(Path::new("."), &[PathBuf::from("f.rs")])
744            .await
745            .expect("add should build `add -- <paths>`");
746    }
747
748    #[tokio::test]
749    async fn worktree_list_parses_porcelain() {
750        let git = Git::with_runner(ScriptedRunner::new().on(
751            ["worktree", "list"],
752            Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
753        ));
754        let wts = git.worktree_list(Path::new(".")).await.expect("list");
755        assert_eq!(wts.len(), 1);
756        assert_eq!(wts[0].branch.as_deref(), Some("main"));
757        assert_eq!(wts[0].head.as_deref(), Some("abc"));
758    }
759
760    // The new-branch worktree must build `worktree add -b <name> <path> <base>`,
761    // in that exact order; only the full argv is scripted (no fallback).
762    #[tokio::test]
763    async fn worktree_add_builds_branch_path_and_base() {
764        let rec = RecordingRunner::replying(Reply::ok(""));
765        let git = Git::with_runner(&rec);
766        git.worktree_add(
767            Path::new("/repo"),
768            WorktreeAdd::create_branch("/wt", "feature", "main"),
769        )
770        .await
771        .expect("worktree add");
772        assert_eq!(
773            rec.only_call().args_str(),
774            ["worktree", "add", "-b", "feature", "/wt", "main"]
775        );
776    }
777
778    #[tokio::test]
779    async fn worktree_remove_passes_force_then_path() {
780        let rec = RecordingRunner::replying(Reply::ok(""));
781        let git = Git::with_runner(&rec);
782        git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
783            .await
784            .expect("remove");
785        assert_eq!(
786            rec.only_call().args_str(),
787            ["worktree", "remove", "--force", "/wt"]
788        );
789    }
790
791    #[tokio::test]
792    async fn branch_exists_maps_exit_codes() {
793        let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
794        assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
795        let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
796        assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
797    }
798
799    // remote_branch_exists must pass `GIT_TERMINAL_PROMPT=0` and treat empty
800    // stdout as "absent".
801    #[tokio::test]
802    async fn remote_branch_exists_sets_env_and_reads_stdout() {
803        let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
804        let git = Git::with_runner(&rec);
805        assert!(
806            git.remote_branch_exists(Path::new("/repo"), "main")
807                .await
808                .unwrap()
809        );
810        assert!(rec.only_call().envs.iter().any(|(k, v)| {
811            k.to_str() == Some("GIT_TERMINAL_PROMPT")
812                && v.as_deref().and_then(|o| o.to_str()) == Some("0")
813        }));
814
815        let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
816        assert!(
817            !empty
818                .remote_branch_exists(Path::new("."), "x")
819                .await
820                .unwrap()
821        );
822    }
823
824    #[tokio::test]
825    async fn diff_shortstat_parses_counts() {
826        let git = Git::with_runner(ScriptedRunner::new().on(
827            ["diff", "--shortstat"],
828            Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
829        ));
830        let stat = git
831            .diff_shortstat(Path::new("."), "main..HEAD")
832            .await
833            .unwrap();
834        assert_eq!(
835            (stat.files_changed, stat.insertions, stat.deletions),
836            (2, 5, 1)
837        );
838    }
839
840    #[tokio::test]
841    async fn merge_commit_builds_no_ff_and_message() {
842        let rec = RecordingRunner::replying(Reply::ok(""));
843        let git = Git::with_runner(&rec);
844        git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
845            .await
846            .unwrap();
847        assert_eq!(
848            rec.only_call().args_str(),
849            ["merge", "--no-ff", "-m", "merge it", "feature"]
850        );
851    }
852
853    #[tokio::test]
854    async fn delete_branch_force_uses_capital_d() {
855        let rec = RecordingRunner::replying(Reply::ok(""));
856        let git = Git::with_runner(&rec);
857        git.delete_branch(Path::new("/r"), "old", true)
858            .await
859            .unwrap();
860        assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
861    }
862
863    // `branch --merged` marks the current branch with `*` and a branch checked out
864    // in another worktree with `+`; both must still match after marker stripping.
865    #[tokio::test]
866    async fn is_merged_strips_branch_markers() {
867        let git = Git::with_runner(ScriptedRunner::new().on(
868            ["branch", "--merged"],
869            Reply::ok("  main\n* feature\n+ wt-branch\n"),
870        ));
871        for name in ["main", "feature", "wt-branch"] {
872            assert!(
873                git.is_merged(Path::new("."), name, "main").await.unwrap(),
874                "{name} should be reported merged"
875            );
876        }
877        assert!(
878            !git.is_merged(Path::new("."), "absent", "main")
879                .await
880                .unwrap()
881        );
882    }
883
884    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
885    // tested with a generated mock.
886    #[cfg(feature = "mock")]
887    #[tokio::test]
888    async fn consumer_mocks_the_interface() {
889        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
890            git.current_branch(Path::new(".")).await.unwrap() == want
891        }
892        let mut mock = MockGitApi::new();
893        mock.expect_current_branch()
894            .returning(|_| Ok("main".to_string()));
895        assert!(on_branch(&mock, "main").await);
896    }
897}