use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{Branch, Commit, StatusEntry};
pub const BINARY: &str = "git";
#[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>;
}
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(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{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>`");
}
#[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);
}
}