git_policy_cli/subvolume/
subvolume_preflight.rs1use 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
26pub const METADATA_BYTES_PER_ENTRY: u64 = 1024;
33
34pub const METADATA_COPIES: u64 = 2;
38
39pub fn required_free_bytes(entries: u64) -> u64 {
51 return entries.saturating_mul(METADATA_BYTES_PER_ENTRY.saturating_mul(METADATA_COPIES));
59}
60
61pub 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#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct Layout {
89 pub root: PathBuf,
91 pub parent: PathBuf,
93 pub new_path: PathBuf,
95 pub old_path: PathBuf,
97 pub journal_path: PathBuf,
99 pub unreadable_path: PathBuf,
101 pub claim_prefix: std::ffi::OsString,
104}
105
106pub 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#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct Refusal {
152 pub cause: String,
154 pub remedy: String,
156}
157
158fn refuse(cause: String, remedy: &str) -> Refusal {
160 return Refusal { cause, remedy: remedy.to_string() };
161}
162
163pub 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 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 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
229fn 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
260fn 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
275fn 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
320pub 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
339pub 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#[cfg(test)]
368#[path = "subvolume_preflight_tests.rs"]
369mod tests;