Skip to main content

git_policy_cli/
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 never walks the tree).
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const refusals: { cause: string; remedy: string }[] = preflight({ platform, layout, journal });
14//! ```
15
16use super::fs_kind::FilesystemKind;
17use super::subvolume_journal::Journal;
18use super::subvolume_lock::{is_ours, token_of};
19use super::subvolume_platform::Platform;
20use std::os::unix::fs::MetadataExt;
21use std::path::{Path, PathBuf};
22
23/// The free space the parent filesystem must have: the copy shares data blocks but still writes one metadata record
24///  per entry (the script's bound, 5 GiB; about 1 KiB per entry is an assumption, not a measurement).
25pub const MIN_FREE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
26
27/// The names whose presence in `.git` or in a linked worktree's administrative directory means Git is mid-operation.
28pub const IN_PROGRESS_NAMES: [&str; 12] = [
29    "index.lock",
30    "MERGE_HEAD",
31    "CHERRY_PICK_HEAD",
32    "REVERT_HEAD",
33    "BISECT_LOG",
34    "rebase-merge",
35    "rebase-apply",
36    "sequencer",
37    "HEAD.lock",
38    "config.lock",
39    "packed-refs.lock",
40    "shallow.lock",
41];
42
43/// What:
44///  Every path of one migration, derived from the checkout's path.
45/// Why:
46///   The new tree and the old tree sit beside the checkout (one parent, so the exchange stays on one filesystem), and
47///  the journal and the unreadable-file list sit beside them, outside the tree that is exchanged.
48///
49/// In TS you'd write (pseudocode):
50/// ```ts
51/// type Layout = { root: string; parent: string; newPath: string; oldPath: string; journalPath: string; unreadablePath: string };
52/// ```
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct Layout {
55    /// The main checkout.
56    pub root: PathBuf,
57    /// Its parent directory.
58    pub parent: PathBuf,
59    /// `<root>.subvol-new`: the subvolume being filled, and after the exchange the old tree until it is renamed.
60    pub new_path: PathBuf,
61    /// `<root>.before-subvolume`: the original tree, kept until removed on request.
62    pub old_path: PathBuf,
63    /// `<parent>/.cli-git-migrate-<name>.journal.json`.
64    pub journal_path: PathBuf,
65    /// `<parent>/.cli-git-migrate-<name>.unreadable.txt`.
66    pub unreadable_path: PathBuf,
67}
68
69/// What:
70///  The layout for a checkout path.
71///  A root with no parent or no name (`/`) has no layout.
72/// Why:
73///   The names are fixed by the root's name, so a rerun finds the same files.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// function layoutOf(root: string): Layout | undefined;
78/// ```
79pub fn layout_of(root: &Path) -> Option<Layout> {
80    let name: &std::ffi::OsStr = root.file_name()?;
81    let parent: &Path = root.parent()?;
82    let with_suffix = |suffix: &str| -> PathBuf {
83        let mut text: std::ffi::OsString = name.to_os_string();
84        text.push(suffix);
85        return parent.join(text);
86    };
87    let dotted = |suffix: &str| -> PathBuf {
88        let mut text: std::ffi::OsString = std::ffi::OsString::from(".cli-git-migrate-");
89        text.push(name);
90        text.push(suffix);
91        return parent.join(text);
92    };
93    return Some(Layout {
94        root: root.to_path_buf(),
95        parent: parent.to_path_buf(),
96        new_path: with_suffix(".subvol-new"),
97        old_path: with_suffix(".before-subvolume"),
98        journal_path: dotted(".journal.json"),
99        unreadable_path: dotted(".unreadable.txt"),
100    });
101}
102
103/// What:
104///  One refusal.
105/// Why:
106///   The cause says what is wrong with which path; the remedy lists every way forward, so the person never has to guess.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// type Refusal = { cause: string; remedy: string };
111/// ```
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct Refusal {
114    /// What is wrong.
115    pub cause: String,
116    /// What can be done about it.
117    pub remedy: String,
118}
119
120/// Shorthand for building a refusal from two texts.
121fn refuse(cause: String, remedy: &str) -> Refusal {
122    return Refusal { cause, remedy: remedy.to_string() };
123}
124
125/// What:
126///  The refusals of a fresh or resumed migration; empty means it may start or continue.
127/// Why:
128///   See the module description. The journal says which leftovers are this migration's own: the new tree after its
129///  `create` step began, the old tree after the `exchange` step began, `.git/index.lock` after the `lock` step began.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// const refusals = migrateRefusals(platform, layout, journal);
134/// ```
135pub fn migrate_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
136    let mut found: Vec<Refusal> = Vec::new();
137    let root: &Path = layout.root.as_path();
138    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(root) {
139        Ok(read) => read,
140        Err(error) => {
141            found.push(refuse(format!("{} cannot be examined: {error}", root.display()), "Run the command inside the repository's main checkout, or check the path."));
142            return found;
143        }
144    };
145    if !metadata.is_dir() {
146        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."));
147        return found;
148    }
149    match platform.filesystem_facts(root) {
150        Ok((FilesystemKind::Btrfs, _)) => {}
151        Ok((kind, _)) => found.push(refuse(
152            format!("{} is on {}, not on btrfs, and only btrfs has subvolumes", root.display(), if kind == FilesystemKind::Overlayfs { "overlayfs" } else { "a filesystem other than btrfs" }),
153            "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.",
154        )),
155        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.")),
156    }
157    if let Ok((_, free)) = platform.filesystem_facts(layout.parent.as_path()) && free < MIN_FREE_BYTES {
158        found.push(refuse(
159            format!("{} has {free} bytes free, fewer than the {MIN_FREE_BYTES} bytes the migration requires for its metadata", layout.parent.display()),
160            "Free space on that filesystem (the copy shares data blocks but writes one metadata record per entry), then run the command again.",
161        ));
162    }
163    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) {
164        found.push(refuse(
165            format!("{} and its parent {} are on different devices, so the two cannot be swapped", root.display(), layout.parent.display()),
166            "Run the command for a checkout that is not itself a mount point.",
167        ));
168    }
169    if platform.is_mount_point(root) {
170        found.push(refuse(
171            format!("{} is a mount point, and a mount point cannot be swapped with another directory", root.display()),
172            "Convert the directory that is mounted there instead, or mount a subvolume at that path yourself (`btrfs subvolume create` and `mount --options subvol=...`).",
173        ));
174    }
175    found.extend(state_refusals(platform, layout, journal));
176    found.extend(git_state_refusals(layout, journal));
177    return found;
178}
179
180/// The refusals about what an earlier run, or someone else, left beside the checkout.
181fn state_refusals(platform: &dyn Platform, layout: &Layout, journal: &Journal) -> Vec<Refusal> {
182    let mut found: Vec<Refusal> = Vec::new();
183    let is_root_subvolume: bool = platform.is_subvolume_root(layout.root.as_path()).unwrap_or(false);
184    if is_root_subvolume && !journal.has_begun("exchange") {
185        found.push(refuse(
186            format!("{} is already a subvolume (inode 256) and no migration of it is recorded", layout.root.display()),
187            "Nothing to do.",
188        ));
189    }
190    if journal.has_begun("rollback-swap") {
191        found.push(refuse(
192            format!("a rollback of {} swapped the directories and did not finish (it is recorded in {})", layout.root.display(), layout.journal_path.display()),
193            "Run `git cli-git migrate-to-subvolume --rollback` again to finish it; migrating again would work on the wrong directories.",
194        ));
195    }
196    if layout.new_path.exists() && !journal.has_begun("create") {
197        found.push(refuse(
198            format!("{} exists and no migration journal ({}) says that this command created it", layout.new_path.display(), layout.journal_path.display()),
199            "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.",
200        ));
201    }
202    if layout.old_path.exists() && !journal.has_begun("exchange") {
203        found.push(refuse(
204            format!("{} exists already, and it is where the original tree would be moved", layout.old_path.display()),
205            "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.",
206        ));
207    }
208    return found;
209}
210
211/// The refusals about Git's own state in the checkout.
212fn git_state_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
213    let mut found: Vec<Refusal> = Vec::new();
214    let git_directory: PathBuf = layout.root.join(".git");
215    if !std::fs::metadata(&git_directory).is_ok_and(|read: std::fs::Metadata| { return read.is_dir(); }) {
216        found.push(refuse(
217            format!("{} is not a directory, so this is not the main checkout of a repository", git_directory.display()),
218            "Run the command from the main checkout (the directory that holds the repository's `.git` directory), not from a linked worktree.",
219        ));
220        return found;
221    }
222    let token: String = token_of(journal);
223    let mut directories: Vec<PathBuf> = vec![git_directory.clone()];
224    if let Ok(names) = std::fs::read_dir(git_directory.join("worktrees")) {
225        for name in names.flatten() {
226            directories.push(name.path());
227        }
228    }
229    directories.sort();
230    for directory in &directories {
231        for name in IN_PROGRESS_NAMES {
232            let own_lock: bool = name == "index.lock" && is_ours(directory.join(name).as_path(), token.as_str());
233            if directory.join(name).exists() && !own_lock {
234                found.push(refuse(
235                    format!("Git is in the middle of an operation: {} exists", directory.join(name).display()),
236                    "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.",
237                ));
238            }
239        }
240        for queue in ["pending", "claimed"] {
241            let path: PathBuf = directory.join("cli-git").join("work").join(queue);
242            if std::fs::read_dir(&path).is_ok_and(|mut entries: std::fs::ReadDir| { return entries.next().is_some(); }) {
243                found.push(refuse(
244                    format!("the commit service has unfinished work in {}", path.display()),
245                    "Wait for the service to finish landing its commits (`git cli-git service status`), then run the command again.",
246                ));
247            }
248        }
249    }
250    return found;
251}
252
253/// What:
254///  The refusals of a rollback: it needs a journal.
255/// Why:
256///   Without a record the command cannot tell which directory is the original and which the copy.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// const refusals = rollbackRefusals(layout, journal);
261/// ```
262pub fn rollback_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
263    if journal.is_persisted() {
264        return Vec::new();
265    }
266    return vec![refuse(
267        format!("there is no migration journal at {}, so there is nothing recorded to undo", layout.journal_path.display()),
268        "If a migration was started by hand, undo it by hand: exchange the directories again with `mv --exchange` and delete the new subvolume.",
269    )];
270}
271
272/// What:
273///  The refusals of deleting the old tree: the migration must have finished and the old tree must exist.
274/// Why:
275///   The old tree is the only copy of the files this user could not read, and of anything not yet carried over; it is
276///  deleted only when the person asks, after a verified migration.
277///
278/// In TS you'd write (pseudocode):
279/// ```ts
280/// const refusals = removeOldRefusals(layout, journal);
281/// ```
282pub fn remove_old_refusals(layout: &Layout, journal: &Journal) -> Vec<Refusal> {
283    let mut found: Vec<Refusal> = Vec::new();
284    if !journal.is_done("verify") {
285        found.push(refuse(
286            format!("the migration of {} is not recorded as finished in {}", layout.root.display(), layout.journal_path.display()),
287            "Run `git cli-git migrate-to-subvolume` to finish (or `--rollback` to undo) before deleting the old tree.",
288        ));
289    }
290    if !layout.old_path.exists() {
291        found.push(refuse(
292            format!("{} does not exist", layout.old_path.display()),
293            "Nothing to delete: it was removed already.",
294        ));
295    }
296    return found;
297}
298
299/// Preflight controls stay out of the release executable.
300#[cfg(test)]
301#[path = "subvolume_preflight_tests.rs"]
302mod tests;