git_policy_cli/worktree_copying/worktree_copy_install.rs
1//! What:
2//! Installation of one validated stage into a newly registered worktree:
3//! preflight of every existing destination entry, batched creation with its durable records,
4//! mode application and rollback of this installation's own paths when it fails.
5//! Why:
6//! A destination that already holds an identical entry is accepted,
7//! and a differing entry is never overwritten, so the copy cannot destroy user state.
8//! Recording each batch before and after it runs bounds the work an interrupted run leaves unrecorded.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const copied = await installSnapshot({ snapshot, destinationRoot, journalState, priorCreations });
13//! ```
14
15use crate::worktree_copy_compare::{entry_matches, lstat_or_absent};
16use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause};
17use crate::parallel_budget::{HostResources, sliced_workers};
18use crate::worktree_copy_stage::PARALLEL_COPY_MAX_WORKERS;
19use crate::worktree_copy_install_entry::{
20 InstalledPath, capture_installed_path, create_selected_entry, ensure_parents_remembering,
21};
22use crate::worktree_copy_manifest::{EntryKind, WorktreeCopyEntry, apply_entry_modes};
23use crate::worktree_copy_rollback::rollback_created;
24use crate::worktree_copy_selection::filesystem_path;
25use crate::json_record::quote;
26use std::collections::BTreeSet;
27use std::path::{Path, PathBuf};
28
29/// What:
30/// The number of manifest entries claimed by one durable intent record and settled by one creation record.
31/// Why:
32/// Bounds both the number of journal appends and the work an interrupted owner can leave unrecorded. Measured with
33/// `install_snapshot` and a recorder that appends each batch to a file and syncs it twice, on a tree of 22,717 entries (2026-11-03,
34/// five runs each in alternating orders, other sessions running, a debug build): medians 6,542, 2,265, 983, 746, 688 and 663 ms
35/// for batches of 64, 256, 1,024, 4,096, 16,384 and one batch, so a batch costs about 16 ms of syncs and 4,096 entries add 12
36/// percent to the install and 16,384 add 4 percent. The real journal's append was not run; its cost per batch may differ. Kept at
37/// 4096 as the coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", option A).
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// const INSTALL_BATCH_ENTRIES = 4096;
42/// ```
43pub const INSTALL_BATCH_ENTRIES: usize = 4096;
44
45
46/// What:
47/// The durable record of an installation in progress.
48/// Why:
49/// The installation reports every claim before it creates a path and every proven creation after,
50/// so the journal slice can persist both without this module knowing the file format.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// recordIntents({ state, relativePaths }); recordCreations({ state, entries });
55/// ```
56pub trait InstallRecorder {
57 /// Records the absent paths one batch is about to create, before any of them exists.
58 fn record_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError>;
59
60 /// Records the identities of the paths one batch created.
61 fn record_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError>;
62}
63
64/// What:
65/// A recorder that writes nothing, for installations whose caller keeps no durable state.
66/// Why:
67/// Such an installation is not crash-safe; it exists so the installer can be exercised before the journal lands.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// // no journal state
72/// ```
73#[derive(Debug, Default)]
74pub struct NoRecord;
75
76impl InstallRecorder for NoRecord {
77 /// Accepts the intent without storing it.
78 fn record_intents(&mut self, _relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
79 return Ok(());
80 }
81
82 /// Accepts the creations without storing them.
83 fn record_creations(&mut self, _entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
84 return Ok(());
85 }
86}
87
88/// What:
89/// The selected paths absent from the destination, after every existing entry has been proven identical.
90/// Why:
91/// An existing entry that differs stops the installation before any mutation, and an identical one is accepted without being overwritten.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// async function preflightDestination({ snapshot, destinationRoot, recorded }): Promise<ReadonlySet<string>>;
96/// ```
97pub fn preflight_destination(
98 stage_root: &Path,
99 destination_root: &Path,
100 entries: &[WorktreeCopyEntry],
101 claimed: &BTreeSet<String>,
102) -> Result<BTreeSet<String>, WorktreeCopyError> {
103 let mut absent: BTreeSet<String> = BTreeSet::new();
104 for entry in entries {
105 let destination_path: PathBuf =
106 filesystem_path(destination_root, entry.relative_path.as_str())?;
107 // `match` on the existing entry: an absent path is recorded, a claimed directory is accepted, anything else must be identical.
108 match lstat_or_absent(destination_path.as_path())? {
109 None => {
110 absent.insert(entry.relative_path.clone());
111 }
112 Some(metadata) => {
113 let claimed_directory: bool = entry.kind == EntryKind::Directory
114 && metadata.is_dir()
115 && claimed.contains(&entry.relative_path);
116 if claimed_directory {
117 continue;
118 }
119 if !entry_matches(stage_root, destination_root, entry)? {
120 let message: String = format!(
121 "cli-git: ignored-state copy would overwrite differing destination entry {} in {}.",
122 quote(entry.relative_path.as_str()),
123 quote(destination_root.to_string_lossy().as_ref())
124 );
125 return Err(failure_with_cause(message.as_str(), None));
126 }
127 }
128 }
129 }
130 return Ok(absent);
131}
132
133/// What:
134/// Creates one batch's absent entries in manifest order, recording the claim before and the identities after.
135/// Why:
136/// The claim is durable before the first path exists, so an interruption leaves a record of every path that might be partial.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// async function installBatch({ snapshot, destinationRoot, batch, absent, created, journalState }): Promise<void>;
141/// ```
142fn install_batch(
143 stage_root: &Path,
144 destination_root: &Path,
145 batch: &[WorktreeCopyEntry],
146 absent: &BTreeSet<String>,
147 created: &mut Vec<InstalledPath>,
148 recorder: &mut dyn InstallRecorder,
149 known: &mut std::collections::HashSet<String>,
150) -> Result<(), WorktreeCopyError> {
151 let claims: Vec<String> = batch
152 .iter()
153 .map(|entry| return entry.relative_path.clone())
154 .filter(|relative_path| return absent.contains(relative_path))
155 .collect();
156 recorder.record_intents(claims.as_slice())?;
157 let batch_start: usize = created.len();
158 // Directories and the scaffolding above every entry first, in manifest order, so each leaf finds its parent.
159 let mut leaves: Vec<&WorktreeCopyEntry> = Vec::new();
160 for entry in batch {
161 ensure_parents_remembering(destination_root, entry, created, known)?;
162 if entry.kind != EntryKind::Directory {
163 leaves.push(entry);
164 continue;
165 }
166 let destination_path: PathBuf =
167 filesystem_path(destination_root, entry.relative_path.as_str())?;
168 // A path that appeared since preflight is never created over; the next settle step decides what it is.
169 if lstat_or_absent(destination_path.as_path())?.is_none() {
170 create_selected_entry(stage_root, destination_root, entry)?;
171 created.push(capture_installed_path(
172 destination_path.as_path(),
173 entry.relative_path.as_str(),
174 true,
175 )?);
176 }
177 known.insert(entry.relative_path.clone());
178 }
179 install_leaves(stage_root, destination_root, leaves.as_slice(), created)?;
180 recorder.record_creations(&created[batch_start..])?;
181 return Ok(());
182}
183
184/// What:
185/// The leaves below which a batch's files and links are created on one thread: `PARALLEL_BREAK_EVEN_ENTRIES`.
186/// Why:
187/// A large batch spends its time in independent link calls; a very small one is faster without the thread start. Measured with
188/// `install_snapshot` itself forced to 1, 2, 4 and 8 workers on reflinked trees of this repository (2026-11-03, five runs each in
189/// alternating orders, other sessions running, a debug build): 8 workers took 1.3 ms against 1.6 ms for 30 entries, 8.9 ms
190/// against 14.8 ms for 296, 49.2 ms against 107.7 ms for 2,119, and 244.9 ms against 500.2 ms for 9,301, so the old threshold of
191/// 1024 left batches of up to 1,023 leaves on one thread for up to 50 ms more. Picked by the coordinator under the owner's delegation, open to the owner's veto
192/// (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", option A).
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// const PARALLEL_INSTALL_MIN_LEAVES = PARALLEL_BREAK_EVEN_ENTRIES;
197/// ```
198const PARALLEL_INSTALL_MIN_LEAVES: usize = crate::parallel_budget::PARALLEL_BREAK_EVEN_ENTRIES;
199
200/// What:
201/// Creates one slice of files and links, stopping at the first failure, and returns what it created with that failure.
202/// Why:
203/// Rollback must know every path that exists, including those created before a failure, so the slice never discards
204/// its record when it stops.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// async function installSlice({ stageRoot, destinationRoot, slice }): Promise<{ created: InstalledPath[]; failure?: WorktreeCopyError }>;
209/// ```
210fn install_slice(
211 stage_root: &Path,
212 destination_root: &Path,
213 slice: &[&WorktreeCopyEntry],
214) -> (Vec<InstalledPath>, Option<WorktreeCopyError>) {
215 let mut made: Vec<InstalledPath> = Vec::new();
216 for entry in slice {
217 let outcome: Result<Option<InstalledPath>, WorktreeCopyError> = (|| {
218 let destination_path: PathBuf =
219 filesystem_path(destination_root, entry.relative_path.as_str())?;
220 // A path that appeared since preflight is never created over; the next settle step decides what it is.
221 if lstat_or_absent(destination_path.as_path())?.is_some() {
222 return Ok(None);
223 }
224 create_selected_entry(stage_root, destination_root, entry)?;
225 return capture_installed_path(destination_path.as_path(), entry.relative_path.as_str(), true).map(Some);
226 })();
227 match outcome {
228 Ok(Some(installed)) => made.push(installed),
229 Ok(None) => {}
230 Err(error) => return (made, Some(error)),
231 }
232 }
233 return (made, None);
234}
235
236/// What:
237/// Creates the files and links of one batch, in parallel slices when the batch is large.
238/// Why:
239/// Their parents exist already, so the creations are independent; every created path is recorded for rollback
240/// before the first failure is returned.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// async function installLeaves({ stageRoot, destinationRoot, leaves, created }): Promise<void>;
245/// ```
246fn install_leaves(
247 stage_root: &Path,
248 destination_root: &Path,
249 leaves: &[&WorktreeCopyEntry],
250 created: &mut Vec<InstalledPath>,
251) -> Result<(), WorktreeCopyError> {
252 let workers: usize = if leaves.len() < PARALLEL_INSTALL_MIN_LEAVES {
253 1
254 } else {
255 sliced_workers(leaves.len(), PARALLEL_COPY_MAX_WORKERS, &HostResources::read())
256 };
257 let slice_length: usize = leaves.len().div_ceil(workers).max(1);
258 let results: Vec<(Vec<InstalledPath>, Option<WorktreeCopyError>)> = std::thread::scope(|scope| {
259 let handles: Vec<_> = leaves
260 .chunks(slice_length)
261 .map(|slice| return scope.spawn(move || return install_slice(stage_root, destination_root, slice)))
262 .collect();
263 return handles
264 .into_iter()
265 .map(|handle| {
266 return handle.join().unwrap_or_else(|_panic| {
267 return (Vec::new(), Some(failure_with_cause("cli-git: an install worker panicked.", None)));
268 });
269 })
270 .collect();
271 });
272 let mut failure: Option<WorktreeCopyError> = None;
273 for (made, stopped) in results {
274 created.extend(made);
275 failure = failure.or(stopped);
276 }
277 return match failure {
278 Some(error) => Err(error),
279 None => Ok(()),
280 };
281}
282
283/// What:
284/// Installs a whole stage into the destination, or rolls back its own paths and reports what it kept.
285/// Why:
286/// A failed installation ends with the destination as it was before, apart from paths the rollback could not prove were its own.
287///
288/// In TS you'd write (pseudocode):
289/// ```ts
290/// async function installSnapshot({ snapshot, destinationRoot, journalState, priorCreations }): Promise<number>;
291/// ```
292pub fn install_snapshot(
293 stage_root: &Path,
294 destination_root: &Path,
295 entries: &[WorktreeCopyEntry],
296 claimed: &BTreeSet<String>,
297 prior: &[InstalledPath],
298 recorder: &mut dyn InstallRecorder,
299) -> Result<usize, WorktreeCopyError> {
300 let mut created: Vec<InstalledPath> = prior.to_vec();
301 match install_all(
302 stage_root,
303 destination_root,
304 entries,
305 claimed,
306 &mut created,
307 recorder,
308 ) {
309 Ok(copied) => return Ok(copied),
310 Err(error) => {
311 let retained: Vec<String> =
312 rollback_created(stage_root, destination_root, entries, created.as_slice())?;
313 let suffix: String = if retained.is_empty() {
314 String::new()
315 } else {
316 let names: Vec<String> = retained
317 .iter()
318 .map(|path| return quote(path.as_str()))
319 .collect();
320 format!(" Rollback retained: {}.", names.join(", "))
321 };
322 let message: String = format!(
323 "cli-git: ignored-state installation failed for {}.{suffix}",
324 quote(destination_root.to_string_lossy().as_ref())
325 );
326 return Err(failure_with_cause(message.as_str(), Some(&error.0)));
327 }
328 }
329}
330
331/// What:
332/// The installation steps that run before rollback can apply: preflight, batches, and modes.
333/// Why:
334/// Separating them lets one rollback cover every failure after the first mutation.
335///
336/// In TS you'd write (pseudocode):
337/// ```ts
338/// const absent = await preflightDestination(...); for (const batch of installBatches(...)) await installBatch(...); await applyEntryModes(...);
339/// ```
340fn install_all(
341 stage_root: &Path,
342 destination_root: &Path,
343 entries: &[WorktreeCopyEntry],
344 claimed: &BTreeSet<String>,
345 created: &mut Vec<InstalledPath>,
346 recorder: &mut dyn InstallRecorder,
347) -> Result<usize, WorktreeCopyError> {
348 let started: std::time::Instant = std::time::Instant::now();
349 let absent: BTreeSet<String> =
350 preflight_destination(stage_root, destination_root, entries, claimed)?;
351 let preflighted: std::time::Duration = started.elapsed();
352 let mut known: std::collections::HashSet<String> = std::collections::HashSet::new();
353 for batch in entries.chunks(INSTALL_BATCH_ENTRIES) {
354 install_batch(
355 stage_root,
356 destination_root,
357 batch,
358 &absent,
359 created,
360 recorder,
361 &mut known,
362 )?;
363 }
364 let created_after: std::time::Duration = started.elapsed();
365 apply_entry_modes(destination_root, entries)?;
366 crate::diagnostic_log::debug(
367 "worktreeCopyInstall",
368 format!(
369 "installed {} entries: preflight {} ms, create and record {} ms, modes {} ms",
370 entries.len(),
371 preflighted.as_millis(),
372 (created_after - preflighted).as_millis(),
373 (started.elapsed() - created_after).as_millis()
374 )
375 .as_str(),
376 );
377 let selected: usize = created
378 .iter()
379 .filter(|installed| return installed.selected)
380 .count();
381 return Ok(selected);
382}
383
384/// Installation controls stay out of the release executable.
385#[cfg(test)]
386#[path = "worktree_copy_install_tests.rs"]
387mod tests;