void_core/index/workspace.rs
1//! Stage/unstage/query operations on the workspace index.
2
3use camino::Utf8Path;
4use serde::{Deserialize, Serialize};
5use void_crypto::CommitCid;
6
7use crate::Result;
8
9use super::entry::index_entry_from_file;
10use super::types::IndexEntry;
11
12/// Workspace index snapshot.
13#[derive(Serialize, Deserialize, Debug, Clone)]
14pub struct WorkspaceIndex {
15 pub version: u32,
16 pub commit_cid: Option<CommitCid>,
17 pub entries: Vec<IndexEntry>,
18}
19
20impl WorkspaceIndex {
21 pub const VERSION: u32 = 1;
22
23 pub fn new(commit_cid: Option<CommitCid>, mut entries: Vec<IndexEntry>) -> Self {
24 entries.sort_by(|a, b| a.path.cmp(&b.path));
25 Self {
26 version: Self::VERSION,
27 commit_cid,
28 entries,
29 }
30 }
31
32 /// Create an empty index with no entries.
33 pub fn empty() -> Self {
34 Self {
35 version: Self::VERSION,
36 commit_cid: None,
37 entries: Vec::new(),
38 }
39 }
40
41 /// Stage a file (add to index from worktree).
42 ///
43 /// Reads the file from disk, computes its hash and metadata, and adds/updates
44 /// the entry in the index.
45 ///
46 /// # Arguments
47 /// * `base` - The workspace root directory
48 /// * `rel_path` - The relative path of the file to stage
49 ///
50 /// # Errors
51 /// Returns an error if the file cannot be read.
52 pub fn stage_file(&mut self, base: &Utf8Path, rel_path: &str) -> Result<()> {
53 let entry = index_entry_from_file(base, rel_path)?;
54 self.upsert_entry(entry);
55 Ok(())
56 }
57
58 /// Insert or replace a precomputed entry.
59 pub fn upsert_entry(&mut self, entry: IndexEntry) {
60 match self
61 .entries
62 .binary_search_by(|e| e.path.as_str().cmp(entry.path.as_str()))
63 {
64 Ok(idx) => {
65 self.entries[idx] = entry;
66 }
67 Err(idx) => {
68 self.entries.insert(idx, entry);
69 }
70 }
71 }
72
73 /// Unstage a file (reset to HEAD state or remove if new).
74 ///
75 /// If `head_entry` is `Some`, replaces the current entry with the HEAD version.
76 /// If `head_entry` is `None`, removes the entry from the index entirely.
77 ///
78 /// # Arguments
79 /// * `rel_path` - The relative path of the file to unstage
80 /// * `head_entry` - The entry from HEAD commit, or None if the file is new
81 pub fn unstage_file(&mut self, rel_path: &str, head_entry: Option<&IndexEntry>) -> Result<()> {
82 match self
83 .entries
84 .binary_search_by(|e| e.path.as_str().cmp(rel_path))
85 {
86 Ok(idx) => {
87 if let Some(head) = head_entry {
88 // Replace with HEAD version
89 self.entries[idx] = head.clone();
90 } else {
91 // Remove entirely (file is new)
92 self.entries.remove(idx);
93 }
94 }
95 Err(idx) => {
96 // Entry not in index; if HEAD has it, add it back
97 if let Some(head) = head_entry {
98 self.entries.insert(idx, head.clone());
99 }
100 // Otherwise nothing to do
101 }
102 }
103
104 Ok(())
105 }
106
107 /// Get entry by path.
108 pub fn get(&self, path: &str) -> Option<&IndexEntry> {
109 self.entries
110 .binary_search_by(|e| e.path.as_str().cmp(path))
111 .ok()
112 .map(|idx| &self.entries[idx])
113 }
114
115 /// Check if index has uncommitted changes vs HEAD.
116 ///
117 /// Returns `true` if:
118 /// - Any entry in the index differs from HEAD (by path or content_hash)
119 /// - Any entry exists in HEAD but not in the index
120 /// - Any entry exists in the index but not in HEAD
121 pub fn has_staged_changes(&self, head_entries: &[IndexEntry]) -> bool {
122 if self.entries.len() != head_entries.len() {
123 return true;
124 }
125
126 // Both are sorted by path, so we can do a linear comparison
127 self.entries
128 .iter()
129 .zip(head_entries.iter())
130 .any(|(a, b)| a.path != b.path || a.content_hash != b.content_hash)
131 }
132
133 /// Iterate over all entries.
134 pub fn iter(&self) -> impl Iterator<Item = &IndexEntry> {
135 self.entries.iter()
136 }
137
138 /// Get the number of entries in the index.
139 pub fn len(&self) -> usize {
140 self.entries.len()
141 }
142
143 /// Check if the index is empty.
144 pub fn is_empty(&self) -> bool {
145 self.entries.is_empty()
146 }
147}