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_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 = "cli-git: 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 read_mount_facts(path).ok().flatten().is_some_and(|facts| { return facts.has_option(REMOVAL_OPTION); });
}
fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String> {
return check_clean(request.context, request.plan.target.as_path(), admin, common_dir).map(drop).map_err(|refusal| { return 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 snapshot_destroy_in(&held.parent, held.name.as_os_str(), held.parent_path.as_path()).map_err(|error| { return error.to_string(); });
}
}
fn hold(target: &Path, registration: &Registration) -> Result<Held, String> {
let parent_path: PathBuf = target.parent().ok_or_else(|| { return format!("{} has no parent directory", target.display()); })?.to_path_buf();
let name: OsString = target.file_name().ok_or_else(|| { return format!("{} has no last component", target.display()); })?.to_os_string();
let refused = |what: &str, path: &Path, error: std::io::Error| -> String { return format!("{what} {} cannot be opened without following a link ({error})", path.display()); };
let parent: File = open_root(parent_path.as_path()).map_err(|error: std::io::Error| { return refused("the directory", parent_path.as_path(), error); })?;
let held_target: File = open_directory_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return refused("the worktree", target, error); })?;
let worktrees: File = open_root(registration.worktrees.as_path()).map_err(|error: std::io::Error| { return refused("the directory", registration.worktrees.as_path(), error); })?;
let admin: File = open_directory_at(&worktrees, registration.id.as_os_str()).map_err(|error: std::io::Error| { return refused("the registration", registration.admin.as_path(), error); })?;
let identity_error = |error: std::io::Error| -> String { return format!("the identity of {} cannot be read ({error})", target.display()); };
return Ok(Held {
identity: identity_of(&held_target).map_err(identity_error)?,
parent_identity: identity_of(&parent).map_err(identity_error)?,
admin_identity: identity_of(&admin).map_err(identity_error)?,
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 = identity_at(&held.parent, held.name.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", shown.display()); })?;
if named_now != held.identity {
return Err(format!("{} is not the directory that was proven clean any more", shown.display()));
}
let parent_now: File = open_root(held.parent_path.as_path()).map_err(|error: std::io::Error| { return format!("{} cannot be opened again without following a link ({error})", held.parent_path.display()); })?;
if identity_of(&parent_now).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", held.parent_path.display()); })? != held.parent_identity {
return Err(format!("{} is not the directory that held the worktree any more", held.parent_path.display()));
}
let admin_now: ObjectId = identity_at(&held.worktrees, registration.id.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be looked up again ({error})", registration.admin.display()); })?;
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> = read_held_file(held, "gitdir").map_err(|error: std::io::Error| { return format!("{} cannot be read again ({error})", registration.admin.join("gitdir").display()); })?;
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 {
return match common_dir.to_str().and_then(|text: &str| { return text.strip_suffix("/.git"); }) {
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!(
"cli-git: 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 finish(held: &Held, registration: &Registration, request: &RemoveRequest<'_>) -> RemoveOutcome {
let target: &Path = request.plan.target.as_path();
let incomplete = |reason: String| -> RemoveOutcome {
let report: String = incomplete_report(target, registration, request.common_dir, reason.as_str());
warn(TAG, report.as_str());
return RemoveOutcome::Incomplete(report);
};
match identity_at(&held.worktrees, registration.id.as_os_str()) {
Ok(found) if found == held.admin_identity => {}
Ok(_other) => return incomplete(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(format!("it cannot be looked up ({error})")),
}
let admin: File = match held.admin.try_clone() {
Ok(admin) => admin,
Err(error) => return incomplete(format!("its directory cannot be used ({error})")),
};
if let Err(error) = verify_below(&admin, registration.admin.as_path()).and_then(|()| { return empty_directory(&admin, registration.admin.as_path()); }) {
return incomplete(format!("its content cannot be removed ({error})"));
}
if let Err(error) = remove_at(&held.worktrees, registration.id.as_os_str(), true) {
return incomplete(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;