use std::env;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
pub(super) fn git_program() -> String {
env::var("SHIPPER_GIT_BIN").unwrap_or_else(|_| "git".to_string())
}
pub(super) fn is_repo_root(repo_root: &Path, git_program: &str) -> bool {
Command::new(git_program)
.arg("rev-parse")
.arg("--git-dir")
.current_dir(repo_root)
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub(super) fn local_is_git_clean(repo_root: &Path, git_program: &str) -> Result<bool> {
let out = Command::new(git_program)
.arg("status")
.arg("--porcelain")
.current_dir(repo_root)
.output()
.context("failed to execute git status; is git installed?")?;
if !out.status.success() {
bail!(
"git status failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().is_empty())
}
pub(super) fn get_git_commit(repo_root: &Path, git_program: &str) -> Option<String> {
let output = Command::new(git_program)
.arg("rev-parse")
.arg("HEAD")
.current_dir(repo_root)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
pub(super) fn get_git_branch(repo_root: &Path, git_program: &str) -> Option<String> {
let output = Command::new(git_program)
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.current_dir(repo_root)
.output()
.ok()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch == "HEAD" { None } else { Some(branch) }
} else {
None
}
}
pub(super) fn get_git_tag(repo_root: &Path, git_program: &str) -> Option<String> {
let output = Command::new(git_program)
.arg("describe")
.arg("--tags")
.arg("--exact-match")
.current_dir(repo_root)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
pub(super) fn get_git_dirty_status(repo_root: &Path, git_program: &str) -> Option<bool> {
let output = Command::new(git_program)
.arg("status")
.arg("--porcelain")
.current_dir(repo_root)
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Some(!stdout.trim().is_empty())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
use tempfile::TempDir;
const NONEXISTENT_GIT: &str = "/definitely/not/a/real/git/binary/at/all";
fn init_repo() -> TempDir {
let dir = TempDir::new().expect("create tempdir for git repo");
run_git(&["init", "-q", "-b", "main"], dir.path());
run_git(&["config", "user.name", "Test User"], dir.path());
run_git(&["config", "user.email", "test@example.com"], dir.path());
std::fs::write(dir.path().join("README"), b"hello").expect("write README");
run_git(&["add", "README"], dir.path());
run_git(
&["-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"],
dir.path(),
);
dir
}
fn run_git(args: &[&str], cwd: &Path) {
let out = StdCommand::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git binary available in test environment");
assert!(
out.status.success(),
"git {args:?} failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn is_repo_root_true_for_initialized_repo() {
let dir = init_repo();
assert!(is_repo_root(dir.path(), "git"));
}
#[test]
fn is_repo_root_false_for_non_repo_dir() {
let dir = TempDir::new().expect("tempdir");
assert!(!is_repo_root(dir.path(), "git"));
}
#[test]
fn is_repo_root_false_when_binary_does_not_exist() {
let dir = TempDir::new().expect("tempdir");
assert!(!is_repo_root(dir.path(), NONEXISTENT_GIT));
}
#[test]
fn local_is_git_clean_true_on_fresh_repo() {
let dir = init_repo();
assert!(local_is_git_clean(dir.path(), "git").expect("status succeeds"));
}
#[test]
fn local_is_git_clean_false_with_untracked_file() {
let dir = init_repo();
std::fs::write(dir.path().join("dirty"), b"x").expect("write untracked file");
assert!(!local_is_git_clean(dir.path(), "git").expect("status succeeds"));
}
#[test]
fn local_is_git_clean_errors_when_binary_missing() {
let dir = TempDir::new().expect("tempdir");
let err = local_is_git_clean(dir.path(), NONEXISTENT_GIT)
.expect_err("missing binary should surface an error");
let msg = format!("{err:#}");
assert!(
msg.contains("failed to execute git status"),
"unexpected error chain: {msg}"
);
}
#[test]
fn local_is_git_clean_errors_when_git_status_nonzero() {
let dir = TempDir::new().expect("tempdir");
let err = local_is_git_clean(dir.path(), "git")
.expect_err("non-repo dir should produce an error");
let msg = format!("{err:#}");
assert!(
msg.contains("git status failed:"),
"unexpected error chain (missing legacy prefix): {msg}"
);
}
#[test]
fn get_git_commit_returns_ascii_sha_in_repo() {
let dir = init_repo();
let commit = get_git_commit(dir.path(), "git").expect("commit available after init");
assert!(
matches!(commit.len(), 40 | 64),
"expected SHA-1 or SHA-256 hex object ID, got {commit:?}"
);
assert!(
commit.chars().all(|c| c.is_ascii_hexdigit()),
"non-hex chars in commit: {commit:?}"
);
}
#[test]
fn get_git_commit_none_in_non_repo_dir() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_commit(dir.path(), "git").is_none());
}
#[test]
fn get_git_commit_none_when_binary_missing() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_commit(dir.path(), NONEXISTENT_GIT).is_none());
}
#[test]
fn get_git_branch_returns_branch_name_in_repo() {
let dir = init_repo();
let branch = get_git_branch(dir.path(), "git").expect("branch on initialized repo");
assert_eq!(branch, "main");
}
#[test]
fn get_git_branch_none_on_detached_head() {
let dir = init_repo();
let sha = get_git_commit(dir.path(), "git").expect("commit");
run_git(&["checkout", "-q", "--detach", &sha], dir.path());
assert!(
get_git_branch(dir.path(), "git").is_none(),
"detached HEAD must surface as None (literal 'HEAD' rev-parse output)"
);
}
#[test]
fn get_git_branch_none_in_non_repo_dir() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_branch(dir.path(), "git").is_none());
}
#[test]
fn get_git_branch_none_when_binary_missing() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_branch(dir.path(), NONEXISTENT_GIT).is_none());
}
#[test]
fn get_git_tag_some_when_head_is_tagged() {
let dir = init_repo();
run_git(&["-c", "tag.gpgSign=false", "tag", "v0.1.0"], dir.path());
let tag = get_git_tag(dir.path(), "git").expect("tag at HEAD");
assert_eq!(tag, "v0.1.0");
}
#[test]
fn get_git_tag_none_when_no_tag_at_head() {
let dir = init_repo();
assert!(get_git_tag(dir.path(), "git").is_none());
}
#[test]
fn get_git_tag_none_when_binary_missing() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_tag(dir.path(), NONEXISTENT_GIT).is_none());
}
#[test]
fn get_git_dirty_status_false_on_clean_repo() {
let dir = init_repo();
assert_eq!(get_git_dirty_status(dir.path(), "git"), Some(false));
}
#[test]
fn get_git_dirty_status_true_with_untracked_file() {
let dir = init_repo();
std::fs::write(dir.path().join("dirty"), b"x").expect("write file");
assert_eq!(get_git_dirty_status(dir.path(), "git"), Some(true));
}
#[test]
fn get_git_dirty_status_none_in_non_repo_dir() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_dirty_status(dir.path(), "git").is_none());
}
#[test]
fn get_git_dirty_status_none_when_binary_missing() {
let dir = TempDir::new().expect("tempdir");
assert!(get_git_dirty_status(dir.path(), NONEXISTENT_GIT).is_none());
}
}