use crate::diagnostic_log::{debug, warn};
use crate::fs_beneath::{ObjectId, handle_path, identity_at, identity_of, open_directory_at, open_file_at, open_root, remove_at};
use crate::fs_btrfs_ioctl::{snapshot_destroy_by_id, snapshot_destroy_in, subvolume_id};
use crate::fs_kind::is_subvolume_root;
use crate::fs_mount_options::read_mount_facts;
use crate::subvolume_remove_walk::{empty_directory, verify_below};
use crate::transaction_git::GitContext;
use crate::worktree_clean::check_clean;
use crate::worktree_path_failure_message::path_failure;
use crate::worktree_remove_plan::RemovePlan;
use crate::worktree_remove_registry::{Registration, registration_of};
use std::ffi::OsString;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
const TAG: &str = "worktreeRemove";
const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
pub const DESTROY_NOTICE: &str = "monochromatic-git-policy-cli: destroyed the btrfs subvolume of the removed worktree in one step; this cannot be undone, and anything another process wrote or locked in the moments since the last check went with it (the risk of git worktree remove, here faster).";
fn lock_state(admin: &Path) -> Result<bool, String> {
let entry: PathBuf = admin.join("locked");
return match entry.symlink_metadata() {
Ok(_found) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!("the lock entry {} cannot be looked up ({error}), so the worktree is not known to be unlocked", entry.display())),
};
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RemoveOutcome {
LeftToGit,
Removed,
Incomplete(String),
}
#[derive(Clone, Copy, Debug)]
pub struct RemoveRequest<'a> {
pub context: &'a GitContext,
pub plan: &'a RemovePlan,
pub common_dir: &'a Path,
}
#[derive(Debug)]
pub struct Held {
pub parent: File,
pub parent_path: PathBuf,
pub name: OsString,
pub target: File,
pub identity: ObjectId,
pub parent_identity: ObjectId,
pub worktrees: File,
pub admin: File,
pub admin_identity: ObjectId,
}
pub trait RemoveOperations {
fn is_subvolume_root(&self, path: &Path) -> bool;
fn removal_allowed(&self, path: &Path) -> bool;
fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String>;
fn destroy(&self, held: &Held) -> Result<(), String>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct LinuxOperations;
fn by_id_unavailable(errno: i32) -> bool {
return errno == libc::ENOTTY || errno == libc::EOPNOTSUPP || errno == libc::EINVAL;
}
impl RemoveOperations for LinuxOperations {
fn is_subvolume_root(&self, path: &Path) -> bool {
return is_subvolume_root(path).unwrap_or(false);
}
fn removal_allowed(&self, path: &Path) -> bool {
return match read_mount_facts(path) {
Ok(Some(facts)) => facts.has_option(REMOVAL_OPTION),
_ => false,
};
}
fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String> {
return match check_clean(request.context, request.plan.target.as_path(), admin, common_dir) {
Ok(_proof) => Ok(()),
Err(refusal) => Err(refusal.reason),
};
}
fn destroy(&self, held: &Held) -> Result<(), String> {
let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
match subvolume_id(&held.target, shown.as_path()) {
Ok(id) => match snapshot_destroy_by_id(&held.parent, id, shown.as_path()) {
Ok(()) => return Ok(()),
Err(error) if by_id_unavailable(error.errno) => {
debug(TAG, format!("the destroy of {} by id is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
}
Err(error) => return Err(error.to_string()),
},
Err(error) if by_id_unavailable(error.errno) => {
debug(TAG, format!("the id of the subvolume {} is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
}
Err(error) => return Err(error.to_string()),
}
return match snapshot_destroy_in(&held.parent, held.name.as_os_str(), held.parent_path.as_path()) {
Ok(()) => Ok(()),
Err(error) => Err(error.to_string()),
};
}
}
fn refused<T>(outcome: std::io::Result<T>, what: &str, path: &Path) -> Result<T, String> {
return match outcome {
Ok(value) => Ok(value),
Err(error) => Err(format!("{what} {} cannot be opened without following a link ({error})", path.display())),
};
}
fn identity_unreadable<T>(outcome: std::io::Result<T>, target: &Path) -> Result<T, String> {
return match outcome {
Ok(value) => Ok(value),
Err(error) => Err(format!("the identity of {} cannot be read ({error})", target.display())),
};
}
fn hold(target: &Path, registration: &Registration) -> Result<Held, String> {
let Some(parent_of_target) = target.parent() else {
return Err(format!("{} has no parent directory", target.display()));
};
let parent_path: PathBuf = parent_of_target.to_path_buf();
let Some(last_component) = target.file_name() else {
return Err(format!("{} has no last component", target.display()));
};
let name: OsString = last_component.to_os_string();
let parent: File = refused(open_root(parent_path.as_path()), "the directory", parent_path.as_path())?;
let held_target: File = refused(open_directory_at(&parent, name.as_os_str()), "the worktree", target)?;
let worktrees: File = refused(open_root(registration.worktrees.as_path()), "the directory", registration.worktrees.as_path())?;
let admin: File = refused(open_directory_at(&worktrees, registration.id.as_os_str()), "the registration", registration.admin.as_path())?;
return Ok(Held {
identity: identity_unreadable(identity_of(&held_target), target)?,
parent_identity: identity_unreadable(identity_of(&parent), target)?,
admin_identity: identity_unreadable(identity_of(&admin), target)?,
parent,
parent_path,
name,
target: held_target,
worktrees,
admin,
});
}
fn confirm(held: &Held, registration: &Registration) -> Result<(), String> {
let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
if lock_state(handle_path(&held.admin).as_path())? {
return Err(format!("{} was locked after it was proven clean", shown.display()));
}
let named_now: ObjectId = path_failure(identity_at(&held.parent, held.name.as_os_str()), shown.as_path(), "cannot be looked up again")?;
if named_now != held.identity {
return Err(format!("{} is not the directory that was proven clean any more", shown.display()));
}
let parent_now: File = path_failure(open_root(held.parent_path.as_path()), held.parent_path.as_path(), "cannot be opened again without following a link")?;
if path_failure(identity_of(&parent_now), held.parent_path.as_path(), "cannot be looked up again")? != held.parent_identity {
return Err(format!("{} is not the directory that held the worktree any more", held.parent_path.display()));
}
let admin_now: ObjectId = path_failure(identity_at(&held.worktrees, registration.id.as_os_str()), registration.admin.as_path(), "cannot be looked up again")?;
if admin_now != held.admin_identity {
return Err(format!("{} is not the registration that was proven any more", registration.admin.display()));
}
let recorded: Vec<u8> = path_failure(read_held_file(held, "gitdir"), registration.admin.join("gitdir").as_path(), "cannot be read again")?;
if recorded != registration.gitdir_text {
return Err(format!("{} changed after it was proven", registration.admin.join("gitdir").display()));
}
return Ok(());
}
fn read_held_file(held: &Held, name: &str) -> std::io::Result<Vec<u8>> {
let mut file: File = open_file_at(&held.admin, std::ffi::OsStr::new(name))?;
let mut bytes: Vec<u8> = Vec::new();
file.read_to_end(&mut bytes)?;
return Ok(bytes);
}
fn prune_command(common_dir: &Path) -> String {
let main_checkout: Option<&str> = match common_dir.to_str() {
Some(text) => text.strip_suffix("/.git"),
None => None,
};
return match main_checkout {
Some(main) => format!("git -C {main} worktree prune"),
None => format!("git --git-dir={} worktree prune", common_dir.display()),
};
}
fn incomplete_report(target: &Path, registration: &Registration, common_dir: &Path, reason: &str) -> String {
let prune: String = prune_command(common_dir);
return format!(
"monochromatic-git-policy-cli: the worktree {target} was removed (its subvolume was destroyed), but its registration {admin} was not: {reason}.\n\
Git still lists the worktree as missing. To finish, run `{prune}`; it forgets every registration whose directory is \
missing, and `{prune} --dry-run --verbose` lists them first.\n\
If that fails the same way, make {admin} and {worktrees} writable by you (`chmod --recursive u+rwx {admin}`) and \
remove {admin} yourself (`rm --recursive {admin}`).\n\
Running `git worktree remove` for {target} again cannot finish this: the directory is already gone.",
target = target.display(),
admin = registration.admin.display(),
worktrees = registration.worktrees.display(),
reason = reason,
prune = prune
);
}
fn incomplete(registration: &Registration, request: &RemoveRequest<'_>, reason: String) -> RemoveOutcome {
let report: String = incomplete_report(request.plan.target.as_path(), registration, request.common_dir, reason.as_str());
warn(TAG, report.as_str());
return RemoveOutcome::Incomplete(report);
}
fn finish(held: &Held, registration: &Registration, request: &RemoveRequest<'_>) -> RemoveOutcome {
let target: &Path = request.plan.target.as_path();
match identity_at(&held.worktrees, registration.id.as_os_str()) {
Ok(found) if found == held.admin_identity => {}
Ok(_other) => return incomplete(registration, request, String::from("another registration took its place while the worktree was being removed, and it was left untouched")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
debug(TAG, format!("the registration {} is already gone", registration.admin.display()).as_str());
return RemoveOutcome::Removed;
}
Err(error) => return incomplete(registration, request, format!("it cannot be looked up ({error})")),
}
let admin: File = match held.admin.try_clone() {
Ok(admin) => admin,
Err(error) => return incomplete(registration, request, format!("its directory cannot be used ({error})")),
};
let emptied = match verify_below(&admin, registration.admin.as_path()) {
Ok(()) => empty_directory(&admin, registration.admin.as_path()),
Err(error) => Err(error),
};
if let Err(error) = emptied {
return incomplete(registration, request, format!("its content cannot be removed ({error})"));
}
if let Err(error) = remove_at(&held.worktrees, registration.id.as_os_str(), true) {
return incomplete(registration, request, format!("the directory cannot be removed from {} ({error})", registration.worktrees.display()));
}
let _kept = std::fs::remove_dir(registration.worktrees.as_path());
debug(TAG, format!("destroyed the subvolume {} and its administrative directory", target.display()).as_str());
return RemoveOutcome::Removed;
}
pub fn remove_subvolume_worktree(request: &RemoveRequest<'_>) -> RemoveOutcome {
return remove_with(&LinuxOperations, request);
}
pub fn remove_with(operations: &dyn RemoveOperations, request: &RemoveRequest<'_>) -> RemoveOutcome {
let target: &Path = request.plan.target.as_path();
let registration: Registration = match registration_of(request.common_dir, target, request.plan.operand.as_os_str()) {
Ok(found) => found,
Err(reason) => {
debug(TAG, format!("{} is not served ({reason}); Git decides", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
};
match lock_state(registration.admin.as_path()) {
Ok(false) => {}
Ok(true) => {
debug(TAG, format!("{} is locked; Git decides", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
Err(reason) => {
debug(TAG, format!("{reason}; Git decides").as_str());
return RemoveOutcome::LeftToGit;
}
}
if !operations.is_subvolume_root(target) {
debug(TAG, format!("{} is not a subvolume; Git removes it file by file", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
if !operations.removal_allowed(target) {
debug(TAG, format!("the mount of {} lacks {REMOVAL_OPTION}; Git removes the worktree file by file", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
let held: Held = match hold(target, ®istration) {
Ok(held) => held,
Err(reason) => {
debug(TAG, format!("{reason}; Git decides").as_str());
return RemoveOutcome::LeftToGit;
}
};
if let Err(reason) = operations.check_clean(request, registration.admin.as_path(), request.common_dir) {
debug(TAG, format!("{} is not proven clean ({reason}); Git decides", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
if let Err(reason) = confirm(&held, ®istration) {
debug(TAG, format!("{reason}; Git decides").as_str());
return RemoveOutcome::LeftToGit;
}
if let Err(error) = operations.destroy(&held) {
debug(TAG, format!("the subvolume of {} was not destroyed ({error}); Git removes it", target.display()).as_str());
return RemoveOutcome::LeftToGit;
}
return finish(&held, ®istration, request);
}
#[cfg(test)]
#[path = "worktree_remove_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "worktree_remove_guard_tests.rs"]
mod guard_tests;