use crate::error::{Error, Result};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn is_git_repo(path: &Path) -> bool {
Command::new("git")
.arg("rev-parse")
.arg("--git-dir")
.current_dir(path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn validate_git_ref(git_ref: &str) -> Result<()> {
if git_ref.is_empty() {
return Err(Error::InvalidArgument(
"git ref must not be empty".to_string(),
));
}
if git_ref.starts_with("--") {
return Err(Error::InvalidArgument(format!(
"invalid git ref '{}': refs may not begin with '--'",
git_ref
)));
}
let valid = git_ref
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_'));
if !valid {
return Err(Error::InvalidArgument(format!(
"invalid git ref '{}': only alphanumeric characters and '.', '/', '-', '_' are allowed",
git_ref
)));
}
Ok(())
}
pub fn git_changed_files(repo_root: &Path, git_ref: &str) -> Result<HashSet<PathBuf>> {
validate_git_ref(git_ref)?;
let mut result = HashSet::new();
let unstaged = run_git_diff(repo_root, git_ref, false)?;
result.extend(unstaged);
let staged = run_git_diff(repo_root, git_ref, true)?;
result.extend(staged);
Ok(result)
}
fn run_git_diff(repo_root: &Path, git_ref: &str, cached: bool) -> Result<HashSet<PathBuf>> {
let mut cmd = Command::new("git");
cmd.arg("diff").arg("--name-only");
if cached {
cmd.arg("--cached");
}
cmd.arg(git_ref);
cmd.current_dir(repo_root);
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::InvalidArgument("'git' command not found — is git installed?".to_string())
} else {
Error::InvalidArgument(format!("failed to spawn git: {}", e))
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.to_lowercase().contains("not a git repository") {
return Err(Error::InvalidArgument(
"git diff failed: not a git repository".to_string(),
));
}
return Err(Error::InvalidArgument(format!(
"invalid git ref '{}': {}",
git_ref, stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let paths = stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect();
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn test_is_git_repo_true() {
if !git_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let init_ok = Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !init_ok {
return;
}
assert!(is_git_repo(tmp.path()));
}
#[test]
fn test_is_git_repo_false() {
let tmp = tempfile::tempdir().unwrap();
assert!(!is_git_repo(tmp.path()));
}
fn init_git_repo_with_commit(dir: &std::path::Path) -> bool {
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
run(&["init"])
&& run(&["config", "user.email", "test@example.com"])
&& run(&["config", "user.name", "Test"])
&& {
std::fs::write(dir.join("init.txt"), b"init").is_ok()
}
&& run(&["add", "."])
&& run(&["commit", "-m", "init"])
}
#[test]
fn test_validate_git_ref_valid() {
assert!(validate_git_ref("HEAD").is_ok());
assert!(validate_git_ref("main").is_ok());
assert!(validate_git_ref("origin/main").is_ok());
assert!(validate_git_ref("v1.2.3").is_ok());
assert!(validate_git_ref("abc1234").is_ok());
assert!(validate_git_ref("feat/my-feature").is_ok());
}
#[test]
fn test_validate_git_ref_invalid() {
assert!(validate_git_ref("").is_err());
assert!(validate_git_ref("--help").is_err());
assert!(validate_git_ref("HEAD^").is_err());
assert!(validate_git_ref("HEAD~1").is_err());
assert!(validate_git_ref("@{-1}").is_err());
assert!(validate_git_ref("refs:heads/main").is_err());
}
#[test]
fn test_git_changed_files_invalid_ref() {
if !git_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
if !init_git_repo_with_commit(tmp.path()) {
return;
}
let result = git_changed_files(tmp.path(), "nonexistent-ref-xyz-abc-999");
assert!(result.is_err());
}
#[test]
fn test_git_changed_files_head_returns_hashset() {
if !git_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
if !init_git_repo_with_commit(tmp.path()) {
return;
}
let result = git_changed_files(tmp.path(), "HEAD");
assert!(result.is_ok());
let _set: HashSet<PathBuf> = result.unwrap();
}
}