1use super::process_use::{UseTarget, processes_using};
18use super::subvolume_error::{Interrupt, MigrateError};
19use super::subvolume_index_files::{IndexLock, linked_worktrees_inside, refresh_all_indexes};
20use super::subvolume_lock::{acquire, lock_paths, release_all, token_of};
21use super::subvolume_platform::Platform;
22use super::subvolume_preflight::{Layout, Refusal, layout_of, migrate_refusals};
23use super::subvolume_run::{INDEX_LOCK, Run};
24use std::path::{Path, PathBuf};
25use std::time::Instant;
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub struct MigrateOptions {
38 pub dry_run: bool,
40 pub nested_worktrees: bool,
42 pub refresh_indexes: bool,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum Verdict {
57 Migrated,
59 AlreadyDone,
61 DryRun,
63 Refused(Vec<Refusal>),
65}
66
67fn is_subvolume(platform: &dyn Platform, path: &Path) -> bool {
69 return platform.is_subvolume_root(path).unwrap_or(false);
70}
71
72fn announce(run: &mut Run<'_>, options: MigrateOptions) {
82 let root: PathBuf = run.layout.root.clone();
83 let removal: bool = run.platform.removal_allowed(root.as_path());
84 run.line(format!("checkout {}; new subvolume {}; old tree will be {}", root.display(), run.layout.new_path.display(), run.layout.old_path.display()).as_str());
85 run.line(format!("user_subvol_rm_allowed: {}", if removal { "active" } else { "absent (a subvolume is then removed by emptying it and rmdir; see git cli-git doctor)" }).as_str());
86 let real: PathBuf = std::fs::canonicalize(root.as_path()).unwrap_or(root);
87 let scan = processes_using(&UseTarget::Inside(real.as_path()));
88 if scan.users.is_empty() {
89 run.line("no other process has its working directory inside the checkout");
90 } else {
91 run.line(format!("WARNING: {} process(es) have a working directory inside the checkout and will stay in the OLD tree after the exchange; restart them (or `cd \"$PWD\"` in a shell) afterwards:", scan.users.len()).as_str());
92 for user in &scan.users {
93 run.line(format!(" pid {} ({})", user.pid, user.command.as_deref().unwrap_or("name unreadable")).as_str());
94 }
95 }
96 let nested: usize = if options.nested_worktrees { linked_worktrees_inside(run.layout.root.as_path()).len() } else { 0 };
97 run.line(format!("linked worktrees inside the checkout that become nested subvolumes: {nested}").as_str());
98}
99
100fn create_subvolumes(run: &mut Run<'_>, options: MigrateOptions) -> Result<(), MigrateError> {
110 if !run.journal.is_done("create") {
111 run.journal.mark("create", "started")?;
112 if !run.layout.new_path.exists() {
113 run.platform.create_subvolume(run.layout.new_path.as_path())?;
114 }
115 run.journal.mark("create", "done")?;
116 }
117 run.checkpoint("after-create")?;
118 if options.nested_worktrees && !run.journal.is_done("nested") {
119 for linked in linked_worktrees_inside(run.layout.root.as_path()) {
120 let Ok(relative) = linked.worktree.strip_prefix(run.layout.root.as_path()) else { continue };
121 let target: PathBuf = run.layout.new_path.join(relative);
122 if let Some(parent) = target.parent() {
123 std::fs::create_dir_all(parent).map_err(|error: std::io::Error| { return MigrateError::io("create the directory", parent, &error); })?;
124 }
125 if !target.exists() {
126 run.platform.create_subvolume(target.as_path())?;
127 }
128 }
129 run.journal.mark("nested", "done")?;
130 }
131 return run.checkpoint("after-nested");
132}
133
134fn exchange_trees(run: &mut Run<'_>) -> Result<(), MigrateError> {
145 if run.journal.is_done("exchange") {
146 return Ok(());
147 }
148 let root: PathBuf = run.layout.root.clone();
149 let new_path: PathBuf = run.layout.new_path.clone();
150 let old_path: PathBuf = run.layout.old_path.clone();
151 if run.journal.has_begun("exchange") && is_subvolume(run.platform, root.as_path()) {
152 if new_path.exists() && !old_path.exists() {
153 std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
154 }
155 return run.journal.mark("exchange", "done");
156 }
157 let token: String = token_of(&run.journal);
158 acquire_all(root.as_path(), token.as_str())?;
159 run.journal.mark("lock", "started")?;
160 run.line(format!("holding {} (and the lock of every linked worktree's index) so no Git command writes an index during the last delta", root.join(INDEX_LOCK).display()).as_str());
161 run.checkpoint("after-lock")?;
162 run.pass("last delta (under the lock)", root.as_path(), new_path.as_path(), true, false)?;
163 acquire_all(root.as_path(), token.as_str())?;
165 acquire_all(new_path.as_path(), token.as_str())?;
166 run.journal.mark("exchange", "started")?;
167 run.checkpoint("before-exchange")?;
168 run.platform.exchange(root.as_path(), new_path.as_path())?;
169 run.checkpoint("after-exchange")?;
170 std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
171 run.checkpoint("after-rename")?;
172 run.line(format!("exchanged; the old tree is now {}", old_path.display()).as_str());
173 return run.journal.mark("exchange", "done");
174}
175
176fn acquire_all(tree: &Path, token: &str) -> Result<(), MigrateError> {
187 for lock in lock_paths(tree) {
188 acquire(lock.as_path(), token)?;
189 }
190 return Ok(());
191}
192
193fn drop_old_locks(run: &mut Run<'_>) -> Result<(), MigrateError> {
204 let token: String = token_of(&run.journal);
205 let released: usize = release_all(run.layout.old_path.as_path(), token.as_str())?;
206 if released > 0 {
207 run.line(format!("removed {released} index lock(s) from {}", run.layout.old_path.display()).as_str());
208 }
209 return Ok(());
210}
211
212fn finish(run: &mut Run<'_>, options: MigrateOptions) -> Result<(), MigrateError> {
223 let root: PathBuf = run.layout.root.clone();
224 let old_path: PathBuf = run.layout.old_path.clone();
225 drop_old_locks(run)?;
226 let token: String = token_of(&run.journal);
227 if !run.journal.is_done("catchup") {
228 run.pass("catch-up (files newer in the old tree)", old_path.as_path(), root.as_path(), false, true)?;
229 run.journal.mark("catchup", "done")?;
230 }
231 run.checkpoint("after-catchup")?;
232 if options.refresh_indexes && !run.journal.is_done("indexes") {
233 let started: Instant = Instant::now();
234 let mut lines: Vec<String> = Vec::new();
235 let count: usize = refresh_all_indexes(root.as_path(), IndexLock::Held(token.as_str()), std::slice::from_ref(&old_path), &mut |text: &str| { lines.push(text.to_string()); });
236 for text in &lines {
237 run.line(text.as_str());
238 }
239 run.line(format!("index stat rewrite: {count} index file(s), {:.2} s", started.elapsed().as_secs_f64()).as_str());
240 run.journal.mark("indexes", "done")?;
241 }
242 run.checkpoint("after-indexes")?;
243 release_all(root.as_path(), token.as_str())?;
245 if !is_subvolume(run.platform, root.as_path()) {
246 return Err(MigrateError::Journal(format!("{} is not a subvolume root after the exchange; run with --rollback to restore the original tree", root.display())));
247 }
248 if !root.join(".git").join("HEAD").exists() {
249 return Err(MigrateError::Journal(format!("{} has no .git/HEAD after the exchange; run with --rollback to restore the original tree", root.display())));
250 }
251 return run.journal.mark("verify", "done");
252}
253
254fn closing(run: &mut Run<'_>) {
264 let layout: &Layout = run.layout;
265 let (root, old_path) = (layout.root.display().to_string(), layout.old_path.display().to_string());
266 run.line(format!("{root} is now a btrfs subvolume (root inode 256).").as_str());
267 run.line(format!("The original tree is kept at {old_path}. Check the result with `git status` and `git fsck`, then delete it with `git cli-git migrate-to-subvolume --remove-old` (or undo everything with `--rollback`).").as_str());
268 run.line("Shells, editors and agent sessions that had their working directory inside the checkout still use the old tree; restart them, or run `cd \"$PWD\"` in a shell.");
269 if !run.unreadable.is_empty() {
270 let count: usize = run.unreadable.len();
271 let list: String = run.layout.unreadable_path.display().to_string();
272 run.line(format!("{count} file(s) this user may not read were not copied and exist only in the old tree; they are listed in {list}. Copy them with the needed privilege before deleting the old tree.").as_str());
273 }
274}
275
276pub fn migrate(
288 platform: &dyn Platform,
289 root: &Path,
290 options: MigrateOptions,
291 interrupt: &Interrupt,
292 say: &mut dyn FnMut(&str),
293) -> Result<Verdict, MigrateError> {
294 let Some(layout) = layout_of(root) else {
295 return Err(MigrateError::Journal(format!("{} has no parent directory to hold the new subvolume", root.display())));
296 };
297 let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
298 if is_subvolume(platform, root) && (run.journal.is_done("verify") || !run.journal.is_persisted()) {
299 run.line(format!("{} is already a btrfs subvolume; nothing to do.", root.display()).as_str());
300 if run.journal.is_done("verify") && layout.old_path.exists() {
301 run.line(format!("The original tree is still at {}; delete it with `git cli-git migrate-to-subvolume --remove-old`.", layout.old_path.display()).as_str());
302 }
303 return Ok(Verdict::AlreadyDone);
304 }
305 let refusals: Vec<Refusal> = migrate_refusals(platform, &layout, &run.journal);
306 if !refusals.is_empty() {
307 return Ok(Verdict::Refused(refusals));
308 }
309 announce(&mut run, options);
310 if options.dry_run {
311 run.line("plan: create the new subvolume and the nested ones; copy with cloned data; repeat as a delta; hold .git/index.lock, run the last delta, exchange the directories atomically; carry over newer files; rewrite the index stat fields; verify.");
312 run.line("dry run complete; nothing was changed. Run the command without --dry-run to migrate.");
313 return Ok(Verdict::DryRun);
314 }
315 let started: Instant = Instant::now();
316 create_subvolumes(&mut run, options)?;
317 if !run.journal.is_done("copy") {
318 run.journal.mark("copy", "started")?;
319 run.pass("copy", layout.root.as_path(), layout.new_path.as_path(), false, false)?;
320 run.checkpoint("after-copy")?;
321 run.journal.mark("copy", "done")?;
322 }
323 if !run.journal.is_done("delta1") && !run.journal.has_begun("exchange") {
324 run.pass("first delta", layout.root.as_path(), layout.new_path.as_path(), true, false)?;
325 run.journal.mark("delta1", "done")?;
326 }
327 run.checkpoint("after-delta1")?;
328 exchange_trees(&mut run)?;
329 finish(&mut run, options)?;
330 run.line(format!("migration took {:.2} s", started.elapsed().as_secs_f64()).as_str());
331 closing(&mut run);
332 return Ok(Verdict::Migrated);
333}
334
335#[cfg(test)]
337#[path = "subvolume_migrate_tests.rs"]
338mod tests;