use crate::line_sink::LineSink;
use crate::fs_beneath::{ObjectId, identity_at, open_root};
use crate::process_use::{UseTarget, processes_using};
use crate::subvolume_error::{Interrupt, MigrateError};
use crate::subvolume_index_files::{IndexLock, RefreshOptions, RefreshStatus, refresh_index_file_report, repository_hash_length};
use crate::subvolume_lock::{acquire, is_ours};
use crate::subvolume_platform::Platform;
use crate::subvolume_sync::{SyncOptions, SyncStats, sync_tree};
use crate::worktree_convert_inspection::{Candidate, GIT_FILE_LIMIT, converting_path};
use crate::worktree_convert_journal::{JOURNAL_NAME, Journal, Phase, io_step, is_recorded_object, read_plain_file};
use crate::worktree_convert_remove::{Removal, remove_recorded_old_tree};
use std::path::{Path, PathBuf};
pub struct Job<'a> {
pub candidate: &'a Candidate,
pub converting: PathBuf,
pub journal: Journal,
}
impl Job<'_> {
pub fn reach(&mut self, phase: Phase) -> Result<(), MigrateError> {
self.journal.phase = phase;
return self.journal.save(self.candidate.admin.as_path());
}
pub fn parent(&self) -> Result<std::fs::File, MigrateError> {
return held_parent(self.candidate.path.as_path());
}
pub fn converting_name(&self) -> &std::ffi::OsStr {
return self.converting.file_name().unwrap_or_default();
}
pub fn stage_identity(&self) -> Result<Option<ObjectId>, MigrateError> {
return stage_identity_of(self.candidate.path.as_path());
}
}
pub fn identity_of_path(path: &Path) -> Option<ObjectId> {
let parent: std::fs::File = held_parent(path).ok()?;
return identity_at(&parent, path.file_name()?).ok();
}
pub fn stage_free_identity(path: &Path) -> Result<ObjectId, MigrateError> {
let parent: std::fs::File = held_parent(path)?;
return io_step(identity_at(&parent, path.file_name().unwrap_or_default()), "examine", path);
}
pub fn held_parent(path: &Path) -> Result<std::fs::File, MigrateError> {
let Some(parent) = path.parent() else {
return Err(MigrateError::Journal(format!("{} has no parent directory", path.display())));
};
return io_step(open_root(parent), "hold the directory of the worktree", parent);
}
pub fn stage_identity_of(path: &Path) -> Result<Option<ObjectId>, MigrateError> {
let converting: PathBuf = converting_path(path);
let parent: std::fs::File = held_parent(path)?;
return match identity_at(&parent, converting.file_name().unwrap_or_default()) {
Ok(found) => Ok(Some(found)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(MigrateError::io("examine", converting.as_path(), &error)),
};
}
pub fn ensure_stage(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
match (job.stage_identity()?, job.journal.new_identity) {
(Some(found), Some(recorded)) if found == recorded => return Ok(()),
(Some(_), _) => {
return Err(MigrateError::Journal(format!(
"{} exists but the journal does not record this conversion creating it, so it is left alone; remove it yourself once you know what it is, or run the command with --abandon",
job.converting.display()
)));
}
(None, Some(_)) => {
return Err(MigrateError::Journal(format!(
"{} was created by this conversion and is gone; run the command with --abandon and start again",
job.converting.display()
)));
}
(None, None) => {}
}
platform.create_subvolume(job.converting.as_path())?;
interrupt.check("stage-unrecorded")?;
let Some(made) = job.stage_identity()? else {
return Err(MigrateError::Journal(format!("{} was not there after it was created", job.converting.display())));
};
job.journal.new_identity = Some(made);
return job.journal.save(job.candidate.admin.as_path());
}
pub fn pass_changed(stats: &SyncStats) -> bool {
return stats.files_copied > 0 || stats.deleted > 0 || stats.vanished > 0 || stats.symlinks > 0 || stats.fifos > 0;
}
pub fn require_complete(stats: &SyncStats, path: &Path) -> Result<(), MigrateError> {
if let Some(first) = stats.unreadable.first() {
return Err(MigrateError::Journal(format!(
"{} holds {} file(s) this user cannot read, the first being {}; they would be lost at the swap, so nothing was swapped",
path.display(),
stats.unreadable.len(),
first.display()
)));
}
return Ok(());
}
pub fn hold_index_lock(job: &Job<'_>) -> Result<(), MigrateError> {
return acquire(job.candidate.admin.join("index.lock").as_path(), job.journal.token.as_str());
}
pub fn release_index_lock(job: &Job<'_>) -> Result<(), MigrateError> {
let lock: PathBuf = job.candidate.admin.join("index.lock");
if !is_ours(lock.as_path(), job.journal.token.as_str()) {
return Ok(());
}
return match std::fs::remove_file(&lock) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(MigrateError::io("release the index lock", lock.as_path(), &error)),
};
}
pub fn copy_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn LineSink) -> Result<(), MigrateError> {
if job.journal.phase < Phase::Creating {
job.reach(Phase::Creating)?;
}
interrupt.check("journaled")?;
ensure_stage(platform, job, interrupt)?;
interrupt.check("created")?;
let first: SyncStats = sync_tree(platform, job.candidate.path.as_path(), job.converting.as_path(), SyncOptions { delete: false, update_only: false, excluded: &[], created_since: None }, interrupt)?;
require_complete(&first, job.candidate.path.as_path())?;
say.say(format!(" copied {} entries ({} files, {} cloned)", first.entries, first.files_copied, first.cloned).as_str());
hold_index_lock(job)?;
let second: SyncStats = sync_tree(platform, job.candidate.path.as_path(), job.converting.as_path(), SyncOptions { delete: true, update_only: false, excluded: &[], created_since: None }, interrupt)?;
if let Err(error) = require_complete(&second, job.candidate.path.as_path()) {
release_index_lock(job)?;
return Err(error);
}
if pass_changed(&second) {
release_index_lock(job)?;
return Err(MigrateError::Journal(format!(
"{} changed while it was copied ({} file(s) copied and {} removed by the proving pass); nothing was swapped, run the command again",
job.candidate.path.display(),
second.files_copied,
second.deleted
)));
}
interrupt.check("verified")?;
job.reach(Phase::Synced)?;
interrupt.check("synced")?;
return Ok(());
}
pub fn is_old_tree(journal: &Journal, path: &Path) -> bool {
return match identity_of_path(path) {
Some(found) => is_recorded_object(&journal.old_identity, &found),
None => false,
};
}
pub fn old_tree_is_at(journal: &Journal, path: &Path, converting: &Path) -> Where {
if is_old_tree(journal, path) {
return Where::AtPath;
}
if is_old_tree(journal, converting) {
return Where::AtConverting;
}
return Where::Nowhere;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Where {
AtPath,
AtConverting,
Nowhere,
}
pub fn swap_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
match old_tree_is_at(&job.journal, job.candidate.path.as_path(), job.converting.as_path()) {
Where::AtPath => {
ensure_stage(platform, job, interrupt)?;
let refused: Vec<String> = refusals_after_lock(platform, job.candidate);
if !refused.is_empty() {
return Err(MigrateError::Journal(format!("it can no longer be swapped: {}", refused.join("; "))));
}
platform.exchange(job.candidate.path.as_path(), job.converting.as_path())?;
}
Where::AtConverting => {}
Where::Nowhere => {
return Err(MigrateError::Journal(format!(
"neither {} nor {} is the directory this conversion recorded, so nothing is swapped or removed",
job.candidate.path.display(),
job.converting.display()
)));
}
}
interrupt.check("exchanged")?;
job.reach(Phase::Exchanged)?;
return Ok(());
}
pub fn refusals_after_lock(_platform: &dyn Platform, candidate: &Candidate) -> Vec<String> {
let mut reasons: Vec<String> = Vec::new();
let scan = processes_using(&UseTarget::Inside(candidate.path.as_path()));
for user in &scan.users {
reasons.push(format!("process {} ({}) is using the worktree", user.pid, user.command.as_deref().unwrap_or("unknown")));
}
if let Some(why) = scan.unavailable.as_ref() {
reasons.push(format!("the process table cannot be read ({why})"));
}
return reasons;
}
pub fn verify_swap(platform: &dyn Platform, job: &Job<'_>) -> Result<(), MigrateError> {
let path: &Path = job.candidate.path.as_path();
if !platform.is_subvolume_root(path)? {
return Err(MigrateError::Journal(format!("{} is not a subvolume root after the swap; the old tree is kept at {}", path.display(), job.converting.display())));
}
let parent: std::fs::File = job.parent()?;
let installed: ObjectId = io_step(identity_at(&parent, path.file_name().unwrap_or_default()), "examine", path)?;
if job.journal.new_identity != Some(installed) {
return Err(MigrateError::Journal(format!("{} is not the subvolume this conversion created; the old tree is kept at {}", path.display(), job.converting.display())));
}
let now: Vec<u8> = io_step(read_plain_file(&path.join(".git"), GIT_FILE_LIMIT), "read", path.join(".git").as_path())?;
if now != job.journal.git_file {
return Err(MigrateError::Journal(format!("the .git file of {} differs after the swap; the old tree is kept at {}", path.display(), job.converting.display())));
}
return Ok(());
}
pub fn index_phase(job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn LineSink) -> Result<(), MigrateError> {
let references: Vec<PathBuf> = vec![job.converting.clone()];
let report = refresh_index_file_report(
job.candidate.admin.join("index").as_path(),
job.candidate.path.as_path(),
&RefreshOptions { lock: IndexLock::Held(job.journal.token.as_str()), references: references.as_slice(), hash_length: repository_hash_length(job.candidate.common.as_path()) },
);
say.say(format!(" index: {}", report.line).as_str());
if report.status == RefreshStatus::Skipped {
return Err(MigrateError::Journal(format!(
"the index of {} was not refreshed ({}); the old tree is kept at {}; fix that and run the command again",
job.candidate.path.display(),
report.line,
job.converting.display()
)));
}
interrupt.check("refreshed")?;
job.reach(Phase::Refreshed)?;
return release_index_lock(job);
}
pub fn removal_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
let parent: std::fs::File = job.parent()?;
let scan = processes_using(&UseTarget::Inside(job.converting.as_path()));
let mut holders: Vec<String> = Vec::new();
for user in &scan.users {
holders.push(format!("process {} ({})", user.pid, user.command.as_deref().unwrap_or("unknown")));
}
if let Some(why) = scan.unavailable.as_ref() {
holders.push(format!("the process table cannot be read ({why})"));
}
if !holders.is_empty() {
return Err(MigrateError::Journal(format!("the old tree at {} is kept because {}; stop that and run the command again", job.converting.display(), holders.join("; "))));
}
match remove_recorded_old_tree(platform, &parent, job.converting_name(), job.journal.old_identity, job.candidate.path.as_path())? {
Removal::Removed | Removal::Absent => {}
Removal::Kept(why) => {
return Err(MigrateError::Journal(format!("the old tree was not removed: {why}; the journal is kept, so put things right and run the command again")));
}
Removal::Differs(why) => {
return Err(MigrateError::Journal(format!(
"the old tree at {} is kept, and nothing was copied from it: {why}; settle which version is wanted by hand, then run the command again to remove it",
job.converting.display()
)));
}
}
interrupt.check("removed")?;
job.reach(Phase::Removed)?;
return Ok(());
}
pub fn finish(job: &Job<'_>) -> Result<(), MigrateError> {
release_index_lock(job)?;
let journal: PathBuf = job.candidate.admin.join(JOURNAL_NAME);
return io_step(std::fs::remove_file(&journal), "remove the conversion journal", journal.as_path());
}