1use crate::diagnostic_log::{debug, warn};
27use crate::fs_beneath::{ObjectId, handle_path, identity_at, identity_of, open_directory_at, open_file_at, open_root, remove_at};
28use crate::fs_btrfs_ioctl::{snapshot_destroy_by_id, snapshot_destroy_in, subvolume_id};
29use crate::fs_kind::is_subvolume_root;
30use crate::fs_mount_options::read_mount_facts;
31use crate::subvolume_remove_walk::{empty_directory, verify_below};
32use crate::transaction_git::GitContext;
33use crate::worktree_clean::check_clean;
34use crate::worktree_remove_plan::RemovePlan;
35use crate::worktree_remove_registry::{Registration, registration_of};
36use std::ffi::OsString;
37use std::fs::File;
38use std::io::Read;
39use std::path::{Path, PathBuf};
40
41const TAG: &str = "worktreeRemove";
43
44const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
46
47pub 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).";
49
50fn lock_state(admin: &Path) -> Result<bool, String> {
61 let entry: PathBuf = admin.join("locked");
62 return match entry.symlink_metadata() {
63 Ok(_found) => Ok(true),
64 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
65 Err(error) => Err(format!("the lock entry {} cannot be looked up ({error}), so the worktree is not known to be unlocked", entry.display())),
66 };
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
80pub enum RemoveOutcome {
81 LeftToGit,
83 Removed,
85 Incomplete(String),
87}
88
89#[derive(Clone, Copy, Debug)]
99pub struct RemoveRequest<'a> {
100 pub context: &'a GitContext,
102 pub plan: &'a RemovePlan,
104 pub common_dir: &'a Path,
106}
107
108#[derive(Debug)]
120pub struct Held {
121 pub parent: File,
123 pub parent_path: PathBuf,
125 pub name: OsString,
127 pub target: File,
129 pub identity: ObjectId,
131 pub parent_identity: ObjectId,
133 pub worktrees: File,
135 pub admin: File,
137 pub admin_identity: ObjectId,
139}
140
141pub trait RemoveOperations {
154 fn is_subvolume_root(&self, path: &Path) -> bool;
156 fn removal_allowed(&self, path: &Path) -> bool;
158 fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String>;
160 fn destroy(&self, held: &Held) -> Result<(), String>;
162}
163
164#[derive(Clone, Copy, Debug, Default)]
166pub struct LinuxOperations;
167
168fn by_id_unavailable(errno: i32) -> bool {
171 return errno == libc::ENOTTY || errno == libc::EOPNOTSUPP || errno == libc::EINVAL;
172}
173
174impl RemoveOperations for LinuxOperations {
176 fn is_subvolume_root(&self, path: &Path) -> bool {
177 return is_subvolume_root(path).unwrap_or(false);
178 }
179
180 fn removal_allowed(&self, path: &Path) -> bool {
181 return read_mount_facts(path).ok().flatten().is_some_and(|facts| { return facts.has_option(REMOVAL_OPTION); });
182 }
183
184 fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String> {
185 return check_clean(request.context, request.plan.target.as_path(), admin, common_dir).map(drop).map_err(|refusal| { return refusal.reason; });
186 }
187
188 fn destroy(&self, held: &Held) -> Result<(), String> {
189 let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
190 match subvolume_id(&held.target, shown.as_path()) {
192 Ok(id) => match snapshot_destroy_by_id(&held.parent, id, shown.as_path()) {
193 Ok(()) => return Ok(()),
194 Err(error) if by_id_unavailable(error.errno) => {
195 debug(TAG, format!("the destroy of {} by id is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
196 }
197 Err(error) => return Err(error.to_string()),
198 },
199 Err(error) if by_id_unavailable(error.errno) => {
200 debug(TAG, format!("the id of the subvolume {} is not available ({error}); destroying it by name below the held parent", shown.display()).as_str());
201 }
202 Err(error) => return Err(error.to_string()),
203 }
204 return snapshot_destroy_in(&held.parent, held.name.as_os_str(), held.parent_path.as_path()).map_err(|error| { return error.to_string(); });
205 }
206}
207
208fn hold(target: &Path, registration: &Registration) -> Result<Held, String> {
218 let parent_path: PathBuf = target.parent().ok_or_else(|| { return format!("{} has no parent directory", target.display()); })?.to_path_buf();
219 let name: OsString = target.file_name().ok_or_else(|| { return format!("{} has no last component", target.display()); })?.to_os_string();
220 let refused = |what: &str, path: &Path, error: std::io::Error| -> String { return format!("{what} {} cannot be opened without following a link ({error})", path.display()); };
221 let parent: File = open_root(parent_path.as_path()).map_err(|error: std::io::Error| { return refused("the directory", parent_path.as_path(), error); })?;
222 let held_target: File = open_directory_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return refused("the worktree", target, error); })?;
223 let worktrees: File = open_root(registration.worktrees.as_path()).map_err(|error: std::io::Error| { return refused("the directory", registration.worktrees.as_path(), error); })?;
224 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); })?;
225 let identity_error = |error: std::io::Error| -> String { return format!("the identity of {} cannot be read ({error})", target.display()); };
226 return Ok(Held {
227 identity: identity_of(&held_target).map_err(identity_error)?,
228 parent_identity: identity_of(&parent).map_err(identity_error)?,
229 admin_identity: identity_of(&admin).map_err(identity_error)?,
230 parent,
231 parent_path,
232 name,
233 target: held_target,
234 worktrees,
235 admin,
236 });
237}
238
239fn confirm(held: &Held, registration: &Registration) -> Result<(), String> {
250 let shown: PathBuf = held.parent_path.join(held.name.as_os_str());
251 if lock_state(handle_path(&held.admin).as_path())? {
252 return Err(format!("{} was locked after it was proven clean", shown.display()));
253 }
254 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()); })?;
255 if named_now != held.identity {
256 return Err(format!("{} is not the directory that was proven clean any more", shown.display()));
257 }
258 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()); })?;
259 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 {
260 return Err(format!("{} is not the directory that held the worktree any more", held.parent_path.display()));
261 }
262 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()); })?;
263 if admin_now != held.admin_identity {
264 return Err(format!("{} is not the registration that was proven any more", registration.admin.display()));
265 }
266 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()); })?;
267 if recorded != registration.gitdir_text {
268 return Err(format!("{} changed after it was proven", registration.admin.join("gitdir").display()));
269 }
270 return Ok(());
271}
272
273fn read_held_file(held: &Held, name: &str) -> std::io::Result<Vec<u8>> {
275 let mut file: File = open_file_at(&held.admin, std::ffi::OsStr::new(name))?;
276 let mut bytes: Vec<u8> = Vec::new();
277 file.read_to_end(&mut bytes)?;
278 return Ok(bytes);
279}
280
281fn prune_command(common_dir: &Path) -> String {
283 return match common_dir.to_str().and_then(|text: &str| { return text.strip_suffix("/.git"); }) {
284 Some(main) => format!("git -C {main} worktree prune"),
285 None => format!("git --git-dir={} worktree prune", common_dir.display()),
286 };
287}
288
289fn incomplete_report(target: &Path, registration: &Registration, common_dir: &Path, reason: &str) -> String {
301 let prune: String = prune_command(common_dir);
302 return format!(
303 "cli-git: the worktree {target} was removed (its subvolume was destroyed), but its registration {admin} was not: {reason}.\n\
304 Git still lists the worktree as missing. To finish, run `{prune}`; it forgets every registration whose directory is \
305 missing, and `{prune} --dry-run --verbose` lists them first.\n\
306 If that fails the same way, make {admin} and {worktrees} writable by you (`chmod --recursive u+rwx {admin}`) and \
307 remove {admin} yourself (`rm --recursive {admin}`).\n\
308 Running `git worktree remove` for {target} again cannot finish this: the directory is already gone.",
309 target = target.display(),
310 admin = registration.admin.display(),
311 worktrees = registration.worktrees.display(),
312 reason = reason,
313 prune = prune
314 );
315}
316
317fn finish(held: &Held, registration: &Registration, request: &RemoveRequest<'_>) -> RemoveOutcome {
328 let target: &Path = request.plan.target.as_path();
329 let incomplete = |reason: String| -> RemoveOutcome {
330 let report: String = incomplete_report(target, registration, request.common_dir, reason.as_str());
331 warn(TAG, report.as_str());
332 return RemoveOutcome::Incomplete(report);
333 };
334 match identity_at(&held.worktrees, registration.id.as_os_str()) {
335 Ok(found) if found == held.admin_identity => {}
336 Ok(_other) => return incomplete(String::from("another registration took its place while the worktree was being removed, and it was left untouched")),
337 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
338 debug(TAG, format!("the registration {} is already gone", registration.admin.display()).as_str());
339 return RemoveOutcome::Removed;
340 }
341 Err(error) => return incomplete(format!("it cannot be looked up ({error})")),
342 }
343 let admin: File = match held.admin.try_clone() {
344 Ok(admin) => admin,
345 Err(error) => return incomplete(format!("its directory cannot be used ({error})")),
346 };
347 if let Err(error) = verify_below(&admin, registration.admin.as_path()).and_then(|()| { return empty_directory(&admin, registration.admin.as_path()); }) {
348 return incomplete(format!("its content cannot be removed ({error})"));
349 }
350 if let Err(error) = remove_at(&held.worktrees, registration.id.as_os_str(), true) {
351 return incomplete(format!("the directory cannot be removed from {} ({error})", registration.worktrees.display()));
352 }
353 let _kept = std::fs::remove_dir(registration.worktrees.as_path());
355 debug(TAG, format!("destroyed the subvolume {} and its administrative directory", target.display()).as_str());
356 return RemoveOutcome::Removed;
357}
358
359pub fn remove_subvolume_worktree(request: &RemoveRequest<'_>) -> RemoveOutcome {
369 return remove_with(&LinuxOperations, request);
370}
371
372pub fn remove_with(operations: &dyn RemoveOperations, request: &RemoveRequest<'_>) -> RemoveOutcome {
382 let target: &Path = request.plan.target.as_path();
383 let registration: Registration = match registration_of(request.common_dir, target, request.plan.operand.as_os_str()) {
384 Ok(found) => found,
385 Err(reason) => {
386 debug(TAG, format!("{} is not served ({reason}); Git decides", target.display()).as_str());
387 return RemoveOutcome::LeftToGit;
388 }
389 };
390 match lock_state(registration.admin.as_path()) {
391 Ok(false) => {}
392 Ok(true) => {
393 debug(TAG, format!("{} is locked; Git decides", target.display()).as_str());
394 return RemoveOutcome::LeftToGit;
395 }
396 Err(reason) => {
397 debug(TAG, format!("{reason}; Git decides").as_str());
398 return RemoveOutcome::LeftToGit;
399 }
400 }
401 if !operations.is_subvolume_root(target) {
402 debug(TAG, format!("{} is not a subvolume; Git removes it file by file", target.display()).as_str());
403 return RemoveOutcome::LeftToGit;
404 }
405 if !operations.removal_allowed(target) {
406 debug(TAG, format!("the mount of {} lacks {REMOVAL_OPTION}; Git removes the worktree file by file", target.display()).as_str());
407 return RemoveOutcome::LeftToGit;
408 }
409 let held: Held = match hold(target, ®istration) {
410 Ok(held) => held,
411 Err(reason) => {
412 debug(TAG, format!("{reason}; Git decides").as_str());
413 return RemoveOutcome::LeftToGit;
414 }
415 };
416 if let Err(reason) = operations.check_clean(request, registration.admin.as_path(), request.common_dir) {
417 debug(TAG, format!("{} is not proven clean ({reason}); Git decides", target.display()).as_str());
418 return RemoveOutcome::LeftToGit;
419 }
420 if let Err(reason) = confirm(&held, ®istration) {
421 debug(TAG, format!("{reason}; Git decides").as_str());
422 return RemoveOutcome::LeftToGit;
423 }
424 if let Err(error) = operations.destroy(&held) {
425 debug(TAG, format!("the subvolume of {} was not destroyed ({error}); Git removes it", target.display()).as_str());
426 return RemoveOutcome::LeftToGit;
427 }
428 return finish(&held, ®istration, request);
429}
430
431#[cfg(test)]
433#[path = "worktree_remove_tests.rs"]
434mod tests;
435
436#[cfg(test)]
438#[path = "worktree_remove_guard_tests.rs"]
439mod guard_tests;