use crate::fs_kind::FilesystemKind;
use crate::single_owner_claim::live_owner_of;
use crate::subvolume_entry_count::count_entries;
use crate::subvolume_journal::Journal;
use crate::subvolume_lock::{is_ours, token_of};
use crate::subvolume_platform::Platform;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
pub const METADATA_BYTES_PER_ENTRY: u64 = 1024;
pub const METADATA_COPIES: u64 = 2;
pub fn required_free_bytes(entries: u64) -> u64 {
return entries.saturating_mul(METADATA_BYTES_PER_ENTRY.saturating_mul(METADATA_COPIES));
}
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.")),
}
let on_btrfs: bool = matches!(platform.filesystem_facts(root), Ok((FilesystemKind::Btrfs, _)));
if on_btrfs && let Ok((_, free)) = platform.filesystem_facts(layout.parent.as_path()) {
let entries: u64 = count_entries(root);
let needed: u64 = required_free_bytes(entries);
if free < needed {
found.push(refuse(
format!(
"{} has {free} bytes free, fewer than the {needed} bytes the migration requires for the metadata of the {entries} entries of {} ({METADATA_COPIES} copies of {METADATA_BYTES_PER_ENTRY} bytes each)",
layout.parent.display(),
root.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(crate::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;