use crate::core::test_helpers::TestRepo;
use crate::git;
const BASE: &str = "1\n2\n3\n4\n5\n6\n7\n";
fn repo_with_dropped_patch_in(file: &str, head_content: &str) -> (TestRepo, String) {
let test_repo = TestRepo::new();
test_repo.write_file(file, BASE);
test_repo.stage_files(&[file]);
test_repo.commit_staged("base");
let base_oid = test_repo.head_oid();
test_repo.write_file(file, &BASE.replace("4\n", "FOUR\n"));
test_repo.stage_files(&[file]);
test_repo.commit_staged("change 4");
let patch = test_repo.diff_commit(&test_repo.head_oid().to_string());
test_repo.reset_hard(base_oid);
test_repo.write_file(file, head_content);
test_repo.stage_files(&[file]);
test_repo.commit_staged("later commit");
test_repo.write_file("staged.txt", "staged");
test_repo.stage_files(&["staged.txt"]);
(test_repo, patch)
}
#[test]
fn apply_to_worktree_falls_back_to_three_way() {
let (test_repo, patch) = repo_with_dropped_patch_in("f.txt", &BASE.replace("6\n", "SIX\n"));
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).unwrap();
assert_eq!(
test_repo.read_file("f.txt"),
"1\n2\n3\nFOUR\n5\nSIX\n7\n",
"both changes should be there"
);
assert_eq!(test_repo.status_porcelain(), " M f.txt\nA staged.txt\n");
}
#[test]
fn apply_to_worktree_reports_a_conflict_and_restores_the_file() {
let (test_repo, patch) = repo_with_dropped_patch_in("f.txt", &BASE.replace("4\n", "QUATRE\n"));
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).expect_err("the patch conflicts");
assert_eq!(
test_repo.read_file("f.txt"),
BASE.replace("4\n", "QUATRE\n"),
"no conflict markers"
);
assert_eq!(test_repo.status_porcelain(), "A staged.txt\n");
}
#[test]
fn apply_to_worktree_cleans_up_a_non_ascii_path() {
let quatre = BASE.replace("4\n", "QUATRE\n");
let (test_repo, patch) = repo_with_dropped_patch_in("été.txt", &quatre);
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).expect_err("the patch conflicts");
assert_eq!(
test_repo.read_file("été.txt"),
quatre,
"no conflict markers"
);
assert_eq!(test_repo.status_porcelain(), "A staged.txt\n");
}
#[test]
fn apply_to_worktree_undoes_a_partial_apply() {
let test_repo = TestRepo::new();
let clean = BASE.replace("6\n", "SIX\n");
let conflicting = BASE.replace("4\n", "QUATRE\n");
test_repo.write_file("clean.txt", BASE);
test_repo.write_file("conflicting.txt", BASE);
test_repo.write_file("added.txt", "gone once the apply is undone\n");
test_repo.stage_files(&["clean.txt", "conflicting.txt"]);
test_repo.commit_staged("base");
let base_oid = test_repo.head_oid();
let four = BASE.replace("4\n", "FOUR\n");
test_repo.write_file("clean.txt", &four);
test_repo.write_file("conflicting.txt", &four);
test_repo.stage_files(&["clean.txt", "conflicting.txt", "added.txt"]);
test_repo.commit_staged("change 4 in both, add a file");
let patch = test_repo.diff_commit(&test_repo.head_oid().to_string());
test_repo.reset_hard(base_oid);
test_repo.write_file("clean.txt", &clean);
test_repo.write_file("conflicting.txt", &conflicting);
test_repo.stage_files(&["clean.txt", "conflicting.txt"]);
test_repo.commit_staged("later commit");
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).expect_err("the patch conflicts");
assert_eq!(
test_repo.read_file("clean.txt"),
clean,
"merged file put back"
);
assert_eq!(
test_repo.read_file("conflicting.txt"),
conflicting,
"conflicted file put back"
);
assert!(
!test_repo.workdir().join("added.txt").exists(),
"the file the patch created should be gone again"
);
assert_eq!(test_repo.status_porcelain(), "");
}
#[test]
fn apply_to_worktree_does_not_treat_a_path_as_a_glob() {
let quatre = BASE.replace("4\n", "QUATRE\n");
let (test_repo, patch) = repo_with_dropped_patch_in("a[12].txt", &quatre);
test_repo.write_file("a1.txt", "committed");
test_repo.stage_files(&["a1.txt"]);
test_repo.commit_staged("a sibling the glob would match");
test_repo.write_file("a1.txt", "the user's uncommitted work");
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).expect_err("the patch conflicts");
assert_eq!(
test_repo.read_file("a1.txt"),
"the user's uncommitted work",
"the cleanup must leave a file the patch never touched alone"
);
assert_eq!(
test_repo.read_file("a[12].txt"),
quatre,
"no conflict markers"
);
}
#[test]
fn apply_to_worktree_leaves_the_tree_alone_when_nothing_was_written() {
let (test_repo, patch) = repo_with_dropped_patch_in("f.txt", &BASE.replace("6\n", "SIX\n"));
test_repo.write_file("unrelated.txt", "committed");
test_repo.stage_files(&["unrelated.txt"]);
test_repo.commit_staged("a file the patch never mentions");
test_repo.write_file("f.txt", "the user's own rewrite\n");
test_repo.write_file("unrelated.txt", "the user's uncommitted work");
git::apply_patch_to_worktree(&test_repo.workdir(), &patch).expect_err("the patch is refused");
assert_eq!(
test_repo.read_file("unrelated.txt"),
"the user's uncommitted work",
"an untouched file must keep the user's changes"
);
assert_eq!(
test_repo.read_file("f.txt"),
"the user's own rewrite\n",
"and so must the file the patch was refused over"
);
}
#[test]
fn restore_staged_after_rebase_keeps_what_the_autostash_left() {
let t = TestRepo::new();
t.write_file("a.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
t.stage_files(&["a.txt"]);
t.commit_staged("base");
t.write_file("a.txt", "l1-EDIT\nl2\nl3\nl4\nl5\nl6\n");
t.write_file("n.txt", "new\n");
t.stage_files(&["a.txt", "n.txt"]);
let workdir = t.workdir();
let patch = git::diff_cached(&workdir).unwrap();
t.write_file("a.txt", "l1\nl2\nl3-UPSTREAM\nl4\nl5\nl6\n");
git::run_git(
&workdir,
&["commit", "-m", "upstream rewrite", "--", "a.txt"],
)
.unwrap();
t.write_file("a.txt", "l1-EDIT\nl2\nl3-UPSTREAM\nl4\nl5\nl6\n");
git::restore_staged_after_rebase(&workdir, &patch);
assert_eq!(t.status_porcelain(), "M a.txt\nA n.txt\n");
assert_eq!(
git::run_git_stdout(&workdir, &["show", ":a.txt"]).unwrap(),
"l1-EDIT\nl2\nl3-UPSTREAM\nl4\nl5\nl6\n",
"the staged edit is merged onto what the rebase wrote"
);
}
#[test]
fn restore_staged_after_rebase_never_fails_the_caller() {
let t = TestRepo::new();
t.commit("First", "first.txt");
t.write_file("first.txt", "staged edit\n");
t.stage_files(&["first.txt"]);
let workdir = t.workdir();
let patch = git::diff_cached(&workdir).unwrap();
assert!(!patch.is_empty());
git::unstage_files(&workdir, &["first.txt"]).unwrap();
std::fs::write(t.repo.path().join("index.lock"), b"").unwrap();
git::restore_staged_after_rebase(&workdir, &patch);
let parked = git::git_path(&workdir, "loom").unwrap();
assert!(
std::fs::read_dir(&parked)
.expect("the patch is parked under the git dir")
.filter_map(|e| e.ok())
.any(|e| e
.file_name()
.to_string_lossy()
.starts_with("unrestored-staged")),
"a failed restore hands the patch over"
);
}
#[test]
fn restore_staged_after_rebase_leaves_no_conflict_in_the_index() {
let t = TestRepo::new();
t.write_file("a.txt", "l1\nl2\nl3\n");
t.stage_files(&["a.txt"]);
t.commit_staged("base");
t.write_file("a.txt", "l1\nMINE\nl3\n");
t.stage_files(&["a.txt"]);
let workdir = t.workdir();
let patch = git::diff_cached(&workdir).unwrap();
t.write_file("a.txt", "l1\nTHEIRS\nl3\n");
t.stage_files(&["a.txt"]);
t.commit_staged("upstream rewrite");
git::restore_staged_after_rebase(&workdir, &patch);
assert!(!git::has_unmerged_paths(&workdir));
assert_eq!(
t.status_porcelain(),
"",
"the index is left as the rebase left it"
);
let parked = git::git_path(&workdir, "loom").unwrap();
let saved: Vec<_> = std::fs::read_dir(&parked)
.expect("the patch is parked under the git dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("unrestored-staged")
})
.collect();
assert_eq!(saved.len(), 1, "exactly one parked patch");
assert!(
std::fs::read_to_string(saved[0].path())
.unwrap()
.contains("MINE"),
"the parked patch carries the staged content"
);
}
#[test]
fn save_patch_aside_never_writes_over_an_earlier_save() {
let test_repo = TestRepo::new();
test_repo.commit("A commit", "file1.txt");
let workdir = test_repo.workdir();
let first = git::save_patch_aside(&workdir, "unrestored", "first patch").unwrap();
let second = git::save_patch_aside(&workdir, "unrestored", "second patch").unwrap();
assert_eq!(first.file_name().unwrap(), "unrestored-0.patch");
assert_eq!(second.file_name().unwrap(), "unrestored-1.patch");
assert_eq!(std::fs::read_to_string(&first).unwrap(), "first patch");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "second patch");
assert!(
first.starts_with(test_repo.repo.path()),
"saved under the git dir, not next to the user's files: {}",
first.display()
);
}
#[test]
fn restore_staged_after_rebase_carries_binary_and_deleted_files() {
let t = TestRepo::new();
t.write_file("keep.txt", "keep\n");
std::fs::write(t.workdir().join("bin.dat"), [0u8, 1, 2, 3, 0, 255]).unwrap();
t.write_file("gone.txt", "gone\n");
t.stage_files(&["keep.txt", "bin.dat", "gone.txt"]);
t.commit_staged("base");
std::fs::write(t.workdir().join("bin.dat"), [9u8, 9, 9, 0, 7]).unwrap();
std::fs::remove_file(t.workdir().join("gone.txt")).unwrap();
let workdir = t.workdir();
git::stage_files(&workdir, &["bin.dat", "gone.txt"]).unwrap();
let patch = git::diff_cached(&workdir).unwrap();
let before = t.status_porcelain();
git::run_git(&workdir, &["reset", "--mixed", "HEAD"]).unwrap();
std::fs::write(t.workdir().join("bin.dat"), [9u8, 9, 9, 0, 7]).unwrap();
git::restore_staged_after_rebase(&workdir, &patch);
assert_eq!(t.status_porcelain(), before);
assert_eq!(
git::run_git_stdout(&workdir, &["show", ":bin.dat"])
.map(|s| s.into_bytes())
.unwrap_or_default()
.len(),
5,
"the staged binary comes back byte for byte"
);
}
#[test]
fn restore_or_park_after_abort_parks_when_the_rebase_survived() {
let t = TestRepo::new();
t.commit("First", "first.txt");
t.write_file("first.txt", "staged edit\n");
t.stage_files(&["first.txt"]);
let workdir = t.workdir();
let patch = git::diff_cached(&workdir).unwrap();
std::fs::create_dir_all(t.repo.path().join("rebase-merge")).unwrap();
let err = anyhow::anyhow!("the abort failed too");
git::restore_or_park_after_abort(&workdir, &patch, &err);
let parked = git::git_path(&workdir, "loom").unwrap();
assert!(
std::fs::read_dir(&parked)
.expect("the patch is parked under the git dir")
.filter_map(|e| e.ok())
.any(|e| e
.file_name()
.to_string_lossy()
.starts_with("unrestored-staged")),
"a rebase loom could not free hands the patch over"
);
}
#[test]
fn save_or_warn_parks_a_patch_and_skips_an_empty_one() {
let t = TestRepo::new();
t.commit("A commit", "file1.txt");
let workdir = t.workdir();
git::save_or_warn(
&workdir,
"unrestored-staged",
"a patch",
git::Replay::Cached,
);
let parked = git::git_path(&workdir, "loom").unwrap();
let saved: Vec<_> = std::fs::read_dir(&parked)
.expect("parked under the git dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("unrestored-staged")
})
.collect();
assert_eq!(saved.len(), 1);
assert_eq!(std::fs::read_to_string(saved[0].path()).unwrap(), "a patch");
git::save_or_warn(&workdir, "unrestored", "", git::Replay::Worktree);
assert!(!parked.join("unrestored-0.patch").exists());
}
#[test]
fn restore_or_park_after_abort_restages_when_the_rebase_is_gone() {
let t = TestRepo::new();
t.write_file("a.txt", "base\n");
t.stage_files(&["a.txt"]);
t.commit_staged("base");
t.write_file("a.txt", "staged edit\n");
t.stage_files(&["a.txt"]);
let workdir = t.workdir();
let patch = git::diff_cached(&workdir).unwrap();
let before = t.status_porcelain();
git::unstage_files(&workdir, &["a.txt"]).unwrap();
git::restore_or_park_after_abort(&workdir, &patch, &anyhow::anyhow!("the rebase stopped"));
assert_eq!(t.status_porcelain(), before);
let parked = git::git_path(&workdir, "loom").unwrap();
assert!(
!parked.join("unrestored-staged-0.patch").exists(),
"it went back into the index, so there is nothing to hand over"
);
}
#[test]
fn restore_staged_after_rebase_is_idempotent() {
let t = TestRepo::new();
t.write_file("keep.txt", "keep\n");
t.write_file("gone.txt", "gone\n");
std::fs::write(t.workdir().join("bin.dat"), [0u8, 1, 2, 3, 0, 255]).unwrap();
t.stage_files(&["keep.txt", "gone.txt", "bin.dat"]);
t.commit_staged("base");
t.write_file("keep.txt", "keep\nstaged edit\n");
std::fs::write(t.workdir().join("bin.dat"), [9u8, 9, 9, 0, 7]).unwrap();
t.write_file("brand-new.txt", "new\n");
let workdir = t.workdir();
t.stage_files(&["keep.txt", "bin.dat", "brand-new.txt"]);
git::run_git(&workdir, &["rm", "--cached", "-q", "gone.txt"]).unwrap();
let patch = git::diff_cached(&workdir).unwrap();
let before = t.status_porcelain();
git::restore_staged_after_rebase(&workdir, &patch);
assert_eq!(t.status_porcelain(), before);
assert_eq!(git::diff_cached(&workdir).unwrap(), patch);
let parked = git::git_path(&workdir, "loom")
.unwrap()
.join("unrestored-staged-0.patch");
assert!(!parked.exists(), "a no-op must not hand anything over");
}
#[test]
fn restore_staged_after_rebase_parks_a_second_restore_over_a_moved_head() {
let t = TestRepo::new();
t.write_file("keep.txt", "alpha\nbeta\ngamma\ndelta\n");
t.write_file("gone.txt", "gone\n");
t.stage_files(&["keep.txt", "gone.txt"]);
t.commit_staged("base");
let workdir = t.workdir();
t.write_file("keep.txt", "alpha staged\nbeta\ngamma\ndelta\n");
t.stage_files(&["keep.txt"]);
git::run_git(&workdir, &["rm", "--cached", "-q", "gone.txt"]).unwrap();
let patch = git::diff_cached(&workdir).unwrap();
git::run_git(&workdir, &["reset", "--hard", "HEAD"]).unwrap();
t.write_file("keep.txt", "alpha\nbeta\ngamma\ndelta rewritten\n");
t.stage_files(&["keep.txt"]);
t.commit_staged("rewrite");
git::restore_staged_after_rebase(&workdir, &patch);
let staged = git::run_git_stdout(&workdir, &["diff", "--cached", "--name-status"]).unwrap();
assert_eq!(
staged, "D\tgone.txt\nM\tkeep.txt\n",
"the first restore must stage both halves"
);
git::restore_staged_after_rebase(&workdir, &patch);
assert_eq!(
git::run_git_stdout(&workdir, &["diff", "--cached", "--name-status"]).unwrap(),
staged,
"the second restore must not cost what the first put back"
);
let parked = git::git_path(&workdir, "loom")
.unwrap()
.join("unrestored-staged-0.patch");
assert_eq!(std::fs::read_to_string(&parked).unwrap(), patch);
}
#[test]
fn restore_staged_after_rebase_parks_a_patch_naming_a_removed_path() {
let t = TestRepo::new();
t.write_file("keep.txt", "alpha\nbeta\ngamma\ndelta\n");
t.write_file("x.txt", "x\n");
t.stage_files(&["keep.txt", "x.txt"]);
t.commit_staged("base");
let workdir = t.workdir();
t.write_file("keep.txt", "alpha staged\nbeta\ngamma\ndelta\n");
t.write_file("x.txt", "x\nsecret\n");
t.stage_files(&["keep.txt", "x.txt"]);
let patch = git::diff_cached(&workdir).unwrap();
git::run_git(&workdir, &["reset", "--hard", "HEAD"]).unwrap();
git::run_git(&workdir, &["rm", "-q", "x.txt"]).unwrap();
t.commit_staged("remove x.txt");
git::restore_staged_after_rebase(&workdir, &patch);
let parked = git::git_path(&workdir, "loom")
.unwrap()
.join("unrestored-staged-0.patch");
assert_eq!(
std::fs::read_to_string(&parked).unwrap(),
patch,
"the staged side is only in the patch by now, so it has to be handed over"
);
}
#[test]
fn restore_or_park_after_abort_leaves_the_index_alone_before_the_rebase_starts() {
let t = TestRepo::new();
t.write_file("a.txt", "committed\n");
t.stage_files(&["a.txt"]);
t.commit_staged("base");
let workdir = t.workdir();
t.write_file("aside.txt", "set aside\n");
t.stage_files(&["aside.txt"]);
let patch = git::diff_cached(&workdir).unwrap();
git::run_git(&workdir, &["reset", "-q", "HEAD"]).unwrap();
let err = git::before_rebase_starts::<()>(Err(anyhow::anyhow!("checked out elsewhere")))
.expect_err("tagged as raised before the rebase started");
git::restore_or_park_after_abort(&workdir, &patch, &err);
assert_eq!(git::diff_cached(&workdir).unwrap(), "");
assert!(
!git::git_path(&workdir, "loom").unwrap().exists(),
"nothing to park either"
);
}
#[test]
fn restore_loom_unstaged_restores_when_no_rebase_is_on_disk() {
let t = TestRepo::new();
t.write_file("a.txt", "committed\n");
t.stage_files(&["a.txt"]);
t.commit_staged("base");
let workdir = t.workdir();
t.write_file("a.txt", "staged\n");
t.stage_files(&["a.txt"]);
let patch = git::diff_cached(&workdir).unwrap();
git::run_git(&workdir, &["reset", "-q", "HEAD"]).unwrap();
git::restore_loom_unstaged(&workdir, &patch);
assert_eq!(git::diff_cached(&workdir).unwrap(), patch);
}
fn conflict_on(t: &TestRepo, files: &[&str]) {
let workdir = t.workdir();
for file in files {
if let Some(dir) = workdir.join(file).parent() {
std::fs::create_dir_all(dir).unwrap();
}
}
let commit = |content: &str| {
for file in files {
t.write_file(file, content);
}
t.stage_files(files);
t.commit_staged(content);
t.head_oid().to_string()
};
let base = commit("base");
let theirs = commit("theirs");
git::run_git(&workdir, &["checkout", "-q", "-b", "ours", &base]).unwrap();
commit("ours");
git::run_git(&workdir, &["merge", "--no-commit", &theirs]).unwrap_err();
}
#[test]
fn the_unmerged_reset_hint_quotes_every_path_it_names() {
let t = TestRepo::new();
let workdir = t.workdir();
conflict_on(&t, &["a.txt", "my file.txt", "été.txt", "weird;&name.txt"]);
assert_eq!(
super::unmerged_reset_hint(&workdir).expect("the merge left stages behind"),
"it left unmerged entries in the index — `git reset -- \
':(top,literal)a.txt' ':(top,literal)my file.txt' \
':(top,literal)weird;&name.txt' ':(top,literal)été.txt'` clears them"
);
}
#[test]
fn the_hinted_reset_clears_the_stages_and_spares_the_rest() {
let t = TestRepo::new();
let workdir = t.workdir();
conflict_on(&t, &["sub/a.txt", "sub/g[1].txt"]);
t.write_file(
"keep.txt",
"the user staged this
",
);
t.stage_files(&["keep.txt"]);
let specs = super::unmerged_pathspecs(&workdir);
let mut args = vec!["reset", "--"];
args.extend(specs.iter().map(String::as_str));
git::run_git(&workdir.join("sub"), &args).unwrap();
assert!(
git::unmerged_paths(&workdir).is_empty(),
"the hinted reset must clear every stage it names, from a subdirectory"
);
assert_eq!(
crate::core::repo::get_staged_files(&t.repo).unwrap(),
vec!["keep.txt".to_string()],
"staging the patch has no copy of must survive the reset"
);
}
#[test]
fn the_unmerged_reset_hint_names_no_path_it_could_not_decode() {
let specs = vec![
":(top,literal)a.txt".to_string(),
":(top,literal)caf\u{fffd}.txt".to_string(),
];
assert_eq!(
super::reset_hint_for(&specs).expect("the index is unmerged"),
"it left unmerged entries in the index — `git status` names them, and a \
`git reset` limited to those paths clears them"
);
}
#[test]
fn the_unmerged_reset_hint_is_none_on_a_clean_index() {
let t = TestRepo::new();
t.commit("only", "a.txt");
assert!(super::unmerged_reset_hint(&t.workdir()).is_none());
}