Skip to main content

gonzalo_store_git/
diff.rs

1//! Git working-tree diffing for incremental code-graph sync (gonzalo#93).
2//!
3//! The indexer's default path is a full reconcile: re-walk and re-parse the
4//! whole tree every run. When the source is a git repo, [`changed_paths`] sources
5//! the changed set directly from git — the tree of a recorded *base* commit
6//! against the live working tree — so only added/modified files need re-parsing
7//! and deleted files are dropped. The classification is uniform A/M/D, matching
8//! [`gonzalo_core::Manifest::reconcile`]'s set model, so a periodic full reconcile
9//! still converges the view if any incremental event is ever missed.
10
11use gonzalo_core::{CoreError, Result};
12use std::path::Path;
13
14/// Source paths that changed between a git base commit and the current working
15/// tree, classified uniformly as added / modified / deleted. Paths are
16/// repo-relative with `/` separators and each vector is sorted.
17#[derive(Clone, Debug, Default, PartialEq, Eq)]
18pub struct ChangedPaths {
19    /// Paths present in the working tree but not in the base commit (includes
20    /// untracked files).
21    pub added: Vec<String>,
22    /// Paths present in both whose content differs.
23    pub modified: Vec<String>,
24    /// Paths present in the base commit but gone from the working tree.
25    pub deleted: Vec<String>,
26}
27
28impl ChangedPaths {
29    /// True when nothing changed between the base commit and the working tree.
30    pub fn is_empty(&self) -> bool {
31        self.added.is_empty() && self.modified.is_empty() && self.deleted.is_empty()
32    }
33}
34
35/// True if `path` is inside a git working tree (has a discoverable repository).
36pub fn is_git_repo(path: &Path) -> bool {
37    git2::Repository::discover(path).is_ok()
38}
39
40/// Resolve the `HEAD` of the repo at `root` to its commit SHA (hex). This is the
41/// value to record as the base after a successful index, so the next run can diff
42/// against it incrementally.
43pub fn head_commit(root: &Path) -> Result<String> {
44    let repo = git2::Repository::open(root).map_err(|e| CoreError::Backend(e.to_string()))?;
45    let head = repo.head().map_err(|e| CoreError::Backend(e.to_string()))?;
46    let oid = head
47        .target()
48        .ok_or_else(|| CoreError::Backend("HEAD has no target commit".into()))?;
49    Ok(oid.to_string())
50}
51
52/// Compute the changed path set between the tree of commit `base` and the current
53/// working tree of the repo at `root`. Untracked files count as additions;
54/// renames are surfaced as a delete of the old path plus an add of the new one
55/// (uniform A/M/D — no rename tracking). Paths are repo-relative, `/`-separated,
56/// and sorted.
57pub fn changed_paths(root: &Path, base: &str) -> Result<ChangedPaths> {
58    let repo = git2::Repository::open(root).map_err(|e| CoreError::Backend(e.to_string()))?;
59    let base_oid =
60        git2::Oid::from_str(base).map_err(|e| CoreError::Backend(format!("bad base oid: {e}")))?;
61    let base_tree = repo
62        .find_commit(base_oid)
63        .map_err(|e| CoreError::Backend(e.to_string()))?
64        .tree()
65        .map_err(|e| CoreError::Backend(e.to_string()))?;
66
67    let mut opts = git2::DiffOptions::new();
68    opts.include_untracked(true).recurse_untracked_dirs(true);
69    let diff = repo
70        .diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut opts))
71        .map_err(|e| CoreError::Backend(e.to_string()))?;
72
73    let mut changed = ChangedPaths::default();
74    for delta in diff.deltas() {
75        match delta.status() {
76            git2::Delta::Added | git2::Delta::Untracked | git2::Delta::Copied => {
77                if let Some(p) = path_str(delta.new_file().path()) {
78                    changed.added.push(p);
79                }
80            }
81            git2::Delta::Deleted => {
82                if let Some(p) = path_str(delta.old_file().path()) {
83                    changed.deleted.push(p);
84                }
85            }
86            git2::Delta::Modified | git2::Delta::Typechange => {
87                if let Some(p) = path_str(delta.new_file().path()) {
88                    changed.modified.push(p);
89                }
90            }
91            git2::Delta::Renamed => {
92                if let Some(p) = path_str(delta.old_file().path()) {
93                    changed.deleted.push(p);
94                }
95                if let Some(p) = path_str(delta.new_file().path()) {
96                    changed.added.push(p);
97                }
98            }
99            _ => {}
100        }
101    }
102    changed.added.sort();
103    changed.added.dedup();
104    changed.modified.sort();
105    changed.modified.dedup();
106    changed.deleted.sort();
107    changed.deleted.dedup();
108    Ok(changed)
109}
110
111/// Render a git delta path as a `/`-separated relative string.
112fn path_str(path: Option<&Path>) -> Option<String> {
113    path.map(|p| p.to_string_lossy().replace('\\', "/"))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use std::path::Path;
120    use tempfile::TempDir;
121
122    /// Init a repo, write `files`, commit them, and return the commit SHA.
123    fn init_repo_with(dir: &Path, files: &[(&str, &str)]) -> String {
124        let repo = git2::Repository::init(dir).unwrap();
125        for (name, contents) in files {
126            let path = dir.join(name);
127            if let Some(parent) = path.parent() {
128                std::fs::create_dir_all(parent).unwrap();
129            }
130            std::fs::write(&path, contents).unwrap();
131        }
132        commit_all(&repo, "initial")
133    }
134
135    fn commit_all(repo: &git2::Repository, msg: &str) -> String {
136        let mut index = repo.index().unwrap();
137        index
138            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
139            .unwrap();
140        index.write().unwrap();
141        let tree_oid = index.write_tree().unwrap();
142        let tree = repo.find_tree(tree_oid).unwrap();
143        let sig = git2::Signature::now("t", "t@localhost").unwrap();
144        let parent = repo
145            .head()
146            .ok()
147            .and_then(|h| h.target())
148            .and_then(|oid| repo.find_commit(oid).ok());
149        let parents: Vec<&git2::Commit> = parent.iter().collect();
150        let oid = repo
151            .commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
152            .unwrap();
153        oid.to_string()
154    }
155
156    #[test]
157    fn is_git_repo_detects_repo() {
158        let dir = TempDir::new().unwrap();
159        assert!(!is_git_repo(dir.path()), "empty dir is not a repo");
160        git2::Repository::init(dir.path()).unwrap();
161        assert!(is_git_repo(dir.path()), "initialized dir is a repo");
162    }
163
164    #[test]
165    fn head_commit_resolves_to_the_last_commit() {
166        let dir = TempDir::new().unwrap();
167        let sha = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
168        assert_eq!(head_commit(dir.path()).unwrap(), sha);
169    }
170
171    #[test]
172    fn changed_paths_reports_added_modified_deleted() {
173        let dir = TempDir::new().unwrap();
174        let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}"), ("b.rs", "fn b() {}")]);
175
176        // Modify a.rs, delete b.rs, add an untracked c.rs.
177        std::fs::write(dir.path().join("a.rs"), "fn a() { x(); }").unwrap();
178        std::fs::remove_file(dir.path().join("b.rs")).unwrap();
179        std::fs::write(dir.path().join("c.rs"), "fn c() {}").unwrap();
180
181        let changed = changed_paths(dir.path(), &base).unwrap();
182        assert_eq!(changed.added, vec!["c.rs".to_string()]);
183        assert_eq!(changed.modified, vec!["a.rs".to_string()]);
184        assert_eq!(changed.deleted, vec!["b.rs".to_string()]);
185        assert!(!changed.is_empty());
186    }
187
188    #[test]
189    fn changed_paths_empty_when_working_tree_matches_base() {
190        let dir = TempDir::new().unwrap();
191        let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
192        let changed = changed_paths(dir.path(), &base).unwrap();
193        assert!(changed.is_empty(), "clean tree has no changes: {changed:?}");
194    }
195
196    #[test]
197    fn changed_paths_finds_nested_untracked_files() {
198        let dir = TempDir::new().unwrap();
199        let base = init_repo_with(dir.path(), &[("a.rs", "fn a() {}")]);
200        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
201        std::fs::write(dir.path().join("sub/deep.rs"), "fn d() {}").unwrap();
202
203        let changed = changed_paths(dir.path(), &base).unwrap();
204        assert_eq!(changed.added, vec!["sub/deep.rs".to_string()]);
205    }
206}