Skip to main content

git_policy_cli/worktree/
worktree_convert_arguments.rs

1//! What:
2//!  The grammar of `git cli-git convert-worktree-to-subvolume`.
3//! Why:
4//!  The grammar is parsed on every platform by the management namespace; only the run needs Linux and btrfs.
5//!
6//! In TS you'd write (pseudocode):
7//! ```ts
8//! const arguments = parseConvertArguments(['--dry-run', '--all']);
9//! ```
10
11/// The text of `--help`.
12pub const CONVERT_HELP: &str = "git cli-git convert-worktree-to-subvolume [--dry-run | --abandon] [--jobs <count>] (--all | <worktree>...)
13
14Convert linked worktrees that are ordinary directories into btrfs subvolumes at the same path, side by side.
15A snapshot of the main checkout then shows each converted worktree as an empty directory instead of a full copy.
16
17For each worktree the command refuses unless it is quiet: not locked, no index.lock, no operation in progress, tracked files
18unmodified, no process with its working directory or an open file inside it, and no worktree registered inside it.
19It then copies the worktree with cloned data into <worktree>.cli-git-converting, proves the copy equal, swaps the two with
20one atomic exchange, rewrites the index stat fields so the first `git status` stays fast, and removes the old tree only
21after the swap is verified. The registration and the bytes of the .git file do not change.
22
23  --all        every linked worktree registered inside the main checkout
24  --dry-run    list each worktree and what would happen, or why it would be skipped; change nothing
25  --abandon    undo an unfinished conversion that has not reached its swap (destroys only the subvolume it created)
26  --jobs <n>   convert at most n worktrees at once (also --jobs=<n>); without it the command uses the threads this process
27               may run, less a margin, and no more worktrees than the free memory holds
28
29The worktrees are named in the order given (with --all, sorted by path) and each one's lines come together, in that order,
30whichever finishes first. A worktree that fails does not stop the others: it keeps its journal, the others are converted, the
31summary counts the failures, and the status is 2. Run the same command again to finish the failed ones.
32
33An interrupted run is finished by running the same command again.
34
35Risk, accepted by running the command: it deletes the old directory once the copy is verified, and it cannot stop
36another process from changing the worktree while it works. A process that writes through an open file or a mapping, or
37renames or replaces a path, between a check and the step that relies on it can lose its data; stop such processes first.
38";
39
40/// The one line printed on standard error when a conversion runs, before anything is removed.
41pub const RISK_NOTICE: &str = "cli-git: this deletes the old directory of each worktree after the copy is verified. A process that changes a worktree while it is converted can lose data, and that risk is yours; stop such processes first.";
42
43/// What:
44///  The parsed options.
45/// Why:
46///   The parse is separate from the run so the grammar is controlled without a filesystem.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type ConvertArguments = { dryRun: boolean; abandon: boolean; all: boolean; paths: string[]; help: boolean; commandLineAttributes: boolean; jobs?: number };
51/// ```
52#[derive(Clone, Debug, Default, Eq, PartialEq)]
53pub struct ConvertArguments {
54    /// Preview only.
55    pub dry_run: bool,
56    /// Undo an unfinished conversion.
57    pub abandon: bool,
58    /// Every linked worktree inside the main checkout.
59    pub all: bool,
60    /// Worktrees named on the command line.
61    pub paths: Vec<std::ffi::OsString>,
62    /// Print the help.
63    pub help: bool,
64    /// The invocation chose an attribute source before the command (`--attr-source`, `-c attr.tree`).
65    pub command_line_attributes: bool,
66    /// The most worktrees converted at once (`--jobs`), or nothing for the budget the host allows.
67    pub jobs: Option<usize>,
68}
69
70/// The count after `--jobs`: a whole number of at least one.
71fn parse_jobs(value: &str) -> Result<usize, String> {
72    return match value.parse::<usize>() {
73        Ok(count) if count >= 1 => Ok(count),
74        _ => Err(format!("--jobs needs a whole number of at least one, but got {value:?}")),
75    };
76}
77
78/// What:
79///  Parse the words after `convert-worktree-to-subvolume`.
80/// Why:
81///   A refusal names the word it refused.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// function parseConvertArguments(rest: string[]): ConvertArguments; // throws the reason
86/// ```
87pub fn parse_convert_arguments(rest: &[std::ffi::OsString]) -> Result<ConvertArguments, String> {
88    let mut parsed: ConvertArguments = ConvertArguments::default();
89    let mut tokens = rest.iter();
90    while let Some(token) = tokens.next() {
91        let Some(word) = token.to_str() else {
92            parsed.paths.push(token.clone());
93            continue;
94        };
95        match word {
96            "--jobs" => {
97                let value: &str = tokens.next().and_then(|next: &std::ffi::OsString| { return next.to_str(); }).ok_or_else(|| { return String::from("--jobs needs a count, as in --jobs 4"); })?;
98                parsed.jobs = Some(parse_jobs(value)?);
99            }
100            other if other.starts_with("--jobs=") => parsed.jobs = Some(parse_jobs(&other["--jobs=".len()..])?),
101            "--help" | "-h" => parsed.help = true,
102            "--dry-run" => parsed.dry_run = true,
103            "--abandon" => parsed.abandon = true,
104            "--all" => parsed.all = true,
105            other if other.starts_with('-') => return Err(format!("unknown option {other}; see `git cli-git convert-worktree-to-subvolume --help`")),
106            _ => parsed.paths.push(token.clone()),
107        }
108    }
109    if parsed.dry_run && parsed.abandon {
110        return Err(String::from("--dry-run and --abandon cannot be combined"));
111    }
112    if !parsed.help && !parsed.all && parsed.paths.is_empty() {
113        return Err(String::from("name a worktree or pass --all; see `git cli-git convert-worktree-to-subvolume --help`"));
114    }
115    if parsed.all && !parsed.paths.is_empty() {
116        return Err(String::from("--all and named worktrees cannot be combined"));
117    }
118    return Ok(parsed);
119}
120
121/// What:
122///  Whether the global options before `cli-git` choose an attribute source: `--attr-source`, `-c attr.tree=...` or
123///  `--config-env=attr.tree=...`.
124/// Why:
125///   Git would then take its attributes from a tree, which the conversion's content check does not read, so the check refuses
126///  to decide by content.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// function selectsAttributeSource(globals: string[]): boolean;
131/// ```
132pub fn selects_attribute_source(globals: &[std::ffi::OsString]) -> bool {
133    let mut words = globals.iter();
134    while let Some(word) = words.next() {
135        let text: String = word.to_string_lossy().to_lowercase();
136        if text.starts_with("--attr-source") || text.starts_with("--config-env=attr.tree") {
137            return true;
138        }
139        if text == "-c" && words.next().is_some_and(|value: &std::ffi::OsString| { return value.to_string_lossy().to_lowercase().starts_with("attr.tree"); }) {
140            return true;
141        }
142    }
143    return false;
144}
145
146/// Argument grammar controls stay out of the release executable.
147#[cfg(test)]
148#[path = "worktree_convert_arguments_tests.rs"]
149mod tests;