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()
}
#[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());
heddle_must_succeed(&["capture", "-m", "empty"], 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());
heddle_must_succeed(&["capture", "-m", "empty"], 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_cherry_pick_refuses_when_untracked_file_present() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
let log = heddle_must_succeed(&["log", "--oneline", "--output", "text"], temp.path());
let feature_commit = log
.lines()
.next()
.unwrap()
.split_whitespace()
.next()
.unwrap()
.to_string();
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let untracked = temp.path().join("user-notes.md");
std::fs::write(&untracked, "user-written content").unwrap();
let err = heddle(
&["cherry-pick", &feature_commit, "--output", "json"],
Some(temp.path()),
)
.expect_err("cherry-pick must refuse on dirty worktree");
assert!(
err.contains("untracked"),
"error should mention untracked: {err}"
);
assert!(untracked.exists(), "untracked file must survive refusal");
}
#[test]
fn test_cherry_pick_refuses_when_tracked_file_modified() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
let log = heddle_must_succeed(&["log", "--oneline", "--output", "text"], temp.path());
let feature_commit = log
.lines()
.next()
.unwrap()
.split_whitespace()
.next()
.unwrap()
.to_string();
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("base.txt"), "uncommitted edit").unwrap();
let err = heddle(
&["cherry-pick", &feature_commit, "--output", "json"],
Some(temp.path()),
)
.expect_err("cherry-pick must refuse with modified file");
assert!(
err.contains("modified"),
"error should mention modified: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("base.txt")).unwrap(),
"uncommitted edit",
"modification must survive refusal"
);
}
#[test]
fn test_cherry_pick_force_proceeds_and_destroys_edit() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
let log = heddle_must_succeed(&["log", "--oneline", "--output", "text"], temp.path());
let feature_commit = log
.lines()
.next()
.unwrap()
.split_whitespace()
.next()
.unwrap()
.to_string();
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let untracked = temp.path().join("user-notes.md");
std::fs::write(&untracked, "user-written content").unwrap();
heddle(
&["cherry-pick", "--force", &feature_commit],
Some(temp.path()),
)
.expect("cherry-pick --force must succeed past the guard");
}
#[test]
fn test_rebase_refuses_when_untracked_file_present() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let untracked = temp.path().join("user-notes.md");
std::fs::write(&untracked, "user-written content").unwrap();
let err = heddle(
&["rebase", "feature", "--output", "json"],
Some(temp.path()),
)
.expect_err("rebase must refuse on dirty worktree");
assert!(
err.contains("untracked"),
"error should mention untracked: {err}"
);
assert!(untracked.exists(), "untracked file must survive refusal");
}
#[test]
fn test_rebase_refuses_when_tracked_file_modified() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("base.txt"), "uncommitted edit").unwrap();
let err = heddle(
&["rebase", "feature", "--output", "json"],
Some(temp.path()),
)
.expect_err("rebase must refuse with modified file");
assert!(
err.contains("modified"),
"error should mention modified: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("base.txt")).unwrap(),
"uncommitted edit",
"modification must survive refusal"
);
}
#[test]
fn test_rebase_force_proceeds_and_destroys_edit() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "Feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let untracked = temp.path().join("user-notes.md");
std::fs::write(&untracked, "user-written content").unwrap();
heddle(&["rebase", "--force", "feature"], Some(temp.path()))
.expect("rebase --force must succeed past the guard");
}
#[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_recovery_marker_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());
heddle_must_succeed(&["undo"], temp.path());
std::fs::write(temp.path().join("notes.md"), "different direction\n").unwrap();
heddle_must_succeed(&["capture", "-m", "diverge"], temp.path());
let repo = Repository::open(temp.path()).unwrap();
let recovery = repo
.refs()
.get_undo_recovery()
.unwrap()
.expect("recovery ref must survive a divergent capture");
assert_eq!(recovery.short(), friction_state);
heddle_must_succeed(&["switch", ".undo-recovery"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION ONE\nFRICTION TWO\n",
"the pre-undo content must be recoverable via the durable recovery handle"
);
}
#[test]
fn test_undo_recovery_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(|m| m["name"] != "undo-recovery"),
"recovery bookkeeping must not appear as a user marker"
);
let repo = Repository::open(temp.path()).unwrap();
let recovery = repo
.refs()
.get_undo_recovery()
.unwrap()
.expect("undo must preserve the pre-undo state in the internal recovery ref");
assert_eq!(
recovery.short(),
friction_state,
"internal recovery ref must pin the pre-undo (friction) state"
);
heddle_must_succeed(&["switch", ".undo-recovery"], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION\n",
"the pre-undo content must be recoverable via the internal handle"
);
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(|id| id.short()),
Some(friction_state),
"user marker create/delete must not touch the internal recovery ref"
);
}
#[test]
fn test_recovery_handle_unshadowable_by_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());
assert_ne!(base_state, friction_state);
let undo: Value = serde_json::from_str(&heddle_must_succeed(
&["--output", "json", "undo"],
temp.path(),
))
.unwrap();
let advertised = undo["recovery_marker"]
.as_str()
.expect("undo advertises a recovery handle");
heddle_must_succeed(&["switch", advertised], temp.path());
assert_eq!(
std::fs::read_to_string(temp.path().join("notes.md")).unwrap(),
"FRICTION\n",
"advertised recovery handle must restore the internal pre-undo state, not the user's ref"
);
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(
repo.refs()
.get_marker(&objects::object::MarkerName::new("undo-recovery"))
.unwrap()
.map(|id| id.short()),
Some(base_state),
"the user's own undo-recovery marker must remain intact and independent"
);
}
#[test]
fn test_undo_ff_merge_restores_head_and_thread_ref() {
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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_before = head_short(temp.path());
assert_ne!(main_tip_before, feature_tip_before);
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
assert_eq!(head_short(temp.path()), main_tip_before);
heddle_must_succeed(&["merge", "feature"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip_before,
"FF merge must advance main to feature's tip"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"undo of FF merge must restore HEAD to main's pre-merge tip"
);
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,
"feature thread tip must be unchanged across merge + undo"
);
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 FF merge must restore the `main` thread ref to its pre-merge tip \
(heddle#99 — was stranded at the FF target before the fix)"
);
match repo.head_ref().unwrap() {
refs::Head::Attached { thread } => assert_eq!(
thread, "main",
"HEAD must stay attached to `main` after FF undo"
),
refs::Head::Detached { state } => panic!(
"HEAD must stay attached to `main`; got detached at {}",
state.short()
),
}
}
#[test]
fn test_undo_ff_merge_refuses_when_pre_target_state_missing() {
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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["merge", "feature"], temp.path());
let state_path = locate_state_loose_file(temp.path(), &main_tip_before)
.expect("pre-FF state's loose file is present after merge");
std::fs::remove_file(&state_path).unwrap();
let packs_dir = temp.path().join(".heddle/packs");
if packs_dir.exists() {
for entry in std::fs::read_dir(&packs_dir).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
}
let err = heddle(&["undo"], Some(temp.path()))
.expect_err("undo must refuse when the pre-FF 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}"
);
}
#[test]
fn test_redo_ff_merge_restores_head_and_thread_ref() {
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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["merge", "feature"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(head_short(temp.path()), main_tip_before);
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip_before,
"redo of FF merge must re-advance HEAD to feature's tip"
);
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, feature_tip_before,
"redo of FF merge must re-advance `main` thread ref to feature's tip"
);
let feature_tip = repo
.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.expect("feature thread still exists")
.short();
assert_eq!(
feature_tip, feature_tip_before,
"feature thread tip stays put across the full merge/undo/redo round-trip"
);
}
#[test]
fn test_redo_ff_merge_pins_recorded_tip_when_source_advances() {
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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_at_ff = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["merge", "feature"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(head_short(temp.path()), main_tip_before);
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work + more").unwrap();
heddle_must_succeed(&["capture", "-m", "feature again"], temp.path());
let feature_tip_advanced = head_short(temp.path());
assert_ne!(
feature_tip_at_ff, feature_tip_advanced,
"post-undo capture on feature must produce a new tip distinct from the FF target"
);
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip_at_ff,
"redo of FF merge must replay to the recorded FF target, not source's current tip"
);
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, feature_tip_at_ff,
"redo of FF merge must set `main` ref to the recorded FF target, not source's current tip"
);
let feature_tip = repo
.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.expect("feature thread still exists")
.short();
assert_eq!(
feature_tip, feature_tip_advanced,
"feature thread's own ref is independent of redo — it stays at its new tip"
);
}
#[test]
fn test_redo_ff_merge_succeeds_when_source_deleted() {
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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_at_ff = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["merge", "feature"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(head_short(temp.path()), main_tip_before);
heddle_must_succeed(&["thread", "delete", "feature"], temp.path());
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip_at_ff,
"redo must replay to the recorded FF target even when source thread is gone"
);
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, feature_tip_at_ff,
"main ref must reach the recorded FF target after redo, source-thread-deletion notwithstanding"
);
}
#[test]
fn test_redo_ff_merge_refuses_when_post_target_state_missing() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_at_ff = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["merge", "feature"], temp.path());
heddle_must_succeed(&["undo"], temp.path());
let state_path = locate_state_loose_file(temp.path(), &feature_tip_at_ff)
.expect("FF target state's loose file is present after undo");
std::fs::remove_file(&state_path).unwrap();
let packs_dir = temp.path().join(".heddle/packs");
if packs_dir.exists() {
for entry in std::fs::read_dir(&packs_dir).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
}
heddle_must_succeed(&["thread", "delete", "feature"], temp.path());
let err = heddle(&["undo", "--redo"], Some(temp.path()))
.expect_err("redo must refuse when the FF target state is missing");
let lower = err.to_lowercase();
assert!(
lower.contains("missing") || lower.contains("gone") || lower.contains("garbage"),
"error must explain that the redo target state is missing: {err}"
);
}
#[test]
fn test_stale_non_ff_merge_refuses_without_moving_threads() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip_before = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("main.txt"), "main work").unwrap();
heddle_must_succeed(&["capture", "-m", "main work"], temp.path());
let main_tip_before = head_short(temp.path());
let err = heddle(&["merge", "feature"], Some(temp.path()))
.expect_err("stale divergent merge must refuse before mutation");
assert!(
err.contains("Thread 'feature' is stale") && err.contains("heddle sync --thread feature"),
"stale merge should explain the refresh path: {err}"
);
assert_eq!(
head_short(temp.path()),
main_tip_before,
"stale merge refusal must leave HEAD at main's pre-merge tip"
);
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,
"stale merge refusal must leave the `main` thread ref unchanged"
);
let feature_tip = repo
.refs()
.get_thread(&ThreadName::new("feature"))
.unwrap()
.expect("feature thread still exists")
.short();
assert_eq!(
feature_tip, feature_tip_before,
"stale merge refusal must leave the source thread unchanged"
);
}
#[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();
let packs_dir = temp.path().join(".heddle/packs");
if packs_dir.exists() {
for entry in std::fs::read_dir(&packs_dir).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
}
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("merge"),
"--help should list merge as undoable: {help}"
);
assert!(
lower.contains("push") || lower.contains("fetch") || 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(
&[
"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(
&[
"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_rebase_ancestor_ff_restores_thread_ref() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_tip = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_tip,
"ancestor rebase must FF main to feature's tip"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"undo of rebase FF must restore HEAD to main's pre-rebase tip"
);
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 rebase FF must restore main thread ref to pre-rebase tip \
(heddle#110 — was stranded at the FF target before the fix)"
);
match repo.head_ref().unwrap() {
refs::Head::Attached { thread } => assert_eq!(
thread, "main",
"HEAD must stay attached to main after rebase FF undo"
),
refs::Head::Detached { state } => panic!(
"HEAD must stay attached to main; got detached at {}",
state.short()
),
}
}
#[test]
fn test_undo_rebase_replay_restores_thread_ref() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature commit"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("main.txt"), "main work").unwrap();
heddle_must_succeed(&["capture", "-m", "main commit"], temp.path());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
let after_rebase = head_short(temp.path());
assert_ne!(
after_rebase, main_tip_before,
"rebase replay must produce a fresh tip distinct from the pre-rebase main"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"undo of rebase replay must restore HEAD to main's pre-rebase tip"
);
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 rebase replay must restore main thread ref to pre-rebase tip \
(heddle#110 — was stranded at the replay tip before the fix)"
);
match repo.head_ref().unwrap() {
refs::Head::Attached { thread } => assert_eq!(thread, "main"),
refs::Head::Detached { state } => panic!(
"HEAD must stay attached to main; got detached at {}",
state.short()
),
}
}
#[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());
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_ship_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());
let _ = heddle(&["thread", "resolve", "feature"], Some(temp.path()));
let land = heddle(
&["--output", "json", "land", "--thread", "feature"],
Some(temp.path()),
);
let ship_out = match land {
Ok(out) => out,
Err(err) => {
panic!("land failed: {err}");
}
};
assert!(
ship_out.contains("\"status\":\"landed\"") || ship_out.contains("\"status\": \"landed\""),
"land must reach the manual-resolution adopt path: {ship_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: {ship_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_rebase_pins_recorded_tip_when_source_advances() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
let feature_at_rebase = head_short(temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
assert_eq!(head_short(temp.path()), feature_at_rebase);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(head_short(temp.path()), main_tip_before);
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature + more").unwrap();
heddle_must_succeed(&["capture", "-m", "feature again"], temp.path());
let feature_advanced = head_short(temp.path());
assert_ne!(feature_at_rebase, feature_advanced);
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
feature_at_rebase,
"redo of rebase FF must replay to the recorded post-FF SHA, \
not feature's advanced tip"
);
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, feature_at_rebase);
}
#[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());
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_rebase_replay_multi_commit_rewinds_whole_transaction() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature commit"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("a.txt"), "a1").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("b.txt"), "b1").unwrap();
heddle_must_succeed(&["capture", "-m", "b"], temp.path());
std::fs::write(temp.path().join("c.txt"), "c1").unwrap();
heddle_must_succeed(&["capture", "-m", "c"], temp.path());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
let after_rebase = head_short(temp.path());
assert_ne!(
after_rebase, main_tip_before,
"rebase replay must produce a fresh tip distinct from the pre-rebase main"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"single undo of a multi-commit rebase must restore HEAD to the pre-rebase tip"
);
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,
"single undo of a multi-commit rebase must restore main thread ref to the pre-rebase tip"
);
match repo.head_ref().unwrap() {
refs::Head::Attached { thread } => assert_eq!(thread, "main"),
refs::Head::Detached { state } => panic!(
"HEAD must stay attached to main; got detached at {}",
state.short()
),
}
for path in ["a.txt", "b.txt", "c.txt"].iter() {
assert!(
temp.path().join(path).exists(),
"{path} from the original pre-rebase tree must still materialize after undo"
);
}
}
#[test]
fn test_redo_rebase_replay_multi_commit_restores_post_rebase_tip() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature work").unwrap();
heddle_must_succeed(&["capture", "-m", "feature commit"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
std::fs::write(temp.path().join("a.txt"), "a1").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("b.txt"), "b1").unwrap();
heddle_must_succeed(&["capture", "-m", "b"], temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
let after_rebase = head_short(temp.path());
heddle_must_succeed(&["undo"], temp.path());
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
after_rebase,
"single redo of a multi-commit rebase must restore HEAD to the post-rebase tip"
);
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, after_rebase,
"single redo of a multi-commit rebase must restore main thread ref to the post-rebase tip"
);
}
#[test]
fn test_undo_rebase_refuses_when_worktree_dirty() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], 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());
let main_tip_before = head_short(temp.path());
heddle_must_succeed(&["rebase", "feature"], temp.path());
let after_rebase = head_short(temp.path());
assert_ne!(after_rebase, main_tip_before);
std::fs::write(temp.path().join("a.txt"), "uncommitted change").unwrap();
let err = heddle(&["undo", "--output", "json"], Some(temp.path()))
.expect_err("undo of rebase must refuse on dirty worktree");
assert!(
err.contains("modified") || err.contains("dirty") || err.contains("untracked"),
"refusal must name the dirty-worktree concern: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("a.txt")).unwrap(),
"uncommitted change",
"uncommitted edit must survive the refusal"
);
assert_eq!(head_short(temp.path()), after_rebase);
}
#[test]
fn test_undo_rebase_refuses_when_pre_rebase_blob_purged() {
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 = \"will-be-purged\"\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("feat.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], 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(&["rebase", "feature"], temp.path());
let log_json = heddle_must_succeed(&["--output", "json", "log", "--limit", "1"], temp.path());
let log: Value = serde_json::from_str(&log_json).unwrap();
let current_state = log["states"][0]["change_id"].as_str().unwrap().to_string();
heddle_must_succeed(
&[
"redact",
"apply",
¤t_state,
"--path",
"config/secrets.toml",
"--reason",
"rebase-undo-safety test",
],
temp.path(),
);
heddle_must_succeed(
&[
"purge",
"apply",
¤t_state,
"--path",
"config/secrets.toml",
"--force",
],
temp.path(),
);
let err = heddle(&["undo", "--allow-redact-undo"], Some(temp.path()))
.expect_err("undo of rebase must refuse when a pre-rebase blob has been purged");
assert!(
err.to_lowercase().contains("purge") || err.to_lowercase().contains("purged"),
"refusal must name the purge concern: {err}"
);
}
#[test]
fn test_undo_rebase_continue_preserves_pre_conflict_advances() {
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 version\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("a.txt"), "a1").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "main version\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main conflict"], temp.path());
let main_tip_before = head_short(temp.path());
let rebase_output = heddle(&["rebase", "feature"], Some(temp.path())).unwrap_or_else(|out| out);
assert!(
rebase_output.contains("Conflict applying")
|| rebase_output.contains("\"status\": \"conflict\""),
"expected rebase to pause on conflict; got: {rebase_output}"
);
assert!(
temp.path().join(".heddle/REBASE_STATE").exists(),
"rebase state should persist while waiting for manual resolution"
);
std::fs::write(
temp.path().join("conflict.txt"),
"feature version\nmain version\n",
)
.unwrap();
heddle_must_succeed(&["capture", "-m", "Manual rebase resolution"], temp.path());
let _ = heddle(&["thread", "resolve", "main", "--json"], Some(temp.path()));
heddle_must_succeed(&["rebase", "--continue"], temp.path());
assert!(
!temp.path().join(".heddle/REBASE_STATE").exists(),
"REBASE_STATE should clear after a successful continue"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"single undo must restore HEAD to pre-rebase tip even when the rebase paused on a conflict"
);
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,
"single undo must restore main thread ref to pre-rebase tip across a --continue"
);
}
#[test]
fn test_redo_rebase_continue_restores_manual_resolution_tip() {
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 version\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("a.txt"), "a1").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "main version\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main conflict"], temp.path());
let main_tip_before = head_short(temp.path());
let rebase_output = heddle(&["rebase", "feature"], Some(temp.path())).unwrap_or_else(|out| out);
assert!(
rebase_output.contains("Conflict applying")
|| rebase_output.contains("\"status\": \"conflict\""),
"expected rebase to pause on conflict; got: {rebase_output}"
);
std::fs::write(
temp.path().join("conflict.txt"),
"feature version\nmain version\n",
)
.unwrap();
heddle_must_succeed(&["capture", "-m", "Manual rebase resolution"], temp.path());
let _ = heddle(&["thread", "resolve", "main", "--json"], Some(temp.path()));
heddle_must_succeed(&["rebase", "--continue"], temp.path());
let after_rebase = head_short(temp.path());
assert_ne!(
after_rebase, main_tip_before,
"rebase must produce a fresh tip distinct from pre-rebase main"
);
heddle_must_succeed(&["undo"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"single undo of a conflict-paused rebase must restore HEAD to pre-rebase tip"
);
heddle_must_succeed(&["undo", "--redo"], temp.path());
assert_eq!(
head_short(temp.path()),
after_rebase,
"single redo must restore HEAD to the manual-resolution tip, \
not the pre-conflict FF target"
);
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, after_rebase,
"single redo must restore main thread ref to the manual-resolution tip \
across a --continue"
);
}
#[test]
fn test_rebase_abort_tolerates_corrupted_pending_advance_line() {
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\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("a.txt"), "a").unwrap();
heddle_must_succeed(&["capture", "-m", "a"], temp.path());
std::fs::write(temp.path().join("conflict.txt"), "main\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main conflict"], temp.path());
let main_tip_before = head_short(temp.path());
let rebase_output = heddle(&["rebase", "feature"], Some(temp.path())).unwrap_or_else(|out| out);
assert!(
rebase_output.contains("Conflict applying")
|| rebase_output.contains("\"status\": \"conflict\""),
"expected rebase to pause on conflict; got: {rebase_output}"
);
let state_path = temp.path().join(".heddle/REBASE_STATE");
let body = std::fs::read_to_string(&state_path).unwrap();
assert!(
body.contains("pending_advance="),
"fixture precondition: REBASE_STATE must carry at least one pending_advance entry; got:\n{body}"
);
let corrupted: String = body
.lines()
.map(|line| {
if line.starts_with("pending_advance=") {
"pending_advance=not-hex!!".to_string()
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&state_path, format!("{corrupted}\n")).unwrap();
let cont_err = heddle(&["rebase", "--continue"], Some(temp.path()))
.expect_err("continue must hard-fail on corrupted pending_advance");
assert!(
cont_err.contains("pending_advance"),
"continue refusal must name the corrupted record; got: {cont_err}"
);
heddle_must_succeed(&["rebase", "--abort"], temp.path());
assert_eq!(
head_short(temp.path()),
main_tip_before,
"abort must rewind HEAD to original_head even with a corrupted pending_advance line"
);
assert!(
!temp.path().join(".heddle/REBASE_STATE").exists(),
"REBASE_STATE must be cleared after a successful abort"
);
}
#[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}"
);
}
}
#[test]
fn test_undo_list_shows_multi_commit_rebase_as_single_batch() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("feat.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], 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(&["rebase", "feature"], temp.path());
let list = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "10"],
temp.path(),
);
let parsed: Value = serde_json::from_str(&list).expect("list output is JSON");
let batches = parsed
.get("batches")
.and_then(|b| b.as_array())
.expect("list output has batches array");
let rebase_batch = &batches[0];
let ops = rebase_batch
.get("operations")
.and_then(|o| o.as_array())
.expect("batch has operations array");
assert!(
ops.len() >= 2,
"multi-commit rebase batch must contain >=2 ops; saw {}: {list}",
ops.len()
);
for op in ops {
let desc = op.get("description").and_then(|d| d.as_str()).unwrap_or("");
assert!(
desc.starts_with("fast-forward") || desc.starts_with("transaction commit"),
"rebase batch entry must be FF or txn-commit marker, got: {desc}"
);
}
}
#[test]
fn test_rebase_up_to_date_when_already_at_target_emits_json_and_records_nothing() {
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());
let batches_before = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "20"],
temp.path(),
);
let parsed_before: Value = serde_json::from_str(&batches_before).unwrap();
let count_before = parsed_before["batches"].as_array().unwrap().len();
let out = heddle_must_succeed(&["--output", "json", "rebase", "main"], temp.path());
let parsed: Value = serde_json::from_str(out.trim()).expect("up_to_date json");
assert_eq!(parsed["status"].as_str(), Some("up_to_date"));
let batches_after = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "20"],
temp.path(),
);
let parsed_after: Value = serde_json::from_str(&batches_after).unwrap();
let count_after = parsed_after["batches"].as_array().unwrap().len();
assert_eq!(
count_before, count_after,
"no-op rebase must not append a batch to the oplog"
);
}
#[test]
fn test_rebase_fast_forwarded_json_lists_target_and_creates_batch() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("f.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], temp.path());
let out = heddle_must_succeed(&["--output", "json", "rebase", "feature"], temp.path());
let parsed: Value = serde_json::from_str(out.trim()).expect("fast_forwarded json");
assert_eq!(parsed["status"].as_str(), Some("fast_forwarded"));
let to = parsed["to"].as_str().expect("to field present");
assert!(!to.is_empty());
let list = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "5"],
temp.path(),
);
let parsed_list: Value = serde_json::from_str(&list).unwrap();
let top = &parsed_list["batches"][0];
let ops = top["operations"].as_array().unwrap();
let has_tc = ops.iter().any(|op| {
op["description"]
.as_str()
.is_some_and(|d| d.starts_with("transaction commit"))
});
assert!(
has_tc,
"single-FF rebase batch must carry TransactionCommit marker"
);
}
#[test]
fn test_rebase_started_json_announces_commit_count() {
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());
heddle_must_succeed(&["thread", "create", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "feature"], temp.path());
std::fs::write(temp.path().join("f.txt"), "feature").unwrap();
heddle_must_succeed(&["capture", "-m", "feature"], temp.path());
heddle_must_succeed(&["thread", "switch", "main"], 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());
let out = heddle_must_succeed(&["--output", "json", "rebase", "feature"], temp.path());
let first = out.lines().next().expect("at least one json line");
let parsed: Value = serde_json::from_str(first).expect("started json");
assert_eq!(parsed["status"].as_str(), Some("started"));
assert_eq!(parsed["commits"].as_u64(), Some(2));
}
#[test]
fn test_rebase_abort_json_clears_state_without_oplog_batch() {
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\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\n").unwrap();
heddle_must_succeed(&["capture", "-m", "main edit"], temp.path());
let head_before = head_short(temp.path());
let _ = heddle(&["rebase", "feature"], Some(temp.path()));
assert!(
temp.path().join(".heddle/REBASE_STATE").exists(),
"rebase should pause and leave REBASE_STATE on disk"
);
let batches_before_abort = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "20"],
temp.path(),
);
let count_before = serde_json::from_str::<Value>(&batches_before_abort).unwrap()["batches"]
.as_array()
.unwrap()
.len();
let out = heddle_must_succeed(&["--output", "json", "rebase", "--abort"], temp.path());
let parsed: Value = serde_json::from_str(out.trim()).expect("aborted json");
assert_eq!(parsed["status"].as_str(), Some("aborted"));
assert!(
!temp.path().join(".heddle/REBASE_STATE").exists(),
"abort must remove REBASE_STATE"
);
assert_eq!(
head_short(temp.path()),
head_before,
"abort must rewind HEAD to original_head"
);
let batches_after = heddle_must_succeed(
&["--output", "json", "undo", "--list", "--depth", "20"],
temp.path(),
);
let count_after = serde_json::from_str::<Value>(&batches_after).unwrap()["batches"]
.as_array()
.unwrap()
.len();
assert_eq!(
count_before, count_after,
"abort is worktree-only — no oplog batch should appear"
);
}
#[test]
fn test_rebase_abort_and_continue_without_state_error() {
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());
let abort_err = heddle(&["rebase", "--abort"], Some(temp.path()))
.expect_err("abort with no rebase must error");
assert!(
abort_err.contains("No rebase in progress"),
"got: {abort_err}"
);
let cont_err = heddle(&["rebase", "--continue"], Some(temp.path()))
.expect_err("continue with no rebase must error");
assert!(
cont_err.contains("No rebase in progress"),
"got: {cont_err}"
);
}