use super::*;
use crate::fs_primitive_error::PrimitiveError;
use crate::parallel_test_support::Rendezvous;
use crate::subvolume_test_support::{PlainPlatform, backdate, make_checkout};
use crate::test_support::{fixture, git, remove};
use crate::worktree_convert::CONVERTING_SUFFIX;
use crate::worktree_convert_journal::JOURNAL_NAME;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
const WORKTREES: usize = 4;
const LARGE_HOST: HostResources = HostResources { parallelism: WORKTREES * 8, memory_available: None };
const LAST_TO_FINISH_PATIENCE: Duration = Duration::from_secs(3);
fn name_of(index: usize) -> String {
return format!("w{index}");
}
struct Watched<'hooks> {
inner: PlainPlatform,
on_create: Box<dyn Fn(&Path) -> Result<(), PrimitiveError> + Send + Sync + 'hooks>,
on_exchange: Box<dyn Fn(&Path) + Send + Sync + 'hooks>,
}
impl Watched<'_> {
fn quiet() -> Watched<'static> {
return Watched { inner: PlainPlatform::new(), on_create: Box::new(|_path: &Path| { return Ok(()); }), on_exchange: Box::new(|_path: &Path| {}) };
}
}
impl Platform for Watched<'_> {
fn filesystem_facts(&self, path: &Path) -> Result<(crate::fs_kind::FilesystemKind, u64), PrimitiveError> {
return self.inner.filesystem_facts(path);
}
fn is_subvolume_root(&self, path: &Path) -> Result<bool, PrimitiveError> {
return self.inner.is_subvolume_root(path);
}
fn create_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
(self.on_create)(path)?;
return self.inner.create_subvolume(path);
}
fn destroy_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.destroy_subvolume(path);
}
fn removal_allowed(&self, path: &Path) -> bool {
return self.inner.removal_allowed(path);
}
fn is_mount_point(&self, path: &Path) -> bool {
return self.inner.is_mount_point(path);
}
fn clone_file(&self, source: &std::fs::File, destination: &std::fs::File, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.clone_file(source, destination, path);
}
fn exchange(&self, first: &Path, second: &Path) -> Result<(), PrimitiveError> {
(self.on_exchange)(first);
return self.inner.exchange(first, second);
}
fn conversion_environment(&self) -> crate::worktree_clean_content::ConversionEnvironment {
return self.inner.conversion_environment();
}
}
fn checkout_with_worktrees(name: &str, count: usize) -> (PathBuf, PathBuf) {
let parent: PathBuf = fixture(name);
let root: PathBuf = make_checkout(parent.as_path(), "checkout");
let mut exclude: String = std::fs::read_to_string(root.join(".git/info/exclude")).unwrap_or_default();
for index in 0..count {
git(root.as_path(), &["worktree", "add", "--quiet", "--detach", name_of(index).as_str()]);
exclude.push_str(format!("/{}/\n", name_of(index)).as_str());
}
std::fs::create_dir_all(root.join(".git/info")).expect("info directory");
std::fs::write(root.join(".git/info/exclude"), exclude).expect("exclude");
for index in 0..count {
backdate(root.join(name_of(index)).as_path());
git(root.join(name_of(index)).as_path(), &["update-index", "--refresh"]);
}
return (parent, root);
}
fn run_on(platform: &(dyn Platform + Sync), host: &HostResources, arguments: &ConvertArguments, cwd: &Path) -> (i32, Vec<String>) {
let mut lines: Vec<String> = Vec::new();
let code: i32 = execute_run(&ConvertRun { platform, arguments, cwd, interrupt: &Interrupt::never(), host }, &mut |text: &str| { lines.push(String::from(text)); });
return (code, lines);
}
fn all_arguments() -> ConvertArguments {
return ConvertArguments { all: true, ..ConvertArguments::default() };
}
fn is_converting_of(path: &Path, name: &str) -> bool {
return path.file_name().is_some_and(|last: &std::ffi::OsStr| { return last.to_string_lossy() == format!("{name}{CONVERTING_SUFFIX}"); });
}
#[test]
fn worktrees_are_converted_side_by_side() {
let (parent, root) = checkout_with_worktrees("convert-parallel-overlap", WORKTREES);
let point: Rendezvous = Rendezvous::new(WORKTREES);
let met: AtomicUsize = AtomicUsize::new(0);
let platform: Watched<'_> = Watched {
on_create: Box::new(|_path: &Path| {
if point.arrive() {
met.fetch_add(1, Ordering::SeqCst);
}
return Ok(());
}),
..Watched::quiet()
};
let (code, lines) = run_on(&platform, &LARGE_HOST, &all_arguments(), root.as_path());
assert_eq!(code, 0, "{lines:#?}");
assert_eq!(met.load(Ordering::SeqCst), WORKTREES, "every conversion met the others while it ran: {lines:#?}");
remove(parent.as_path());
}
#[test]
fn the_report_keeps_input_order_whichever_worktree_finishes_first() {
let (parent, root) = checkout_with_worktrees("convert-parallel-order", WORKTREES);
let others_done: Rendezvous = Rendezvous::impatient(WORKTREES - 1, LAST_TO_FINISH_PATIENCE);
let platform: Watched<'_> = Watched {
on_create: Box::new(|path: &Path| {
if is_converting_of(path, name_of(0).as_str()) {
assert!(others_done.wait_for_target(), "the other worktrees were converted while the first waited");
}
return Ok(());
}),
on_exchange: Box::new(|path: &Path| {
if !path.ends_with(name_of(0)) {
others_done.arrive_without_waiting();
}
}),
..Watched::quiet()
};
let (code, lines) = run_on(&platform, &LARGE_HOST, &all_arguments(), root.as_path());
assert_eq!(code, 0, "{lines:#?}");
assert_eq!(lines.first().map(String::as_str), Some(RISK_NOTICE));
let starts: Vec<usize> = (0..WORKTREES)
.map(|index: usize| { return lines.iter().position(|line: &String| { return line.ends_with(format!("/{}", name_of(index)).as_str()); }).expect("the worktree is named"); })
.collect();
assert!(starts.windows(2).all(|pair: &[usize]| { return pair[0] < pair[1]; }), "named in input order: {lines:#?}");
for (index, start) in starts.iter().enumerate() {
let end: usize = starts.get(index + 1).copied().unwrap_or(lines.len() - 1);
let own: &[String] = &lines[start + 1..end];
assert_eq!(own.iter().filter(|line: &&String| { return line.as_str() == " converted into a subvolume"; }).count(), 1, "{own:#?}");
assert!(own.iter().all(|line: &String| { return line.starts_with(" "); }), "only this worktree's lines between its name and the next: {own:#?}");
}
assert!(lines.last().is_some_and(|line: &String| { return line.starts_with(format!("{WORKTREES} worktree(s) looked at").as_str()); }), "{lines:#?}");
remove(parent.as_path());
}
#[test]
fn a_failed_worktree_does_not_stop_or_corrupt_the_others() {
let (parent, root) = checkout_with_worktrees("convert-parallel-failure", WORKTREES);
let platform: Watched<'_> = Watched {
on_create: Box::new(|path: &Path| {
if is_converting_of(path, name_of(0).as_str()) {
return Err(PrimitiveError { operation: "BTRFS_IOC_SUBVOL_CREATE", path: path.to_path_buf(), errno: libc::EIO });
}
return Ok(());
}),
..Watched::quiet()
};
let (code, lines) = run_on(&platform, &LARGE_HOST, &all_arguments(), root.as_path());
assert_eq!(code, FAILED_EXIT_CODE, "{lines:#?}");
assert_eq!(lines.iter().filter(|line: &&String| { return line.starts_with(" failed:"); }).count(), 1, "{lines:#?}");
assert_eq!(lines.iter().filter(|line: &&String| { return line.as_str() == " converted into a subvolume"; }).count(), WORKTREES - 1, "{lines:#?}");
assert!(lines.last().is_some_and(|line: &String| { return line.contains("1 failed"); }), "the summary counts the failure: {lines:#?}");
assert!(!platform.is_subvolume_root(root.join(name_of(0)).as_path()).expect("examined"), "the failed worktree is untouched");
assert!(root.join(".git/worktrees").join(name_of(0)).join(JOURNAL_NAME).exists(), "and journaled, so the command can finish it");
for index in 1..WORKTREES {
assert!(platform.is_subvolume_root(root.join(name_of(index)).as_path()).expect("examined"), "{} was converted", name_of(index));
}
let healthy: Watched<'_> = Watched { inner: platform.inner, ..Watched::quiet() };
let (again_code, again_lines) = run_on(&healthy, &LARGE_HOST, &all_arguments(), root.as_path());
assert_eq!(again_code, 0, "{again_lines:#?}");
assert!(healthy.is_subvolume_root(root.join(name_of(0)).as_path()).expect("examined"), "{again_lines:#?}");
remove(parent.as_path());
}
fn busiest_of(name: &str, host: &HostResources, jobs: Option<usize>, meeting: usize) -> usize {
let (parent, root) = checkout_with_worktrees(name, WORKTREES + WORKTREES);
let running: AtomicUsize = AtomicUsize::new(0);
let busiest: AtomicUsize = AtomicUsize::new(0);
let point: Rendezvous = Rendezvous::impatient(meeting, LAST_TO_FINISH_PATIENCE);
let platform: Watched<'_> = Watched {
on_create: Box::new(|_path: &Path| {
let now: usize = running.fetch_add(1, Ordering::SeqCst) + 1;
busiest.fetch_max(now, Ordering::SeqCst);
if meeting > 1 {
point.arrive();
}
return Ok(());
}),
on_exchange: Box::new(|_path: &Path| { running.fetch_sub(1, Ordering::SeqCst); }),
..Watched::quiet()
};
let (code, lines) = run_on(&platform, host, &ConvertArguments { jobs, ..all_arguments() }, root.as_path());
assert_eq!(code, 0, "{lines:#?}");
remove(parent.as_path());
return busiest.load(Ordering::SeqCst);
}
#[test]
fn the_budget_of_the_host_bounds_the_overlap() {
let host: HostResources = HostResources { parallelism: 3, memory_available: None };
let allowed: usize = conversion_workers(usize::MAX, None, &host);
assert!(allowed > 1 && allowed < WORKTREES, "the control needs a bound between one and the worktrees: {allowed}");
assert_eq!(busiest_of("convert-parallel-budget", &host, None, allowed), allowed);
}
#[test]
fn the_free_memory_of_the_host_bounds_the_overlap() {
let host: HostResources = HostResources { parallelism: WORKTREES * 8, memory_available: Some(CONVERSION_MEMORY) };
assert_eq!(busiest_of("convert-parallel-memory", &host, None, 1), 1);
}
#[test]
fn jobs_caps_the_overlap() {
let cap: usize = 2;
assert_eq!(busiest_of("convert-parallel-jobs", &LARGE_HOST, Some(cap), cap), cap);
}
#[test]
fn a_worktree_named_twice_is_converted_once_and_then_skipped() {
let (parent, root) = checkout_with_worktrees("convert-parallel-twice", 2);
let platform: Watched = Watched::quiet();
let named: ConvertArguments = ConvertArguments { paths: vec![name_of(0).into(), name_of(0).into(), name_of(1).into()], ..ConvertArguments::default() };
let (code, lines) = run_on(&platform, &LARGE_HOST, &named, root.as_path());
assert_eq!(code, 0, "{lines:#?}");
assert_eq!(lines.iter().filter(|line: &&String| { return line.as_str() == " converted into a subvolume"; }).count(), 2, "{lines:#?}");
assert_eq!(lines.iter().filter(|line: &&String| { return line.as_str() == " already a subvolume, skipped"; }).count(), 1, "{lines:#?}");
assert!(!lines.iter().any(|line: &String| { return line.contains("another conversion"); }), "{lines:#?}");
remove(parent.as_path());
}
#[test]
fn a_dry_run_lists_every_worktree_in_order_and_changes_nothing() {
let (parent, root) = checkout_with_worktrees("convert-parallel-dry", WORKTREES);
let platform: Watched = Watched::quiet();
let (code, lines) = run_on(&platform, &LARGE_HOST, &ConvertArguments { dry_run: true, ..all_arguments() }, root.as_path());
assert_eq!(code, 0, "{lines:#?}");
let expected: Vec<String> = (0..WORKTREES)
.flat_map(|index: usize| { return [format!("{}/{}", root.display(), name_of(index)), String::from(" would convert")]; })
.collect();
assert_eq!(lines[..lines.len() - 1], expected, "{lines:#?}");
for index in 0..WORKTREES {
assert!(!platform.is_subvolume_root(root.join(name_of(index)).as_path()).expect("examined"));
}
remove(parent.as_path());
}
fn lines_without_root(lines: &[String], root: &Path) -> Vec<String> {
return lines.iter().map(|line: &String| { return line.replace(root.to_string_lossy().as_ref(), "<root>"); }).collect();
}
#[test]
fn the_output_equals_the_one_at_a_time_output() {
let (serial_parent, serial_root) = checkout_with_worktrees("convert-parallel-equal-serial", WORKTREES);
let (side_parent, side_root) = checkout_with_worktrees("convert-parallel-equal-side", WORKTREES);
let (serial_code, serial_lines) = run_on(&Watched::quiet(), &LARGE_HOST, &ConvertArguments { jobs: Some(1), ..all_arguments() }, serial_root.as_path());
let (side_code, side_lines) = run_on(&Watched::quiet(), &LARGE_HOST, &all_arguments(), side_root.as_path());
assert_eq!(serial_code, side_code);
assert_eq!(lines_without_root(&serial_lines, serial_root.as_path()), lines_without_root(&side_lines, side_root.as_path()));
remove(serial_parent.as_path());
remove(side_parent.as_path());
}
#[test]
fn a_large_host_does_not_pass_the_storage_ceiling() {
let huge: HostResources = HostResources { parallelism: CONVERSION_STORAGE_CEILING * 8, memory_available: None };
assert_eq!(conversion_workers(CONVERSION_STORAGE_CEILING * 4, None, &huge), CONVERSION_STORAGE_CEILING);
assert_eq!(conversion_workers(CONVERSION_STORAGE_CEILING * 4, Some(2), &huge), 2);
assert_eq!(conversion_workers(3, None, &huge), 3);
}