use crate::core::hunk_select::HunkArgs;
use crate::core::repo;
use crate::core::test_helpers::TestRepo;
use crate::core::weave::{Position, Weave};
use crate::tui::hunk_selector::{FileEntry, HunkEntry, HunkOrigin};
#[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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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,
&[],
None,
);
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_into_a_non_head_commit_stages_the_other_files_again() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file.txt", "target\n");
test_repo.stage_files(&["file.txt"]);
test_repo.commit_staged("target commit");
let target_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later\n");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("second commit");
test_repo.write_file("file.txt", "target amended\n");
test_repo.write_file("kept.txt", "staged, and none of the fold's business\n");
test_repo.stage_files(&["file.txt", "kept.txt"]);
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file.txt".to_string()],
&target_oid.to_string(),
true,
&[],
None,
);
assert!(result.is_ok(), "fold failed: {result:?}");
let status = test_repo.status_porcelain();
assert!(status.contains("A kept.txt"), "{status}");
}
#[test]
fn a_fold_failing_after_the_fixup_commit_puts_back_the_staging() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file.txt", "target\n");
test_repo.stage_files(&["file.txt"]);
test_repo.commit_staged("target commit");
let target_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later\n");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("second commit");
test_repo.write_file("file.txt", "target amended\n");
test_repo.write_file("kept.txt", "staged, and none of the fold's business\n");
test_repo.stage_files(&["file.txt", "kept.txt"]);
std::fs::write(test_repo.repo.path().join("loom"), "not a directory").unwrap();
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file.txt".to_string()],
&target_oid.to_string(),
true,
&[],
None,
);
assert!(result.is_err(), "the state file cannot be written");
let reflog = crate::git::run_git_stdout(&test_repo.workdir(), &["reflog", "--format=%gs"])
.expect("reflog");
assert!(
reflog.contains("fixup! target commit"),
"the fixup commit must already exist: {reflog}"
);
let status = test_repo.status_porcelain();
assert!(status.contains("A kept.txt"), "{status}");
}
#[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,
None,
HunkArgs::default(),
vec![commit_sid.clone(), branch_sid.clone()],
vec![],
&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()
.ok()
.flatten()
.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_several_commits_into_branch() {
let sources = vec![
repo::Target::Commit("abc123".into()),
repo::Target::Commit("def456".into()),
];
let target = repo::Target::Branch("feature-a".into());
match super::classify(&sources, &target) {
Ok(super::FoldOp::CommitsToBranch { commits, branch }) => {
assert_eq!(commits.len(), 2);
assert_eq!(branch, "feature-a");
}
other => panic!("expected CommitsToBranch, got {other:?}"),
}
}
#[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 fold_commit_to_unstaged_when_a_later_commit_edited_nearby_lines() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_multi(&[("f.txt", "1\n2\n3\n4\n5\n6\n7\n")], "Base");
let target = test_repo.commit_multi(&[("f.txt", "1\n2\n3\nFOUR\n5\n6\n7\n")], "Change 4");
test_repo.commit_multi(&[("f.txt", "1\n2\n3\nFOUR\n5\nSIX\n7\n")], "Change 6");
let result = super::fold_commit_to_unstaged(&test_repo.repo, &target.to_string());
assert!(
result.is_ok(),
"fold_commit_to_unstaged failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Change 6");
assert_eq!(test_repo.read_file("f.txt"), "1\n2\n3\nFOUR\n5\nSIX\n7\n");
assert_eq!(test_repo.status_porcelain(), " M f.txt\n");
}
#[test]
fn fold_commit_to_unstaged_rollback_keeps_uncommitted_changes() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_multi(&[("f.txt", "1\n2\n3\n4\n5\n6\n7\n")], "Base");
let target = test_repo.commit_multi(&[("f.txt", "1\n2\n3\nFOUR\n5\n6\n7\n")], "Change 4");
test_repo.commit("Other", "other.txt");
crate::git::run_git(&test_repo.workdir(), &["mv", "f.txt", "g.txt"]).unwrap();
test_repo.commit_staged("Rename");
let head_before = test_repo.head_oid();
test_repo.write_file("other.txt", "uncommitted work");
let err = super::fold_commit_to_unstaged(&test_repo.repo, &target.to_string())
.expect_err("the diff must not merge back");
assert!(
err.to_string().contains("rolled back"),
"unexpected error: {err}"
);
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(test_repo.read_file("other.txt"), "uncommitted work");
assert_eq!(test_repo.read_file("g.txt"), "1\n2\n3\nFOUR\n5\n6\n7\n");
assert_eq!(test_repo.status_porcelain(), " M other.txt\n");
}
#[test]
fn fold_commit_to_unstaged_parks_sole_branch_at_base() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("inner", &base_oid.to_string());
test_repo.switch_branch("inner");
let i1_oid = test_repo.commit("I1", "i1.txt");
test_repo.create_branch_at("inner-too", &i1_oid.to_string());
test_repo.create_branch_at("outer", &i1_oid.to_string());
test_repo.switch_branch("outer");
test_repo.commit("O1", "o1.txt");
test_repo.create_branch_at("solo", &base_oid.to_string());
test_repo.switch_branch("solo");
let s1_oid = test_repo.commit("S1", "s1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("outer");
test_repo.merge_no_ff("solo");
super::fold_commit_to_unstaged(&test_repo.repo, &i1_oid.to_string())
.expect("uncommitting the sole commit of two inner branches");
for name in ["inner", "inner-too"] {
assert_eq!(test_repo.get_branch_target(name), base_oid, "{name}");
}
let outer = test_repo.get_branch_target("outer");
let o1 = test_repo.repo.find_commit(outer).unwrap();
assert_eq!(o1.summary().unwrap().unwrap(), "O1");
assert_eq!(o1.parent_id(0).unwrap(), base_oid, "I1 is gone from outer");
assert!(test_repo.status_porcelain().contains("i1.txt"));
let head_before = test_repo.head_oid();
super::fold_commit_to_unstaged(&test_repo.repo, &s1_oid.to_string())
.expect("uncommitting the sole commit of a woven branch");
assert_eq!(test_repo.get_branch_target("solo"), base_oid);
assert_ne!(test_repo.head_oid(), head_before);
let head = test_repo.repo.head().unwrap().peel_to_commit().unwrap();
assert_eq!(head.parent_count(), 2, "only the merge of outer remains");
assert_eq!(
head.parent_id(1).unwrap(),
test_repo.get_branch_target("outer")
);
assert!(test_repo.status_porcelain().contains("s1.txt"));
}
#[test]
fn fold_commit_to_branch_leaves_inner_branch_behind() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("inner", &base_oid.to_string());
test_repo.switch_branch("inner");
let i1_oid = test_repo.commit("I1", "i1.txt");
test_repo.create_branch_at("outer", &i1_oid.to_string());
test_repo.switch_branch("outer");
test_repo.commit("O1", "o1.txt");
test_repo.create_branch_at("other", &base_oid.to_string());
test_repo.switch_branch("other");
test_repo.commit("X1", "x1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("outer");
test_repo.merge_no_ff("other");
let (_, parked) =
super::move_commits_to_branch(&test_repo.repo, &[i1_oid.to_string()], "other")
.expect("moving the sole commit of an inner branch");
assert_eq!(parked, vec!["inner".to_string()]);
assert_eq!(test_repo.get_branch_target("inner"), base_oid);
let repo = &test_repo.repo;
let other = repo
.find_commit(test_repo.get_branch_target("other"))
.unwrap();
assert_eq!(other.summary().unwrap().unwrap(), "I1");
assert_eq!(
repo.find_commit(other.parent_id(0).unwrap())
.unwrap()
.summary()
.unwrap()
.unwrap(),
"X1"
);
let outer = repo
.find_commit(test_repo.get_branch_target("outer"))
.unwrap();
assert_eq!(outer.summary().unwrap().unwrap(), "O1");
assert_eq!(outer.parent_id(0).unwrap(), base_oid);
}
#[test]
fn fold_commit_to_branch_leaves_inner_branch_at_the_commit_before() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("inner", &base_oid.to_string());
test_repo.switch_branch("inner");
let i1_oid = test_repo.commit("I1", "i1.txt");
let i2_oid = test_repo.commit("I2", "i2.txt");
test_repo.create_branch_at("outer", &i2_oid.to_string());
test_repo.switch_branch("outer");
test_repo.commit("O1", "o1.txt");
test_repo.create_branch_at("other", &base_oid.to_string());
test_repo.switch_branch("other");
test_repo.commit("X1", "x1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("outer");
test_repo.merge_no_ff("other");
let (_, parked) =
super::move_commits_to_branch(&test_repo.repo, &[i2_oid.to_string()], "other")
.expect("moving a commit out of an inner branch");
assert!(parked.is_empty(), "inner still has a commit: {parked:?}");
assert_eq!(
test_repo.branch_commit_summary("inner"),
"I1",
"inner must end at the commit before I2"
);
assert_eq!(test_repo.get_branch_target("inner"), i1_oid);
assert_eq!(test_repo.branch_commit_summary("other"), "I2");
}
#[test]
fn fold_commit_to_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("inner", &base_oid.to_string());
test_repo.switch_branch("inner");
let i1_oid = test_repo.commit("I1", "i1.txt");
test_repo.create_branch_at("outer", &i1_oid.to_string());
test_repo.switch_branch("outer");
test_repo.commit("O1", "o1.txt");
test_repo.create_branch_at("other", &base_oid.to_string());
test_repo.switch_branch("other");
let x1_oid = test_repo.commit("X1", "x1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("outer");
test_repo.merge_no_ff("other");
let (_, parked) =
super::move_commits_to_branch(&test_repo.repo, &[x1_oid.to_string()], "inner")
.expect("moving a commit onto an inner branch");
assert_eq!(parked, vec!["other".to_string()]);
assert_eq!(test_repo.get_branch_target("other"), base_oid);
let repo = &test_repo.repo;
let inner = repo
.find_commit(test_repo.get_branch_target("inner"))
.unwrap();
assert_eq!(inner.summary().unwrap().unwrap(), "X1");
assert_eq!(
inner.parent_id(0).unwrap(),
i1_oid,
"X1 sits right after I1"
);
let outer = repo
.find_commit(test_repo.get_branch_target("outer"))
.unwrap();
assert_eq!(outer.summary().unwrap().unwrap(), "O1");
assert_eq!(
outer.parent_id(0).unwrap(),
inner.id(),
"outer is replayed on top of the moved commit"
);
assert!(test_repo.commit_has_file(outer.id(), "x1.txt"));
}
#[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,
None,
HunkArgs::default(),
vec!["zz".into(), "HEAD".into()],
vec![],
&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,
None,
HunkArgs::default(),
vec!["zz".into(), "HEAD".into()],
vec![],
&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_submodule() {
let test_repo = TestRepo::new();
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "Data", &[])
.unwrap();
let new_head = test_repo.head_oid();
assert_eq!(test_repo.commit_file_paths(new_head), ["other.txt"]);
assert_eq!(test_repo.submodule_oid(new_head, "Data"), first);
assert_eq!(test_repo.status_porcelain().trim(), "M Data");
}
#[test]
fn fold_commit_file_to_unstaged_submodule_non_head() {
let test_repo = TestRepo::new_with_remote();
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let bump_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("Later");
super::fold_commit_file_to_unstaged(&test_repo.repo, &bump_oid.to_string(), "Data", &[])
.unwrap();
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(1)),
["other.txt"]
);
assert_eq!(test_repo.submodule_oid(test_repo.head_oid(), "Data"), first);
assert_eq!(test_repo.status_porcelain().trim(), "M Data");
}
#[test]
fn fold_commit_file_to_unstaged_submodule_add() {
let test_repo = TestRepo::new();
test_repo.add_submodule("Data");
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("Add submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "Data", &[])
.unwrap();
let new_head = test_repo.head_oid();
assert!(!test_repo.commit_has_file(new_head, "Data"));
assert!(test_repo.commit_has_file(new_head, "other.txt"));
assert_eq!(test_repo.status_porcelain().trim(), "?? Data/");
}
#[test]
fn picked_whole_files_carries_a_submodule() {
let test_repo = TestRepo::new();
let (_first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let workdir = test_repo.workdir();
let head = test_repo.head_oid().to_string();
let entry = |path: &str, selected: bool| FileEntry {
path: path.to_string(),
hunks: vec![HunkEntry {
hunk: crate::core::diff::DiffHunk {
text: String::from("(submodule)"),
modified_lines: vec![],
},
selected,
origin: HunkOrigin::Commit,
}],
index_status: 'M',
worktree_status: ' ',
binary: path == "Data",
};
let picked = [entry("other.txt", true), entry("Data", true)];
let gitlinks = super::picked_whole_files(&workdir, &head, &picked).unwrap();
assert_eq!(gitlinks.len(), 1);
assert_eq!(gitlinks[0].path, "Data");
assert!(matches!(
gitlinks[0].kind,
super::WholeFileKind::Gitlink { removed: false }
));
assert!(gitlinks[0].diff.contains("160000"));
let untouched = [entry("other.txt", true), entry("Data", false)];
assert!(
super::picked_whole_files(&workdir, &head, &untouched)
.unwrap()
.is_empty()
);
}
#[test]
fn fold_commit_file_to_unstaged_submodule_remove_keeps_the_checkout() {
let test_repo = TestRepo::new();
let (first, _second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
crate::git::run_git(
&test_repo.workdir(),
&["rm", "-r", "-q", "--cached", "Data"],
)
.unwrap();
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("Remove submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "Data", &[])
.unwrap();
let new_head = test_repo.head_oid();
assert_eq!(test_repo.submodule_oid(new_head, "Data"), first);
assert_eq!(
test_repo.status_porcelain().trim(),
"D Data
?? Data/"
);
}
#[test]
fn fold_commit_file_to_unstaged_submodule_remove_drops_the_checkout() {
let test_repo = TestRepo::new();
let (first, _second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
crate::git::run_git(&test_repo.workdir(), &["rm", "-r", "-q", "Data"]).unwrap();
test_repo.commit_staged("Remove submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "Data", &[])
.unwrap();
let new_head = test_repo.head_oid();
assert_eq!(test_repo.submodule_oid(new_head, "Data"), first);
assert_eq!(test_repo.status_porcelain().trim(), "D Data");
}
#[test]
fn fold_commit_file_to_unstaged_submodule_remove_non_head() {
let test_repo = TestRepo::new_with_remote();
let (first, _second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
crate::git::run_git(
&test_repo.workdir(),
&["rm", "-r", "-q", "--cached", "Data"],
)
.unwrap();
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("Remove submodule");
let remove_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("Later");
super::fold_commit_file_to_unstaged(&test_repo.repo, &remove_oid.to_string(), "Data", &[])
.unwrap();
assert_eq!(test_repo.submodule_oid(test_repo.head_oid(), "Data"), first);
assert_eq!(
test_repo.status_porcelain().trim(),
"D Data
?? Data/"
);
}
#[test]
fn fold_commit_to_unstaged_submodule_remove() {
let test_repo = TestRepo::new();
let (first, _second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
crate::git::run_git(
&test_repo.workdir(),
&["rm", "-r", "-q", "--cached", "Data"],
)
.unwrap();
test_repo.commit_staged("Remove submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_to_unstaged(&test_repo.repo, &head_oid.to_string()).unwrap();
assert_eq!(test_repo.submodule_oid(test_repo.head_oid(), "Data"), first);
assert_eq!(
test_repo.status_porcelain().trim(),
"D Data
?? Data/"
);
}
#[test]
fn fold_commit_to_unstaged_submodule_non_head() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_config("submodule.recurse", "true");
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.stage_files(&["Data"]);
test_repo.commit_staged("Bump submodule");
let bump_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("Later");
super::fold_commit_to_unstaged(&test_repo.repo, &bump_oid.to_string()).unwrap();
assert_eq!(test_repo.submodule_oid(test_repo.head_oid(), "Data"), first);
assert_eq!(test_repo.status_porcelain().trim(), "M Data");
}
#[test]
fn fold_commit_file_to_unstaged_submodule_non_ascii_path() {
let test_repo = TestRepo::new();
let (first, second) = test_repo.add_submodule("Dätä");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Dätä", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Dätä", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let head_oid = test_repo.head_oid();
super::fold_commit_file_to_unstaged(&test_repo.repo, &head_oid.to_string(), "Dätä", &[])
.unwrap();
let new_head = test_repo.head_oid();
assert_eq!(test_repo.commit_file_paths(new_head), ["other.txt"]);
assert_eq!(test_repo.submodule_oid(new_head, "Dätä"), first);
let changed: Vec<String> = test_repo
.repo
.statuses(None)
.unwrap()
.iter()
.filter_map(|e| e.path().map(str::to_string).ok())
.collect();
assert_eq!(changed, ["Dätä"]);
}
#[test]
fn fold_commit_to_unstaged_rollback_keeps_a_staged_only_change() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_multi(&[("f.txt", "1\n2\n3\n4\n5\n6\n7\n")], "Base");
let target = test_repo.commit_multi(&[("f.txt", "1\n2\n3\nFOUR\n5\n6\n7\n")], "Change 4");
test_repo.commit("Other", "other.txt");
crate::git::run_git(&test_repo.workdir(), &["mv", "f.txt", "g.txt"]).unwrap();
test_repo.commit_staged("Rename");
let head_before = test_repo.head_oid();
test_repo.write_file("other.txt", "staged only");
test_repo.stage_files(&["other.txt"]);
test_repo.write_file("other.txt", "Other");
super::fold_commit_to_unstaged(&test_repo.repo, &target.to_string())
.expect_err("the diff must not merge back");
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(test_repo.status_porcelain(), "MM other.txt\n");
assert_eq!(test_repo.read_file("other.txt"), "Other");
assert!(
crate::git::diff_cached(&test_repo.workdir())
.unwrap()
.contains("staged only"),
"the staged content must be the one that was staged"
);
}
#[test]
fn fold_commit_to_unstaged_rollback_keeps_binary_changes() {
let test_repo = TestRepo::new_with_remote();
let workdir = test_repo.workdir();
test_repo.commit_multi(&[("f.txt", "1\n2\n3\n4\n5\n6\n7\n")], "Base");
let target = test_repo.commit_multi(&[("f.txt", "1\n2\n3\nFOUR\n5\n6\n7\n")], "Change 4");
std::fs::write(workdir.join("logo.bin"), [0u8, 1, 2, 3]).unwrap();
test_repo.stage_files(&["logo.bin"]);
test_repo.commit_staged("Binary");
crate::git::run_git(&workdir, &["mv", "f.txt", "g.txt"]).unwrap();
test_repo.commit_staged("Rename");
let head_before = test_repo.head_oid();
std::fs::write(workdir.join("logo.bin"), [9u8, 9, 9, 9, 9]).unwrap();
test_repo.stage_files(&["logo.bin"]);
test_repo.write_file("text.txt", "untracked-but-tracked-later");
super::fold_commit_to_unstaged(&test_repo.repo, &target.to_string())
.expect_err("the diff must not merge back");
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(
std::fs::read(workdir.join("logo.bin")).unwrap(),
[9u8, 9, 9, 9, 9],
"the binary change must survive the rollback"
);
assert_eq!(
test_repo.read_file("text.txt"),
"untracked-but-tracked-later",
"the text change must survive it too"
);
assert_eq!(
test_repo.status_porcelain(),
"M logo.bin\n?? text.txt\n",
"and the staged binary change must still be staged"
);
}
#[test]
fn fold_commit_file_to_unstaged_rollback_keeps_uncommitted_changes() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file1.txt", "v1");
test_repo.stage_files(&["file1.txt"]);
test_repo.commit_staged("Base");
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("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("Other");
crate::git::run_git(&test_repo.workdir(), &["mv", "file1.txt", "renamed.txt"]).unwrap();
test_repo.commit_staged("Rename");
let head_before = test_repo.head_oid();
test_repo.write_file("other.txt", "uncommitted work");
let err =
super::fold_commit_file_to_unstaged(&test_repo.repo, &c1_oid.to_string(), "file1.txt", &[])
.expect_err("the diff must not merge back");
assert!(
err.to_string().contains("rolled back"),
"unexpected error: {err}"
);
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(test_repo.read_file("other.txt"), "uncommitted work");
assert_eq!(test_repo.read_file("renamed.txt"), "content1");
assert_eq!(test_repo.status_porcelain(), " M other.txt\n");
}
#[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_submodule() {
let test_repo = TestRepo::new_with_remote();
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let source_oid = test_repo.head_oid();
test_repo.write_file("later.txt", "later");
test_repo.stage_files(&["later.txt"]);
test_repo.commit_staged("Later");
let target_oid = test_repo.head_oid();
super::fold_commit_file_to_commit(
&test_repo.repo,
&source_oid.to_string(),
"Data",
&target_oid.to_string(),
&[],
)
.unwrap();
let source = test_repo.get_oid(1);
let target = test_repo.head_oid();
assert_eq!(test_repo.commit_file_paths(source), ["other.txt"]);
assert_eq!(test_repo.submodule_oid(source, "Data"), first);
assert_eq!(test_repo.submodule_oid(target, "Data"), second);
test_repo.assert_working_tree_clean();
}
#[test]
fn fold_commit_file_to_commit_submodule_source_newer() {
let test_repo = TestRepo::new_with_remote();
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.write_file("target.txt", "target");
test_repo.stage_files(&["target.txt"]);
test_repo.commit_staged("Target commit");
let target_oid = test_repo.head_oid();
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Source commit");
let source_oid = test_repo.head_oid();
super::fold_commit_file_to_commit(
&test_repo.repo,
&source_oid.to_string(),
"Data",
&target_oid.to_string(),
&[],
)
.unwrap();
let source = test_repo.head_oid();
let target = test_repo.get_oid(1);
assert_eq!(test_repo.commit_file_paths(source), ["other.txt"]);
assert_eq!(test_repo.submodule_oid(target, "Data"), second);
assert_ne!(test_repo.submodule_oid(target, "Data"), first);
test_repo.assert_working_tree_clean();
}
#[test]
fn fold_commit_file_to_commit_rollback_keeps_uncommitted_changes() {
let test_repo = TestRepo::new_with_remote();
let body = "1\n2\n3\n4\n5\n6\n7\n";
test_repo.commit_multi(&[("f.txt", body)], "Base");
let target = test_repo.commit_multi(
&[("f.txt", body.replace("6\n", "SIX\n").as_str())],
"Target",
);
let middle = body.replace("6\n", "SIX\n").replace("2\n", "TWO\n");
test_repo.commit_multi(&[("f.txt", &middle)], "Middle");
let source = test_repo.commit_multi(
&[
("f.txt", middle.replace("4\n", "FOUR\n").as_str()),
("other.txt", "other"),
],
"Source",
);
let head_before = test_repo.head_oid();
test_repo.write_file("other.txt", "uncommitted work");
super::fold_commit_file_to_commit(
&test_repo.repo,
&source.to_string(),
"f.txt",
&target.to_string(),
&[],
)
.expect_err("the file's diff must not apply onto the target");
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(test_repo.read_file("other.txt"), "uncommitted work");
assert_eq!(test_repo.status_porcelain(), " M other.txt\n");
}
#[test]
fn fold_commit_file_to_commit_forward_rollback_keeps_uncommitted_changes() {
let test_repo = TestRepo::new_with_remote();
let body = "1\n2\n3\n4\n5\n6\n7\n";
test_repo.commit_multi(&[("f.txt", body), ("other.txt", "other")], "Base");
let source = test_repo.commit_multi(
&[("f.txt", body.replace("4\n", "FOUR\n").as_str())],
"Source",
);
test_repo.commit_multi(
&[(
"f.txt",
body.replace("4\n", "FOUR\n")
.replace("2\n", "TWO\n")
.as_str(),
)],
"Middle",
);
let target = test_repo.commit_multi(&[("g.txt", "unrelated")], "Target");
let head_before = test_repo.head_oid();
test_repo.write_file("other.txt", "uncommitted work");
super::fold_commit_file_to_commit(
&test_repo.repo,
&source.to_string(),
"f.txt",
&target.to_string(),
&[],
)
.expect_err("the file's diff must not apply onto the target");
assert_eq!(
test_repo.head_oid(),
head_before,
"history must be restored"
);
assert_eq!(test_repo.read_file("other.txt"), "uncommitted work");
assert_eq!(test_repo.status_porcelain(), " M other.txt\n");
}
#[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_moves_multiple_commits_to_new_branch() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
let l1_oid = test_repo.commit("L1", "l1.txt");
let l2_oid = test_repo.commit("L2", "l2.txt");
let result = super::run_create(
&test_repo.repo,
&[
l2_oid.to_string(),
l1_oid.to_string(),
"new-branch".to_string(),
],
);
assert!(result.is_ok(), "fold --create failed: {:?}", result);
let tip = test_repo.get_branch_target("new-branch");
let tip_commit = test_repo.find_commit(tip);
assert_eq!(
tip_commit.summary().unwrap().unwrap(),
"L2",
"L2 should be at tip"
);
let parent = tip_commit.parent(0).unwrap();
assert_eq!(
parent.summary().unwrap().unwrap(),
"L1",
"L1 should be below L2"
);
assert_eq!(
parent.parent(0).unwrap().id(),
base_oid,
"L1 should sit on the merge-base"
);
}
fn write_hook_rejecting_diffs(test_repo: &TestRepo) {
let hook = test_repo.workdir().join(".git/hooks/commit-msg");
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
std::fs::write(
&hook,
"#!/bin/sh\ngrep -q '^diff --git' \"$1\" && exit 1\nexit 0\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn fold_create_keeps_the_diff_out_of_the_merge_message() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_config("commit.verbose", "true");
write_hook_rejecting_diffs(&test_repo);
let l1_oid = test_repo.commit("L1", "l1.txt");
let result = super::run_create(
&test_repo.repo,
&[l1_oid.to_string(), "new-branch".to_string()],
);
assert!(result.is_ok(), "fold --create failed: {:?}", result);
let head = test_repo.head_commit();
assert_eq!(
head.parent_count(),
2,
"fold --create should have woven the branch in with a merge commit"
);
let message = head.message().unwrap();
assert!(
!message.contains("diff --git"),
"the merge message must not carry the diff: {:?}",
message
);
}
#[test]
fn fold_create_uses_the_weave_base_when_a_branch_landed_upstream() {
let test_repo = TestRepo::new_with_remote();
let weave_base = test_repo.head_oid();
test_repo.create_branch_at_commit("feature-a", weave_base);
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.write_file("l1.txt", "l1");
test_repo.stage_files(&["l1.txt"]);
test_repo.commit_staged("L1");
let l1_oid = test_repo.head_oid();
test_repo.push_branch_to_remote_main("feature-a");
let info = repo::gather_commit_graph(&test_repo.repo).unwrap();
assert_ne!(
info.upstream.merge_base_oid, weave_base,
"setup should have left the merge-base off the weave base"
);
let result = super::run_create(
&test_repo.repo,
&[l1_oid.to_string(), "new-branch".to_string()],
);
assert!(result.is_ok(), "fold --create failed: {:?}", result);
let tip = test_repo
.repo
.find_branch("new-branch", git2::BranchType::Local)
.unwrap()
.get()
.peel_to_commit()
.unwrap();
assert_eq!(repo::commit_subject(&tip), "L1");
assert_eq!(
tip.parent(0).unwrap().id(),
weave_base,
"the new branch should be built on the weave base"
);
}
#[test]
fn fold_rolls_back_when_the_rebase_refuses_to_start() {
let test_repo = TestRepo::new_with_remote();
let workdir = test_repo.workdir();
let base = test_repo
.find_remote_branch_target("origin/main")
.to_string();
test_repo.create_branch_at("feature", &base);
test_repo.switch_branch("feature");
let a_oid = test_repo.commit("A1", "a1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature");
let wt = workdir.parent().unwrap().join("wt");
crate::git::run_git(
&workdir,
&["worktree", "add", wt.to_str().unwrap(), "feature"],
)
.unwrap();
let head_before = test_repo.head_oid();
test_repo.write_file("a1.txt", "the change to fold\n");
test_repo.write_file("other.txt", "staged by the user\n");
test_repo.stage_files(&["other.txt"]);
let staged_before = crate::git::diff_cached(&workdir).unwrap();
let result = super::fold_files_into_commit(
&test_repo.repo,
&["a1.txt".to_string()],
&a_oid.to_string(),
false,
&[],
None,
);
assert!(
result.is_err(),
"the rebase cannot start, so the fold fails"
);
assert_eq!(
test_repo.head_oid(),
head_before,
"the `fixup!` commit must be gone"
);
let status = test_repo.status_porcelain();
assert!(
status.contains(" M a1.txt"),
"the folded file goes back to modified-but-unstaged, got: {:?}",
status
);
let staged = crate::core::repo::get_staged_files(&test_repo.repo).unwrap();
assert_eq!(
staged,
vec!["other.txt".to_string()],
"only the user's own staged file may be left staged"
);
assert_eq!(
crate::git::diff_cached(&workdir).unwrap(),
staged_before,
"a refusal before the rebase must not touch the index at all"
);
assert!(
test_repo
.repo
.find_branch(super::TRACK_BRANCH, git2::BranchType::Local)
.is_err(),
"the temp branch must not survive"
);
assert!(
!test_repo
.repo
.path()
.join("loom")
.join("state.json")
.exists(),
"no state file may be left behind"
);
}
#[test]
fn fold_into_an_out_of_scope_commit_leaves_the_repo_alone() {
let test_repo = TestRepo::new_with_remote();
let out_of_scope = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("L0", "other.txt");
test_repo.commit("L1", "l1.txt");
let head_before = test_repo.head_oid();
test_repo.write_file("l1.txt", "the change to fold\n");
test_repo.write_file("other.txt", "staged by the user\n");
test_repo.stage_files(&["other.txt"]);
let result = super::fold_files_into_commit(
&test_repo.repo,
&["l1.txt".to_string()],
&out_of_scope.to_string(),
false,
&[],
None,
);
assert!(result.is_err(), "an out-of-scope target must be refused");
assert_eq!(
test_repo.head_oid(),
head_before,
"a refused fold must leave no commit behind"
);
let staged = crate::core::repo::get_staged_files(&test_repo.repo).unwrap();
assert!(
staged.contains(&"other.txt".to_string()),
"the user's own staged file must still be staged, got: {:?}",
staged
);
}
#[test]
fn fold_create_rejects_an_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()],
);
let err = result.unwrap_err().to_string();
let mut lines = err.lines();
assert_eq!(lines.next(), Some("Branch `feature-a` already exists"));
assert_eq!(
lines.next(),
Some("Use `loom fold <commit>... feature-a` to move commits onto it")
);
assert_eq!(lines.next(), None, "no extra lines: {err}");
assert_eq!(
test_repo.get_branch_target("feature-a"),
base_oid,
"feature-a must be untouched"
);
}
#[test]
fn sort_commits_puts_an_ancestor_first_whatever_the_input_order() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
const TIED: i64 = 1_700_000_000;
test_repo.create_branch_at("b1", &base_oid.to_string());
test_repo.switch_branch("b1");
let x = test_repo.commit_at("X", "x.txt", TIED);
let z = test_repo.commit_at("Z", "z.txt", TIED);
test_repo.create_branch_at("b2", &base_oid.to_string());
test_repo.switch_branch("b2");
let y = test_repo.commit_at("Y", "y.txt", TIED);
let repo = &test_repo.repo;
let secs = |o: git2::Oid| repo.find_commit(o).unwrap().time().seconds();
assert_eq!(secs(x), secs(y));
assert_eq!(secs(y), secs(z));
let mut outputs = Vec::new();
for input in [
[x, y, z],
[x, z, y],
[y, x, z],
[y, z, x],
[z, x, y],
[z, y, x],
] {
let hashes: Vec<String> = input.iter().map(|o| o.to_string()).collect();
let sorted = super::commits_to_move(repo, hashes, base_oid).unwrap();
assert_eq!(sorted.len(), 3, "no commit may be dropped: {sorted:?}");
let at = |o: git2::Oid| sorted.iter().position(|h| *h == o.to_string()).unwrap();
assert!(
at(x) < at(z),
"X is Z's ancestor and must come first, got {sorted:?} from {input:?}"
);
outputs.push(sorted);
}
assert!(
outputs.windows(2).all(|w| w[0] == w[1]),
"every permutation must agree: {outputs:?}"
);
}
#[test]
fn sort_commits_rejects_a_commit_from_below_the_boundary() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
let local = test_repo.commit("L1", "l1.txt");
let err = super::commits_to_move(
&test_repo.repo,
vec![local.to_string(), base_oid.to_string()],
base_oid,
)
.unwrap_err()
.to_string();
assert!(
err.contains("not in the integration scope"),
"should name the out-of-scope commit: {err}"
);
assert!(
err.contains(&base_oid.to_string()[..7]),
"should name which commit: {err}"
);
}
#[test]
fn sort_commits_orders_unrelated_branches_by_time() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("b1", &base_oid.to_string());
test_repo.switch_branch("b1");
let a1 = test_repo.commit_at("A1", "a1.txt", 1_700_000_023);
let a2 = test_repo.commit_at("A2", "a2.txt", 1_700_000_026);
test_repo.create_branch_at("b2", &base_oid.to_string());
test_repo.switch_branch("b2");
let b1 = test_repo.commit_at("B1", "b1.txt", 1_700_000_021);
let b2 = test_repo.commit_at("B2", "b2.txt", 1_700_000_028);
let repo = &test_repo.repo;
let sorted = super::commits_to_move(
repo,
vec![
a1.to_string(),
a2.to_string(),
b1.to_string(),
b2.to_string(),
],
base_oid,
)
.unwrap();
let names: Vec<String> = sorted
.iter()
.map(|h| {
let oid = git2::Oid::from_str(h).unwrap();
repo.find_commit(oid)
.unwrap()
.summary()
.unwrap()
.unwrap()
.to_string()
})
.collect();
assert_eq!(names, vec!["B1", "A1", "A2", "B2"]);
}
#[test]
fn sort_commits_keeps_ancestry_over_a_skewed_date() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("b1", &base_oid.to_string());
test_repo.switch_branch("b1");
let x = test_repo.commit_at("X", "x.txt", 1_700_000_100);
let z = test_repo.commit_at("Z", "z.txt", 1_700_000_050);
let sorted = super::commits_to_move(
&test_repo.repo,
vec![z.to_string(), x.to_string()],
base_oid,
)
.unwrap();
assert_eq!(sorted, vec![x.to_string(), z.to_string()]);
}
#[test]
fn sort_commits_rejects_a_single_out_of_scope_commit() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("L1", "l1.txt");
let err = super::commits_to_move(&test_repo.repo, vec![base_oid.to_string()], base_oid)
.unwrap_err()
.to_string();
assert!(
err.contains("not in the integration scope"),
"one commit must get the same message as several: {err}"
);
}
#[test]
fn sort_commits_drops_duplicates() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
let x = test_repo.commit("X", "x.txt");
let sorted = super::commits_to_move(
&test_repo.repo,
vec![x.to_string(), x.to_string()],
base_oid,
)
.unwrap();
assert_eq!(sorted, vec![x.to_string()]);
}
#[test]
fn fold_moves_several_commits_to_a_branch() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.commit("A1", "a1.txt");
let a1_oid = test_repo.head_oid();
test_repo.create_branch_at("feature-a", &a1_oid.to_string());
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 m1 = test_repo.commit("M1", "m1.txt");
let m2 = test_repo.commit("M2", "m2.txt");
let result = test_repo.in_dir(|| {
super::run(
false,
false,
None,
HunkArgs::default(),
vec![m2.to_string(), m1.to_string(), "feature-a".to_string()],
vec![],
&crate::core::graph::Theme::dark(),
)
});
assert!(result.is_ok(), "multi-commit move failed: {:?}", result);
let repo = &test_repo.repo;
let tip = repo
.find_commit(test_repo.get_branch_target("feature-a"))
.unwrap();
assert_eq!(tip.summary().unwrap().unwrap(), "M2");
let mid = repo.find_commit(tip.parent_id(0).unwrap()).unwrap();
assert_eq!(mid.summary().unwrap().unwrap(), "M1");
assert_eq!(
repo.find_commit(mid.parent_id(0).unwrap())
.unwrap()
.summary()
.unwrap()
.unwrap(),
"A1"
);
}
#[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");
let head_before = test_repo.head_oid();
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,
&[],
None,
);
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.head_oid(),
head_before,
"abort must leave no `fixup!` commit behind"
);
assert_eq!(
test_repo.read_file("shared.txt"),
"version-folded",
"the change being folded comes back to the working tree"
);
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");
}
#[test]
fn fold_unstaged_deletion_into_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
std::fs::remove_file(test_repo.workdir().join("file1.txt")).unwrap();
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,
&[],
None,
);
assert!(result.is_ok(), "fold of a deletion failed: {:?}", result);
assert_eq!(test_repo.get_message(0), "Second commit");
assert!(!test_repo.commit_has_file(test_repo.head_oid(), "file1.txt"));
test_repo.assert_working_tree_clean();
}
#[test]
fn fold_staged_deletion_into_head() {
let test_repo = TestRepo::new();
test_repo.commit("First commit", "file1.txt");
test_repo.commit("Second commit", "file2.txt");
std::fs::remove_file(test_repo.workdir().join("file1.txt")).unwrap();
test_repo.stage_files(&["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,
&[],
None,
);
assert!(
result.is_ok(),
"fold of a staged deletion failed: {:?}",
result
);
assert_eq!(test_repo.get_message(0), "Second commit");
assert!(!test_repo.commit_has_file(test_repo.head_oid(), "file1.txt"));
test_repo.assert_working_tree_clean();
}
#[test]
fn fold_staged_deletion_into_non_head_commit() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("First commit", "file1.txt");
let c2_oid = test_repo.commit("Second commit", "file2.txt");
test_repo.commit("Third commit", "file3.txt");
std::fs::remove_file(test_repo.workdir().join("file1.txt")).unwrap();
test_repo.stage_files(&["file1.txt"]);
let result = super::fold_files_into_commit(
&test_repo.repo,
&["file1.txt".to_string()],
&c2_oid.to_string(),
false,
&[],
None,
);
assert!(
result.is_ok(),
"fold of a staged deletion failed: {:?}",
result
);
assert_eq!(
test_repo.commit_messages()[..3],
[
"Third commit".to_string(),
"Second commit".to_string(),
"First commit".to_string()
]
);
assert!(!test_repo.commit_has_file(test_repo.get_oid(1), "file1.txt"));
test_repo.assert_working_tree_clean();
}
#[test]
fn apply_and_amend_uncommits_a_picked_submodule() {
let test_repo = TestRepo::new();
let (first, second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
test_repo.checkout_submodule("Data", second);
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["Data", "other.txt"]);
test_repo.commit_staged("Bump submodule");
let workdir = test_repo.workdir();
let head = test_repo.head_oid().to_string();
let selections = [FileEntry {
path: String::from("Data"),
hunks: vec![HunkEntry {
hunk: crate::core::diff::DiffHunk {
text: String::from("(submodule)"),
modified_lines: vec![],
},
selected: true,
origin: HunkOrigin::Commit,
}],
index_status: 'M',
worktree_status: ' ',
binary: true,
}];
let gitlinks = super::picked_whole_files(&workdir, &head, &selections).unwrap();
super::apply_and_amend(&workdir, &selections, "", &gitlinks, true, &[]).unwrap();
let new_head = test_repo.head_oid();
assert_eq!(test_repo.commit_file_paths(new_head), ["other.txt"]);
assert_eq!(test_repo.submodule_oid(new_head, "Data"), first);
assert_eq!(test_repo.status_porcelain().trim(), "M Data");
}
fn branch_log(test_repo: &TestRepo, branch: &str, base: git2::Oid) -> Vec<String> {
let mut out = Vec::new();
let mut commit = test_repo.find_commit(test_repo.get_branch_target(branch));
while commit.id() != base {
out.push(commit.summary().unwrap().unwrap().to_string());
commit = commit.parent(0).unwrap();
}
out
}
#[test]
fn fold_commit_below_reorders_within_its_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.commit("A2", "a2.txt");
let a3_oid = test_repo.commit("A3", "a3.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
super::fold_commit_relative(
&test_repo.repo,
&a3_oid.to_string(),
&a1_oid.to_string(),
Position::Below,
)
.expect("moving A3 below A1");
assert_eq!(
branch_log(&test_repo, "feature-a", base_oid),
["A2", "A1", "A3"]
);
let tip = test_repo.get_branch_target("feature-a");
assert!(test_repo.commit_has_file(tip, "a3.txt"));
assert!(
!test_repo.branch_exists("_loom-track"),
"the tracking branch is cleaned up"
);
}
#[test]
fn fold_commit_below_across_branches_parks_the_emptied_source() {
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");
let a2_oid = test_repo.commit("A2", "a2.txt");
test_repo.create_branch_at("feature-b", &base_oid.to_string());
test_repo.switch_branch("feature-b");
let b1_oid = test_repo.commit("B1", "b1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo.merge_no_ff("feature-b");
let (_, parked) = super::plan_relative(
&test_repo.repo,
&[b1_oid.to_string()],
&a2_oid.to_string(),
Position::Below,
)
.expect("planning B1 below A2");
assert_eq!(parked, vec!["feature-b".to_string()]);
super::fold_commit_relative(
&test_repo.repo,
&b1_oid.to_string(),
&a2_oid.to_string(),
Position::Below,
)
.expect("moving B1 below A2");
assert_eq!(
branch_log(&test_repo, "feature-a", base_oid),
["A2", "B1", "A1"]
);
assert_eq!(test_repo.get_branch_target("feature-b"), base_oid);
}
#[test]
fn fold_commits_above_a_branch_tip_advance_the_branch_in_order() {
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");
let c1_oid = test_repo.commit("C1", "c1.txt");
let c2_oid = test_repo.commit("C2", "c2.txt");
test_repo.merge_no_ff("feature-a");
super::move_commits_relative_and_report(
&test_repo.repo,
&[c1_oid.to_string(), c2_oid.to_string()],
&a1_oid.to_string(),
Position::Above,
)
.expect("moving C1 and C2 above A1");
assert_eq!(
branch_log(&test_repo, "feature-a", base_oid),
["C2", "C1", "A1"]
);
assert_eq!(
test_repo.head_commit().parent_count(),
2,
"HEAD is the merge again"
);
}
#[test]
fn fold_file_out_of_a_commit_refuses_when_the_replay_is_dropped() {
let (t, target) = crate::core::test_helpers::repo_with_dropped_replay();
let head_before = t.head_oid();
let alpha_before = t.get_branch_target("alpha");
let err = super::fold_commit_file_to_unstaged(&t.repo, &target.to_string(), "one.txt", &[])
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert_eq!(t.get_branch_target("alpha"), alpha_before, "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
}
#[test]
fn fold_between_commits_walks_past_a_redundant_one() {
let (t, older, newer) = crate::core::test_helpers::repo_with_a_redundant_commit_between();
super::fold_commit_file_to_commit(
&t.repo,
&older.to_string(),
"moved.txt",
&newer.to_string(),
&[],
)
.unwrap();
assert!(!crate::git::rebase_is_in_progress(t.repo.path()));
assert!(!t.commit_messages().contains(&"branch change".to_string()));
let newer_oid = t.get_branch_target("alpha");
assert!(t.commit_has_file(newer_oid, "moved.txt"));
assert!(t.commit_has_file(newer_oid, "newer.txt"));
}
#[test]
fn fold_commit_relative_refuses_when_the_moved_commit_replays_empty() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let head_before = t.head_oid();
let alpha_before = t.get_branch_target("alpha");
let staged_before = stage_a_tracked_edit(&t);
let err = super::fold_commit_relative(
&t.repo,
&redundant.to_string(),
&keeper.to_string(),
Position::Above,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(err.contains("loom drop"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert_eq!(t.get_branch_target("alpha"), alpha_before, "{err}");
assert!(!t.branch_exists("_loom-track"), "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
assert!(
!t.repo.path().join("loom").join("state.json").exists(),
"{err}"
);
assert_eq!(
crate::git::diff_cached(&t.workdir()).unwrap(),
staged_before,
"{err}"
);
}
#[test]
fn fold_commit_into_commit_refuses_when_the_target_replays_empty() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let head_before = t.head_oid();
let alpha_before = t.get_branch_target("alpha");
let staged_before = stage_a_tracked_edit(&t);
let err = super::fold_commit_into_commit(&t.repo, &keeper.to_string(), &redundant.to_string())
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert_eq!(t.get_branch_target("alpha"), alpha_before, "{err}");
assert!(!t.branch_exists("_loom-track"), "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
assert!(
!t.repo.path().join("loom").join("state.json").exists(),
"{err}"
);
assert_eq!(
crate::git::diff_cached(&t.workdir()).unwrap(),
staged_before,
"{err}"
);
}
#[test]
fn fold_files_into_commit_refuses_when_the_target_replays_empty() {
let (t, redundant, _keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let head_before = t.head_oid();
t.write_file("three.txt", "folded\n");
let err = super::fold_files_into_commit(
&t.repo,
&["three.txt".to_string()],
&redundant.to_string(),
false,
&[],
None,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert!(!t.branch_exists("_loom-track"), "{err}");
assert_eq!(t.read_file("three.txt"), "folded\n", "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
assert!(
!t.repo.path().join("loom").join("state.json").exists(),
"{err}"
);
}
#[test]
fn fold_commit_to_branch_refuses_when_the_moved_commit_replays_empty() {
let (t, redundant, _keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
t.create_branch_at(
"beta",
&t.find_remote_branch_target("origin/main").to_string(),
);
let head_before = t.head_oid();
let beta_before = t.get_branch_target("beta");
let err = super::fold_commit_to_branch(&t.repo, &redundant.to_string(), "beta")
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert_eq!(t.get_branch_target("beta"), beta_before, "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
assert!(
!t.repo.path().join("loom").join("state.json").exists(),
"{err}"
);
}
#[test]
fn fold_several_commits_to_a_branch_refuses_when_one_replays_empty() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
t.create_branch_at(
"beta",
&t.find_remote_branch_target("origin/main").to_string(),
);
let head_before = t.head_oid();
let err = super::move_commits_and_report(
&t.workdir(),
&t.repo,
&[redundant.to_string(), keeper.to_string()],
"beta",
None,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
}
#[test]
fn fold_patch_between_commits_names_the_source_that_survives_phase_two() {
let t = TestRepo::new_with_remote();
let body = "1\n2\n3\n4\n5\n6\n7\n8\n";
t.commit_multi(&[("f.txt", body)], "Base");
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
let moved = body.replace("2\n", "TWO\n");
let source = t.commit_multi(&[("f.txt", &moved), ("g.txt", "g\n")], "Source");
let workdir = t.workdir();
let mut selections =
crate::core::staging::collect_commit_hunks(&workdir, &source.to_string(), &[]).unwrap();
for file in &mut selections {
if file.path == "f.txt" {
for hunk in &mut file.hunks {
hunk.selected = true;
}
}
}
let (new_source, new_target) = super::fold_selected_hunks_to_commit(
&t.repo,
&workdir,
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.unwrap();
assert_eq!(new_source, t.head_oid().to_string());
assert_eq!(new_target, t.get_oid(1).to_string());
assert_eq!(t.commit_file_paths(t.head_oid()), ["g.txt"]);
assert!(t.commit_has_file(t.get_oid(1), "f.txt"));
assert!(!t.branch_exists("_loom-track"));
}
#[test]
fn fold_several_commits_to_a_branch_refusal_leaves_the_index_as_it_was() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
t.create_branch_at(
"beta",
&t.find_remote_branch_target("origin/main").to_string(),
);
let staged_before = stage_a_tracked_edit(&t);
let err = super::move_commits_and_report(
&t.workdir(),
&t.repo,
&[redundant.to_string(), keeper.to_string()],
"beta",
None,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(
crate::git::diff_cached(&t.workdir()).unwrap(),
staged_before
);
}
#[test]
fn fold_several_commits_relative_refusal_leaves_the_index_as_it_was() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let anchor = t.commit_multi(&[("anchor.txt", "anchor\n")], "anchor");
let staged_before = stage_a_tracked_edit(&t);
let err = super::move_commits_relative_and_report(
&t.repo,
&[redundant.to_string(), keeper.to_string()],
&anchor.to_string(),
Position::Above,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(
crate::git::diff_cached(&t.workdir()).unwrap(),
staged_before
);
}
fn stage_a_tracked_edit(t: &TestRepo) -> String {
t.write_file("three.txt", "three\nstaged edit\n");
t.stage_files(&["three.txt"]);
crate::git::diff_cached(&t.workdir()).unwrap()
}
fn deleted_and_changed() -> TestRepo {
let test_repo = TestRepo::new();
test_repo.commit("Add gone", "gone.txt");
test_repo.commit("Add kept", "kept.txt");
std::fs::remove_file(test_repo.workdir().join("gone.txt")).unwrap();
test_repo.write_file("kept.txt", "changed");
test_repo.stage_files(&["gone.txt", "kept.txt"]);
test_repo.commit_staged("Delete one, change another");
test_repo
}
fn changed_binary() -> TestRepo {
let test_repo = TestRepo::new();
test_repo.commit("Add kept", "kept.txt");
test_repo.write_file("blob.bin", "\u{0}\u{1}old\u{0}");
test_repo.stage_files(&["blob.bin"]);
test_repo.commit_staged("Add binary");
test_repo.write_file("blob.bin", "\u{0}\u{1}new\u{0}");
test_repo.write_file("kept.txt", "changed");
test_repo.stage_files(&["blob.bin", "kept.txt"]);
test_repo.commit_staged("Change binary and another");
test_repo
}
fn all_selected(test_repo: &TestRepo, oid: git2::Oid) -> Vec<FileEntry> {
let mut entries =
crate::core::staging::collect_commit_hunks(&test_repo.workdir(), &oid.to_string(), &[])
.unwrap();
for file in &mut entries {
for hunk in &mut file.hunks {
hunk.selected = true;
}
}
entries
}
fn picked_whole_files(
test_repo: &TestRepo,
oid: git2::Oid,
selections: &[FileEntry],
) -> Vec<super::PickedWholeFile> {
super::picked_whole_files(&test_repo.workdir(), &oid.to_string(), selections).unwrap()
}
#[test]
fn build_selected_patch_leaves_out_a_deleted_file() {
let test_repo = deleted_and_changed();
let patch = super::build_selected_patch(&all_selected(&test_repo, test_repo.head_oid()));
assert!(!patch.contains("gone.txt"), "{patch}");
assert!(!patch.contains("(file deleted)"), "{patch}");
assert!(
patch.starts_with("--- a/kept.txt\n+++ b/kept.txt\n@@"),
"{patch}"
);
}
#[test]
fn build_selected_patch_leaves_out_a_binary_file() {
let test_repo = changed_binary();
let selections = all_selected(&test_repo, test_repo.head_oid());
let binary = selections.iter().find(|f| f.path == "blob.bin").unwrap();
assert!(!binary.hunks[0].hunk.is_text());
let patch = super::build_selected_patch(&selections);
assert!(!patch.contains("blob.bin"), "{patch}");
}
#[test]
fn picked_whole_files_carries_a_deletion() {
let test_repo = deleted_and_changed();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].path, "gone.txt");
assert!(matches!(picked[0].kind, super::WholeFileKind::File));
assert!(picked[0].diff.contains("deleted file mode"));
let mut unpicked = all_selected(&test_repo, head);
for file in &mut unpicked {
if file.path == "gone.txt" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
assert!(picked_whole_files(&test_repo, head, &unpicked).is_empty());
}
#[test]
fn a_picked_deletion_leaves_the_source_commit() {
let test_repo = deleted_and_changed();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(amended.is_empty(), "{amended:?}");
assert_eq!(test_repo.read_file("gone.txt"), "Add gone");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_of_an_ignored_file_still_moves() {
let test_repo = TestRepo::new();
test_repo.commit("Add gone", "gone.txt");
test_repo.commit("Add kept", "kept.txt");
test_repo.write_file(".gitignore", "gone.txt\n");
test_repo.stage_files(&[".gitignore"]);
test_repo.commit_staged("Ignore it");
std::fs::remove_file(test_repo.workdir().join("gone.txt")).unwrap();
test_repo.write_file("kept.txt", "changed");
test_repo.stage_files(&["gone.txt", "kept.txt"]);
test_repo.commit_staged("Delete one, change another");
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
assert_eq!(test_repo.read_file("gone.txt"), "Add gone");
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(amended.is_empty(), "{amended:?}");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_of_a_binary_file_still_moves() {
let test_repo = TestRepo::new();
test_repo.commit("Add kept", "kept.txt");
test_repo.write_file("blob.bin", "\u{0}\u{1}old\u{0}");
test_repo.stage_files(&["blob.bin"]);
test_repo.commit_staged("Add binary");
std::fs::remove_file(test_repo.workdir().join("blob.bin")).unwrap();
test_repo.write_file("kept.txt", "changed");
test_repo.stage_files(&["blob.bin", "kept.txt"]);
test_repo.commit_staged("Delete the binary, change another");
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
assert!(matches!(picked[0].kind, super::WholeFileKind::File));
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
assert_eq!(test_repo.read_file("blob.bin"), "\u{0}\u{1}old\u{0}");
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(amended.is_empty(), "{amended:?}");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_of_an_empty_file_still_moves() {
let test_repo = TestRepo::new();
test_repo.commit("Add kept", "kept.txt");
test_repo.write_file("empty.txt", "");
test_repo.stage_files(&["empty.txt"]);
test_repo.commit_staged("Add empty");
std::fs::remove_file(test_repo.workdir().join("empty.txt")).unwrap();
test_repo.write_file("kept.txt", "changed");
test_repo.stage_files(&["empty.txt", "kept.txt"]);
test_repo.commit_staged("Delete the empty one, change another");
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
assert_eq!(test_repo.read_file("empty.txt"), "");
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(amended.is_empty(), "{amended:?}");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_enters_the_target_commit() {
let test_repo = deleted_and_changed();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
test_repo.reset_hard(test_repo.get_oid(1));
super::apply_and_amend(&workdir, &selections, &patch, &picked, false, &[]).unwrap();
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(
amended.contains(&('D', "gone.txt".to_string())),
"{amended:?}"
);
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_uncommits_as_an_unstaged_deletion() {
let test_repo = deleted_and_changed();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
super::restore_to_worktree(&workdir, &patch, &picked).unwrap();
assert!(!workdir.join("gone.txt").exists());
let status = test_repo.status_porcelain();
assert!(status.contains(" D gone.txt"), "{status}");
assert!(status.contains(" M kept.txt"), "{status}");
}
#[test]
fn picked_whole_files_carries_a_binary_file() {
let test_repo = changed_binary();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].path, "blob.bin");
assert!(matches!(picked[0].kind, super::WholeFileKind::File));
assert!(
picked[0].diff.contains("Binary files"),
"{}",
picked[0].diff
);
}
#[test]
fn a_picked_binary_leaves_the_source_commit() {
let test_repo = changed_binary();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(amended.is_empty(), "{amended:?}");
assert_eq!(test_repo.read_file("blob.bin"), "\u{0}\u{1}old\u{0}");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_binary_enters_the_target_commit() {
let test_repo = changed_binary();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
test_repo.reset_hard(test_repo.get_oid(1));
super::apply_and_amend(&workdir, &selections, &patch, &picked, false, &[]).unwrap();
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert!(
amended.contains(&('A', "blob.bin".to_string())),
"{amended:?}"
);
assert_eq!(test_repo.read_file("blob.bin"), "\u{0}\u{1}new\u{0}");
test_repo.assert_working_tree_clean();
}
#[test]
fn a_picked_binary_uncommits_as_an_unstaged_change() {
let test_repo = changed_binary();
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
super::restore_to_worktree(&workdir, &patch, &picked).unwrap();
assert_eq!(test_repo.read_file("blob.bin"), "\u{0}\u{1}new\u{0}");
let status = test_repo.status_porcelain();
assert!(status.contains(" M blob.bin"), "{status}");
assert!(status.contains(" M kept.txt"), "{status}");
}
#[test]
fn a_picked_new_binary_uncommits_as_an_untracked_file() {
let test_repo = TestRepo::new();
test_repo.commit("Add kept", "kept.txt");
test_repo.write_file("blob.bin", "\u{0}\u{1}new\u{0}");
test_repo.stage_files(&["blob.bin"]);
test_repo.commit_staged("Add binary");
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let selections = all_selected(&test_repo, head);
let picked = picked_whole_files(&test_repo, head, &selections);
let patch = super::build_selected_patch(&selections);
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
super::restore_to_worktree(&workdir, &patch, &picked).unwrap();
assert!(!test_repo.commit_has_file(test_repo.head_oid(), "blob.bin"));
assert_eq!(test_repo.read_file("blob.bin"), "\u{0}\u{1}new\u{0}");
assert_eq!(test_repo.status_porcelain(), "?? blob.bin\n");
}
#[test]
fn fold_patch_between_commits_moves_a_binary_file() {
let t = TestRepo::new_with_remote();
t.commit_multi(&[("blob.bin", "\u{0}\u{1}old\u{0}")], "Base");
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
let source = t.commit_multi(
&[("blob.bin", "\u{0}\u{1}new\u{0}"), ("g.txt", "g\n")],
"Source",
);
let workdir = t.workdir();
let mut selections = all_selected(&t, source);
for file in &mut selections {
if file.path != "blob.bin" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
let (new_source, new_target) = super::fold_selected_hunks_to_commit(
&t.repo,
&workdir,
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.unwrap();
assert_eq!(new_source, t.head_oid().to_string());
assert_eq!(new_target, t.get_oid(1).to_string());
assert_eq!(t.commit_file_paths(t.head_oid()), ["g.txt"]);
let mut moved = t.commit_file_paths(t.get_oid(1));
moved.sort();
assert_eq!(moved, ["blob.bin", "t.txt"]);
assert_eq!(t.read_file("blob.bin"), "\u{0}\u{1}new\u{0}");
t.assert_working_tree_clean();
}
#[test]
fn fold_patch_between_commits_refuses_a_binary_changed_in_between() {
let t = TestRepo::new_with_remote();
std::fs::create_dir_all(t.workdir().join("assets")).unwrap();
t.commit_multi(
&[("assets/blob.bin", "\u{0}v1"), ("other.txt", "other")],
"Base",
);
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
t.commit_multi(&[("assets/blob.bin", "\u{0}v2")], "Middle");
let source = t.commit_multi(&[("assets/blob.bin", "\u{0}v3")], "Source");
let head_before = t.head_oid();
t.write_file("other.txt", "uncommitted work");
let selections = all_selected(&t, source);
let err = super::fold_selected_hunks_to_commit(
&t.repo,
&t.workdir(),
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.expect_err("the binary's diff must not apply onto the target");
assert!(
err.to_string()
.starts_with("`assets/blob.bin` changed between"),
"{err}"
);
assert_eq!(t.head_oid(), head_before);
assert!(!t.workdir().join(".git/rebase-merge").exists());
assert_eq!(t.read_file("assets/blob.bin"), "\u{0}v3");
assert_eq!(t.read_file("other.txt"), "uncommitted work");
assert_eq!(t.status_porcelain(), " M other.txt\n");
assert!(!t.branch_exists("_loom-track"));
}
#[test]
fn fold_patch_between_commits_refuses_a_deletion_the_target_lacks() {
let t = TestRepo::new_with_remote();
t.commit_multi(&[("base.txt", "base\n")], "Base");
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
t.commit_multi(&[("gone.txt", "gone\n")], "Add gone");
std::fs::remove_file(t.workdir().join("gone.txt")).unwrap();
t.write_file("s.txt", "source\n");
t.stage_files(&["gone.txt", "s.txt"]);
t.commit_staged("Delete gone");
let source = t.head_oid();
let head_before = t.head_oid();
let mut selections = all_selected(&t, source);
for file in &mut selections {
if file.path != "gone.txt" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
let err = super::fold_selected_hunks_to_commit(
&t.repo,
&t.workdir(),
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.expect_err("the target has no gone.txt to delete");
assert!(
err.to_string().starts_with("`gone.txt` changed between"),
"{err}"
);
assert_eq!(t.head_oid(), head_before);
t.assert_working_tree_clean();
}
#[test]
fn fold_patch_between_commits_moves_a_binary_the_source_adds() {
let t = TestRepo::new_with_remote();
t.commit_multi(&[("base.txt", "base\n")], "Base");
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
let source = t.commit_multi(
&[("blob.bin", "\u{0}\u{1}new\u{0}"), ("s.txt", "source\n")],
"Source",
);
let mut selections = all_selected(&t, source);
for file in &mut selections {
if file.path != "blob.bin" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
super::fold_selected_hunks_to_commit(
&t.repo,
&t.workdir(),
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.unwrap();
assert_eq!(t.commit_file_paths(t.head_oid()), ["s.txt"]);
assert!(t.commit_has_file(t.get_oid(1), "blob.bin"));
t.assert_working_tree_clean();
}
#[test]
fn a_picked_deletion_with_a_glob_in_its_name_moves_alone() {
let test_repo = TestRepo::new();
let workdir = test_repo.workdir();
test_repo.commit("Add a1", "a1.txt");
test_repo.write_file("a[1].txt", "bracket");
crate::git::run_git(&workdir, &["add", "-A"]).unwrap();
test_repo.commit_staged("Add bracket");
std::fs::remove_file(workdir.join("a[1].txt")).unwrap();
test_repo.write_file("a1.txt", "changed");
crate::git::run_git(&workdir, &["add", "-A"]).unwrap();
test_repo.commit_staged("Delete bracket, change a1");
let head = test_repo.head_oid();
let mut selections = all_selected(&test_repo, head);
for file in &mut selections {
if file.path != "a[1].txt" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
let picked = picked_whole_files(&test_repo, head, &selections);
assert_eq!(picked.len(), 1);
assert!(!picked[0].diff.contains("a/a1.txt"), "{}", picked[0].diff);
let patch = super::build_selected_patch(&selections);
assert!(patch.is_empty(), "only the deletion was picked: {patch}");
super::apply_and_amend(&workdir, &selections, &patch, &picked, true, &[]).unwrap();
let amended = crate::git::diff_commit_name_status(&workdir, "HEAD").unwrap();
assert_eq!(amended, [('M', String::from("a1.txt"))], "{amended:?}");
assert_eq!(test_repo.read_file("a[1].txt"), "bracket");
}
#[test]
fn a_picked_submodule_removal_stays_a_gitlink() {
let test_repo = TestRepo::new();
let (first, _second) = test_repo.add_submodule("Data");
test_repo.commit_staged("Add submodule");
crate::git::run_git(
&test_repo.workdir(),
&["rm", "-r", "-q", "--cached", "Data"],
)
.unwrap();
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.commit_staged("Remove submodule");
let workdir = test_repo.workdir();
let head = test_repo.head_oid();
let mut selections = all_selected(&test_repo, head);
for file in &mut selections {
if file.path != "Data" {
file.hunks.iter_mut().for_each(|h| h.selected = false);
}
}
let picked = picked_whole_files(&test_repo, head, &selections);
assert_eq!(picked.len(), 1);
assert!(matches!(
picked[0].kind,
super::WholeFileKind::Gitlink { removed: true }
));
super::apply_and_amend(&workdir, &selections, "", &picked, true, &[]).unwrap();
assert_eq!(test_repo.submodule_oid(test_repo.head_oid(), "Data"), first);
assert!(workdir.join("Data").exists(), "the checkout stays on disk");
}
fn woven_repo_with_a_loose_commit() -> TestRepo {
let t = TestRepo::new_with_remote();
let a1 = t.commit("A1", "a1.txt");
t.create_branch_at("feature-a", &a1.to_string());
let base = t.find_remote_branch_target("origin/main");
t.commit("B1", "b1.txt");
t.rebase_onto(&base.to_string(), &a1.to_string());
t.merge_no_ff("feature-a");
t.commit("C1", "c1.txt");
t
}
fn stage_a_mix(t: &TestRepo, tracked: &str) -> String {
t.write_file(tracked, "staged edit\n");
t.write_file("brand-new.txt", "new\n");
t.stage_files(&[tracked, "brand-new.txt"]);
t.status_porcelain()
}
#[test]
fn fold_commit_into_commit_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
let c1 = t.commit("First", "first.txt");
t.commit("Second", "second.txt");
let c3 = t.commit("Third", "third.txt");
let before = stage_a_mix(&t, "first.txt");
super::fold_commit_into_commit(&t.repo, &c3.to_string(), &c1.to_string()).unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn fold_commit_to_branch_keeps_staging_on_success() {
let t = woven_repo_with_a_loose_commit();
let loose = t.head_oid();
let before = stage_a_mix(&t, "b1.txt");
super::fold_commit_to_branch(&t.repo, &loose.to_string(), "feature-a").unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn move_commits_to_branch_keeps_staging_on_success() {
let t = woven_repo_with_a_loose_commit();
let c1 = t.head_oid();
let c2 = t.commit("C2", "c2.txt");
let before = stage_a_mix(&t, "b1.txt");
let workdir = t.workdir();
super::move_commits_and_report(
&workdir,
&t.repo,
&[c1.to_string(), c2.to_string()],
"feature-a",
None,
)
.unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn fold_commit_relative_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
let base = t.find_remote_branch_target("origin/main").to_string();
t.create_branch_at("feature-a", &base);
t.switch_branch("feature-a");
let a1 = t.commit("A1", "a1.txt");
t.commit("A2", "a2.txt");
let a3 = t.commit("A3", "a3.txt");
t.switch_branch("integration");
t.merge_no_ff("feature-a");
let before = stage_a_mix(&t, "a1.txt");
super::fold_commit_relative(&t.repo, &a3.to_string(), &a1.to_string(), Position::Below)
.unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn move_commits_relative_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
let base = t.find_remote_branch_target("origin/main").to_string();
t.create_branch_at("feature-a", &base);
t.switch_branch("feature-a");
let a1 = t.commit("A1", "a1.txt");
t.commit("A2", "a2.txt");
let a3 = t.commit("A3", "a3.txt");
let a4 = t.commit("A4", "a4.txt");
t.switch_branch("integration");
t.merge_no_ff("feature-a");
let before = stage_a_mix(&t, "a1.txt");
super::move_commits_relative_and_report(
&t.repo,
&[a3.to_string(), a4.to_string()],
&a1.to_string(),
Position::Below,
)
.unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn fold_keeps_staging_across_continue() {
let t = TestRepo::new_with_remote();
let a_oid = t.commit("version-a", "shared.txt");
t.write_file("shared.txt", "version-b");
t.stage_files(&["shared.txt"]);
t.commit_staged("Commit B");
t.write_file("shared.txt", "version-c");
t.stage_files(&["shared.txt"]);
t.commit_staged("Commit C");
let c_oid = t.head_oid();
t.write_file("bystander.txt", "bystander\n");
t.stage_files(&["bystander.txt"]);
t.commit_staged("Commit D");
let before = stage_a_mix(&t, "bystander.txt");
super::fold_commit_into_commit(&t.repo, &c_oid.to_string(), &a_oid.to_string()).unwrap();
assert!(crate::git::rebase_is_in_progress(t.repo.path()));
t.write_file("shared.txt", "version-b");
t.stage_files(&["shared.txt"]);
let workdir = t.workdir();
crate::core::transaction::continue_cmd(&workdir, t.repo.path()).unwrap();
assert!(!crate::git::rebase_is_in_progress(t.repo.path()));
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn fold_commit_to_unstaged_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
t.commit("First", "first.txt");
let c2 = t.commit("Second", "second.txt");
t.commit("Third", "third.txt");
let before = stage_a_mix(&t, "first.txt");
super::fold_commit_to_unstaged(&t.repo, &c2.to_string()).unwrap();
let after = t.status_porcelain();
let staged: Vec<&str> = after.lines().filter(|l| !l.starts_with("??")).collect();
assert_eq!(staged.join("\n") + "\n", before);
assert!(after.contains("?? second.txt"));
}
#[test]
fn fold_commit_file_to_unstaged_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
t.commit("First", "first.txt");
let c2 = t.commit_multi(&[("a.txt", "a\n"), ("b.txt", "b\n")], "Second");
t.commit("Third", "third.txt");
let before = stage_a_mix(&t, "first.txt");
super::fold_commit_file_to_unstaged(&t.repo, &c2.to_string(), "a.txt", &[]).unwrap();
let after = t.status_porcelain();
let staged: Vec<&str> = after.lines().filter(|l| !l.starts_with("??")).collect();
assert_eq!(staged.join("\n") + "\n", before);
assert!(after.contains("?? a.txt"));
}
#[test]
fn fold_commit_file_to_commit_keeps_staging_on_success() {
let t = TestRepo::new_with_remote();
let c1 = t.commit("First", "first.txt");
let c2 = t.commit_multi(&[("a.txt", "a\n"), ("b.txt", "b\n")], "Second");
t.commit("Third", "third.txt");
let before = stage_a_mix(&t, "first.txt");
super::fold_commit_file_to_commit(&t.repo, &c2.to_string(), "a.txt", &c1.to_string(), &[])
.unwrap();
assert_eq!(t.status_porcelain(), before);
}
#[test]
fn committed_onto_sees_what_git_did() {
let t = TestRepo::new();
let base = t.commit("Base", "base.txt");
assert!(!super::committed_onto(&t.workdir(), base));
let child = t.commit("Child", "child.txt");
assert!(super::committed_onto(&t.workdir(), base));
assert!(!super::committed_onto(&t.workdir(), child));
}
#[test]
fn an_amend_that_replaced_head_is_taken_back() {
let t = TestRepo::new();
t.commit("First", "file1.txt");
t.commit("Second", "other.txt");
t.write_file("other.txt", "staged by the user");
t.stage_files(&["other.txt"]);
t.write_file("file1.txt", "folded");
let workdir = t.workdir();
let saved =
crate::core::staging::save_and_unstage_other_staged(&t.repo, &workdir, &["file1.txt"])
.unwrap();
crate::git::stage_files(&workdir, &["file1.txt"]).unwrap();
let head = t.head_oid();
crate::git::run_git(&workdir, &["commit", "--amend", "--no-edit"]).unwrap();
assert_ne!(t.head_oid(), head, "the amend should have moved HEAD");
super::undo_commit_attempt(&workdir, head, &["file1.txt"], saved);
assert_eq!(t.head_oid(), head);
assert_eq!(t.get_message(0), "Second");
assert_eq!(t.read_file("file1.txt"), "folded");
let status = t.status_porcelain();
assert!(status.contains(" M file1.txt"), "{status}");
assert!(status.contains("M other.txt"), "{status}");
}
#[cfg(unix)]
#[test]
fn a_patch_fold_forwards_to_the_amend_at_the_rebase_pause() {
let body = "1\n2\n3\n4\n5\n6\n7\n8\n";
let select_moved_hunks = |t: &TestRepo, source: git2::Oid| {
let mut selections =
crate::core::staging::collect_commit_hunks(&t.workdir(), &source.to_string(), &[])
.unwrap();
for file in &mut selections {
for hunk in &mut file.hunks {
hunk.selected = true;
}
}
selections
};
let build = || {
let t = TestRepo::new_with_remote();
t.commit_multi(&[("f.txt", body)], "Base");
let target = t.commit_multi(&[("t.txt", "target\n")], "Target");
let source = t.commit_multi(&[("f.txt", &body.replace("2\n", "TWO\n"))], "Source");
t.install_hook("pre-commit", "exit 1\n");
(t, target, source)
};
let (t, target, source) = build();
let selections = select_moved_hunks(&t, source);
assert!(
super::fold_selected_hunks_to_commit(
&t.repo,
&t.workdir(),
&source.to_string(),
&target.to_string(),
&selections,
&[],
)
.is_err(),
"the hook should block the amend"
);
let (t, target, source) = build();
let selections = select_moved_hunks(&t, source);
let (_, new_target) = super::fold_selected_hunks_to_commit(
&t.repo,
&t.workdir(),
&source.to_string(),
&target.to_string(),
&selections,
&["--no-verify"],
)
.unwrap();
assert!(t.commit_has_file(git2::Oid::from_str(&new_target).unwrap(), "f.txt"));
assert_eq!(t.commit_messages()[..3], ["Source", "Target", "Base"]);
}
#[cfg(unix)]
#[test]
fn a_commit_file_fold_forwards_to_its_amend() {
let build = || {
let t = TestRepo::new_with_remote();
t.write_file("file1.txt", "content1");
t.write_file("file2.txt", "content2");
t.stage_files(&["file1.txt", "file2.txt"]);
t.commit_staged("Source commit");
let source = t.head_oid();
t.write_file("file3.txt", "content3");
t.stage_files(&["file3.txt"]);
t.commit_staged("Target commit");
let target = t.head_oid();
t.install_hook("pre-commit", "exit 1\n");
(t, source, target)
};
let (t, source, target) = build();
assert!(
super::fold_commit_file_to_commit(
&t.repo,
&source.to_string(),
"file1.txt",
&target.to_string(),
&[],
)
.is_err(),
"the hook should block the amend"
);
let (t, source, target) = build();
super::fold_commit_file_to_commit(
&t.repo,
&source.to_string(),
"file1.txt",
&target.to_string(),
&["--no-verify"],
)
.unwrap();
assert_eq!(t.commit_messages()[..2], ["Target commit", "Source commit"]);
assert!(t.commit_has_file(t.head_oid(), "file1.txt"));
assert_eq!(t.read_file("file1.txt"), "content1");
}
#[cfg(unix)]
#[test]
fn a_forwarded_fold_into_an_older_commit_keeps_the_commits_above_it() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let first = t.commit("First", "f1.txt");
t.commit("Second", "f2.txt");
t.install_hook("pre-commit", "exit 1\n");
t.write_file("f1.txt", "folded");
super::fold_files_into_commit(
&t.repo,
&["f1.txt".to_string()],
&first.to_string(),
false,
&["--no-verify"],
None,
)
.unwrap();
assert_eq!(t.commit_messages()[..3], ["Second", "First", "Base"]);
assert_eq!(t.read_file("f1.txt"), "folded");
}
#[test]
fn a_forwarded_amend_on_the_fixup_path_rolls_back() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let first = t.commit("First", "f1.txt");
t.commit("Second", "f2.txt");
let before = stage_a_mix(&t, "base.txt");
t.write_file("f1.txt", "folded");
let err = super::fold_files_into_commit(
&t.repo,
&["f1.txt".to_string()],
&first.to_string(),
false,
&["--amend"],
None,
)
.unwrap_err();
assert!(
err.to_string().contains("left no new commit on HEAD"),
"{err}"
);
assert_eq!(t.commit_messages()[..3], ["Second", "First", "Base"]);
assert_eq!(t.read_file("f1.txt"), "folded");
let status = t.status_porcelain();
assert!(status.contains(" M f1.txt"), "{status}");
assert_eq!(
status.lines().filter(|l| !l.contains("f1.txt")).count(),
before.lines().count(),
"the user's own staged set is back: {status}"
);
}
#[test]
fn a_forwarded_dry_run_on_the_amend_rolls_back() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let head = t.commit("Only", "f1.txt");
let before = stage_a_mix(&t, "base.txt");
t.write_file("f1.txt", "folded");
let err = super::fold_files_into_commit(
&t.repo,
&["f1.txt".to_string()],
&head.to_string(),
false,
&["--dry-run"],
None,
)
.unwrap_err();
assert!(err.to_string().contains("nothing was amended"), "{err}");
assert_eq!(t.head_oid(), head);
assert_eq!(t.read_file("f1.txt"), "folded");
assert_eq!(t.status_porcelain(), format!("{before} M f1.txt\n"));
}
#[cfg(unix)]
#[test]
fn a_refused_fixup_commit_gives_the_index_back() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let first = t.commit("First", "f1.txt");
t.commit("Second", "f2.txt");
let before = stage_a_mix(&t, "base.txt");
let head = t.head_oid();
t.write_file("f1.txt", "folded");
t.install_hook("pre-commit", "exit 1\n");
assert!(
super::fold_files_into_commit(
&t.repo,
&["f1.txt".to_string()],
&first.to_string(),
false,
&[],
None,
)
.is_err()
);
assert_eq!(t.head_oid(), head);
assert_eq!(t.commit_messages()[..3], ["Second", "First", "Base"]);
assert_eq!(t.status_porcelain(), format!("{before} M f1.txt\n"));
}
#[test]
fn every_whole_commit_form_rejects_the_separator() {
let t = TestRepo::new_with_remote();
t.commit("First", "f1.txt");
let first = t.head_oid().to_string();
t.commit("Second", "f2.txt");
let second = t.head_oid().to_string();
t.create_branch_at("other", &first);
let cases: [(bool, Option<super::Anchor>, Vec<String>, &str); 5] = [
(
false,
Some(super::Anchor::Above(first.clone())),
vec![second.clone()],
"moving commits next to another",
),
(
true,
None,
vec![second.clone(), "brand-new".into()],
"moving commits to a new branch",
),
(
false,
None,
vec![second.clone(), first.clone()],
"folding a commit into another",
),
(
false,
None,
vec![second.clone(), "other".into()],
"moving commits to a branch",
),
(
false,
None,
vec![second.clone(), "zz".into()],
"uncommitting a commit",
),
];
for (create, anchor, args, what) in cases {
let result = t.in_dir(|| {
super::run(
create,
false,
anchor.clone(),
HunkArgs::default(),
args.clone(),
vec!["--no-verify".into()],
&crate::core::graph::Theme::dark(),
)
});
let err = result.expect_err(what).to_string();
assert!(err.contains(what), "{what}: {err}");
assert!(
err.contains("takes no arguments after `--`"),
"{what}: {err}"
);
}
assert_eq!(t.commit_messages()[..2], ["Second", "First"]);
}
#[test]
fn a_forwarded_message_rewords_both_commits_of_a_move() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let target = t.commit("Target", "t.txt");
let source = t.commit_multi(&[("moved.txt", "m"), ("stays.txt", "s")], "Source");
super::fold_commit_file_to_commit(
&t.repo,
&source.to_string(),
"moved.txt",
&target.to_string(),
&["-m", "hijacked"],
)
.unwrap();
assert_eq!(t.commit_messages()[..3], ["hijacked", "hijacked", "Base"]);
}
#[test]
fn an_empty_fixup_commit_is_refused_before_the_squash() {
let t = TestRepo::new_with_remote();
t.commit("Base", "base.txt");
let first = t.commit("First", "f1.txt");
t.commit("Second", "f2.txt");
let before = stage_a_mix(&t, "base.txt");
let head = t.head_oid();
t.write_file("f1.txt", "folded");
let err = super::fold_files_into_commit(
&t.repo,
&["f1.txt".to_string()],
&first.to_string(),
false,
&["--only", "--allow-empty"],
None,
)
.unwrap_err();
assert!(err.to_string().contains("empty `fixup!` commit"), "{err}");
assert_eq!(t.head_oid(), head, "nothing was rewritten");
assert_eq!(t.commit_messages()[..3], ["Second", "First", "Base"]);
assert_eq!(t.status_porcelain(), format!("{before} M f1.txt\n"));
}
#[test]
fn fold_patch_refuses_an_upstream_target_before_staging() {
let t = TestRepo::new_with_remote();
let base = t.find_remote_branch_target("origin/main").to_string();
t.commit_multi(&[("a.txt", "a\n"), ("b.txt", "b\n")], "Local");
t.write_file("a.txt", "a staged\n");
t.stage_files(&["a.txt"]);
t.write_file("b.txt", "b unstaged\n");
let entries = crate::core::staging::collect_file_entries(&t.repo, &t.workdir(), None).unwrap();
let fp = crate::core::hunk_select::fingerprint("", Some(&base), &entries);
let result = t.in_dir(|| {
super::run(
false,
true,
None,
HunkArgs::new(vec!["b.txt:1".into()], Some(fp)),
vec!["zz".into(), base.clone()],
vec![],
&crate::core::graph::Theme::dark(),
)
});
let err = result.expect_err("upstream target").to_string();
assert!(err.contains("not in the weave"), "{err}");
assert_eq!(t.status_porcelain(), "M a.txt\n M b.txt\n");
}
#[test]
fn fold_file_into_a_redundant_newer_target_does_not_offer_to_drop_it() {
let (t, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_above();
let redundant = t.get_branch_target("alpha").to_string();
let err = super::fold_commit_file_to_commit(
&t.repo,
&keeper.to_string(),
"three.txt",
&redundant,
&[],
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
}
#[test]
fn fold_patch_into_a_redundant_target_does_not_offer_to_drop_it() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let workdir = t.workdir();
let mut selections =
crate::core::staging::collect_commit_hunks(&workdir, &keeper.to_string(), &[]).unwrap();
for hunk in selections.iter_mut().flat_map(|file| &mut file.hunks) {
hunk.selected = true;
}
let err = super::fold_selected_hunks_to_commit(
&t.repo,
&workdir,
&keeper.to_string(),
&redundant.to_string(),
&selections,
&[],
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
}
#[test]
fn fold_relative_to_a_redundant_commit_does_not_offer_to_drop_it() {
let (t, redundant, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_below();
let err = super::move_commits_relative_and_report(
&t.repo,
&[keeper.to_string()],
&redundant.to_string(),
Position::Below,
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
}