1use std::fmt::Write as _;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use crate::atomic;
26use crate::hash::{Hash, ZERO};
27use crate::index::{self, Index};
28use crate::object::{Commit, Identity, Object};
29use crate::ops::diff::{DiffKind, DiffResult, diff_trees};
30use crate::ops::restore::{self, RestoreOptions};
31use crate::refs;
32use crate::serialize;
33use crate::store::{MKIT_DIR, ObjectStore};
34use crate::worktree;
35
36pub const MAGIC: [u8; 4] = *b"MKST";
38
39pub const STASH_FILE: &str = ".mkit/stash";
41
42pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
44
45pub const MAX_MESSAGE_LEN: usize = u16::MAX as usize;
47
48const MIN_ENTRY_BYTES: u64 = 32 + 32 + 4 + 2;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StashEntry {
57 pub commit_hash: Hash,
58 pub parent_hash: Hash,
59 pub timestamp: u32,
60 pub message: String,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct StashList {
66 pub entries: Vec<StashEntry>,
67}
68
69#[derive(Debug, thiserror::Error)]
71pub enum StashError {
72 #[error("stash index {0} is out of range")]
73 IndexOutOfRange(usize),
74 #[error("stash manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit")]
75 ManifestTooLarge,
76 #[error("stash manifest format is invalid")]
77 InvalidFormat,
78 #[error("stash message exceeds {MAX_MESSAGE_LEN} bytes")]
79 MessageTooLong,
80 #[error("stash commit object is not a Commit")]
81 NotACommit,
82 #[error(transparent)]
83 Diff(#[from] crate::store::StoreError),
84 #[error(transparent)]
85 Object(#[from] crate::object::MkitError),
86 #[error(transparent)]
87 Refs(#[from] crate::refs::RefError),
88 #[error(transparent)]
89 Index(#[from] crate::index::IndexError),
90 #[error(transparent)]
91 Worktree(#[from] crate::worktree::WorktreeError),
92 #[error(transparent)]
93 Restore(#[from] crate::ops::restore::RestoreError),
94 #[error(transparent)]
95 Io(#[from] io::Error),
96}
97
98pub type StashResult<T> = Result<T, StashError>;
100
101pub fn save(store: &ObjectStore, repo_root: &Path, message: &str) -> StashResult<()> {
112 if message.len() > MAX_MESSAGE_LEN {
113 return Err(StashError::MessageTooLong);
114 }
115 let mkit_dir = repo_root.join(MKIT_DIR);
116
117 let batch = store.batch();
120 let tree_hash = worktree::build_tree(&batch, repo_root)?;
121 let head_hash = refs::resolve_head(&mkit_dir)?;
122
123 let timestamp_u64 = unix_seconds_now();
124 let parents = head_hash.into_iter().collect::<Vec<_>>();
125 let zero_pk = [0u8; 32];
126 let commit = Object::Commit(Commit::new_unannotated(
127 tree_hash,
128 parents,
129 Identity::ed25519(zero_pk),
130 [0u8; 32],
131 message.as_bytes().to_vec(),
132 timestamp_u64,
133 [0u8; 64],
134 ));
135 let commit_bytes = serialize::serialize(&commit)?;
136 let stash_hash = batch.write(&commit_bytes)?;
137 batch.commit()?;
138
139 let mut list = read_list(repo_root)?;
141 let ts_u32: u32 = timestamp_u64.try_into().unwrap_or(u32::MAX);
142 let new_entry = StashEntry {
143 commit_hash: stash_hash,
144 parent_hash: head_hash.unwrap_or(ZERO),
145 timestamp: ts_u32,
146 message: message.to_string(),
147 };
148 list.entries.insert(0, new_entry);
149 write_list(repo_root, &list)?;
150
151 if let Some(hh) = head_hash {
153 let head_obj = store.read_object(&hh)?;
154 if let Object::Commit(c) = head_obj {
155 restore::restore_tree(store, c.tree_hash, repo_root, &RestoreOptions::default())?;
156 }
157 }
158
159 let _ = index::write_index(repo_root, &Index::new());
161 Ok(())
162}
163
164pub fn list(repo_root: &Path) -> StashResult<StashList> {
170 read_list(repo_root)
171}
172
173pub fn entry_tree_hash(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<Hash> {
181 let list = read_list(repo_root)?;
182 if idx >= list.entries.len() {
183 return Err(StashError::IndexOutOfRange(idx));
184 }
185 let obj = store.read_object(&list.entries[idx].commit_hash)?;
186 let Object::Commit(commit) = obj else {
187 return Err(StashError::NotACommit);
188 };
189 Ok(commit.tree_hash)
190}
191
192pub fn pop(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<()> {
207 let mut list = read_list(repo_root)?;
208 if idx >= list.entries.len() {
209 return Err(StashError::IndexOutOfRange(idx));
210 }
211 let entry = list.entries[idx].clone();
212 let obj = store.read_object(&entry.commit_hash)?;
213 let Object::Commit(commit) = obj else {
214 return Err(StashError::NotACommit);
215 };
216 let ts = unix_seconds_now();
225 crate::ops::recovery::record(
226 &repo_root.join(MKIT_DIR),
227 &crate::ops::recovery::RecoveryEntry {
228 timestamp: ts,
229 op: "stash-pop".to_string(),
230 superseded: entry.commit_hash,
231 branch: String::new(),
232 },
233 )
234 .map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
235 restore::restore_tree(
236 store,
237 commit.tree_hash,
238 repo_root,
239 &RestoreOptions::default(),
240 )?;
241 list.entries.remove(idx);
242 write_list(repo_root, &list)?;
243 Ok(())
244}
245
246pub fn apply(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<()> {
262 let list = read_list(repo_root)?;
263 if idx >= list.entries.len() {
264 return Err(StashError::IndexOutOfRange(idx));
265 }
266 let entry = &list.entries[idx];
267 let obj = store.read_object(&entry.commit_hash)?;
268 let Object::Commit(commit) = obj else {
269 return Err(StashError::NotACommit);
270 };
271 restore::restore_tree(
272 store,
273 commit.tree_hash,
274 repo_root,
275 &RestoreOptions::default(),
276 )?;
277 Ok(())
278}
279
280pub fn clear(repo_root: &Path) -> StashResult<()> {
286 write_list(repo_root, &StashList::default())
287}
288
289pub fn render_stash_show(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<String> {
305 let list = read_list(repo_root)?;
306 if idx >= list.entries.len() {
307 return Err(StashError::IndexOutOfRange(idx));
308 }
309 let entry = &list.entries[idx];
310
311 let stash_obj = store.read_object(&entry.commit_hash)?;
313 let Object::Commit(stash_commit) = stash_obj else {
314 return Err(StashError::NotACommit);
315 };
316
317 let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
319 None
320 } else {
321 match store.read_object(&entry.parent_hash) {
322 Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
323 _ => None,
324 }
325 };
326
327 let diff = diff_trees(store, parent_tree, Some(stash_commit.tree_hash))?;
328
329 let mut out = String::new();
330 let _ = writeln!(out, "stash@{{{idx}}}: {}", entry.message);
331 let _ = writeln!(out, "Date: {}", entry.timestamp);
332 let _ = writeln!(out);
333 for e in &diff.entries {
334 let tag = match e.kind {
335 DiffKind::Added => "A",
336 DiffKind::Removed => "D",
337 DiffKind::Modified => "M",
338 DiffKind::ModeChanged => "T",
339 };
340 let _ = writeln!(out, "{tag} {}", e.path);
341 }
342 Ok(out)
343}
344
345pub fn show_diff(store: &ObjectStore, repo_root: &Path, idx: usize) -> StashResult<DiffResult> {
351 let list = read_list(repo_root)?;
352 if idx >= list.entries.len() {
353 return Err(StashError::IndexOutOfRange(idx));
354 }
355 let entry = &list.entries[idx];
356
357 let stash_obj = store.read_object(&entry.commit_hash)?;
358 let Object::Commit(stash_commit) = stash_obj else {
359 return Err(StashError::NotACommit);
360 };
361
362 let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
363 None
364 } else {
365 match store.read_object(&entry.parent_hash) {
366 Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
367 _ => None,
368 }
369 };
370
371 Ok(diff_trees(
372 store,
373 parent_tree,
374 Some(stash_commit.tree_hash),
375 )?)
376}
377
378pub fn drop(repo_root: &Path, idx: usize) -> StashResult<()> {
383 let mut list = read_list(repo_root)?;
384 if idx >= list.entries.len() {
385 return Err(StashError::IndexOutOfRange(idx));
386 }
387 list.entries.remove(idx);
388 write_list(repo_root, &list)?;
389 Ok(())
390}
391
392fn stash_path(repo_root: &Path) -> PathBuf {
395 repo_root.join(STASH_FILE)
396}
397
398fn read_list(repo_root: &Path) -> StashResult<StashList> {
399 let path = stash_path(repo_root);
400 let meta = match fs::metadata(&path) {
401 Ok(m) => m,
402 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(StashList::default()),
403 Err(e) => return Err(StashError::Io(e)),
404 };
405 if meta.len() == 0 {
406 return Ok(StashList::default());
407 }
408 if meta.len() > MAX_MANIFEST_BYTES {
409 return Err(StashError::ManifestTooLarge);
410 }
411 let data = fs::read(&path)?;
412 deserialize_list(&data)
413}
414
415fn write_list(repo_root: &Path, list: &StashList) -> StashResult<()> {
416 let bytes = serialize_list(list)?;
417 let path = stash_path(repo_root);
418 atomic::write_atomic(&path, &bytes, true)?;
419 Ok(())
420}
421
422pub fn write_list_test_only(repo_root: &Path, list: &StashList) {
427 write_list(repo_root, list).expect("write_list_test_only failed");
428}
429
430pub fn serialize_list(list: &StashList) -> StashResult<Vec<u8>> {
435 let mut total = 4 + 4;
436 for e in &list.entries {
437 if e.message.len() > MAX_MESSAGE_LEN {
438 return Err(StashError::MessageTooLong);
439 }
440 total += 32 + 32 + 4 + 2 + e.message.len();
441 }
442 let mut out = Vec::with_capacity(total);
443 out.extend_from_slice(&MAGIC);
444 out.extend_from_slice(
445 &u32::try_from(list.entries.len())
446 .unwrap_or(u32::MAX)
447 .to_le_bytes(),
448 );
449 for e in &list.entries {
450 out.extend_from_slice(&e.commit_hash);
451 out.extend_from_slice(&e.parent_hash);
452 out.extend_from_slice(&e.timestamp.to_le_bytes());
453 let len_u16 = u16::try_from(e.message.len()).map_err(|_| StashError::MessageTooLong)?;
454 out.extend_from_slice(&len_u16.to_le_bytes());
455 out.extend_from_slice(e.message.as_bytes());
456 }
457 Ok(out)
458}
459
460pub fn deserialize_list(data: &[u8]) -> StashResult<StashList> {
469 if data.len() < 8 {
470 return Err(StashError::InvalidFormat);
471 }
472 if &data[..4] != MAGIC.as_slice() {
473 return Err(StashError::InvalidFormat);
474 }
475 let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
476 if (count as u64).saturating_mul(MIN_ENTRY_BYTES) > data.len() as u64 {
482 return Err(StashError::InvalidFormat);
483 }
484 let mut entries = Vec::with_capacity(count);
485 let mut pos = 8usize;
486 for _ in 0..count {
487 if pos + 32 + 32 + 4 + 2 > data.len() {
488 return Err(StashError::InvalidFormat);
489 }
490 let mut commit_hash = [0u8; 32];
491 commit_hash.copy_from_slice(&data[pos..pos + 32]);
492 pos += 32;
493 let mut parent_hash = [0u8; 32];
494 parent_hash.copy_from_slice(&data[pos..pos + 32]);
495 pos += 32;
496 let timestamp = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
497 pos += 4;
498 let msg_len = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()) as usize;
499 pos += 2;
500 if pos + msg_len > data.len() {
501 return Err(StashError::InvalidFormat);
502 }
503 let msg = String::from_utf8(data[pos..pos + msg_len].to_vec())
504 .map_err(|_| StashError::InvalidFormat)?;
505 pos += msg_len;
506 entries.push(StashEntry {
507 commit_hash,
508 parent_hash,
509 timestamp,
510 message: msg,
511 });
512 }
513 Ok(StashList { entries })
514}
515
516fn unix_seconds_now() -> u64 {
517 SystemTime::now()
518 .duration_since(UNIX_EPOCH)
519 .map_or(0, |d| d.as_secs())
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::hash;
526 use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
527 use crate::ops::diff::DiffKind;
528 use crate::serialize;
529 use crate::store::ObjectStore;
530 use tempfile::TempDir;
531
532 fn fresh_store() -> (TempDir, ObjectStore) {
533 let dir = TempDir::new().unwrap();
534 let store = ObjectStore::init(dir.path()).unwrap();
535 (dir, store)
536 }
537
538 fn put_blob_data(store: &ObjectStore, data: &[u8]) -> Hash {
539 let obj = Object::Blob(Blob {
540 data: data.to_vec(),
541 });
542 store.write(&serialize::serialize(&obj).unwrap()).unwrap()
543 }
544
545 fn put_tree_entries(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
546 let obj = Object::Tree(Tree { entries });
547 store.write(&serialize::serialize(&obj).unwrap()).unwrap()
548 }
549
550 fn put_commit_obj(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
551 let commit = Object::Commit(Commit::new_unannotated(
552 tree_h,
553 parents,
554 Identity::ed25519([0u8; 32]),
555 [0u8; 32],
556 b"msg".to_vec(),
557 ts,
558 [0u8; 64],
559 ));
560 store
561 .write(&serialize::serialize(&commit).unwrap())
562 .unwrap()
563 }
564
565 fn build_stash_fixture(store: &ObjectStore, repo_root: &std::path::Path) {
568 let blob_v1 = put_blob_data(store, b"original content");
570 let parent_tree = put_tree_entries(
571 store,
572 vec![TreeEntry {
573 name: b"existing.txt".to_vec(),
574 mode: EntryMode::Blob,
575 object_hash: blob_v1,
576 }],
577 );
578 let parent_commit = put_commit_obj(store, parent_tree, vec![], 1_000_000);
579
580 let blob_v2 = put_blob_data(store, b"modified content");
582 let blob_new = put_blob_data(store, b"brand new file");
583 let stash_tree = put_tree_entries(
584 store,
585 vec![
586 TreeEntry {
587 name: b"existing.txt".to_vec(),
588 mode: EntryMode::Blob,
589 object_hash: blob_v2,
590 },
591 TreeEntry {
592 name: b"new.txt".to_vec(),
593 mode: EntryMode::Blob,
594 object_hash: blob_new,
595 },
596 ],
597 );
598 let stash_commit = put_commit_obj(store, stash_tree, vec![parent_commit], 1_000_001);
599
600 let list = StashList {
601 entries: vec![StashEntry {
602 commit_hash: stash_commit,
603 parent_hash: parent_commit,
604 timestamp: 1_000_001_u32,
605 message: "WIP: stash message".to_string(),
606 }],
607 };
608 write_list(repo_root, &list).unwrap();
609 }
610
611 #[test]
612 fn show_diff_returns_correct_entries() {
613 let (tmp, store) = fresh_store();
614 build_stash_fixture(&store, tmp.path());
615
616 let diff = show_diff(&store, tmp.path(), 0).unwrap();
617 assert_eq!(diff.entries.len(), 2, "expected 2 diff entries");
618
619 let existing = diff.entries.iter().find(|e| e.path == "existing.txt");
620 let new_f = diff.entries.iter().find(|e| e.path == "new.txt");
621 assert!(existing.is_some(), "existing.txt must appear in diff");
622 assert!(new_f.is_some(), "new.txt must appear in diff");
623 assert_eq!(existing.unwrap().kind, DiffKind::Modified);
624 assert_eq!(new_f.unwrap().kind, DiffKind::Added);
625 }
626
627 #[test]
628 fn render_stash_show_header_and_entries() {
629 let (tmp, store) = fresh_store();
630 build_stash_fixture(&store, tmp.path());
631
632 let output = render_stash_show(&store, tmp.path(), 0).unwrap();
633 assert!(
634 output.contains("stash@{0}:"),
635 "missing stash header: {output}"
636 );
637 assert!(
638 output.contains("WIP: stash message"),
639 "missing message: {output}"
640 );
641 assert!(output.contains("Date:"), "missing date line: {output}");
642 assert!(
643 output.contains("M existing.txt"),
644 "missing modified entry: {output}"
645 );
646 assert!(
647 output.contains("A new.txt"),
648 "missing added entry: {output}"
649 );
650 }
651
652 #[test]
653 fn apply_restores_tree_and_keeps_entry() {
654 let (tmp, store) = fresh_store();
655 build_stash_fixture(&store, tmp.path());
656 assert_eq!(read_list(tmp.path()).unwrap().entries.len(), 1);
657
658 apply(&store, tmp.path(), 0).unwrap();
659
660 assert_eq!(
662 fs::read(tmp.path().join("existing.txt")).unwrap(),
663 b"modified content"
664 );
665 assert_eq!(
666 fs::read(tmp.path().join("new.txt")).unwrap(),
667 b"brand new file"
668 );
669 assert_eq!(
671 read_list(tmp.path()).unwrap().entries.len(),
672 1,
673 "apply must not drop the entry"
674 );
675 }
676
677 #[test]
678 fn apply_out_of_range_returns_error() {
679 let (tmp, _store) = fresh_store();
680 let store = ObjectStore::open(tmp.path()).unwrap();
681 let err = apply(&store, tmp.path(), 0).unwrap_err();
682 assert!(matches!(err, StashError::IndexOutOfRange(0)));
683 }
684
685 #[test]
686 fn clear_empties_the_stack() {
687 let (tmp, store) = fresh_store();
688 build_stash_fixture(&store, tmp.path());
689 assert_eq!(read_list(tmp.path()).unwrap().entries.len(), 1);
690
691 clear(tmp.path()).unwrap();
692 assert!(read_list(tmp.path()).unwrap().entries.is_empty());
693
694 clear(tmp.path()).unwrap();
696 assert!(read_list(tmp.path()).unwrap().entries.is_empty());
697 }
698
699 #[test]
700 fn show_diff_out_of_range_returns_error() {
701 let (tmp, _store) = fresh_store();
702 let store = ObjectStore::open(tmp.path()).unwrap();
703 let err = show_diff(&store, tmp.path(), 0).unwrap_err();
704 assert!(matches!(err, StashError::IndexOutOfRange(0)));
705 }
706
707 #[test]
708 fn manifest_roundtrip_two_entries() {
709 let list = StashList {
710 entries: vec![
711 StashEntry {
712 commit_hash: hash::hash(b"commit1"),
713 parent_hash: hash::hash(b"parent1"),
714 timestamp: 1000,
715 message: "first stash".to_string(),
716 },
717 StashEntry {
718 commit_hash: hash::hash(b"commit2"),
719 parent_hash: ZERO,
720 timestamp: 2000,
721 message: "second stash".to_string(),
722 },
723 ],
724 };
725 let bytes = serialize_list(&list).unwrap();
726 let back = deserialize_list(&bytes).unwrap();
727 assert_eq!(back, list);
728 }
729
730 #[test]
731 fn deserialize_rejects_short_data() {
732 assert!(matches!(
733 deserialize_list(&[0u8; 4]),
734 Err(StashError::InvalidFormat)
735 ));
736 }
737
738 #[test]
739 fn deserialize_rejects_bad_magic() {
740 assert!(matches!(
741 deserialize_list(&[b'X', b'Y', b'Z', b'W', 0, 0, 0, 0]),
742 Err(StashError::InvalidFormat)
743 ));
744 }
745
746 #[test]
747 fn deserialize_rejects_bogus_huge_count() {
748 let mut bytes = Vec::new();
753 bytes.extend_from_slice(MAGIC.as_slice());
754 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
755 assert!(matches!(
757 deserialize_list(&bytes),
758 Err(StashError::InvalidFormat)
759 ));
760 }
761 #[test]
766 fn pop_records_recovery_entry_for_popped_commit() {
767 let dir = tempfile::TempDir::new().unwrap();
768 let store = ObjectStore::init(dir.path()).unwrap();
769 std::fs::write(dir.path().join("file.txt"), b"stash me").unwrap();
770 save(&store, dir.path(), "wip").unwrap();
771 let entry_hash = read_list(dir.path()).unwrap().entries[0].commit_hash;
772
773 pop(&store, dir.path(), 0).unwrap();
774
775 let log = crate::ops::recovery::read_all(&dir.path().join(MKIT_DIR)).unwrap();
776 assert!(
777 log.iter()
778 .any(|e| e.op == "stash-pop" && e.superseded == entry_hash),
779 "popped stash commit must be recorded as recoverable; log: {log:?}"
780 );
781 assert!(read_list(dir.path()).unwrap().entries.is_empty());
782 }
783}