use super::*;
use crate::fs_kind::FilesystemKind;
use crate::subvolume_arguments::MigrateArguments;
use crate::subvolume_error::Interrupt;
use crate::subvolume_platform::Platform;
use crate::subvolume_preflight::layout_of;
use crate::subvolume_test_support::{Hooked, PlainPlatform, checkout_with_worktree, index_inode, migrate_arguments, run_command};
use crate::test_support::{git_text, remove};
use std::os::unix::fs::MetadataExt;
const DONE: i32 = 0;
const REFUSED: i32 = 1;
const FAILED: i32 = 2;
fn running_as_root() -> bool {
return unsafe { libc::geteuid() } == 0;
}
fn assert_same_tree(original: &Path, copy: &Path) {
for path in ["README.md", "src/main.rs", "src/deep/er/leaf.txt", "empty", "script.sh", ".gitignore", "ignored/node_modules/pkg/index.js"] {
assert_eq!(std::fs::read(original.join(path)).expect("original"), std::fs::read(copy.join(path)).expect("copy"), "{path}");
let (left, right) = (std::fs::metadata(original.join(path)).expect("original"), std::fs::metadata(copy.join(path)).expect("copy"));
assert_eq!(left.mode(), right.mode(), "{path} mode");
assert_eq!((left.mtime(), left.mtime_nsec()), (right.mtime(), right.mtime_nsec()), "{path} modification time");
}
assert_eq!(std::fs::read_link(copy.join("link")).expect("link"), PathBuf::from("src/main.rs"));
assert_eq!(std::fs::metadata(copy.join("src/main.rs")).expect("main").ino(), std::fs::metadata(copy.join("ignored/main-link.rs")).expect("link").ino(), "hard links stay one file");
}
fn assert_migrated(platform: &PlainPlatform, root: &Path, old: &Path) {
assert_eq!(platform.is_subvolume_root(root), Ok(true));
assert_same_tree(old, root);
for path in ["README.md", "src/main.rs", "script.sh"] {
assert_eq!(index_inode(root, path), std::fs::metadata(root.join(path)).expect("file").ino(), "{path} index inode");
}
assert_eq!(git_text(root, &["status", "--porcelain"]), "", "the first status finds nothing dirty");
assert!(!root.join(".git/index.lock").exists());
assert!(!old.join(".git/index.lock").exists());
}
#[test]
fn a_checkout_with_a_linked_worktree_is_migrated_and_stays_clean() {
let (parent, root) = checkout_with_worktree("migrate-happy");
let platform: PlainPlatform = PlainPlatform::new();
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
assert_migrated(&platform, root.as_path(), layout.old_path.as_path());
assert_eq!(platform.is_subvolume_root(root.join("wt").as_path()), Ok(true), "the linked worktree is a nested subvolume");
assert_eq!(git_text(root.join("wt").as_path(), &["status", "--porcelain"]), "");
assert!(git_text(root.as_path(), &["worktree", "list"]).contains(root.join("wt").to_str().expect("path")));
assert!(layout.old_path.join("README.md").exists(), "the old tree is kept");
assert!(layout.journal_path.exists() && !layout.new_path.exists());
let text: String = lines.join("\n");
for expected in ["first delta", "last delta (under the lock)", "catch-up", "index stat rewrite", "is now a btrfs subvolume", "--remove-old", "--rollback"] {
assert!(text.contains(expected), "{expected} missing from {text}");
}
remove(parent.as_path());
}
#[test]
fn the_dry_run_changes_nothing() {
let (parent, root) = checkout_with_worktree("migrate-dry");
let platform: PlainPlatform = PlainPlatform::new();
let arguments: MigrateArguments = MigrateArguments { dry_run: true, ..migrate_arguments() };
let (code, lines) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
assert!(!layout.new_path.exists() && !layout.old_path.exists() && !layout.journal_path.exists());
assert_eq!(platform.is_subvolume_root(root.as_path()), Ok(false));
let text: String = lines.join("\n");
assert!(text.contains("dry run complete") && text.contains("linked worktrees inside the checkout that become nested subvolumes: 1"), "{text}");
remove(parent.as_path());
}
#[test]
fn a_filesystem_other_than_btrfs_is_refused_with_its_name() {
let (parent, root) = checkout_with_worktree("migrate-not-btrfs");
for (kind, word) in [(FilesystemKind::Overlayfs, "overlayfs"), (FilesystemKind::Other, "a filesystem other than btrfs")] {
let mut platform: PlainPlatform = PlainPlatform::new();
platform.kind = kind;
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED);
let text: String = lines.join("\n");
assert!(text.contains(word) && text.contains("What to do:") && text.contains("Nothing was changed."), "{text}");
}
let layout = layout_of(root.as_path()).expect("layout");
assert!(!layout.new_path.exists() && !layout.journal_path.exists());
remove(parent.as_path());
}
#[test]
fn an_already_migrated_checkout_is_reported_and_left_alone() {
let (parent, root) = checkout_with_worktree("migrate-again");
let platform: PlainPlatform = PlainPlatform::new();
assert_eq!(run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never()).0, DONE);
let (again_code, again_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(again_code, DONE);
let text: String = again_lines.join("\n");
assert!(text.contains("already a btrfs subvolume") && text.contains("--remove-old"), "{text}");
let layout = layout_of(root.as_path()).expect("layout");
std::fs::remove_file(layout.journal_path.as_path()).expect("forget the journal");
let (forgotten_code, forgotten_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(forgotten_code, DONE, "{forgotten_lines:#?}");
assert!(forgotten_lines.join("\n").contains("already a btrfs subvolume"));
remove(parent.as_path());
}
#[test]
fn a_rerun_after_a_simulated_kill_at_every_step_completes() {
let points: [(&str, u64); 12] = [
("after-create", 0),
("after-nested", 0),
("copy-entry", 0),
("copy-entry", 5),
("after-copy", 0),
("after-delta1", 0),
("after-lock", 0),
("before-exchange", 0),
("after-exchange", 0),
("after-rename", 0),
("after-catchup", 0),
("after-indexes", 0),
];
for (index, (point, after)) in points.iter().enumerate() {
let (parent, root) = checkout_with_worktree(format!("migrate-kill-{index}").as_str());
let platform: PlainPlatform = PlainPlatform::new();
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at(point, *after));
assert_eq!(code, FAILED, "{point}: {lines:#?}");
assert!(lines.join("\n").contains("Run the command again to resume"), "{point}");
let (resumed_code, resumed_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(resumed_code, DONE, "{point}: {resumed_lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
assert_migrated(&platform, root.as_path(), layout.old_path.as_path());
assert_eq!(platform.is_subvolume_root(root.join("wt").as_path()), Ok(true), "{point}: nested worktree");
assert!(!layout.new_path.exists(), "{point}: the new path is renamed away");
remove(parent.as_path());
}
}
#[test]
fn unreadable_files_stay_in_the_old_tree_and_are_listed() {
if running_as_root() {
return;
}
let (parent, root) = checkout_with_worktree("migrate-unreadable-file");
std::fs::write(root.join("ignored/root-owned.log"), b"secret").expect("log");
std::fs::set_permissions(root.join("ignored/root-owned.log"), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unreadable");
std::fs::write(root.join("wt/ignored-log"), b"secret").expect("worktree log");
std::fs::set_permissions(root.join("wt/ignored-log"), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unreadable");
let platform: PlainPlatform = PlainPlatform::new();
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
assert!(!root.join("ignored/root-owned.log").exists() && layout.old_path.join("ignored/root-owned.log").exists());
let list: String = std::fs::read_to_string(layout.unreadable_path.as_path()).expect("the list");
assert!(list.lines().any(|line| { return line == "ignored/root-owned.log"; }) && list.lines().any(|line| { return line == "wt/ignored-log"; }), "{list}");
let text: String = lines.join("\n");
assert!(text.contains("may not read") && text.contains("exist only in the old tree"), "{text}");
assert_eq!(git_text(root.as_path(), &["status", "--porcelain"]), "");
remove(parent.as_path());
}
#[test]
fn an_unreadable_directory_stops_the_run_before_the_exchange() {
if running_as_root() {
return;
}
let (parent, root) = checkout_with_worktree("migrate-unreadable-dir");
std::fs::create_dir(root.join("ignored/locked")).expect("directory");
std::fs::write(root.join("ignored/locked/file"), b"x").expect("file");
std::fs::set_permissions(root.join("ignored/locked"), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unlistable");
let platform: PlainPlatform = PlainPlatform::new();
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
std::fs::set_permissions(root.join("ignored/locked"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("restore");
assert_eq!(code, FAILED, "{lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("ignored/locked") && text.contains("cannot be listed or searched") && text.contains("--rollback"), "{text}");
assert_eq!(platform.is_subvolume_root(root.as_path()), Ok(false), "the checkout was not exchanged");
assert!(root.join("README.md").exists());
remove(parent.as_path());
}
#[test]
fn a_write_to_the_old_tree_after_the_exchange_is_carried_over() {
let (parent, root) = checkout_with_worktree("migrate-catchup");
let platform: PlainPlatform = PlainPlatform::new();
let (stopped_code, _stopped_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at("after-rename", 0));
assert_eq!(stopped_code, FAILED);
let layout = layout_of(root.as_path()).expect("layout");
std::fs::write(layout.old_path.join("late.txt"), b"written by a straggler\n").expect("late file");
let (resumed_code, resumed_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(resumed_code, DONE, "{resumed_lines:#?}");
assert_eq!(std::fs::read(root.join("late.txt")).expect("carried over"), b"written by a straggler\n");
remove(parent.as_path());
}
#[test]
fn the_index_rewrite_can_be_switched_off_and_then_the_inodes_differ() {
let (parent, root) = checkout_with_worktree("migrate-no-refresh");
let platform: PlainPlatform = PlainPlatform::new();
let arguments: MigrateArguments = MigrateArguments { refresh_indexes: false, ..migrate_arguments() };
let (code, lines) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_ne!(index_inode(root.as_path(), "README.md"), std::fs::metadata(root.join("README.md")).expect("file").ino());
remove(parent.as_path());
}
#[test]
fn the_nested_worktree_switch_keeps_worktrees_ordinary() {
let (parent, root) = checkout_with_worktree("migrate-no-nested");
let platform: PlainPlatform = PlainPlatform::new();
let arguments: MigrateArguments = MigrateArguments { nested_worktrees: false, ..migrate_arguments() };
let (code, lines) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_eq!(platform.is_subvolume_root(root.as_path()), Ok(true));
assert_eq!(platform.is_subvolume_root(root.join("wt").as_path()), Ok(false));
assert!(root.join("wt/README.md").exists());
remove(parent.as_path());
}
#[test]
fn a_process_inside_the_checkout_is_named_in_the_warning() {
let (parent, root) = checkout_with_worktree("migrate-process-warning");
let mut child: std::process::Child = crate::test_support::spawn_owned(std::process::Command::new("sleep").arg("60").current_dir(root.as_path())).expect("sleep");
let platform: PlainPlatform = PlainPlatform::new();
let arguments: MigrateArguments = MigrateArguments { dry_run: true, ..migrate_arguments() };
let (code, lines) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
let pid: String = child.id().to_string();
child.kill().expect("stop the sleeper");
child.wait().expect("reap the sleeper");
assert_eq!(code, DONE);
let text: String = lines.join("\n");
assert!(text.contains("WARNING") && text.contains(format!("pid {pid} (sleep)").as_str()), "{text}");
remove(parent.as_path());
}
fn lock_taker(lock: PathBuf) -> Hooked {
return Hooked::new(Box::new(move |call: u32| {
if call == 0 {
std::fs::write(lock.as_path(), b"git add is writing here\n").expect("the foreign lock");
}
}));
}
#[test]
fn an_index_lock_taken_by_another_git_command_after_the_preflight_stops_the_migration() {
for relative in [".git/index.lock", ".git/worktrees/wt/index.lock"] {
let (parent, root) = checkout_with_worktree(format!("migrate-foreign-lock-{}", relative.replace('/', "-")).as_str());
let platform: Hooked = lock_taker(root.join(relative));
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, FAILED, "{relative}: {lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("is not this migration's lock") && text.contains("index.lock"), "{relative}: {text}");
let layout = layout_of(root.as_path()).expect("layout");
assert_eq!(platform.inner.is_subvolume_root(root.as_path()), Ok(false), "{relative}: the trees were not exchanged");
assert!(!layout.old_path.exists(), "{relative}");
assert_eq!(std::fs::read(root.join(relative)).expect("foreign lock kept"), b"git add is writing here\n", "{relative}");
let (refused, _lines) = run_command(&platform.inner, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(refused, REFUSED, "{relative}: the preflight refuses while the other command's lock stands");
std::fs::remove_file(root.join(relative)).expect("the other command finishes");
let (done, lines2) = run_command(&platform.inner, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(done, DONE, "{relative}: {lines2:#?}");
assert_migrated(&platform.inner, root.as_path(), layout.old_path.as_path());
remove(parent.as_path());
}
}
#[test]
fn the_new_checkout_holds_the_index_locks_until_the_indexes_are_rewritten() {
let main: &str = ".git/index.lock";
let linked: &str = ".git/worktrees/wt/index.lock";
for point in ["after-exchange", "after-catchup", "after-indexes"] {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree(format!("migrate-held-{point}").as_str());
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at(point, 0));
assert_eq!(code, FAILED, "{point}: {lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
for lock in [main, linked] {
let content: Vec<u8> = std::fs::read(root.join(lock)).unwrap_or_default();
assert!(content.starts_with(b"cli-git migrate-to-subvolume"), "{point}: {lock} is held by the migration in the new checkout");
}
if point != "after-exchange" {
assert!(!layout.old_path.join(main).exists() && !layout.old_path.join(linked).exists(), "{point}: the old tree's locks are dropped");
}
let (code2, lines2) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{point}: {lines2:#?}");
assert_migrated(&platform, root.as_path(), layout.old_path.as_path());
assert!(!root.join(linked).exists() && !layout.old_path.join(linked).exists(), "{point}");
remove(parent.as_path());
}
}
#[test]
fn the_linked_worktrees_index_locks_are_held_in_both_trees_before_the_exchange() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("migrate-worktree-lock");
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at("before-exchange", 0));
assert_eq!(code, FAILED, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
for tree in [root.as_path(), layout.new_path.as_path()] {
for lock in [".git/index.lock", ".git/worktrees/wt/index.lock"] {
assert!(tree.join(lock).exists(), "{} holds {lock}", tree.display());
}
}
remove(parent.as_path());
}