1use 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
15pub fn diff_commits<S: ObjectStoreExt>(
28 store: &S,
29 vault: &KeyVault,
30 old_commit: Option<&VoidCid>,
31 new_commit: &VoidCid,
32) -> Result<TreeDiff> {
33 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 let new_map: HashMap<&str, ContentHash> = new_files
39 .iter()
40 .map(|e| (e.path.as_str(), e.content_hash))
41 .collect();
42
43 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 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 for entry in &old_files {
62 match new_map.get(entry.path.as_str()) {
63 None => {
64 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 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 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 fs::write(root.join("README.md"), "# Test").unwrap();
142 fs::write(root.join("main.rs"), "fn main() {}").unwrap();
143
144 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 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 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 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 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 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 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 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 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 let stats = diff.stats();
257 assert_eq!(stats.added, 1);
258 assert_eq!(stats.modified, 1);
259 assert_eq!(stats.deleted, 1);
260 }
261}