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