Skip to main content

git_policy_cli/subvolume/
subvolume_migrate.rs

1//! What:
2//!  Convert a main checkout into a btrfs subvolume at the same path, in process, resumably.
3//! Why:
4//!  The steps are those of the proven script (`doc/handover/cli-git-subvolume-migration-script.md`), without any
5//!  program started: create `<root>.subvol-new` as a subvolume; create the linked worktrees inside it as nested
6//!  subvolumes; copy the tree with cloned data; repeat the copy as a delta; hold `.git/index.lock`, run the last
7//!  delta and swap the two directories in one atomic step; rename the old tree aside; carry over files newer in the
8//!  old tree; rewrite the Git index stat fields; verify. Each step is recorded in the journal (`subvolume_journal.rs`),
9//!  so a rerun after a kill at any point resumes: a finished step is skipped and an unfinished one is repeated, and all
10//!  of them are idempotent. The old tree is never deleted here.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const verdict = await migrate({ platform, root, options, interrupt, say });
15//! ```
16
17use 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/// What:
28///  What the person asked for the migration to do.
29/// Why:
30///   The two switches are the script's `--no-nested-worktrees` and `--no-refresh-indexes`; the dry run previews.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// type MigrateOptions = { dryRun: boolean; nestedWorktrees: boolean; refreshIndexes: boolean };
35/// ```
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub struct MigrateOptions {
38    /// Preview only: nothing is created, written or journaled.
39    pub dry_run: bool,
40    /// Create the linked worktrees inside the checkout as nested subvolumes.
41    pub nested_worktrees: bool,
42    /// Rewrite the Git index stat fields after the exchange.
43    pub refresh_indexes: bool,
44}
45
46/// What:
47///  How a migration command ended, when it did not fail.
48/// Why:
49///   The command maps each to an exit status and the closing text.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type Verdict = { kind: 'migrated' } | { kind: 'already' } | { kind: 'dry-run' } | { kind: 'refused'; refusals: Refusal[] };
54/// ```
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum Verdict {
57    /// The checkout is now a subvolume and verified.
58    Migrated,
59    /// The checkout already was one (with a finished journal or none).
60    AlreadyDone,
61    /// The preview was printed.
62    DryRun,
63    /// Nothing was changed: these conditions stand in the way.
64    Refused(Vec<Refusal>),
65}
66
67/// Whether a path is the root of a subvolume, false when it cannot be asked.
68fn is_subvolume(platform: &dyn Platform, path: &Path) -> bool {
69    return platform.is_subvolume_root(path).unwrap_or(false);
70}
71
72/// What:
73///  Print the facts the preview and the run begin with: the mount, the option, the processes that will stay behind.
74/// Why:
75///   A process whose working directory is inside the checkout keeps writing into the old tree after the exchange.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// announce(run, options);
80/// ```
81fn 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
100/// What:
101///  The create and nested steps: the new subvolume, then one nested subvolume per linked worktree inside the checkout.
102/// Why:
103///   A later snapshot of the root then holds an empty stub for each worktree instead of a full copy; the copy fills them.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// await createSubvolumes(run, options);
108/// ```
109fn 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
134/// What:
135///  The exchange step: hold the index lock, run the last delta, swap the two directories, rename the old tree aside.
136/// Why:
137///   The lock keeps Git's index writers out while the last delta runs and the swap happens. It is Git's own `index.lock`
138///  (nothing else excludes Git), one per index, created with `O_EXCL`, never waited for, and held only from the last delta
139///  to the end of the index rewrite; see `subvolume_lock.rs`. After a kill, `exchange`
140///  recorded as started with the checkout already a subvolume means only the rename or the record is left.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// await exchangeTrees(run);
145/// ```
146fn 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    // A worktree administered since the locks were taken has no lock yet; the new tree holds the same locks before the exchange.
166    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
178/// What:
179///  Create the migration's lock of every index in a tree.
180/// Why:
181///   A lock held by another Git command stops the migration with the file named; its own lock from an earlier run is
182///  accepted.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// for (const lock of lockPaths(tree)) await acquire(lock, token);
187/// ```
188fn 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
195/// What:
196///  Remove the migration's locks that travelled with the old tree.
197/// Why:
198///   The old tree is kept as a plain directory; a lock left in it would block every Git command there, and only the
199///  locks that hold this migration's token are removed.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// await releaseAll(oldPath, token);
204/// ```
205fn 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
214/// What:
215///  The steps after the exchange: catch-up, index rewrite, verification.
216/// Why:
217///   Writers that held descriptors in the old tree may have written after the swap; the index rewrite makes the first
218///  `git status` cheap; the verification proves the checkout is a subvolume root with a readable `.git/HEAD`.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// await finish(run, options);
223/// ```
224fn 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    // The new checkout was locked from the exchange until now; Git commands may use it again.
246    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
256/// What:
257///  The closing text of a finished migration.
258/// Why:
259///   It states what is left to do and every way to undo or finish: restart sessions, check Git, delete the old tree.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// closing(run);
264/// ```
265fn 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
278/// What:
279///  Run the whole migration command: preflight, preview or steps.
280///  `say` receives progress lines as they happen.
281/// Why:
282///   A refusal returns before anything is created. A finished migration or a plain subvolume with no journal returns
283///  `AlreadyDone`. Any failure after the first step leaves the journal so a rerun resumes, and the error says so.
284///
285/// In TS you'd write (pseudocode):
286/// ```ts
287/// const verdict = await migrate({ platform, root, options, interrupt, say });
288/// ```
289pub 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/// Migration controls stay out of the release executable.
338#[cfg(test)]
339#[path = "subvolume_migrate_tests.rs"]
340mod tests;