Skip to main content

void_core/diff/
tree.rs

1//! Tree diff operations.
2//!
3//! Computes differences between two commits by comparing their file trees.
4
5use std::collections::HashMap;
6
7use super::collect::{collect_commit_files, load_commit_and_reader};
8use super::rename::apply_rename_detection;
9use super::types::{DiffKind, FileDiff, TreeDiff};
10use crate::crypto::KeyVault;
11use crate::store::ObjectStoreExt;
12use crate::cid::VoidCid;
13use crate::{ContentHash, Result};
14
15/// Computes the diff between two commits.
16///
17/// If `old_commit` is None, all files in `new_commit` are treated as Added.
18///
19/// # Arguments
20/// * `store` - Object store for fetching encrypted objects
21/// * `vault` - Key vault for decryption
22/// * `old_commit` - CID of the old commit (None for empty tree)
23/// * `new_commit` - CID of the new commit
24///
25/// # Returns
26/// A `TreeDiff` containing all file differences.
27pub fn diff_commits<S: ObjectStoreExt>(
28    store: &S,
29    vault: &KeyVault,
30    old_commit: Option<&VoidCid>,
31    new_commit: &VoidCid,
32) -> Result<TreeDiff> {
33    // Load new commit files
34    let (new_commit_obj, new_reader) = load_commit_and_reader(store, vault, new_commit)?;
35    let new_files = collect_commit_files(store, &new_commit_obj, &new_reader)?;
36
37    // Build map of new files: path -> hash
38    let new_map: HashMap<&str, ContentHash> = new_files
39        .iter()
40        .map(|e| (e.path.as_str(), e.content_hash))
41        .collect();
42
43    // Load old commit files (empty if None)
44    let old_files = match old_commit {
45        Some(cid) => {
46            let (old_commit_obj, old_reader) = load_commit_and_reader(store, vault, cid)?;
47            collect_commit_files(store, &old_commit_obj, &old_reader)?
48        }
49        None => Vec::new(),
50    };
51
52    // Build map of old files: path -> hash
53    let old_map: HashMap<&str, ContentHash> = old_files
54        .iter()
55        .map(|e| (e.path.as_str(), e.content_hash))
56        .collect();
57
58    let mut diffs = Vec::new();
59
60    // Find deleted and modified files (in old but not in new, or different hash)
61    for entry in &old_files {
62        match new_map.get(entry.path.as_str()) {
63            None => {
64                // Deleted
65                diffs.push(FileDiff {
66                    path: entry.path.clone(),
67                    kind: DiffKind::Deleted,
68                    old_hash: Some(entry.content_hash),
69                    new_hash: None,
70                });
71            }
72            Some(&new_hash) => {
73                if new_hash != entry.content_hash {
74                    // Modified
75                    diffs.push(FileDiff {
76                        path: entry.path.clone(),
77                        kind: DiffKind::Modified,
78                        old_hash: Some(entry.content_hash),
79                        new_hash: Some(new_hash),
80                    });
81                }
82            }
83        }
84    }
85
86    // Find added files (in new but not in old)
87    for entry in &new_files {
88        if !old_map.contains_key(entry.path.as_str()) {
89            diffs.push(FileDiff {
90                path: entry.path.clone(),
91                kind: DiffKind::Added,
92                old_hash: None,
93                new_hash: Some(entry.content_hash),
94            });
95        }
96    }
97
98    let files = apply_rename_detection(diffs);
99
100    Ok(TreeDiff { files })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::sync::Arc;
107    use crate::cid::ToVoidCid;
108    use crate::crypto::{self, KeyVault};
109    use crate::metadata::ShardMap;
110    use crate::pipeline::{commit_workspace, CommitOptions, SealOptions};
111    use crate::stage::{stage_paths, StageOptions};
112    use crate::VoidContext;
113    use camino::Utf8PathBuf;
114    use std::fs;
115    use tempfile::TempDir;
116
117    fn setup_test_workspace() -> (
118        TempDir,
119        std::path::PathBuf,
120        std::path::PathBuf,
121        [u8; 32],
122        [u8; 32],
123    ) {
124        let dir = TempDir::new().unwrap();
125        let root = dir.path().to_path_buf();
126        let void_dir = root.join(".void");
127        fs::create_dir_all(void_dir.join("objects")).unwrap();
128
129        let key = crypto::generate_key();
130        let repo_secret = crypto::generate_key();
131
132        (dir, root, void_dir, key, repo_secret)
133    }
134
135    #[test]
136    fn diff_commits_initial_commit() {
137        let (_dir, root, void_dir, key, repo_secret) = setup_test_workspace();
138        let vault = KeyVault::new(key).expect("key derivation should not fail");
139
140        // Create test files
141        fs::write(root.join("README.md"), "# Test").unwrap();
142        fs::write(root.join("main.rs"), "fn main() {}").unwrap();
143
144        // Seal to create a commit
145        let vault = Arc::new(KeyVault::new(key).unwrap());
146        let mut ctx = VoidContext::headless(&void_dir, Arc::clone(&vault), 0).unwrap();
147        ctx.paths.root = Utf8PathBuf::try_from(root.clone()).unwrap();
148        ctx.repo.secret = void_crypto::RepoSecret::new(repo_secret);
149
150        let seal_opts = SealOptions {
151            ctx,
152            shard_map: ShardMap::new(64),
153            ..Default::default()
154        };
155
156        let seal_result = commit_workspace(CommitOptions {
157            seal: seal_opts,
158            message: "initial commit".into(),
159            parent_cid: None,
160            allow_data_loss: false,
161            foreign_parent: false,
162        })
163        .unwrap();
164
165        // Create store and diff against empty tree
166        let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects")).unwrap();
167        let store = crate::store::FsStore::new(objects_dir).unwrap();
168
169        let new_cid = seal_result.commit_cid.to_void_cid().unwrap();
170        let diff = diff_commits(&store, &vault, None, &new_cid).unwrap();
171
172        // All files should be Added
173        assert_eq!(diff.len(), 2);
174        assert!(diff.files.iter().all(|f| matches!(f.kind, DiffKind::Added)));
175    }
176
177    #[test]
178    fn diff_commits_with_changes() {
179        let (_dir, root, void_dir, key, repo_secret) = setup_test_workspace();
180        let vault = KeyVault::new(key).expect("key derivation should not fail");
181
182        // Create initial files
183        fs::write(root.join("keep.txt"), "unchanged").unwrap();
184        fs::write(root.join("modify.txt"), "original").unwrap();
185        fs::write(root.join("delete.txt"), "will be deleted").unwrap();
186
187        // First commit
188        let vault1 = Arc::new(KeyVault::new(key).unwrap());
189        let mut ctx1 = VoidContext::headless(&void_dir, vault1, 0).unwrap();
190        ctx1.paths.root = Utf8PathBuf::try_from(root.clone()).unwrap();
191        ctx1.repo.secret = void_crypto::RepoSecret::new(repo_secret);
192
193        let seal_opts1 = SealOptions {
194            ctx: ctx1,
195            shard_map: ShardMap::new(64),
196            ..Default::default()
197        };
198
199        let result1 = commit_workspace(CommitOptions {
200            seal: seal_opts1,
201            message: "first".into(),
202            parent_cid: None,
203            allow_data_loss: false,
204            foreign_parent: false,
205        })
206        .unwrap();
207
208        // Modify files
209        fs::write(root.join("modify.txt"), "modified").unwrap();
210        fs::remove_file(root.join("delete.txt")).unwrap();
211        fs::write(root.join("add.txt"), "new file").unwrap();
212
213        // Stage changes
214        let vault_arc = std::sync::Arc::new(KeyVault::new(key).unwrap());
215        let void_ctx = crate::VoidContext::with_workspace(
216            root.clone(), void_dir.clone(), void_dir.clone(), vault_arc, 0,
217        ).unwrap();
218        stage_paths(StageOptions {
219            ctx: void_ctx,
220            patterns: vec![".".to_string()],
221            observer: None,
222        })
223        .unwrap();
224
225        // Second commit
226        let vault2 = Arc::new(KeyVault::new(key).unwrap());
227        let mut ctx2 = VoidContext::headless(&void_dir, vault2, 0).unwrap();
228        ctx2.paths.root = Utf8PathBuf::try_from(root.clone()).unwrap();
229        ctx2.repo.secret = void_crypto::RepoSecret::new(repo_secret);
230
231        let seal_opts2 = SealOptions {
232            ctx: ctx2,
233            shard_map: ShardMap::new(64),
234            ..Default::default()
235        };
236
237        let result2 = commit_workspace(CommitOptions {
238            seal: seal_opts2,
239            message: "second".into(),
240            parent_cid: Some(result1.commit_cid.clone()),
241            allow_data_loss: false,
242            foreign_parent: false,
243        })
244        .unwrap();
245
246        // Diff the two commits
247        let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects")).unwrap();
248        let store = crate::store::FsStore::new(objects_dir).unwrap();
249
250        let old_cid = result1.commit_cid.to_void_cid().unwrap();
251        let new_cid = result2.commit_cid.to_void_cid().unwrap();
252
253        let diff = diff_commits(&store, &vault, Some(&old_cid), &new_cid).unwrap();
254
255        // Should have: 1 added, 1 modified, 1 deleted (keep.txt unchanged)
256        let stats = diff.stats();
257        assert_eq!(stats.added, 1);
258        assert_eq!(stats.modified, 1);
259        assert_eq!(stats.deleted, 1);
260    }
261}