use super::fs_kind::FilesystemKind;
use super::single_owner_claim::live_owner_of;
use super::subvolume_journal::Journal;
use super::subvolume_lock::{is_ours, token_of};
use super::subvolume_platform::Platform;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
pub const MIN_FREE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
pub const IN_PROGRESS_NAMES: [&str; 12] = [
"index.lock",
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"BISECT_LOG",
"rebase-merge",
"rebase-apply",
"sequencer",
"HEAD.lock",
"config.lock",
"packed-refs.lock",
"shallow.lock",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Layout {
pub root: PathBuf,
pub parent: PathBuf,
pub new_path: PathBuf,
pub old_path: PathBuf,
pub journal_path: PathBuf,
pub unreadable_path: PathBuf,
pub claim_prefix: std::ffi::OsString,
}
pub fn layout_of(root: &Path) -> Option<Layout> {
let name: &std::ffi::OsStr = root.file_name()?;
let parent: &Path = root.parent()?;
let with_suffix = |suffix: &str| -> PathBuf {
let mut text: std::ffi::OsString = name.to_os_string();
text.push(suffix);
return parent.join(text);
};
let dotted = |suffix: &str| -> PathBuf {
let mut text: std::ffi::OsString = std::ffi::OsString::from(".cli-git-migrate-");
text.push(name);
text.push(suffix);
return parent.join(text);
};
return Some(Layout {
root: root.to_path_buf(),
parent: parent.to_path_buf(),
new_path: with_suffix(".subvol-new"),
old_path: with_suffix(".before-subvolume"),
journal_path: dotted(".journal.json"),
unreadable_path: dotted(".unreadable.txt"),
claim_prefix: dotted(".owner").file_name().unwrap_or_default().to_os_string(),
});
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Refusal {
pub cause: String,
pub remedy: String,
}
fn refuse(cause: String, remedy: &str) -> Refusal {
return Refusal { cause, remedy: remedy.to_string() };
}
pub fn migrate_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
let mut found: Vec<Refusal> = Vec::new();
let root: &Path = layout.root.as_path();
let metadata: std::fs::Metadata = match std::fs::symlink_metadata(root) {
Ok(read) => read,
Err(error) => {
found.push(refuse(format!("{} cannot be examined: {error}", root.display()), "Run the command inside the repository's main checkout, or check the path."));
return found;
}
};
if !metadata.is_dir() {
found.push(refuse(format!("{} is not a directory (it is a symbolic link or a file)", root.display()), "Run the command from the real path of the main checkout."));
return found;
}
match platform.filesystem_facts(root) {
Ok((FilesystemKind::Btrfs, _)) => {}
Ok((kind, _)) => found.push(refuse(
format!("{} is on {}, not on btrfs, and only btrfs has subvolumes", root.display(), if kind == FilesystemKind::Overlayfs { "overlayfs" } else { "a filesystem other than btrfs" }),
"Nothing to convert here: on this filesystem `git worktree add` uses Git's checkout (on overlayfs the wrapper can reflink files inside the overlay). To get subvolume speed, move the checkout to a btrfs filesystem first.",
)),
Err(error) => found.push(refuse(format!("the filesystem of {} cannot be identified: {error}", root.display()), "Check that the path exists and that this user can read it.")),
}
if let Ok((_, free)) = platform.filesystem_facts(layout.parent.as_path()) && free < MIN_FREE_BYTES {
found.push(refuse(
format!("{} has {free} bytes free, fewer than the {MIN_FREE_BYTES} bytes the migration requires for its metadata", layout.parent.display()),
"Free space on that filesystem (the copy shares data blocks but writes one metadata record per entry), then run the command again.",
));
}
if let Ok(parent_metadata) = std::fs::metadata(layout.parent.as_path()) && parent_metadata.dev() != metadata.dev() && !platform.is_subvolume_root(root).unwrap_or(false) {
found.push(refuse(
format!("{} and its parent {} are on different devices, so the two cannot be swapped", root.display(), layout.parent.display()),
"Run the command for a checkout that is not itself a mount point.",
));
}
if platform.is_mount_point(root) {
found.push(refuse(
format!("{} is a mount point, and a mount point cannot be swapped with another directory", root.display()),
"Convert the directory that is mounted there instead, or mount a subvolume at that path yourself (`btrfs subvolume create` and `mount --options subvol=...`).",
));
}
found.extend(state_refusals(platform, layout, journal));
found.extend(git_state_refusals(layout, journal));
return found;
}
fn state_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
let mut found: Vec<Refusal> = Vec::new();
let is_root_subvolume: bool = platform.is_subvolume_root(layout.root.as_path()).unwrap_or(false);
if is_root_subvolume && !journal.has_begun("exchange") {
found.push(refuse(
format!("{} is already a subvolume (inode 256) and no migration of it is recorded", layout.root.display()),
"Nothing to do.",
));
}
if journal.has_begun("rollback-swap") {
found.push(refuse(
format!("a rollback of {} swapped the directories and did not finish (it is recorded in {})", layout.root.display(), layout.journal_path.display()),
"Run `git cli-git migrate-to-subvolume --rollback` again to finish it; migrating again would work on the wrong directories.",
));
}
if layout.new_path.exists() && !journal.has_begun("create") {
found.push(refuse(
format!("{} exists and no migration journal ({}) says that this command created it", layout.new_path.display(), layout.journal_path.display()),
"Look at what it is. If it is the leftover of a migration whose journal was deleted, remove it (`btrfs subvolume delete <path>`, or empty it and `rmdir` it) and run the command again. If it is something else, rename it.",
));
}
if layout.old_path.exists() && !journal.has_begun("exchange") {
found.push(refuse(
format!("{} exists already, and it is where the original tree would be moved", layout.old_path.display()),
"If it is the old tree of an earlier migration you have checked, delete it (`git cli-git migrate-to-subvolume --remove-old` needs the journal; otherwise `rm --recursive` it yourself). Otherwise rename it.",
));
}
return found;
}
fn conversion_refusal(administrative: &Path) -> Option<Refusal> {
return match live_owner_of(administrative, std::ffi::OsStr::new(super::worktree_convert_journal::CLAIM_PREFIX)) {
Ok(None) => None,
Ok(Some(owner)) => Some(refuse(
format!("process {} is converting the linked worktree registered in {} to a subvolume (it owns {})", owner.pid, administrative.display(), owner.path.display()),
"Wait for `git cli-git convert-worktree-to-subvolume` to finish, then run the command again.",
)),
Err(error) => Some(refuse(
format!("whether the linked worktree registered in {} is being converted cannot be decided ({error})", administrative.display()),
"Look at the claim file the message names; if no cli-git command is running, remove it, then run the command again.",
)),
};
}
fn git_state_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
let mut found: Vec<Refusal> = Vec::new();
let git_directory: PathBuf = layout.root.join(".git");
if !std::fs::metadata(&git_directory).is_ok_and(|read: std::fs::Metadata| { return read.is_dir(); }) {
found.push(refuse(
format!("{} is not a directory, so this is not the main checkout of a repository", git_directory.display()),
"Run the command from the main checkout (the directory that holds the repository's `.git` directory), not from a linked worktree.",
));
return found;
}
let token: String = token_of(journal);
let mut directories: Vec<PathBuf> = vec![git_directory.clone()];
if let Ok(names) = std::fs::read_dir(git_directory.join("worktrees")) {
for name in names.flatten() {
directories.push(name.path());
}
}
directories.sort();
for directory in &directories {
for name in IN_PROGRESS_NAMES {
let own_lock: bool = name == "index.lock" && is_ours(directory.join(name).as_path(), token.as_str());
if directory.join(name).exists() && !own_lock {
found.push(refuse(
format!("Git is in the middle of an operation: {} exists", directory.join(name).display()),
"Finish or abort that operation (commit, merge, rebase, cherry-pick, bisect), or, if no Git command is running, remove the stale file after checking, then run the command again.",
));
}
}
if let Some(refusal) = conversion_refusal(directory.as_path()) {
found.push(refusal);
}
for queue in ["pending", "claimed"] {
let path: PathBuf = directory.join("cli-git").join("work").join(queue);
if std::fs::read_dir(&path).is_ok_and(|mut entries: std::fs::ReadDir| { return entries.next().is_some(); }) {
found.push(refuse(
format!("the commit service has unfinished work in {}", path.display()),
"Wait for the service to finish landing its commits (`git cli-git service status`), then run the command again.",
));
}
}
}
return found;
}
pub fn rollback_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
if journal.is_persisted() {
return Vec::new();
}
return vec![refuse(
format!("there is no migration journal at {}, so there is nothing recorded to undo", layout.journal_path.display()),
"If a migration was started by hand, undo it by hand: exchange the directories again with `mv --exchange` and delete the new subvolume.",
)];
}
pub fn remove_old_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
let mut found: Vec<Refusal> = Vec::new();
if !journal.is_done("verify") {
found.push(refuse(
format!("the migration of {} is not recorded as finished in {}", layout.root.display(), layout.journal_path.display()),
"Run `git cli-git migrate-to-subvolume` to finish (or `--rollback` to undo) before deleting the old tree.",
));
}
if !layout.old_path.exists() {
found.push(refuse(
format!("{} does not exist", layout.old_path.display()),
"Nothing to delete: it was removed already.",
));
}
return found;
}
#[cfg(test)]
#[path = "subvolume_preflight_tests.rs"]
mod tests;