Skip to main content

code_repo_wiki/incremental/
diff.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5/// Git diff 分析结果
6#[derive(Debug, Clone, Default)]
7pub struct GitDiffResult {
8    /// 新增文件列表
9    pub added: Vec<PathBuf>,
10    /// 修改文件列表
11    pub modified: Vec<PathBuf>,
12    /// 删除文件列表
13    pub deleted: Vec<PathBuf>,
14    /// 重命名文件列表(旧路径,新路径)
15    pub renamed: Vec<(PathBuf, PathBuf)>,
16    /// 起始 commit hash
17    pub from_commit: String,
18    /// 目标 commit hash
19    pub to_commit: String,
20    /// 新增行数
21    pub added_lines: usize,
22    /// 删除行数
23    pub deleted_lines: usize,
24}
25
26/// 分析 Git diff,返回变更的文件列表
27///
28/// 使用 git2 库分析 last_commit_hash(如有)与 HEAD 的差异。
29/// 无 Git 历史或 last_commit_hash 为 None 时退化为 HEAD^。
30/// 无父 commit 时返回空结果。
31pub 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    // 获取 HEAD commit
36    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    // 确定 from_commit:优先使用 last_commit_hash,退化为 HEAD^
49    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    // 解析 from_commit 对应的 tree
60    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    // 统计整个 diff 的新增/删除行数(DiffDelta 无行级统计 API,用 Diff 级 stats)
115    let stats = diff.stats()?;
116    result.added_lines = stats.insertions();
117    result.deleted_lines = stats.deletions();
118
119    Ok(result)
120}
121
122
123/// 在指定项目根下获取当前 HEAD commit hash
124///
125/// git 仓库定位基准显式注入:不再依赖进程 cwd(watch 常驻进程的
126/// cwd 漂移不再改变仓库解析目标)。
127pub 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        // 非 Git 仓库应返回 Err
155        assert!(result.is_err());
156    }
157
158    /// 在临时仓库中做 2 次提交,验证 diff 行数统计正确
159    #[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        // 第二次提交:新增 2 行
172        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        // 第三次提交:删除 1 行
180        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    /// 提交当前工作区全部文件并返回 commit id
192    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}