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())
}
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}",
..
}
));
}
}