Skip to main content

codescout/git/
mod.rs

1//! Git integration using `git2`.
2
3use anyhow::Result;
4use std::path::Path;
5
6/// Open the git repository at or above `path`.
7pub fn open_repo(path: &Path) -> Result<git2::Repository> {
8    git2::Repository::discover(path)
9        .map_err(|e| anyhow::anyhow!("No git repository found at {}: {}", path.display(), e))
10}
11
12#[derive(Debug, Clone)]
13pub enum DiffStatus {
14    Added,
15    Modified,
16    Deleted,
17    Renamed { old_path: String },
18}
19
20#[derive(Debug, Clone)]
21pub struct DiffEntry {
22    pub path: String,
23    pub status: DiffStatus,
24}
25
26/// Diff two commits by SHA, returning a list of changed files.
27/// Returns `Err` if either SHA is not found (e.g. after a rebase).
28pub fn diff_tree_to_tree(
29    repo: &git2::Repository,
30    from_sha: &str,
31    to_sha: &str,
32) -> Result<Vec<DiffEntry>> {
33    let from_obj = repo.revparse_single(from_sha)?;
34    let to_obj = repo.revparse_single(to_sha)?;
35    let from_tree = from_obj.peel_to_commit()?.tree()?;
36    let to_tree = to_obj.peel_to_commit()?.tree()?;
37
38    let mut opts = git2::DiffOptions::new();
39    let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut opts))?;
40
41    // Enable rename detection
42    let mut find_opts = git2::DiffFindOptions::new();
43    find_opts.renames(true);
44    let mut diff = diff;
45    diff.find_similar(Some(&mut find_opts))?;
46
47    let mut entries = Vec::new();
48    for delta in diff.deltas() {
49        let status = match delta.status() {
50            git2::Delta::Added => DiffStatus::Added,
51            git2::Delta::Modified => DiffStatus::Modified,
52            git2::Delta::Deleted => DiffStatus::Deleted,
53            git2::Delta::Renamed => {
54                let old = delta
55                    .old_file()
56                    .path()
57                    .unwrap()
58                    .to_string_lossy()
59                    .replace('\\', "/");
60                DiffStatus::Renamed { old_path: old }
61            }
62            _ => continue, // Ignore typechange, copied, etc.
63        };
64        let path = delta
65            .new_file()
66            .path()
67            .or_else(|| delta.old_file().path())
68            .unwrap()
69            .to_string_lossy()
70            .replace('\\', "/");
71        entries.push(DiffEntry { path, status });
72    }
73    Ok(entries)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use tempfile::tempdir;
80
81    fn init_repo(dir: &Path) -> git2::Repository {
82        let repo = git2::Repository::init(dir).unwrap();
83        let mut config = repo.config().unwrap();
84        config.set_str("user.name", "Test").unwrap();
85        config.set_str("user.email", "test@test.com").unwrap();
86        repo
87    }
88
89    fn commit_file(repo: &git2::Repository, path: &str, content: &str, msg: &str) -> git2::Oid {
90        let root = repo.workdir().unwrap();
91        let file_path = root.join(path);
92        if let Some(parent) = file_path.parent() {
93            std::fs::create_dir_all(parent).unwrap();
94        }
95        std::fs::write(&file_path, content).unwrap();
96        let mut index = repo.index().unwrap();
97        index.add_path(Path::new(path)).unwrap();
98        index.write().unwrap();
99        let tree_oid = index.write_tree().unwrap();
100        let tree = repo.find_tree(tree_oid).unwrap();
101        let sig = repo.signature().unwrap();
102        let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
103        let parents: Vec<&git2::Commit> = parent.iter().collect();
104        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
105            .unwrap()
106    }
107
108    #[test]
109    fn diff_tree_detects_added_file() {
110        let dir = tempdir().unwrap();
111        let repo = init_repo(dir.path());
112        let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
113        let c2 = commit_file(&repo, "b.rs", "fn b() {}", "add b");
114        let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
115        assert_eq!(entries.len(), 1);
116        assert_eq!(entries[0].path, "b.rs");
117        assert!(matches!(entries[0].status, DiffStatus::Added));
118    }
119
120    #[test]
121    fn diff_tree_detects_modified_file() {
122        let dir = tempdir().unwrap();
123        let repo = init_repo(dir.path());
124        let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
125        let c2 = commit_file(&repo, "a.rs", "fn a() { 1 }", "modify a");
126        let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
127        assert_eq!(entries.len(), 1);
128        assert_eq!(entries[0].path, "a.rs");
129        assert!(matches!(entries[0].status, DiffStatus::Modified));
130    }
131
132    #[test]
133    fn diff_tree_detects_deleted_file() {
134        let dir = tempdir().unwrap();
135        let repo = init_repo(dir.path());
136        let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
137        // Delete a.rs and commit
138        std::fs::remove_file(dir.path().join("a.rs")).unwrap();
139        let mut index = repo.index().unwrap();
140        index.remove_path(Path::new("a.rs")).unwrap();
141        index.write().unwrap();
142        let tree_oid = index.write_tree().unwrap();
143        let tree = repo.find_tree(tree_oid).unwrap();
144        let sig = repo.signature().unwrap();
145        let parent = repo.head().unwrap().peel_to_commit().unwrap();
146        let c2 = repo
147            .commit(Some("HEAD"), &sig, &sig, "del a", &tree, &[&parent])
148            .unwrap();
149        let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c2.to_string()).unwrap();
150        assert_eq!(entries.len(), 1);
151        assert_eq!(entries[0].path, "a.rs");
152        assert!(matches!(entries[0].status, DiffStatus::Deleted));
153    }
154
155    #[test]
156    fn diff_tree_returns_empty_for_same_commit() {
157        let dir = tempdir().unwrap();
158        let repo = init_repo(dir.path());
159        let c1 = commit_file(&repo, "a.rs", "fn a() {}", "init");
160        let entries = diff_tree_to_tree(&repo, &c1.to_string(), &c1.to_string()).unwrap();
161        assert!(entries.is_empty());
162    }
163}