use super::process_use::{UseTarget, processes_using};
use super::subvolume_error::{Interrupt, MigrateError};
use super::subvolume_index_files::{IndexLock, RefreshOptions, linked_worktrees_inside, refresh_index_file_with, repository_hash_length};
use super::subvolume_lock::{acquire, is_ours};
use super::subvolume_platform::Platform;
use super::subvolume_remove::remove_tree;
use super::subvolume_sync::{SyncOptions, SyncStats, sync_tree};
use super::worktree_clean::check_clean_tracked;
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
pub const CONVERTING_SUFFIX: &str = ".cli-git-converting";
pub const JOURNAL_NAME: &str = "cli-git-convert";
const JOURNAL_HEADER: &str = "cli-git-convert v1";
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Phase {
Started,
Creating,
Synced,
Exchanged,
Refreshed,
Removed,
}
impl Phase {
fn name(self) -> &'static str {
return match self {
Phase::Started => "started",
Phase::Creating => "creating",
Phase::Synced => "synced",
Phase::Exchanged => "exchanged",
Phase::Refreshed => "refreshed",
Phase::Removed => "removed",
};
}
fn parse(text: &str) -> Option<Phase> {
return [Phase::Started, Phase::Creating, Phase::Synced, Phase::Exchanged, Phase::Refreshed, Phase::Removed]
.into_iter()
.find(|phase: &Phase| { return phase.name() == text; });
}
}
#[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,
}
#[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> = std::fs::read(&git_path).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> = std::fs::read(admin.join("gitdir")).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 = std::fs::read_to_string(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 });
}
pub fn refusals(platform: &dyn Platform, candidate: &Candidate) -> Vec<String> {
let mut reasons: Vec<String> = Vec::new();
if candidate.admin.join("locked").exists() {
reasons.push(format!("the worktree is locked ({} exists); unlock it with `git worktree unlock` first", candidate.admin.join("locked").display()));
}
if candidate.admin.join("index.lock").exists() {
reasons.push(format!("{} exists: a Git command is writing the index", candidate.admin.join("index.lock").display()));
}
let converting: PathBuf = converting_path(candidate.path.as_path());
if std::fs::symlink_metadata(&converting).is_ok() {
reasons.push(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 == super::fs_kind::FilesystemKind::Btrfs => {}
Ok(_) => reasons.push(format!("{} is not on btrfs", candidate.path.display())),
Err(error) => reasons.push(format!("the filesystem of {} cannot be examined ({error})", candidate.path.display())),
}
if let Some(root) = candidate.common.parent().filter(|_parent: &&Path| { return candidate.common.file_name().is_some_and(|name: &std::ffi::OsStr| { return name == ".git"; }); }) {
for nested in linked_worktrees_inside(root) {
if nested.worktree != candidate.path && nested.worktree.starts_with(candidate.path.as_path()) {
reasons.push(format!("the worktree {} is registered inside it; convert or move that one first", nested.worktree.display()));
}
}
}
if let Err(reason) = check_clean_tracked(candidate.path.as_path(), candidate.admin.as_path(), candidate.common.as_path()) {
reasons.push(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() {
reasons.push(format!("the process table cannot be read ({why}), so no process can be ruled out"));
}
for user in &scan.users {
reasons.push(format!("process {} ({}) has its working directory or an open file inside it", user.pid, user.command.as_deref().unwrap_or("unknown")));
}
return reasons;
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Journal {
phase: Phase,
token: String,
path: Vec<u8>,
old_device: u64,
old_inode: u64,
git_file: Vec<u8>,
}
fn hex(bytes: &[u8]) -> String {
return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if text.len() % 2 != 0 {
return None;
}
return (0..text.len()).step_by(2).map(|at: usize| { return u8::from_str_radix(text.get(at..at + 2)?, 16).ok(); }).collect();
}
impl Journal {
fn encode(&self) -> String {
return format!(
"{JOURNAL_HEADER}\nphase={}\ntoken={}\npath={}\nold_device={}\nold_inode={}\ngit_file={}\n",
self.phase.name(),
self.token,
hex(self.path.as_slice()),
self.old_device,
self.old_inode,
hex(self.git_file.as_slice())
);
}
fn decode(text: &str) -> Result<Journal, MigrateError> {
let bad = || { return MigrateError::Journal(String::from("the conversion journal has a shape this command does not know; nothing was changed")); };
let mut lines = text.lines();
if lines.next() != Some(JOURNAL_HEADER) {
return Err(bad());
}
let field = |name: &str| -> Option<String> { return text.lines().find_map(|line: &str| { return line.strip_prefix(name).and_then(|rest: &str| { return rest.strip_prefix('='); }).map(String::from); }); };
return Ok(Journal {
phase: Phase::parse(field("phase").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
token: field("token").ok_or_else(bad)?,
path: unhex(field("path").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
old_device: field("old_device").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
old_inode: field("old_inode").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
git_file: unhex(field("git_file").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
});
}
fn save(&self, admin: &Path) -> Result<(), MigrateError> {
let target: PathBuf = admin.join(JOURNAL_NAME);
let temporary: PathBuf = admin.join(format!("{JOURNAL_NAME}.tmp"));
let written: std::io::Result<()> = (|| {
let mut file: std::fs::File = std::fs::File::create(&temporary)?;
file.write_all(self.encode().as_bytes())?;
file.sync_all()?;
std::fs::rename(&temporary, &target)?;
return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
})();
return written.map_err(|error: std::io::Error| { return MigrateError::io("write the conversion journal", target.as_path(), &error); });
}
fn load(admin: &Path) -> Result<Option<Journal>, MigrateError> {
let path: PathBuf = admin.join(JOURNAL_NAME);
match std::fs::read_to_string(&path) {
Ok(text) => return Journal::decode(text.as_str()).map(Some),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(MigrateError::io("read the conversion journal", path.as_path(), &error)),
}
}
}
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());
}
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(());
}
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 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")?;
if std::fs::symlink_metadata(&job.converting).is_err() {
platform.create_subvolume(job.converting.as_path())?;
}
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());
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)?;
require_complete(&second, job.candidate.path.as_path())?;
if pass_changed(&second) {
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)?;
return Ok(());
}
fn old_tree_is_at(journal: &Journal, path: &Path, converting: &Path) -> Where {
let is_old = |candidate: &Path| -> bool {
return std::fs::symlink_metadata(candidate).is_ok_and(|metadata: std::fs::Metadata| { return metadata.dev() == journal.old_device && metadata.ino() == journal.old_inode; });
};
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 => {
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 now: Vec<u8> = std::fs::read(path.join(".git")).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: String = refresh_index_file_with(
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}").as_str());
interrupt.check("refreshed")?;
job.reach(Phase::Refreshed)?;
return Ok(());
}
fn removal_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<(), MigrateError> {
match std::fs::symlink_metadata(&job.converting) {
Ok(metadata) => {
let is_old: bool = metadata.dev() == job.journal.old_device && metadata.ino() == job.journal.old_inode && !platform.is_subvolume_root(job.converting.as_path())?;
if is_old {
remove_tree(job.converting.as_path()).map_err(|error: std::io::Error| { return MigrateError::io("remove the old tree", job.converting.as_path(), &error); })?;
} else {
say(format!(" {} is not the old tree this conversion recorded, so it was left alone", job.converting.display()).as_str());
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(MigrateError::io("examine", job.converting.as_path(), &error)),
}
interrupt.check("removed")?;
job.reach(Phase::Removed)?;
return Ok(());
}
fn finish(job: &Job<'_>) -> Result<(), MigrateError> {
let lock: PathBuf = job.candidate.admin.join("index.lock");
if is_ours(lock.as_path(), job.journal.token.as_str()) {
std::fs::remove_file(&lock).map_err(|error: std::io::Error| { return MigrateError::io("release the index lock", lock.as_path(), &error); })?;
}
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 journal.phase >= Phase::Creating && std::fs::symlink_metadata(&converting).is_ok() {
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())?;
}
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 mut journal: Option<Journal> = Journal::load(candidate.admin.as_path())?;
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 && !options.dry_run {
found.phase = Phase::Exchanged;
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 mut reasons: Vec<String> = refusals(platform, &candidate);
if resuming {
let token: &str = journal.as_ref().map_or("", |found: &Journal| { return found.token.as_str(); });
reasons.retain(|reason: &String| { return !(reason.contains("index.lock") && is_ours(candidate.admin.join("index.lock").as_path(), token)) && !reason.contains(CONVERTING_SUFFIX); });
}
if !reasons.is_empty() {
return Ok(Verdict::Skipped(reasons));
}
}
if options.dry_run {
return Ok(if resuming { Verdict::Resumed } else { Verdict::WouldConvert });
}
let old: std::fs::Metadata = std::fs::symlink_metadata(&candidate.path).map_err(|error: std::io::Error| { return MigrateError::io("examine", candidate.path.as_path(), &error); })?;
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_device: old.dev(),
old_inode: old.ino(),
git_file: candidate.git_file.clone(),
});
let converting: PathBuf = converting_path(candidate.path.as_path());
let mut job: Job<'_> = Job { candidate: &candidate, converting, journal: started };
if !resuming {
job.journal.save(candidate.admin.as_path())?;
}
acquire(candidate.admin.join("index.lock").as_path(), job.journal.token.as_str())?;
if job.journal.phase < Phase::Synced {
copy_phase(platform, &mut job, interrupt, say)?;
}
if job.journal.phase < Phase::Exchanged {
swap_phase(platform, &mut job, interrupt)?;
}
verify_swap(platform, &job)?;
if job.journal.phase < Phase::Refreshed {
index_phase(&mut job, interrupt, say)?;
}
if job.journal.phase < Phase::Removed {
removal_phase(platform, &mut job, interrupt, say)?;
}
finish(&job)?;
return Ok(if resuming { Verdict::Resumed } else { Verdict::Converted });
}
#[cfg(test)]
#[path = "worktree_convert_tests.rs"]
mod tests;