Skip to main content

git_policy_cli/subvolume/
subvolume_arguments.rs

1//! What:
2//!  The grammar of `git cli-git migrate-to-subvolume`, the wrapper's command that converts the main checkout into a
3//!  btrfs subvolume, and the help text.
4//! Why:
5//!  The command name says what it does and follows the other multi-word `git cli-git` spellings (`service status`).
6//!  Running it migrates after the preflight; `--dry-run` previews; `--rollback` and `--remove-old` are the two ways
7//!  out; the rest only switch off a part of the migration. Modes that exclude each other, and switches that belong to
8//!  one mode, are refused with the reason.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! parseMigrateArguments(['--dry-run']) // => { mode: 'migrate', dryRun: true, nestedWorktrees: true, refreshIndexes: true }
13//! ```
14
15use std::ffi::OsString;
16
17/// The usage and the meaning of each option, printed for `--help` and after a refused invocation.
18pub const MIGRATE_HELP: &str = "\
19Usage: git cli-git migrate-to-subvolume [--dry-run] [--no-nested-worktrees] [--no-refresh-indexes]
20       git cli-git migrate-to-subvolume --rollback [--discard-unreadable] [--dry-run]
21       git cli-git migrate-to-subvolume --remove-old [--discard-unreadable] [--dry-run]
22
23Convert the repository's main checkout into a btrfs subvolume at the same path, so that creating a worktree can start
24from a snapshot. Nothing is spawned: the command calls the btrfs ioctls, clones file data, copies the tree, swaps the
25old and new directories atomically in one step, and rewrites the Git index stat fields so the first `git status` stays
26fast. Every step is recorded beside the checkout; if the command is stopped at any point, run it again and it resumes.
27The original tree is kept until you delete it.
28
29Options:
30  --dry-run               Check the conditions and print the plan; change nothing.
31  --rollback              Undo a migration, finished or not: carry back newer files, restore the original directory
32                          and delete the new subvolume.
33  --remove-old            Delete the original tree of a finished migration (slow: every file is unlinked).
34  --discard-unreadable    With --remove-old: also delete the files this user may not read, which were not copied.
35                          With --rollback: also delete the files only the new subvolume holds that this user may not
36                          read, which cannot be carried back.
37  --no-nested-worktrees   Keep the linked worktrees inside the checkout as ordinary directories (default: each becomes
38                          a nested subvolume).
39  --no-refresh-indexes    Leave the Git index files as copied (the first git status of each worktree then hashes every
40                          file).
41  --help                  Show this help.
42
43Before you run it, stop agent sessions and editors that have their working directory inside the checkout: after the
44swap they would keep writing into the old tree. The dry run lists them. Run it on a machine with the checkout's
45filesystem mounted with enough free space; `git cli-git doctor` explains the mount option that makes removal fast.
46";
47
48/// What:
49///  Which of the three things the command does.
50/// Why:
51///   They exclude each other.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type MigrateMode = 'migrate' | 'rollback' | 'remove-old';
56/// ```
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum MigrateMode {
59    /// Convert the checkout.
60    Migrate,
61    /// Undo a conversion.
62    Rollback,
63    /// Delete the original tree.
64    RemoveOld,
65}
66
67/// What:
68///  A parsed invocation.
69/// Why:
70///   The command acts on a typed value; nothing downstream re-reads raw arguments.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type MigrateArguments = { mode: MigrateMode; dryRun: boolean; nestedWorktrees: boolean; refreshIndexes: boolean; discardUnreadable: boolean; help: boolean };
75/// ```
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct MigrateArguments {
78    /// What to do.
79    pub mode: MigrateMode,
80    /// Preview only.
81    pub dry_run: bool,
82    /// Make linked worktrees nested subvolumes.
83    pub nested_worktrees: bool,
84    /// Rewrite the index stat fields.
85    pub refresh_indexes: bool,
86    /// With remove-old or rollback: delete the unreadable files too.
87    pub discard_unreadable: bool,
88    /// Print the help.
89    pub help: bool,
90}
91
92/// What:
93///  Parse the arguments after `migrate-to-subvolume`.
94///  `Result<MigrateArguments, String>` is the parse, or the reason the invocation is refused.
95/// Why:
96///   Each refusal names the option and says what to use instead, so the person is never left to guess.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// function parseMigrateArguments(args: string[]): MigrateArguments;
101/// ```
102pub fn parse_migrate_arguments(arguments: &[OsString]) -> Result<MigrateArguments, String> {
103    let mut parsed: MigrateArguments = MigrateArguments {
104        mode: MigrateMode::Migrate,
105        dry_run: false,
106        nested_worktrees: true,
107        refresh_indexes: true,
108        discard_unreadable: false,
109        help: false,
110    };
111    let (mut rollback, mut remove_old, mut migrate_switch): (bool, bool, bool) = (false, false, false);
112    for token in arguments {
113        let text: &str = token.to_str().ok_or_else(|| { return String::from("an argument is not valid UTF-8 text; the options are ASCII"); })?;
114        match text {
115            "--dry-run" => parsed.dry_run = true,
116            "--rollback" => rollback = true,
117            "--remove-old" => remove_old = true,
118            "--discard-unreadable" => parsed.discard_unreadable = true,
119            "--no-nested-worktrees" => {
120                parsed.nested_worktrees = false;
121                migrate_switch = true;
122            }
123            "--no-refresh-indexes" => {
124                parsed.refresh_indexes = false;
125                migrate_switch = true;
126            }
127            "--help" | "-h" => parsed.help = true,
128            other => return Err(format!("unknown argument {other}; the options are listed by `git cli-git migrate-to-subvolume --help`")),
129        }
130    }
131    if parsed.help {
132        return Ok(parsed);
133    }
134    if rollback && remove_old {
135        return Err(String::from("--rollback and --remove-old exclude each other: --rollback restores the original tree, --remove-old deletes it. Run one, then the other if you still want it."));
136    }
137    if (rollback || remove_old) && migrate_switch {
138        return Err(String::from("--no-nested-worktrees and --no-refresh-indexes change how the migration runs, so they apply only without --rollback and --remove-old."));
139    }
140    if parsed.discard_unreadable && !remove_old && !rollback {
141        return Err(String::from("--discard-unreadable applies only with --remove-old or --rollback."));
142    }
143    if rollback {
144        parsed.mode = MigrateMode::Rollback;
145    }
146    if remove_old {
147        parsed.mode = MigrateMode::RemoveOld;
148    }
149    return Ok(parsed);
150}
151
152/// Argument controls stay out of the release executable.
153#[cfg(test)]
154#[path = "subvolume_arguments_tests.rs"]
155mod tests;