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_clean::check_clean_tracked_by_content;
use crate::single_owner_claim::{ClaimOutcome, OwnerClaim, live_owner_of, take_claim};
use crate::worktree_convert_journal::{CLAIM_PREFIX, Journal, is_recorded_object, legacy_lock_is_held, read_plain_file, read_plain_text};
pub use crate::worktree_convert_journal::{JOURNAL_NAME, Phase};
use crate::worktree_convert_remove::{Removal, remove_recorded_old_tree};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
const GIT_FILE_LIMIT: u64 = 1 << 16;
pub const CONVERTING_SUFFIX: &str = ".cli-git-converting";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
Converted,
Resumed,
WouldConvert,
AlreadySubvolume,
Abandoned,
Skipped(Vec<String>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConvertOptions {
pub dry_run: bool,
pub abandon: bool,
pub command_line_attributes: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Candidate {
pub path: PathBuf,
pub admin: PathBuf,
pub common: PathBuf,
pub git_file: Vec<u8>,
}
pub fn converting_path(path: &Path) -> PathBuf {
let mut name: std::ffi::OsString = path.as_os_str().to_os_string();
name.push(CONVERTING_SUFFIX);
return PathBuf::from(name);
}
pub fn inspect(path: &Path) -> Result<Candidate, String> {
let real: PathBuf = std::fs::canonicalize(path).map_err(|error: std::io::Error| { return format!("{} cannot be resolved ({error})", path.display()); })?;
let git_path: PathBuf = real.join(".git");
let metadata: std::fs::Metadata = std::fs::symlink_metadata(&git_path).map_err(|_| { return format!("{} has no .git entry, so it is not a worktree", real.display()); })?;
if metadata.is_dir() {
return Err(format!("{} holds a .git directory: it is a main checkout or a repository, not a linked worktree", real.display()));
}
let git_file: Vec<u8> = read_plain_file(&git_path, GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return format!("{} is unreadable ({error})", git_path.display()); })?;
let text: &str = std::str::from_utf8(git_file.as_slice()).map_err(|_| { return format!("{} is not text", git_path.display()); })?;
let Some(named) = text.trim().strip_prefix("gitdir:") else {
return Err(format!("{} has no gitdir line", git_path.display()));
};
let admin: PathBuf = std::fs::canonicalize(real.join(named.trim())).map_err(|error: std::io::Error| { return format!("the administrative directory {} cannot be resolved ({error})", named.trim()); })?;
let recorded: Vec<u8> = read_plain_file(&admin.join("gitdir"), GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return format!("{} has no gitdir file ({error})", admin.display()); })?;
if Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())) != git_path.as_path() {
return Err(format!("{} does not name {} back, so the registration is stale or points elsewhere", admin.display(), git_path.display()));
}
let common_text: String = read_plain_text(&admin.join("commondir")).map_err(|error: std::io::Error| { return format!("{} has no commondir ({error})", admin.display()); })?;
let common: PathBuf = std::fs::canonicalize(admin.join(common_text.trim())).map_err(|error: std::io::Error| { return format!("the common directory cannot be resolved ({error})"); })?;
return Ok(Candidate { path: real, admin, common, git_file });
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefusalKind {
Locked,
IndexLock,
ConvertingName,
Filesystem,
Nested,
NotQuiet,
Process,
OtherCommand,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct Owned {
pub index_lock: bool,
pub stage: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Refusal {
pub kind: RefusalKind,
pub text: String,
}
fn registered_inside(common: &Path, inside: &Path) -> Result<Vec<PathBuf>, String> {
let directory: PathBuf = common.join("worktrees");
let names = match std::fs::read_dir(&directory) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display())),
};
let mut found: Vec<PathBuf> = Vec::new();
for item in names {
let name: std::fs::DirEntry = item.map_err(|error: std::io::Error| { return format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display()); })?;
let recorded: Vec<u8> = match read_plain_file(&name.path().join("gitdir"), GIT_FILE_LIMIT) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(format!("the registration {} cannot be read ({error}), so a worktree inside this one cannot be ruled out", name.path().display())),
};
let Some(worktree) = Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())).parent() else { continue };
if worktree.starts_with(inside) && worktree != inside && worktree.is_dir() {
found.push(worktree.to_path_buf());
}
}
found.sort();
return Ok(found);
}
fn migration_of_main_checkout(common: &Path) -> Option<String> {
let root: &Path = common.parent().filter(|_parent: &&Path| { return common.file_name() == Some(std::ffi::OsStr::new(".git")); })?;
let layout = crate::subvolume_preflight::layout_of(root)?;
return match live_owner_of(layout.parent.as_path(), layout.claim_prefix.as_os_str()) {
Ok(None) => None,
Ok(Some(owner)) => Some(format!("process {} is migrating the main checkout {} to a subvolume (it owns {}); wait for it to finish, then run this command again", owner.pid, root.display(), owner.path.display())),
Err(error) => Some(format!("whether the main checkout {} is being migrated cannot be decided ({error})", root.display())),
};
}
pub fn refusals(platform: &dyn Platform, candidate: &Candidate, owned: Owned, command_line_attributes: bool) -> Vec<Refusal> {
let mut reasons: Vec<Refusal> = Vec::new();
let mut refuse = |kind: RefusalKind, text: String| { reasons.push(Refusal { kind, text }); };
if candidate.admin.join("locked").exists() {
refuse(RefusalKind::Locked, format!("the worktree is locked ({} exists); unlock it with `git worktree unlock` first", candidate.admin.join("locked").display()));
}
if !owned.index_lock && candidate.admin.join("index.lock").exists() {
refuse(RefusalKind::IndexLock, format!("{} exists: a Git command is writing the index", candidate.admin.join("index.lock").display()));
}
if let Some(text) = migration_of_main_checkout(candidate.common.as_path()) {
refuse(RefusalKind::OtherCommand, text);
}
let converting: PathBuf = converting_path(candidate.path.as_path());
if !owned.stage && std::fs::symlink_metadata(&converting).is_ok() {
refuse(RefusalKind::ConvertingName, format!("{} exists and no journal of this command owns it, so it is left alone; remove it yourself once you know what it is", converting.display()));
}
match platform.filesystem_facts(candidate.path.as_path()) {
Ok((kind, _device)) if kind == crate::fs_kind::FilesystemKind::Btrfs => {}
Ok(_) => refuse(RefusalKind::Filesystem, format!("{} is not on btrfs", candidate.path.display())),
Err(error) => refuse(RefusalKind::Filesystem, format!("the filesystem of {} cannot be examined ({error})", candidate.path.display())),
}
match registered_inside(candidate.common.as_path(), candidate.path.as_path()) {
Ok(nested_worktrees) => {
for nested in nested_worktrees {
refuse(RefusalKind::Nested, format!("the worktree {} is registered inside it; convert or move that one first", nested.display()));
}
}
Err(reason) => refuse(RefusalKind::Nested, reason),
}
let mut environment = platform.conversion_environment();
if command_line_attributes && environment.alternate_attribute_source.is_none() {
environment.alternate_attribute_source = Some(String::from("--attr-source or -c attr.tree was given on the command line"));
}
if let Err(reason) = check_clean_tracked_by_content(candidate.path.as_path(), candidate.admin.as_path(), candidate.common.as_path(), &environment, owned.index_lock) {
refuse(RefusalKind::NotQuiet, format!("it is not quiet and unmodified: {reason}"));
}
let scan = processes_using(&UseTarget::Inside(candidate.path.as_path()));
if let Some(why) = scan.unavailable.as_ref() {
refuse(RefusalKind::Process, format!("the process table cannot be read ({why}), so no process can be ruled out"));
}
for user in &scan.users {
refuse(RefusalKind::Process, format!("process {} ({}) has its working directory or an open file inside it", user.pid, user.command.as_deref().unwrap_or("unknown")));
}
return reasons;
}
fn new_token() -> String {
let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
return format!("cli-git convert-worktree-to-subvolume pid={} at={nanos}", std::process::id());
}
struct Job<'a> {
candidate: &'a Candidate,
converting: PathBuf,
journal: Journal,
}
impl Job<'_> {
fn reach(&mut self, phase: Phase) -> Result<(), MigrateError> {
self.journal.phase = phase;
return self.journal.save(self.candidate.admin.as_path());
}
fn parent(&self) -> Result<std::fs::File, MigrateError> {
return held_parent(self.candidate.path.as_path());
}
fn converting_name(&self) -> &std::ffi::OsStr {
return self.converting.file_name().unwrap_or_default();
}
fn stage_identity(&self) -> Result<Option<ObjectId>, MigrateError> {
return stage_identity_of(self.candidate.path.as_path());
}
}
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();
}
fn stage_free_identity(path: &Path) -> Result<ObjectId, MigrateError> {
let parent: std::fs::File = held_parent(path)?;
return identity_at(&parent, path.file_name().unwrap_or_default()).map_err(|error: std::io::Error| { return MigrateError::io("examine", path, &error); });
}
fn held_parent(path: &Path) -> Result<std::fs::File, MigrateError> {
let parent: &Path = path.parent().ok_or_else(|| { return MigrateError::Journal(format!("{} has no parent directory", path.display())); })?;
return open_root(parent).map_err(|error: std::io::Error| { return MigrateError::io("hold the directory of the worktree", parent, &error); });
}
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)),
};
}
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 made: ObjectId = job.stage_identity()?.ok_or_else(|| { return 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());
}
fn pass_changed(stats: &SyncStats) -> bool {
return stats.files_copied > 0 || stats.deleted > 0 || stats.vanished > 0 || stats.symlinks > 0 || stats.fifos > 0;
}
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(());
}
fn hold_index_lock(job: &Job<'_>) -> Result<(), MigrateError> {
return acquire(job.candidate.admin.join("index.lock").as_path(), job.journal.token.as_str());
}
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)),
};
}
fn copy_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> 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(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(());
}
fn old_tree_is_at(journal: &Journal, path: &Path, converting: &Path) -> Where {
let is_old = |candidate: &Path| -> bool {
return identity_of_path(candidate).is_some_and(|found: ObjectId| { return is_recorded_object(&journal.old_identity, &found); });
};
if is_old(path) {
return Where::AtPath;
}
if is_old(converting) {
return Where::AtConverting;
}
return Where::Nowhere;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Where {
AtPath,
AtConverting,
Nowhere,
}
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(());
}
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;
}
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 = identity_at(&parent, path.file_name().unwrap_or_default()).map_err(|error: std::io::Error| { return MigrateError::io("examine", path, &error); })?;
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> = read_plain_file(&path.join(".git"), GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return MigrateError::io("read", path.join(".git").as_path(), &error); })?;
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(());
}
fn index_phase(job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> 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(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);
}
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> = scan.users.iter().map(|user| { return format!("process {} ({})", user.pid, user.command.as_deref().unwrap_or("unknown")); }).collect();
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(());
}
fn finish(job: &Job<'_>) -> Result<(), MigrateError> {
release_index_lock(job)?;
let journal: PathBuf = job.candidate.admin.join(JOURNAL_NAME);
return std::fs::remove_file(&journal).map_err(|error: std::io::Error| { return MigrateError::io("remove the conversion journal", journal.as_path(), &error); });
}
fn abandon(platform: &dyn Platform, candidate: &Candidate, journal: Journal, say: &mut dyn FnMut(&str)) -> Result<Verdict, MigrateError> {
let converting: PathBuf = converting_path(candidate.path.as_path());
if journal.phase >= Phase::Exchanged {
say(" the swap already happened; run the command again to finish it (there is nothing to undo)");
return Ok(Verdict::Skipped(vec![String::from("past the swap")]));
}
if let Some(found) = stage_identity_of(candidate.path.as_path())? {
if journal.new_identity == Some(found) {
if !platform.is_subvolume_root(converting.as_path())? {
return Err(MigrateError::Journal(format!("{} is not a subvolume root, so it is not what this conversion created and is left alone", converting.display())));
}
platform.destroy_subvolume(converting.as_path())?;
} else {
say(format!(" {} is not recorded as made by this conversion, so it was left alone", converting.display()).as_str());
}
}
let job: Job<'_> = Job { candidate, converting, journal };
finish(&job)?;
return Ok(Verdict::Abandoned);
}
pub fn convert_worktree(platform: &dyn Platform, path: &Path, options: ConvertOptions, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<Verdict, MigrateError> {
let candidate: Candidate = match inspect(path) {
Ok(found) => found,
Err(reason) => return Ok(Verdict::Skipped(vec![reason])),
};
let _claim: Option<OwnerClaim> = if options.dry_run {
None
} else {
match take_claim(candidate.admin.as_path(), std::ffi::OsStr::new(CLAIM_PREFIX))? {
ClaimOutcome::Held(held) => {
if legacy_lock_is_held(candidate.admin.as_path())? {
return Ok(Verdict::Skipped(vec![format!(
"another conversion of {} is running (a command of release 0.5.20 or older holds {}), so this one did not start",
candidate.path.display(),
candidate.admin.join(crate::worktree_convert_journal::LOCK_NAME).display()
)]));
}
Some(held)
}
ClaimOutcome::Busy(owner) => {
return Ok(Verdict::Skipped(vec![format!(
"another conversion of {} is running (process {} owns it through {}), so this one did not start",
candidate.path.display(),
owner.pid,
owner.path.display()
)]));
}
}
};
let mut journal: Option<Journal> = Journal::load(candidate.admin.as_path())?;
if let Some(found) = journal.as_ref().filter(|found: &&Journal| { return found.path.as_slice() != candidate.path.as_os_str().as_bytes(); }) {
return Err(MigrateError::Journal(format!(
"an unfinished conversion began at {}, but the worktree is at {} now; it was moved while the conversion was unfinished, so nothing was changed; move it back and run the command again",
String::from_utf8_lossy(found.path.as_slice()),
candidate.path.display()
)));
}
if let Some(found) = journal.as_mut().filter(|found: &&mut Journal| { return found.phase == Phase::Synced; }) {
let converting: PathBuf = converting_path(candidate.path.as_path());
if old_tree_is_at(found, candidate.path.as_path(), converting.as_path()) == Where::AtConverting {
found.phase = Phase::Exchanged;
if !options.dry_run {
found.save(candidate.admin.as_path())?;
}
}
}
if options.abandon {
return match journal {
Some(found) => abandon(platform, &candidate, found, say),
None => Ok(Verdict::Skipped(vec![String::from("no conversion is in progress here, so there is nothing to abandon")])),
};
}
if journal.is_none() && platform.is_subvolume_root(candidate.path.as_path())? {
return Ok(Verdict::AlreadySubvolume);
}
let resuming: bool = journal.is_some();
let must_be_quiet: bool = journal.as_ref().is_none_or(|found: &Journal| { return found.phase < Phase::Exchanged; });
if must_be_quiet {
let owned: Owned = match journal.as_ref() {
Some(found) => Owned {
index_lock: is_ours(candidate.admin.join("index.lock").as_path(), found.token.as_str()),
stage: found.new_identity.is_some() && stage_identity_of(candidate.path.as_path())? == found.new_identity,
},
None => Owned::default(),
};
let reasons: Vec<Refusal> = refusals(platform, &candidate, owned, options.command_line_attributes);
if !reasons.is_empty() {
return Ok(Verdict::Skipped(reasons.into_iter().map(|reason: Refusal| { return reason.text; }).collect()));
}
}
if options.dry_run {
return Ok(if resuming { Verdict::Resumed } else { Verdict::WouldConvert });
}
let old: ObjectId = stage_free_identity(candidate.path.as_path())?;
let token: String = journal.as_ref().map_or_else(new_token, |found: &Journal| { return found.token.clone(); });
let started: Journal = journal.unwrap_or(Journal {
phase: Phase::Started,
token,
path: candidate.path.as_os_str().as_bytes().to_vec(),
old_identity: old,
git_file: candidate.git_file.clone(),
new_identity: None,
});
let converting: PathBuf = converting_path(candidate.path.as_path());
let mut job: Job<'_> = Job { candidate: &candidate, converting, journal: started };
if !resuming {
job.journal.create_new(candidate.admin.as_path())?;
}
if job.journal.phase < Phase::Exchanged {
copy_phase(platform, &mut job, interrupt, say)?;
swap_phase(platform, &mut job, interrupt)?;
}
verify_swap(platform, &job)?;
if job.journal.phase < Phase::Refreshed {
hold_index_lock(&job)?;
index_phase(&mut job, interrupt, say)?;
}
if job.journal.phase < Phase::Removed {
removal_phase(platform, &mut job, interrupt)?;
}
finish(&job)?;
return Ok(if resuming { Verdict::Resumed } else { Verdict::Converted });
}
#[cfg(test)]
#[path = "worktree_convert_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "worktree_convert_content_tests.rs"]
mod content_tests;
#[cfg(test)]
#[path = "worktree_convert_ownership_tests.rs"]
mod ownership_tests;
#[cfg(test)]
#[path = "worktree_convert_safety_tests.rs"]
mod safety_tests;