1use 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
23pub const MIN_FREE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
26
27pub 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#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct Layout {
55 pub root: PathBuf,
57 pub parent: PathBuf,
59 pub new_path: PathBuf,
61 pub old_path: PathBuf,
63 pub journal_path: PathBuf,
65 pub unreadable_path: PathBuf,
67}
68
69pub 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#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct Refusal {
114 pub cause: String,
116 pub remedy: String,
118}
119
120fn refuse(cause: String, remedy: &str) -> Refusal {
122 return Refusal { cause, remedy: remedy.to_string() };
123}
124
125pub 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
180fn 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
211fn 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
253pub 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
272pub 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#[cfg(test)]
301#[path = "subvolume_preflight_tests.rs"]
302mod tests;