use std::{fs, path::Path};
use objects::{
object::{ContentHash, ThreadName},
store::ObjectStore,
};
use repo::Repository;
use tempfile::TempDir;
use super::heddle;
fn delete_loose_tree(repo_root: &Path, target_hex: &str) -> bool {
let prefix = &target_hex[..2];
let rest = &target_hex[2..];
let path = repo_root
.join(".heddle/objects/trees")
.join(prefix)
.join(rest);
match fs::remove_file(&path) {
Ok(()) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => panic!("failed to delete loose tree at {path:?}: {error}"),
}
}
fn current_state_tree_hex(repo_root: &Path) -> String {
let repo = Repository::open(repo_root).unwrap();
let tip = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.expect("main thread must have a tip after capture");
let state = repo
.store()
.get_state(&tip)
.unwrap()
.expect("state must exist after capture");
state.tree.to_hex()
}
fn assert_missing_tree_error(err: &str, tree_hex: &str) {
assert!(
err.contains("missing") && err.contains("tree"),
"error must surface the missing-tree diagnostic so the operator can tell \
store corruption from a normal absent-state case; got: {err}"
);
assert!(
err.contains(tree_hex),
"error must include the missing tree's hash so the operator can correlate \
with `heddle fsck` output; got: {err}"
);
assert!(
err.contains("heddle fsck"),
"error must point at the recovery command so the operator has a next step \
instead of just a stack trace; got: {err}"
);
}
#[test]
fn test_status_missing_tree_fails_loud_not_silent_empty() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "hello\n").unwrap();
heddle(&["capture", "-m", "initial"], Some(temp.path())).unwrap();
let tree_hex = current_state_tree_hex(temp.path());
assert!(
delete_loose_tree(temp.path(), &tree_hex),
"test setup: expected to find loose tree at hash {tree_hex} to delete",
);
let err = heddle(&["status"], Some(temp.path())).expect_err(
"status against a corrupt baseline tree must fail loud; \
pre-#93 it silently rendered the entire worktree as 'added' content",
);
assert_missing_tree_error(&err, &tree_hex);
}
#[test]
fn test_ready_missing_tree_fails_loud_not_silent_empty() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "hello\n").unwrap();
heddle(&["capture", "-m", "initial"], Some(temp.path())).unwrap();
let tree_hex = current_state_tree_hex(temp.path());
assert!(
delete_loose_tree(temp.path(), &tree_hex),
"test setup: expected to find loose tree at hash {tree_hex} to delete",
);
let err = heddle(&["ready"], Some(temp.path())).expect_err(
"ready against a corrupt baseline tree must fail loud; \
pre-#93 it silently reported the worktree as dirty against an empty baseline",
);
assert_missing_tree_error(&err, &tree_hex);
}
#[test]
fn test_revert_missing_parent_tree_fails_loud_not_silent_empty() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "first\n").unwrap();
let first_capture = heddle(&["capture", "-m", "first"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "second\n").unwrap();
heddle(&["capture", "-m", "second"], Some(temp.path())).unwrap();
let parent_tree_hex = {
let repo = Repository::open(temp.path()).unwrap();
let tip = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let second_state = repo.store().get_state(&tip).unwrap().unwrap();
let parent_id = second_state
.first_parent()
.expect("second state must have a parent");
let parent_state = repo.store().get_state(parent_id).unwrap().unwrap();
parent_state.tree.to_hex()
};
assert!(
delete_loose_tree(temp.path(), &parent_tree_hex),
"test setup: expected to find loose tree at parent hash {parent_tree_hex}",
);
let _ = first_capture;
let err = heddle(&["revert", "@"], Some(temp.path())).expect_err(
"revert against a state whose parent tree is corrupt must fail loud; \
pre-#93 it silently computed an inverse diff against an empty baseline",
);
assert_missing_tree_error(&err, &parent_tree_hex);
}
#[test]
fn test_stash_pop_missing_tree_fails_loud_not_silent_noop() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "baseline\n").unwrap();
heddle(&["capture", "-m", "baseline"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "dirty\n").unwrap();
fs::write(temp.path().join("b.txt"), "new file\n").unwrap();
heddle(&["stash", "push"], Some(temp.path())).unwrap();
let stash_tree_hex = {
let repo = Repository::open(temp.path()).unwrap();
let stash = repo
.stash_manager()
.top()
.unwrap()
.expect("stash must exist after push");
ContentHash::from_hex(&stash.tree_hash)
.expect("stash tree hash must be valid hex")
.to_hex()
};
assert!(
delete_loose_tree(temp.path(), &stash_tree_hex),
"test setup: expected to find loose tree at stash hash {stash_tree_hex}",
);
let err = heddle(&["stash", "pop"], Some(temp.path())).expect_err(
"stash pop against a corrupt stash tree must fail loud; \
pre-#93 it silently completed with no entries applied",
);
assert_missing_tree_error(&err, &stash_tree_hex);
}
#[test]
fn test_clean_force_missing_tree_fails_loud_not_wipe_tracked_files() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("tracked.txt"), "tracked\n").unwrap();
heddle(&["capture", "-m", "initial"], Some(temp.path())).unwrap();
let tree_hex = current_state_tree_hex(temp.path());
assert!(
delete_loose_tree(temp.path(), &tree_hex),
"test setup: expected to find loose tree at hash {tree_hex} to delete",
);
let err = heddle(&["clean", "--force"], Some(temp.path())).expect_err(
"clean --force against a corrupt baseline tree must fail loud; \
pre-#93 it silently deleted every tracked file in the worktree",
);
assert_missing_tree_error(&err, &tree_hex);
assert!(
temp.path().join("tracked.txt").exists(),
"failed clean must not partially delete: tracked.txt should still exist",
);
}
#[test]
fn test_goto_missing_current_tree_fails_loud_not_silent_dirty() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "first\n").unwrap();
heddle(&["capture", "-m", "first"], Some(temp.path())).unwrap();
fs::write(temp.path().join("a.txt"), "second\n").unwrap();
heddle(&["capture", "-m", "second"], Some(temp.path())).unwrap();
let (current_tree_hex, first_state_id) = {
let repo = Repository::open(temp.path()).unwrap();
let tip = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let second_state = repo.store().get_state(&tip).unwrap().unwrap();
let parent_id = second_state
.first_parent()
.copied()
.expect("second state must have a parent");
(second_state.tree.to_hex(), parent_id.to_string())
};
assert!(
delete_loose_tree(temp.path(), ¤t_tree_hex),
"test setup: expected to find loose tree at current hash {current_tree_hex}",
);
let err = heddle(&["switch", &first_state_id], Some(temp.path())).expect_err(
"goto against a corrupt current tree must fail loud; \
pre-#93 it silently treated the worktree as dirty against an empty baseline",
);
assert_missing_tree_error(&err, ¤t_tree_hex);
}