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 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 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 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 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 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 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 let _ = index::write_index(layout, &Index::new());
251 Ok(())
252}
253
254pub fn list(layout: &RepoLayout) -> StashResult<StashList> {
260 read_list(layout)
261}
262
263pub 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
282pub 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 let Some(index_commit) = stash_commit.parents.last() else {
315 return Ok(None);
316 };
317 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 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 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
358pub 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 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
412pub 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
445pub 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
479pub fn clear(layout: &RepoLayout) -> StashResult<()> {
485 write_list(layout, &StashList::default())
486}
487
488pub 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 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 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
549pub 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
582pub 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
596fn 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
626pub fn write_list_test_only(layout: &RepoLayout, list: &StashList) {
631 write_list(layout, list).expect("write_list_test_only failed");
632}
633
634pub 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
664pub 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 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 fn build_stash_fixture(store: &ObjectStore, layout: &RepoLayout) {
779 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 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 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 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 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 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 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 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 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 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, 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 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, 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 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 let mut bytes = Vec::new();
1168 bytes.extend_from_slice(MAGIC.as_slice());
1169 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1170 assert!(matches!(
1172 deserialize_list(&bytes),
1173 Err(StashError::InvalidFormat)
1174 ));
1175 }
1176 #[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}