Skip to main content

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;