use super::*;
use crate::subvolume_test_support::{PlainPlatform, backdate, checkout_with_worktree};
use crate::test_support::{git, remove};
use crate::worktree_convert_journal::Phase as JournalPhase;
use crate::worktree_convert_remove::remove_recorded_old_tree_seamed;
const RUN: ConvertOptions = ConvertOptions { dry_run: false, abandon: false, command_line_attributes: false };
fn quiet_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf) {
let (parent, root) = checkout_with_worktree(name);
let worktree: PathBuf = std::fs::canonicalize(root.join("wt")).expect("canonical worktree");
backdate(worktree.as_path());
git(worktree.as_path(), &["update-index", "--refresh"]);
return (parent, root, worktree);
}
fn run(platform: &dyn Platform, worktree: &Path, options: ConvertOptions, interrupt: &Interrupt) -> (Result<Verdict, MigrateError>, Vec<String>) {
let mut lines: Vec<String> = Vec::new();
let verdict = convert_worktree(platform, worktree, options, interrupt, &mut |text: &str| { lines.push(String::from(text)); });
return (verdict, lines);
}
fn admin_of(worktree: &Path) -> PathBuf {
return inspect(worktree).expect("registered").admin;
}
fn failure_of(verdict: Result<Verdict, MigrateError>) -> String {
return verdict.expect_err("the run fails").to_string();
}
fn skipped(verdict: Result<Verdict, MigrateError>) -> String {
return match verdict.expect("no failure") {
Verdict::Skipped(reasons) => reasons.join("\n"),
other => panic!("expected a skip, got {other:?}"),
};
}
#[test]
fn the_journal_never_writes_through_a_file_that_is_in_the_way() {
let (parent, _root, worktree) = quiet_worktree("safety-journal-tmp");
let victim: PathBuf = parent.join("victim");
std::fs::write(&victim, b"keep me").expect("victim");
std::os::unix::fs::symlink(&victim, admin_of(worktree.as_path()).join("cli-git-convert.tmp")).expect("link at the old name");
let platform: PlainPlatform = PlainPlatform::new();
let (verdict, _lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(verdict.expect("converted"), Verdict::Converted);
assert_eq!(std::fs::read(&victim).expect("victim"), b"keep me");
remove(parent.as_path());
}
#[test]
fn a_fresh_journal_never_replaces_one_that_exists() {
let (parent, _root, worktree) = quiet_worktree("safety-journal-new");
let admin: PathBuf = admin_of(worktree.as_path());
let made = Journal { phase: JournalPhase::Started, token: String::from("a"), path: b"/x".to_vec(), old_identity: ObjectId { device: 1, inode: 2, birth: Some((3, 4)) }, git_file: b"g".to_vec(), new_identity: None };
made.create_new(admin.as_path()).expect("first journal");
let other = Journal { token: String::from("b"), ..made.clone() };
assert!(other.create_new(admin.as_path()).is_err(), "the second one must not replace the first");
assert_eq!(Journal::load(admin.as_path()).expect("load"), Some(made));
let names: Vec<String> = std::fs::read_dir(&admin).expect("listing").flatten().map(|entry: std::fs::DirEntry| { return entry.file_name().to_string_lossy().into_owned(); }).collect();
assert!(names.iter().all(|name: &String| { return !name.contains(".tmp."); }), "no temporary file is left: {names:?}");
remove(parent.as_path());
}
#[test]
fn a_foreign_stage_is_never_synced_into_exchanged_or_destroyed() {
for point in ["journaled", "stage-unrecorded"] {
let (parent, _root, worktree) = quiet_worktree(format!("safety-foreign-{point}").as_str());
let platform: PlainPlatform = PlainPlatform::new();
let converting: PathBuf = converting_path(worktree.as_path());
let (killed, _lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::at(point, 0));
assert!(killed.is_err(), "{point}: the run was killed");
if point == "journaled" {
platform.create_subvolume(converting.as_path()).expect("a foreign subvolume");
}
std::fs::write(converting.join("theirs"), b"theirs").expect("their file");
let reasons: String = skipped(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(reasons.contains("exists") && reasons.contains("owns it"), "{point}: {reasons}");
assert_eq!(std::fs::read(converting.join("theirs")).expect("theirs"), b"theirs", "{point}: nothing was synced over it");
assert!(!platform.is_subvolume_root(worktree.as_path()).expect("examined"), "{point}: nothing was exchanged");
let (abandoned, lines) = run(&platform, worktree.as_path(), ConvertOptions { dry_run: false, abandon: true, command_line_attributes: false }, &Interrupt::never());
assert_eq!(abandoned.expect("abandoned"), Verdict::Abandoned, "{point}");
assert!(converting.join("theirs").exists(), "{point}: the abandon left the foreign directory: {lines:?}");
assert!(!admin_of(worktree.as_path()).join(JOURNAL_NAME).exists(), "{point}: the journal is gone");
remove(parent.as_path());
}
}
#[test]
fn a_replaced_installed_subvolume_is_not_accepted() {
let (parent, _root, worktree) = quiet_worktree("safety-replaced");
let platform: PlainPlatform = PlainPlatform::new();
let converting: PathBuf = converting_path(worktree.as_path());
let git_file: Vec<u8> = std::fs::read(worktree.join(".git")).expect(".git");
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("exchanged", 0)).0.is_err());
std::fs::rename(&worktree, parent.join("installed-aside")).expect("move the installed copy away");
platform.create_subvolume(worktree.as_path()).expect("another subvolume");
std::fs::write(worktree.join(".git"), git_file).expect("same .git bytes");
let message: String = failure_of(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(message.contains("not the subvolume this conversion created"), "{message}");
assert!(converting.join("README.md").exists(), "the old tree was not removed");
remove(parent.as_path());
}
#[test]
fn a_file_written_after_a_synced_kill_is_not_lost() {
let (parent, _root, worktree) = quiet_worktree("safety-synced");
let platform: PlainPlatform = PlainPlatform::new();
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("synced", 0)).0.is_err());
std::fs::write(worktree.join("late.txt"), b"written after the proof\n").expect("late file");
let (finished, lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(finished.expect("finished"), Verdict::Resumed, "{lines:#?}");
assert_eq!(std::fs::read(worktree.join("late.txt")).expect("late file"), b"written after the proof\n");
assert!(platform.is_subvolume_root(worktree.as_path()).expect("examined"));
remove(parent.as_path());
}
#[test]
fn a_difference_keeps_the_old_tree_and_nothing_is_copied() {
let (parent, _root, worktree) = quiet_worktree("safety-late");
let platform: PlainPlatform = PlainPlatform::new();
let converting: PathBuf = converting_path(worktree.as_path());
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("exchanged", 0)).0.is_err());
std::fs::write(worktree.join("README.md"), b"an edit made to the installed worktree\n").expect("edit of the installed copy");
std::fs::write(converting.join("README.md"), b"a later write to the old tree\n").expect("write to the old tree");
std::fs::write(converting.join("late.txt"), b"only in the old tree\n").expect("late file in the old tree");
let message: String = failure_of(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(message.contains("differs") && message.contains("kept"), "{message}");
assert_eq!(std::fs::read(worktree.join("README.md")).expect("installed"), b"an edit made to the installed worktree\n", "the installed edit is not overwritten");
assert!(!worktree.join("late.txt").exists(), "nothing was copied from the old tree");
assert!(converting.join("late.txt").exists(), "the old tree is kept whole");
std::fs::remove_file(converting.join("late.txt")).expect("settled");
std::fs::copy(worktree.join("README.md"), converting.join("README.md")).expect("settled");
let mtime = std::fs::metadata(worktree.join("README.md")).expect("metadata").modified().expect("mtime");
std::fs::OpenOptions::new().write(true).open(converting.join("README.md")).expect("open").set_modified(mtime).expect("same time");
let (finished, lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(finished.expect("finished"), Verdict::Resumed, "{lines:#?}");
assert!(std::fs::symlink_metadata(&converting).is_err(), "now the old tree is gone");
remove(parent.as_path());
}
#[test]
fn a_process_holding_the_old_tree_keeps_it() {
let (parent, _root, worktree) = quiet_worktree("safety-holder");
let platform: PlainPlatform = PlainPlatform::new();
let converting: PathBuf = converting_path(worktree.as_path());
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("refreshed", 0)).0.is_err());
let mut child: std::process::Child = std::process::Command::new("sleep").arg("30").current_dir(&converting).spawn().expect("sleep");
let message: String = failure_of(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(message.contains("kept because process"), "{message}");
assert!(converting.join("README.md").exists());
child.kill().expect("kill");
child.wait().expect("wait");
assert_eq!(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0.expect("finished"), Verdict::Resumed);
remove(parent.as_path());
}
#[test]
fn the_removal_follows_the_pinned_directory_not_the_name() {
let (parent, _root, worktree) = quiet_worktree("safety-pinned");
let platform: PlainPlatform = PlainPlatform::new();
let old: PathBuf = parent.join("old");
std::fs::create_dir(&old).expect("old");
let installed: PathBuf = parent.join("installed");
std::fs::create_dir(&installed).expect("installed");
let recorded: ObjectId = identity_at(&open_root(parent.as_path()).expect("held parent"), std::ffi::OsStr::new("old")).expect("identity");
let held = open_root(parent.as_path()).expect("held parent");
let moved: PathBuf = parent.join("moved");
let outcome = remove_recorded_old_tree_seamed(&platform, &held, std::ffi::OsStr::new("old"), recorded, installed.as_path(), &mut || {
std::fs::rename(&old, &moved).expect("move the old tree away");
std::fs::create_dir(&old).expect("another directory at the name");
std::fs::write(old.join("theirs"), b"theirs").expect("theirs");
})
.expect("no failure");
assert!(matches!(outcome, Removal::Kept(_)), "{outcome:?}");
assert_eq!(std::fs::read(old.join("theirs")).expect("theirs"), b"theirs", "the directory that took the name is untouched");
drop(worktree);
remove(parent.as_path());
}
#[test]
fn a_nested_registration_is_found_in_a_bare_layout() {
let (parent, root) = checkout_with_worktree("safety-bare");
let bare: PathBuf = parent.join("r.git");
git(parent.as_path(), &["clone", "--quiet", "--bare", root.to_str().expect("UTF-8"), bare.to_str().expect("UTF-8")]);
git(bare.as_path(), &["worktree", "add", "--quiet", "--detach", "../outer"]);
let outer: PathBuf = std::fs::canonicalize(parent.join("outer")).expect("canonical outer");
backdate(outer.as_path());
git(outer.as_path(), &["update-index", "--refresh"]);
git(bare.as_path(), &["worktree", "add", "--quiet", "--detach", "../outer/inner"]);
let platform: PlainPlatform = PlainPlatform::new();
let reasons: String = skipped(run(&platform, outer.as_path(), ConvertOptions { dry_run: true, abandon: false, command_line_attributes: false }, &Interrupt::never()).0);
assert!(reasons.contains("registered inside it") && reasons.contains("inner"), "{reasons}");
remove(parent.as_path());
}
#[test]
fn a_nested_worktree_named_like_the_stage_still_stops_a_resume() {
let (parent, root, worktree) = quiet_worktree("safety-suffix");
let platform: PlainPlatform = PlainPlatform::new();
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("journaled", 0)).0.is_err());
git(root.as_path(), &["worktree", "add", "--quiet", "--detach", worktree.join(format!("inner{CONVERTING_SUFFIX}-backup")).to_str().expect("UTF-8")]);
let reasons: String = skipped(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(reasons.contains("registered inside it") && reasons.contains(CONVERTING_SUFFIX), "{reasons}");
remove(parent.as_path());
}
#[test]
fn a_failed_index_refresh_keeps_the_old_tree() {
let (parent, _root, worktree) = quiet_worktree("safety-index");
let platform: PlainPlatform = PlainPlatform::new();
let converting: PathBuf = converting_path(worktree.as_path());
let index: PathBuf = admin_of(worktree.as_path()).join("index");
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("exchanged", 0)).0.is_err());
std::fs::set_permissions(&index, std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("make the index unreadable");
let message: String = failure_of(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0);
assert!(message.contains("not refreshed") && message.contains("kept"), "{message}");
assert!(converting.join("README.md").exists(), "the old tree is kept");
assert!(admin_of(worktree.as_path()).join(JOURNAL_NAME).exists(), "the journal is kept");
std::fs::set_permissions(&index, std::os::unix::fs::PermissionsExt::from_mode(0o644)).expect("restore");
assert_eq!(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0.expect("finished"), Verdict::Resumed);
assert!(std::fs::symlink_metadata(&converting).is_err());
remove(parent.as_path());
}
fn finishes<T: Send + 'static>(what: &str, work: impl FnOnce() -> T + Send + 'static) -> T {
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _sent = sender.send(work());
});
return receiver.recv_timeout(PATIENCE).unwrap_or_else(|_error| { panic!("{what} did not finish within {PATIENCE:?}") });
}
const OWNER_ONLY: libc::mode_t = 0o600;
const SEARCH_WITHOUT_LISTING: u32 = 0o311;
const LISTABLE: u32 = 0o755;
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(20);
fn make_pipe(path: &Path) {
let name = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())).expect("path text");
let result: libc::c_int = unsafe { libc::mkfifo(name.as_ptr(), OWNER_ONLY) };
assert_eq!(result, 0, "mkfifo {}", path.display());
}
#[test]
fn an_unreadable_list_of_registrations_is_a_refusal() {
if unsafe { libc::geteuid() } == 0 {
return;
}
let (parent, _root, worktree) = quiet_worktree("safety-registrations");
let platform: PlainPlatform = PlainPlatform::new();
let registrations: PathBuf = inspect(worktree.as_path()).expect("registered").common.join("worktrees");
std::fs::set_permissions(®istrations, std::os::unix::fs::PermissionsExt::from_mode(SEARCH_WITHOUT_LISTING)).expect("search without listing");
let reasons: String = skipped(run(&platform, worktree.as_path(), ConvertOptions { dry_run: true, abandon: false, command_line_attributes: false }, &Interrupt::never()).0);
std::fs::set_permissions(®istrations, std::os::unix::fs::PermissionsExt::from_mode(LISTABLE)).expect("restore");
assert!(reasons.contains("cannot be listed"), "{reasons}");
remove(parent.as_path());
}
#[test]
fn a_worktree_moved_during_an_unfinished_conversion_is_refused() {
let (parent, root, worktree) = quiet_worktree("safety-moved");
let platform: PlainPlatform = PlainPlatform::new();
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("synced", 0)).0.is_err());
let moved: PathBuf = worktree.with_file_name("wt-moved");
git(root.as_path(), &["worktree", "move", worktree.to_str().expect("UTF-8"), moved.to_str().expect("UTF-8")]);
for dry_run in [false, true] {
let message: String = failure_of(run(&platform, moved.as_path(), ConvertOptions { dry_run, abandon: false, command_line_attributes: false }, &Interrupt::never()).0);
assert!(message.contains("moved") && message.contains(worktree.to_str().expect("UTF-8")), "dry run {dry_run}: {message}");
}
assert!(!platform.is_subvolume_root(moved.as_path()).expect("examined"), "nothing was exchanged");
remove(parent.as_path());
}
#[test]
fn a_named_pipe_in_the_place_of_a_file_does_not_block() {
let (parent, _root, worktree) = quiet_worktree("safety-pipe-journal");
let admin: PathBuf = admin_of(worktree.as_path());
make_pipe(admin.join(JOURNAL_NAME).as_path());
let at: PathBuf = worktree.clone();
let outcome = finishes("a run with a pipe for a journal", move || { return run(&PlainPlatform::new(), at.as_path(), RUN, &Interrupt::never()).0; });
assert!(outcome.is_err(), "{outcome:?}");
std::fs::remove_file(admin.join(JOURNAL_NAME)).expect("remove the pipe");
let (other, _other_root, second) = quiet_worktree("safety-pipe-git");
std::fs::remove_file(second.join(".git")).expect("remove the pointer");
make_pipe(second.join(".git").as_path());
let there: PathBuf = second.clone();
let skipped_run = finishes("a run with a pipe for .git", move || { return run(&PlainPlatform::new(), there.as_path(), RUN, &Interrupt::never()).0; });
assert!(matches!(skipped_run, Ok(Verdict::Skipped(_))), "{skipped_run:?}");
remove(parent.as_path());
remove(other.as_path());
}
#[test]
fn a_dry_run_sees_an_exchange_the_journal_has_not_recorded() {
let (parent, _root, worktree) = quiet_worktree("safety-dry-exchanged");
let platform: PlainPlatform = PlainPlatform::new();
assert!(run(&platform, worktree.as_path(), RUN, &Interrupt::at("exchanged", 0)).0.is_err());
let (verdict, lines) = run(&platform, worktree.as_path(), ConvertOptions { dry_run: true, abandon: false, command_line_attributes: false }, &Interrupt::never());
assert_eq!(verdict.expect("no failure"), Verdict::Resumed, "{lines:#?}");
let journal: Journal = Journal::load(admin_of(worktree.as_path()).as_path()).expect("load").expect("journal");
assert_eq!(journal.phase, JournalPhase::Synced, "a dry run writes nothing");
remove(parent.as_path());
}
#[test]
fn the_old_root_is_recorded_with_its_birth_time_and_compared_with_it() {
let (parent, _root, worktree) = quiet_worktree("safety-birth");
let admin: PathBuf = admin_of(worktree.as_path());
let identity = ObjectId { device: 1, inode: 2, birth: Some((3, 4)) };
let made = Journal { phase: JournalPhase::Started, token: String::from("a"), path: b"/x".to_vec(), old_identity: identity, git_file: b"g".to_vec(), new_identity: None };
made.save(admin.as_path()).expect("saved");
assert_eq!(Journal::load(admin.as_path()).expect("load"), Some(made));
let legacy: String = format!("cli-git-convert v1\nphase=started\ntoken=a\npath=2f78\nold_device=5\nold_inode=6\ngit_file=67\nnew_identity=-\n");
std::fs::write(admin.join(JOURNAL_NAME), legacy).expect("legacy journal");
let read = Journal::load(admin.as_path()).expect("load").expect("journal");
assert_eq!(read.old_identity, ObjectId { device: 5, inode: 6, birth: None });
let found = ObjectId { device: 5, inode: 6, birth: Some((7, 8)) };
assert!(is_recorded_object(&read.old_identity, &found), "a journal without a birth time compares device and inode");
assert!(is_recorded_object(&found, &found));
assert!(!is_recorded_object(&found, &ObjectId { birth: Some((9, 8)), ..found }), "another birth time is another directory");
assert!(!is_recorded_object(&found, &ObjectId { birth: None, ..found }), "a missing birth time does not match a recorded one");
remove(parent.as_path());
}
#[test]
fn a_directory_with_another_birth_time_is_not_removed() {
let (parent, _root, worktree) = quiet_worktree("safety-birth-removal");
let platform: PlainPlatform = PlainPlatform::new();
let old: PathBuf = parent.join("old");
std::fs::create_dir(&old).expect("old");
let held = open_root(parent.as_path()).expect("held parent");
let real: ObjectId = identity_at(&held, std::ffi::OsStr::new("old")).expect("identity");
let other_birth: ObjectId = ObjectId { birth: Some((0, 0)), ..real };
assert_ne!(Some((0, 0)), real.birth, "the control needs a real birth time to differ from");
let outcome = remove_recorded_old_tree(&platform, &held, std::ffi::OsStr::new("old"), other_birth, worktree.as_path()).expect("no failure");
assert!(matches!(outcome, Removal::Kept(_)), "{outcome:?}");
assert!(old.exists(), "nothing was removed");
remove(parent.as_path());
}