use std::io;
use std::path::Path;
use std::process::Command;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GitError {
#[error("`git` is not installed or not on PATH")]
NotInstalled,
#[error("git {operation} failed (exit {code:?})")]
Failed {
operation: &'static str,
code: Option<i32>,
},
#[error(transparent)]
Io(io::Error),
}
pub fn clone(url: &str, dest: &Path) -> Result<(), GitError> {
let status = Command::new("git")
.arg("clone")
.arg("--quiet")
.arg(url)
.arg(dest)
.status()
.map_err(spawn_error)?;
if !status.success() {
return Err(GitError::Failed {
operation: "clone",
code: status.code(),
});
}
Ok(())
}
pub fn pull(repo_dir: &Path) -> Result<(), GitError> {
let status = Command::new("git")
.arg("pull")
.arg("--ff-only")
.arg("--quiet")
.current_dir(repo_dir)
.status()
.map_err(spawn_error)?;
if !status.success() {
return Err(GitError::Failed {
operation: "pull",
code: status.code(),
});
}
Ok(())
}
pub fn fetch(repo_dir: &Path) -> Result<(), GitError> {
let status = Command::new("git")
.arg("fetch")
.arg("--quiet")
.current_dir(repo_dir)
.status()
.map_err(spawn_error)?;
if !status.success() {
return Err(GitError::Failed {
operation: "fetch",
code: status.code(),
});
}
Ok(())
}
pub fn head_commit(repo_dir: &Path) -> Result<String, GitError> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--short")
.arg("HEAD")
.current_dir(repo_dir)
.output()
.map_err(spawn_error)?;
if !output.status.success() {
return Err(GitError::Failed {
operation: "rev-parse HEAD",
code: output.status.code(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn upstream_commit(repo_dir: &Path) -> Result<String, GitError> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--short")
.arg("@{u}")
.current_dir(repo_dir)
.output()
.map_err(spawn_error)?;
if !output.status.success() {
return Err(GitError::Failed {
operation: "rev-parse @{u}",
code: output.status.code(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn log_between(repo_dir: &Path, from: &str, to: &str) -> Result<Vec<String>, GitError> {
let output = Command::new("git")
.arg("log")
.arg(format!("{from}..{to}"))
.arg("--pretty=format:%s")
.arg("--no-decorate")
.current_dir(repo_dir)
.output()
.map_err(spawn_error)?;
if !output.status.success() {
return Err(GitError::Failed {
operation: "log",
code: output.status.code(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(str::to_string).collect())
}
pub fn diffstat(repo_dir: &Path, from: &str, to: &str) -> Result<(usize, usize, usize), GitError> {
let output = Command::new("git")
.arg("diff")
.arg("--numstat")
.arg(format!("{from}..{to}"))
.current_dir(repo_dir)
.output()
.map_err(spawn_error)?;
if !output.status.success() {
return Err(GitError::Failed {
operation: "diff --numstat",
code: output.status.code(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut files = 0usize;
let mut insertions = 0usize;
let mut deletions = 0usize;
for line in stdout.lines() {
let mut parts = line.split('\t');
let adds = parts.next().unwrap_or("");
let dels = parts.next().unwrap_or("");
if let Ok(n) = adds.parse::<usize>() {
insertions += n;
}
if let Ok(n) = dels.parse::<usize>() {
deletions += n;
}
files += 1;
}
Ok((files, insertions, deletions))
}
fn spawn_error(e: io::Error) -> GitError {
if e.kind() == io::ErrorKind::NotFound {
GitError::NotInstalled
} else {
GitError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let run = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(p)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed");
};
run(&["init", "--quiet", "--initial-branch=main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "test"]);
run(&["config", "commit.gpgsign", "false"]);
std::fs::write(p.join("README"), "hello").unwrap();
run(&["add", "."]);
run(&["commit", "--quiet", "-m", "initial"]);
dir
}
#[test]
fn head_commit_returns_short_hash() {
let repo = init_repo();
let commit = head_commit(repo.path()).unwrap();
assert!(
commit.len() >= 7 && commit.len() <= 40,
"expected short hash, got {commit:?}"
);
assert!(
commit.chars().all(|c| c.is_ascii_hexdigit()),
"expected hex, got {commit:?}"
);
}
#[test]
fn head_commit_fails_outside_a_repo() {
let dir = tempfile::tempdir().unwrap();
let err = head_commit(dir.path()).unwrap_err();
assert!(matches!(
err,
GitError::Failed {
operation: "rev-parse HEAD",
..
}
));
}
#[test]
fn upstream_commit_fails_without_upstream() {
let repo = init_repo();
let err = upstream_commit(repo.path()).unwrap_err();
assert!(matches!(
err,
GitError::Failed {
operation: "rev-parse @{u}",
..
}
));
}
fn git_in(p: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(p)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed");
}
fn commit_file(p: &Path, name: &str, contents: &str, message: &str) -> String {
std::fs::write(p.join(name), contents).unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "--quiet", "-m", message]);
head_commit(p).unwrap()
}
#[test]
fn log_between_is_empty_when_from_equals_to() {
let repo = init_repo();
let head = head_commit(repo.path()).unwrap();
let commits = log_between(repo.path(), &head, &head).unwrap();
assert!(commits.is_empty());
}
#[test]
fn log_between_returns_subjects_in_reverse_chronological_order() {
let repo = init_repo();
let initial = head_commit(repo.path()).unwrap();
let _ = commit_file(repo.path(), "a.txt", "a\n", "second");
let third = commit_file(repo.path(), "b.txt", "b\n", "third");
let subjects = log_between(repo.path(), &initial, &third).unwrap();
assert_eq!(subjects, vec!["third".to_string(), "second".to_string()]);
}
#[test]
fn diffstat_is_all_zeros_when_from_equals_to() {
let repo = init_repo();
let head = head_commit(repo.path()).unwrap();
let (files, ins, dels) = diffstat(repo.path(), &head, &head).unwrap();
assert_eq!((files, ins, dels), (0, 0, 0));
}
#[test]
fn diffstat_counts_added_files_and_lines() {
let repo = init_repo();
let initial = head_commit(repo.path()).unwrap();
let head = commit_file(repo.path(), "new.txt", "line1\nline2\nline3\n", "add new");
let (files, ins, dels) = diffstat(repo.path(), &initial, &head).unwrap();
assert_eq!(files, 1);
assert_eq!(ins, 3);
assert_eq!(dels, 0);
}
}