Skip to main content

void_core/diff/
working.rs

1//! Working tree diff operations.
2//!
3//! Computes differences between commits/index and the working tree.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9use rayon::prelude::*;
10
11use super::collect::{collect_commit_files, load_commit_and_reader, FileEntry};
12use super::rename::apply_rename_detection;
13use super::types::{DiffKind, FileDiff, TreeDiff};
14use crate::cid::VoidCid;
15use crate::crypto::KeyVault;
16use crate::index::WorkspaceIndex;
17use crate::store::ObjectStoreExt;
18use crate::support::configure_walker;
19use crate::{ContentHash, Result, VoidError};
20
21/// Walks a workspace directory and collects file entries with their hashes.
22///
23/// Uses rayon for parallel hashing.
24fn collect_workspace_files(workspace: &Path) -> Result<Vec<FileEntry>> {
25    // Use ignore crate to respect repo ignore rules (match staging behavior)
26    let mut builder = ignore::WalkBuilder::new(workspace);
27    let walker = configure_walker(&mut builder)
28        .filter_entry(|entry| {
29            let name = entry.file_name().to_string_lossy();
30            name != ".void" && name != ".git" && name != "node_modules" && name != ".DS_Store"
31        })
32        .build();
33
34    let paths: Vec<_> = walker
35        .filter_map(|e| e.ok())
36        .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
37        .filter_map(|e| {
38            let path = e.path();
39            path.strip_prefix(workspace)
40                .ok()
41                .map(|rel| rel.to_path_buf())
42        })
43        .collect();
44
45    // Parallel hash computation
46    let entries: Result<Vec<FileEntry>> = paths
47        .par_iter()
48        .map(|rel_path| {
49            let full_path = workspace.join(rel_path);
50            let content = fs::read(&full_path)?;
51            let hash = ContentHash::digest(&content);
52
53            let path_str = rel_path.to_str().ok_or_else(|| {
54                VoidError::Io(std::io::Error::new(
55                    std::io::ErrorKind::InvalidData,
56                    "path not valid UTF-8",
57                ))
58            })?;
59
60            // Normalize path separators to forward slashes
61            let normalized = path_str.replace('\\', "/");
62
63            Ok(FileEntry {
64                path: normalized,
65                content_hash: hash,
66            })
67        })
68        .collect();
69
70    entries
71}
72
73/// Computes the diff between a commit and the working tree.
74///
75/// # Arguments
76/// * `store` - Object store for fetching encrypted objects
77/// * `vault` - Key vault for decryption
78/// * `commit` - CID of the commit to compare against
79/// * `workspace` - Path to the workspace directory
80///
81/// # Returns
82/// A `TreeDiff` showing changes in the working tree relative to the commit.
83pub fn diff_working<S: ObjectStoreExt>(
84    store: &S,
85    vault: &KeyVault,
86    commit: &VoidCid,
87    workspace: &Path,
88) -> Result<TreeDiff> {
89    // Load commit files via manifest
90    let (commit_obj, reader) = load_commit_and_reader(store, vault, commit)?;
91    let commit_files = collect_commit_files(store, &commit_obj, &reader)?;
92
93    // Build map of commit files: path -> hash
94    let commit_map: HashMap<&str, ContentHash> = commit_files
95        .iter()
96        .map(|e| (e.path.as_str(), e.content_hash))
97        .collect();
98
99    // Collect workspace files with parallel hashing
100    let workspace_files = collect_workspace_files(workspace)?;
101
102    // Build map of workspace files: path -> hash
103    let workspace_map: HashMap<&str, ContentHash> = workspace_files
104        .iter()
105        .map(|e| (e.path.as_str(), e.content_hash))
106        .collect();
107
108    let mut diffs = Vec::new();
109
110    // Find deleted and modified files
111    for entry in &commit_files {
112        match workspace_map.get(entry.path.as_str()) {
113            None => {
114                // Deleted from workspace
115                diffs.push(FileDiff {
116                    path: entry.path.clone(),
117                    kind: DiffKind::Deleted,
118                    old_hash: Some(entry.content_hash),
119                    new_hash: None,
120                });
121            }
122            Some(&ws_hash) => {
123                if ws_hash != entry.content_hash {
124                    // Modified in workspace
125                    diffs.push(FileDiff {
126                        path: entry.path.clone(),
127                        kind: DiffKind::Modified,
128                        old_hash: Some(entry.content_hash),
129                        new_hash: Some(ws_hash),
130                    });
131                }
132            }
133        }
134    }
135
136    // Find added files
137    for entry in &workspace_files {
138        if !commit_map.contains_key(entry.path.as_str()) {
139            diffs.push(FileDiff {
140                path: entry.path.clone(),
141                kind: DiffKind::Added,
142                old_hash: None,
143                new_hash: Some(entry.content_hash),
144            });
145        }
146    }
147
148    let files = apply_rename_detection(diffs);
149
150    Ok(TreeDiff { files })
151}
152
153/// Computes the diff between the index and the working tree.
154///
155/// # Arguments
156/// * `index` - The workspace index (staged files)
157/// * `workspace` - Path to the workspace directory
158///
159/// # Returns
160/// A `TreeDiff` showing unstaged changes (working tree vs index).
161pub fn diff_index(index: &WorkspaceIndex, workspace: &Path) -> Result<TreeDiff> {
162    // Build map of index entries: path -> hash
163    let index_map: HashMap<&str, ContentHash> = index
164        .iter()
165        .map(|e| (e.path.as_str(), e.content_hash))
166        .collect();
167
168    // Collect workspace files with parallel hashing
169    let workspace_files = collect_workspace_files(workspace)?;
170
171    // Build map of workspace files: path -> hash
172    let workspace_map: HashMap<&str, ContentHash> = workspace_files
173        .iter()
174        .map(|e| (e.path.as_str(), e.content_hash))
175        .collect();
176
177    let mut diffs = Vec::new();
178
179    // Find deleted and modified files (relative to index)
180    for entry in index.iter() {
181        match workspace_map.get(entry.path.as_str()) {
182            None => {
183                // Deleted from workspace
184                diffs.push(FileDiff {
185                    path: entry.path.clone(),
186                    kind: DiffKind::Deleted,
187                    old_hash: Some(entry.content_hash),
188                    new_hash: None,
189                });
190            }
191            Some(&ws_hash) => {
192                if ws_hash != entry.content_hash {
193                    // Modified in workspace
194                    diffs.push(FileDiff {
195                        path: entry.path.clone(),
196                        kind: DiffKind::Modified,
197                        old_hash: Some(entry.content_hash),
198                        new_hash: Some(ws_hash),
199                    });
200                }
201            }
202        }
203    }
204
205    // Find untracked files (in workspace but not in index)
206    for entry in &workspace_files {
207        if !index_map.contains_key(entry.path.as_str()) {
208            diffs.push(FileDiff {
209                path: entry.path.clone(),
210                kind: DiffKind::Added,
211                old_hash: None,
212                new_hash: Some(entry.content_hash),
213            });
214        }
215    }
216
217    let files = apply_rename_detection(diffs);
218
219    Ok(TreeDiff { files })
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::sync::Arc;
226    use crate::cid::ToVoidCid;
227    use crate::crypto::{self, KeyVault};
228    use crate::index::IndexEntry;
229    use crate::metadata::ShardMap;
230    use crate::pipeline::{commit_workspace, CommitOptions, SealOptions};
231    use crate::VoidContext;
232    use camino::Utf8PathBuf;
233    use tempfile::TempDir;
234
235    fn setup_test_workspace() -> (
236        TempDir,
237        std::path::PathBuf,
238        std::path::PathBuf,
239        [u8; 32],
240        [u8; 32],
241    ) {
242        let dir = TempDir::new().unwrap();
243        let root = dir.path().to_path_buf();
244        let void_dir = root.join(".void");
245        fs::create_dir_all(void_dir.join("objects")).unwrap();
246
247        let key = crypto::generate_key();
248        let repo_secret = crypto::generate_key();
249
250        (dir, root, void_dir, key, repo_secret)
251    }
252
253    fn make_test_entry(path: &str, hash_byte: u8) -> IndexEntry {
254        IndexEntry {
255            path: path.to_string(),
256            content_hash: ContentHash([hash_byte; 32]),
257            mtime_secs: 1700000000,
258            mtime_nanos: 123456789,
259            size: 100,
260        }
261    }
262
263    #[test]
264    fn diff_empty_index_with_workspace() {
265        let temp = TempDir::new().unwrap();
266        let workspace = temp.path();
267
268        // Create some files in workspace
269        fs::write(workspace.join("file1.txt"), "hello").unwrap();
270        fs::write(workspace.join("file2.txt"), "world").unwrap();
271
272        let index = WorkspaceIndex::empty();
273        let diff = diff_index(&index, workspace).unwrap();
274
275        // Both files should be Added
276        assert_eq!(diff.len(), 2);
277        assert!(diff.files.iter().all(|f| matches!(f.kind, DiffKind::Added)));
278
279        let paths: Vec<_> = diff.files.iter().map(|f| f.path.as_str()).collect();
280        assert!(paths.contains(&"file1.txt"));
281        assert!(paths.contains(&"file2.txt"));
282    }
283
284    #[test]
285    fn diff_index_with_modified_file() {
286        let temp = TempDir::new().unwrap();
287        let workspace = temp.path();
288
289        // Create file
290        fs::write(workspace.join("file.txt"), "original").unwrap();
291
292        // Create index with different hash
293        let mut index = WorkspaceIndex::empty();
294        index.entries.push(make_test_entry("file.txt", 0xAA));
295
296        let diff = diff_index(&index, workspace).unwrap();
297
298        assert_eq!(diff.len(), 1);
299        assert_eq!(diff.files[0].path, "file.txt");
300        assert!(matches!(diff.files[0].kind, DiffKind::Modified));
301    }
302
303    #[test]
304    fn diff_index_with_deleted_file() {
305        let temp = TempDir::new().unwrap();
306        let workspace = temp.path();
307
308        // Create index with a file that doesn't exist in workspace
309        let mut index = WorkspaceIndex::empty();
310        index.entries.push(make_test_entry("deleted.txt", 0xBB));
311
312        let diff = diff_index(&index, workspace).unwrap();
313
314        assert_eq!(diff.len(), 1);
315        assert_eq!(diff.files[0].path, "deleted.txt");
316        assert!(matches!(diff.files[0].kind, DiffKind::Deleted));
317    }
318
319    #[test]
320    fn diff_index_detects_rename() {
321        let temp = TempDir::new().unwrap();
322        let workspace = temp.path();
323
324        let content = b"same content";
325        fs::write(workspace.join("new.txt"), content).unwrap();
326        let hash = ContentHash::digest(content);
327
328        let mut index = WorkspaceIndex::empty();
329        index.entries.push(IndexEntry {
330            path: "old.txt".to_string(),
331            content_hash: hash,
332            mtime_secs: 1700000000,
333            mtime_nanos: 0,
334            size: content.len() as u64,
335        });
336
337        let diff = diff_index(&index, workspace).unwrap();
338
339        assert_eq!(diff.len(), 1);
340        assert_eq!(diff.files[0].path, "new.txt");
341        match &diff.files[0].kind {
342            DiffKind::Renamed { from, similarity } => {
343                assert_eq!(from, "old.txt");
344                assert_eq!(*similarity, 100);
345            }
346            other => panic!("expected renamed diff, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn diff_index_with_no_changes() {
352        let temp = TempDir::new().unwrap();
353        let workspace = temp.path();
354
355        // Create file
356        let content = b"hello world";
357        fs::write(workspace.join("file.txt"), content).unwrap();
358
359        // Create index with matching hash
360        let hash = ContentHash::digest(content);
361        let mut index = WorkspaceIndex::empty();
362        index.entries.push(IndexEntry {
363            path: "file.txt".to_string(),
364            content_hash: hash,
365            mtime_secs: 1700000000,
366            mtime_nanos: 0,
367            size: content.len() as u64,
368        });
369
370        let diff = diff_index(&index, workspace).unwrap();
371
372        assert!(diff.is_empty());
373    }
374
375    #[test]
376    fn diff_working_detects_changes() {
377        let (_dir, root, void_dir, key, repo_secret) = setup_test_workspace();
378        let vault = KeyVault::new(key).expect("key derivation should not fail");
379
380        // Create initial files
381        fs::write(root.join("file1.txt"), "original").unwrap();
382        fs::write(root.join("file2.txt"), "unchanged").unwrap();
383
384        // Commit
385        let vault = Arc::new(KeyVault::new(key).unwrap());
386        let mut ctx = VoidContext::headless(&void_dir, Arc::clone(&vault), 0).unwrap();
387        ctx.paths.root = Utf8PathBuf::try_from(root.clone()).unwrap();
388        ctx.repo.secret = void_crypto::RepoSecret::new(repo_secret);
389
390        let seal_opts = SealOptions {
391            ctx,
392            shard_map: ShardMap::new(64),
393            ..Default::default()
394        };
395
396        let result = commit_workspace(CommitOptions {
397            seal: seal_opts,
398            message: "initial".into(),
399            parent_cid: None,
400            allow_data_loss: false,
401            foreign_parent: false,
402        })
403        .unwrap();
404
405        // Modify workspace
406        fs::write(root.join("file1.txt"), "modified").unwrap();
407        fs::write(root.join("new.txt"), "new file").unwrap();
408
409        // Diff working tree against commit
410        let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects")).unwrap();
411        let store = crate::store::FsStore::new(objects_dir).unwrap();
412
413        let commit_cid = result.commit_cid.to_void_cid().unwrap();
414        let diff = diff_working(&store, &vault, &commit_cid, &root).unwrap();
415
416        // Should have: 1 added (new.txt), 1 modified (file1.txt)
417        let stats = diff.stats();
418        assert_eq!(stats.added, 1);
419        assert_eq!(stats.modified, 1);
420        assert_eq!(stats.deleted, 0);
421    }
422}