use objects::object::ThreadName;
use super::*;
fn head_short(root: &std::path::Path) -> String {
let repo = Repository::open(root).unwrap();
repo.head().unwrap().expect("repo has HEAD").short()
}
fn wipe_pack_store(repo_root: &std::path::Path) {
let packs_dir = repo_root.join(".heddle/packs");
if !packs_dir.exists() {
return;
}
for entry in std::fs::read_dir(&packs_dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
std::fs::remove_dir_all(&path).unwrap();
} else {
std::fs::remove_file(&path).unwrap();
}
}
}
#[test]
fn test_undo_at_beginning() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
heddle_must_succeed(&["capture", "-m", "Initial"], temp.path());
let result = heddle(&["undo"], Some(temp.path()));
assert!(result.is_ok());
let result = heddle(&["undo"], Some(temp.path()));
assert!(result.is_err());
}
#[test]
fn test_redo_without_undo() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
heddle_must_succeed(&["capture", "-m", "Initial"], temp.path());
let result = heddle(&["undo", "--redo"], Some(temp.path()));
assert!(result.is_err());
}
#[test]
fn test_large_file_handling() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
let large_content = vec![0u8; 1024 * 1024];
std::fs::write(temp.path().join("large.bin"), &large_content).unwrap();
heddle_must_succeed(&["capture", "-m", "Large file"], temp.path());
let retrieved = std::fs::read(temp.path().join("large.bin")).unwrap();
assert_eq!(retrieved.len(), large_content.len());
}
#[test]
fn test_spaces_in_filename() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("file with spaces.txt"), "content").unwrap();
heddle_must_succeed(&["capture", "-m", "Spaces in name"], temp.path());
assert!(temp.path().join("file with spaces.txt").exists());
let result = heddle(&["status", "--output", "json"], Some(temp.path())).unwrap();
let status: Value = serde_json::from_str(&result).expect("Status should be valid JSON");
let changes = status.get("changes").expect("Should have changes field");
let modified = changes.get("modified").and_then(|m| m.as_array()).unwrap();
let added = changes.get("added").and_then(|a| a.as_array()).unwrap();
assert!(modified.is_empty() && added.is_empty());
}
#[test]
fn test_unicode_filename() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("файл.txt"), "unicode content").unwrap();
std::fs::write(temp.path().join("文件.txt"), "chinese content").unwrap();
std::fs::write(temp.path().join("emoji_😀.txt"), "emoji content").unwrap();
heddle_must_succeed(&["capture", "-m", "Unicode filenames"], temp.path());
assert!(temp.path().join("файл.txt").exists());
assert!(temp.path().join("文件.txt").exists());
assert!(temp.path().join("emoji_😀.txt").exists());
}
#[test]
fn test_undo_preserves_ignored_siblings_in_tracked_dirs() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(
temp.path().join(".heddleignore"),
"target/\nnode_modules/\n",
)
.unwrap();
heddle_must_succeed(&["capture", "-m", "ignore colocated git"], temp.path());
std::fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
std::fs::create_dir_all(temp.path().join("web")).unwrap();
std::fs::write(temp.path().join("web/index.html"), "<html/>").unwrap();
heddle_must_succeed(&["capture", "-m", "tracked"], temp.path());
std::fs::create_dir_all(temp.path().join("web/node_modules/lodash")).unwrap();
std::fs::write(
temp.path().join("web/node_modules/lodash/index.js"),
"ignored",
)
.unwrap();
std::fs::create_dir_all(temp.path().join("target")).unwrap();
std::fs::write(temp.path().join("target/foo.bin"), "build").unwrap();
heddle(&["undo", "-n", "1"], Some(temp.path())).expect("undo must succeed");
assert!(!temp.path().join("main.rs").exists());
assert!(!temp.path().join("web/index.html").exists());
assert!(
temp.path()
.join("web/node_modules/lodash/index.js")
.exists()
);
assert!(temp.path().join("target/foo.bin").exists());
let status_json = heddle_must_succeed(&["status", "--output", "json"], temp.path());
let status: Value = serde_json::from_str(&status_json).unwrap();
let changes = status.get("changes").unwrap();
assert!(changes["modified"].as_array().unwrap().is_empty());
assert!(changes["added"].as_array().unwrap().is_empty());
assert!(changes["deleted"].as_array().unwrap().is_empty());
}
#[test]
fn test_undo_refuses_when_untracked_file_present() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("seed.txt"), "seed").unwrap();
heddle_must_succeed(&["capture", "-m", "seed"], temp.path());
std::fs::write(temp.path().join("a.txt"), "a").unwrap();
heddle_must_succeed(&["capture", "-m", "tracked"], temp.path());
let untracked = temp.path().join("my-notes.md");
std::fs::write(&untracked, "user-written content").unwrap();
let err = heddle(&["undo", "-n", "1", "--output", "json"], Some(temp.path()))
.expect_err("undo must refuse on dirty worktree");
assert!(
err.contains("untracked"),
"error should mention untracked: {err}"
);
assert!(untracked.exists(), "untracked file must survive refusal");
assert!(
temp.path().join("a.txt").exists(),
"tracked file must survive refusal"
);
}
#[test]
fn test_undo_refuses_when_tracked_file_modified() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("seed.txt"), "seed").unwrap();
heddle_must_succeed(&["capture", "-m", "seed"], temp.path());
std::fs::write(temp.path().join("a.txt"), "original").unwrap();
heddle_must_succeed(&["capture", "-m", "tracked"], temp.path());
std::fs::write(temp.path().join("a.txt"), "uncommitted edit").unwrap();
let err = heddle(&["undo", "-n", "1", "--output", "json"], Some(temp.path()))
.expect_err("undo must refuse with modified file");
assert!(
err.contains("modified"),
"error should mention modified: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("a.txt")).unwrap(),
"uncommitted edit",
"modification must survive refusal"
);
heddle_must_succeed(&["capture", "-m", "edit"], temp.path());
heddle(&["undo", "-n", "1"], Some(temp.path())).expect("undo succeeds once worktree is clean");
}
#[test]
fn test_undo_with_dotgit_directory_present() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join(".heddleignore"), ".git/\n").unwrap();
heddle_must_succeed(&["capture", "-m", "empty"], temp.path());
std::fs::write(temp.path().join("file.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "v1"], temp.path());
std::fs::create_dir_all(temp.path().join(".git/objects/01")).unwrap();
std::fs::write(temp.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(temp.path().join(".git/objects/01/abc"), "fake git object").unwrap();
heddle(&["undo", "-n", "1"], Some(temp.path())).expect("undo must succeed alongside .git");
assert!(!temp.path().join("file.txt").exists());
assert!(
temp.path().join(".git/HEAD").exists(),
".git must survive undo"
);
assert!(
temp.path().join(".git/objects/01/abc").exists(),
".git contents must survive undo"
);
let repo = Repository::open(temp.path()).unwrap();
let head = repo.head().unwrap().expect("repo has HEAD");
let tree = repo.get_tree_for_state(&head).unwrap().expect("HEAD tree");
assert!(
repo.compare_worktree_cached_detailed(&tree)
.unwrap()
.is_clean(),
"worktree must match HEAD after undo"
);
}
#[test]
fn test_undo_capture_restores_head_to_parent() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("a.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "first"], temp.path());
let parent = head_short(temp.path());
std::fs::write(temp.path().join("a.txt"), "v2").unwrap();
heddle_must_succeed(&["capture", "-m", "second"], temp.path());
let after_second = head_short(temp.path());
assert_ne!(
parent, after_second,
"second capture must produce a fresh state"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
parent,
"undo of capture must restore HEAD to the immediate parent state"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("a.txt")).unwrap(),
"v1",
"worktree must reflect the parent state's tree after undo"
);
}
#[test]
fn test_undo_captures_pre_undo_state_into_recovery_marker() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
std::fs::write(temp.path().join("notes.md"), "FRICTION ONE\nFRICTION TWO\n").unwrap();
heddle_must_succeed(&["capture", "-m", "friction"], temp.path());
let friction_state = head_short(temp.path());
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"base\n",
"undo must reset the worktree to the parent state"
);
let repo = Repository::open(temp.path()).unwrap();
let recovery = repo
.refs()
.get_undo_recovery()
.unwrap()
.expect("undo must record the pre-undo state in the internal recovery ref");
assert_eq!(
recovery.short(),
friction_state,
"the recovery ref must point at the pre-undo (friction) state, not the reset target"
);
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION ONE\nFRICTION TWO\n",
"redo must restore the friction content to the worktree"
);
}
#[test]
fn test_undo_recover_survives_divergent_capture() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
std::fs::write(temp.path().join("notes.md"), "FRICTION ONE\nFRICTION TWO\n").unwrap();
heddle_must_succeed(&["capture", "-m", "friction"], temp.path());
let friction_state = head_short(temp.path());
let undo: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "undo"],
temp.path(),
))
.unwrap();
assert_eq!(undo["next_action"], "heddle undo --recover");
std::fs::write(temp.path().join("notes.md"), "different direction\n").unwrap();
heddle_must_succeed(&["capture", "-m", "diverge"], temp.path());
let divergent_tip = head_short(temp.path());
let head_before = Repository::open(temp.path())
.unwrap()
.refs()
.read_head()
.unwrap();
let recovered: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "undo", "--recover"],
temp.path(),
))
.unwrap();
assert_eq!(recovered["output_kind"], "undo_recover");
assert_eq!(recovered["action"], "recover");
assert_eq!(recovered["status"], "completed");
assert_eq!(recovered["recovery_state"], friction_state);
assert!(
recovered["recommended_action"]
.as_str()
.is_some_and(|action| action.starts_with("heddle capture")),
"{recovered}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION ONE\nFRICTION TWO\n"
);
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(
repo.head().unwrap().map(|state| state.short()),
Some(divergent_tip)
);
assert_eq!(repo.refs().read_head().unwrap(), head_before);
let status: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "status"],
temp.path(),
))
.unwrap();
assert_eq!(status["verification"]["worktree_state"], "dirty");
}
#[test]
fn test_undo_recover_ref_lives_outside_user_marker_namespace() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
std::fs::write(temp.path().join("notes.md"), "FRICTION\n").unwrap();
heddle_must_succeed(&["capture", "-m", "friction"], temp.path());
let friction_state = head_short(temp.path());
heddle_must_succeed(&["undo"], temp.path());
let markers: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "thread", "marker", "list"],
temp.path(),
))
.unwrap();
assert!(
markers["markers"]
.as_array()
.unwrap()
.iter()
.all(|marker| marker["name"] != "undo-recovery")
);
heddle_must_succeed(
&["thread", "marker", "create", "undo-recovery"],
temp.path(),
);
heddle_must_succeed(
&["thread", "marker", "delete", "undo-recovery"],
temp.path(),
);
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(
repo.refs()
.get_undo_recovery()
.unwrap()
.map(|state| state.short()),
Some(friction_state.clone())
);
let head_before_recovery = head_short(temp.path());
heddle_must_succeed(&["undo", "--recover"], temp.path());
assert_eq!(head_short(temp.path()), head_before_recovery);
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION\n"
);
}
#[test]
fn test_undo_recover_is_unshadowable_by_same_named_user_marker() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
let base_state = head_short(temp.path());
heddle_must_succeed(
&["thread", "marker", "create", "undo-recovery"],
temp.path(),
);
std::fs::write(temp.path().join("notes.md"), "FRICTION\n").unwrap();
heddle_must_succeed(&["capture", "-m", "friction"], temp.path());
let friction_state = head_short(temp.path());
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
Repository::open(temp.path())
.unwrap()
.refs()
.get_undo_recovery()
.unwrap()
.map(|state| state.short()),
Some(friction_state)
);
heddle_must_succeed(&["undo", "--recover"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION\n"
);
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(
repo.head().unwrap().map(|state| state.short()),
Some(base_state.clone())
);
assert_eq!(
repo.refs()
.get_marker(&objects::object::MarkerName::new("undo-recovery"))
.unwrap()
.map(|state| state.short()),
Some(base_state)
);
}
#[test]
fn test_undo_recover_refuses_without_recovery_state_or_clean_worktree() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
let absent = heddle(
&["--output", "json", "undo", "--recover"],
Some(temp.path()),
)
.expect_err("recovery without a preserved state must refuse");
assert!(absent.contains("undo_recovery_unavailable"), "{absent}");
std::fs::write(temp.path().join("notes.md"), "recover me\n").unwrap();
heddle_must_succeed(&["capture", "-m", "recoverable"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
std::fs::write(temp.path().join("notes.md"), "unsaved\n").unwrap();
let dirty = heddle(
&["--output", "json", "undo", "--recover"],
Some(temp.path()),
)
.expect_err("recovery must refuse a dirty worktree");
assert!(dirty.contains("dirty_worktree"), "{dirty}");
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"unsaved\n"
);
}
#[test]
fn test_undo_recover_refuses_when_preserved_state_is_missing() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("notes.md"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
std::fs::write(temp.path().join("notes.md"), "recover me\n").unwrap();
heddle_must_succeed(&["capture", "-m", "recoverable"], temp.path());
let recovery_state = head_short(temp.path());
heddle_must_succeed(&["undo"], temp.path());
let state_path = locate_state_loose_file(temp.path(), &recovery_state)
.expect("preserved state has a loose object");
std::fs::remove_file(state_path).unwrap();
wipe_pack_store(temp.path());
let missing = heddle(
&["--output", "json", "undo", "--recover"],
Some(temp.path()),
)
.expect_err("recovery must refuse when its preserved state is missing");
assert!(missing.contains("undo_recovery_state_missing"), "{missing}");
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"base\n"
);
}
#[test]
fn test_undo_dry_run_alias_does_not_apply() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("a.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "first"], temp.path());
std::fs::write(temp.path().join("a.txt"), "v2").unwrap();
heddle_must_succeed(&["capture", "-m", "second"], temp.path());
let before = head_short(temp.path());
let out = heddle_must_succeed(&["undo", "--dry-run"], temp.path());
assert_eq!(
head_short(temp.path()),
before,
"--dry-run must not move HEAD"
);
assert!(
out.to_lowercase().contains("would undo"),
"--dry-run output must announce the dry-run shape: {out}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("a.txt")).unwrap(),
"v2",
"--dry-run must not touch the worktree"
);
let out_preview = heddle_must_succeed(&["undo", "--preview"], temp.path());
assert!(
out_preview.to_lowercase().contains("would undo"),
"--preview must keep working: {out_preview}"
);
assert_eq!(head_short(temp.path()), before);
}
#[test]
fn test_undo_refuses_when_prior_state_missing() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("a.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "first"], temp.path());
let first_state_short = head_short(temp.path());
std::fs::write(temp.path().join("a.txt"), "v2").unwrap();
heddle_must_succeed(&["capture", "-m", "second"], temp.path());
let state_path = locate_state_loose_file(temp.path(), &first_state_short)
.expect("prior state's loose file is present after capture");
std::fs::remove_file(&state_path).unwrap();
wipe_pack_store(temp.path());
let err = heddle(&["undo"], Some(temp.path()))
.expect_err("undo must refuse when the prior state is missing");
let lower = err.to_lowercase();
assert!(
lower.contains("missing") || lower.contains("gone") || lower.contains("garbage"),
"error must explain that prior state is missing: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("a.txt")).unwrap(),
"v2",
"refusal must not touch the worktree"
);
}
#[test]
fn test_undo_help_lists_undoable_and_unsupported() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
let help = heddle_must_succeed(&["undo", "--help"], temp.path());
let lower = help.to_lowercase();
assert!(
lower.contains("undoable") || lower.contains("undo "),
"--help should describe what undo does: {help}"
);
assert!(
lower.contains("capture"),
"--help should list capture as undoable: {help}"
);
assert!(
lower.contains("land"),
"--help should list land as undoable: {help}"
);
assert!(
lower.contains("--recover") && lower.contains("worktree changes"),
"--help should explain recovery without moving HEAD: {help}"
);
assert!(
lower.contains("push") || lower.contains("pull") || lower.contains("cross-worktree"),
"--help should call out what is NOT undoable: {help}"
);
assert!(
lower.contains("worktree") || lower.contains("--path"),
"--help should mention the worktree-attached ThreadCreate refusal: {help}"
);
assert!(
lower.contains("thread drop") || lower.contains("--delete-thread"),
"--help should redirect users to the teardown command for the worktree case: {help}"
);
}
fn locate_state_loose_file(repo_root: &std::path::Path, short: &str) -> Option<std::path::PathBuf> {
let states_dir = repo_root.join(".heddle/objects/states");
for entry in std::fs::read_dir(&states_dir).ok()? {
let entry = entry.ok()?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(short) {
return Some(entry.path());
}
}
None
}
fn setup_repo_with_secret() -> (TempDir, String) {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::create_dir_all(temp.path().join("config")).unwrap();
std::fs::write(
temp.path().join("config/secrets.toml"),
b"api_token = \"super-secret-leaked-value\"\n",
)
.unwrap();
heddle_must_succeed(&["capture", "-m", "leak the secret"], temp.path());
let state = head_short(temp.path());
(temp, state)
}
#[test]
fn test_undo_redact_with_allow_flag_restores_original_content() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
let blob_hash =
objects::object::Blob::from_slice(b"api_token = \"super-secret-leaked-value\"\n").hash();
{
let repo = Repository::open(temp.path()).unwrap();
let stub = repo
.redaction_stub_for_blob(&blob_hash)
.expect("redaction_stub_for_blob must not error");
assert!(
stub.is_some(),
"with the redaction active, materialize must substitute the stub"
);
}
let list_before: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_before["count"].as_u64().unwrap(),
1,
"redact list should surface the new redaction: {list_before:?}",
);
heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect("undo of Redact must succeed with --allow-redact-undo");
let list_after: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_after["count"].as_u64().unwrap(),
0,
"after undo, no redaction should remain: {list_after:?}",
);
let repo = Repository::open(temp.path()).unwrap();
let stub = repo
.redaction_stub_for_blob(&blob_hash)
.expect("redaction_stub_for_blob must not error");
assert!(
stub.is_none(),
"after undo, materialize must restore original bytes (no active stub)"
);
}
#[test]
fn test_undo_redact_refuses_without_allow_flag() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
let err = heddle(&["undo"], Some(temp.path()))
.expect_err("undo of a Redact must refuse without --allow-redact-undo");
let lower = err.to_lowercase();
assert!(
lower.contains("redact"),
"refusal must name the redaction cause: {err}"
);
assert!(
lower.contains("--allow-redact-undo") || lower.contains("allow-redact-undo"),
"refusal must point at the opt-in flag: {err}"
);
let list: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list["count"].as_u64().unwrap(),
1,
"refusal must not mutate the redactions sidecar: {list:?}"
);
}
#[test]
fn test_undo_redact_refuses_when_blob_already_purged() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
heddle_must_succeed(
&[
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
temp.path(),
);
let err = heddle(
&["undo", "-n", "2", "--allow-redact-undo"],
Some(temp.path()),
)
.expect_err("undo across a purged redaction must refuse");
let lower = err.to_lowercase();
assert!(
lower.contains("purge") || lower.contains("irreversible"),
"refusal must name purge/irreversibility: {err}"
);
let list: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list["count"].as_u64().unwrap(),
1,
"refusal must not mutate the redactions sidecar: {list:?}",
);
assert!(
list["redactions"][0]["purged"].as_bool().unwrap(),
"the redaction must still be marked purged after refusal: {list:?}",
);
}
#[test]
fn test_redo_of_undone_redact_refuses() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect("undo of Redact must succeed with --allow-redact-undo");
let err = heddle(&["undo", "--redo"], Some(temp.path()))
.expect_err("redo of an undone Redact must refuse");
let lower = err.to_lowercase();
assert!(
lower.contains("redact"),
"redo refusal must mention Redact: {err}"
);
assert!(
lower.contains("redact apply") || lower.contains("re-apply"),
"redo refusal should redirect to `heddle redact apply`: {err}"
);
let list: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list["count"].as_u64().unwrap(),
0,
"redo refusal must not re-create the redaction: {list:?}",
);
}
#[test]
fn test_undo_redact_preserves_sibling_redactions_on_same_blob() {
let (temp, state_a) = setup_repo_with_secret();
std::fs::write(temp.path().join("trailing.txt"), "trailing").unwrap();
heddle_must_succeed(&["capture", "-m", "trailing"], temp.path());
let state_b = head_short(temp.path());
heddle_must_succeed(
&[
"redact",
"apply",
&state_a,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential (state_a)",
],
temp.path(),
);
heddle_must_succeed(
&[
"redact",
"apply",
&state_b,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential (state_b)",
],
temp.path(),
);
let list_before: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_before["count"].as_u64().unwrap(),
2,
"two redactions on the same blob expected: {list_before:?}",
);
heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect("undo of single Redact succeeds");
let list_after: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_after["count"].as_u64().unwrap(),
1,
"exactly one redaction should remain after one undo: {list_after:?}",
);
let surviving_reason = list_after["redactions"][0]["reason"].as_str().unwrap();
assert_eq!(
surviving_reason, "leaked credential (state_a)",
"the state_a redaction must survive when state_b's is undone"
);
}
#[test]
fn test_undo_redact_removes_exact_record_when_multiple_target_same_triple() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"initial: leaked credential",
],
temp.path(),
);
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"refined: leaked api token v2",
],
temp.path(),
);
let list_before: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_before["count"].as_u64().unwrap(),
2,
"two redactions on same (blob,state,path) expected: {list_before:?}",
);
heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect("undo of refined Redact succeeds");
let list_after: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "redact", "list"],
temp.path(),
))
.unwrap();
assert_eq!(
list_after["count"].as_u64().unwrap(),
1,
"exactly one redaction must remain after one undo: {list_after:?}",
);
let surviving_reason = list_after["redactions"][0]["reason"].as_str().unwrap();
assert_eq!(
surviving_reason, "initial: leaked credential",
"the initial (older) redaction must survive — undoing the refined batch must remove the refined record, not the initial one"
);
}
#[test]
fn test_undo_preview_refuses_redact_without_allow_flag() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
let err = heddle(&["undo", "--preview"], Some(temp.path())).expect_err(
"undo --preview against a Redact batch must refuse without --allow-redact-undo",
);
let lower = err.to_lowercase();
assert!(
lower.contains("redact"),
"preview refusal must name the redaction cause: {err}"
);
assert!(
lower.contains("--allow-redact-undo") || lower.contains("allow-redact-undo"),
"preview refusal must point at the opt-in flag: {err}"
);
}
#[test]
fn test_undo_preview_refuses_redact_when_blob_already_purged() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
heddle_must_succeed(
&[
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
temp.path(),
);
let err = heddle(
&["undo", "-n", "2", "--preview", "--allow-redact-undo"],
Some(temp.path()),
)
.expect_err("undo --preview across a purged redaction must refuse");
let lower = err.to_lowercase();
assert!(
lower.contains("purge") || lower.contains("irreversible"),
"preview refusal must name purge/irreversibility: {err}"
);
}
#[test]
fn test_redo_preview_refuses_redact_chain() {
let (temp, state) = setup_repo_with_secret();
heddle_must_succeed(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
temp.path(),
);
heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect("undo of Redact must succeed with --allow-redact-undo");
let err = heddle(&["undo", "--redo", "--preview"], Some(temp.path()))
.expect_err("redo --preview of an undone Redact must refuse");
let lower = err.to_lowercase();
assert!(
lower.contains("redact"),
"redo preview refusal must mention Redact: {err}"
);
assert!(
lower.contains("redact apply") || lower.contains("re-apply"),
"redo preview refusal should redirect to `heddle redact apply`: {err}"
);
}
fn bootstrap_repo_with_initial_state() -> TempDir {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("base.txt"), "base").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
temp
}
#[test]
fn test_undo_thread_create_removes_record_when_no_worktree() {
use repo::ThreadManager;
let temp = bootstrap_repo_with_initial_state();
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
{
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let record = manager
.find_by_thread("feature")
.unwrap()
.expect("`thread create` writes a ThreadManager record");
assert!(
record.materialized_path.is_none(),
"plain `thread create` must not materialize a worktree"
);
}
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_none(),
"undo of `thread create` must delete the thread ref"
);
let manager = ThreadManager::new(repo.heddle_dir());
assert!(
manager.find_by_thread("feature").unwrap().is_none(),
"undo of `thread create` must remove the matching ThreadManager record \
(heddle#23 r2 — cross-thread undo contract rule 4)"
);
}
#[test]
fn test_undo_thread_create_refuses_with_materialized_worktree() {
let temp = bootstrap_repo_with_initial_state();
let wt_path = temp.path().join("feature-wt");
heddle_must_succeed(
&[
"start",
"feature",
"--path",
wt_path.to_str().unwrap(),
"--workspace",
"solid",
],
temp.path(),
);
assert!(
wt_path.exists(),
"`heddle start --path` must materialize the requested worktree"
);
{
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_some(),
"feature thread ref must exist after `start --path`"
);
}
let err = heddle(&["undo"], Some(temp.path()))
.expect_err("undo of `start --path` must refuse so the worktree isn't orphaned");
let lower = err.to_lowercase();
assert!(
lower.contains("worktree") || lower.contains("materialized"),
"refusal must name the worktree concern: {err}"
);
assert!(
err.contains("feature-wt") || err.contains("feature"),
"refusal must surface the affected thread or worktree path: {err}"
);
assert!(
lower.contains("thread drop") || lower.contains("--delete-thread"),
"refusal must point at the teardown command: {err}"
);
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_some(),
"thread ref must survive a refused undo (pre-flight refusal — \
consistent with the redaction/dirty-worktree gates)"
);
assert!(
wt_path.exists(),
"worktree directory must survive a refused undo"
);
}
#[test]
fn test_undo_thread_rename_round_trips_refs_and_record() {
use repo::ThreadManager;
let temp = bootstrap_repo_with_initial_state();
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "rename", "feature", "feature-v2"], temp.path());
{
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature-v2"))
.unwrap()
.is_some(),
"rename forward path must create `feature-v2`"
);
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_none(),
"rename forward path must remove `feature`"
);
}
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_some(),
"undo of rename must restore the old name's ref"
);
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature-v2"))
.unwrap()
.is_none(),
"undo of rename must delete the new name's ref"
);
let manager = ThreadManager::new(repo.heddle_dir());
assert!(
manager.find_by_thread("feature-v2").unwrap().is_none(),
"undo of rename must not leave a ThreadManager record under the new name \
(heddle#23 r2 — cross-thread undo contract rule 4)"
);
}
#[test]
fn test_redo_thread_create_restores_manager_record() {
use repo::ThreadManager;
let temp = bootstrap_repo_with_initial_state();
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
let (orig_id, orig_mode, orig_base_state, orig_target_thread) = {
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let record = manager
.find_by_thread("feature")
.unwrap()
.expect("`thread create` writes a ThreadManager record");
(
record.id.clone(),
record.mode.clone(),
record.base_state.clone(),
record.target_thread.clone(),
)
};
heddle_must_succeed(&["undo"], temp.path());
heddle_must_succeed(&["undo", "--redo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.is_some(),
"redo of `thread create` must restore the thread ref"
);
let manager = ThreadManager::new(repo.heddle_dir());
let restored = manager.find_by_thread("feature").unwrap().expect(
"redo of `thread create` must recreate the ThreadManager record \
(heddle#23 r2 Codex P1 — record/redo symmetry, cross-thread \
undo contract rule 4)",
);
assert_eq!(restored.id, orig_id, "id must round-trip");
assert_eq!(
format!("{:?}", restored.mode),
format!("{:?}", orig_mode),
"mode must round-trip"
);
assert_eq!(
restored.base_state, orig_base_state,
"base_state must round-trip"
);
assert_eq!(
restored.target_thread, orig_target_thread,
"target_thread must round-trip"
);
}
#[test]
fn test_undo_thread_refresh_restores_base_state() {
use repo::ThreadManager;
let temp = bootstrap_repo_with_initial_state();
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feature.txt"), "feature\n").unwrap();
heddle_must_succeed(&["capture", "-m", "feature work"], temp.path());
let feature_tip_before_refresh = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("main.txt"), "main\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main advance"], temp.path());
let refreshed_base = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
let (base_before_refresh, current_before_refresh) = {
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let record = manager
.find_by_thread("feature")
.unwrap()
.expect("feature record exists before refresh");
(
record.base_state.clone(),
record
.current_state
.clone()
.expect("feature has current state before refresh"),
)
};
heddle_must_succeed(&["thread", "refresh", "feature"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("feature.txt")).unwrap(),
"feature\n",
"refresh must keep the feature work materialized"
);
assert!(
temp.path().join("main.txt").exists(),
"test setup must materialize the refreshed base on disk"
);
{
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let record = manager
.find_by_thread("feature")
.unwrap()
.expect("feature record exists after refresh");
assert_eq!(
record.base_state, refreshed_base,
"refresh must advance feature's recorded base to main"
);
assert_ne!(
record.base_state, base_before_refresh,
"test setup must actually change base_state"
);
}
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
let feature_ref = repo
.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.expect("feature ref survives refresh undo")
.short();
assert_eq!(
feature_ref, feature_tip_before_refresh,
"undo of refresh must restore the feature ref"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("feature.txt")).unwrap(),
"feature\n",
"undo of refresh must restore the pre-refresh worktree content"
);
assert!(
!temp.path().join("main.txt").exists(),
"undo of refresh on the checked-out thread must remove files introduced by refresh"
);
let manager = ThreadManager::new(repo.heddle_dir());
let restored = manager
.find_by_thread("feature")
.unwrap()
.expect("feature record survives refresh undo");
assert_eq!(
restored.base_state, base_before_refresh,
"undo of refresh must restore the prior base_state"
);
assert_eq!(
restored.current_state.as_deref(),
Some(current_before_refresh.as_str()),
"undo of refresh must restore the manager record's current_state too"
);
}
#[test]
fn test_undo_preview_surfaces_worktree_refusal() {
let temp = bootstrap_repo_with_initial_state();
let wt_path = temp.path().join("feature-wt");
heddle_must_succeed(
&[
"start",
"feature",
"--path",
wt_path.to_str().unwrap(),
"--workspace",
"solid",
],
temp.path(),
);
let err = heddle(&["undo", "--preview"], Some(temp.path())).expect_err(
"`undo --preview` must refuse a worktree-attached ThreadCreate \
instead of advertising 'Would undo …'",
);
let lower = err.to_lowercase();
assert!(
lower.contains("worktree") || lower.contains("materialized"),
"preview refusal must name the worktree concern: {err}"
);
}
#[test]
fn test_undo_pull_local_restores_thread_ref() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
heddle_must_succeed(&["init"], source.path());
std::fs::write(source.path().join("a.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "source v1"], source.path());
heddle_must_succeed(&["init"], target.path());
std::fs::write(target.path().join("target.txt"), "target init").unwrap();
heddle_must_succeed(&["capture", "-m", "target init"], target.path());
let source_path = source.path().to_str().unwrap().to_string();
heddle_must_succeed(&["pull", &source_path, "--thread", "main"], target.path());
let main_after_first_pull = head_short(target.path());
std::fs::write(source.path().join("a.txt"), "v2").unwrap();
heddle_must_succeed(&["capture", "-m", "source v2"], source.path());
heddle_must_succeed(&["pull", &source_path, "--thread", "main"], target.path());
let main_after_second_pull = head_short(target.path());
assert_ne!(
main_after_first_pull, main_after_second_pull,
"second pull must advance main to a new state"
);
heddle_must_succeed(&["undo"], target.path());
assert_eq!(
head_short(target.path()),
main_after_first_pull,
"undo of pull must restore HEAD to pre-pull tip"
);
let repo = Repository::open(target.path()).unwrap();
let main_tip = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.expect("main thread still exists")
.short();
assert_eq!(
main_tip, main_after_first_pull,
"undo of pull must restore main thread ref to pre-pull tip \
(heddle#110 — was stranded at the pulled state before the fix)"
);
}
#[test]
fn test_undo_resolve_abort_keeps_thread_ref_at_ours() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "base\n").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "feature edit\n").unwrap();
heddle_must_succeed(&["capture", "-m", "feature edit"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "main edit\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main edit"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
let feature_tip_before = head_short(temp.path());
let refresh = heddle(
&["thread", "refresh", "feature", "--output", "json"],
Some(temp.path()),
);
assert!(
refresh
.as_ref()
.is_err_and(|err| err.contains("thread_refresh_conflicted")),
"refresh should create a durable conflict state: {refresh:?}"
);
heddle_must_succeed(&["resolve", "--abort"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip_before,
"abort must leave HEAD at feature's pre-refresh tip"
);
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
let feature_tip = repo
.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.expect("feature thread still exists")
.short();
assert_eq!(
feature_tip, feature_tip_before,
"undo of refresh abort must leave feature thread ref at the pre-refresh tip"
);
match repo.head_ref().unwrap() {
refs::Head::Attached { thread } => assert_eq!(thread, "feature"),
refs::Head::Detached { state } => panic!(
"HEAD must stay attached to feature; got detached at {}",
state.short()
),
}
}
#[test]
fn test_undo_land_manual_resolution_restores_thread_ref() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("a.txt"), "base").unwrap();
heddle_must_succeed(&["capture", "-m", "base"], temp.path());
let feature_wt = temp.path().join("feature-wt");
heddle_must_succeed(
&[
"start",
"feature",
"--path",
feature_wt.to_str().unwrap(),
"--workspace",
"materialized",
],
temp.path(),
);
std::fs::write(feature_wt.join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], &feature_wt);
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "resolve", "feature"], temp.path());
{
use repo::ThreadManager;
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let feature = manager
.find_by_thread("feature")
.unwrap()
.expect("feature thread record exists after resolve");
assert!(
feature
.integration_policy_result
.manual_resolution_state
.is_some(),
"thread resolve must record the state that land will adopt"
);
}
let land = heddle(
&["--output", "json", "land", "--thread", "feature"],
Some(temp.path()),
);
let land_out = match land {
Ok(out) => out,
Err(err) => {
panic!("land failed: {err}");
}
};
assert!(
land_out.contains("\"status\":\"landed\"") || land_out.contains("\"status\": \"landed\""),
"land must reach the manual-resolution adopt path: {land_out}"
);
let after_ship = head_short(temp.path());
assert_ne!(
after_ship, main_tip_before,
"land must advance main; otherwise the FF is a no-op and there's nothing to undo: {land_out}"
);
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
let main_tip = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.expect("main thread still exists")
.short();
assert_eq!(
main_tip, main_tip_before,
"undo of land must restore main thread ref to pre-land tip \
(heddle#110 — was stranded at the adopted state before the fix)"
);
}
#[test]
fn test_redo_pull_pins_recorded_tip_when_source_advances() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
heddle_must_succeed(&["init"], source.path());
std::fs::write(source.path().join("a.txt"), "v1").unwrap();
heddle_must_succeed(&["capture", "-m", "source v1"], source.path());
heddle_must_succeed(&["init"], target.path());
std::fs::write(target.path().join("target.txt"), "target init").unwrap();
heddle_must_succeed(&["capture", "-m", "target init"], target.path());
let source_path = source.path().to_str().unwrap().to_string();
heddle_must_succeed(&["pull", &source_path, "--thread", "main"], target.path());
let main_after_first_pull = head_short(target.path());
std::fs::write(source.path().join("a.txt"), "v2").unwrap();
heddle_must_succeed(&["capture", "-m", "source v2"], source.path());
heddle_must_succeed(&["pull", &source_path, "--thread", "main"], target.path());
let main_after_second_pull = head_short(target.path());
heddle_must_succeed(&["undo"], target.path());
assert_eq!(head_short(target.path()), main_after_first_pull);
std::fs::write(source.path().join("a.txt"), "v3").unwrap();
heddle_must_succeed(&["capture", "-m", "source v3"], source.path());
heddle_must_succeed(&["undo", "--redo"], target.path());
assert_eq!(
head_short(target.path()),
main_after_second_pull,
"redo of pull must replay to the recorded pulled SHA, \
not the source's advanced tip"
);
}
#[test]
fn test_undo_list_hides_atomic_commit_marker_batches() {
let temp = TempDir::new().unwrap();
heddle_must_succeed(&["init"], temp.path());
std::fs::write(temp.path().join("a.txt"), "a").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("b.txt"), "b").unwrap();
heddle_must_succeed(&["capture", "-m", "b"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
let scope = repo.op_scope();
let raw = repo
.oplog()
.recent_batches_scoped(50, Some(&scope))
.unwrap();
assert!(
raw.iter().any(|batch| batch.is_transaction_marker_only()),
"the atomic undo must have committed a marker-only sentinel batch"
);
let list = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "20"],
temp.path(),
);
let parsed: Value = serde_json::from_str(&list).unwrap();
let batches = parsed["batches"].as_array().unwrap();
for batch in batches {
let ops = batch["operations"].as_array().unwrap();
let only_markers = !ops.is_empty()
&& ops.iter().all(|op| {
op["description"]
.as_str()
.is_some_and(|desc| desc.starts_with("transaction commit"))
});
assert!(
!only_markers,
"undo --list must not surface a record-less commit-marker batch: {list}"
);
}
}