use crate::core::repo;
use crate::core::test_helpers::TestRepo;
use crate::core::weave::Weave;
#[test]
fn fold_file_into_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "modified content");
let head_oid = test_repo.head_oid();
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string()],
&head_oid.to_string(),
false,
);
assert!(
result.is_ok(),
"fold_files_into_commit failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Second commit");
assert_ne!(test_repo.head_oid(), head_oid, "Hash should have changed");
assert_eq!(test_repo.read_file("file1.txt"), "modified content");
}
#[test]
fn fold_multiple_files_into_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.write_file("file1.txt", "modified 1");
test_repo.write_file("new_file.txt", "new content");
let head_oid = test_repo.head_oid();
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string(), "new_file.txt".to_string()],
&head_oid.to_string(),
false,
);
assert!(result.is_ok(), "fold failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "First commit");
assert_eq!(test_repo.read_file("file1.txt"), "modified 1");
assert_eq!(test_repo.read_file("new_file.txt"), "new content");
}
#[test]
fn fold_file_into_non_head_commit() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "amended content");
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string()],
&c1_oid.to_string(),
false,
);
assert!(
result.is_ok(),
"fold_files_into_commit failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Second commit");
assert_eq!(test_repo.get_message(1), "First commit");
assert_ne!(test_repo.get_oid(1), c1_oid);
}
#[test]
fn fold_file_no_changes_fails() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
let head_oid = test_repo.head_oid();
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string()],
&head_oid.to_string(),
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no changes"));
}
#[test]
fn fold_file_into_non_head_with_other_changes_autostashed() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "change 1");
test_repo.write_file("file2.txt", "change 2");
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string()],
&c1_oid.to_string(),
false,
);
assert!(
result.is_ok(),
"fold should succeed with autostash: {:?}",
result
);
assert_eq!(test_repo.read_file("file2.txt"), "change 2");
}
#[test]
fn fold_file_into_woven_branch_commit() {
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.write_file("feature1", "initial feature content");
test_repo.stage_files(&["feature1"]);
test_repo.commit_staged("Feature 1");
let feat1_oid = test_repo.head_oid();
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feat1");
test_repo.write_file("feature1", "updated feature content");
let result = super::fold_files_into_commit(
&test_repo.repo,
&["feature1".to_string()],
&feat1_oid.to_string(),
false,
);
assert!(
result.is_ok(),
"fold_files_into_commit (woven branch) failed: {:?}",
result
);
let feat1_new_tip = test_repo.get_branch_target("feat1");
assert_ne!(feat1_new_tip, feat1_oid, "feat1 should have been rewritten");
assert_eq!(test_repo.read_file("feature1"), "updated feature content");
let status_output = test_repo.status_porcelain();
assert!(
!status_output.contains("UU") && !status_output.contains("AA"),
"Working tree should have no merge conflicts, but status shows:\n{}",
status_output
);
}
fn read_file_from_commit(repo: &git2::Repository, commit_oid: git2::Oid, path: &str) -> String {
let commit = repo.find_commit(commit_oid).unwrap();
let tree = commit.tree().unwrap();
let entry = tree.get_path(std::path::Path::new(path)).unwrap();
let blob = repo.find_blob(entry.id()).unwrap();
std::str::from_utf8(blob.content()).unwrap().to_string()
}
#[test]
fn fold_patch_only_staged_hunk_is_folded_into_head() {
let test_repo = TestRepo::new();
let initial = "line 1\nline 2\nline 3\nline 4\nline 5\n\
line 6\nline 7\nline 8\nline 9\nline 10\n\
line 11\nline 12\nline 13\nline 14\nline 15\n";
test_repo.write_file("file.txt", initial);
test_repo.stage_files(&["file.txt"]);
test_repo.commit_staged("initial");
let modified = "line 1\nMODIFIED TOP\nline 3\nline 4\nline 5\n\
line 6\nline 7\nline 8\nline 9\nline 10\n\
line 11\nline 12\nline 13\nMODIFIED BOTTOM\nline 15\n";
test_repo.write_file("file.txt", modified);
let first_hunk_patch = "--- a/file.txt\n+++ b/file.txt\n\
@@ -1,5 +1,5 @@\n line 1\n-line 2\n+MODIFIED TOP\n \
line 3\n line 4\n line 5\n";
let workdir = test_repo.workdir();
crate::git::apply_cached_patch(workdir.as_path(), first_hunk_patch).unwrap();
let head_oid = test_repo.head_oid();
let staged = crate::core::repo::get_staged_files(&test_repo.repo).unwrap();
assert_eq!(staged, vec!["file.txt"]);
let result =
super::fold_files_into_commit(&test_repo.repo, &staged, &head_oid.to_string(), true);
assert!(
result.is_ok(),
"fold_files_into_commit failed: {:?}",
result
);
let committed = read_file_from_commit(&test_repo.repo, test_repo.head_oid(), "file.txt");
assert!(
committed.contains("MODIFIED TOP"),
"committed content should contain MODIFIED TOP"
);
assert!(
!committed.contains("MODIFIED BOTTOM"),
"committed content must NOT contain MODIFIED BOTTOM (whole-file bug)"
);
let worktree = test_repo.read_file("file.txt");
assert!(
worktree.contains("MODIFIED BOTTOM"),
"working tree should still have MODIFIED BOTTOM"
);
}
#[test]
fn fold_patch_only_staged_hunk_is_folded_into_non_head() {
let test_repo = TestRepo::new_with_remote();
let initial = "line 1\nline 2\nline 3\nline 4\nline 5\n\
line 6\nline 7\nline 8\nline 9\nline 10\n\
line 11\nline 12\nline 13\nline 14\nline 15\n";
test_repo.write_file("file.txt", initial);
test_repo.stage_files(&["file.txt"]);
test_repo.commit_staged("target commit");
let target_oid = test_repo.head_oid();
test_repo.write_file("other.txt", "other content");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("second commit");
let modified = "line 1\nMODIFIED TOP\nline 3\nline 4\nline 5\n\
line 6\nline 7\nline 8\nline 9\nline 10\n\
line 11\nline 12\nline 13\nMODIFIED BOTTOM\nline 15\n";
test_repo.write_file("file.txt", modified);
let first_hunk_patch = "--- a/file.txt\n+++ b/file.txt\n\
@@ -1,5 +1,5 @@\n line 1\n-line 2\n+MODIFIED TOP\n \
line 3\n line 4\n line 5\n";
let workdir = test_repo.workdir();
crate::git::apply_cached_patch(workdir.as_path(), first_hunk_patch).unwrap();
let staged = crate::core::repo::get_staged_files(&test_repo.repo).unwrap();
let result =
super::fold_files_into_commit(&test_repo.repo, &staged, &target_oid.to_string(), true);
assert!(
result.is_ok(),
"fold_files_into_commit failed: {:?}",
result
);
let new_target_oid = test_repo.get_oid(1);
let committed = read_file_from_commit(&test_repo.repo, new_target_oid, "file.txt");
assert!(
committed.contains("MODIFIED TOP"),
"committed content should contain MODIFIED TOP"
);
assert!(
!committed.contains("MODIFIED BOTTOM"),
"committed content must NOT contain MODIFIED BOTTOM (whole-file bug)"
);
}
#[test]
fn fold_commit_into_earlier_commit() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("Original feature", "feature.txt");
let c2_oid = test_repo.commit("Fix typo in feature", "feature.txt");
let result =
super::fold_commit_into_commit(&test_repo.repo, &c2_oid.to_string(), &c1_oid.to_string());
assert!(
result.is_ok(),
"fold_commit_into_commit failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Original feature");
assert_ne!(test_repo.head_oid(), c1_oid);
assert_ne!(test_repo.head_oid(), c2_oid);
}
#[test]
fn fold_commit_into_commit_preserves_other_commits() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("First", "file1.txt");
test_repo.commit("Second", "file2.txt");
let c3_oid = test_repo.commit("Fix for first", "file1.txt");
let result =
super::fold_commit_into_commit(&test_repo.repo, &c3_oid.to_string(), &c1_oid.to_string());
assert!(result.is_ok(), "fold failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "Second");
assert_eq!(test_repo.get_message(1), "First");
}
#[test]
fn fold_commit_same_commit_fails() {
let test_repo = TestRepo::new();
let c1_oid = test_repo.commit("First", "file1.txt");
let result =
super::fold_commit_into_commit(&test_repo.repo, &c1_oid.to_string(), &c1_oid.to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("same commit"));
}
#[test]
fn fold_commit_wrong_direction_fails() {
let test_repo = TestRepo::new();
let c1_oid = test_repo.commit("First", "file1.txt");
let c2_oid = test_repo.commit("Second", "file2.txt");
let result =
super::fold_commit_into_commit(&test_repo.repo, &c1_oid.to_string(), &c2_oid.to_string());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("newer than target")
);
}
#[test]
fn fold_commit_dirty_working_tree_autostashed() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("First", "file1.txt");
let c2_oid = test_repo.commit("Second", "file2.txt");
test_repo.write_file("file1.txt", "dirty");
let result =
super::fold_commit_into_commit(&test_repo.repo, &c2_oid.to_string(), &c1_oid.to_string());
assert!(
result.is_ok(),
"fold should succeed with autostash: {:?}",
result
);
assert_eq!(test_repo.read_file("file1.txt"), "dirty");
}
#[test]
fn fold_commit_to_branch() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at("feature-a", &a1_oid.to_string());
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("B1", "b1.txt");
test_repo.rebase_onto(&base_oid.to_string(), &a1_oid.to_string());
test_repo.merge_no_ff("feature-a");
test_repo.commit("C1", "c1.txt");
let c1_oid = test_repo.head_oid();
let result = super::fold_commit_to_branch(&test_repo.repo, &c1_oid.to_string(), "feature-a");
assert!(result.is_ok(), "fold_commit_to_branch failed: {:?}", result);
assert_eq!(
test_repo.branch_commit_summary("feature-a"),
"C1",
"C1 should now be at the tip of feature-a"
);
}
#[test]
fn fold_commit_to_branch_via_short_ids() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at("feature-a", &a1_oid.to_string());
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("B1", "b1.txt");
test_repo.rebase_onto(&base_oid.to_string(), &a1_oid.to_string());
test_repo.merge_no_ff("feature-a");
test_repo.commit("C1", "c1.txt");
let c1_oid = test_repo.head_oid();
let (commit_sid, branch_sid) = test_repo.in_dir(|| {
let info = crate::core::repo::gather_repo_info(&test_repo.repo, false, 1).unwrap();
let alloc = crate::core::shortid::IdAllocator::new(info.collect_entities());
(
alloc.get_commit(c1_oid).to_string(),
alloc.get_branch("feature-a").to_string(),
)
});
let result = test_repo.in_dir(|| {
super::run(
false,
false,
vec![commit_sid.clone(), branch_sid.clone()],
&crate::core::graph::Theme::dark(),
)
});
assert!(result.is_ok(), "fold via short IDs failed: {:?}", result);
assert_eq!(
test_repo.branch_commit_summary("feature-a"),
"C1",
"C1 should now be at the tip of feature-a"
);
}
#[test]
fn fold_commit_to_branch_dirty_autostashed() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at("feature-a", &a1_oid.to_string());
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("B1", "b1.txt");
test_repo.rebase_onto(&base_oid.to_string(), &a1_oid.to_string());
test_repo.merge_no_ff("feature-a");
let loose_oid = test_repo.commit("Loose", "loose.txt");
test_repo.write_file("a1.txt", "dirty");
let result = super::fold_commit_to_branch(&test_repo.repo, &loose_oid.to_string(), "feature-a");
assert!(
result.is_ok(),
"fold should succeed with autostash: {:?}",
result
);
assert_eq!(test_repo.read_file("a1.txt"), "dirty");
}
#[test]
fn fold_commit_to_colocated_branch_only_affects_target() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("test", &base_oid.to_string());
test_repo.switch_branch("test");
test_repo.commit("Feat1", "feat1.txt");
test_repo.commit("Feat3", "feat3.txt");
let feat3_oid = test_repo.head_oid();
test_repo.switch_branch("integration");
test_repo.merge_no_ff("test");
test_repo.create_branch_at("feat2", &base_oid.to_string());
test_repo.switch_branch("feat2");
test_repo.commit("Feat2", "feat2.txt");
test_repo.switch_branch("integration");
let feat2_tip = test_repo.get_branch_target("feat2");
test_repo.create_branch_at("feat3", &feat2_tip.to_string());
test_repo.merge_no_ff("feat2");
let result = super::fold_commit_to_branch(&test_repo.repo, &feat3_oid.to_string(), "feat3");
assert!(result.is_ok(), "fold_commit_to_branch failed: {:?}", result);
assert_eq!(
test_repo.branch_commit_summary("feat3"),
"Feat3",
"feat3 tip should be Feat3"
);
assert_eq!(
test_repo.branch_commit_summary("feat2"),
"Feat2",
"feat2 tip should still be Feat2, not Feat3"
);
let feat3_commit = test_repo.find_commit(test_repo.get_branch_target("feat3"));
assert_eq!(
feat3_commit.parent_id(0).unwrap(),
test_repo.get_branch_target("feat2"),
"feat3 should be stacked on feat2"
);
let head = test_repo.head_commit();
assert!(
head.summary().unwrap_or("").contains("feat3"),
"HEAD merge message should reference 'feat3', got: {:?}",
head.summary()
);
}
#[test]
fn fold_commit_to_empty_branch() {
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.create_branch_at("feature-b", &base_oid.to_string());
test_repo.switch_branch("feature-b");
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.commit("B1", "b1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-b");
let graph = Weave::from_repo(&test_repo.repo).unwrap();
assert!(
!graph.branch_sections.iter().any(|s| s.label == "feature-a"),
"feature-a should NOT have a section before the fold"
);
let result = super::fold_commit_to_branch(&test_repo.repo, &a1_oid.to_string(), "feature-a");
assert!(result.is_ok(), "fold_commit_to_branch failed: {:?}", result);
assert_eq!(
test_repo.branch_commit_summary("feature-a"),
"A1",
"feature-a tip should be A1, but branch was not updated (still at base)"
);
assert_ne!(
test_repo.get_branch_target("feature-a"),
base_oid,
"feature-a should have moved from the base commit"
);
}
#[test]
fn fold_commit_to_existing_out_of_scope_branch_fails() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("out-of-scope", &base_oid.to_string());
test_repo.switch_branch("out-of-scope");
test_repo.commit("Out of scope work", "oos.txt");
test_repo.switch_branch("integration");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
let result = super::fold_commit_to_branch(&test_repo.repo, &a1_oid.to_string(), "out-of-scope");
assert!(result.is_err(), "should reject out-of-scope branch");
let err = result.unwrap_err().to_string();
assert!(
err.contains("not part of the current integration scope"),
"error should mention scope, got: {}",
err
);
}
#[test]
fn classify_files_into_commit() {
let sources = vec![repo::Target::File("f1.txt".into())];
let target = repo::Target::Commit("abc123".into());
let result = super::classify(&sources, &target);
assert!(result.is_ok());
}
#[test]
fn classify_commit_into_commit() {
let sources = vec![repo::Target::Commit("abc123".into())];
let target = repo::Target::Commit("def456".into());
let result = super::classify(&sources, &target);
assert!(result.is_ok());
}
#[test]
fn classify_commit_into_branch() {
let sources = vec![repo::Target::Commit("abc123".into())];
let target = repo::Target::Branch("feature-a".into());
let result = super::classify(&sources, &target);
assert!(result.is_ok());
}
#[test]
fn classify_branch_source_rejected() {
let sources = vec![repo::Target::Branch("feature-a".into())];
let target = repo::Target::Commit("abc123".into());
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot fold a branch")
);
}
#[test]
fn classify_files_into_branch_rejected() {
let sources = vec![repo::Target::File("f1.txt".into())];
let target = repo::Target::Branch("feature-a".into());
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot fold files into a branch")
);
}
#[test]
fn classify_mixed_sources_rejected() {
let sources = vec![
repo::Target::File("f1.txt".into()),
repo::Target::Commit("abc123".into()),
];
let target = repo::Target::Commit("def456".into());
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Cannot mix"));
}
#[test]
fn classify_multiple_commit_sources_rejected() {
let sources = vec![
repo::Target::Commit("abc123".into()),
repo::Target::Commit("def456".into()),
];
let target = repo::Target::Commit("ghi789".into());
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Only one commit"));
}
#[test]
fn fold_commit_to_unstaged_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
let head_oid = test_repo.head_oid();
let result = super::fold_commit_to_unstaged(&test_repo.repo, &head_oid.to_string());
assert!(
result.is_ok(),
"fold_commit_to_unstaged failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "First commit");
assert_eq!(test_repo.read_file("file2.txt"), "Second commit");
assert_ne!(test_repo.head_oid(), head_oid);
}
#[test]
fn fold_commit_to_unstaged_non_head() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
let result = super::fold_commit_to_unstaged(&test_repo.repo, &c1_oid.to_string());
assert!(
result.is_ok(),
"fold_commit_to_unstaged (non-HEAD) failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Second commit");
assert_eq!(test_repo.read_file("file1.txt"), "First commit");
}
#[test]
fn fold_commit_to_unstaged_dirty_autostashed() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "dirty");
let head_oid = test_repo.head_oid();
let result = super::fold_commit_to_unstaged(&test_repo.repo, &head_oid.to_string());
assert!(
result.is_ok(),
"fold should succeed with dirty tree: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "First commit");
assert_eq!(test_repo.read_file("file1.txt"), "dirty");
assert_eq!(test_repo.read_file("file2.txt"), "Second commit");
}
#[test]
fn classify_commit_into_unstaged() {
let sources = vec![repo::Target::Commit("abc123".into())];
let target = repo::Target::Unstaged;
let result = super::classify(&sources, &target);
assert!(result.is_ok());
assert!(matches!(
result.unwrap(),
super::FoldOp::CommitToUnstaged { .. }
));
}
#[test]
fn classify_files_into_unstaged_rejected() {
let sources = vec![repo::Target::File("f1.txt".into())];
let target = repo::Target::Unstaged;
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot fold files into unstaged")
);
}
#[test]
fn fold_unstaged_into_commit() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "modified 1");
test_repo.write_file("file2.txt", "modified 2");
let head_oid = test_repo.head_oid();
let result = test_repo.in_dir(|| {
super::run(
false,
false,
vec!["zz".into(), "HEAD".into()],
&crate::core::graph::Theme::dark(),
)
});
assert!(result.is_ok(), "fold zz HEAD failed: {:?}", result);
assert_ne!(test_repo.head_oid(), head_oid, "Hash should have changed");
assert_eq!(test_repo.read_file("file1.txt"), "modified 1");
assert_eq!(test_repo.read_file("file2.txt"), "modified 2");
}
#[test]
fn fold_unstaged_clean_tree_fails() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
let result = test_repo.in_dir(|| {
super::run(
false,
false,
vec!["zz".into(), "HEAD".into()],
&crate::core::graph::Theme::dark(),
)
});
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("working tree is clean"),
"Expected clean-tree error"
);
}
#[test]
fn fold_commit_file_to_unstaged_head() {
let test_repo = TestRepo::new();
test_repo.write_file("file1.txt", "content1");
test_repo.write_file("file2.txt", "content2");
test_repo.stage_files(&["file1.txt", "file2.txt"]);
test_repo.commit_staged("Two files");
let head_oid = test_repo.head_oid();
let result =
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "file1.txt");
assert!(
result.is_ok(),
"fold_commit_file_to_unstaged failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Two files");
assert_eq!(test_repo.read_file("file1.txt"), "content1");
assert_eq!(test_repo.read_file("file2.txt"), "content2");
}
#[test]
fn fold_commit_file_to_unstaged_non_head() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file1.txt", "content1");
test_repo.write_file("file2.txt", "content2");
test_repo.stage_files(&["file1.txt", "file2.txt"]);
test_repo.commit_staged("Two files");
let c1_oid = test_repo.head_oid();
test_repo.write_file("file3.txt", "content3");
test_repo.stage_files(&["file3.txt"]);
test_repo.commit_staged("Second commit");
let result =
super::fold_commit_file_to_unstaged(&test_repo.repo, &c1_oid.to_string(), "file1.txt");
assert!(
result.is_ok(),
"fold_commit_file_to_unstaged (non-HEAD) failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Second commit");
assert_eq!(test_repo.get_message(1), "Two files");
assert_eq!(test_repo.read_file("file1.txt"), "content1");
assert_eq!(test_repo.read_file("file2.txt"), "content2");
}
#[test]
fn fold_commit_file_to_unstaged_no_changes_fails() {
let test_repo = TestRepo::new();
test_repo.commit("A commit", "file1.txt");
let head_oid = test_repo.head_oid();
let result = super::fold_commit_file_to_unstaged(
&test_repo.repo,
&head_oid.to_string(),
"nonexistent.txt",
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no changes"));
}
#[test]
fn fold_commit_file_to_commit() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file1.txt", "content1");
test_repo.write_file("file2.txt", "content2");
test_repo.stage_files(&["file1.txt", "file2.txt"]);
test_repo.commit_staged("Source commit");
let source_oid = test_repo.head_oid();
test_repo.write_file("file3.txt", "content3");
test_repo.stage_files(&["file3.txt"]);
test_repo.commit_staged("Target commit");
let target_oid = test_repo.head_oid();
let result = super::fold_commit_file_to_commit(
&test_repo.repo,
&source_oid.to_string(),
"file1.txt",
&target_oid.to_string(),
);
assert!(
result.is_ok(),
"fold_commit_file_to_commit failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Target commit");
assert_eq!(test_repo.get_message(1), "Source commit");
assert_eq!(test_repo.read_file("file1.txt"), "content1");
assert_eq!(test_repo.read_file("file2.txt"), "content2");
}
#[test]
fn fold_commit_file_to_commit_same_commit_fails() {
let test_repo = TestRepo::new();
let c1_oid = test_repo.commit("A commit", "file1.txt");
let result = super::fold_commit_file_to_commit(
&test_repo.repo,
&c1_oid.to_string(),
"file1.txt",
&c1_oid.to_string(),
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("same commit"));
}
#[test]
fn fold_commit_file_to_older_commit() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file_a.txt", "aaa");
test_repo.stage_files(&["file_a.txt"]);
test_repo.commit_staged("Add file_a");
let c1_oid = test_repo.head_oid();
test_repo.write_file("file_a.txt", "aaa modified");
test_repo.write_file("file_b.txt", "bbb");
test_repo.stage_files(&["file_a.txt", "file_b.txt"]);
test_repo.commit_staged("Add file_b and modify file_a");
let c2_oid = test_repo.head_oid();
let result = super::fold_commit_file_to_commit(
&test_repo.repo,
&c2_oid.to_string(),
"file_a.txt",
&c1_oid.to_string(),
);
assert!(
result.is_ok(),
"fold_commit_file_to_commit (newer→older) failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Add file_b and modify file_a");
assert_eq!(test_repo.get_message(1), "Add file_a");
assert_eq!(test_repo.read_file("file_a.txt"), "aaa modified");
assert_eq!(test_repo.read_file("file_b.txt"), "bbb");
let c2_diff = test_repo.diff_commit(&test_repo.head_oid().to_string());
assert!(
!c2_diff.contains("file_a.txt"),
"C2 should no longer have any changes to file_a.txt, but diff contains:\n{}",
c2_diff
);
}
#[test]
fn fold_commit_file_to_unstaged_stacked_branch() {
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.write_file("fa1.txt", "feature-a file 1");
test_repo.write_file("fa2.txt", "feature-a file 2");
test_repo.stage_files(&["fa1.txt", "fa2.txt"]);
test_repo.commit_staged("A1: two files");
test_repo.create_branch_at("feature-b", &test_repo.head_oid().to_string());
test_repo.switch_branch("feature-b");
test_repo.write_file("fb1.txt", "feature-b file 1");
test_repo.stage_files(&["fb1.txt"]);
test_repo.commit_staged("B1: one file");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-b");
assert!(
test_repo.branch_exists("feature-a"),
"feature-a should exist before fold"
);
assert!(
test_repo.branch_exists("feature-b"),
"feature-b should exist before fold"
);
assert_eq!(test_repo.read_file("fa1.txt"), "feature-a file 1");
let fa_tip = test_repo.get_branch_target("feature-a");
let result =
super::fold_commit_file_to_unstaged(&test_repo.repo, &fa_tip.to_string(), "fa1.txt");
assert!(
result.is_ok(),
"fold_commit_file_to_unstaged (stacked branch) failed: {:?}",
result
);
assert!(
test_repo.branch_exists("feature-a"),
"feature-a branch should still exist after fold"
);
assert!(
test_repo.branch_exists("feature-b"),
"feature-b branch should still exist after fold"
);
assert_eq!(test_repo.read_file("fa1.txt"), "feature-a file 1");
assert_eq!(test_repo.read_file("fa2.txt"), "feature-a file 2");
assert_eq!(test_repo.read_file("fb1.txt"), "feature-b file 1");
}
#[test]
fn fold_commit_file_to_commit_stacked_branch() {
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.write_file("fa1.txt", "feature-a file 1");
test_repo.stage_files(&["fa1.txt"]);
test_repo.commit_staged("A1");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at("feature-b", &a1_oid.to_string());
test_repo.switch_branch("feature-b");
test_repo.write_file("fb1.txt", "feature-b file 1");
test_repo.write_file("fb2.txt", "feature-b file 2");
test_repo.stage_files(&["fb1.txt", "fb2.txt"]);
test_repo.commit_staged("B1");
let b1_oid = test_repo.head_oid();
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-b");
let result = super::fold_commit_file_to_commit(
&test_repo.repo,
&b1_oid.to_string(),
"fb1.txt",
&a1_oid.to_string(),
);
assert!(
result.is_ok(),
"fold_commit_file_to_commit (stacked branch) failed: {:?}",
result
);
assert!(
test_repo.branch_exists("feature-a"),
"feature-a should still exist after fold"
);
assert!(
test_repo.branch_exists("feature-b"),
"feature-b should still exist after fold"
);
assert_eq!(test_repo.read_file("fb1.txt"), "feature-b file 1");
let b_tip = test_repo.get_branch_target("feature-b");
let b_diff = test_repo.diff_commit(&b_tip.to_string());
assert!(
!b_diff.contains("fb1.txt"),
"B1 should no longer have fb1.txt changes, but diff contains:\n{}",
b_diff
);
assert!(
b_diff.contains("fb2.txt"),
"B1 should still have fb2.txt changes"
);
let a_tip = test_repo.get_branch_target("feature-a");
let a_diff = test_repo.diff_commit(&a_tip.to_string());
assert!(
a_diff.contains("fb1.txt"),
"feature-a (A1) should now include fb1.txt, but diff is:\n{}",
a_diff
);
assert!(
a_diff.contains("fa1.txt"),
"feature-a (A1) should still include fa1.txt"
);
}
#[test]
fn fold_commit_file_to_commit_woven_branches() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("foo1", &base_oid.to_string());
test_repo.switch_branch("foo1");
test_repo.write_file("feature1", "feat 1");
test_repo.stage_files(&["feature1"]);
test_repo.commit_staged("Feature 1");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("foo1");
test_repo.create_branch_at("foo2", &base_oid.to_string());
test_repo.switch_branch("foo2");
test_repo.write_file("feature2", "feat 2");
test_repo.stage_files(&["feature2"]);
test_repo.commit_staged("Feature 2");
let foo2_tip = test_repo.head_oid();
test_repo.create_branch_at("foo3", &foo2_tip.to_string());
test_repo.switch_branch("foo3");
test_repo.write_file("feature2", "feat 2 updated");
test_repo.write_file("feature7", "feat 7");
test_repo.stage_files(&["feature2", "feature7"]);
test_repo.commit_staged("Feature 2 fixup");
let foo3_tip = test_repo.head_oid();
test_repo.switch_branch("integration");
test_repo.merge_no_ff("foo3");
let result = super::fold_commit_file_to_commit(
&test_repo.repo,
&foo3_tip.to_string(),
"feature7",
&foo2_tip.to_string(),
);
assert!(
result.is_ok(),
"fold_commit_file_to_commit (woven branches) failed: {:?}",
result
);
assert!(test_repo.branch_exists("foo1"), "foo1 should still exist");
assert!(test_repo.branch_exists("foo2"), "foo2 should still exist");
assert!(test_repo.branch_exists("foo3"), "foo3 should still exist");
assert_eq!(test_repo.read_file("feature7"), "feat 7");
let foo2_new_tip = test_repo.get_branch_target("foo2");
let foo2_diff = test_repo.diff_commit(&foo2_new_tip.to_string());
assert!(
foo2_diff.contains("feature7"),
"foo2 should now include feature7, but diff is:\n{}",
foo2_diff
);
let foo3_new_tip = test_repo.get_branch_target("foo3");
let foo3_diff = test_repo.diff_commit(&foo3_new_tip.to_string());
assert!(
!foo3_diff.contains("feature7"),
"foo3 should no longer include feature7, but diff contains:\n{}",
foo3_diff
);
assert!(
foo3_diff.contains("feature2"),
"foo3 should still have feature2 changes"
);
}
#[test]
fn classify_commit_file_into_unstaged() {
let sources = vec![repo::Target::CommitFile {
commit: "abc123".into(),
path: "file.txt".into(),
}];
let target = repo::Target::Unstaged;
let result = super::classify(&sources, &target);
assert!(result.is_ok());
assert!(matches!(
result.unwrap(),
super::FoldOp::CommitFileToUnstaged { .. }
));
}
#[test]
fn classify_commit_file_into_commit() {
let sources = vec![repo::Target::CommitFile {
commit: "abc123".into(),
path: "file.txt".into(),
}];
let target = repo::Target::Commit("def456".into());
let result = super::classify(&sources, &target);
assert!(result.is_ok());
assert!(matches!(
result.unwrap(),
super::FoldOp::CommitFileToCommit { .. }
));
}
#[test]
fn classify_commit_file_into_branch_rejected() {
let sources = vec![repo::Target::CommitFile {
commit: "abc123".into(),
path: "file.txt".into(),
}];
let target = repo::Target::Branch("feature-a".into());
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot fold a commit file into a branch")
);
}
#[test]
fn classify_commit_file_target_rejected() {
let sources = vec![repo::Target::Commit("abc123".into())];
let target = repo::Target::CommitFile {
commit: "def456".into(),
path: "file.txt".into(),
};
let result = super::classify(&sources, &target);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not a commit file")
);
}
#[test]
fn resolve_fold_arg_filesystem_path() {
let test_repo = TestRepo::new();
test_repo.commit("commit", "file1.txt");
test_repo.write_file("file1.txt", "changed");
let result = test_repo
.in_dir(|| repo::resolve_arg(&test_repo.repo, "file1.txt", &[repo::TargetKind::File]));
assert!(result.is_ok(), "resolve failed: {:?}", result);
assert!(matches!(result.unwrap(), repo::Target::File(_)));
}
#[test]
fn resolve_fold_arg_commit_hash() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit_empty("commit");
let result = test_repo.in_dir(|| {
repo::resolve_arg(
&test_repo.repo,
&c1_oid.to_string(),
&[repo::TargetKind::Commit],
)
});
assert!(result.is_ok());
assert!(matches!(result.unwrap(), repo::Target::Commit(_)));
}
#[test]
fn resolve_fold_arg_branch_name() {
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("feature-a", a1_oid);
let result = test_repo
.in_dir(|| repo::resolve_arg(&test_repo.repo, "feature-a", &[repo::TargetKind::Branch]));
assert!(result.is_ok());
assert!(matches!(result.unwrap(), repo::Target::Branch(_)));
}
#[test]
fn resolve_fold_arg_head() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_empty("commit");
let result = test_repo
.in_dir(|| repo::resolve_arg(&test_repo.repo, "HEAD", &[repo::TargetKind::Commit]));
assert!(result.is_ok());
assert!(matches!(result.unwrap(), repo::Target::Commit(_)));
}
#[test]
fn fold_create_moves_commit_on_branch_to_new_branch() {
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");
let a1_oid = test_repo.commit("A1", "a1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
let result = super::run_create(
&test_repo.repo,
&[a1_oid.to_string(), "new-branch".to_string()],
);
assert!(result.is_ok(), "fold --create failed: {:?}", result);
assert_eq!(
test_repo.branch_commit_summary("new-branch"),
"A1",
"new-branch should have A1 at its tip"
);
}
#[test]
fn fold_create_warns_and_moves_to_existing_branch() {
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());
let loose_oid = test_repo.commit("Loose", "loose.txt");
let result = super::run_create(
&test_repo.repo,
&[loose_oid.to_string(), "feature-a".to_string()],
);
assert!(
result.is_ok(),
"fold --create with existing branch should succeed: {:?}",
result
);
assert_eq!(
test_repo.branch_commit_summary("feature-a"),
"Loose",
"Loose commit should have moved to feature-a"
);
}
#[test]
fn fold_create_rejects_non_commit_source() {
let test_repo = TestRepo::new_with_remote();
test_repo.create_branch("feature-a");
let result = super::run_create(
&test_repo.repo,
&["feature-a".to_string(), "new-branch".to_string()],
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("commit"),
"should error when source is a branch, got: {err}"
);
}
#[test]
fn fold_staged_into_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "staged content");
test_repo.stage_files(&["file1.txt"]);
let head_oid = test_repo.head_oid();
let result = super::run_staged(&test_repo.repo, &head_oid.to_string());
assert!(result.is_ok(), "run_staged failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "Second commit");
assert_ne!(test_repo.head_oid(), head_oid);
assert_eq!(test_repo.read_file("file1.txt"), "staged content");
}
#[test]
fn fold_staged_nothing_staged_fails() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
let head_oid = test_repo.head_oid();
let result = super::run_staged(&test_repo.repo, &head_oid.to_string());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Nothing to commit"),
"should error when nothing is staged"
);
}
#[test]
fn fold_staged_only_uses_staged_not_unstaged() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
test_repo.write_file("file1.txt", "staged content");
test_repo.write_file("file2.txt", "unstaged content");
test_repo.stage_files(&["file1.txt"]);
let head_oid = test_repo.head_oid();
let result = super::run_staged(&test_repo.repo, &head_oid.to_string());
assert!(result.is_ok(), "run_staged failed: {:?}", result);
assert_eq!(test_repo.read_file("file1.txt"), "staged content");
assert_eq!(test_repo.read_file("file2.txt"), "unstaged content");
}
#[test]
fn fold_staged_non_commit_target_fails() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("First commit", "file1.txt");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at_commit("feature-a", a1_oid);
test_repo.write_file("file1.txt", "staged content");
test_repo.stage_files(&["file1.txt"]);
let result = test_repo.in_dir(|| super::run_staged(&test_repo.repo, "feature-a"));
assert!(result.is_err(), "should have failed");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("commit"),
"should error when target is not a commit, got: {err_msg}"
);
}
#[test]
fn fold_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", "version-folded");
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::fold_files_into_commit(
&test_repo.repo,
&["shared.txt".to_string()],
&a_oid.to_string(),
false,
);
assert!(
result.is_ok(),
"fold 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 fold 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("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");
}