Skip to main content

git_policy_cli/subvolume/
subvolume_preflight.rs

1//! What:
2//!  The paths of one migration and the refusals that stop it before anything is created: every refusal names its cause
3//!  and every remedy that applies.
4//! Why:
5//!  Converting a checkout swaps its directory under every process and worktree, so the migration starts only when the
6//!  filesystem, the checkout and the state left by an earlier run allow it. The conditions are those of the proven
7//!  script (`doc/handover/cli-git-subvolume-migration-script.md`, "Refusals") with the rest of the owner's rules:
8//!  nothing is spawned, and a refusal is text a person can act on. A condition checks only metadata (the dry run reads
9//!  `.git` and `.git/worktrees/*/gitdir`, and lists every directory of the tree once to count its entries for the free-space
10//!  check, without opening a file).
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const refusals: { cause: string; remedy: string }[] = preflight({ platform, layout, journal });
15//! ```
16
17use crate::fs_kind::FilesystemKind;
18use crate::single_owner_claim::live_owner_of;
19use crate::subvolume_entry_count::count_entries;
20use crate::subvolume_journal::Journal;
21use crate::subvolume_lock::{is_ours, token_of};
22use crate::subvolume_platform::Platform;
23use std::os::unix::fs::MetadataExt;
24use std::path::{Path, PathBuf};
25
26/// The metadata the copy writes for one entry, in bytes of the filesystem's own accounting: the `Metadata, DUP` figure of
27///  `btrfs filesystem df --block-size=1` read before and after reflink-copying a tree of 22,719 entries eight times
28///  (181,752 entries) on this host, three alternating rounds against a no-copy control of the same wall time:
29///  957.9, 979.0 and 984.8 bytes per entry, while the control moved by at most 9.9 MB against 174 MB to 179 MB for a copy.
30///  Rounded up to one kibibyte. Recorded in `doc/planning/cli-git-shipped-limits-audit.md`, "The free-space preflight". This is
31///  the coordinator's pick under the owner's delegation ("Pick options as if I had picked them"), open to the owner's veto.
32pub const METADATA_BYTES_PER_ENTRY: u64 = 1024;
33
34/// How many physical copies of the metadata the filesystem keeps: the `DUP` profile of this host writes each metadata byte twice
35///  (`Metadata ratio: 2.00` in `btrfs filesystem usage`), and the free space the check reads is physical. A filesystem with a
36///  single copy is over-estimated by this factor, which only refuses earlier.
37pub const METADATA_COPIES: u64 = 2;
38
39/// What:
40///  The free bytes the migration needs for the metadata of `entries` entries.
41/// Why:
42///   The copy shares data blocks and writes one metadata record per entry, so the need grows with the entries, not with a fixed
43///  floor; the measured cost of an entry and the copies of the metadata are the two named constants above. A count so large
44///  that the product overflows saturates at the largest `u64`, which no filesystem has free, so it refuses.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// const needed = Math.min(Number.MAX_SAFE_INTEGER, entries * METADATA_BYTES_PER_ENTRY * METADATA_COPIES);
49/// ```
50pub fn required_free_bytes(entries: u64) -> u64 {
51    // What: `saturating_mul` multiplies two `u64` values and gives the largest `u64` instead of wrapping around when the product is too big.
52    // Why: An absurd entry count must refuse, never wrap to a small number that passes.
53    //
54    // In TS you'd write (pseudocode):
55    // ```ts
56    // return Math.min(U64_MAX, entries * (METADATA_BYTES_PER_ENTRY * METADATA_COPIES));
57    // ```
58    return entries.saturating_mul(METADATA_BYTES_PER_ENTRY.saturating_mul(METADATA_COPIES));
59}
60
61/// The names whose presence in `.git` or in a linked worktree's administrative directory means Git is mid-operation.
62pub const IN_PROGRESS_NAMES: [&str; 12] = [
63    "index.lock",
64    "MERGE_HEAD",
65    "CHERRY_PICK_HEAD",
66    "REVERT_HEAD",
67    "BISECT_LOG",
68    "rebase-merge",
69    "rebase-apply",
70    "sequencer",
71    "HEAD.lock",
72    "config.lock",
73    "packed-refs.lock",
74    "shallow.lock",
75];
76
77/// What:
78///  Every path of one migration, derived from the checkout's path.
79/// Why:
80///   The new tree and the old tree sit beside the checkout (one parent, so the exchange stays on one filesystem), and
81///  the journal and the unreadable-file list sit beside them, outside the tree that is exchanged.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type Layout = { root: string; parent: string; newPath: string; oldPath: string; journalPath: string; unreadablePath: string; claimPrefix: string };
86/// ```
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct Layout {
89    /// The main checkout.
90    pub root: PathBuf,
91    /// Its parent directory.
92    pub parent: PathBuf,
93    /// `<root>.subvol-new`: the subvolume being filled, and after the exchange the old tree until it is renamed.
94    pub new_path: PathBuf,
95    /// `<root>.before-subvolume`: the original tree, kept until removed on request.
96    pub old_path: PathBuf,
97    /// `<parent>/.cli-git-migrate-<name>.journal.json`.
98    pub journal_path: PathBuf,
99    /// `<parent>/.cli-git-migrate-<name>.unreadable.txt`.
100    pub unreadable_path: PathBuf,
101    /// `.cli-git-migrate-<name>.owner`: the name, in the parent directory, of the numbered claim files that give one command
102    ///  at a time the migration of this checkout.
103    pub claim_prefix: std::ffi::OsString,
104}
105
106/// What:
107///  The layout for a checkout path.
108///  A root with no parent or no name (`/`) has no layout.
109/// Why:
110///   The names are fixed by the root's name, so a rerun finds the same files.
111///
112/// In TS you'd write (pseudocode):
113/// ```ts
114/// function layoutOf(root: string): Layout | undefined;
115/// ```
116pub fn layout_of(root: &Path) -> Option<Layout> {
117    let name: &std::ffi::OsStr = root.file_name()?;
118    let parent: &Path = root.parent()?;
119    let with_suffix = |suffix: &str| -> PathBuf {
120        let mut text: std::ffi::OsString = name.to_os_string();
121        text.push(suffix);
122        return parent.join(text);
123    };
124    let dotted = |suffix: &str| -> PathBuf {
125        let mut text: std::ffi::OsString = std::ffi::OsString::from(".cli-git-migrate-");
126        text.push(name);
127        text.push(suffix);
128        return parent.join(text);
129    };
130    return Some(Layout {
131        root: root.to_path_buf(),
132        parent: parent.to_path_buf(),
133        new_path: with_suffix(".subvol-new"),
134        old_path: with_suffix(".before-subvolume"),
135        journal_path: dotted(".journal.json"),
136        unreadable_path: dotted(".unreadable.txt"),
137        claim_prefix: dotted(".owner").file_name().unwrap_or_default().to_os_string(),
138    });
139}
140
141/// What:
142///  One refusal.
143/// Why:
144///   The cause says what is wrong with which path; the remedy lists every way forward, so the person never has to guess.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// type Refusal = { cause: string; remedy: string };
149/// ```
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct Refusal {
152    /// What is wrong.
153    pub cause: String,
154    /// What can be done about it.
155    pub remedy: String,
156}
157
158/// Shorthand for building a refusal from two texts.
159fn refuse(cause: String, remedy: &str) -> Refusal {
160    return Refusal { cause, remedy: remedy.to_string() };
161}
162
163/// What:
164///  The refusals of a fresh or resumed migration; empty means it may start or continue.
165/// Why:
166///   See the module description. The journal says which leftovers are this migration's own: the new tree after its
167///  `create` step began, the old tree after the `exchange` step began, `.git/index.lock` after the `lock` step began.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// const refusals = migrateRefusals(platform, layout, journal);
172/// ```
173pub fn migrate_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
174    let mut found: Vec<Refusal> = Vec::new();
175    let root: &Path = layout.root.as_path();
176    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(root) {
177        Ok(read) => read,
178        Err(error) => {
179            found.push(refuse(format!("{} cannot be examined: {error}", root.display()), "Run the command inside the repository's main checkout, or check the path."));
180            return found;
181        }
182    };
183    if !metadata.is_dir() {
184        found.push(refuse(format!("{} is not a directory (it is a symbolic link or a file)", root.display()), "Run the command from the real path of the main checkout."));
185        return found;
186    }
187    match platform.filesystem_facts(root) {
188        Ok((FilesystemKind::Btrfs, _)) => {}
189        Ok((kind, _)) => found.push(refuse(
190            format!("{} is on {}, not on btrfs, and only btrfs has subvolumes", root.display(), if kind == FilesystemKind::Overlayfs { "overlayfs" } else { "a filesystem other than btrfs" }),
191            "Nothing to convert here: on this filesystem `git worktree add` uses Git's checkout (on overlayfs the wrapper can reflink files inside the overlay). To get subvolume speed, move the checkout to a btrfs filesystem first.",
192        )),
193        Err(error) => found.push(refuse(format!("the filesystem of {} cannot be identified: {error}", root.display()), "Check that the path exists and that this user can read it.")),
194    }
195    // The need is the metadata of btrfs, so the tree is counted and the space compared only where the checkout is on btrfs.
196    let on_btrfs: bool = matches!(platform.filesystem_facts(root), Ok((FilesystemKind::Btrfs, _)));
197    if on_btrfs && let Ok((_, free)) = platform.filesystem_facts(layout.parent.as_path()) {
198        // Counting reads directory listings only; it is the one walk of the tree this check makes.
199        let entries: u64 = count_entries(root);
200        let needed: u64 = required_free_bytes(entries);
201        if free < needed {
202            found.push(refuse(
203                format!(
204                    "{} has {free} bytes free, fewer than the {needed} bytes the migration requires for the metadata of the {entries} entries of {} ({METADATA_COPIES} copies of {METADATA_BYTES_PER_ENTRY} bytes each)",
205                    layout.parent.display(),
206                    root.display(),
207                ),
208                "Free space on that filesystem (the copy shares data blocks but writes one metadata record per entry), then run the command again.",
209            ));
210        }
211    }
212    if let Ok(parent_metadata) = std::fs::metadata(layout.parent.as_path()) && parent_metadata.dev() != metadata.dev() && !platform.is_subvolume_root(root).unwrap_or(false) {
213        found.push(refuse(
214            format!("{} and its parent {} are on different devices, so the two cannot be swapped", root.display(), layout.parent.display()),
215            "Run the command for a checkout that is not itself a mount point.",
216        ));
217    }
218    if platform.is_mount_point(root) {
219        found.push(refuse(
220            format!("{} is a mount point, and a mount point cannot be swapped with another directory", root.display()),
221            "Convert the directory that is mounted there instead, or mount a subvolume at that path yourself (`btrfs subvolume create` and `mount --options subvol=...`).",
222        ));
223    }
224    found.extend(state_refusals(platform, layout, journal));
225    found.extend(git_state_refusals(layout, journal));
226    return found;
227}
228
229/// The refusals about what an earlier run, or someone else, left beside the checkout.
230fn state_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
231    let mut found: Vec<Refusal> = Vec::new();
232    let is_root_subvolume: bool = platform.is_subvolume_root(layout.root.as_path()).unwrap_or(false);
233    if is_root_subvolume && !journal.has_begun("exchange") {
234        found.push(refuse(
235            format!("{} is already a subvolume (inode 256) and no migration of it is recorded", layout.root.display()),
236            "Nothing to do.",
237        ));
238    }
239    if journal.has_begun("rollback-swap") {
240        found.push(refuse(
241            format!("a rollback of {} swapped the directories and did not finish (it is recorded in {})", layout.root.display(), layout.journal_path.display()),
242            "Run `git cli-git migrate-to-subvolume --rollback` again to finish it; migrating again would work on the wrong directories.",
243        ));
244    }
245    if layout.new_path.exists() && !journal.has_begun("create") {
246        found.push(refuse(
247            format!("{} exists and no migration journal ({}) says that this command created it", layout.new_path.display(), layout.journal_path.display()),
248            "Look at what it is. If it is the leftover of a migration whose journal was deleted, remove it (`btrfs subvolume delete <path>`, or empty it and `rmdir` it) and run the command again. If it is something else, rename it.",
249        ));
250    }
251    if layout.old_path.exists() && !journal.has_begun("exchange") {
252        found.push(refuse(
253            format!("{} exists already, and it is where the original tree would be moved", layout.old_path.display()),
254            "If it is the old tree of an earlier migration you have checked, delete it (`git cli-git migrate-to-subvolume --remove-old` needs the journal; otherwise `rm --recursive` it yourself). Otherwise rename it.",
255        ));
256    }
257    return found;
258}
259
260/// The refusal for a conversion of the linked worktree whose administrative directory this is, when its command is running.
261fn conversion_refusal(administrative: &Path) -> Option<Refusal> {
262    return match live_owner_of(administrative, std::ffi::OsStr::new(crate::worktree_convert_journal::CLAIM_PREFIX)) {
263        Ok(None) => None,
264        Ok(Some(owner)) => Some(refuse(
265            format!("process {} is converting the linked worktree registered in {} to a subvolume (it owns {})", owner.pid, administrative.display(), owner.path.display()),
266            "Wait for `git cli-git convert-worktree-to-subvolume` to finish, then run the command again.",
267        )),
268        Err(error) => Some(refuse(
269            format!("whether the linked worktree registered in {} is being converted cannot be decided ({error})", administrative.display()),
270            "Look at the claim file the message names; if no cli-git command is running, remove it, then run the command again.",
271        )),
272    };
273}
274
275/// The refusals about Git's own state in the checkout.
276fn git_state_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
277    let mut found: Vec<Refusal> = Vec::new();
278    let git_directory: PathBuf = layout.root.join(".git");
279    if !std::fs::metadata(&git_directory).is_ok_and(|read: std::fs::Metadata| { return read.is_dir(); }) {
280        found.push(refuse(
281            format!("{} is not a directory, so this is not the main checkout of a repository", git_directory.display()),
282            "Run the command from the main checkout (the directory that holds the repository's `.git` directory), not from a linked worktree.",
283        ));
284        return found;
285    }
286    let token: String = token_of(journal);
287    let mut directories: Vec<PathBuf> = vec![git_directory.clone()];
288    if let Ok(names) = std::fs::read_dir(git_directory.join("worktrees")) {
289        for name in names.flatten() {
290            directories.push(name.path());
291        }
292    }
293    directories.sort();
294    for directory in &directories {
295        for name in IN_PROGRESS_NAMES {
296            let own_lock: bool = name == "index.lock" && is_ours(directory.join(name).as_path(), token.as_str());
297            if directory.join(name).exists() && !own_lock {
298                found.push(refuse(
299                    format!("Git is in the middle of an operation: {} exists", directory.join(name).display()),
300                    "Finish or abort that operation (commit, merge, rebase, cherry-pick, bisect), or, if no Git command is running, remove the stale file after checking, then run the command again.",
301                ));
302            }
303        }
304        if let Some(refusal) = conversion_refusal(directory.as_path()) {
305            found.push(refusal);
306        }
307        for queue in ["pending", "claimed"] {
308            let path: PathBuf = directory.join("cli-git").join("work").join(queue);
309            if std::fs::read_dir(&path).is_ok_and(|mut entries: std::fs::ReadDir| { return entries.next().is_some(); }) {
310                found.push(refuse(
311                    format!("the commit service has unfinished work in {}", path.display()),
312                    "Wait for the service to finish landing its commits (`git cli-git service status`), then run the command again.",
313                ));
314            }
315        }
316    }
317    return found;
318}
319
320/// What:
321///  The refusals of a rollback: it needs a journal.
322/// Why:
323///   Without a record the command cannot tell which directory is the original and which the copy.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// const refusals = rollbackRefusals(layout, journal);
328/// ```
329pub fn rollback_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
330    if journal.is_persisted() {
331        return Vec::new();
332    }
333    return vec![refuse(
334        format!("there is no migration journal at {}, so there is nothing recorded to undo", layout.journal_path.display()),
335        "If a migration was started by hand, undo it by hand: exchange the directories again with `mv --exchange` and delete the new subvolume.",
336    )];
337}
338
339/// What:
340///  The refusals of deleting the old tree: the migration must have finished and the old tree must exist.
341/// Why:
342///   The old tree is the only copy of the files this user could not read, and of anything not yet carried over; it is
343///  deleted only when the person asks, after a verified migration.
344///
345/// In TS you'd write (pseudocode):
346/// ```ts
347/// const refusals = removeOldRefusals(layout, journal);
348/// ```
349pub fn remove_old_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
350    let mut found: Vec<Refusal> = Vec::new();
351    if !journal.is_done("verify") {
352        found.push(refuse(
353            format!("the migration of {} is not recorded as finished in {}", layout.root.display(), layout.journal_path.display()),
354            "Run `git cli-git migrate-to-subvolume` to finish (or `--rollback` to undo) before deleting the old tree.",
355        ));
356    }
357    if !layout.old_path.exists() {
358        found.push(refuse(
359            format!("{} does not exist", layout.old_path.display()),
360            "Nothing to delete: it was removed already.",
361        ));
362    }
363    return found;
364}
365
366/// Preflight controls stay out of the release executable.
367#[cfg(test)]
368#[path = "subvolume_preflight_tests.rs"]
369mod tests;