use std::path::{Path, PathBuf};
use std::time::Duration;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{Branch, Commit, DiffStat, StatusEntry, Worktree};
pub const BINARY: &str = "git";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorktreeAdd {
pub path: PathBuf,
pub new_branch: Option<String>,
pub commitish: Option<String>,
}
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()),
}
}
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()),
}
}
}
#[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 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 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 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_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
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 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 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 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 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,
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.exit_code() == 0 {
Ok(res.stdout().trim().rsplit('/').next().map(str::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,
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.exit_code() == 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,
stderr: String::new(),
}),
}
}
async fn diff_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
self.core
.parse(
self.core.command_in(dir, ["diff", "--shortstat", range]),
parse::parse_shortstat,
)
.await
}
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,
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 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);
}
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
}
}
impl<R: ProcessRunner> Git<R> {
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 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_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_shortstat_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_shortstat(Path::new("."), "main..HEAD")
.await
.unwrap();
assert_eq!(
(stat.files_changed, stat.insertions, stat.deletions),
(2, 5, 1)
);
}
#[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);
}
}