Skip to main content

git_policy_cli/worktree/
worktree_remove.rs

1//! What:
2//!  The removal of a clean linked worktree that is a btrfs subvolume, by one subvolume destroy call and the
3//!  deletion of its administrative directory, in place of Git's file-by-file removal.
4//! Why:
5//!  Git unlinks every file of the worktree (0.7 s without ignored state, 3.6 s with a large ignored tree in the
6//!  measurements of `doc/planning/cli-git-btrfs-worktree.md`), and the subvolume call takes milliseconds. The call
7//!  cannot be undone, so the wrapper takes it only when the same conditions hold that make Git remove without
8//!  `--force`: the worktree is registered in the repository the command runs in, under the name Git would pick, is not
9//!  locked, is clean (`worktree_clean.rs`), and holds no submodule. The mount must also allow the call to an
10//!  unprivileged user (`user_subvol_rm_allowed`). Every other case, and every call that the kernel refuses, is left to
11//!  Git, which then words the refusal itself.
12//!
13//!  The check and the destroy are not one step, as Git's check and its deletion are not. Three things keep the
14//!  window small and its worst case no worse than Git's: the directories are held open from before the proof of
15//!  cleanliness to the destroy and the destroy names the subvolume by id (or by name below the held parent), the lock,
16//!  the identity of the worktree directory and of the registration are looked at again immediately before the destroy,
17//!  and the registration is emptied through its held descriptor, never by a path that could name another directory by
18//!  then. What remains is a write or a lock made by another process in the milliseconds between the last look and the
19//!  destroy; Git has the same window for its whole deletion, which takes seconds (`SPEC.md`).
20//!
21//! In TS you'd write (pseudocode):
22//! ```ts
23//! const outcome = await removeSubvolumeWorktree(request); // 'left-to-git': run git worktree remove
24//! ```
25
26use 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
41/// Tag used for diagnostics from this module.
42const TAG: &str = "worktreeRemove";
43
44/// The mount option that lets a regular user destroy a subvolume with the ioctl.
45const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
46
47/// What the command says on standard error whenever it destroys a subvolume.
48pub 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
50/// What:
51///  Whether the administrative directory holds a `locked` entry.
52/// Why:
53///   A lock is a promise to keep the worktree. Only an entry that is certainly absent (`NotFound`) means unlocked; any
54///  other failure to look it up leaves the lock unknown, and an unknown lock is not a reason to destroy.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// function lockState(admin: string): boolean; // throws when the entry cannot be looked up
59/// ```
60fn 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/// What:
70///  How one attempt to remove a worktree with a subvolume destroy ended.
71/// Why:
72///   Three endings need three different next steps: Git removes it (nothing changed), the work is done, or the files
73///  are gone and the registration is not, which only the person can finish and which must never reach Git.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// type RemoveOutcome = { kind: 'left-to-git' } | { kind: 'removed' } | { kind: 'incomplete'; report: string };
78/// ```
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub enum RemoveOutcome {
81    /// Nothing was changed; real Git removes the worktree or states its refusal.
82    LeftToGit,
83    /// The subvolume and the administrative directory are gone.
84    Removed,
85    /// The subvolume was destroyed but the registration could not be removed; the report is for standard error.
86    Incomplete(String),
87}
88
89/// What:
90///  What one removal is asked to do and in which repository.
91/// Why:
92///   The repository that was invoked decides which registrations count; the worktree's own pointers never do.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// type RemoveRequest = { context: GitContext; plan: RemovePlan; commonDir: string };
97/// ```
98#[derive(Clone, Copy, Debug)]
99pub struct RemoveRequest<'a> {
100    /// How to start real Git for the cleanliness proof.
101    pub context: &'a GitContext,
102    /// The directory the person named.
103    pub plan: &'a RemovePlan,
104    /// The canonical common Git directory of the repository the command was run in.
105    pub common_dir: &'a Path,
106}
107
108/// What:
109///  The directories one removal holds open from before the proof of cleanliness to the destroy, and the identities they
110///  had when they were looked at.
111/// Why:
112///   A descriptor names the object it was opened on, whatever happens to the path afterwards; the identities let the
113///  last look before the destroy tell a swapped name from the held object.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// type Held = { parent: FileHandle; name: string; target: FileHandle; identity: ObjectId; admin: FileHandle; ... };
118/// ```
119#[derive(Debug)]
120pub struct Held {
121    /// The directory that holds the worktree directory.
122    pub parent: File,
123    /// The canonical path of `parent`, for messages and for the check that the path still names it.
124    pub parent_path: PathBuf,
125    /// The name of the worktree directory in `parent`.
126    pub name: OsString,
127    /// The worktree directory itself (the root of its subvolume).
128    pub target: File,
129    /// The identity of `target` when it was opened.
130    pub identity: ObjectId,
131    /// The identity of `parent` when it was opened.
132    pub parent_identity: ObjectId,
133    /// The `worktrees` directory of the repository.
134    pub worktrees: File,
135    /// The administrative directory of the worktree.
136    pub admin: File,
137    /// The identity of `admin` when it was opened.
138    pub admin_identity: ObjectId,
139}
140
141/// What:
142///  The operations of one removal that touch the kernel or start Git, behind one trait.
143///  `&self` borrows the implementation read-only; `dyn RemoveOperations` is how a caller holds "any implementation"
144///  (a TS interface).
145/// Why:
146///   The ordering of the checks is the same on any filesystem; a stand-in lets the controls run it on a plain
147///  directory tree, and swap objects between two steps to prove the identity checks.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// interface RemoveOperations { isSubvolumeRoot(path): boolean; removalAllowed(path): boolean; checkClean(...): void; destroy(held): void }
152/// ```
153pub trait RemoveOperations {
154    /// Whether `path` is the root of a subvolume.
155    fn is_subvolume_root(&self, path: &Path) -> bool;
156    /// Whether an unprivileged destroy is allowed on the mount holding `path`.
157    fn removal_allowed(&self, path: &Path) -> bool;
158    /// The proof that the worktree is clean, or the reason it is not proven.
159    fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, common_dir: &Path) -> Result<(), String>;
160    /// Destroy the subvolume that `held` holds, and nothing else.
161    fn destroy(&self, held: &Held) -> Result<(), String>;
162}
163
164/// The kernel and Git, as they are.
165#[derive(Clone, Copy, Debug, Default)]
166pub struct LinuxOperations;
167
168/// Whether an errno from the destroy by id means this kernel lacks the call, so that the destroy by name below the held
169///  parent is the way left.
170fn by_id_unavailable(errno: i32) -> bool {
171    return errno == libc::ENOTTY || errno == libc::EOPNOTSUPP || errno == libc::EINVAL;
172}
173
174/// Every operation is the primitive of the same name.
175impl 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        // The id names the subvolume that was held, whatever path it has by now.
191        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
208/// What:
209///  Open the worktree directory, its parent, and its registration, and record what each one is.
210/// Why:
211///   Everything after this point is about these objects, not about the paths that name them.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// async function hold(target: string, registration: Registration): Promise<Held>; // throws the reason
216/// ```
217fn 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
239/// What:
240///  The last look before the destroy: the lock, and that the names still lead to the objects that were held and proven.
241/// Why:
242///   The proof of cleanliness takes time (it starts Git); a lock taken, a directory moved away and another put in its
243///  place, an ancestor replaced by a link, or a registration replaced in that time must stop the destroy.
244///
245/// In TS you'd write (pseudocode):
246/// ```ts
247/// async function confirm(held: Held, registration: Registration): Promise<void>; // throws the reason
248/// ```
249fn 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
273/// The bytes of a file of the held administrative directory.
274fn 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
281/// The command that forgets a registration whose directory is gone, aimed at the repository of `common_dir`.
282fn 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
289/// What:
290///  The report of a removal that destroyed the files but left the registration: what is gone, what is left and where, why,
291///  and each way to finish.
292/// Why:
293///   Git would now answer that the path is not a working tree, so the person needs the leftover's name and the command
294///  that removes it, and must not be told that the removal succeeded.
295///
296/// In TS you'd write (pseudocode):
297/// ```ts
298/// function incompleteReport(args: { target: string; admin: string; commonDir: string; reason: string }): string;
299/// ```
300fn 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
317/// What:
318///  After the destroy, remove the registration through the descriptors that were held, and report how that ended.
319/// Why:
320///   The registration is emptied through its held descriptor and removed from the held `worktrees` directory by name, which
321///  succeeds only for an empty directory; a registration that another process replaced is therefore never touched.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// async function finish(held: Held, registration: Registration, request: RemoveRequest): Promise<RemoveOutcome>;
326/// ```
327fn 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    // Git removes the `worktrees` directory when the last registration goes; a directory that still holds others stays.
354    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
359/// What:
360///  Remove the worktree if every condition holds, with the kernel and Git as they are.
361/// Why:
362///   See `remove_with`.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// async function removeSubvolumeWorktree(request: RemoveRequest): Promise<RemoveOutcome>;
367/// ```
368pub fn remove_subvolume_worktree(request: &RemoveRequest<'_>) -> RemoveOutcome {
369    return remove_with(&LinuxOperations, request);
370}
371
372/// What:
373///  Remove the worktree if every condition holds; report how it ended.
374/// Why:
375///   `LeftToGit` means Git has to do the removal, and nothing was changed.
376///
377/// In TS you'd write (pseudocode):
378/// ```ts
379/// async function removeWith(operations: RemoveOperations, request: RemoveRequest): Promise<RemoveOutcome>;
380/// ```
381pub 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, &registration) {
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, &registration) {
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, &registration, request);
429}
430
431/// Subvolume removal controls stay out of the release executable.
432#[cfg(test)]
433#[path = "worktree_remove_tests.rs"]
434mod tests;
435
436/// Controls that swap objects between the steps of a removal run on a stand-in for the kernel.
437#[cfg(test)]
438#[path = "worktree_remove_guard_tests.rs"]
439mod guard_tests;