use crate::core::test_helpers::TestRepo;
fn setup_woven_branch(num_commits: usize) -> TestRepo {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
for i in 1..=num_commits {
test_repo.commit(&format!("A{}", i), &format!("a{}.txt", i));
}
test_repo.switch_branch("integration");
test_repo.commit("Int", "int.txt");
test_repo.merge_no_ff("feature-a");
test_repo
}
#[test]
fn drop_commit_removes_it_from_history() {
let test_repo = TestRepo::new_with_remote();
let _c1_oid = test_repo.commit("Keep", "keep.txt");
let c2_oid = test_repo.commit("Drop me", "drop.txt");
test_repo.commit("Keep2", "keep2.txt");
let result = super::drop_commit(&test_repo.repo, &c2_oid.to_string(), true);
assert!(result.is_ok(), "drop_commit failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "Keep2");
assert_eq!(test_repo.get_message(1), "Keep");
}
#[test]
fn drop_commit_dirty_tree_autostashed() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
let c1_oid = test_repo.commit("Commit", "file.txt");
test_repo.write_file("base.txt", "dirty");
let result = super::drop_commit(&test_repo.repo, &c1_oid.to_string(), true);
assert!(
result.is_ok(),
"drop should succeed with autostash: {:?}",
result
);
assert_eq!(test_repo.read_file("base.txt"), "dirty");
}
#[test]
fn drop_last_commit_on_branch_auto_deletes_branch() {
let test_repo = setup_woven_branch(1);
let branch_oid = test_repo.get_branch_target("feature-a");
let result = super::drop_commit(&test_repo.repo, &branch_oid.to_string(), true);
assert!(result.is_ok(), "drop_commit failed: {:?}", result);
assert!(
!test_repo.branch_exists("feature-a"),
"feature-a should have been auto-deleted"
);
}
#[test]
fn drop_one_of_two_commits_preserves_branch() {
let test_repo = setup_woven_branch(2);
let a2_oid = test_repo.get_branch_target("feature-a");
let result = super::drop_commit(&test_repo.repo, &a2_oid.to_string(), true);
assert!(result.is_ok(), "drop_commit failed: {:?}", result);
assert!(
test_repo.branch_exists("feature-a"),
"feature-a should still exist"
);
}
#[test]
fn drop_woven_branch_removes_commits_and_ref() {
let test_repo = setup_woven_branch(2);
let result = super::drop_branch(&test_repo.repo, "feature-a", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(
!test_repo.branch_exists("feature-a"),
"feature-a should be deleted"
);
let messages = test_repo.commit_messages();
assert!(!messages.contains(&"A1".to_string()), "A1 should be gone");
assert!(!messages.contains(&"A2".to_string()), "A2 should be gone");
assert!(
messages.contains(&"Int".to_string()),
"Int commit should remain"
);
}
#[test]
fn drop_branch_at_merge_base_just_deletes_ref() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("empty-branch", &base_oid.to_string());
test_repo.commit("C1", "c1.txt");
let result = super::drop_branch(&test_repo.repo, "empty-branch", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(
!test_repo.branch_exists("empty-branch"),
"empty-branch should be deleted"
);
assert_eq!(test_repo.get_message(0), "C1");
}
#[test]
fn drop_non_woven_branch_removes_commits_and_ref() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("A1", "a1.txt");
test_repo.commit("A2", "a2.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo.force_checkout();
test_repo.commit("Int", "int.txt");
let result = super::drop_branch(&test_repo.repo, "feature-a", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(
!test_repo.branch_exists("feature-a"),
"feature-a should be deleted"
);
let messages = test_repo.commit_messages();
assert!(!messages.contains(&"A1".to_string()), "A1 should be gone");
assert!(!messages.contains(&"A2".to_string()), "A2 should be gone");
assert!(
messages.contains(&"Int".to_string()),
"Int commit should remain"
);
}
#[test]
fn drop_file_target_fails() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("C1", "c1.txt");
let result = test_repo.in_dir(|| super::run("nonexistent".to_string(), true));
assert!(result.is_err());
}
#[test]
fn drop_woven_branch_with_two_branches_preserves_other() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("A1", "a1.txt");
test_repo.commit("A2", "a2.txt");
test_repo.switch_branch("integration");
test_repo.create_branch_at("feature-b", &base_oid.to_string());
test_repo.switch_branch("feature-b");
test_repo.commit("B1", "b1.txt");
test_repo.switch_branch("integration");
test_repo.commit("Int", "int.txt");
test_repo.merge_no_ff("feature-a");
test_repo.merge_no_ff("feature-b");
let result = super::drop_branch(&test_repo.repo, "feature-a", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(!test_repo.branch_exists("feature-a"));
assert!(test_repo.branch_exists("feature-b"));
let messages = test_repo.commit_messages();
assert!(!messages.contains(&"A1".to_string()), "A1 should be gone");
assert!(!messages.contains(&"A2".to_string()), "A2 should be gone");
assert!(messages.contains(&"B1".to_string()), "B1 should remain");
}
#[test]
fn drop_colocated_non_woven_preserves_other_branch_and_commits() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("A1", "a1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo.force_checkout();
let fa_tip = test_repo.get_branch_target("feature-a");
test_repo.create_branch_at("feature-b", &fa_tip.to_string());
test_repo.commit("Int", "int.txt");
let result = super::drop_branch(&test_repo.repo, "feature-a", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(
!test_repo.branch_exists("feature-a"),
"feature-a should be deleted"
);
assert!(
test_repo.branch_exists("feature-b"),
"feature-b should still exist"
);
let messages = test_repo.commit_messages();
assert!(
messages.contains(&"A1".to_string()),
"A1 should still be in history"
);
assert!(
messages.contains(&"Int".to_string()),
"Int should still be in history"
);
}
#[test]
fn drop_colocated_woven_preserves_other_branch_and_commits() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("A1", "a1.txt");
test_repo.switch_branch("integration");
let fa_tip = test_repo.get_branch_target("feature-a");
test_repo.create_branch_at("feature-b", &fa_tip.to_string());
test_repo.commit("Int", "int.txt");
test_repo.merge_no_ff("feature-a");
let result = super::drop_branch(&test_repo.repo, "feature-a", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(
!test_repo.branch_exists("feature-a"),
"feature-a should be deleted"
);
assert!(
test_repo.branch_exists("feature-b"),
"feature-b should still exist"
);
let messages = test_repo.commit_messages();
assert!(
messages.contains(&"A1".to_string()),
"A1 should still be in history"
);
assert!(
messages.contains(&"Int".to_string()),
"Int should still be in history"
);
}
#[test]
fn drop_stacked_outer_branch_preserves_inner_branch() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feat1", &base_oid.to_string());
test_repo.switch_branch("feat1");
test_repo.commit("A1", "a1.txt");
let feat1_tip = test_repo.head_oid();
test_repo.create_branch_at("feat2", &feat1_tip.to_string());
test_repo.switch_branch("feat2");
test_repo.commit("A2", "a2.txt");
test_repo.switch_branch("integration");
test_repo.commit("Int", "int.txt");
test_repo.merge_no_ff("feat2");
let result = super::drop_branch(&test_repo.repo, "feat2", true);
assert!(result.is_ok(), "drop_branch failed: {:?}", result);
assert!(!test_repo.branch_exists("feat2"), "feat2 should be deleted");
assert!(test_repo.branch_exists("feat1"), "feat1 should still exist");
let messages = test_repo.commit_messages();
assert!(
messages.contains(&"A1".to_string()),
"A1 should still be in history"
);
assert!(!messages.contains(&"A2".to_string()), "A2 should be gone");
assert!(
messages.contains(&"Int".to_string()),
"Int should still be in history"
);
}
#[test]
fn run_drop_commit_by_hash() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Keep", "keep.txt");
let drop_oid = test_repo.commit("Drop me", "drop.txt");
test_repo.commit("Keep2", "keep2.txt");
let result = test_repo.in_dir(|| super::run(drop_oid.to_string(), true));
assert!(result.is_ok(), "run failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "Keep2");
assert_eq!(test_repo.get_message(1), "Keep");
}
#[test]
fn run_drop_branch_by_name() {
let test_repo = setup_woven_branch(2);
let result = test_repo.in_dir(|| super::run("feature-a".to_string(), true));
assert!(result.is_ok(), "run failed: {:?}", result);
assert!(!test_repo.branch_exists("feature-a"));
}
#[test]
fn drop_file_restores_tracked_modifications() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
test_repo.write_file("base.txt", "modified content");
let result = super::drop_file(&test_repo.repo, "base.txt", true);
assert!(result.is_ok(), "drop_file failed: {:?}", result);
assert_eq!(test_repo.read_file("base.txt"), "Base");
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_file_deletes_untracked_file() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
test_repo.write_file("untracked.txt", "new content");
let result = super::drop_file(&test_repo.repo, "untracked.txt", true);
assert!(result.is_ok(), "drop_file failed: {:?}", result);
let path = test_repo.workdir().join("untracked.txt");
assert!(!path.exists(), "untracked.txt should be deleted");
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_file_deletes_staged_new_file() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
test_repo.write_file("new.txt", "new content");
test_repo.stage_files(&["new.txt"]);
let result = super::drop_file(&test_repo.repo, "new.txt", true);
assert!(result.is_ok(), "drop_file failed: {:?}", result);
let path = test_repo.workdir().join("new.txt");
assert!(!path.exists(), "new.txt should be deleted");
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_dir_with_only_untracked_files() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
std::fs::create_dir_all(test_repo.workdir().join("newdir")).unwrap();
test_repo.write_file("newdir/a.txt", "aaa");
test_repo.write_file("newdir/b.txt", "bbb");
let result = super::drop_file(&test_repo.repo, "newdir", true);
assert!(result.is_ok(), "drop_file (dir) failed: {:?}", result);
assert!(
!test_repo.workdir().join("newdir").exists(),
"newdir should be deleted"
);
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_dir_with_only_tracked_modifications() {
let test_repo = TestRepo::new_with_remote();
std::fs::create_dir_all(test_repo.workdir().join("src")).unwrap();
test_repo.write_file("src/one.txt", "original-one");
test_repo.write_file("src/two.txt", "original-two");
test_repo.stage_files(&["src/one.txt", "src/two.txt"]);
test_repo.commit_staged("Initial src files");
test_repo.write_file("src/one.txt", "modified-one");
test_repo.write_file("src/two.txt", "modified-two");
let result = super::drop_file(&test_repo.repo, "src", true);
assert!(result.is_ok(), "drop_file (dir) failed: {:?}", result);
assert_eq!(test_repo.read_file("src/one.txt"), "original-one");
assert_eq!(test_repo.read_file("src/two.txt"), "original-two");
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_dir_with_mixed_tracked_and_untracked() {
let test_repo = TestRepo::new_with_remote();
std::fs::create_dir_all(test_repo.workdir().join("mix")).unwrap();
test_repo.write_file("mix/tracked.txt", "original");
test_repo.stage_files(&["mix/tracked.txt"]);
test_repo.commit_staged("Initial mix file");
test_repo.write_file("mix/tracked.txt", "modified");
test_repo.write_file("mix/untracked.txt", "new");
let result = super::drop_file(&test_repo.repo, "mix", true);
assert!(result.is_ok(), "drop_file (dir) failed: {:?}", result);
assert_eq!(test_repo.read_file("mix/tracked.txt"), "original");
assert!(
!test_repo.workdir().join("mix/untracked.txt").exists(),
"untracked file should be deleted"
);
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_dir_with_staged_new_files() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
std::fs::create_dir_all(test_repo.workdir().join("staged")).unwrap();
test_repo.write_file("staged/a.txt", "aaa");
test_repo.write_file("staged/b.txt", "bbb");
test_repo.stage_files(&["staged/a.txt", "staged/b.txt"]);
let result = super::drop_file(&test_repo.repo, "staged", true);
assert!(result.is_ok(), "drop_file (dir) failed: {:?}", result);
assert!(
!test_repo.workdir().join("staged").exists(),
"staged dir should be deleted"
);
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
}
#[test]
fn drop_all_discards_tracked_and_untracked_changes() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
test_repo.write_file("base.txt", "modified");
test_repo.write_file("untracked.txt", "new");
let result = super::drop_all(&test_repo.repo, true);
assert!(result.is_ok(), "drop_all failed: {:?}", result);
assert!(
test_repo.status_porcelain().is_empty(),
"working tree should be clean"
);
let path = test_repo.workdir().join("untracked.txt");
assert!(!path.exists(), "untracked.txt should be deleted");
assert_eq!(test_repo.read_file("base.txt"), "Base");
}
#[test]
fn drop_all_fails_when_no_changes() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("Base", "base.txt");
let result = super::drop_all(&test_repo.repo, true);
assert!(result.is_err(), "drop_all should fail with no changes");
assert!(
result.unwrap_err().to_string().contains("No local changes"),
"error should mention no changes"
);
}
#[test]
fn drop_merge_commit_fails() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("a.txt", "a.txt");
let a_oid = test_repo.head_oid();
let upstream_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit_merge("Merge side", a_oid, upstream_oid);
let merge_oid = test_repo.head_oid();
let result = test_repo.in_dir(|| crate::drop::run(merge_oid.to_string(), true));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("merge commit"),
"Error should mention merge commit"
);
}
#[test]
fn drop_prefers_file_over_branch_name_collision() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_empty("A1");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at_commit("collision", a1_oid);
test_repo.write_file("collision", "dirty data");
let result = test_repo.in_dir(|| crate::drop::run("collision".to_string(), true));
assert!(result.is_ok(), "Expected ok, got: {:?}", result);
assert!(test_repo.branch_exists("collision"));
}
#[test]
fn drop_file_resolves_from_nested_cwd() {
let test_repo = TestRepo::new_with_remote();
let sub_dir = test_repo.workdir().join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
std::fs::write(sub_dir.join("file.txt"), "content").unwrap();
test_repo.stage_files(&["sub/file.txt"]);
test_repo.commit_staged("add sub/file.txt");
std::fs::write(sub_dir.join("file.txt"), "modified").unwrap();
let result = test_repo.in_dir_path(&sub_dir, || crate::drop::run("file.txt".to_string(), true));
assert!(result.is_ok(), "Expected ok, got: {:?}", result);
}
#[test]
fn drop_abort_preserves_working_state() {
let test_repo = TestRepo::new_with_remote();
let a_oid = test_repo.commit("version-a", "shared.txt");
test_repo.write_file("shared.txt", "version-b");
test_repo.stage_files(&["shared.txt"]);
test_repo.commit_staged("Commit B");
test_repo.write_file("shared.txt", "working-edit"); test_repo.write_file("other-staged.txt", "staged-content");
test_repo.stage_files(&["other-staged.txt"]);
test_repo.write_file("other-unstaged.txt", "unstaged-content");
test_repo.write_file("new-file.txt", "new-content");
let result = super::drop_commit(&test_repo.repo, &a_oid.to_string(), true);
assert!(
result.is_ok(),
"drop_commit should pause on conflict: {:?}",
result
);
let state_path = test_repo.repo.path().join("loom").join("state.json");
assert!(
state_path.exists(),
"loom state must exist when drop is paused on conflict"
);
let workdir = test_repo.workdir();
let git_dir = test_repo.repo.path().to_path_buf();
crate::core::transaction::abort_cmd(&workdir, &git_dir).unwrap();
assert_eq!(test_repo.read_file("shared.txt"), "working-edit");
assert_eq!(test_repo.read_file("other-staged.txt"), "staged-content");
assert_eq!(
test_repo.read_file("other-unstaged.txt"),
"unstaged-content"
);
assert!(
workdir.join("new-file.txt").exists(),
"new untracked file must survive abort"
);
assert_eq!(test_repo.read_file("new-file.txt"), "new-content");
}