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::PathBuf;
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use crate::atomic;
26use crate::hash::{Hash, ZERO};
27use crate::index::{self, Index};
28use crate::layout::RepoLayout;
29use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
30use crate::ops::diff::{DiffKind, DiffResult, diff_trees};
31use crate::ops::restore::{self, RestoreOptions};
32use crate::refs;
33use crate::serialize;
34use crate::store::ObjectStore;
35use crate::worktree;
36
37/// Magic bytes for the stash manifest: `MKST` ("`MKit` `STash`").
38pub const MAGIC: [u8; 4] = *b"MKST";
39
40/// Stash manifest path under the repo root.
41pub const STASH_FILE: &str = ".mkit/stash";
42
43/// Hard cap on manifest size (16 MiB).
44pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
45
46/// Maximum stash message length (`u16` on the wire).
47pub const MAX_MESSAGE_LEN: usize = u16::MAX as usize;
48
49/// Minimum on-wire entry size, used to sanity-check attacker-supplied
50/// `count` up-front during deserialise (so a tiny buffer declaring a huge
51/// `count` cannot drive a large pre-allocation). Layout:
52/// `commit_hash` (32) + `parent_hash` (32) + `timestamp` (4) +
53/// `msg_len` (2) + message (0).
54const MIN_ENTRY_BYTES: u64 = 32 + 32 + 4 + 2;
55
56/// One entry in the stash stack.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct StashEntry {
59    pub commit_hash: Hash,
60    pub parent_hash: Hash,
61    pub timestamp: u32,
62    pub message: String,
63}
64
65/// The full stash stack (newest first).
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct StashList {
68    pub entries: Vec<StashEntry>,
69}
70
71/// Errors raised by this module.
72#[derive(Debug, thiserror::Error)]
73pub enum StashError {
74    #[error("stash index {0} is out of range")]
75    IndexOutOfRange(usize),
76    #[error("stash manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit")]
77    ManifestTooLarge,
78    #[error("stash manifest format is invalid")]
79    InvalidFormat,
80    #[error("stash message exceeds {MAX_MESSAGE_LEN} bytes")]
81    MessageTooLong,
82    #[error("stash commit object is not a Commit")]
83    NotACommit,
84    #[error(transparent)]
85    Diff(#[from] crate::store::StoreError),
86    #[error(transparent)]
87    Object(#[from] crate::object::MkitError),
88    #[error(transparent)]
89    Refs(#[from] crate::refs::RefError),
90    #[error(transparent)]
91    Index(#[from] crate::index::IndexError),
92    #[error(transparent)]
93    Worktree(#[from] crate::worktree::WorktreeError),
94    #[error(transparent)]
95    Restore(#[from] crate::ops::restore::RestoreError),
96    #[error(transparent)]
97    Io(#[from] io::Error),
98}
99
100/// Result alias.
101pub type StashResult<T> = Result<T, StashError>;
102
103/// Save the worktree as a stash entry, then reset the worktree to
104/// HEAD:
105///
106/// 1. Build a tree from `repo_root` (skipping `.mkit/`).
107/// 2. Resolve HEAD to a parent (or none for first commit).
108/// 3. Create an unsigned `Commit` over that tree with `Ed25519` zero
109///    pubkey author and zeroed signer/signature.
110/// 4. Prepend a new [`StashEntry`] to the manifest.
111/// 5. Restore the worktree to HEAD's tree.
112/// 6. Truncate the index.
113pub fn save(store: &ObjectStore, layout: &RepoLayout, message: &str) -> StashResult<()> {
114    if message.len() > MAX_MESSAGE_LEN {
115        return Err(StashError::MessageTooLong);
116    }
117
118    // One durability batch over the worktree snapshot, the staged-index
119    // snapshot, and both commits — committed before the manifest write
120    // that references them.
121    let staged = index::read_index(layout)?;
122    let batch = store.batch();
123    let tree_hash = worktree::build_tree_filtered(&batch, layout.worktree_root(), Some(&staged))?;
124    let head_hash = refs::resolve_head(layout)?;
125
126    let timestamp_u64 = unix_seconds_now();
127    let zero_pk = [0u8; 32];
128
129    // Capture the staged index as its own commit and record it as the
130    // stash commit's SECOND parent (git-style `[HEAD, I]`), so
131    // `stash pop/apply --index` can restore the staged state later. It is
132    // reachable from the stash commit, so `gc` retains it (graph closure
133    // follows every parent). Older single-parent entries simply carry no
134    // index snapshot, and `--index` is a no-op for them.
135    //
136    // The index commit's tree is a fixed two-entry WRAPPER:
137    //   `i` — a blob holding the SERIALIZED index. Unlike a tree, this
138    //         preserves staged DELETIONS (`Removed` entries); a tree can
139    //         only encode present paths.
140    //   `t` — the staged-content tree (`build_tree_from_index`). It keeps the
141    //         blobs of staged present files gc-reachable, since the serialized
142    //         index blob is opaque to the gc graph walk.
143    // The two reserved names can never collide with a tracked path, and this
144    // tree is never materialized into a worktree (only the `i` blob is read
145    // back on `--index`).
146    //
147    // The authoritative index was read before the worktree walk; pass the
148    // discovered layout's staging state rather than guessing a single-worktree
149    // .mkit directory (linked worktrees have a pointer file there).
150    let staged_tree = worktree::build_tree_from_index_with(store, &batch, &staged, true)?;
151    let index_blob = batch.write(&serialize::serialize(&Object::Blob(Blob {
152        data: staged.serialize(),
153    }))?)?;
154    let wrapper = Object::Tree(Tree {
155        entries: vec![
156            TreeEntry {
157                name: b"i".to_vec(),
158                mode: EntryMode::Blob,
159                object_hash: index_blob,
160            },
161            TreeEntry {
162                name: b"t".to_vec(),
163                mode: EntryMode::Tree,
164                object_hash: staged_tree,
165            },
166        ],
167    });
168    let wrapper_hash = batch.write(&serialize::serialize(&wrapper)?)?;
169    let index_parents = head_hash.into_iter().collect::<Vec<_>>();
170    let index_commit = Object::Commit(Commit::new_unannotated(
171        wrapper_hash,
172        index_parents,
173        Identity::ed25519(zero_pk),
174        [0u8; 32],
175        b"index".to_vec(),
176        timestamp_u64,
177        [0u8; 64],
178    ));
179    let index_commit_hash = batch.write(&serialize::serialize(&index_commit)?)?;
180
181    // Worktree commit: parents are `[HEAD, index_commit]` (HEAD omitted
182    // when there is none), so the index snapshot is always the last parent
183    // when a HEAD exists.
184    let mut parents = head_hash.into_iter().collect::<Vec<_>>();
185    parents.push(index_commit_hash);
186    let commit = Object::Commit(Commit::new_unannotated(
187        tree_hash,
188        parents,
189        Identity::ed25519(zero_pk),
190        [0u8; 32],
191        message.as_bytes().to_vec(),
192        timestamp_u64,
193        [0u8; 64],
194    ));
195    let commit_bytes = serialize::serialize(&commit)?;
196    let stash_hash = batch.write(&commit_bytes)?;
197    batch.commit()?;
198
199    // Prepend the new entry.
200    let mut list = read_list(layout)?;
201    let ts_u32: u32 = timestamp_u64.try_into().unwrap_or(u32::MAX);
202    let new_entry = StashEntry {
203        commit_hash: stash_hash,
204        parent_hash: head_hash.unwrap_or(ZERO),
205        timestamp: ts_u32,
206        message: message.to_string(),
207    };
208    list.entries.insert(0, new_entry);
209    write_list(layout, &list)?;
210
211    // Restore the worktree to HEAD's tree. In an UNBORN repo there is no HEAD,
212    // so clear EVERY path captured in the worktree snapshot (tracked AND
213    // untracked) — mirroring the HEAD case, where `restore_tree` removes
214    // everything not in HEAD. Removing only the staged entries would leave
215    // untracked files that the snapshot also captured, and a later
216    // `pop`/`pop --index` would then refuse to overwrite them.
217    if let Some(hh) = head_hash {
218        let head_obj = store.read_object(&hh)?;
219        if let Object::Commit(c) = head_obj {
220            restore::restore_tree(
221                store,
222                c.tree_hash,
223                layout.worktree_root(),
224                &RestoreOptions::default(),
225            )?;
226        }
227    } else {
228        let snapshot = index::from_tree(store, tree_hash)?;
229        for e in &snapshot.entries {
230            let abs = layout.worktree_root().join(&e.path);
231            if let Err(err) = std::fs::remove_file(&abs)
232                && err.kind() != std::io::ErrorKind::NotFound
233            {
234                return Err(StashError::Io(err));
235            }
236            // Remove now-empty parent directories up to the repo root, so a
237            // later `pop` can recreate the path without tripping the "would
238            // overwrite untracked directory" guard.
239            let mut parent = abs.parent();
240            while let Some(dir) = parent {
241                if dir == layout.worktree_root() || std::fs::remove_dir(dir).is_err() {
242                    break;
243                }
244                parent = dir.parent();
245            }
246        }
247    }
248
249    // Clear the index.
250    let _ = index::write_index(layout, &Index::new());
251    Ok(())
252}
253
254/// List all stashes (newest first).
255///
256/// # Errors
257/// - [`StashError::ManifestTooLarge`] / [`StashError::InvalidFormat`]
258///   for a corrupt or oversized manifest.
259pub fn list(layout: &RepoLayout) -> StashResult<StashList> {
260    read_list(layout)
261}
262
263/// Resolve the tree hash recorded by stash entry `idx` (newest = 0)
264/// without mutating anything. Callers use this to run a restore-safety
265/// pre-flight (the #176 guard) over the stash tree before [`pop`].
266///
267/// # Errors
268/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
269/// - [`StashError::NotACommit`] if the stored object is not a Commit.
270pub fn entry_tree_hash(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<Hash> {
271    let list = read_list(layout)?;
272    if idx >= list.entries.len() {
273        return Err(StashError::IndexOutOfRange(idx));
274    }
275    let obj = store.read_object(&list.entries[idx].commit_hash)?;
276    let Object::Commit(commit) = obj else {
277        return Err(StashError::NotACommit);
278    };
279    Ok(commit.tree_hash)
280}
281
282/// The full staged index recorded by stash entry `idx`, if any.
283///
284/// New stashes record the staged state as the stash commit's second parent,
285/// whose tree is a `{ i: <serialized-index blob>, t: <staged tree> }` wrapper
286/// (see [`save`]); this reads back the `i` blob and deserializes it, so
287/// `stash pop/apply --index` can restore the EXACT index — including staged
288/// deletions (`Removed` entries), which a tree cannot represent.
289///
290/// Returns `Ok(None)` for entries that carry no index snapshot: those created
291/// before the feature / saved with no HEAD (single parent), where `--index`
292/// is a no-op.
293///
294/// # Errors
295/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
296/// - [`StashError::NotACommit`] if a stored object is not a Commit.
297/// - [`StashError::Index`] if the serialized index blob is malformed.
298pub fn entry_index(
299    store: &ObjectStore,
300    layout: &RepoLayout,
301    idx: usize,
302) -> StashResult<Option<Index>> {
303    let list = read_list(layout)?;
304    if idx >= list.entries.len() {
305        return Err(StashError::IndexOutOfRange(idx));
306    }
307    let Object::Commit(stash_commit) = store.read_object(&list.entries[idx].commit_hash)? else {
308        return Err(StashError::NotACommit);
309    };
310    // The index snapshot, when present, is always the LAST parent:
311    //   [HEAD, index]  (normal)           -> parents[1]
312    //   [index]        (unborn, no HEAD)  -> parents[0]
313    //   [HEAD]         (legacy, no index) -> parents[0] IS the HEAD commit
314    let Some(index_commit) = stash_commit.parents.last() else {
315        return Ok(None);
316    };
317    // Disambiguate via the manifest's `parent_hash` (= HEAD at save time, or
318    // ZERO when unborn) — NOT by sniffing the tree shape. The last parent
319    // equals `parent_hash` EXACTLY for a legacy single-parent `[HEAD]` stash
320    // (parent_hash=HEAD, last=HEAD): that carries no index snapshot, so return
321    // None without inspecting the HEAD root tree (a legacy HEAD whose root
322    // happens to contain top-level `i`/`t` files must NOT be misread as a
323    // wrapper). For every other shape — `[HEAD, index]` (last=index≠HEAD) and
324    // unborn `[index]` (last=index≠ZERO) — an index wrapper IS expected, so a
325    // non-Tree / missing-marker / bad-blob is CORRUPTION and must fail closed
326    // (never silently drop the only staged-state copy).
327    let parent_hash = list.entries[idx].parent_hash;
328    if *index_commit == parent_hash {
329        return Ok(None);
330    }
331    let Object::Commit(index_commit) = store.read_object(index_commit)? else {
332        return Err(StashError::NotACommit);
333    };
334    // The current-format index snapshot is a WRAPPER tree carrying BOTH the
335    // serialized index blob (`i`) and the staged content tree (`t`).
336    let Object::Tree(wrapper) = store.read_object(&index_commit.tree_hash)? else {
337        return Err(StashError::InvalidFormat);
338    };
339    let has_index_blob = wrapper.entries.iter().any(|e| e.name == b"i");
340    let has_content_tree = wrapper.entries.iter().any(|e| e.name == b"t");
341    if !(has_index_blob && has_content_tree) {
342        return Err(StashError::InvalidFormat);
343    }
344    // A malformed blob is likewise corruption: fail closed and preserve the
345    // only staged-state copy, rather than silently dropping it (which
346    // `pop --index` would then treat as "no index" and discard).
347    let blob_entry = wrapper
348        .entries
349        .iter()
350        .find(|e| e.name == b"i")
351        .ok_or(StashError::InvalidFormat)?;
352    let Object::Blob(blob) = store.read_object(&blob_entry.object_hash)? else {
353        return Err(StashError::InvalidFormat);
354    };
355    Ok(Some(index::deserialize(&blob.data)?))
356}
357
358/// Pop a stash: restore its tree into the worktree and remove the
359/// entry. Index 0 = newest.
360///
361/// # Safety against data loss
362/// This restores **unconditionally** — it does not itself run the #176
363/// destructive-restore guard, because that guard lives in the CLI layer
364/// (`commands::ensure_restore_safe`). Callers that expose `pop` to users
365/// **must** run [`entry_tree_hash`] + the guard first so uncommitted
366/// edits on unrelated paths are never clobbered. The stash entry is
367/// dropped only after a successful restore (restore failure short-
368/// circuits via `?`, leaving the entry in place for a retry).
369///
370/// # Errors
371/// - [`StashError::IndexOutOfRange`] if `index` is past the end.
372pub fn pop(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<()> {
373    let mut list = read_list(layout)?;
374    if idx >= list.entries.len() {
375        return Err(StashError::IndexOutOfRange(idx));
376    }
377    let entry = list.entries[idx].clone();
378    let obj = store.read_object(&entry.commit_hash)?;
379    let Object::Commit(commit) = obj else {
380        return Err(StashError::NotACommit);
381    };
382    // Record the popped commit in the recovery log BEFORE removing the
383    // manifest entry: restored worktree files are not crash-durable
384    // (ops/restore writes them unflushed), and the manifest entry is
385    // the stash commit's only gc root. Without this, a power loss in
386    // the writeback window could lose both the restored bytes and the
387    // only pointer for re-running the restore. The recovery log is a
388    // durable gc root (SPEC-GC), so the commit stays reachable and
389    // recoverable until the grace window expires.
390    let ts = unix_seconds_now();
391    crate::ops::recovery::record(
392        layout,
393        &crate::ops::recovery::RecoveryEntry {
394            timestamp: ts,
395            op: "stash-pop".to_string(),
396            superseded: entry.commit_hash,
397            branch: String::new(),
398        },
399    )
400    .map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
401    restore::restore_tree(
402        store,
403        commit.tree_hash,
404        layout.worktree_root(),
405        &RestoreOptions::default(),
406    )?;
407    list.entries.remove(idx);
408    write_list(layout, &list)?;
409    Ok(())
410}
411
412/// Finalize a pop whose worktree (and, for `--index`, staged index) the
413/// caller has already restored: record the popped commit in the recovery
414/// log, then remove the manifest entry. Index 0 = newest.
415///
416/// Split out of [`pop`] so `pop --index` can restore the staged index
417/// (a separate, fallible step in the CLI) **before** dropping the entry —
418/// a failure there then leaves the stash in place for a normal retry,
419/// rather than dropping it with the index half-restored.
420///
421/// # Errors
422/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
423pub fn pop_finalize(layout: &RepoLayout, idx: usize) -> StashResult<()> {
424    let mut list = read_list(layout)?;
425    if idx >= list.entries.len() {
426        return Err(StashError::IndexOutOfRange(idx));
427    }
428    let entry = list.entries[idx].clone();
429    let ts = unix_seconds_now();
430    crate::ops::recovery::record(
431        layout,
432        &crate::ops::recovery::RecoveryEntry {
433            timestamp: ts,
434            op: "stash-pop".to_string(),
435            superseded: entry.commit_hash,
436            branch: String::new(),
437        },
438    )
439    .map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
440    list.entries.remove(idx);
441    write_list(layout, &list)?;
442    Ok(())
443}
444
445/// Apply a stash entry's tree to the worktree **without** removing the
446/// entry. Index 0 = newest. This is the non-destructive complement to
447/// [`pop`]: it leaves the stash stack untouched so the same entry can be
448/// re-applied or popped later.
449///
450/// # Safety against data loss
451/// Like [`pop`], this restores **unconditionally** — it does not run the
452/// #176 destructive-restore guard itself. Callers exposing `apply` to
453/// users **must** run [`entry_tree_hash`] + `ensure_restore_safe` first,
454/// exactly as `pop` does, so uncommitted edits on unrelated paths are
455/// never clobbered.
456///
457/// # Errors
458/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
459/// - [`StashError::NotACommit`] if the stored object is not a Commit.
460pub fn apply(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<()> {
461    let list = read_list(layout)?;
462    if idx >= list.entries.len() {
463        return Err(StashError::IndexOutOfRange(idx));
464    }
465    let entry = &list.entries[idx];
466    let obj = store.read_object(&entry.commit_hash)?;
467    let Object::Commit(commit) = obj else {
468        return Err(StashError::NotACommit);
469    };
470    restore::restore_tree(
471        store,
472        commit.tree_hash,
473        layout.worktree_root(),
474        &RestoreOptions::default(),
475    )?;
476    Ok(())
477}
478
479/// Drop **all** stash entries, leaving an empty stack. Idempotent — a
480/// missing or already-empty manifest is not an error.
481///
482/// # Errors
483/// - [`StashError::Io`] if the manifest cannot be written.
484pub fn clear(layout: &RepoLayout) -> StashResult<()> {
485    write_list(layout, &StashList::default())
486}
487
488/// Render `stash show [<stash>]` output: header + unified-diff-style listing.
489///
490/// Output format:
491/// ```text
492/// stash@{<idx>}: <message>
493/// Date: <unix-timestamp>
494///
495/// <A|M|D> <path>
496/// ...
497/// ```
498///
499/// # Errors
500/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
501/// - [`StashError::NotACommit`] if the stored object is not a Commit.
502/// - Store/object errors if objects cannot be read.
503pub fn render_stash_show(
504    store: &ObjectStore,
505    layout: &RepoLayout,
506    idx: usize,
507) -> StashResult<String> {
508    let list = read_list(layout)?;
509    if idx >= list.entries.len() {
510        return Err(StashError::IndexOutOfRange(idx));
511    }
512    let entry = &list.entries[idx];
513
514    // Load the stash commit to get its tree.
515    let stash_obj = store.read_object(&entry.commit_hash)?;
516    let Object::Commit(stash_commit) = stash_obj else {
517        return Err(StashError::NotACommit);
518    };
519
520    // Resolve parent tree (None if parent is the zero hash or commit load fails).
521    let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
522        None
523    } else {
524        match store.read_object(&entry.parent_hash) {
525            Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
526            _ => None,
527        }
528    };
529
530    let diff = diff_trees(store, parent_tree, Some(stash_commit.tree_hash))?;
531
532    let mut out = String::new();
533    let _ = writeln!(out, "stash@{{{idx}}}: {}", entry.message);
534    let _ = writeln!(out, "Date: {}", entry.timestamp);
535    let _ = writeln!(out);
536    for e in &diff.entries {
537        let tag = match e.kind {
538            DiffKind::Added => "A",
539            DiffKind::Removed => "D",
540            DiffKind::Modified => "M",
541            DiffKind::ModeChanged => "T",
542            DiffKind::Renamed => "R",
543        };
544        let _ = writeln!(out, "{tag} {}", e.path);
545    }
546    Ok(out)
547}
548
549/// Show the diff for a stash entry (raw [`DiffResult`]).
550///
551/// # Errors
552/// - [`StashError::IndexOutOfRange`] if `idx` is past the end.
553/// - [`StashError::NotACommit`] if the stash commit object is not a Commit.
554pub fn show_diff(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<DiffResult> {
555    let list = read_list(layout)?;
556    if idx >= list.entries.len() {
557        return Err(StashError::IndexOutOfRange(idx));
558    }
559    let entry = &list.entries[idx];
560
561    let stash_obj = store.read_object(&entry.commit_hash)?;
562    let Object::Commit(stash_commit) = stash_obj else {
563        return Err(StashError::NotACommit);
564    };
565
566    let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
567        None
568    } else {
569        match store.read_object(&entry.parent_hash) {
570            Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
571            _ => None,
572        }
573    };
574
575    Ok(diff_trees(
576        store,
577        parent_tree,
578        Some(stash_commit.tree_hash),
579    )?)
580}
581
582/// Drop a stash without applying it.
583///
584/// # Errors
585/// - [`StashError::IndexOutOfRange`] if `index` is past the end.
586pub fn drop(layout: &RepoLayout, idx: usize) -> StashResult<()> {
587    let mut list = read_list(layout)?;
588    if idx >= list.entries.len() {
589        return Err(StashError::IndexOutOfRange(idx));
590    }
591    list.entries.remove(idx);
592    write_list(layout, &list)?;
593    Ok(())
594}
595
596// -- Manifest IO -------------------------------------------------------------
597
598fn stash_path(layout: &RepoLayout) -> PathBuf {
599    layout.stash_file()
600}
601
602fn read_list(layout: &RepoLayout) -> StashResult<StashList> {
603    let path = stash_path(layout);
604    let meta = match fs::metadata(&path) {
605        Ok(m) => m,
606        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(StashList::default()),
607        Err(e) => return Err(StashError::Io(e)),
608    };
609    if meta.len() == 0 {
610        return Ok(StashList::default());
611    }
612    if meta.len() > MAX_MANIFEST_BYTES {
613        return Err(StashError::ManifestTooLarge);
614    }
615    let data = fs::read(&path)?;
616    deserialize_list(&data)
617}
618
619fn write_list(layout: &RepoLayout, list: &StashList) -> StashResult<()> {
620    let bytes = serialize_list(list)?;
621    let path = stash_path(layout);
622    atomic::write_atomic(&path, &bytes, true)?;
623    Ok(())
624}
625
626/// Write a [`StashList`] to disk. Public only for integration-test goldens.
627///
628/// # Panics
629/// Panics if serialization or the write fails (test-only helper).
630pub fn write_list_test_only(layout: &RepoLayout, list: &StashList) {
631    write_list(layout, list).expect("write_list_test_only failed");
632}
633
634/// Encode a [`StashList`] as the on-disk manifest. Public for goldens.
635///
636/// # Errors
637/// - [`StashError::MessageTooLong`] if any entry message exceeds [`MAX_MESSAGE_LEN`].
638pub fn serialize_list(list: &StashList) -> StashResult<Vec<u8>> {
639    let mut total = 4 + 4;
640    for e in &list.entries {
641        if e.message.len() > MAX_MESSAGE_LEN {
642            return Err(StashError::MessageTooLong);
643        }
644        total += 32 + 32 + 4 + 2 + e.message.len();
645    }
646    let mut out = Vec::with_capacity(total);
647    out.extend_from_slice(&MAGIC);
648    out.extend_from_slice(
649        &u32::try_from(list.entries.len())
650            .unwrap_or(u32::MAX)
651            .to_le_bytes(),
652    );
653    for e in &list.entries {
654        out.extend_from_slice(&e.commit_hash);
655        out.extend_from_slice(&e.parent_hash);
656        out.extend_from_slice(&e.timestamp.to_le_bytes());
657        let len_u16 = u16::try_from(e.message.len()).map_err(|_| StashError::MessageTooLong)?;
658        out.extend_from_slice(&len_u16.to_le_bytes());
659        out.extend_from_slice(e.message.as_bytes());
660    }
661    Ok(out)
662}
663
664/// Decode the on-disk manifest. Public for goldens.
665///
666/// # Errors
667/// - [`StashError::InvalidFormat`] if the bytes are malformed.
668pub fn deserialize_list(data: &[u8]) -> StashResult<StashList> {
669    if data.len() < 8 {
670        return Err(StashError::InvalidFormat);
671    }
672    if &data[..4] != MAGIC.as_slice() {
673        return Err(StashError::InvalidFormat);
674    }
675    let count = u32::from_le_bytes(
676        data[4..8]
677            .try_into()
678            .map_err(|_| StashError::InvalidFormat)?,
679    ) as usize;
680    // Reject an attacker-supplied `count` that cannot possibly fit in
681    // the remaining body. With [`MIN_ENTRY_BYTES`] = 70 (empty message)
682    // an 8-byte header declaring count = u32::MAX is rejected here
683    // instead of driving `Vec::with_capacity(u32::MAX)`.
684    if (count as u64).saturating_mul(MIN_ENTRY_BYTES) > data.len() as u64 {
685        return Err(StashError::InvalidFormat);
686    }
687    let mut entries = Vec::with_capacity(count);
688    let mut pos = 8usize;
689    for _ in 0..count {
690        if pos + 32 + 32 + 4 + 2 > data.len() {
691            return Err(StashError::InvalidFormat);
692        }
693        let mut commit_hash = [0u8; 32];
694        commit_hash.copy_from_slice(&data[pos..pos + 32]);
695        pos += 32;
696        let mut parent_hash = [0u8; 32];
697        parent_hash.copy_from_slice(&data[pos..pos + 32]);
698        pos += 32;
699        let timestamp = u32::from_le_bytes(
700            data[pos..pos + 4]
701                .try_into()
702                .map_err(|_| StashError::InvalidFormat)?,
703        );
704        pos += 4;
705        let msg_len = u16::from_le_bytes(
706            data[pos..pos + 2]
707                .try_into()
708                .map_err(|_| StashError::InvalidFormat)?,
709        ) as usize;
710        pos += 2;
711        if pos + msg_len > data.len() {
712            return Err(StashError::InvalidFormat);
713        }
714        let msg = String::from_utf8(data[pos..pos + msg_len].to_vec())
715            .map_err(|_| StashError::InvalidFormat)?;
716        pos += msg_len;
717        entries.push(StashEntry {
718            commit_hash,
719            parent_hash,
720            timestamp,
721            message: msg,
722        });
723    }
724    Ok(StashList { entries })
725}
726
727fn unix_seconds_now() -> u64 {
728    SystemTime::now()
729        .duration_since(UNIX_EPOCH)
730        .map_or(0, |d| d.as_secs())
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::hash;
737    use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
738    use crate::ops::diff::DiffKind;
739    use crate::serialize;
740    use crate::store::ObjectStore;
741    use tempfile::TempDir;
742
743    fn fresh_store() -> (TempDir, ObjectStore) {
744        let dir = TempDir::new().unwrap();
745        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
746        (dir, store)
747    }
748
749    fn put_blob_data(store: &ObjectStore, data: &[u8]) -> Hash {
750        let obj = Object::Blob(Blob {
751            data: data.to_vec(),
752        });
753        store.write(&serialize::serialize(&obj).unwrap()).unwrap()
754    }
755
756    fn put_tree_entries(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
757        let obj = Object::Tree(Tree { entries });
758        store.write(&serialize::serialize(&obj).unwrap()).unwrap()
759    }
760
761    fn put_commit_obj(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
762        let commit = Object::Commit(Commit::new_unannotated(
763            tree_h,
764            parents,
765            Identity::ed25519([0u8; 32]),
766            [0u8; 32],
767            b"msg".to_vec(),
768            ts,
769            [0u8; 64],
770        ));
771        store
772            .write(&serialize::serialize(&commit).unwrap())
773            .unwrap()
774    }
775
776    /// Build a deterministic stash fixture: parent commit has `existing.txt`,
777    /// stash commit adds `new.txt` and modifies `existing.txt`.
778    fn build_stash_fixture(store: &ObjectStore, layout: &RepoLayout) {
779        // Parent tree: one file "existing.txt"
780        let blob_v1 = put_blob_data(store, b"original content");
781        let parent_tree = put_tree_entries(
782            store,
783            vec![TreeEntry {
784                name: b"existing.txt".to_vec(),
785                mode: EntryMode::Blob,
786                object_hash: blob_v1,
787            }],
788        );
789        let parent_commit = put_commit_obj(store, parent_tree, vec![], 1_000_000);
790
791        // Stash tree: existing.txt modified + new.txt added
792        let blob_v2 = put_blob_data(store, b"modified content");
793        let blob_new = put_blob_data(store, b"brand new file");
794        let stash_tree = put_tree_entries(
795            store,
796            vec![
797                TreeEntry {
798                    name: b"existing.txt".to_vec(),
799                    mode: EntryMode::Blob,
800                    object_hash: blob_v2,
801                },
802                TreeEntry {
803                    name: b"new.txt".to_vec(),
804                    mode: EntryMode::Blob,
805                    object_hash: blob_new,
806                },
807            ],
808        );
809        let stash_commit = put_commit_obj(store, stash_tree, vec![parent_commit], 1_000_001);
810
811        let list = StashList {
812            entries: vec![StashEntry {
813                commit_hash: stash_commit,
814                parent_hash: parent_commit,
815                timestamp: 1_000_001_u32,
816                message: "WIP: stash message".to_string(),
817            }],
818        };
819        write_list(layout, &list).unwrap();
820    }
821
822    #[test]
823    fn show_diff_returns_correct_entries() {
824        let (tmp, store) = fresh_store();
825        let layout = RepoLayout::single(tmp.path());
826        build_stash_fixture(&store, &layout);
827
828        let diff = show_diff(&store, &layout, 0).unwrap();
829        assert_eq!(diff.entries.len(), 2, "expected 2 diff entries");
830
831        let existing = diff.entries.iter().find(|e| e.path == "existing.txt");
832        let new_f = diff.entries.iter().find(|e| e.path == "new.txt");
833        assert!(existing.is_some(), "existing.txt must appear in diff");
834        assert!(new_f.is_some(), "new.txt must appear in diff");
835        assert_eq!(existing.unwrap().kind, DiffKind::Modified);
836        assert_eq!(new_f.unwrap().kind, DiffKind::Added);
837    }
838
839    #[test]
840    fn render_stash_show_header_and_entries() {
841        let (tmp, store) = fresh_store();
842        let layout = RepoLayout::single(tmp.path());
843        build_stash_fixture(&store, &layout);
844
845        let output = render_stash_show(&store, &layout, 0).unwrap();
846        assert!(
847            output.contains("stash@{0}:"),
848            "missing stash header: {output}"
849        );
850        assert!(
851            output.contains("WIP: stash message"),
852            "missing message: {output}"
853        );
854        assert!(output.contains("Date:"), "missing date line: {output}");
855        assert!(
856            output.contains("M existing.txt"),
857            "missing modified entry: {output}"
858        );
859        assert!(
860            output.contains("A new.txt"),
861            "missing added entry: {output}"
862        );
863    }
864
865    #[test]
866    fn apply_restores_tree_and_keeps_entry() {
867        let (tmp, store) = fresh_store();
868        let layout = RepoLayout::single(tmp.path());
869        build_stash_fixture(&store, &layout);
870        assert_eq!(read_list(&layout).unwrap().entries.len(), 1);
871
872        apply(&store, &layout, 0).unwrap();
873
874        // The stash tree was materialised into the worktree.
875        assert_eq!(
876            fs::read(tmp.path().join("existing.txt")).unwrap(),
877            b"modified content"
878        );
879        assert_eq!(
880            fs::read(tmp.path().join("new.txt")).unwrap(),
881            b"brand new file"
882        );
883        // ...and the entry is still on the stack (apply, not pop).
884        assert_eq!(
885            read_list(&layout).unwrap().entries.len(),
886            1,
887            "apply must not drop the entry"
888        );
889    }
890
891    #[test]
892    fn entry_index_round_trips_index_including_staged_deletions() {
893        // A real `save` records the FULL serialized index (not a tree), so a
894        // staged deletion (`Removed` entry) — which a tree cannot encode —
895        // survives `entry_index`.
896        let dir = tempfile::TempDir::new().unwrap();
897        let layout = RepoLayout::single(dir.path());
898        let store = ObjectStore::init(&layout).unwrap();
899        let blob_a = put_blob_data(&store, b"a");
900        let tree = put_tree_entries(
901            &store,
902            vec![TreeEntry {
903                name: b"a.txt".to_vec(),
904                mode: EntryMode::Blob,
905                object_hash: blob_a,
906            }],
907        );
908        let head = put_commit_obj(&store, tree, vec![], 5);
909        refs::write_head_branch(&layout, "main").unwrap();
910        refs::write_ref(&layout, "main", &head).unwrap();
911        std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
912
913        // Stage `a.txt` (present) and a deletion of `b.txt` (Removed).
914        let staged = Index::from_entries(vec![
915            index::IndexEntry {
916                path: "a.txt".to_string(),
917                status: index::EntryStatus::Blob,
918                object_hash: blob_a,
919                mtime_ns: 0,
920                size: 0,
921                ino: 0,
922                ctime_ns: 0,
923            },
924            index::IndexEntry {
925                path: "b.txt".to_string(),
926                status: index::EntryStatus::Removed,
927                object_hash: ZERO,
928                mtime_ns: 0,
929                size: 0,
930                ino: 0,
931                ctime_ns: 0,
932            },
933        ]);
934        index::write_index(&layout, &staged).unwrap();
935
936        save(&store, &layout, "wip").unwrap();
937
938        let restored = entry_index(&store, &layout, 0)
939            .unwrap()
940            .expect("save must record an index snapshot");
941        let b = restored
942            .entries
943            .iter()
944            .find(|e| e.path == "b.txt")
945            .expect("the staged deletion must be present");
946        assert_eq!(
947            b.status,
948            index::EntryStatus::Removed,
949            "staged deletion must round-trip as Removed"
950        );
951        assert!(
952            restored
953                .entries
954                .iter()
955                .any(|e| e.path == "a.txt" && e.status == index::EntryStatus::Blob),
956            "the present staged file must round-trip too"
957        );
958    }
959
960    #[test]
961    fn entry_index_none_for_single_parent_entry() {
962        // Historical single-parent (`[HEAD]`) entries carry no index
963        // snapshot, so `--index` is a no-op for them.
964        let (tmp, store) = fresh_store();
965        let layout = RepoLayout::single(tmp.path());
966        build_stash_fixture(&store, &layout);
967        assert!(entry_index(&store, &layout, 0).unwrap().is_none());
968    }
969
970    #[test]
971    fn entry_index_fails_closed_on_corrupt_multiparent_wrapper() {
972        // A 2-parent stash `[HEAD, index]` is always current-format, so its
973        // last parent MUST be a valid `i`/`t` wrapper. A non-wrapper tree
974        // there is corruption — `entry_index` must FAIL CLOSED (InvalidFormat)
975        // rather than silently return None, which would let `pop --index` drop
976        // the only staged-state copy.
977        let (tmp, store) = fresh_store();
978        let root = &RepoLayout::single(tmp.path());
979        let head_tree = put_tree_entries(&store, vec![]);
980        let head_commit = put_commit_obj(&store, head_tree, vec![], 1);
981        // A bogus "index" commit whose tree lacks the i/t wrapper markers.
982        let bogus_tree = put_tree_entries(
983            &store,
984            vec![TreeEntry {
985                name: b"not_a_wrapper".to_vec(),
986                mode: EntryMode::Blob,
987                object_hash: put_blob_data(&store, b"junk"),
988            }],
989        );
990        let bogus_index_commit = put_commit_obj(&store, bogus_tree, vec![head_commit], 2);
991        let stash_commit =
992            put_commit_obj(&store, head_tree, vec![head_commit, bogus_index_commit], 3);
993        write_list(
994            root,
995            &StashList {
996                entries: vec![StashEntry {
997                    commit_hash: stash_commit,
998                    parent_hash: head_commit,
999                    timestamp: 3,
1000                    message: "WIP".to_string(),
1001                }],
1002            },
1003        )
1004        .unwrap();
1005        assert!(
1006            matches!(entry_index(&store, root, 0), Err(StashError::InvalidFormat)),
1007            "a corrupt multi-parent wrapper must fail closed"
1008        );
1009    }
1010
1011    #[test]
1012    fn entry_index_fails_closed_on_corrupt_unborn_wrapper() {
1013        // A single-parent UNBORN `[index]` stash (parent_hash == ZERO) whose
1014        // wrapper is corrupt must ALSO fail closed — disambiguated by
1015        // parent_hash, not parent count.
1016        let (tmp, store) = fresh_store();
1017        let root = &RepoLayout::single(tmp.path());
1018        let bogus_tree = put_tree_entries(
1019            &store,
1020            vec![TreeEntry {
1021                name: b"junk".to_vec(),
1022                mode: EntryMode::Blob,
1023                object_hash: put_blob_data(&store, b"x"),
1024            }],
1025        );
1026        let index_commit = put_commit_obj(&store, bogus_tree, vec![], 1);
1027        let stash_commit = put_commit_obj(&store, bogus_tree, vec![index_commit], 2);
1028        write_list(
1029            root,
1030            &StashList {
1031                entries: vec![StashEntry {
1032                    commit_hash: stash_commit,
1033                    parent_hash: ZERO, // unborn: no HEAD
1034                    timestamp: 2,
1035                    message: "WIP".to_string(),
1036                }],
1037            },
1038        )
1039        .unwrap();
1040        assert!(
1041            matches!(entry_index(&store, root, 0), Err(StashError::InvalidFormat)),
1042            "a corrupt unborn [index] wrapper must fail closed"
1043        );
1044    }
1045
1046    #[test]
1047    fn entry_index_legacy_head_with_coincidental_markers_reads_as_none() {
1048        // A LEGACY single-parent `[HEAD]` stash (parent_hash == sole parent)
1049        // whose HEAD root tree HAPPENS to carry top-level `i`+`t` files must be
1050        // treated as no-index (parent_hash short-circuit), NOT misread as a
1051        // wrapper.
1052        let (tmp, store) = fresh_store();
1053        let root = &RepoLayout::single(tmp.path());
1054        let head_tree = put_tree_entries(
1055            &store,
1056            vec![
1057                TreeEntry {
1058                    name: b"i".to_vec(),
1059                    mode: EntryMode::Blob,
1060                    object_hash: put_blob_data(&store, b"not an index"),
1061                },
1062                TreeEntry {
1063                    name: b"t".to_vec(),
1064                    mode: EntryMode::Blob,
1065                    object_hash: put_blob_data(&store, b"whatever"),
1066                },
1067            ],
1068        );
1069        let head_commit = put_commit_obj(&store, head_tree, vec![], 1);
1070        let stash_commit = put_commit_obj(&store, head_tree, vec![head_commit], 2);
1071        write_list(
1072            root,
1073            &StashList {
1074                entries: vec![StashEntry {
1075                    commit_hash: stash_commit,
1076                    parent_hash: head_commit, // legacy: parent_hash == sole parent
1077                    timestamp: 2,
1078                    message: "legacy".to_string(),
1079                }],
1080            },
1081        )
1082        .unwrap();
1083        assert!(
1084            entry_index(&store, root, 0).unwrap().is_none(),
1085            "legacy [HEAD] must read as no-index regardless of tree shape"
1086        );
1087    }
1088
1089    #[test]
1090    fn apply_out_of_range_returns_error() {
1091        let (tmp, _store) = fresh_store();
1092        let layout = RepoLayout::single(tmp.path());
1093        let store = ObjectStore::open(&layout).unwrap();
1094        let err = apply(&store, &layout, 0).unwrap_err();
1095        assert!(matches!(err, StashError::IndexOutOfRange(0)));
1096    }
1097
1098    #[test]
1099    fn clear_empties_the_stack() {
1100        let (tmp, store) = fresh_store();
1101        let layout = RepoLayout::single(tmp.path());
1102        build_stash_fixture(&store, &layout);
1103        assert_eq!(read_list(&layout).unwrap().entries.len(), 1);
1104
1105        clear(&layout).unwrap();
1106        assert!(read_list(&layout).unwrap().entries.is_empty());
1107
1108        // Idempotent: clearing an already-empty stack is fine.
1109        clear(&layout).unwrap();
1110        assert!(read_list(&layout).unwrap().entries.is_empty());
1111    }
1112
1113    #[test]
1114    fn show_diff_out_of_range_returns_error() {
1115        let (tmp, _store) = fresh_store();
1116        let layout = RepoLayout::single(tmp.path());
1117        let store = ObjectStore::open(&layout).unwrap();
1118        let err = show_diff(&store, &layout, 0).unwrap_err();
1119        assert!(matches!(err, StashError::IndexOutOfRange(0)));
1120    }
1121
1122    #[test]
1123    fn manifest_roundtrip_two_entries() {
1124        let list = StashList {
1125            entries: vec![
1126                StashEntry {
1127                    commit_hash: hash::hash(b"commit1"),
1128                    parent_hash: hash::hash(b"parent1"),
1129                    timestamp: 1000,
1130                    message: "first stash".to_string(),
1131                },
1132                StashEntry {
1133                    commit_hash: hash::hash(b"commit2"),
1134                    parent_hash: ZERO,
1135                    timestamp: 2000,
1136                    message: "second stash".to_string(),
1137                },
1138            ],
1139        };
1140        let bytes = serialize_list(&list).unwrap();
1141        let back = deserialize_list(&bytes).unwrap();
1142        assert_eq!(back, list);
1143    }
1144
1145    #[test]
1146    fn deserialize_rejects_short_data() {
1147        assert!(matches!(
1148            deserialize_list(&[0u8; 4]),
1149            Err(StashError::InvalidFormat)
1150        ));
1151    }
1152
1153    #[test]
1154    fn deserialize_rejects_bad_magic() {
1155        assert!(matches!(
1156            deserialize_list(&[b'X', b'Y', b'Z', b'W', 0, 0, 0, 0]),
1157            Err(StashError::InvalidFormat)
1158        ));
1159    }
1160
1161    #[test]
1162    fn deserialize_rejects_bogus_huge_count() {
1163        // G12 regression: an 8-byte manifest whose header declares
1164        // count = u32::MAX must be rejected up-front — the decoder
1165        // must NOT call `Vec::with_capacity(u32::MAX)` nor loop that
1166        // many times.
1167        let mut bytes = Vec::new();
1168        bytes.extend_from_slice(MAGIC.as_slice());
1169        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1170        // No entries follow.
1171        assert!(matches!(
1172            deserialize_list(&bytes),
1173            Err(StashError::InvalidFormat)
1174        ));
1175    }
1176    /// `pop` must record the popped commit in the recovery log BEFORE
1177    /// dropping the manifest entry — the only pointer keeping the stash
1178    /// commit gc-reachable while the (unflushed) worktree restore is in
1179    /// the writeback window.
1180    #[test]
1181    fn pop_records_recovery_entry_for_popped_commit() {
1182        let dir = tempfile::TempDir::new().unwrap();
1183        let layout = RepoLayout::single(dir.path());
1184        let store = ObjectStore::init(&layout).unwrap();
1185        std::fs::write(dir.path().join("file.txt"), b"stash me").unwrap();
1186        save(&store, &layout, "wip").unwrap();
1187        let entry_hash = read_list(&layout).unwrap().entries[0].commit_hash;
1188
1189        pop(&store, &layout, 0).unwrap();
1190
1191        let log = crate::ops::recovery::read_all(&layout).unwrap();
1192        assert!(
1193            log.iter()
1194                .any(|e| e.op == "stash-pop" && e.superseded == entry_hash),
1195            "popped stash commit must be recorded as recoverable; log: {log:?}"
1196        );
1197        assert!(read_list(&layout).unwrap().entries.is_empty());
1198    }
1199}