use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub enum GitError {
NotInstalled(String),
CommandFailed {
command: &'static str,
stderr: String,
},
Parse(String),
}
impl std::fmt::Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GitError::NotInstalled(msg) => write!(f, "git not available: {msg}"),
GitError::CommandFailed { command, stderr } => {
write!(f, "`git {command}` failed: {}", stderr.trim())
}
GitError::Parse(msg) => write!(f, "git output parse error: {msg}"),
}
}
}
impl std::error::Error for GitError {}
pub fn rev_parse_head(workspace_root: &Path) -> Result<String, GitError> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(workspace_root)
.output()
.map_err(|e| GitError::NotInstalled(format!("spawn `git rev-parse HEAD`: {e}")))?;
if !out.status.success() {
return Err(GitError::CommandFailed {
command: "rev-parse HEAD",
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !looks_like_sha(&sha) {
return Err(GitError::Parse(format!(
"expected 40- or 64-char hex SHA, got `{sha}`"
)));
}
Ok(sha)
}
pub fn commit_present_on_remote(workspace_root: &Path, commit_sha: &str) -> Result<bool, GitError> {
if !looks_like_sha(commit_sha) {
return Err(GitError::Parse(format!(
"commit_sha `{commit_sha}` is not a hex SHA"
)));
}
let out = Command::new("git")
.args(["branch", "-r", "--contains", commit_sha])
.current_dir(workspace_root)
.output()
.map_err(|e| GitError::NotInstalled(format!("spawn `git branch -r --contains`: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("malformed object name")
|| stderr.contains("unknown revision")
|| stderr.contains("no such commit")
|| stderr.contains("bad object")
{
return Ok(false);
}
return Err(GitError::CommandFailed {
command: "branch -r --contains",
stderr: stderr.into_owned(),
});
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(stdout
.lines()
.map(|l| l.trim())
.any(|l| l.starts_with("origin/")))
}
fn looks_like_sha(s: &str) -> bool {
(s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn run_git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn init_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
run_git(tmp.path(), &["config", "user.email", "t@x"]);
run_git(tmp.path(), &["config", "user.name", "t"]);
run_git(tmp.path(), &["config", "commit.gpgsign", "false"]);
fs::write(tmp.path().join("README"), b"x").unwrap();
run_git(tmp.path(), &["add", "."]);
run_git(tmp.path(), &["commit", "-q", "-m", "init"]);
tmp
}
#[test]
fn looks_like_sha_accepts_40_and_64_chars() {
assert!(looks_like_sha(&"a".repeat(40)));
assert!(looks_like_sha(&"f".repeat(40)));
assert!(looks_like_sha(&"0".repeat(64)));
assert!(!looks_like_sha("abc"));
assert!(!looks_like_sha(&"g".repeat(40))); assert!(!looks_like_sha(&"a".repeat(41))); }
#[test]
fn rev_parse_head_returns_40_char_sha_for_fresh_repo() {
let repo = init_repo();
let sha = rev_parse_head(repo.path()).expect("rev-parse");
assert!(looks_like_sha(&sha), "got: {sha}");
assert_eq!(sha.len(), 40);
}
#[test]
fn commit_present_on_remote_false_for_local_only_repo() {
let repo = init_repo();
let sha = rev_parse_head(repo.path()).unwrap();
let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
assert!(!present, "local-only repo must report not-pushed");
}
#[test]
fn commit_present_on_remote_true_after_simulated_push() {
let repo = init_repo();
let upstream = TempDir::new().unwrap();
run_git(upstream.path(), &["init", "--bare", "-q"]);
run_git(
repo.path(),
&["remote", "add", "origin", upstream.path().to_str().unwrap()],
);
run_git(repo.path(), &["push", "-q", "origin", "main"]);
let sha = rev_parse_head(repo.path()).unwrap();
let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
assert!(
present,
"after `git push origin main`, commit must be on origin/*"
);
}
#[test]
fn commit_present_on_remote_rejects_non_sha_input() {
let repo = init_repo();
let err = commit_present_on_remote(repo.path(), "not-a-sha").unwrap_err();
assert!(matches!(err, GitError::Parse(_)));
}
#[test]
fn commit_present_on_remote_false_for_unknown_sha() {
let repo = init_repo();
let bogus = "0".repeat(40);
let present = commit_present_on_remote(repo.path(), &bogus).expect("branch -r");
assert!(!present, "unknown SHA must report not-on-remote");
}
}