Skip to main content

git_policy_cli/worktree/
worktree_convert_command.rs

1//! What:
2//!  `git cli-git convert-worktree-to-subvolume`: parse the options, find the worktrees to convert, run the conversion on each,
3//!  and turn the results into lines and an exit status.
4//! Why:
5//!  A conversion takes seconds to minutes per worktree and is bound by the filesystem's metadata work, not by any one
6//!  worktree's state, so the worktrees are converted side by side within a budget the host allows (`parallel_budget.rs`);
7//!  progress lines go to standard error as they happen for the first unfinished worktree, and every worktree's lines come in
8//!  input order (`parallel_runner.rs`). Exit status 0 means every worktree is converted, already a subvolume, previewed, or
9//!  skipped under `--all`; 1 means a named worktree was refused before anything changed; 2 means a step failed after the
10//!  journal began, with the way forward. One worktree's failure never stops another: each has its own journal and claim.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const code = await executeConvert({ platform, args, cwd, interrupt, say });
15//! ```
16
17use crate::action::Action;
18use crate::subvolume_command::main_checkout_root;
19use crate::diagnostic_log::debug;
20use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
21use crate::parallel_runner::{Event, OrderedRun, run_ordered};
22use crate::subvolume_error::{Interrupt, MigrateError};
23use crate::subvolume_index_files::linked_worktrees_inside;
24use crate::subvolume_platform::{LinuxPlatform, Platform};
25use crate::worktree_convert_arguments::{CONVERT_HELP, ConvertArguments, RISK_NOTICE};
26use crate::worktree_convert::{ConvertOptions, Verdict, convert_worktree};
27use std::collections::HashMap;
28use std::ffi::OsString;
29use std::io::Write;
30use std::path::{Path, PathBuf};
31
32/// The tag of the diagnostic lines of this command.
33const TAG: &str = "convertWorktrees";
34
35/// The exit status of a refusal: a condition stood in the way of a named worktree and nothing was changed.
36pub const REFUSED_EXIT_CODE: i32 = 1;
37
38/// The exit status of a failure after the journal began.
39pub const FAILED_EXIT_CODE: i32 = 2;
40
41/// The worktrees a run covers: every linked worktree inside the main checkout, or the named paths resolved from `cwd`.
42fn targets(arguments: &ConvertArguments, cwd: &Path) -> Result<Vec<PathBuf>, String> {
43    if arguments.all {
44        let root: PathBuf = main_checkout_root(cwd)?;
45        return Ok(linked_worktrees_inside(root.as_path()).into_iter().map(|linked| { return linked.worktree; }).collect());
46    }
47    return Ok(arguments.paths.iter().map(|word: &std::ffi::OsString| { return cwd.join(word); }).collect());
48}
49
50/// What:
51///  The bytes one conversion holds at its peak, rounded up for a worktree of about two hundred thousand files.
52/// Why:
53///   The budget divides the free memory by it. A conversion holds the index entries and the tree-walk state of one worktree and
54///  streams the file data through the kernel's clone call, so its peak follows the number of files and not their size: measured
55///  on 2026-11-03 at 7484 KiB resident for a worktree of 16 thousand files (about 0.45 KiB per file), which is about 90 MiB at
56///  two hundred thousand files.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// const CONVERSION_MEMORY = 128 * MiB;
61/// ```
62pub const CONVERSION_MEMORY: u64 = 128 << 20;
63
64/// What:
65///  The most worktrees converted at once however many CPUs and bytes the host has.
66/// Why:
67///   The conversions are bound by the filesystem's metadata work, which stops scaling past a point. Measured on 2026-11-03 on a
68///  btrfs volume (32 worktrees of about ten thousand files each, 28 threads, other sessions running, medians of three or more
69///  runs at 1, 2, 4, 8, 16 and 18 workers: 61, 35, 19, 14, 12 and 12 seconds; further runs at 12, 16 and 24 workers: 11 to 30, 11
70///  to 16 and 16 to 18 seconds): more than sixteen workers were never faster.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// const CONVERSION_STORAGE_CEILING = 16;
75/// ```
76pub const CONVERSION_STORAGE_CEILING: usize = 16;
77
78/// The workers a run of `lanes` independent conversions uses on `host`, with the person's `cap`.
79pub fn conversion_workers(lanes: usize, cap: Option<usize>, host: &HostResources) -> usize {
80    return worker_budget(&BudgetRequest { items: lanes, per_item_memory: CONVERSION_MEMORY, ceiling: Some(CONVERSION_STORAGE_CEILING), cap, host });
81}
82
83/// What:
84///  Everything one run of the command needs besides the line sink.
85/// Why:
86///   The platform, the interrupt and the host's resources are injected so the controls run the real command on any filesystem
87///  and with a host of any size.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type ConvertRun = { platform: Platform; arguments: ConvertArguments; cwd: string; interrupt: Interrupt; host: HostResources };
92/// ```
93pub struct ConvertRun<'run> {
94    /// The filesystem operations; shared by every worktree converted at once.
95    pub platform: &'run (dyn Platform + Sync),
96    /// The parsed options.
97    pub arguments: &'run ConvertArguments,
98    /// The directory relative paths and `--all` are resolved from.
99    pub cwd: &'run Path,
100    /// The simulated kill; shared by every worktree converted at once.
101    pub interrupt: &'run Interrupt,
102    /// The CPUs and memory the budget is derived from.
103    pub host: &'run HostResources,
104}
105
106/// What:
107///  Run the command over its targets and return the exit status; every line goes through `say`.
108/// Why:
109///   The platform and the interrupt are injected so the controls run the real command on any filesystem.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// const code = await execute(platform, arguments, cwd, interrupt, say);
114/// ```
115pub fn execute(platform: &(dyn Platform + Sync), arguments: &ConvertArguments, cwd: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> i32 {
116    return execute_run(&ConvertRun { platform, arguments, cwd, interrupt, host: &HostResources::read() }, say);
117}
118
119/// What:
120///  The lanes of a run: the targets that name one worktree share a lane, so they run one after another, and every other
121///  target has a lane of its own.
122/// Why:
123///   The same worktree named twice (`wt wt`, `wt ./wt`) is converted by the first and found converted by the second, as in
124///  a run of one after another; run side by side, the second would meet the claim of the first and be refused as busy with itself.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// const lanes = groupBy(list.keys(), (index) => realpath(list[index]) ?? list[index]);
129/// ```
130fn lanes_of(list: &[PathBuf]) -> Vec<Vec<usize>> {
131    let mut lanes: Vec<Vec<usize>> = Vec::new();
132    let mut lane_of: HashMap<PathBuf, usize> = HashMap::new();
133    for (index, path) in list.iter().enumerate() {
134        let real: PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_error: std::io::Error| { return path.clone(); });
135        match lane_of.get(&real) {
136            Some(&lane) => lanes[lane].push(index),
137            None => {
138                lane_of.insert(real, lanes.len());
139                lanes.push(vec![index]);
140            }
141        }
142    }
143    return lanes;
144}
145
146/// `execute` with the host's resources chosen by the caller.
147pub fn execute_run(run: &ConvertRun<'_>, say: &mut dyn FnMut(&str)) -> i32 {
148    let ConvertRun { platform, arguments, cwd, interrupt, host } = *run;
149    let list: Vec<PathBuf> = match targets(arguments, cwd) {
150        Ok(found) => found,
151        Err(reason) => {
152            say(format!("git cli-git convert-worktree-to-subvolume: refused: {reason}").as_str());
153            say("Nothing was changed.");
154            return REFUSED_EXIT_CODE;
155        }
156    };
157    let options: ConvertOptions = ConvertOptions { dry_run: arguments.dry_run, abandon: arguments.abandon, command_line_attributes: arguments.command_line_attributes };
158    let mut refused: usize = 0;
159    let mut converted: usize = 0;
160    let mut failed: usize = 0;
161    // Said once, before the first worktree is touched, when the run can delete an old tree.
162    if !arguments.dry_run && !arguments.abandon && !list.is_empty() {
163        say(RISK_NOTICE);
164    }
165    let lanes: Vec<Vec<usize>> = lanes_of(list.as_slice());
166    let workers: usize = conversion_workers(lanes.len(), arguments.jobs, host);
167    debug(TAG, format!("{} worktree(s) in {} lane(s) on {workers} worker(s) (host {host:?}, cap {:?})", list.len(), lanes.len(), arguments.jobs).as_str());
168    // Each worktree has its own administrative directory, index, claim and journal, so the conversions share nothing but the
169    //  filesystem. Lines and results come back to this thread, which says them in input order.
170    run_ordered(OrderedRun {
171        items: list.as_slice(),
172        lanes: lanes.as_slice(),
173        workers,
174        work: &|_index: usize, path: &PathBuf, worker_say: &mut dyn FnMut(&str)| { return convert_worktree(platform, path.as_path(), options, interrupt, worker_say); },
175        on_event: &mut |event: Event<'_, Result<Verdict, MigrateError>>| {
176            match event {
177                Event::Begin(index) => say(format!("{}", list[index].display()).as_str()),
178                Event::Line(_index, text) => say(text),
179                Event::End(_index, Ok(Verdict::Converted)) => {
180                    converted += 1;
181                    say("  converted into a subvolume");
182                }
183                Event::End(_index, Ok(Verdict::Resumed)) => {
184                    converted += 1;
185                    say(if arguments.dry_run { "  an unfinished conversion would be completed" } else { "  the unfinished conversion was completed" });
186                }
187                Event::End(_index, Ok(Verdict::WouldConvert)) => say("  would convert"),
188                Event::End(_index, Ok(Verdict::AlreadySubvolume)) => say("  already a subvolume, skipped"),
189                Event::End(_index, Ok(Verdict::Abandoned)) => say("  the unfinished conversion was undone"),
190                Event::End(_index, Ok(Verdict::Skipped(reasons))) => {
191                    refused += 1;
192                    for reason in reasons {
193                        say(format!("  skipped: {reason}").as_str());
194                    }
195                }
196                Event::End(_index, Err(error)) => {
197                    failed += 1;
198                    say(format!("  failed: {error}").as_str());
199                    say("  The conversion is journaled. Run the same command again to finish it, or add --abandon to undo it before its swap.");
200                }
201            }
202        },
203    });
204    let failures: String = if failed > 0 { format!(", {failed} failed") } else { String::new() };
205    say(format!("{} worktree(s) looked at, {converted} converted or completed, {refused} skipped{failures}.", list.len()).as_str());
206    if failed > 0 {
207        return FAILED_EXIT_CODE;
208    }
209    return if refused > 0 && !arguments.all && !arguments.dry_run { REFUSED_EXIT_CODE } else { 0 };
210}
211
212/// What:
213///  The action of the command: help, a refusal, or the run on the real platform with progress on standard error.
214/// Why:
215///   The command reads `.git` itself and calls the filesystem, and starts no Git. It still needs to know which Git the wrapper
216///  forwards to (`real_git`, resolved by the caller), because where that Git reads its system files from is a fact only that
217///  Git reports, and the wrapper's own environment does not name it.
218///
219/// In TS you'd write (pseudocode):
220/// ```ts
221/// function convertAction(arguments: ConvertArguments, cwd: string, environment: [string, string][], realGit?: string): Action;
222/// ```
223pub fn convert_action(arguments: &ConvertArguments, cwd: &Path, environment: &[(OsString, OsString)], real_git: Option<&Path>) -> Action {
224    if arguments.help {
225        return Action::Exit { code: 0, stdout: String::from(CONVERT_HELP), stderr: String::new() };
226    }
227    let mut say = |text: &str| {
228        let mut stderr = std::io::stderr().lock();
229        let _written = writeln!(stderr, "{text}");
230    };
231    let code: i32 = execute(&LinuxPlatform::forwarding_to(environment, real_git), arguments, cwd, &Interrupt::never(), &mut say);
232    return Action::Exit { code, stdout: String::new(), stderr: String::new() };
233}
234
235/// Command controls stay out of the release executable.
236#[cfg(test)]
237#[path = "worktree_convert_command_tests.rs"]
238mod tests;
239
240/// The controls for worktrees converted side by side stay out of the release executable.
241#[cfg(test)]
242#[path = "worktree_convert_parallel_tests.rs"]
243mod parallel_tests;