use crate::core::hunk_select::HunkArgs;
use crate::core::test_helpers::TestRepo;
#[test]
fn split_head_commit() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "content a"), ("file_b.txt", "content b")],
"Two files commit",
);
let result = super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file_a.txt".to_string()],
"First part".to_string(),
);
assert!(result.is_ok(), "split_head_commit failed: {:?}", result);
assert_eq!(test_repo.get_subject(0), "Two files commit");
assert_eq!(test_repo.get_subject(1), "First part");
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(0)),
vec!["file_b.txt"]
);
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(1)),
vec!["file_a.txt"]
);
}
#[test]
fn split_head_commit_with_absolute_path() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "content a"), ("file_b.txt", "content b")],
"Two files commit",
);
let abs = test_repo
.workdir()
.join("file_a.txt")
.to_string_lossy()
.into_owned();
let result = test_repo.in_dir(|| {
super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec![abs.clone()],
"First part".to_string(),
)
});
assert!(
result.is_ok(),
"split with absolute path failed: {result:?}"
);
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(1)),
vec!["file_a.txt"]
);
}
#[test]
fn split_non_head_commit() {
let test_repo = TestRepo::new_with_remote();
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "content a"), ("file_b.txt", "content b")],
"Two files commit",
);
test_repo.commit("Later commit", "later.txt");
let result = super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file_a.txt".to_string()],
"First part".to_string(),
);
assert!(result.is_ok(), "split_non_head_commit failed: {:?}", result);
assert_eq!(test_repo.get_subject(0), "Later commit");
assert_eq!(test_repo.get_subject(1), "Two files commit");
assert_eq!(test_repo.get_subject(2), "First part");
}
#[test]
fn split_single_file_fails() {
let test_repo = TestRepo::new();
let c1_oid = test_repo.commit("Single file", "only.txt");
let result = super::split_commit_with_selection(
&test_repo.repo,
&c1_oid.to_string(),
vec!["only.txt".to_string()],
"Should fail".to_string(),
);
assert!(result.is_err(), "Should fail on single-file commit");
let err = result.unwrap_err().to_string();
assert!(
err.contains("only one file"),
"Error should mention single file: {}",
err
);
}
#[test]
fn split_merge_commit_fails() {
let test_repo = TestRepo::new();
let c1_oid = test_repo.commit("First", "file1.txt");
let default_branch = test_repo.current_branch_name();
test_repo.create_branch("side");
test_repo.switch_branch("side");
let c2_oid = test_repo.commit("Side", "side.txt");
test_repo.switch_branch(&default_branch);
let merge_oid = test_repo.commit_merge("Merge", c1_oid, c2_oid);
let result = super::split_commit_with_selection(
&test_repo.repo,
&merge_oid.to_string(),
vec!["file1.txt".to_string()],
"Should fail".to_string(),
);
assert!(result.is_err(), "Should fail on merge commit");
let err = result.unwrap_err().to_string();
assert!(
err.contains("merge commit"),
"Error should mention merge commit: {}",
err
);
}
#[test]
fn split_preserves_other_commits() {
let test_repo = TestRepo::new_with_remote();
let c1_oid = test_repo.commit("Before", "before.txt");
let split_oid = test_repo.commit_multi(
&[("file_a.txt", "content a"), ("file_b.txt", "content b")],
"Split me",
);
test_repo.commit("After", "after.txt");
let result = super::split_commit_with_selection(
&test_repo.repo,
&split_oid.to_string(),
vec!["file_a.txt".to_string()],
"First part".to_string(),
);
assert!(
result.is_ok(),
"split_preserves_other_commits failed: {:?}",
result
);
assert_eq!(test_repo.get_subject(0), "After");
assert_eq!(test_repo.get_subject(1), "Split me");
assert_eq!(test_repo.get_subject(2), "First part");
assert_eq!(test_repo.get_subject(3), "Before");
assert_eq!(test_repo.get_oid(3), c1_oid);
}
#[test]
fn split_with_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("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
let split_oid = test_repo.commit_multi(
&[("fa1.txt", "content a1"), ("fa2.txt", "content a2")],
"Feature A files",
);
test_repo.switch_branch("integration");
test_repo.commit("Int commit", "int.txt");
test_repo.merge_no_ff("feature-a");
let result = super::split_commit_with_selection(
&test_repo.repo,
&split_oid.to_string(),
vec!["fa1.txt".to_string()],
"Feature A part 1".to_string(),
);
assert!(
result.is_ok(),
"split_with_woven_branches failed: {:?}",
result
);
assert!(
test_repo.branch_exists("feature-a"),
"feature-a branch should still exist"
);
let head = test_repo.head_commit();
assert!(
head.parent_count() > 1,
"HEAD should still be a merge commit"
);
}
#[test]
fn split_head_commit_with_a_deletion() {
let test_repo = TestRepo::new();
test_repo.commit("Add file1", "file1.txt");
std::fs::remove_file(test_repo.workdir().join("file1.txt")).unwrap();
test_repo.write_file("file2.txt", "content b");
test_repo.stage_files(&["file1.txt", "file2.txt"]);
test_repo.commit_staged("Delete one, add another");
let target_oid = test_repo.head_oid();
let result = super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file1.txt".to_string()],
"Delete file1".to_string(),
);
assert!(result.is_ok(), "split of a deletion failed: {:?}", result);
assert_eq!(test_repo.get_subject(1), "Delete file1");
assert_eq!(test_repo.get_subject(0), "Delete one, add another");
assert!(!test_repo.commit_has_file(test_repo.get_oid(1), "file1.txt"));
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(0)),
vec!["file2.txt"]
);
test_repo.assert_working_tree_clean();
}
fn blob_at(test_repo: &TestRepo, steps_back: usize, path: &str) -> Vec<u8> {
let commit = test_repo.find_commit(test_repo.get_oid(steps_back));
let entry = commit
.tree()
.unwrap()
.get_path(std::path::Path::new(path))
.unwrap();
let blob = test_repo.repo.find_blob(entry.id()).unwrap();
blob.content().to_vec()
}
#[test]
fn split_head_takes_the_files_from_the_commit_not_the_working_tree() {
let test_repo = TestRepo::new();
test_repo.commit("Base", "base.txt");
test_repo.commit_multi(
&[
("a[1].txt", "a\n"),
("a1.txt", "one\n"),
("gone.txt", "g\n"),
],
"Three files",
);
test_repo.write_file("a[1].txt", "a\nDIRTY\n");
test_repo.write_file("a1.txt", "one\nDIRTY\n");
std::fs::remove_file(test_repo.workdir().join("gone.txt")).unwrap();
super::split_commit_with_selection(
&test_repo.repo,
"HEAD",
vec!["a[1].txt".to_string()],
"First".to_string(),
)
.unwrap();
assert_eq!(
test_repo.commit_file_paths(test_repo.get_oid(1)),
vec!["a[1].txt"]
);
assert_eq!(blob_at(&test_repo, 0, "a[1].txt"), b"a\n");
assert_eq!(blob_at(&test_repo, 0, "a1.txt"), b"one\n");
assert_eq!(blob_at(&test_repo, 0, "gone.txt"), b"g\n");
assert_eq!(test_repo.read_file("a[1].txt"), "a\nDIRTY\n");
assert_eq!(
test_repo.status_porcelain(),
" M a1.txt\n M a[1].txt\n D gone.txt\n"
);
}
#[test]
fn split_head_keeps_a_staged_and_an_unstaged_edit_of_one_file() {
let test_repo = TestRepo::new();
test_repo.commit("Base", "base.txt");
test_repo.commit_multi(&[("a.txt", "a\n"), ("b.txt", "b\n")], "Two files");
test_repo.write_file("a.txt", "a\nSTAGED\n");
test_repo.stage_files(&["a.txt"]);
test_repo.write_file("a.txt", "a\nSTAGED\nUNSTAGED\n");
super::split_commit_with_selection(
&test_repo.repo,
"HEAD",
vec!["a.txt".to_string()],
"First".to_string(),
)
.unwrap();
assert_eq!(blob_at(&test_repo, 1, "a.txt"), b"a\n");
assert_eq!(blob_at(&test_repo, 0, "b.txt"), b"b\n");
assert_eq!(test_repo.status_porcelain(), "MM a.txt\n");
assert_eq!(test_repo.read_file("a.txt"), "a\nSTAGED\nUNSTAGED\n");
let index = test_repo.repo.index().unwrap();
let staged = index.get_path(std::path::Path::new("a.txt"), 0).unwrap();
let blob = test_repo.repo.find_blob(staged.id).unwrap();
assert_eq!(blob.content(), b"a\nSTAGED\n");
}
#[test]
fn split_head_by_hunks_takes_whole_files_from_the_commit() {
let test_repo = TestRepo::new();
test_repo.commit("Base", "base.txt");
test_repo.write_file("text.txt", "one\n");
std::fs::write(test_repo.workdir().join("f.bin"), b"\0one").unwrap();
std::fs::remove_file(test_repo.workdir().join("base.txt")).unwrap();
test_repo.stage_files(&["text.txt", "f.bin", "base.txt"]);
test_repo.commit_staged("Text, binary and a deletion");
let workdir = test_repo.workdir();
let mut selections = crate::core::staging::collect_commit_hunks(&workdir, "HEAD", &[]).unwrap();
for file in &mut selections {
for hunk in &mut file.hunks {
hunk.selected = file.path != "text.txt";
}
}
test_repo.write_file("text.txt", "one\nDIRTY\n");
std::fs::write(workdir.join("f.bin"), b"\0dirty").unwrap();
test_repo.write_file("base.txt", "recreated\n");
super::perform_head_split_by_hunks(
&test_repo.repo,
&workdir,
&selections,
Some("Binary"),
"Text",
)
.unwrap();
assert_eq!(blob_at(&test_repo, 1, "f.bin"), b"\0one");
assert!(!test_repo.commit_has_file(test_repo.get_oid(1), "base.txt"));
assert_eq!(blob_at(&test_repo, 0, "text.txt"), b"one\n");
assert_eq!(
test_repo.status_porcelain(),
" M f.bin\n M text.txt\n?? base.txt\n"
);
}
#[test]
fn split_by_hunks_takes_a_submodule_from_the_commit() {
use crate::tui::hunk_selector::{FileEntry, HunkEntry, HunkOrigin};
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 and add");
test_repo.checkout_submodule("Data", first);
let entry = |path: &str, selected: bool, binary: 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,
};
let selections = [entry("Data", true, true), entry("other.txt", false, false)];
let workdir = test_repo.workdir();
let (hash1, _hash2) = super::perform_head_split_by_hunks(
&test_repo.repo,
&workdir,
&selections,
Some("first"),
"second",
)
.unwrap();
let split_off = git2::Oid::from_str(&hash1).unwrap();
assert_eq!(test_repo.submodule_oid(split_off, "Data"), second);
assert_eq!(test_repo.get_subject(1), "first");
assert_eq!(test_repo.get_subject(0), "second");
}
#[test]
fn split_refuses_when_the_replay_is_dropped() {
let (t, target) = crate::core::test_helpers::repo_with_dropped_replay();
let head_before = t.head_oid();
let base_before = t.find_remote_branch_target("origin/main");
let err = super::split_commit_with_selection(
&t.repo,
&target.to_string(),
vec!["one.txt".to_string()],
"First part".to_string(),
)
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert_eq!(t.head_oid(), head_before, "{err}");
assert_eq!(
t.find_remote_branch_target("origin/main"),
base_before,
"{err}"
);
assert!(!crate::git::rebase_is_in_progress(t.repo.path()), "{err}");
}
const CHANGE_ID: &str = "I0123456789abcdef0123456789abcdef01234567";
fn change_id_at(test_repo: &TestRepo, steps_back: usize) -> Option<String> {
crate::core::changeid::from_message(test_repo.get_commit(steps_back).message().unwrap())
}
#[test]
fn split_keeps_the_change_id_on_the_second_commit_and_mints_one_for_the_first() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "a"), ("file_b.txt", "b")],
&format!("Two files commit\n\nChange-Id: {CHANGE_ID}\n"),
);
super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file_a.txt".to_string()],
"First part".to_string(),
)
.unwrap();
assert_eq!(change_id_at(&test_repo, 0).as_deref(), Some(CHANGE_ID));
let first = change_id_at(&test_repo, 1).expect("first half gets a fresh id");
assert_ne!(first, CHANGE_ID);
assert_eq!(
test_repo.get_message(0).matches("Change-Id:").count(),
1,
"the original trailer is not duplicated"
);
}
#[test]
fn split_mints_change_ids_for_both_halves_of_a_plain_commit() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "a"), ("file_b.txt", "b")],
"Two files commit",
);
super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file_a.txt".to_string()],
"First part".to_string(),
)
.unwrap();
let second = change_id_at(&test_repo, 0).expect("second half");
let first = change_id_at(&test_repo, 1).expect("first half");
assert_ne!(first, second);
assert!(
test_repo
.get_message(0)
.starts_with("Two files commit\n\nChange-Id: I")
);
}
#[test]
fn split_gives_distinct_ids_when_both_halves_share_the_message() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "a"), ("file_b.txt", "b")],
"Two files commit",
);
super::split_commit_with_selection(
&test_repo.repo,
&target_oid.to_string(),
vec!["file_a.txt".to_string()],
"Two files commit".to_string(),
)
.unwrap();
let second = change_id_at(&test_repo, 0).expect("second half");
let first = change_id_at(&test_repo, 1).expect("first half");
assert_ne!(first, second);
}
#[test]
fn split_with_editor_stamps_the_first_half_and_keeps_the_second() {
let test_repo = TestRepo::new();
test_repo.commit("Add files", "file1.txt");
let target_oid = test_repo.commit_multi(
&[("file_a.txt", "a"), ("file_b.txt", "b")],
&format!("Two files commit\n\nChange-Id: {CHANGE_ID}\n"),
);
test_repo.set_fake_editor("First part by editor");
super::split_commit(
&test_repo.repo,
&target_oid.to_string(),
None,
None,
vec!["file_a.txt".to_string()],
&crate::core::graph::Theme::dark(),
)
.unwrap();
assert!(
test_repo
.get_message(1)
.starts_with("First part by editor\n\nChange-Id: I")
);
assert_eq!(change_id_at(&test_repo, 0).as_deref(), Some(CHANGE_ID));
}
fn hint(message: Option<&str>, patch: bool, hunks: HunkArgs, files: &[&str]) -> String {
let files: Vec<String> = files.iter().map(|f| f.to_string()).collect();
super::invocation("ab", message, patch, &hunks, &files)
}
#[test]
fn hint_keeps_the_message_it_was_given() {
assert_eq!(
hint(Some("fix the thing"), false, HunkArgs::default(), &[]),
"loom split ab -m 'fix the thing'"
);
}
#[test]
fn hint_without_a_message_asks_for_one() {
assert_eq!(
hint(None, true, HunkArgs::default(), &[]),
"loom split ab -m <message> -p"
);
}
#[test]
fn hint_quotes_a_path_with_a_space() {
assert_eq!(
hint(Some("m"), true, HunkArgs::default(), &["with space.txt"]),
"loom split ab -m m -p 'with space.txt'"
);
}
#[test]
fn hint_quotes_every_selection_argument() {
let hunks = HunkArgs::new(
vec!["a,b.txt:1".to_string(), "plain.txt:2".to_string()],
Some("; echo pwned".to_string()),
);
assert_eq!(
hint(Some("m"), true, hunks, &[]),
"loom split ab -m m -p --hunks 'a,b.txt:1' --hunks 'plain.txt:2' --hunks-from '; echo pwned'"
);
}
#[test]
fn hint_omits_the_selection_when_none_was_given() {
let hunks = HunkArgs::new(vec![], Some("abc123".to_string()));
assert_eq!(hint(Some("m"), true, hunks, &[]), "loom split ab -m m -p");
}