use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::atomic;
use crate::hash::{Hash, ZERO};
use crate::index::{self, Index};
use crate::layout::RepoLayout;
use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
use crate::ops::diff::{DiffKind, DiffResult, diff_trees};
use crate::ops::restore::{self, RestoreOptions};
use crate::refs;
use crate::serialize;
use crate::store::ObjectStore;
use crate::worktree;
pub const MAGIC: [u8; 4] = *b"MKST";
pub const STASH_FILE: &str = ".mkit/stash";
pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_MESSAGE_LEN: usize = u16::MAX as usize;
const MIN_ENTRY_BYTES: u64 = 32 + 32 + 4 + 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StashEntry {
pub commit_hash: Hash,
pub parent_hash: Hash,
pub timestamp: u32,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StashList {
pub entries: Vec<StashEntry>,
}
#[derive(Debug, thiserror::Error)]
pub enum StashError {
#[error("stash index {0} is out of range")]
IndexOutOfRange(usize),
#[error("stash manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit")]
ManifestTooLarge,
#[error("stash manifest format is invalid")]
InvalidFormat,
#[error("stash message exceeds {MAX_MESSAGE_LEN} bytes")]
MessageTooLong,
#[error("stash commit object is not a Commit")]
NotACommit,
#[error(transparent)]
Diff(#[from] crate::store::StoreError),
#[error(transparent)]
Object(#[from] crate::object::MkitError),
#[error(transparent)]
Refs(#[from] crate::refs::RefError),
#[error(transparent)]
Index(#[from] crate::index::IndexError),
#[error(transparent)]
Worktree(#[from] crate::worktree::WorktreeError),
#[error(transparent)]
Restore(#[from] crate::ops::restore::RestoreError),
#[error(transparent)]
Io(#[from] io::Error),
}
pub type StashResult<T> = Result<T, StashError>;
pub fn save(store: &ObjectStore, layout: &RepoLayout, message: &str) -> StashResult<()> {
if message.len() > MAX_MESSAGE_LEN {
return Err(StashError::MessageTooLong);
}
let batch = store.batch();
let tree_hash = worktree::build_tree(&batch, layout.worktree_root())?;
let head_hash = refs::resolve_head(layout)?;
let timestamp_u64 = unix_seconds_now();
let zero_pk = [0u8; 32];
let staged = index::read_index(layout)?;
let staged_tree = worktree::build_tree_from_index_with(store, &batch, &staged, true)?;
let index_blob = batch.write(&serialize::serialize(&Object::Blob(Blob {
data: staged.serialize(),
}))?)?;
let wrapper = Object::Tree(Tree {
entries: vec![
TreeEntry {
name: b"i".to_vec(),
mode: EntryMode::Blob,
object_hash: index_blob,
},
TreeEntry {
name: b"t".to_vec(),
mode: EntryMode::Tree,
object_hash: staged_tree,
},
],
});
let wrapper_hash = batch.write(&serialize::serialize(&wrapper)?)?;
let index_parents = head_hash.into_iter().collect::<Vec<_>>();
let index_commit = Object::Commit(Commit::new_unannotated(
wrapper_hash,
index_parents,
Identity::ed25519(zero_pk),
[0u8; 32],
b"index".to_vec(),
timestamp_u64,
[0u8; 64],
));
let index_commit_hash = batch.write(&serialize::serialize(&index_commit)?)?;
let mut parents = head_hash.into_iter().collect::<Vec<_>>();
parents.push(index_commit_hash);
let commit = Object::Commit(Commit::new_unannotated(
tree_hash,
parents,
Identity::ed25519(zero_pk),
[0u8; 32],
message.as_bytes().to_vec(),
timestamp_u64,
[0u8; 64],
));
let commit_bytes = serialize::serialize(&commit)?;
let stash_hash = batch.write(&commit_bytes)?;
batch.commit()?;
let mut list = read_list(layout)?;
let ts_u32: u32 = timestamp_u64.try_into().unwrap_or(u32::MAX);
let new_entry = StashEntry {
commit_hash: stash_hash,
parent_hash: head_hash.unwrap_or(ZERO),
timestamp: ts_u32,
message: message.to_string(),
};
list.entries.insert(0, new_entry);
write_list(layout, &list)?;
if let Some(hh) = head_hash {
let head_obj = store.read_object(&hh)?;
if let Object::Commit(c) = head_obj {
restore::restore_tree(
store,
c.tree_hash,
layout.worktree_root(),
&RestoreOptions::default(),
)?;
}
} else {
let snapshot = index::from_tree(store, tree_hash)?;
for e in &snapshot.entries {
let abs = layout.worktree_root().join(&e.path);
if let Err(err) = std::fs::remove_file(&abs)
&& err.kind() != std::io::ErrorKind::NotFound
{
return Err(StashError::Io(err));
}
let mut parent = abs.parent();
while let Some(dir) = parent {
if dir == layout.worktree_root() || std::fs::remove_dir(dir).is_err() {
break;
}
parent = dir.parent();
}
}
}
let _ = index::write_index(layout, &Index::new());
Ok(())
}
pub fn list(layout: &RepoLayout) -> StashResult<StashList> {
read_list(layout)
}
pub fn entry_tree_hash(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<Hash> {
let list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let obj = store.read_object(&list.entries[idx].commit_hash)?;
let Object::Commit(commit) = obj else {
return Err(StashError::NotACommit);
};
Ok(commit.tree_hash)
}
pub fn entry_index(
store: &ObjectStore,
layout: &RepoLayout,
idx: usize,
) -> StashResult<Option<Index>> {
let list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let Object::Commit(stash_commit) = store.read_object(&list.entries[idx].commit_hash)? else {
return Err(StashError::NotACommit);
};
let Some(index_commit) = stash_commit.parents.last() else {
return Ok(None);
};
let parent_hash = list.entries[idx].parent_hash;
if *index_commit == parent_hash {
return Ok(None);
}
let Object::Commit(index_commit) = store.read_object(index_commit)? else {
return Err(StashError::NotACommit);
};
let Object::Tree(wrapper) = store.read_object(&index_commit.tree_hash)? else {
return Err(StashError::InvalidFormat);
};
let has_index_blob = wrapper.entries.iter().any(|e| e.name == b"i");
let has_content_tree = wrapper.entries.iter().any(|e| e.name == b"t");
if !(has_index_blob && has_content_tree) {
return Err(StashError::InvalidFormat);
}
let blob_entry = wrapper
.entries
.iter()
.find(|e| e.name == b"i")
.ok_or(StashError::InvalidFormat)?;
let Object::Blob(blob) = store.read_object(&blob_entry.object_hash)? else {
return Err(StashError::InvalidFormat);
};
Ok(Some(index::deserialize(&blob.data)?))
}
pub fn pop(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<()> {
let mut list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let entry = list.entries[idx].clone();
let obj = store.read_object(&entry.commit_hash)?;
let Object::Commit(commit) = obj else {
return Err(StashError::NotACommit);
};
let ts = unix_seconds_now();
crate::ops::recovery::record(
layout,
&crate::ops::recovery::RecoveryEntry {
timestamp: ts,
op: "stash-pop".to_string(),
superseded: entry.commit_hash,
branch: String::new(),
},
)
.map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
restore::restore_tree(
store,
commit.tree_hash,
layout.worktree_root(),
&RestoreOptions::default(),
)?;
list.entries.remove(idx);
write_list(layout, &list)?;
Ok(())
}
pub fn pop_finalize(layout: &RepoLayout, idx: usize) -> StashResult<()> {
let mut list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let entry = list.entries[idx].clone();
let ts = unix_seconds_now();
crate::ops::recovery::record(
layout,
&crate::ops::recovery::RecoveryEntry {
timestamp: ts,
op: "stash-pop".to_string(),
superseded: entry.commit_hash,
branch: String::new(),
},
)
.map_err(|e| StashError::Io(io::Error::other(format!("recovery log: {e}"))))?;
list.entries.remove(idx);
write_list(layout, &list)?;
Ok(())
}
pub fn apply(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<()> {
let list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let entry = &list.entries[idx];
let obj = store.read_object(&entry.commit_hash)?;
let Object::Commit(commit) = obj else {
return Err(StashError::NotACommit);
};
restore::restore_tree(
store,
commit.tree_hash,
layout.worktree_root(),
&RestoreOptions::default(),
)?;
Ok(())
}
pub fn clear(layout: &RepoLayout) -> StashResult<()> {
write_list(layout, &StashList::default())
}
pub fn render_stash_show(
store: &ObjectStore,
layout: &RepoLayout,
idx: usize,
) -> StashResult<String> {
let list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let entry = &list.entries[idx];
let stash_obj = store.read_object(&entry.commit_hash)?;
let Object::Commit(stash_commit) = stash_obj else {
return Err(StashError::NotACommit);
};
let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
None
} else {
match store.read_object(&entry.parent_hash) {
Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
_ => None,
}
};
let diff = diff_trees(store, parent_tree, Some(stash_commit.tree_hash))?;
let mut out = String::new();
let _ = writeln!(out, "stash@{{{idx}}}: {}", entry.message);
let _ = writeln!(out, "Date: {}", entry.timestamp);
let _ = writeln!(out);
for e in &diff.entries {
let tag = match e.kind {
DiffKind::Added => "A",
DiffKind::Removed => "D",
DiffKind::Modified => "M",
DiffKind::ModeChanged => "T",
DiffKind::Renamed => "R",
};
let _ = writeln!(out, "{tag} {}", e.path);
}
Ok(out)
}
pub fn show_diff(store: &ObjectStore, layout: &RepoLayout, idx: usize) -> StashResult<DiffResult> {
let list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
let entry = &list.entries[idx];
let stash_obj = store.read_object(&entry.commit_hash)?;
let Object::Commit(stash_commit) = stash_obj else {
return Err(StashError::NotACommit);
};
let parent_tree: Option<Hash> = if entry.parent_hash == ZERO {
None
} else {
match store.read_object(&entry.parent_hash) {
Ok(Object::Commit(parent_commit)) => Some(parent_commit.tree_hash),
_ => None,
}
};
Ok(diff_trees(
store,
parent_tree,
Some(stash_commit.tree_hash),
)?)
}
pub fn drop(layout: &RepoLayout, idx: usize) -> StashResult<()> {
let mut list = read_list(layout)?;
if idx >= list.entries.len() {
return Err(StashError::IndexOutOfRange(idx));
}
list.entries.remove(idx);
write_list(layout, &list)?;
Ok(())
}
fn stash_path(layout: &RepoLayout) -> PathBuf {
layout.stash_file()
}
fn read_list(layout: &RepoLayout) -> StashResult<StashList> {
let path = stash_path(layout);
let meta = match fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(StashList::default()),
Err(e) => return Err(StashError::Io(e)),
};
if meta.len() == 0 {
return Ok(StashList::default());
}
if meta.len() > MAX_MANIFEST_BYTES {
return Err(StashError::ManifestTooLarge);
}
let data = fs::read(&path)?;
deserialize_list(&data)
}
fn write_list(layout: &RepoLayout, list: &StashList) -> StashResult<()> {
let bytes = serialize_list(list)?;
let path = stash_path(layout);
atomic::write_atomic(&path, &bytes, true)?;
Ok(())
}
pub fn write_list_test_only(layout: &RepoLayout, list: &StashList) {
write_list(layout, list).expect("write_list_test_only failed");
}
pub fn serialize_list(list: &StashList) -> StashResult<Vec<u8>> {
let mut total = 4 + 4;
for e in &list.entries {
if e.message.len() > MAX_MESSAGE_LEN {
return Err(StashError::MessageTooLong);
}
total += 32 + 32 + 4 + 2 + e.message.len();
}
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&MAGIC);
out.extend_from_slice(
&u32::try_from(list.entries.len())
.unwrap_or(u32::MAX)
.to_le_bytes(),
);
for e in &list.entries {
out.extend_from_slice(&e.commit_hash);
out.extend_from_slice(&e.parent_hash);
out.extend_from_slice(&e.timestamp.to_le_bytes());
let len_u16 = u16::try_from(e.message.len()).map_err(|_| StashError::MessageTooLong)?;
out.extend_from_slice(&len_u16.to_le_bytes());
out.extend_from_slice(e.message.as_bytes());
}
Ok(out)
}
pub fn deserialize_list(data: &[u8]) -> StashResult<StashList> {
if data.len() < 8 {
return Err(StashError::InvalidFormat);
}
if &data[..4] != MAGIC.as_slice() {
return Err(StashError::InvalidFormat);
}
let count = u32::from_le_bytes(
data[4..8]
.try_into()
.map_err(|_| StashError::InvalidFormat)?,
) as usize;
if (count as u64).saturating_mul(MIN_ENTRY_BYTES) > data.len() as u64 {
return Err(StashError::InvalidFormat);
}
let mut entries = Vec::with_capacity(count);
let mut pos = 8usize;
for _ in 0..count {
if pos + 32 + 32 + 4 + 2 > data.len() {
return Err(StashError::InvalidFormat);
}
let mut commit_hash = [0u8; 32];
commit_hash.copy_from_slice(&data[pos..pos + 32]);
pos += 32;
let mut parent_hash = [0u8; 32];
parent_hash.copy_from_slice(&data[pos..pos + 32]);
pos += 32;
let timestamp = u32::from_le_bytes(
data[pos..pos + 4]
.try_into()
.map_err(|_| StashError::InvalidFormat)?,
);
pos += 4;
let msg_len = u16::from_le_bytes(
data[pos..pos + 2]
.try_into()
.map_err(|_| StashError::InvalidFormat)?,
) as usize;
pos += 2;
if pos + msg_len > data.len() {
return Err(StashError::InvalidFormat);
}
let msg = String::from_utf8(data[pos..pos + msg_len].to_vec())
.map_err(|_| StashError::InvalidFormat)?;
pos += msg_len;
entries.push(StashEntry {
commit_hash,
parent_hash,
timestamp,
message: msg,
});
}
Ok(StashList { entries })
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash;
use crate::object::{Blob, Commit, EntryMode, Identity, Object, Tree, TreeEntry};
use crate::ops::diff::DiffKind;
use crate::serialize;
use crate::store::ObjectStore;
use tempfile::TempDir;
fn fresh_store() -> (TempDir, ObjectStore) {
let dir = TempDir::new().unwrap();
let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
(dir, store)
}
fn put_blob_data(store: &ObjectStore, data: &[u8]) -> Hash {
let obj = Object::Blob(Blob {
data: data.to_vec(),
});
store.write(&serialize::serialize(&obj).unwrap()).unwrap()
}
fn put_tree_entries(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
let obj = Object::Tree(Tree { entries });
store.write(&serialize::serialize(&obj).unwrap()).unwrap()
}
fn put_commit_obj(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
let commit = Object::Commit(Commit::new_unannotated(
tree_h,
parents,
Identity::ed25519([0u8; 32]),
[0u8; 32],
b"msg".to_vec(),
ts,
[0u8; 64],
));
store
.write(&serialize::serialize(&commit).unwrap())
.unwrap()
}
fn build_stash_fixture(store: &ObjectStore, layout: &RepoLayout) {
let blob_v1 = put_blob_data(store, b"original content");
let parent_tree = put_tree_entries(
store,
vec![TreeEntry {
name: b"existing.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_v1,
}],
);
let parent_commit = put_commit_obj(store, parent_tree, vec![], 1_000_000);
let blob_v2 = put_blob_data(store, b"modified content");
let blob_new = put_blob_data(store, b"brand new file");
let stash_tree = put_tree_entries(
store,
vec![
TreeEntry {
name: b"existing.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_v2,
},
TreeEntry {
name: b"new.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_new,
},
],
);
let stash_commit = put_commit_obj(store, stash_tree, vec![parent_commit], 1_000_001);
let list = StashList {
entries: vec![StashEntry {
commit_hash: stash_commit,
parent_hash: parent_commit,
timestamp: 1_000_001_u32,
message: "WIP: stash message".to_string(),
}],
};
write_list(layout, &list).unwrap();
}
#[test]
fn show_diff_returns_correct_entries() {
let (tmp, store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
build_stash_fixture(&store, &layout);
let diff = show_diff(&store, &layout, 0).unwrap();
assert_eq!(diff.entries.len(), 2, "expected 2 diff entries");
let existing = diff.entries.iter().find(|e| e.path == "existing.txt");
let new_f = diff.entries.iter().find(|e| e.path == "new.txt");
assert!(existing.is_some(), "existing.txt must appear in diff");
assert!(new_f.is_some(), "new.txt must appear in diff");
assert_eq!(existing.unwrap().kind, DiffKind::Modified);
assert_eq!(new_f.unwrap().kind, DiffKind::Added);
}
#[test]
fn render_stash_show_header_and_entries() {
let (tmp, store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
build_stash_fixture(&store, &layout);
let output = render_stash_show(&store, &layout, 0).unwrap();
assert!(
output.contains("stash@{0}:"),
"missing stash header: {output}"
);
assert!(
output.contains("WIP: stash message"),
"missing message: {output}"
);
assert!(output.contains("Date:"), "missing date line: {output}");
assert!(
output.contains("M existing.txt"),
"missing modified entry: {output}"
);
assert!(
output.contains("A new.txt"),
"missing added entry: {output}"
);
}
#[test]
fn apply_restores_tree_and_keeps_entry() {
let (tmp, store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
build_stash_fixture(&store, &layout);
assert_eq!(read_list(&layout).unwrap().entries.len(), 1);
apply(&store, &layout, 0).unwrap();
assert_eq!(
fs::read(tmp.path().join("existing.txt")).unwrap(),
b"modified content"
);
assert_eq!(
fs::read(tmp.path().join("new.txt")).unwrap(),
b"brand new file"
);
assert_eq!(
read_list(&layout).unwrap().entries.len(),
1,
"apply must not drop the entry"
);
}
#[test]
fn entry_index_round_trips_index_including_staged_deletions() {
let dir = tempfile::TempDir::new().unwrap();
let layout = RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let blob_a = put_blob_data(&store, b"a");
let tree = put_tree_entries(
&store,
vec![TreeEntry {
name: b"a.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_a,
}],
);
let head = put_commit_obj(&store, tree, vec![], 5);
refs::write_head_branch(&layout, "main").unwrap();
refs::write_ref(&layout, "main", &head).unwrap();
std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
let staged = Index::from_entries(vec![
index::IndexEntry {
path: "a.txt".to_string(),
status: index::EntryStatus::Blob,
object_hash: blob_a,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
},
index::IndexEntry {
path: "b.txt".to_string(),
status: index::EntryStatus::Removed,
object_hash: ZERO,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
},
]);
index::write_index(&layout, &staged).unwrap();
save(&store, &layout, "wip").unwrap();
let restored = entry_index(&store, &layout, 0)
.unwrap()
.expect("save must record an index snapshot");
let b = restored
.entries
.iter()
.find(|e| e.path == "b.txt")
.expect("the staged deletion must be present");
assert_eq!(
b.status,
index::EntryStatus::Removed,
"staged deletion must round-trip as Removed"
);
assert!(
restored
.entries
.iter()
.any(|e| e.path == "a.txt" && e.status == index::EntryStatus::Blob),
"the present staged file must round-trip too"
);
}
#[test]
fn entry_index_none_for_single_parent_entry() {
let (tmp, store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
build_stash_fixture(&store, &layout);
assert!(entry_index(&store, &layout, 0).unwrap().is_none());
}
#[test]
fn entry_index_fails_closed_on_corrupt_multiparent_wrapper() {
let (tmp, store) = fresh_store();
let root = &RepoLayout::single(tmp.path());
let head_tree = put_tree_entries(&store, vec![]);
let head_commit = put_commit_obj(&store, head_tree, vec![], 1);
let bogus_tree = put_tree_entries(
&store,
vec![TreeEntry {
name: b"not_a_wrapper".to_vec(),
mode: EntryMode::Blob,
object_hash: put_blob_data(&store, b"junk"),
}],
);
let bogus_index_commit = put_commit_obj(&store, bogus_tree, vec![head_commit], 2);
let stash_commit =
put_commit_obj(&store, head_tree, vec![head_commit, bogus_index_commit], 3);
write_list(
root,
&StashList {
entries: vec![StashEntry {
commit_hash: stash_commit,
parent_hash: head_commit,
timestamp: 3,
message: "WIP".to_string(),
}],
},
)
.unwrap();
assert!(
matches!(entry_index(&store, root, 0), Err(StashError::InvalidFormat)),
"a corrupt multi-parent wrapper must fail closed"
);
}
#[test]
fn entry_index_fails_closed_on_corrupt_unborn_wrapper() {
let (tmp, store) = fresh_store();
let root = &RepoLayout::single(tmp.path());
let bogus_tree = put_tree_entries(
&store,
vec![TreeEntry {
name: b"junk".to_vec(),
mode: EntryMode::Blob,
object_hash: put_blob_data(&store, b"x"),
}],
);
let index_commit = put_commit_obj(&store, bogus_tree, vec![], 1);
let stash_commit = put_commit_obj(&store, bogus_tree, vec![index_commit], 2);
write_list(
root,
&StashList {
entries: vec![StashEntry {
commit_hash: stash_commit,
parent_hash: ZERO, timestamp: 2,
message: "WIP".to_string(),
}],
},
)
.unwrap();
assert!(
matches!(entry_index(&store, root, 0), Err(StashError::InvalidFormat)),
"a corrupt unborn [index] wrapper must fail closed"
);
}
#[test]
fn entry_index_legacy_head_with_coincidental_markers_reads_as_none() {
let (tmp, store) = fresh_store();
let root = &RepoLayout::single(tmp.path());
let head_tree = put_tree_entries(
&store,
vec![
TreeEntry {
name: b"i".to_vec(),
mode: EntryMode::Blob,
object_hash: put_blob_data(&store, b"not an index"),
},
TreeEntry {
name: b"t".to_vec(),
mode: EntryMode::Blob,
object_hash: put_blob_data(&store, b"whatever"),
},
],
);
let head_commit = put_commit_obj(&store, head_tree, vec![], 1);
let stash_commit = put_commit_obj(&store, head_tree, vec![head_commit], 2);
write_list(
root,
&StashList {
entries: vec![StashEntry {
commit_hash: stash_commit,
parent_hash: head_commit, timestamp: 2,
message: "legacy".to_string(),
}],
},
)
.unwrap();
assert!(
entry_index(&store, root, 0).unwrap().is_none(),
"legacy [HEAD] must read as no-index regardless of tree shape"
);
}
#[test]
fn apply_out_of_range_returns_error() {
let (tmp, _store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
let store = ObjectStore::open(&layout).unwrap();
let err = apply(&store, &layout, 0).unwrap_err();
assert!(matches!(err, StashError::IndexOutOfRange(0)));
}
#[test]
fn clear_empties_the_stack() {
let (tmp, store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
build_stash_fixture(&store, &layout);
assert_eq!(read_list(&layout).unwrap().entries.len(), 1);
clear(&layout).unwrap();
assert!(read_list(&layout).unwrap().entries.is_empty());
clear(&layout).unwrap();
assert!(read_list(&layout).unwrap().entries.is_empty());
}
#[test]
fn show_diff_out_of_range_returns_error() {
let (tmp, _store) = fresh_store();
let layout = RepoLayout::single(tmp.path());
let store = ObjectStore::open(&layout).unwrap();
let err = show_diff(&store, &layout, 0).unwrap_err();
assert!(matches!(err, StashError::IndexOutOfRange(0)));
}
#[test]
fn manifest_roundtrip_two_entries() {
let list = StashList {
entries: vec![
StashEntry {
commit_hash: hash::hash(b"commit1"),
parent_hash: hash::hash(b"parent1"),
timestamp: 1000,
message: "first stash".to_string(),
},
StashEntry {
commit_hash: hash::hash(b"commit2"),
parent_hash: ZERO,
timestamp: 2000,
message: "second stash".to_string(),
},
],
};
let bytes = serialize_list(&list).unwrap();
let back = deserialize_list(&bytes).unwrap();
assert_eq!(back, list);
}
#[test]
fn deserialize_rejects_short_data() {
assert!(matches!(
deserialize_list(&[0u8; 4]),
Err(StashError::InvalidFormat)
));
}
#[test]
fn deserialize_rejects_bad_magic() {
assert!(matches!(
deserialize_list(&[b'X', b'Y', b'Z', b'W', 0, 0, 0, 0]),
Err(StashError::InvalidFormat)
));
}
#[test]
fn deserialize_rejects_bogus_huge_count() {
let mut bytes = Vec::new();
bytes.extend_from_slice(MAGIC.as_slice());
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(
deserialize_list(&bytes),
Err(StashError::InvalidFormat)
));
}
#[test]
fn pop_records_recovery_entry_for_popped_commit() {
let dir = tempfile::TempDir::new().unwrap();
let layout = RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
std::fs::write(dir.path().join("file.txt"), b"stash me").unwrap();
save(&store, &layout, "wip").unwrap();
let entry_hash = read_list(&layout).unwrap().entries[0].commit_hash;
pop(&store, &layout, 0).unwrap();
let log = crate::ops::recovery::read_all(&layout).unwrap();
assert!(
log.iter()
.any(|e| e.op == "stash-pop" && e.superseded == entry_hash),
"popped stash commit must be recorded as recoverable; log: {log:?}"
);
assert!(read_list(&layout).unwrap().entries.is_empty());
}
}