Skip to main content

void_core/
stash.rs

1//! Stash module - save and restore working tree changes
2//!
3//! Stash allows saving the current working tree state (both staged and unstaged changes)
4//! to a special commit, then restoring it later. The stash stack is stored as:
5//! - `.void/stash/meta.bin` - Encrypted metadata (messages, timestamps, etc.)
6//! - `.void/refs/stash/<index>` - Commit CIDs for each stash entry
7
8use std::fs;
9use std::path::Path;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13
14use void_crypto::{CommitCid, EncryptedStash};
15
16use crate::crypto;
17use crate::{cid, Result};
18
19const STASH_DIR: &str = "stash";
20const STASH_META_FILE: &str = "meta.bin";
21const REFS_STASH_DIR: &str = "refs/stash";
22
23/// A single stash entry
24#[derive(Serialize, Deserialize, Debug, Clone)]
25pub struct StashEntry {
26    /// Index in the stash stack (0 = most recent)
27    pub index: u32,
28    /// CID of the stash commit
29    pub commit_cid: CommitCid,
30    /// CID of HEAD when stash was created
31    pub original_head: CommitCid,
32    /// Optional message describing the stash
33    pub message: Option<String>,
34    /// Unix timestamp when stash was created
35    pub timestamp: u64,
36}
37
38/// The stash stack containing all stash entries
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub struct StashStack {
41    /// Schema version for forward compatibility
42    pub version: u32,
43    /// Stash entries, ordered by index (0 = most recent)
44    pub entries: Vec<StashEntry>,
45}
46
47impl StashStack {
48    pub const VERSION: u32 = 1;
49
50    /// Create a new empty stash stack
51    pub fn new() -> Self {
52        Self {
53            version: Self::VERSION,
54            entries: Vec::new(),
55        }
56    }
57
58    /// Get the number of stash entries
59    pub fn len(&self) -> usize {
60        self.entries.len()
61    }
62
63    /// Check if the stash stack is empty
64    pub fn is_empty(&self) -> bool {
65        self.entries.is_empty()
66    }
67
68    /// Get a stash entry by index
69    pub fn get(&self, index: u32) -> Option<&StashEntry> {
70        self.entries.iter().find(|e| e.index == index)
71    }
72
73    /// Push a new entry onto the stack
74    ///
75    /// This shifts all existing indices up by 1.
76    pub fn push(&mut self, commit_cid: CommitCid, original_head: CommitCid, message: Option<String>) {
77        // Shift all existing indices up
78        for entry in &mut self.entries {
79            entry.index += 1;
80        }
81
82        let timestamp = SystemTime::now()
83            .duration_since(UNIX_EPOCH)
84            .map(|d| d.as_secs())
85            .unwrap_or(0);
86
87        self.entries.insert(
88            0,
89            StashEntry {
90                index: 0,
91                commit_cid,
92                original_head,
93                message,
94                timestamp,
95            },
96        );
97    }
98
99    /// Remove and return an entry by index
100    ///
101    /// Entries with higher indices are shifted down.
102    pub fn remove(&mut self, index: u32) -> Option<StashEntry> {
103        let pos = self.entries.iter().position(|e| e.index == index)?;
104        let entry = self.entries.remove(pos);
105
106        // Shift indices down for entries that were after the removed one
107        for e in &mut self.entries {
108            if e.index > index {
109                e.index -= 1;
110            }
111        }
112
113        Some(entry)
114    }
115
116    /// Drop an entry without returning it
117    pub fn drop(&mut self, index: u32) -> bool {
118        self.remove(index).is_some()
119    }
120}
121
122impl Default for StashStack {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128// ============================================================================
129// File I/O for stash stack
130// ============================================================================
131
132fn stash_meta_path(void_dir: impl AsRef<Path>) -> std::path::PathBuf {
133    void_dir.as_ref().join(STASH_DIR).join(STASH_META_FILE)
134}
135
136fn stash_ref_path(void_dir: impl AsRef<Path>, index: u32) -> std::path::PathBuf {
137    void_dir.as_ref().join(REFS_STASH_DIR).join(index.to_string())
138}
139
140/// Read the stash stack from disk
141///
142/// Returns an empty stack if the stash file doesn't exist.
143pub fn read_stash_stack(void_dir: impl AsRef<Path>, key: &crypto::SecretKey) -> Result<StashStack> {
144    let void_dir = void_dir.as_ref();
145    let path = stash_meta_path(void_dir);
146    if !path.exists() {
147        return Ok(StashStack::new());
148    }
149
150    let raw = fs::read(&path)?;
151    let blob = EncryptedStash::from_bytes(raw);
152    Ok(blob.decrypt_and_parse(key.as_bytes())?)
153}
154
155/// Write the stash stack to disk
156pub fn write_stash_stack(void_dir: impl AsRef<Path>, key: &crypto::SecretKey, stack: &StashStack) -> Result<()> {
157    let void_dir = void_dir.as_ref();
158    let stash_dir = void_dir.join(STASH_DIR);
159    fs::create_dir_all(&stash_dir)?;
160
161    let bytes = crate::support::cbor_to_vec(stack)?;
162    let blob = EncryptedStash::encrypt(key.as_bytes(), &bytes)?;
163
164    let path = stash_meta_path(void_dir);
165    let temp_path = path.with_extension("tmp");
166    fs::write(&temp_path, blob.as_bytes())?;
167    fs::rename(&temp_path, &path)?;
168
169    // Also write ref files for each entry
170    let refs_dir = void_dir.join(REFS_STASH_DIR);
171    fs::create_dir_all(&refs_dir)?;
172
173    // Clean up old ref files
174    if refs_dir.exists() {
175        for entry in fs::read_dir(&refs_dir)? {
176            let entry = entry?;
177            fs::remove_file(entry.path())?;
178        }
179    }
180
181    // Write current ref files
182    for entry in &stack.entries {
183        let ref_path = stash_ref_path(void_dir, entry.index);
184        let cid_obj = cid::from_bytes(entry.commit_cid.as_bytes())?;
185        fs::write(&ref_path, format!("{}\n", cid_obj))?;
186    }
187
188    Ok(())
189}
190
191/// Clear all stash entries
192pub fn clear_stash(void_dir: impl AsRef<Path>, key: &crypto::SecretKey) -> Result<()> {
193    write_stash_stack(void_dir, key, &StashStack::new())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tempfile::TempDir;
200
201    fn setup() -> (TempDir, std::path::PathBuf, crypto::SecretKey) {
202        let temp = TempDir::new().unwrap();
203        let void_dir = temp.path().join(".void");
204        fs::create_dir_all(&void_dir).unwrap();
205        let key = crypto::SecretKey::new(crypto::generate_key());
206        (temp, void_dir, key)
207    }
208
209    fn make_cid(data: &[u8]) -> CommitCid {
210        let cid_obj = cid::create(data);
211        CommitCid::from_bytes(cid::to_bytes(&cid_obj))
212    }
213
214    #[test]
215    fn test_stash_stack_new() {
216        let stack = StashStack::new();
217        assert!(stack.is_empty());
218        assert_eq!(stack.len(), 0);
219        assert_eq!(stack.version, StashStack::VERSION);
220    }
221
222    #[test]
223    fn test_stash_stack_push() {
224        let mut stack = StashStack::new();
225
226        let cid1 = make_cid(b"commit1");
227        let head1 = make_cid(b"head1");
228        stack.push(cid1.clone(), head1.clone(), Some("first".to_string()));
229
230        assert_eq!(stack.len(), 1);
231        let entry = stack.get(0).unwrap();
232        assert_eq!(entry.commit_cid, cid1);
233        assert_eq!(entry.original_head, head1);
234        assert_eq!(entry.message, Some("first".to_string()));
235
236        // Push another
237        let cid2 = make_cid(b"commit2");
238        let head2 = make_cid(b"head2");
239        stack.push(cid2.clone(), head2.clone(), None);
240
241        assert_eq!(stack.len(), 2);
242
243        // Index 0 should be the newest
244        let newest = stack.get(0).unwrap();
245        assert_eq!(newest.commit_cid, cid2);
246
247        // Index 1 should be the oldest
248        let oldest = stack.get(1).unwrap();
249        assert_eq!(oldest.commit_cid, cid1);
250    }
251
252    #[test]
253    fn test_stash_stack_remove() {
254        let mut stack = StashStack::new();
255
256        stack.push(make_cid(b"c1"), make_cid(b"h1"), Some("first".to_string()));
257        stack.push(make_cid(b"c2"), make_cid(b"h2"), Some("second".to_string()));
258        stack.push(make_cid(b"c3"), make_cid(b"h3"), Some("third".to_string()));
259
260        // Remove middle entry (index 1)
261        let removed = stack.remove(1).unwrap();
262        assert_eq!(removed.message, Some("second".to_string()));
263
264        assert_eq!(stack.len(), 2);
265
266        // Index 0 should still be the newest
267        assert_eq!(stack.get(0).unwrap().message, Some("third".to_string()));
268
269        // What was index 2 should now be index 1
270        assert_eq!(stack.get(1).unwrap().message, Some("first".to_string()));
271    }
272
273    #[test]
274    fn test_stash_stack_drop() {
275        let mut stack = StashStack::new();
276        let cid1 = make_cid(b"c1");
277        let cid2 = make_cid(b"c2");
278        stack.push(cid1.clone(), make_cid(b"h1"), None);
279        stack.push(cid2.clone(), make_cid(b"h2"), None);
280
281        assert!(stack.drop(0));
282        assert_eq!(stack.len(), 1);
283        assert_eq!(stack.get(0).unwrap().commit_cid, cid1);
284
285        assert!(!stack.drop(5)); // Non-existent
286    }
287
288    #[test]
289    fn test_stash_roundtrip() {
290        let (_temp, void_dir, key) = setup();
291
292        let cid1 = make_cid(b"commit1");
293        let cid2 = make_cid(b"commit2");
294
295        let mut stack = StashStack::new();
296        stack.push(
297            cid1.clone(),
298            make_cid(b"head1"),
299            Some("test stash".to_string()),
300        );
301        stack.push(cid2.clone(), make_cid(b"head2"), None);
302
303        write_stash_stack(&void_dir, &key, &stack).unwrap();
304        let loaded = read_stash_stack(&void_dir, &key).unwrap();
305
306        assert_eq!(loaded.len(), 2);
307        assert_eq!(loaded.get(0).unwrap().commit_cid, cid2);
308        assert_eq!(
309            loaded.get(1).unwrap().message,
310            Some("test stash".to_string())
311        );
312    }
313
314    #[test]
315    fn test_read_empty_stash() {
316        let (_temp, void_dir, key) = setup();
317
318        let stack = read_stash_stack(&void_dir, &key).unwrap();
319        assert!(stack.is_empty());
320    }
321
322    #[test]
323    fn test_clear_stash() {
324        let (_temp, void_dir, key) = setup();
325
326        let mut stack = StashStack::new();
327        stack.push(make_cid(b"c1"), make_cid(b"h1"), None);
328        write_stash_stack(&void_dir, &key, &stack).unwrap();
329
330        clear_stash(&void_dir, &key).unwrap();
331
332        let loaded = read_stash_stack(&void_dir, &key).unwrap();
333        assert!(loaded.is_empty());
334    }
335}