use super::process_use::{UseTarget, processes_using};
use super::subvolume_error::{Interrupt, MigrateError};
use super::subvolume_index_files::{IndexLock, linked_worktrees_inside, refresh_all_indexes};
use super::subvolume_lock::{acquire, lock_paths, release_all, token_of};
use super::subvolume_platform::Platform;
use super::subvolume_preflight::{Layout, Refusal, layout_of, migrate_refusals};
use super::subvolume_run::{INDEX_LOCK, Run};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MigrateOptions {
pub dry_run: bool,
pub nested_worktrees: bool,
pub refresh_indexes: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
Migrated,
AlreadyDone,
DryRun,
Refused(Vec<Refusal>),
}
fn is_subvolume(platform: &dyn Platform, path: &Path) -> bool {
return platform.is_subvolume_root(path).unwrap_or(false);
}
fn announce(run: &mut Run<'_>, options: MigrateOptions) {
let root: PathBuf = run.layout.root.clone();
let removal: bool = run.platform.removal_allowed(root.as_path());
run.line(format!("checkout {}; new subvolume {}; old tree will be {}", root.display(), run.layout.new_path.display(), run.layout.old_path.display()).as_str());
run.line(format!("user_subvol_rm_allowed: {}", if removal { "active" } else { "absent (a subvolume is then removed by emptying it and rmdir; see git cli-git doctor)" }).as_str());
let real: PathBuf = std::fs::canonicalize(root.as_path()).unwrap_or(root);
let scan = processes_using(&UseTarget::Inside(real.as_path()));
if scan.users.is_empty() {
run.line("no other process has its working directory inside the checkout");
} else {
run.line(format!("WARNING: {} process(es) have a working directory inside the checkout and will stay in the OLD tree after the exchange; restart them (or `cd \"$PWD\"` in a shell) afterwards:", scan.users.len()).as_str());
for user in &scan.users {
run.line(format!(" pid {} ({})", user.pid, user.command.as_deref().unwrap_or("name unreadable")).as_str());
}
}
let nested: usize = if options.nested_worktrees { linked_worktrees_inside(run.layout.root.as_path()).len() } else { 0 };
run.line(format!("linked worktrees inside the checkout that become nested subvolumes: {nested}").as_str());
}
fn create_subvolumes(run: &mut Run<'_>, options: MigrateOptions) -> Result<(), MigrateError> {
if !run.journal.is_done("create") {
run.journal.mark("create", "started")?;
if !run.layout.new_path.exists() {
run.platform.create_subvolume(run.layout.new_path.as_path())?;
}
run.journal.mark("create", "done")?;
}
run.checkpoint("after-create")?;
if options.nested_worktrees && !run.journal.is_done("nested") {
for linked in linked_worktrees_inside(run.layout.root.as_path()) {
let Ok(relative) = linked.worktree.strip_prefix(run.layout.root.as_path()) else { continue };
let target: PathBuf = run.layout.new_path.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|error: std::io::Error| { return MigrateError::io("create the directory", parent, &error); })?;
}
if !target.exists() {
run.platform.create_subvolume(target.as_path())?;
}
}
run.journal.mark("nested", "done")?;
}
return run.checkpoint("after-nested");
}
fn exchange_trees(run: &mut Run<'_>) -> Result<(), MigrateError> {
if run.journal.is_done("exchange") {
return Ok(());
}
let root: PathBuf = run.layout.root.clone();
let new_path: PathBuf = run.layout.new_path.clone();
let old_path: PathBuf = run.layout.old_path.clone();
if run.journal.has_begun("exchange") && is_subvolume(run.platform, root.as_path()) {
if new_path.exists() && !old_path.exists() {
std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
}
return run.journal.mark("exchange", "done");
}
let token: String = token_of(&run.journal);
acquire_all(root.as_path(), token.as_str())?;
run.journal.mark("lock", "started")?;
run.line(format!("holding {} (and the lock of every linked worktree's index) so no Git command writes an index during the last delta", root.join(INDEX_LOCK).display()).as_str());
run.checkpoint("after-lock")?;
run.pass("last delta (under the lock)", root.as_path(), new_path.as_path(), true, false)?;
acquire_all(root.as_path(), token.as_str())?;
acquire_all(new_path.as_path(), token.as_str())?;
run.journal.mark("exchange", "started")?;
run.checkpoint("before-exchange")?;
run.platform.exchange(root.as_path(), new_path.as_path())?;
run.checkpoint("after-exchange")?;
std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
run.checkpoint("after-rename")?;
run.line(format!("exchanged; the old tree is now {}", old_path.display()).as_str());
return run.journal.mark("exchange", "done");
}
fn acquire_all(tree: &Path, token: &str) -> Result<(), MigrateError> {
for lock in lock_paths(tree) {
acquire(lock.as_path(), token)?;
}
return Ok(());
}
fn drop_old_locks(run: &mut Run<'_>) -> Result<(), MigrateError> {
let token: String = token_of(&run.journal);
let released: usize = release_all(run.layout.old_path.as_path(), token.as_str())?;
if released > 0 {
run.line(format!("removed {released} index lock(s) from {}", run.layout.old_path.display()).as_str());
}
return Ok(());
}
fn finish(run: &mut Run<'_>, options: MigrateOptions) -> Result<(), MigrateError> {
let root: PathBuf = run.layout.root.clone();
let old_path: PathBuf = run.layout.old_path.clone();
drop_old_locks(run)?;
let token: String = token_of(&run.journal);
if !run.journal.is_done("catchup") {
run.pass("catch-up (files newer in the old tree)", old_path.as_path(), root.as_path(), false, true)?;
run.journal.mark("catchup", "done")?;
}
run.checkpoint("after-catchup")?;
if options.refresh_indexes && !run.journal.is_done("indexes") {
let started: Instant = Instant::now();
let mut lines: Vec<String> = Vec::new();
let count: usize = refresh_all_indexes(root.as_path(), IndexLock::Held(token.as_str()), std::slice::from_ref(&old_path), &mut |text: &str| { lines.push(text.to_string()); });
for text in &lines {
run.line(text.as_str());
}
run.line(format!("index stat rewrite: {count} index file(s), {:.2} s", started.elapsed().as_secs_f64()).as_str());
run.journal.mark("indexes", "done")?;
}
run.checkpoint("after-indexes")?;
release_all(root.as_path(), token.as_str())?;
if !is_subvolume(run.platform, root.as_path()) {
return Err(MigrateError::Journal(format!("{} is not a subvolume root after the exchange; run with --rollback to restore the original tree", root.display())));
}
if !root.join(".git").join("HEAD").exists() {
return Err(MigrateError::Journal(format!("{} has no .git/HEAD after the exchange; run with --rollback to restore the original tree", root.display())));
}
return run.journal.mark("verify", "done");
}
fn closing(run: &mut Run<'_>) {
let layout: &Layout = run.layout;
let (root, old_path) = (layout.root.display().to_string(), layout.old_path.display().to_string());
run.line(format!("{root} is now a btrfs subvolume (root inode 256).").as_str());
run.line(format!("The original tree is kept at {old_path}. Check the result with `git status` and `git fsck`, then delete it with `git cli-git migrate-to-subvolume --remove-old` (or undo everything with `--rollback`).").as_str());
run.line("Shells, editors and agent sessions that had their working directory inside the checkout still use the old tree; restart them, or run `cd \"$PWD\"` in a shell.");
if !run.unreadable.is_empty() {
let count: usize = run.unreadable.len();
let list: String = run.layout.unreadable_path.display().to_string();
run.line(format!("{count} file(s) this user may not read were not copied and exist only in the old tree; they are listed in {list}. Copy them with the needed privilege before deleting the old tree.").as_str());
}
}
pub fn migrate(
platform: &dyn Platform,
root: &Path,
options: MigrateOptions,
interrupt: &Interrupt,
say: &mut dyn FnMut(&str),
) -> Result<Verdict, MigrateError> {
let Some(layout) = layout_of(root) else {
return Err(MigrateError::Journal(format!("{} has no parent directory to hold the new subvolume", root.display())));
};
let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
if is_subvolume(platform, root) && (run.journal.is_done("verify") || !run.journal.is_persisted()) {
run.line(format!("{} is already a btrfs subvolume; nothing to do.", root.display()).as_str());
if run.journal.is_done("verify") && layout.old_path.exists() {
run.line(format!("The original tree is still at {}; delete it with `git cli-git migrate-to-subvolume --remove-old`.", layout.old_path.display()).as_str());
}
return Ok(Verdict::AlreadyDone);
}
let refusals: Vec<Refusal> = migrate_refusals(platform, &layout, &run.journal);
if !refusals.is_empty() {
return Ok(Verdict::Refused(refusals));
}
announce(&mut run, options);
if options.dry_run {
run.line("plan: create the new subvolume and the nested ones; copy with cloned data; repeat as a delta; hold .git/index.lock, run the last delta, exchange the directories atomically; carry over newer files; rewrite the index stat fields; verify.");
run.line("dry run complete; nothing was changed. Run the command without --dry-run to migrate.");
return Ok(Verdict::DryRun);
}
let started: Instant = Instant::now();
create_subvolumes(&mut run, options)?;
if !run.journal.is_done("copy") {
run.journal.mark("copy", "started")?;
run.pass("copy", layout.root.as_path(), layout.new_path.as_path(), false, false)?;
run.checkpoint("after-copy")?;
run.journal.mark("copy", "done")?;
}
if !run.journal.is_done("delta1") && !run.journal.has_begun("exchange") {
run.pass("first delta", layout.root.as_path(), layout.new_path.as_path(), true, false)?;
run.journal.mark("delta1", "done")?;
}
run.checkpoint("after-delta1")?;
exchange_trees(&mut run)?;
finish(&mut run, options)?;
run.line(format!("migration took {:.2} s", started.elapsed().as_secs_f64()).as_str());
closing(&mut run);
return Ok(Verdict::Migrated);
}
#[cfg(test)]
#[path = "subvolume_migrate_tests.rs"]
mod tests;