1use 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
37pub const MAGIC: [u8; 4] = *b"MKST";
39
40pub const STASH_FILE: &str = ".mkit/stash";
42
43pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
45
46pub const MAX_MESSAGE_LEN: usize = u16::MAX as usize;
48
49const MIN_ENTRY_BYTES: u64 = 32 + 32 + 4 + 2;
55
56#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct StashList {
68 pub entries: Vec<StashEntry>,
69}
70
71#[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
100pub type StashResult<T> = Result<T, StashError>;
102
103pub fn save(store: &ObjectStore, layout: &RepoLayout, message: &str) -> StashResult<()> {
114 if message.len() > MAX_MESSAGE_LEN {
115 return Err(StashError::MessageTooLong);
116 }
117
118 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 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 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 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 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 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 let _ = index::write_index(layout, &Index::new());
250 Ok(())
251}
252
253pub fn list(layout: &RepoLayout) -> StashResult<StashList> {
259 read_list(layout)
260}
261
262pub 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
281pub 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 let Some(index_commit) = stash_commit.parents.last() else {
314 return Ok(None);
315 };
316 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 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 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
357pub 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 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
411pub 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
444pub 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
478pub fn clear(layout: &RepoLayout) -> StashResult<()> {
484 write_list(layout, &StashList::default())
485}
486
487pub 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 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 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
548pub 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
581pub 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
595fn 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
625pub fn write_list_test_only(layout: &RepoLayout, list: &StashList) {
630 write_list(layout, list).expect("write_list_test_only failed");
631}
632
633pub 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
663pub 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 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 fn build_stash_fixture(store: &ObjectStore, layout: &RepoLayout) {
778 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 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 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 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 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 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 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 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 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 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, 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 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, 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 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 let mut bytes = Vec::new();
1167 bytes.extend_from_slice(MAGIC.as_slice());
1168 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1169 assert!(matches!(
1171 deserialize_list(&bytes),
1172 Err(StashError::InvalidFormat)
1173 ));
1174 }
1175 #[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}