use std::path::PathBuf;
use anyhow::{Context, Result};
#[derive(Debug, Clone, Default)]
pub struct GitDiffResult {
pub added: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
pub renamed: Vec<(PathBuf, PathBuf)>,
pub from_commit: String,
pub to_commit: String,
pub added_lines: usize,
pub deleted_lines: usize,
}
pub fn analyze_git_diff(repo_path: &std::path::Path, last_commit_hash: Option<&str>) -> Result<GitDiffResult> {
let repo = git2::Repository::open(repo_path)
.with_context(|| format!("不是 Git 仓库: {}", repo_path.display()))?;
let head = match repo.head() {
Ok(h) => h,
Err(_) => {
tracing::info!("Git 仓库无 HEAD(空仓库或未提交)");
return Ok(GitDiffResult::default());
}
};
let head_commit = head.peel_to_commit()?;
let head_tree = head_commit.tree()?;
let head_oid = head_commit.id().to_string();
let from_commit = if let Some(prev_hash) = last_commit_hash {
prev_hash.to_string()
} else if head_commit.parents().count() > 0 {
let parent = head_commit.parent(0)?;
parent.id().to_string()
} else {
tracing::info!("首次提交或无上次生成记录,无法做增量 diff");
return Ok(GitDiffResult::default());
};
let from_obj = match repo.revparse_single(&from_commit) {
Ok(obj) => obj,
Err(e) => {
tracing::warn!("无法解析 commit {}: {},退化为空 diff", from_commit, e);
return Ok(GitDiffResult::default());
}
};
let from_tree = from_obj.peel_to_tree()?;
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&head_tree), None)?;
let mut result = GitDiffResult {
from_commit,
to_commit: head_oid,
..Default::default()
};
diff.foreach(
&mut |delta, _| {
let new_file = delta.new_file();
let old_file = delta.old_file();
match delta.status() {
git2::Delta::Added => {
if let Some(path) = new_file.path() {
result.added.push(path.to_path_buf());
}
}
git2::Delta::Deleted => {
if let Some(path) = old_file.path() {
result.deleted.push(path.to_path_buf());
}
}
git2::Delta::Modified => {
if let Some(path) = new_file.path() {
result.modified.push(path.to_path_buf());
}
}
git2::Delta::Renamed => {
let old = old_file.path().map(|p| p.to_path_buf());
let new = new_file.path().map(|p| p.to_path_buf());
if let (Some(old), Some(new)) = (old, new) {
result.renamed.push((old, new));
}
}
_ => {}
}
true
},
None,
None,
None,
)?;
let stats = diff.stats()?;
result.added_lines = stats.insertions();
result.deleted_lines = stats.deletions();
Ok(result)
}
pub fn get_head_commit_hash_at(root: &crate::project::ProjectRoot) -> Result<String> {
let repo = git2::Repository::open(root.path())?;
let head = repo.head()?;
let oid = head.target().ok_or_else(|| anyhow::anyhow!("HEAD 没有目标"))?;
Ok(oid.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_git_diff_result_default() {
let result = GitDiffResult::default();
assert!(result.added.is_empty());
assert!(result.modified.is_empty());
assert!(result.deleted.is_empty());
assert!(result.renamed.is_empty());
assert!(result.from_commit.is_empty());
assert!(result.to_commit.is_empty());
}
#[test]
fn test_analyze_git_diff_non_repo() {
let tmp = std::env::temp_dir().join("code-repo-wiki-test-diff-nonexistent");
let result = analyze_git_diff(&tmp, None);
assert!(result.is_err());
}
#[test]
fn test_diff_line_stats() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_diff_stats_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::write(dir.join("a.txt"), "line1\nline2\n").unwrap();
let first = add_and_commit(&repo, "init");
std::fs::write(dir.join("a.txt"), "line1\nline2\nline3\nline4\n").unwrap();
add_and_commit(&repo, "add two lines");
let result = analyze_git_diff(&dir, Some(&first)).unwrap();
assert_eq!(result.added_lines, 2);
assert_eq!(result.deleted_lines, 0);
std::fs::write(dir.join("a.txt"), "line1\nline3\nline4\n").unwrap();
add_and_commit(&repo, "del one line");
let second = repo.revparse_single("HEAD^").unwrap().peel_to_commit().unwrap();
let result = analyze_git_diff(&dir, Some(&second.id().to_string())).unwrap();
assert_eq!(result.added_lines, 0);
assert_eq!(result.deleted_lines, 1);
let _ = std::fs::remove_dir_all(&dir);
}
fn add_and_commit(repo: &git2::Repository, message: &str) -> String {
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let commit_id = match repo.head().ok() {
Some(head) => {
let parent = head.peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap()
}
None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap(),
};
commit_id.to_string()
}
}