Skip to main content

git_policy_cli/worktree/
worktree_subvolume.rs

1//! What:
2//!  The empty btrfs subvolume the wrapper makes at the destination of a `git worktree add`, before real Git runs, and
3//!  the cleanup of it when Git leaves it empty.
4//! Why:
5//!  A worktree that is a subvolume is a source of a later snapshot with no migration, and it is removed in one
6//!  call. Git accepts an existing empty directory as the destination and fills it, and an empty subvolume is an
7//!  empty directory (`doc/planning/cli-git-btrfs-worktree.md`, "Creating new worktrees as subvolumes"). Git removes the
8//!  destination again only when it created it and failed late; a failure before that point (a branch that exists, a
9//!  name Git rejects) leaves the directory, so the wrapper removes the empty subvolume itself. A destination that is
10//!  not on btrfs, or a subvolume the kernel refuses, leaves Git to make its ordinary directory without a word to the
11//!  person, with one diagnostic line for the log.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const made = await prepareSubvolumeDestination(plan); // undefined: Git makes the directory
16//! await runGit();
17//! if (made) await settleSubvolumeDestination(made); // rmdir when still empty
18//! ```
19
20use crate::diagnostic_log::{debug, warn};
21use crate::fs_beneath::{ObjectId, handle_path, identity_at, identity_of, open_directory_at, remove_at};
22use crate::fs_btrfs_ioctl::subvolume_create_at;
23use crate::fs_kind::{FilesystemKind, filesystem_kind};
24use crate::worktree_add_plan::AddPlan;
25use std::path::{Path, PathBuf};
26
27/// Tag used for diagnostics from this module.
28const TAG: &str = "worktreeSubvolume";
29
30/// What:
31///  An empty subvolume this process created at a destination.
32/// Why:
33///   Only a destination the wrapper created is the wrapper's to remove again.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type CreatedSubvolume = { path: string; identity?: ObjectId };
38/// ```
39#[derive(Debug, Eq, PartialEq)]
40pub struct CreatedSubvolume {
41    /// The destination, an empty subvolume when it was created.
42    pub path: PathBuf,
43    /// What the created subvolume was when it was looked at right after its creation: a path proves nothing about which
44    ///  object it names later. Absent when it could not be looked at, in which case settlement leaves it alone.
45    pub identity: Option<ObjectId>,
46}
47
48/// What:
49///  Create the destination of a planned `git worktree add` as an empty subvolume, when it can be one.
50///  `Option<CreatedSubvolume>` is the created subvolume or nothing.
51/// Why:
52///   The destination must be absent (an existing one is Git's to accept or refuse) and its parent must be on btrfs. The
53///  creation ioctl is unrestricted for a regular user, so a refusal is a fault the log keeps and Git's ordinary directory
54///  replaces.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// async function prepareSubvolumeDestination(plan: AddPlan): Promise<CreatedSubvolume | undefined>;
59/// ```
60pub fn prepare_subvolume_destination(plan: &AddPlan) -> Option<CreatedSubvolume> {
61    let destination: &Path = plan.destination.as_path();
62    if std::fs::symlink_metadata(destination).is_ok() {
63        debug(TAG, format!("{} exists already; Git decides whether it is acceptable", destination.display()).as_str());
64        return None;
65    }
66    let parent: &Path = destination.parent()?;
67    match filesystem_kind(parent) {
68        Ok(FilesystemKind::Btrfs) => {}
69        Ok(_) => return None,
70        Err(error) => {
71            debug(TAG, format!("the filesystem of {} is unknown ({error}); Git makes the directory", parent.display()).as_str());
72            return None;
73        }
74    }
75    if let Err(error) = subvolume_create_at(destination) {
76        debug(TAG, format!("no subvolume at {} ({error}); Git makes the directory", destination.display()).as_str());
77        return None;
78    }
79    debug(TAG, format!("created the empty subvolume {}", destination.display()).as_str());
80    let identity: Option<ObjectId> = std::fs::File::open(parent)
81        .and_then(|held: std::fs::File| { return identity_at(&held, destination.file_name().unwrap_or_default()); })
82        .inspect_err(|error: &std::io::Error| { debug(TAG, format!("the identity of the created subvolume {} cannot be read ({error}); settlement will leave it alone", destination.display()).as_str()); })
83        .ok();
84    return Some(CreatedSubvolume { path: destination.to_path_buf(), identity });
85}
86
87/// What:
88///  After Git ended, remove the created subvolume if it is still the one this process created and still empty.
89///  `rmdir` deletes an empty subvolume without the `user_subvol_rm_allowed` mount option.
90/// Why:
91///   Git leaves an empty destination when it failed before it started to fill it, and a leftover empty subvolume would
92///  make the next attempt at the same path look like an accepted empty directory. A destination that holds anything
93///  is Git's result (or its late failure's) and stays, and so does an object that another process put at the path: the
94///  identity recorded at creation must still be the identity of what the name leads to, and the emptiness is read through
95///  the descriptor of that object. The last step names the entry in the held parent, and `rmdir` removes only an empty
96///  directory, so a swap in that instant could remove only an empty directory, never data.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// async function settleSubvolumeDestination(made: CreatedSubvolume): Promise<void>;
101/// ```
102pub fn settle_subvolume_destination(created: &CreatedSubvolume) {
103    let shown: &Path = created.path.as_path();
104    let Some(expected) = created.identity else {
105        debug(TAG, format!("{} was not identified when it was created; left alone", shown.display()).as_str());
106        return;
107    };
108    let (Some(parent_path), Some(name)) = (shown.parent(), shown.file_name()) else {
109        debug(TAG, format!("{} has no parent and name; left alone", shown.display()).as_str());
110        return;
111    };
112    let parent: std::fs::File = match std::fs::File::open(parent_path) {
113        Ok(parent) => parent,
114        Err(error) => {
115            debug(TAG, format!("{} cannot be settled ({error}); left alone", parent_path.display()).as_str());
116            return;
117        }
118    };
119    let held: std::fs::File = match open_directory_at(&parent, name) {
120        Ok(held) => held,
121        Err(error) => {
122            debug(TAG, format!("{} cannot be opened ({error}); left alone", shown.display()).as_str());
123            return;
124        }
125    };
126    if identity_of(&held).ok() != Some(expected) {
127        warn(TAG, format!("{} is not the empty subvolume this command created, so it is left in place; remove it yourself if it is not wanted", shown.display()).as_str());
128        return;
129    }
130    let mut listing = match std::fs::read_dir(handle_path(&held)) {
131        Ok(listing) => listing,
132        Err(error) => {
133            debug(TAG, format!("{} cannot be listed ({error}); left alone", shown.display()).as_str());
134            return;
135        }
136    };
137    if listing.next().is_some() {
138        return;
139    }
140    match remove_at(&parent, name, true) {
141        Ok(()) => debug(TAG, format!("removed the empty subvolume {} that Git did not fill", shown.display()).as_str()),
142        Err(error) => debug(TAG, format!("could not remove the empty subvolume {} ({error})", shown.display()).as_str()),
143    }
144}
145
146/// Subvolume destination controls stay out of the release executable.
147#[cfg(test)]
148#[path = "worktree_subvolume_tests.rs"]
149mod tests;