use super::*;
use crate::subvolume_test_support::{Hooked, PlainPlatform, backdate, checkout_with_worktree, index_inode};
use crate::test_support::{git, git_text, remove};
use std::os::unix::fs::MetadataExt;
const RUN: ConvertOptions = ConvertOptions { dry_run: false, abandon: false };
fn quiet_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf) {
let (parent, root) = checkout_with_worktree(name);
let worktree: PathBuf = root.join("wt");
backdate(worktree.as_path());
git(worktree.as_path(), &["update-index", "--refresh"]);
return (parent, root, std::fs::canonicalize(worktree).expect("canonical 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 run_quiet(platform: &dyn Platform, worktree: &Path, options: ConvertOptions, interrupt: &Interrupt) -> Result<Verdict, MigrateError> {
return run(platform, worktree, options, interrupt).0;
}
fn admin_of(worktree: &Path) -> PathBuf {
return inspect(worktree).expect("registered").admin;
}
fn assert_nothing_left(worktree: &Path) {
assert!(std::fs::symlink_metadata(converting_path(worktree)).is_err(), "the converting directory is gone");
let admin: PathBuf = admin_of(worktree);
assert!(!admin.join(JOURNAL_NAME).exists(), "the journal is gone");
assert!(!admin.join("index.lock").exists(), "the lock is released");
}
fn assert_converted(platform: &PlainPlatform, root: &Path, worktree: &Path, git_file: &[u8]) {
assert!(platform.is_subvolume_root(worktree).expect("examined"));
assert_eq!(std::fs::read(worktree.join(".git")).expect(".git"), git_file, "the .git file is byte for byte the same");
assert_eq!(std::fs::read(worktree.join("README.md")).expect("readme"), b"readme\n");
assert_eq!(std::fs::read(worktree.join("src/deep/er/leaf.txt")).expect("leaf").len(), 70_000);
assert_eq!(std::fs::read_link(worktree.join("link")).expect("link"), PathBuf::from("src/main.rs"));
assert_eq!(std::fs::metadata(worktree.join("script.sh")).expect("script").mode() & 0o777, 0o755);
assert_eq!(git_text(worktree, &["status", "--porcelain"]), "", "Git sees a clean worktree");
assert!(git_text(root, &["worktree", "list", "--porcelain"]).contains(worktree.to_string_lossy().as_ref()), "still registered");
assert_eq!(index_inode(worktree, "src/main.rs"), std::fs::metadata(worktree.join("src/main.rs")).expect("main").ino(), "the index describes the new file");
assert_nothing_left(worktree);
}
#[test]
fn a_worktree_is_converted_in_place() {
let (parent, root, worktree) = quiet_worktree("convert-whole");
let platform: PlainPlatform = PlainPlatform::new();
let git_file: Vec<u8> = std::fs::read(worktree.join(".git")).expect(".git");
let old_inode: u64 = std::fs::metadata(&worktree).expect("old").ino();
std::fs::write(parent.join("foreign"), b"keep").expect("foreign file");
let (verdict, lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(verdict.expect("converted"), Verdict::Converted, "{lines:#?}");
assert_converted(&platform, root.as_path(), worktree.as_path(), git_file.as_slice());
assert_ne!(std::fs::metadata(&worktree).expect("new").ino(), old_inode, "the path names a new directory");
assert_eq!(std::fs::read(parent.join("foreign")).expect("foreign"), b"keep", "nothing foreign was touched");
let again = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(again.expect("skipped"), Verdict::AlreadySubvolume);
remove(parent.as_path());
}
#[test]
fn a_dry_run_changes_nothing() {
let (parent, _root, worktree) = quiet_worktree("convert-dry");
let platform: PlainPlatform = PlainPlatform::new();
let verdict = run_quiet(&platform, worktree.as_path(), ConvertOptions { dry_run: true, abandon: false }, &Interrupt::never());
assert_eq!(verdict.expect("dry"), Verdict::WouldConvert);
assert!(!platform.is_subvolume_root(worktree.as_path()).expect("examined"));
assert!(!admin_of(worktree.as_path()).join(JOURNAL_NAME).exists());
assert!(!admin_of(worktree.as_path()).join("index.lock").exists());
assert!(std::fs::symlink_metadata(converting_path(worktree.as_path())).is_err());
remove(parent.as_path());
}
fn skipped_with(verdict: Result<Verdict, MigrateError>, fragment: &str) {
match verdict.expect("no failure") {
Verdict::Skipped(reasons) => assert!(reasons.iter().any(|reason: &String| { return reason.contains(fragment); }), "{reasons:?} should mention {fragment}"),
other => panic!("expected a skip mentioning {fragment}, got {other:?}"),
}
}
#[test]
fn each_refusal_leaves_the_worktree_untouched() {
let (parent, root, worktree) = quiet_worktree("convert-refusals");
let platform: PlainPlatform = PlainPlatform::new();
let admin: PathBuf = admin_of(worktree.as_path());
let untouched = |checked: &PlainPlatform| {
assert!(!checked.is_subvolume_root(worktree.as_path()).expect("examined"));
assert!(!admin.join(JOURNAL_NAME).exists(), "no journal was written");
};
git(root.as_path(), &["worktree", "lock", worktree.to_str().expect("utf8")]);
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "locked");
git(root.as_path(), &["worktree", "unlock", worktree.to_str().expect("utf8")]);
untouched(&platform);
std::fs::write(admin.join("index.lock"), b"another git").expect("lock");
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "index.lock");
assert_eq!(std::fs::read(admin.join("index.lock")).expect("lock"), b"another git", "the foreign lock is not removed");
std::fs::remove_file(admin.join("index.lock")).expect("remove lock");
std::fs::create_dir(converting_path(worktree.as_path())).expect("foreign directory");
std::fs::write(converting_path(worktree.as_path()).join("theirs"), b"x").expect("theirs");
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, CONVERTING_SUFFIX);
assert!(converting_path(worktree.as_path()).join("theirs").exists(), "the foreign directory is left alone");
std::fs::remove_dir_all(converting_path(worktree.as_path())).expect("remove foreign");
let mut child: std::process::Child = std::process::Command::new("sleep").arg("30").current_dir(&worktree).spawn().expect("sleep");
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "working directory or an open file");
child.kill().expect("kill");
child.wait().expect("wait");
std::fs::write(worktree.join("README.md"), b"changed\n").expect("modify");
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "not quiet and unmodified");
untouched(&platform);
std::fs::write(worktree.join("README.md"), b"readme\n").expect("restore");
backdate(worktree.as_path());
git(worktree.as_path(), &["update-index", "--refresh"]);
git(worktree.as_path(), &["worktree", "add", "--quiet", "--detach", "inner"]);
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "registered inside it");
untouched(&platform);
remove(parent.as_path());
}
#[test]
fn a_kill_at_every_phase_is_finished_by_a_rerun() {
for point in ["journaled", "created", "verified", "exchanged", "refreshed", "removed"] {
let (parent, root, worktree) = quiet_worktree(format!("convert-kill-{point}").as_str());
let platform: PlainPlatform = PlainPlatform::new();
let git_file: Vec<u8> = std::fs::read(worktree.join(".git")).expect(".git");
std::fs::write(parent.join("foreign"), b"keep").expect("foreign file");
let killed = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::at(point, 0));
assert_eq!(killed.expect_err("killed"), MigrateError::Interrupted(String::from(point)), "{point}");
let (resumed, lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(resumed.expect("finished"), Verdict::Resumed, "{point}: {lines:#?}");
assert_converted(&platform, root.as_path(), worktree.as_path(), git_file.as_slice());
assert_eq!(std::fs::read(parent.join("foreign")).expect("foreign"), b"keep", "{point}");
remove(parent.as_path());
}
}
#[test]
fn abandon_undoes_before_the_swap_and_refuses_after() {
let (parent, _root, worktree) = quiet_worktree("convert-abandon");
let platform: PlainPlatform = PlainPlatform::new();
let killed = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::at("verified", 0));
assert!(killed.is_err());
assert!(std::fs::symlink_metadata(converting_path(worktree.as_path())).is_ok(), "the copy exists");
let undone = run_quiet(&platform, worktree.as_path(), ConvertOptions { dry_run: false, abandon: true }, &Interrupt::never());
assert_eq!(undone.expect("abandoned"), Verdict::Abandoned);
assert_nothing_left(worktree.as_path());
assert!(!platform.is_subvolume_root(worktree.as_path()).expect("examined"));
assert_eq!(std::fs::read(worktree.join("README.md")).expect("original intact"), b"readme\n");
let nothing = run_quiet(&platform, worktree.as_path(), ConvertOptions { dry_run: false, abandon: true }, &Interrupt::never());
skipped_with(nothing, "nothing to abandon");
let past = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::at("exchanged", 0));
assert!(past.is_err());
let refused = run_quiet(&platform, worktree.as_path(), ConvertOptions { dry_run: false, abandon: true }, &Interrupt::never());
skipped_with(refused, "past the swap");
assert!(std::fs::symlink_metadata(converting_path(worktree.as_path())).is_ok(), "the old tree is still there");
let finished = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(finished.expect("finished"), Verdict::Resumed);
remove(parent.as_path());
}
#[test]
fn a_directory_that_is_not_the_recorded_old_tree_is_never_removed() {
let (parent, _root, worktree) = quiet_worktree("convert-foreign-old");
let platform: PlainPlatform = PlainPlatform::new();
let killed = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::at("refreshed", 0));
assert!(killed.is_err());
let converting: PathBuf = converting_path(worktree.as_path());
let kept: PathBuf = parent.join("old-tree-kept");
std::fs::rename(&converting, &kept).expect("move the old tree away");
std::fs::create_dir(&converting).expect("foreign directory at the name");
std::fs::write(converting.join("theirs"), b"x").expect("theirs");
let (finished, lines) = run(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(finished.expect("finished"), Verdict::Resumed);
assert!(lines.iter().any(|line: &String| { return line.contains("left alone"); }), "{lines:#?}");
assert!(converting.join("theirs").exists(), "the foreign directory is untouched");
assert!(kept.join("README.md").exists(), "the old tree is untouched");
remove(parent.as_path());
}
#[test]
fn a_process_inside_stops_the_swap_of_a_resumed_conversion() {
let (parent, _root, worktree) = quiet_worktree("convert-process");
let platform: PlainPlatform = PlainPlatform::new();
let killed = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::at("verified", 0));
assert!(killed.is_err());
let mut child: std::process::Child = std::process::Command::new("sleep").arg("30").current_dir(&worktree).spawn().expect("sleep");
skipped_with(run(&platform, worktree.as_path(), RUN, &Interrupt::never()).0, "working directory or an open file");
assert!(!platform.is_subvolume_root(worktree.as_path()).expect("examined"), "nothing was swapped");
child.kill().expect("kill");
child.wait().expect("wait");
let finished = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(finished.expect("finished"), Verdict::Resumed);
remove(parent.as_path());
}
#[test]
fn a_change_during_the_copy_is_caught_before_the_swap() {
let (parent, _root, worktree) = quiet_worktree("convert-change");
let watched: PathBuf = worktree.clone();
let platform: Hooked = Hooked::new(Box::new(move |call: u32| {
if call == 3 {
for name in ["README.md", "src/main.rs", "script.sh", "empty"] {
std::fs::write(watched.join(name), b"rewritten while copying\n").expect("rewrite");
}
}
}));
let verdict = run_quiet(&platform, worktree.as_path(), RUN, &Interrupt::never());
let message: String = verdict.expect_err("the proving pass refuses").to_string();
assert!(message.contains("changed while it was copied"), "{message}");
assert!(!platform.is_subvolume_root(worktree.as_path()).expect("examined"), "nothing was swapped");
remove(parent.as_path());
}
#[test]
fn a_real_conversion_makes_a_subvolume_and_clones_file_data() {
use crate::fs_btrfs_test_support::btrfs_fixture;
use crate::subvolume_platform::LinuxPlatform;
let Some(probe) = btrfs_fixture("convert-real") else { return };
remove(probe.as_path());
let (parent, root, worktree) = quiet_worktree("convert-real");
let git_file: Vec<u8> = std::fs::read(worktree.join(".git")).expect(".git");
let (verdict, lines) = run(&LinuxPlatform, worktree.as_path(), RUN, &Interrupt::never());
assert_eq!(verdict.expect("converted"), Verdict::Converted, "{lines:#?}");
assert_eq!(std::fs::metadata(&worktree).expect("new").ino(), crate::fs_kind::BTRFS_SUBVOLUME_ROOT_INODE);
let copied: &String = lines.iter().find(|line: &&String| { return line.contains("copied "); }).expect("the copy line");
assert!(!copied.contains(", 0 cloned"), "file data was cloned: {copied}");
assert_eq!(std::fs::read(worktree.join(".git")).expect(".git"), git_file);
assert_eq!(git_text(worktree.as_path(), &["status", "--porcelain"]), "");
assert!(git_text(root.as_path(), &["worktree", "list", "--porcelain"]).contains(worktree.to_string_lossy().as_ref()));
assert_nothing_left(worktree.as_path());
remove(parent.as_path());
}