Skip to main content

mkit_core/ops/
stash.rs

1//! Stash.
2//!
3//! On-disk format (`<repo_root>/.mkit/stash`) is a tagged binary
4//! manifest:
5//!
6//! ```text
7//! magic   : 4   bytes  "MKST"
8//! count   : u32 LE
9//! entries : count *
10//!     commit_hash  : 32 bytes
11//!     parent_hash  : 32 bytes
12//!     timestamp    : u32 LE (Unix seconds, saturating)
13//!     msg_len      : u16 LE
14//!     message      : msg_len bytes
15//! ```
16//!
17//! New stashes are prepended (LIFO).
18
19use std::fmt::Write as _;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use crate::atomic;
26use crate::hash::{Hash, ZERO};
27use crate::index::{self, Index};
28use crate::object::{Commit, Identity, Object};
29use crate::ops::diff::{DiffKind, DiffResult, diff_trees};
30use crate::ops::restore::{self, RestoreOptions};
31use crate::refs;
32use crate::serialize;
33use crate::store::{MKIT_DIR, ObjectStore};
34use crate::worktree;
35
36/// Magic bytes for the stash manifest: `MKST` ("`MKit` `STash`").
37pub const MAGIC: [u8; 4] = *b"MKST";
38
39/// Stash manifest path under the repo root.
40pub const STASH_FILE: &str = ".mkit/stash";
41
42/// Hard cap on manifest size (16 MiB).
43pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
44
45/// Maximum stash message length (`u16` on the wire).
46pub const MAX_MESSAGE_LEN: usize = u16::MAX as usize;
47
48/// Minimum on-wire entry size, used to sanity-check attacker-supplied
49/// `count` up-front during deserialise (SEC finding G12). Layout:
50/// `commit_hash` (32) + `parent_hash` (32) + `timestamp` (4) +
51/// `msg_len` (2) + message (0).
52const MIN_ENTRY_BYTES: u64 = 32 + 32 + 4 + 2;
53
54/// One entry in the stash stack.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StashEntry {
57    pub commit_hash: Hash,
58    pub parent_hash: Hash,
59    pub timestamp: u32,
60    pub message: String,
61}
62
63/// The full stash stack (newest first).
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct StashList {
66    pub entries: Vec<StashEntry>,
67}
68
69/// Errors raised by this module.
70#[derive(Debug, thiserror::Error)]
71pub enum StashError {
72    #[error("stash index {0} is out of range")]
73    IndexOutOfRange(usize),
74    #[error("stash manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit")]
75    ManifestTooLarge,
76    #[error("stash manifest format is invalid")]
77    InvalidFormat,
78    #[error("stash message exceeds {MAX_MESSAGE_LEN} bytes")]
79    MessageTooLong,
80    #[error("stash commit object is not a Commit")]
81    NotACommit,
82    #[error(transparent)]
83    Diff(#[from] crate::store::StoreError),
84    #[error(transparent)]
85    Object(#[from] crate::object::MkitError),
86    #[error(transparent)]
87    Refs(#[from] crate::refs::RefError),
88    #[error(transparent)]
89    Index(#[from] crate::index::IndexError),
90    #[error(transparent)]
91    Worktree(#[from] crate::worktree::WorktreeError),
92    #[error(transparent)]
93    Restore(#[from] crate::ops::restore::RestoreError),
94    #[error(transparent)]
95    Io(#[from] io::Error),
96}
97
98/// Result alias.
99pub type StashResult<T> = Result<T, StashError>;
100
101/// Save the worktree as a stash entry, then reset the worktree to
102/// HEAD:
103///
104/// 1. Build a tree from `repo_root` (skipping `.mkit/`).
105/// 2. Resolve HEAD to a parent (or none for first commit).
106/// 3. Create an unsigned `Commit` over that tree with `Ed25519` zero
107///    pubkey author and zeroed signer/signature.
108/// 4. Prepend a new [`StashEntry`] to the manifest.
109/// 5. Restore the worktree to HEAD's tree.
110/// 6. Truncate the index.
111pub fn save(store: &ObjectStore, repo_root: &Path, message: &str) -> StashResult<()> {
112    if message.len() > MAX_MESSAGE_LEN {
113        return Err(StashError::MessageTooLong);
114    }
115    let mkit_dir = repo_root.join(MKIT_DIR);
116
117    // One durability batch over the worktree snapshot + stash commit,
118    // committed before the manifest write that references them.
119    let batch = store.batch();
120    let tree_hash = worktree::build_tree(&batch, repo_root)?;
121    let head_hash = refs::resolve_head(&mkit_dir)?;
122
123    let timestamp_u64 = unix_seconds_now();
124    let parents = head_hash.into_iter().collect::<Vec<_>>();
125    let zero_pk = [0u8; 32];
126    let commit = Object::Commit(Commit::new_unannotated(
127        tree_hash,
128        parents,
129        Identity::ed25519(zero_pk),
130        [0u8; 32],
131        message.as_bytes().to_vec(),
132        timestamp_u64,
133        [0u8; 64],
134    ));
135    let commit_bytes = serialize::serialize(&commit)?;
136    let stash_hash = batch.write(&commit_bytes)?;
137    batch.commit()?;
138
139    // Prepend the new entry.
140    let mut list = read_list(repo_root)?;
141    let ts_u32: u32 = timestamp_u64.try_into().unwrap_or(u32::MAX);
142    let new_entry = StashEntry {
143        commit_hash: stash_hash,
144        parent_hash: head_hash.unwrap_or(ZERO),
145        timestamp: ts_u32,
146        message: message.to_string(),
147    };
148    list.entries.insert(0, new_entry);
149    write_list(repo_root, &list)?;
150
151    // Restore the worktree to HEAD's tree (if any).
152    if let Some(hh) = head_hash {
153        let head_obj = store.read_object(&hh)?;
154        if let Object::Commit(c) = head_obj {
155            restore::restore_tree(store, c.tree_hash, repo_root, &RestoreOptions::default())?;
156        }
157    }
158
159    // Clear the index.
160    let _ = index::write_index(repo_root, &Index::new());
161    Ok(())
162}
163
164/// List all stashes (newest first).
165///
166/// # Errors
167/// - [`StashError::ManifestTooLarge`] / [`StashError::InvalidFormat`]
168///   for a corrupt or oversized manifest.
169pub fn list(repo_root: &Path) -> StashResult<StashList> {
170    read_list(repo_root)
171}
172
173/// Resolve the tree hash recorded by stash entry `idx` (newest = 0)
174/// without mutating anything. Callers use this to run a restore-safety
175/// pre-flight (the #176 guard) over the stash tree before [`pop`].
176///
177/// # Errors
178/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
179/// - [`StashError::NotACommit`] if the stored object is not a Commit.
180pub fn entry_tree_hash(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<Hash> {
181    let list = read_list(repo_root)?;
182    if idx >= list.entries.len() {
183        return Err(StashError::IndexOutOfRange(idx));
184    }
185    let obj = store.read_object(&list.entries[idx].commit_hash)?;
186    let Object::Commit(commit) = obj else {
187        return Err(StashError::NotACommit);
188    };
189    Ok(commit.tree_hash)
190}
191
192/// Pop a stash: restore its tree into the worktree and remove the
193/// entry. Index 0 = newest.
194///
195/// # Safety against data loss
196/// This restores **unconditionally** — it does not itself run the #176
197/// destructive-restore guard, because that guard lives in the CLI layer
198/// (`commands::ensure_restore_safe`). Callers that expose `pop` to users
199/// **must** run [`entry_tree_hash`] + the guard first so uncommitted
200/// edits on unrelated paths are never clobbered. The stash entry is
201/// dropped only after a successful restore (restore failure short-
202/// circuits via `?`, leaving the entry in place for a retry).
203///
204/// # Errors
205/// - [`StashError::IndexOutOfRange`] if `index` is past the end.
206pub fn pop(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<()> {
207    let mut list = read_list(repo_root)?;
208    if idx >= list.entries.len() {
209        return Err(StashError::IndexOutOfRange(idx));
210    }
211    let entry = list.entries[idx].clone();
212    let obj = store.read_object(&entry.commit_hash)?;
213    let Object::Commit(commit) = obj else {
214        return Err(StashError::NotACommit);
215    };
216    // Record the popped commit in the recovery log BEFORE removing the
217    // manifest entry: restored worktree files are not crash-durable
218    // (ops/restore writes them unflushed), and the manifest entry is
219    // the stash commit's only gc root. Without this, a power loss in
220    // the writeback window could lose both the restored bytes and the
221    // only pointer for re-running the restore. The recovery log is a
222    // durable gc root (SPEC-GC), so the commit stays reachable and
223    // recoverable until the grace window expires.
224    let ts = unix_seconds_now();
225    crate::ops::recovery::record(
226        &repo_root.join(MKIT_DIR),
227        &crate::ops::recovery::RecoveryEntry {
228            timestamp: ts,
229            op: "stash-pop".to_string(),
230            superseded: entry.commit_hash,
231            branch: String::new(),
232        },
233    )
234    .map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
235    restore::restore_tree(
236        store,
237        commit.tree_hash,
238        repo_root,
239        &RestoreOptions::default(),
240    )?;
241    list.entries.remove(idx);
242    write_list(repo_root, &list)?;
243    Ok(())
244}
245
246/// Apply a stash entry's tree to the worktree **without** removing the
247/// entry. Index 0 = newest. This is the non-destructive complement to
248/// [`pop`]: it leaves the stash stack untouched so the same entry can be
249/// re-applied or popped later.
250///
251/// # Safety against data loss
252/// Like [`pop`], this restores **unconditionally** — it does not run the
253/// #176 destructive-restore guard itself. Callers exposing `apply` to
254/// users **must** run [`entry_tree_hash`] + `ensure_restore_safe` first,
255/// exactly as `pop` does, so uncommitted edits on unrelated paths are
256/// never clobbered.
257///
258/// # Errors
259/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
260/// - [`StashError::NotACommit`] if the stored object is not a Commit.
261pub fn apply(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<()> {
262    let list = read_list(repo_root)?;
263    if idx >= list.entries.len() {
264        return Err(StashError::IndexOutOfRange(idx));
265    }
266    let entry = &list.entries[idx];
267    let obj = store.read_object(&entry.commit_hash)?;
268    let Object::Commit(commit) = obj else {
269        return Err(StashError::NotACommit);
270    };
271    restore::restore_tree(
272        store,
273        commit.tree_hash,
274        repo_root,
275        &RestoreOptions::default(),
276    )?;
277    Ok(())
278}
279
280/// Drop **all** stash entries, leaving an empty stack. Idempotent — a
281/// missing or already-empty manifest is not an error.
282///
283/// # Errors
284/// - [`StashError::Io`] if the manifest cannot be written.
285pub fn clear(repo_root: &Path) -> StashResult<()> {
286    write_list(repo_root, &StashList::default())
287}
288
289/// Render `stash show [<stash>]` output: header + unified-diff-style listing.
290///
291/// Output format:
292/// ```text
293/// stash@{<idx>}: <message>
294/// Date: <unix-timestamp>
295///
296/// <A|M|D> <path>
297/// ...
298/// ```
299///
300/// # Errors
301/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
302/// - [`StashError::NotACommit`] if the stored object is not a Commit.
303/// - Store/object errors if objects cannot be read.
304pub fn render_stash_show(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<String> {
305    let list = read_list(repo_root)?;
306    if idx >= list.entries.len() {
307        return Err(StashError::IndexOutOfRange(idx));
308    }
309    let entry = &list.entries[idx];
310
311    // Load the stash commit to get its tree.
312    let stash_obj = store.read_object(&entry.commit_hash)?;
313    let Object::Commit(stash_commit) = stash_obj else {
314        return Err(StashError::NotACommit);
315    };
316
317    // Resolve parent tree (None if parent is the zero hash or commit load fails).
318    let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
319        None
320    } else {
321        match store.read_object(&entry.parent_hash) {
322            Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
323            _ => None,
324        }
325    };
326
327    let diff = diff_trees(store, parent_tree, Some(stash_commit.tree_hash))?;
328
329    let mut out = String::new();
330    let _ = writeln!(out, "stash@{{{idx}}}: {}", entry.message);
331    let _ = writeln!(out, "Date: {}", entry.timestamp);
332    let _ = writeln!(out);
333    for e in &diff.entries {
334        let tag = match e.kind {
335            DiffKind::Added => "A",
336            DiffKind::Removed => "D",
337            DiffKind::Modified => "M",
338            DiffKind::ModeChanged => "T",
339        };
340        let _ = writeln!(out, "{tag} {}", e.path);
341    }
342    Ok(out)
343}
344
345/// Show the diff for a stash entry (raw [`DiffResult`]).
346///
347/// # Errors
348/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
349/// - [`StashError::NotACommit`] if the stash commit object is not a Commit.
350pub fn show_diff(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<DiffResult> {
351    let list = read_list(repo_root)?;
352    if idx >= list.entries.len() {
353        return Err(StashError::IndexOutOfRange(idx));
354    }
355    let entry = &list.entries[idx];
356
357    let stash_obj = store.read_object(&entry.commit_hash)?;
358    let Object::Commit(stash_commit) = stash_obj else {
359        return Err(StashError::NotACommit);
360    };
361
362    let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
363        None
364    } else {
365        match store.read_object(&entry.parent_hash) {
366            Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
367            _ => None,
368        }
369    };
370
371    Ok(diff_trees(
372        store,
373        parent_tree,
374        Some(stash_commit.tree_hash),
375    )?)
376}
377
378/// Drop a stash without applying it.
379///
380/// # Errors
381/// - [`StashError::IndexOutOfRange`] if `index` is past the end.
382pub fn drop(repo_root: &Path, idx: usize) -> StashResult<()> {
383    let mut list = read_list(repo_root)?;
384    if idx >= list.entries.len() {
385        return Err(StashError::IndexOutOfRange(idx));
386    }
387    list.entries.remove(idx);
388    write_list(repo_root, &list)?;
389    Ok(())
390}
391
392// -- Manifest IO -------------------------------------------------------------
393
394fn stash_path(repo_root: &Path) -> PathBuf {
395    repo_root.join(STASH_FILE)
396}
397
398fn read_list(repo_root: &Path) -> StashResult<StashList> {
399    let path = stash_path(repo_root);
400    let meta = match fs::metadata(&path) {
401        Ok(m) => m,
402        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(StashList::default()),
403        Err(e) => return Err(StashError::Io(e)),
404    };
405    if meta.len() == 0 {
406        return Ok(StashList::default());
407    }
408    if meta.len() > MAX_MANIFEST_BYTES {
409        return Err(StashError::ManifestTooLarge);
410    }
411    let data = fs::read(&path)?;
412    deserialize_list(&data)
413}
414
415fn write_list(repo_root: &Path, list: &StashList) -> StashResult<()> {
416    let bytes = serialize_list(list)?;
417    let path = stash_path(repo_root);
418    atomic::write_atomic(&path, &bytes, true)?;
419    Ok(())
420}
421
422/// Write a [`StashList`] to disk. Public only for integration-test goldens.
423///
424/// # Panics
425/// Panics if serialization or the write fails (test-only helper).
426pub fn write_list_test_only(repo_root: &Path, list: &StashList) {
427    write_list(repo_root, list).expect("write_list_test_only failed");
428}
429
430/// Encode a [`StashList`] as the on-disk manifest. Public for goldens.
431///
432/// # Errors
433/// - [`StashError::MessageTooLong`] if any entry message exceeds [`MAX_MESSAGE_LEN`].
434pub fn serialize_list(list: &StashList) -> StashResult<Vec<u8>> {
435    let mut total = 4 + 4;
436    for e in &list.entries {
437        if e.message.len() > MAX_MESSAGE_LEN {
438            return Err(StashError::MessageTooLong);
439        }
440        total += 32 + 32 + 4 + 2 + e.message.len();
441    }
442    let mut out = Vec::with_capacity(total);
443    out.extend_from_slice(&MAGIC);
444    out.extend_from_slice(
445        &u32::try_from(list.entries.len())
446            .unwrap_or(u32::MAX)
447            .to_le_bytes(),
448    );
449    for e in &list.entries {
450        out.extend_from_slice(&e.commit_hash);
451        out.extend_from_slice(&e.parent_hash);
452        out.extend_from_slice(&e.timestamp.to_le_bytes());
453        let len_u16 = u16::try_from(e.message.len()).map_err(|_| StashError::MessageTooLong)?;
454        out.extend_from_slice(&len_u16.to_le_bytes());
455        out.extend_from_slice(e.message.as_bytes());
456    }
457    Ok(out)
458}
459
460/// Decode the on-disk manifest. Public for goldens.
461///
462/// # Errors
463/// - [`StashError::InvalidFormat`] if the bytes are malformed.
464///
465/// # Panics
466/// Panics only on internal invariant violation: each `try_into` on a
467/// 4-byte / 2-byte slice we just bounds-checked cannot fail.
468pub fn deserialize_list(data: &[u8]) -> StashResult<StashList> {
469    if data.len() < 8 {
470        return Err(StashError::InvalidFormat);
471    }
472    if &data[..4] != MAGIC.as_slice() {
473        return Err(StashError::InvalidFormat);
474    }
475    let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
476    // Reject an attacker-supplied `count` that cannot possibly fit in
477    // the remaining body. With [`MIN_ENTRY_BYTES`] = 70 (empty message)
478    // an 8-byte header declaring count = u32::MAX is rejected here
479    // instead of driving `Vec::with_capacity(u32::MAX)`. See SEC
480    // finding G12.
481    if (count as u64).saturating_mul(MIN_ENTRY_BYTES) > data.len() as u64 {
482        return Err(StashError::InvalidFormat);
483    }
484    let mut entries = Vec::with_capacity(count);
485    let mut pos = 8usize;
486    for _ in 0..count {
487        if pos + 32 + 32 + 4 + 2 > data.len() {
488            return Err(StashError::InvalidFormat);
489        }
490        let mut commit_hash = [0u8; 32];
491        commit_hash.copy_from_slice(&data[pos..pos + 32]);
492        pos += 32;
493        let mut parent_hash = [0u8; 32];
494        parent_hash.copy_from_slice(&data[pos..pos + 32]);
495        pos += 32;
496        let timestamp = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
497        pos += 4;
498        let msg_len = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()) as usize;
499        pos += 2;
500        if pos + msg_len > data.len() {
501            return Err(StashError::InvalidFormat);
502        }
503        let msg = String::from_utf8(data[pos..pos + msg_len].to_vec())
504            .map_err(|_| StashError::InvalidFormat)?;
505        pos += msg_len;
506        entries.push(StashEntry {
507            commit_hash,
508            parent_hash,
509            timestamp,
510            message: msg,
511        });
512    }
513    Ok(StashList { entries })
514}
515
516fn unix_seconds_now() -> u64 {
517    SystemTime::now()
518        .duration_since(UNIX_EPOCH)
519        .map_or(0, |d| d.as_secs())
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::hash;
526    use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
527    use crate::ops::diff::DiffKind;
528    use crate::serialize;
529    use crate::store::ObjectStore;
530    use tempfile::TempDir;
531
532    fn fresh_store() -> (TempDir, ObjectStore) {
533        let dir = TempDir::new().unwrap();
534        let store = ObjectStore::init(dir.path()).unwrap();
535        (dir, store)
536    }
537
538    fn put_blob_data(store: &ObjectStore, data: &[u8]) -> Hash {
539        let obj = Object::Blob(Blob {
540            data: data.to_vec(),
541        });
542        store.write(&serialize::serialize(&obj).unwrap()).unwrap()
543    }
544
545    fn put_tree_entries(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
546        let obj = Object::Tree(Tree { entries });
547        store.write(&serialize::serialize(&obj).unwrap()).unwrap()
548    }
549
550    fn put_commit_obj(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
551        let commit = Object::Commit(Commit::new_unannotated(
552            tree_h,
553            parents,
554            Identity::ed25519([0u8; 32]),
555            [0u8; 32],
556            b"msg".to_vec(),
557            ts,
558            [0u8; 64],
559        ));
560        store
561            .write(&serialize::serialize(&commit).unwrap())
562            .unwrap()
563    }
564
565    /// Build a deterministic stash fixture: parent commit has `existing.txt`,
566    /// stash commit adds `new.txt` and modifies `existing.txt`.
567    fn build_stash_fixture(store: &ObjectStore, repo_root: &std::path::Path) {
568        // Parent tree: one file "existing.txt"
569        let blob_v1 = put_blob_data(store, b"original content");
570        let parent_tree = put_tree_entries(
571            store,
572            vec![TreeEntry {
573                name: b"existing.txt".to_vec(),
574                mode: EntryMode::Blob,
575                object_hash: blob_v1,
576            }],
577        );
578        let parent_commit = put_commit_obj(store, parent_tree, vec![], 1_000_000);
579
580        // Stash tree: existing.txt modified + new.txt added
581        let blob_v2 = put_blob_data(store, b"modified content");
582        let blob_new = put_blob_data(store, b"brand new file");
583        let stash_tree = put_tree_entries(
584            store,
585            vec![
586                TreeEntry {
587                    name: b"existing.txt".to_vec(),
588                    mode: EntryMode::Blob,
589                    object_hash: blob_v2,
590                },
591                TreeEntry {
592                    name: b"new.txt".to_vec(),
593                    mode: EntryMode::Blob,
594                    object_hash: blob_new,
595                },
596            ],
597        );
598        let stash_commit = put_commit_obj(store, stash_tree, vec![parent_commit], 1_000_001);
599
600        let list = StashList {
601            entries: vec![StashEntry {
602                commit_hash: stash_commit,
603                parent_hash: parent_commit,
604                timestamp: 1_000_001_u32,
605                message: "WIP: stash message".to_string(),
606            }],
607        };
608        write_list(repo_root, &list).unwrap();
609    }
610
611    #[test]
612    fn show_diff_returns_correct_entries() {
613        let (tmp, store) = fresh_store();
614        build_stash_fixture(&store, tmp.path());
615
616        let diff = show_diff(&store, tmp.path(), 0).unwrap();
617        assert_eq!(diff.entries.len(), 2, "expected 2 diff entries");
618
619        let existing = diff.entries.iter().find(|e| e.path == "existing.txt");
620        let new_f = diff.entries.iter().find(|e| e.path == "new.txt");
621        assert!(existing.is_some(), "existing.txt must appear in diff");
622        assert!(new_f.is_some(), "new.txt must appear in diff");
623        assert_eq!(existing.unwrap().kind, DiffKind::Modified);
624        assert_eq!(new_f.unwrap().kind, DiffKind::Added);
625    }
626
627    #[test]
628    fn render_stash_show_header_and_entries() {
629        let (tmp, store) = fresh_store();
630        build_stash_fixture(&store, tmp.path());
631
632        let output = render_stash_show(&store, tmp.path(), 0).unwrap();
633        assert!(
634            output.contains("stash@{0}:"),
635            "missing stash header: {output}"
636        );
637        assert!(
638            output.contains("WIP: stash message"),
639            "missing message: {output}"
640        );
641        assert!(output.contains("Date:"), "missing date line: {output}");
642        assert!(
643            output.contains("M existing.txt"),
644            "missing modified entry: {output}"
645        );
646        assert!(
647            output.contains("A new.txt"),
648            "missing added entry: {output}"
649        );
650    }
651
652    #[test]
653    fn apply_restores_tree_and_keeps_entry() {
654        let (tmp, store) = fresh_store();
655        build_stash_fixture(&store, tmp.path());
656        assert_eq!(read_list(tmp.path()).unwrap().entries.len(), 1);
657
658        apply(&store, tmp.path(), 0).unwrap();
659
660        // The stash tree was materialised into the worktree.
661        assert_eq!(
662            fs::read(tmp.path().join("existing.txt")).unwrap(),
663            b"modified content"
664        );
665        assert_eq!(
666            fs::read(tmp.path().join("new.txt")).unwrap(),
667            b"brand new file"
668        );
669        // ...and the entry is still on the stack (apply, not pop).
670        assert_eq!(
671            read_list(tmp.path()).unwrap().entries.len(),
672            1,
673            "apply must not drop the entry"
674        );
675    }
676
677    #[test]
678    fn apply_out_of_range_returns_error() {
679        let (tmp, _store) = fresh_store();
680        let store = ObjectStore::open(tmp.path()).unwrap();
681        let err = apply(&store, tmp.path(), 0).unwrap_err();
682        assert!(matches!(err, StashError::IndexOutOfRange(0)));
683    }
684
685    #[test]
686    fn clear_empties_the_stack() {
687        let (tmp, store) = fresh_store();
688        build_stash_fixture(&store, tmp.path());
689        assert_eq!(read_list(tmp.path()).unwrap().entries.len(), 1);
690
691        clear(tmp.path()).unwrap();
692        assert!(read_list(tmp.path()).unwrap().entries.is_empty());
693
694        // Idempotent: clearing an already-empty stack is fine.
695        clear(tmp.path()).unwrap();
696        assert!(read_list(tmp.path()).unwrap().entries.is_empty());
697    }
698
699    #[test]
700    fn show_diff_out_of_range_returns_error() {
701        let (tmp, _store) = fresh_store();
702        let store = ObjectStore::open(tmp.path()).unwrap();
703        let err = show_diff(&store, tmp.path(), 0).unwrap_err();
704        assert!(matches!(err, StashError::IndexOutOfRange(0)));
705    }
706
707    #[test]
708    fn manifest_roundtrip_two_entries() {
709        let list = StashList {
710            entries: vec![
711                StashEntry {
712                    commit_hash: hash::hash(b"commit1"),
713                    parent_hash: hash::hash(b"parent1"),
714                    timestamp: 1000,
715                    message: "first stash".to_string(),
716                },
717                StashEntry {
718                    commit_hash: hash::hash(b"commit2"),
719                    parent_hash: ZERO,
720                    timestamp: 2000,
721                    message: "second stash".to_string(),
722                },
723            ],
724        };
725        let bytes = serialize_list(&list).unwrap();
726        let back = deserialize_list(&bytes).unwrap();
727        assert_eq!(back, list);
728    }
729
730    #[test]
731    fn deserialize_rejects_short_data() {
732        assert!(matches!(
733            deserialize_list(&[0u8; 4]),
734            Err(StashError::InvalidFormat)
735        ));
736    }
737
738    #[test]
739    fn deserialize_rejects_bad_magic() {
740        assert!(matches!(
741            deserialize_list(&[b'X', b'Y', b'Z', b'W', 0, 0, 0, 0]),
742            Err(StashError::InvalidFormat)
743        ));
744    }
745
746    #[test]
747    fn deserialize_rejects_bogus_huge_count() {
748        // G12 regression: an 8-byte manifest whose header declares
749        // count = u32::MAX must be rejected up-front — the decoder
750        // must NOT call `Vec::with_capacity(u32::MAX)` nor loop that
751        // many times.
752        let mut bytes = Vec::new();
753        bytes.extend_from_slice(MAGIC.as_slice());
754        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
755        // No entries follow.
756        assert!(matches!(
757            deserialize_list(&bytes),
758            Err(StashError::InvalidFormat)
759        ));
760    }
761    /// `pop` must record the popped commit in the recovery log BEFORE
762    /// dropping the manifest entry — the only pointer keeping the stash
763    /// commit gc-reachable while the (unflushed) worktree restore is in
764    /// the writeback window.
765    #[test]
766    fn pop_records_recovery_entry_for_popped_commit() {
767        let dir = tempfile::TempDir::new().unwrap();
768        let store = ObjectStore::init(dir.path()).unwrap();
769        std::fs::write(dir.path().join("file.txt"), b"stash me").unwrap();
770        save(&store, dir.path(), "wip").unwrap();
771        let entry_hash = read_list(dir.path()).unwrap().entries[0].commit_hash;
772
773        pop(&store, dir.path(), 0).unwrap();
774
775        let log = crate::ops::recovery::read_all(&dir.path().join(MKIT_DIR)).unwrap();
776        assert!(
777            log.iter()
778                .any(|e| e.op == "stash-pop" && e.superseded == entry_hash),
779            "popped stash commit must be recorded as recoverable; log: {log:?}"
780        );
781        assert!(read_list(dir.path()).unwrap().entries.is_empty());
782    }
783}