code_repo_wiki/incremental/
diff.rs1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5#[derive(Debug, Clone, Default)]
7pub struct GitDiffResult {
8 pub added: Vec<PathBuf>,
10 pub modified: Vec<PathBuf>,
12 pub deleted: Vec<PathBuf>,
14 pub renamed: Vec<(PathBuf, PathBuf)>,
16 pub from_commit: String,
18 pub to_commit: String,
20 pub added_lines: usize,
22 pub deleted_lines: usize,
24}
25
26pub fn analyze_git_diff(repo_path: &std::path::Path, last_commit_hash: Option<&str>) -> Result<GitDiffResult> {
32 let repo = git2::Repository::open(repo_path)
33 .with_context(|| format!("不是 Git 仓库: {}", repo_path.display()))?;
34
35 let head = match repo.head() {
37 Ok(h) => h,
38 Err(_) => {
39 tracing::info!("Git 仓库无 HEAD(空仓库或未提交)");
40 return Ok(GitDiffResult::default());
41 }
42 };
43
44 let head_commit = head.peel_to_commit()?;
45 let head_tree = head_commit.tree()?;
46 let head_oid = head_commit.id().to_string();
47
48 let from_commit = if let Some(prev_hash) = last_commit_hash {
50 prev_hash.to_string()
51 } else if head_commit.parents().count() > 0 {
52 let parent = head_commit.parent(0)?;
53 parent.id().to_string()
54 } else {
55 tracing::info!("首次提交或无上次生成记录,无法做增量 diff");
56 return Ok(GitDiffResult::default());
57 };
58
59 let from_obj = match repo.revparse_single(&from_commit) {
61 Ok(obj) => obj,
62 Err(e) => {
63 tracing::warn!("无法解析 commit {}: {},退化为空 diff", from_commit, e);
64 return Ok(GitDiffResult::default());
65 }
66 };
67 let from_tree = from_obj.peel_to_tree()?;
68
69 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&head_tree), None)?;
70
71 let mut result = GitDiffResult {
72 from_commit,
73 to_commit: head_oid,
74 ..Default::default()
75 };
76
77 diff.foreach(
78 &mut |delta, _| {
79 let new_file = delta.new_file();
80 let old_file = delta.old_file();
81
82 match delta.status() {
83 git2::Delta::Added => {
84 if let Some(path) = new_file.path() {
85 result.added.push(path.to_path_buf());
86 }
87 }
88 git2::Delta::Deleted => {
89 if let Some(path) = old_file.path() {
90 result.deleted.push(path.to_path_buf());
91 }
92 }
93 git2::Delta::Modified => {
94 if let Some(path) = new_file.path() {
95 result.modified.push(path.to_path_buf());
96 }
97 }
98 git2::Delta::Renamed => {
99 let old = old_file.path().map(|p| p.to_path_buf());
100 let new = new_file.path().map(|p| p.to_path_buf());
101 if let (Some(old), Some(new)) = (old, new) {
102 result.renamed.push((old, new));
103 }
104 }
105 _ => {}
106 }
107 true
108 },
109 None,
110 None,
111 None,
112 )?;
113
114 let stats = diff.stats()?;
116 result.added_lines = stats.insertions();
117 result.deleted_lines = stats.deletions();
118
119 Ok(result)
120}
121
122
123pub fn get_head_commit_hash_at(root: &crate::project::ProjectRoot) -> Result<String> {
128 let repo = git2::Repository::open(root.path())?;
129 let head = repo.head()?;
130 let oid = head.target().ok_or_else(|| anyhow::anyhow!("HEAD 没有目标"))?;
131 Ok(oid.to_string())
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_git_diff_result_default() {
140 let result = GitDiffResult::default();
141 assert!(result.added.is_empty());
142 assert!(result.modified.is_empty());
143 assert!(result.deleted.is_empty());
144 assert!(result.renamed.is_empty());
145 assert!(result.from_commit.is_empty());
146 assert!(result.to_commit.is_empty());
147 }
148
149 #[test]
150 fn test_analyze_git_diff_non_repo() {
151 let tmp = std::env::temp_dir().join("code-repo-wiki-test-diff-nonexistent");
152 let result = analyze_git_diff(&tmp, None);
153
154 assert!(result.is_err());
156 }
157
158 #[test]
160 fn test_diff_line_stats() {
161 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_diff_stats_{}", std::process::id()));
162 let _ = std::fs::remove_dir_all(&dir);
163 let repo = git2::Repository::init(&dir).unwrap();
164 let mut cfg = repo.config().unwrap();
165 cfg.set_str("user.name", "test").unwrap();
166 cfg.set_str("user.email", "test@test.com").unwrap();
167
168 std::fs::write(dir.join("a.txt"), "line1\nline2\n").unwrap();
169 let first = add_and_commit(&repo, "init");
170
171 std::fs::write(dir.join("a.txt"), "line1\nline2\nline3\nline4\n").unwrap();
173 add_and_commit(&repo, "add two lines");
174
175 let result = analyze_git_diff(&dir, Some(&first)).unwrap();
176 assert_eq!(result.added_lines, 2);
177 assert_eq!(result.deleted_lines, 0);
178
179 std::fs::write(dir.join("a.txt"), "line1\nline3\nline4\n").unwrap();
181 add_and_commit(&repo, "del one line");
182
183 let second = repo.revparse_single("HEAD^").unwrap().peel_to_commit().unwrap();
184 let result = analyze_git_diff(&dir, Some(&second.id().to_string())).unwrap();
185 assert_eq!(result.added_lines, 0);
186 assert_eq!(result.deleted_lines, 1);
187
188 let _ = std::fs::remove_dir_all(&dir);
189 }
190
191 fn add_and_commit(repo: &git2::Repository, message: &str) -> String {
193 let mut index = repo.index().unwrap();
194 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
195 index.write().unwrap();
196 let tree_id = index.write_tree().unwrap();
197 let tree = repo.find_tree(tree_id).unwrap();
198 let sig = git2::Signature::now("test", "test@test.com").unwrap();
199 let commit_id = match repo.head().ok() {
200 Some(head) => {
201 let parent = head.peel_to_commit().unwrap();
202 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap()
203 }
204 None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap(),
205 };
206 commit_id.to_string()
207 }
208}