Skip to main content

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}