use std::path::{Path, PathBuf};
use std::time::Duration;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{
Branch, ChangeKind, Commit, DiffLine, DiffStat, FileDiff, Hunk, StatusEntry, Worktree,
};
pub const BINARY: &str = "git";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DiffSpec {
WorkingTree,
Rev(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorktreeAdd {
pub path: PathBuf,
pub new_branch: Option<String>,
pub commitish: Option<String>,
pub no_checkout: bool,
}
impl WorktreeAdd {
pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
Self {
path: path.into(),
new_branch: None,
commitish: Some(commitish.into()),
no_checkout: false,
}
}
pub fn create_branch(
path: impl Into<PathBuf>,
name: impl Into<String>,
commitish: impl Into<String>,
) -> Self {
Self {
path: path.into(),
new_branch: Some(name.into()),
commitish: Some(commitish.into()),
no_checkout: false,
}
}
pub fn no_checkout(mut self) -> Self {
self.no_checkout = true;
self
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GitApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
async fn status_text(&self, dir: &Path) -> Result<String>;
async fn current_branch(&self, dir: &Path) -> Result<String>;
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
async fn init(&self, dir: &Path) -> Result<()>;
async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
async fn commit_paths(
&self,
dir: &Path,
paths: &[PathBuf],
message: &str,
amend: bool,
) -> Result<()>;
async fn last_commit_message(&self, dir: &Path) -> Result<String>;
async fn is_unborn(&self, dir: &Path) -> Result<bool>;
async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
async fn fetch(&self, dir: &Path) -> Result<()>;
async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
async fn merge_commit(
&self,
dir: &Path,
branch: &str,
no_ff: bool,
message: Option<String>,
) -> Result<()>;
async fn merge_no_commit(
&self,
dir: &Path,
branch: &str,
squash: bool,
no_ff: bool,
) -> Result<()>;
async fn merge_abort(&self, dir: &Path) -> Result<()>;
async fn merge_continue(&self, dir: &Path) -> Result<()>;
async fn reset_merge(&self, dir: &Path) -> Result<()>;
async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
async fn rebase_abort(&self, dir: &Path) -> Result<()>;
async fn rebase_continue(&self, dir: &Path) -> Result<()>;
async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
async fn stash_pop(&self, dir: &Path) -> Result<()>;
async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
async fn worktree_prune(&self, dir: &Path) -> Result<()>;
}
processkit::cli_client!(
pub struct Git => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GitApi for Git<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.text(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.capture(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.text(self.core.command(["--version"])).await
}
async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
self.core
.parse(
self.core
.command_in(dir, ["status", "--porcelain=v1", "-z"]),
parse::parse_porcelain,
)
.await
}
async fn status_text(&self, dir: &Path) -> Result<String> {
self.core
.text(self.core.command_in(dir, ["status", "--porcelain=v1"]))
.await
}
async fn current_branch(&self, dir: &Path) -> Result<String> {
self.core
.text(
self.core
.command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
)
.await
}
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
self.core
.parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
.await
}
async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
n.as_str(),
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
],
),
parse::parse_log,
)
.await
}
async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
range,
n.as_str(),
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
],
),
parse::parse_log,
)
.await
}
async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
self.core
.text(self.core.command_in(dir, ["rev-parse", rev]))
.await
}
async fn init(&self, dir: &Path) -> Result<()> {
self.core.unit(self.core.command_in(dir, ["init"])).await
}
async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
let mut command = self.core.command_in(dir, ["add", "--"]);
for path in paths {
command = command.arg(path);
}
self.core.unit(command).await
}
async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["commit", "-m", message]))
.await
}
async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["branch", name]))
.await
}
async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["checkout", reference]))
.await
}
async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
.await
}
async fn commit_paths(
&self,
dir: &Path,
paths: &[PathBuf],
message: &str,
amend: bool,
) -> Result<()> {
let mut command = self.core.command_in(dir, ["commit"]);
if amend {
command = command.arg("--amend");
}
command = command.arg("-m").arg(message).arg("--only").arg("--");
for path in paths {
command = command.arg(path);
}
self.core.unit(command).await
}
async fn last_commit_message(&self, dir: &Path) -> Result<String> {
self.core
.text(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
.await
}
async fn is_unborn(&self, dir: &Path) -> Result<bool> {
match self
.core
.code(
self.core
.command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
)
.await?
{
0 => Ok(false),
1 => Ok(true),
other => Err(Error::Exit {
program: BINARY.to_string(),
code: other,
stdout: String::new(),
stderr: String::new(),
}),
}
}
async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
match self
.core
.code(self.core.command_in(dir, ["diff", "--quiet"]))
.await?
{
0 => Ok(true),
1 => Ok(false),
other => Err(Error::Exit {
program: BINARY.to_string(),
code: other,
stdout: String::new(),
stderr: String::new(),
}),
}
}
async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core
.text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
.await?,
))
}
async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core
.text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
.await?,
))
}
async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
let spec = format!("{rev}^{{commit}}");
self.core
.text(
self.core
.command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
)
.await
}
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
let res = self
.core
.capture(
self.core
.command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
)
.await?;
if res.code() == Some(0) {
let out = res.stdout().trim();
Ok(Some(
out.strip_prefix("refs/remotes/origin/")
.unwrap_or(out)
.to_string(),
))
} else {
Ok(None)
}
}
async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
let refname = format!("refs/heads/{name}");
match self
.core
.code(
self.core
.command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
)
.await?
{
0 => Ok(true),
1 => Ok(false),
other => Err(Error::Exit {
program: BINARY.to_string(),
code: other,
stdout: String::new(),
stderr: String::new(),
}),
}
}
async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
let cmd = self
.core
.command_in(dir, ["ls-remote", "--heads", "origin", name])
.env("GIT_TERMINAL_PROMPT", "0")
.timeout(Duration::from_secs(10));
let res = self.core.capture(cmd).await?;
Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
}
async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
self.core
.text(self.core.command_in(dir, ["remote", "get-url", remote]))
.await
}
async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
let out = self
.core
.text(self.core.command_in(dir, ["branch", "--merged", target]))
.await?;
Ok(out
.lines()
.map(|line| line.trim_start_matches(['*', '+', ' ']))
.any(|b| b == branch))
}
async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
let flag = if force { "-D" } else { "-d" };
self.core
.unit(self.core.command_in(dir, ["branch", flag, name]))
.await
}
async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["branch", "-m", old, new]))
.await
}
async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
self.core
.try_parse(
self.core.command_in(dir, ["rev-list", "--count", range]),
|s| {
s.trim().parse::<usize>().map_err(|e| Error::Parse {
program: BINARY.to_string(),
message: e.to_string(),
})
},
)
.await
}
async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
match self
.core
.code(self.core.command_in(dir, ["diff", "--quiet", range]))
.await?
{
0 => Ok(true),
1 => Ok(false),
other => Err(Error::Exit {
program: BINARY.to_string(),
code: other,
stdout: String::new(),
stderr: String::new(),
}),
}
}
async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
self.core
.parse(
self.core.command_in(dir, ["diff", "--shortstat", range]),
parse::parse_shortstat,
)
.await
}
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
let target = match spec {
DiffSpec::WorkingTree => "HEAD".to_string(),
DiffSpec::Rev(rev) => rev,
};
self.core
.text(self.core.command_in(
dir,
["diff", target.as_str(), "--no-color", "--no-ext-diff", "-M"],
))
.await
}
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
let text = self.diff_text(dir, spec).await?;
Ok(parse::parse_diff(&text))
}
async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
match self
.core
.code(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
.await?
{
0 => Ok(true),
1 => Ok(false),
other => Err(Error::Exit {
program: BINARY.to_string(),
code: other,
stdout: String::new(),
stderr: String::new(),
}),
}
}
async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
let git_dir = self.resolved_git_dir(dir).await?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}
async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("MERGE_HEAD")
.exists())
}
async fn fetch(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["fetch", "--quiet"]))
.await
}
async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
let cmd = self
.core
.command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
.env("GIT_TERMINAL_PROMPT", "0");
self.core.unit(cmd).await
}
async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["merge", "--squash", branch]))
.await
}
async fn merge_commit(
&self,
dir: &Path,
branch: &str,
no_ff: bool,
message: Option<String>,
) -> Result<()> {
let mut args: Vec<&str> = vec!["merge"];
if no_ff {
args.push("--no-ff");
}
if let Some(msg) = message.as_deref() {
args.push("-m");
args.push(msg);
}
args.push(branch);
self.core.unit(self.core.command_in(dir, args)).await
}
async fn merge_no_commit(
&self,
dir: &Path,
branch: &str,
squash: bool,
no_ff: bool,
) -> Result<()> {
let mut args: Vec<&str> = vec!["merge", "--no-commit"];
if squash {
args.push("--squash");
}
if no_ff {
args.push("--no-ff");
}
args.push(branch);
self.core.unit(self.core.command_in(dir, args)).await
}
async fn merge_abort(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["merge", "--abort"]))
.await
}
async fn merge_continue(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["commit", "--no-edit"]))
.await
}
async fn reset_merge(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["reset", "--merge"]))
.await
}
async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["reset", "--hard", rev]))
.await
}
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["rebase", onto]))
.await
}
async fn rebase_abort(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["rebase", "--abort"]))
.await
}
async fn rebase_continue(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["rebase", "--continue"]))
.await
}
async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
let mut command = self.core.command_in(dir, ["stash", "push"]);
if include_untracked {
command = command.arg("--include-untracked");
}
self.core.unit(command).await
}
async fn stash_pop(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["stash", "pop"]))
.await
}
async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
self.core
.parse(
self.core
.command_in(dir, ["worktree", "list", "--porcelain"]),
parse::parse_worktree_porcelain,
)
.await
}
async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
let mut command = self.core.command_in(dir, ["worktree", "add"]);
if let Some(name) = spec.new_branch.as_deref() {
command = command.arg("-b").arg(name);
}
if spec.no_checkout {
command = command.arg("--no-checkout");
}
command = command.arg(&spec.path);
if let Some(commitish) = spec.commitish.as_deref() {
command = command.arg(commitish);
}
self.core.unit(command).await
}
async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
let mut command = self.core.command_in(dir, ["worktree", "remove"]);
if force {
command = command.arg("--force");
}
command = command.arg(path);
self.core.unit(command).await
}
async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
let command = self
.core
.command_in(dir, ["worktree", "move"])
.arg(from)
.arg(to);
self.core.unit(command).await
}
async fn worktree_prune(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["worktree", "prune"]))
.await
}
}
const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
const TRANSIENT_FETCH_MARKERS: &[&str] = &[
"could not resolve host",
"couldn't resolve host",
"temporary failure in name resolution",
"connection timed out",
"connection refused",
"operation timed out",
"timed out",
"network is unreachable",
"failed to connect",
"could not read from remote repository",
"the remote end hung up",
"early eof",
"rpc failed",
];
fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
let Error::Exit { stdout, stderr, .. } = err else {
return false;
};
let out = stdout.to_ascii_lowercase();
let errt = stderr.to_ascii_lowercase();
markers.iter().any(|m| out.contains(m) || errt.contains(m))
}
pub fn is_merge_conflict(err: &Error) -> bool {
exit_output_matches(err, CONFLICT_MARKERS)
}
pub fn is_nothing_to_commit(err: &Error) -> bool {
exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
}
pub fn is_transient_fetch_error(err: &Error) -> bool {
matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
}
impl<R: ProcessRunner> Git<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.text(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.capture(self.core.command(args)).await
}
async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
let git_dir = PathBuf::from(
self.core
.text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
.await?,
);
Ok(if git_dir.is_absolute() {
git_dir
} else {
dir.join(git_dir)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_git() {
assert_eq!(BINARY, "git");
}
#[tokio::test]
async fn status_parses_scripted_output() {
let git =
Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
let entries = git.status(Path::new(".")).await.expect("status");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].code, " M");
assert_eq!(entries[1].path, "b.rs");
}
#[tokio::test]
async fn nonzero_exit_is_structured_error() {
let git = Git::with_runner(
ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
);
match git.status(Path::new(".")).await.unwrap_err() {
Error::Exit { code, stderr, .. } => {
assert_eq!(code, 128);
assert!(stderr.contains("not a git repository"), "{stderr}");
}
other => panic!("expected Exit, got {other:?}"),
}
}
#[tokio::test]
async fn diff_is_empty_maps_exit_codes() {
let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
let dirty =
Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
let broken = Git::with_runner(
ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
);
assert!(matches!(
broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
Error::Exit { code: 128, .. }
));
}
#[tokio::test]
async fn add_inserts_pathspec_separator() {
let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
git.add(Path::new("."), &[PathBuf::from("f.rs")])
.await
.expect("add should build `add -- <paths>`");
}
#[tokio::test]
async fn worktree_list_parses_porcelain() {
let git = Git::with_runner(ScriptedRunner::new().on(
["worktree", "list"],
Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
));
let wts = git.worktree_list(Path::new(".")).await.expect("list");
assert_eq!(wts.len(), 1);
assert_eq!(wts[0].branch.as_deref(), Some("main"));
assert_eq!(wts[0].head.as_deref(), Some("abc"));
}
#[tokio::test]
async fn worktree_add_builds_branch_path_and_base() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_add(
Path::new("/repo"),
WorktreeAdd::create_branch("/wt", "feature", "main"),
)
.await
.expect("worktree add");
assert_eq!(
rec.only_call().args_str(),
["worktree", "add", "-b", "feature", "/wt", "main"]
);
}
#[tokio::test]
async fn worktree_remove_passes_force_then_path() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
.await
.expect("remove");
assert_eq!(
rec.only_call().args_str(),
["worktree", "remove", "--force", "/wt"]
);
}
#[tokio::test]
async fn worktree_add_no_checkout_inserts_flag() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_add(
Path::new("/repo"),
WorktreeAdd::checkout("/wt", "main").no_checkout(),
)
.await
.expect("worktree add");
assert_eq!(
rec.only_call().args_str(),
["worktree", "add", "--no-checkout", "/wt", "main"]
);
}
#[tokio::test]
async fn checkout_detach_builds_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.checkout_detach(Path::new("."), "abc123")
.await
.expect("detach");
assert_eq!(
rec.only_call().args_str(),
["checkout", "--detach", "abc123"]
);
}
#[tokio::test]
async fn commit_paths_builds_only_amend_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.commit_paths(
Path::new("."),
&[PathBuf::from("a.rs"), PathBuf::from("b.rs")],
"msg",
true,
)
.await
.expect("commit_paths");
assert_eq!(
rec.only_call().args_str(),
[
"commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
]
);
}
#[tokio::test]
async fn is_unborn_maps_exit_codes() {
let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
assert!(!born.is_unborn(Path::new(".")).await.unwrap());
let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
let broken =
Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
assert!(matches!(
broken.is_unborn(Path::new(".")).await.unwrap_err(),
Error::Exit { code: 128, .. }
));
}
#[tokio::test]
async fn log_range_builds_range_and_format() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.log_range(Path::new("."), "main..HEAD", 5)
.await
.expect("log_range");
assert_eq!(
rec.only_call().args_str(),
[
"log",
"main..HEAD",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
]
);
}
#[tokio::test]
async fn stash_push_adds_include_untracked() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.stash_push(Path::new("."), true).await.expect("stash");
assert_eq!(
rec.only_call().args_str(),
["stash", "push", "--include-untracked"]
);
}
#[tokio::test]
async fn diff_text_builds_working_tree_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.diff_text(Path::new("."), DiffSpec::WorkingTree)
.await
.expect("diff_text");
assert_eq!(
rec.only_call().args_str(),
["diff", "HEAD", "--no-color", "--no-ext-diff", "-M"]
);
}
#[tokio::test]
async fn diff_parses_scripted_output() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
let files = git
.diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
.await
.expect("diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "m");
assert_eq!(files[0].change, ChangeKind::Modified);
}
#[tokio::test]
async fn branch_exists_maps_exit_codes() {
let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
}
#[tokio::test]
async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
let simple = Git::with_runner(
ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
);
assert_eq!(
simple
.remote_head_branch(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("main")
);
let slashed = Git::with_runner(ScriptedRunner::new().on(
["symbolic-ref"],
Reply::ok("refs/remotes/origin/release/v2\n"),
));
assert_eq!(
slashed
.remote_head_branch(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("release/v2")
);
let unset =
Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
assert!(
unset
.remote_head_branch(Path::new("."))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn remote_branch_exists_sets_env_and_reads_stdout() {
let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
let git = Git::with_runner(&rec);
assert!(
git.remote_branch_exists(Path::new("/repo"), "main")
.await
.unwrap()
);
assert!(rec.only_call().envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_TERMINAL_PROMPT")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
assert!(
!empty
.remote_branch_exists(Path::new("."), "x")
.await
.unwrap()
);
}
#[tokio::test]
async fn diff_stat_parses_counts() {
let git = Git::with_runner(ScriptedRunner::new().on(
["diff", "--shortstat"],
Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
));
let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
assert_eq!(
(stat.files_changed, stat.insertions, stat.deletions),
(2, 5, 1)
);
}
#[tokio::test]
async fn status_text_returns_raw_porcelain() {
let git = Git::with_runner(ScriptedRunner::new().on(
["status", "--porcelain=v1"],
Reply::ok(" M a.rs\n?? b.rs\n"),
));
let text = git.status_text(Path::new(".")).await.expect("status_text");
assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
}
#[test]
fn classifies_merge_conflict() {
let on_stdout = Error::Exit {
program: "git".into(),
code: 1,
stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
stderr: String::new(),
};
let on_stderr = Error::Exit {
program: "git".into(),
code: 1,
stdout: String::new(),
stderr: "Automatic merge failed; fix conflicts and then commit".into(),
};
let unrelated = Error::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "fatal: not a git repository".into(),
};
assert!(is_merge_conflict(&on_stdout));
assert!(is_merge_conflict(&on_stderr));
assert!(!is_merge_conflict(&unrelated));
assert!(!is_nothing_to_commit(&on_stdout));
}
#[test]
fn classifies_nothing_to_commit_and_transient_fetch() {
let nothing = Error::Exit {
program: "git".into(),
code: 1,
stdout: "nothing to commit, working tree clean".into(),
stderr: String::new(),
};
assert!(is_nothing_to_commit(¬hing));
let dns = Error::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
};
assert!(is_transient_fetch_error(&dns));
assert!(!is_transient_fetch_error(¬hing));
let timeout = Error::Timeout {
program: "git".into(),
timeout: Duration::from_secs(10),
};
assert!(is_transient_fetch_error(&timeout));
}
#[tokio::test]
async fn merge_commit_builds_no_ff_and_message() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["merge", "--no-ff", "-m", "merge it", "feature"]
);
}
#[tokio::test]
async fn delete_branch_force_uses_capital_d() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.delete_branch(Path::new("/r"), "old", true)
.await
.unwrap();
assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
}
#[tokio::test]
async fn is_merged_strips_branch_markers() {
let git = Git::with_runner(ScriptedRunner::new().on(
["branch", "--merged"],
Reply::ok(" main\n* feature\n+ wt-branch\n"),
));
for name in ["main", "feature", "wt-branch"] {
assert!(
git.is_merged(Path::new("."), name, "main").await.unwrap(),
"{name} should be reported merged"
);
}
assert!(
!git.is_merged(Path::new("."), "absent", "main")
.await
.unwrap()
);
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
git.current_branch(Path::new(".")).await.unwrap() == want
}
let mut mock = MockGitApi::new();
mock.expect_current_branch()
.returning(|_| Ok("main".to_string()));
assert!(on_branch(&mock, "main").await);
}
}