git_policy_cli/subvolume/
subvolume_migrate.rs1use crate::process_use::{UseTarget, processes_using};
18use crate::subvolume_error::{Interrupt, MigrateError};
19use crate::subvolume_index_files::{IndexLock, linked_worktrees_inside, refresh_all_indexes};
20use crate::subvolume_lock::{acquire, lock_paths, release_all, token_of};
21use crate::subvolume_platform::Platform;
22use crate::subvolume_preflight::{Layout, Refusal, layout_of, migrate_refusals};
23use crate::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> {
147 if run.journal.is_done("exchange") {
148 return Ok(());
149 }
150 let root: PathBuf = run.layout.root.clone();
151 let new_path: PathBuf = run.layout.new_path.clone();
152 let old_path: PathBuf = run.layout.old_path.clone();
153 if run.journal.has_begun("exchange") && is_subvolume(run.platform, root.as_path()) {
154 if new_path.exists() && !old_path.exists() {
155 std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
156 }
157 return run.journal.mark("exchange", "done");
158 }
159 let token: String = token_of(&run.journal);
160 acquire_all(root.as_path(), token.as_str())?;
161 run.journal.mark("lock", "started")?;
162 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());
163 run.checkpoint("after-lock")?;
164 run.pass("last delta (under the lock)", root.as_path(), new_path.as_path(), true, false)?;
165 acquire_all(root.as_path(), token.as_str())?;
167 acquire_all(new_path.as_path(), token.as_str())?;
168 run.journal.mark("exchange", "started")?;
169 run.checkpoint("before-exchange")?;
170 run.platform.exchange(root.as_path(), new_path.as_path())?;
171 run.checkpoint("after-exchange")?;
172 std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
173 run.checkpoint("after-rename")?;
174 run.line(format!("exchanged; the old tree is now {}", old_path.display()).as_str());
175 return run.journal.mark("exchange", "done");
176}
177
178fn acquire_all(tree: &Path, token: &str) -> Result<(), MigrateError> {
189 for lock in lock_paths(tree) {
190 acquire(lock.as_path(), token)?;
191 }
192 return Ok(());
193}
194
195fn drop_old_locks(run: &mut Run<'_>) -> Result<(), MigrateError> {
206 let token: String = token_of(&run.journal);
207 let released: usize = release_all(run.layout.old_path.as_path(), token.as_str())?;
208 if released > 0 {
209 run.line(format!("removed {released} index lock(s) from {}", run.layout.old_path.display()).as_str());
210 }
211 return Ok(());
212}
213
214fn finish(run: &mut Run<'_>, options: MigrateOptions) -> Result<(), MigrateError> {
225 let root: PathBuf = run.layout.root.clone();
226 let old_path: PathBuf = run.layout.old_path.clone();
227 drop_old_locks(run)?;
228 let token: String = token_of(&run.journal);
229 if !run.journal.is_done("catchup") {
230 run.pass("catch-up (files newer in the old tree)", old_path.as_path(), root.as_path(), false, true)?;
231 run.journal.mark("catchup", "done")?;
232 }
233 run.checkpoint("after-catchup")?;
234 if options.refresh_indexes && !run.journal.is_done("indexes") {
235 let started: Instant = Instant::now();
236 let mut lines: Vec<String> = Vec::new();
237 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()); });
238 for text in &lines {
239 run.line(text.as_str());
240 }
241 run.line(format!("index stat rewrite: {count} index file(s), {:.2} s", started.elapsed().as_secs_f64()).as_str());
242 run.journal.mark("indexes", "done")?;
243 }
244 run.checkpoint("after-indexes")?;
245 release_all(root.as_path(), token.as_str())?;
247 if !is_subvolume(run.platform, root.as_path()) {
248 return Err(MigrateError::Journal(format!("{} is not a subvolume root after the exchange; run with --rollback to restore the original tree", root.display())));
249 }
250 if !root.join(".git").join("HEAD").exists() {
251 return Err(MigrateError::Journal(format!("{} has no .git/HEAD after the exchange; run with --rollback to restore the original tree", root.display())));
252 }
253 return run.journal.mark("verify", "done");
254}
255
256fn closing(run: &mut Run<'_>) {
266 let layout: &Layout = run.layout;
267 let (root, old_path) = (layout.root.display().to_string(), layout.old_path.display().to_string());
268 run.line(format!("{root} is now a btrfs subvolume (root inode 256).").as_str());
269 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());
270 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.");
271 if !run.unreadable.is_empty() {
272 let count: usize = run.unreadable.len();
273 let list: String = run.layout.unreadable_path.display().to_string();
274 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());
275 }
276}
277
278pub fn migrate(
290 platform: &dyn Platform,
291 root: &Path,
292 options: MigrateOptions,
293 interrupt: &Interrupt,
294 say: &mut dyn FnMut(&str),
295) -> Result<Verdict, MigrateError> {
296 let Some(layout) = layout_of(root) else {
297 return Err(MigrateError::Journal(format!("{} has no parent directory to hold the new subvolume", root.display())));
298 };
299 let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
300 if is_subvolume(platform, root) && (run.journal.is_done("verify") || !run.journal.is_persisted()) {
301 run.line(format!("{} is already a btrfs subvolume; nothing to do.", root.display()).as_str());
302 if run.journal.is_done("verify") && layout.old_path.exists() {
303 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());
304 }
305 return Ok(Verdict::AlreadyDone);
306 }
307 let refusals: Vec<Refusal> = migrate_refusals(platform, &layout, &run.journal);
308 if !refusals.is_empty() {
309 return Ok(Verdict::Refused(refusals));
310 }
311 announce(&mut run, options);
312 if options.dry_run {
313 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.");
314 run.line("dry run complete; nothing was changed. Run the command without --dry-run to migrate.");
315 return Ok(Verdict::DryRun);
316 }
317 let started: Instant = Instant::now();
318 create_subvolumes(&mut run, options)?;
319 if !run.journal.is_done("copy") {
320 run.journal.mark("copy", "started")?;
321 run.pass("copy", layout.root.as_path(), layout.new_path.as_path(), false, false)?;
322 run.checkpoint("after-copy")?;
323 run.journal.mark("copy", "done")?;
324 }
325 if !run.journal.is_done("delta1") && !run.journal.has_begun("exchange") {
326 run.pass("first delta", layout.root.as_path(), layout.new_path.as_path(), true, false)?;
327 run.journal.mark("delta1", "done")?;
328 }
329 run.checkpoint("after-delta1")?;
330 exchange_trees(&mut run)?;
331 finish(&mut run, options)?;
332 run.line(format!("migration took {:.2} s", started.elapsed().as_secs_f64()).as_str());
333 closing(&mut run);
334 return Ok(Verdict::Migrated);
335}
336
337#[cfg(test)]
339#[path = "subvolume_migrate_tests.rs"]
340mod tests;