use anyhow::{bail, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn get_git_tree_sha(repo_path: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD^{tree}"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"Failed to get git tree SHA: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
pub fn get_head_commit(repo_path: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"Failed to get HEAD commit: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
pub fn get_current_branch(repo_path: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"Failed to get current branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileStatus {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone)]
pub struct FileChange {
pub status: FileStatus,
pub path: PathBuf,
}
pub fn git_diff_tree(old_tree: &str, new_tree: &str, repo_path: &Path) -> Result<Vec<FileChange>> {
let output = Command::new("git")
.args([
"diff-tree",
"-r", "--no-commit-id", "--name-status", "--diff-filter=AMD", "-z", old_tree,
new_tree,
])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"git diff-tree failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
parse_diff_tree_output_z(&String::from_utf8(output.stdout)?)
}
fn parse_diff_tree_output_z(output: &str) -> Result<Vec<FileChange>> {
let mut changes = Vec::new();
let mut fields = output.split('\0');
while let Some(status_field) = fields.next() {
let status_field = status_field.trim_end_matches('\n');
if status_field.is_empty() {
continue;
}
let status = match status_field {
"A" => FileStatus::Added,
"M" => FileStatus::Modified,
"D" => FileStatus::Deleted,
other => {
tracing::debug!("Skipping diff-tree status {:?}", other);
fields.next();
continue;
}
};
let Some(path) = fields.next() else {
bail!("git diff-tree -z output ended mid-record (status without path)");
};
if path.is_empty() {
bail!("git diff-tree -z produced an empty path for status {status_field}");
}
changes.push(FileChange {
status,
path: PathBuf::from(path),
});
}
Ok(changes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_get_git_tree_sha() {
let repo_path = Path::new(".");
let result = get_git_tree_sha(repo_path);
assert!(result.is_ok(), "Should get tree SHA from current repo");
let tree_sha = result.unwrap();
assert_eq!(tree_sha.len(), 40, "Git SHA-1 should be 40 characters");
assert!(
tree_sha.chars().all(|c| c.is_ascii_hexdigit()),
"Tree SHA should be hexadecimal"
);
}
#[test]
fn test_get_current_branch() {
let repo_path = Path::new(".");
let result = get_current_branch(repo_path);
assert!(result.is_ok(), "Should get branch name from current repo");
let branch = result.unwrap();
assert!(!branch.is_empty(), "Branch name should not be empty");
}
#[test]
fn test_parse_diff_tree_output_z() {
let output = "A\0src/new_file.rs\0M\0src/existing.rs\0D\0src/old.rs\0";
let result = parse_diff_tree_output_z(output);
assert!(result.is_ok(), "Should parse -z diff-tree output");
let changes = result.unwrap();
assert_eq!(changes.len(), 3, "Should parse 3 file changes");
assert_eq!(changes[0].status, FileStatus::Added);
assert_eq!(changes[0].path, PathBuf::from("src/new_file.rs"));
assert_eq!(changes[1].status, FileStatus::Modified);
assert_eq!(changes[1].path, PathBuf::from("src/existing.rs"));
assert_eq!(changes[2].status, FileStatus::Deleted);
assert_eq!(changes[2].path, PathBuf::from("src/old.rs"));
}
#[test]
fn test_parse_diff_tree_z_spaces_and_non_ascii() {
let output = "M\0docs/user guide.md\0D\0notes/übersicht.md\0";
let changes = parse_diff_tree_output_z(output).unwrap();
assert_eq!(changes.len(), 2);
assert_eq!(changes[0].path, PathBuf::from("docs/user guide.md"));
assert_eq!(changes[1].status, FileStatus::Deleted);
assert_eq!(changes[1].path, PathBuf::from("notes/übersicht.md"));
}
#[test]
fn test_parse_diff_tree_empty_output() {
let output = "";
let result = parse_diff_tree_output_z(output);
assert!(result.is_ok());
assert_eq!(
result.unwrap().len(),
0,
"Empty output should yield no changes"
);
}
#[test]
fn test_parse_diff_tree_unknown_status() {
let output = "R100\0old.rs\0M\0valid.rs\0";
let result = parse_diff_tree_output_z(output);
assert!(result.is_ok());
let changes = result.unwrap();
assert_eq!(changes.len(), 1, "Should only parse valid status codes");
assert_eq!(changes[0].status, FileStatus::Modified);
assert_eq!(changes[0].path, PathBuf::from("valid.rs"));
}
}