Skip to main content

git_policy_cli/
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 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/// 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. After a kill, `exchange`
138///  recorded as started with the checkout already a subvolume means only the rename or the record is left.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// await exchangeTrees(run);
143/// ```
144fn 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    // A worktree administered since the locks were taken has no lock yet; the new tree holds the same locks before the exchange.
164    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
176/// What:
177///  Create the migration's lock of every index in a tree.
178/// Why:
179///   A lock held by another Git command stops the migration with the file named; its own lock from an earlier run is
180///  accepted.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// for (const lock of lockPaths(tree)) await acquire(lock, token);
185/// ```
186fn 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
193/// What:
194///  Remove the migration's locks that travelled with the old tree.
195/// Why:
196///   The old tree is kept as a plain directory; a lock left in it would block every Git command there, and only the
197///  locks that hold this migration's token are removed.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// await releaseAll(oldPath, token);
202/// ```
203fn 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
212/// What:
213///  The steps after the exchange: catch-up, index rewrite, verification.
214/// Why:
215///   Writers that held descriptors in the old tree may have written after the swap; the index rewrite makes the first
216///  `git status` cheap; the verification proves the checkout is a subvolume root with a readable `.git/HEAD`.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// await finish(run, options);
221/// ```
222fn 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    // The new checkout was locked from the exchange until now; Git commands may use it again.
244    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
254/// What:
255///  The closing text of a finished migration.
256/// Why:
257///   It states what is left to do and every way to undo or finish: restart sessions, check Git, delete the old tree.
258///
259/// In TS you'd write (pseudocode):
260/// ```ts
261/// closing(run);
262/// ```
263fn 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
276/// What:
277///  Run the whole migration command: preflight, preview or steps.
278///  `say` receives progress lines as they happen.
279/// Why:
280///   A refusal returns before anything is created. A finished migration or a plain subvolume with no journal returns
281///  `AlreadyDone`. Any failure after the first step leaves the journal so a rerun resumes, and the error says so.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// const verdict = await migrate({ platform, root, options, interrupt, say });
286/// ```
287pub 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/// Migration controls stay out of the release executable.
336#[cfg(test)]
337#[path = "subvolume_migrate_tests.rs"]
338mod tests;