use super::action::{Action, ENGINE_FAILURE_EXIT_CODE};
use super::subvolume_arguments::{MIGRATE_HELP, MigrateArguments, MigrateMode};
use super::subvolume_error::{Interrupt, MigrateError};
use super::subvolume_migrate::{MigrateOptions, Verdict, migrate};
use super::subvolume_platform::{LinuxPlatform, Platform};
use super::subvolume_preflight::{Refusal, layout_of};
use super::subvolume_rollback::{remove_old_tree, rollback};
use super::subvolume_run::Run;
use std::io::Write;
use std::path::{Path, PathBuf};
pub const REFUSED_EXIT_CODE: i32 = 1;
pub fn main_checkout_root(start: &Path) -> Result<PathBuf, String> {
let real: PathBuf = std::fs::canonicalize(start).map_err(|error: std::io::Error| { return format!("cannot resolve {}: {error}", start.display()); })?;
let mut directory: PathBuf = real.clone();
loop {
let dot_git: PathBuf = directory.join(".git");
if let Ok(metadata) = std::fs::symlink_metadata(&dot_git) {
if metadata.is_dir() {
return Ok(directory);
}
return common_root_of_gitfile(dot_git.as_path());
}
let Some(parent) = directory.parent() else {
return Err(format!("{} is not inside a Git working tree (no .git above it)", real.display()));
};
directory = parent.to_path_buf();
}
}
fn common_root_of_gitfile(gitfile: &Path) -> Result<PathBuf, String> {
let text: String = std::fs::read_to_string(gitfile).map_err(|error: std::io::Error| { return format!("cannot read {}: {error}", gitfile.display()); })?;
let Some(named) = text.trim().strip_prefix("gitdir:") else {
return Err(format!("{} is not a gitfile (no `gitdir:` line)", gitfile.display()));
};
let base: &Path = gitfile.parent().unwrap_or(Path::new("/"));
let admin: PathBuf = base.join(named.trim());
let common_text: String = std::fs::read_to_string(admin.join("commondir"))
.map_err(|_| { return format!("{} is not a linked worktree's gitfile ({} has no commondir), so this is not a main checkout or a linked worktree of one", gitfile.display(), admin.display()); })?;
let common: PathBuf = std::fs::canonicalize(admin.join(common_text.trim())).map_err(|error: std::io::Error| { return format!("cannot resolve the common directory: {error}"); })?;
if common.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
return Err(format!("the common directory {} is not named .git, so the repository has no main checkout directory to convert", common.display()));
}
return common.parent().map(Path::to_path_buf).ok_or_else(|| { return String::from("the common directory has no parent"); });
}
fn say_refusals(say: &mut dyn FnMut(&str), refusals: &[Refusal]) {
for refusal in refusals {
say(format!("git cli-git migrate-to-subvolume: refused: {}", refusal.cause).as_str());
say(format!(" What to do: {}", refusal.remedy).as_str());
}
say("Nothing was changed.");
}
pub fn execute(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> i32 {
let outcome: Result<i32, MigrateError> = run_mode(platform, arguments, root, interrupt, &mut *say);
match outcome {
Ok(code) => return code,
Err(error) => {
let journal: String = layout_of(root).map(|layout| { return layout.journal_path.display().to_string(); }).unwrap_or_default();
say(format!("git cli-git migrate-to-subvolume: failed: {error}").as_str());
say(format!("The journal {journal} records the steps that finished. Run the command again to resume from there, or add --rollback to restore the original directory.").as_str());
return ENGINE_FAILURE_EXIT_CODE;
}
}
}
fn run_mode(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<i32, MigrateError> {
if arguments.mode == MigrateMode::Migrate {
let options: MigrateOptions = MigrateOptions { dry_run: arguments.dry_run, nested_worktrees: arguments.nested_worktrees, refresh_indexes: arguments.refresh_indexes };
let verdict: Verdict = migrate(platform, root, options, interrupt, &mut *say)?;
if let Verdict::Refused(refusals) = verdict {
say_refusals(say, refusals.as_slice());
return Ok(REFUSED_EXIT_CODE);
}
return Ok(0);
}
let Some(layout) = layout_of(root) else {
return Err(MigrateError::Journal(format!("{} has no parent directory", root.display())));
};
let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
if arguments.dry_run {
let what: &str = if arguments.mode == MigrateMode::Rollback { "restore the original directory" } else { "delete the original tree" };
run.line(format!("dry run: the command would {what} of {}; nothing was changed.", layout.root.display()).as_str());
return Ok(0);
}
let refused: Option<Vec<Refusal>> = if arguments.mode == MigrateMode::Rollback { rollback(&mut run, arguments.discard_unreadable)? } else { remove_old_tree(&mut run, arguments.discard_unreadable)? };
if let Some(refusals) = refused {
say_refusals(run.say, refusals.as_slice());
return Ok(REFUSED_EXIT_CODE);
}
return Ok(0);
}
pub fn migrate_action(arguments: &MigrateArguments, cwd: &Path) -> Action {
if arguments.help {
return Action::Exit { code: 0, stdout: String::from(MIGRATE_HELP), stderr: String::new() };
}
let root: PathBuf = match main_checkout_root(cwd) {
Ok(found) => found,
Err(message) => {
return Action::Exit {
code: REFUSED_EXIT_CODE,
stdout: String::new(),
stderr: format!("git cli-git migrate-to-subvolume: refused: {message}\n What to do: run the command inside the repository whose main checkout you want to convert.\nNothing was changed.\n"),
};
}
};
let mut say = |text: &str| {
let mut stderr = std::io::stderr().lock();
let _written = writeln!(stderr, "{text}");
};
let code: i32 = execute(&LinuxPlatform, arguments, root.as_path(), &Interrupt::never(), &mut say);
return Action::Exit { code, stdout: String::new(), stderr: String::new() };
}
#[cfg(test)]
#[path = "subvolume_command_tests.rs"]
mod tests;