Skip to main content

git_policy_cli/
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 super::worktree_copy_compare::{entry_matches, lstat_or_absent};
16use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause};
17use super::worktree_copy_install_entry::{
18    InstalledPath, capture_installed_path, create_selected_entry, ensure_parents,
19};
20use super::worktree_copy_manifest::{EntryKind, WorktreeCopyEntry, apply_entry_modes};
21use super::worktree_copy_rollback::rollback_created;
22use super::worktree_copy_selection::filesystem_path;
23use crate::json_record::quote;
24use std::collections::BTreeSet;
25use std::path::{Path, PathBuf};
26
27/// What:
28///  The number of manifest entries claimed by one durable intent record and settled by one creation record.
29/// Why:
30///   Bounds both the number of journal appends and the work an interrupted owner can leave unrecorded.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// const INSTALL_BATCH_ENTRIES = 512;
35/// ```
36pub const INSTALL_BATCH_ENTRIES: usize = 512;
37
38/// What:
39///  The durable record of an installation in progress.
40/// Why:
41///   The installation reports every claim before it creates a path and every proven creation after,
42///  so the journal slice can persist both without this module knowing the file format.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// recordIntents({ state, relativePaths }); recordCreations({ state, entries });
47/// ```
48pub trait InstallRecorder {
49    /// Records the absent paths one batch is about to create, before any of them exists.
50    fn record_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError>;
51
52    /// Records the identities of the paths one batch created.
53    fn record_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError>;
54}
55
56/// What:
57///  A recorder that writes nothing, for installations whose caller keeps no durable state.
58/// Why:
59///   Such an installation is not crash-safe; it exists so the installer can be exercised before the journal lands.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// // no journal state
64/// ```
65#[derive(Debug, Default)]
66pub struct NoRecord;
67
68impl InstallRecorder for NoRecord {
69    /// Accepts the intent without storing it.
70    fn record_intents(&mut self, _relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
71        return Ok(());
72    }
73
74    /// Accepts the creations without storing them.
75    fn record_creations(&mut self, _entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
76        return Ok(());
77    }
78}
79
80/// What:
81///  The selected paths absent from the destination, after every existing entry has been proven identical.
82/// Why:
83///   An existing entry that differs stops the installation before any mutation, and an identical one is accepted without being overwritten.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// async function preflightDestination({ snapshot, destinationRoot, recorded }): Promise<ReadonlySet<string>>;
88/// ```
89pub fn preflight_destination(
90    stage_root: &Path,
91    destination_root: &Path,
92    entries: &[WorktreeCopyEntry],
93    claimed: &BTreeSet<String>,
94) -> Result<BTreeSet<String>, WorktreeCopyError> {
95    let mut absent: BTreeSet<String> = BTreeSet::new();
96    for entry in entries {
97        let destination_path: PathBuf =
98            filesystem_path(destination_root, entry.relative_path.as_str())?;
99        // `match` on the existing entry: an absent path is recorded, a claimed directory is accepted, anything else must be identical.
100        match lstat_or_absent(destination_path.as_path())? {
101            None => {
102                absent.insert(entry.relative_path.clone());
103            }
104            Some(metadata) => {
105                let claimed_directory: bool = entry.kind == EntryKind::Directory
106                    && metadata.is_dir()
107                    && claimed.contains(&entry.relative_path);
108                if claimed_directory {
109                    continue;
110                }
111                if !entry_matches(stage_root, destination_root, entry)? {
112                    let message: String = format!(
113                        "cli-git: ignored-state copy would overwrite differing destination entry {} in {}.",
114                        quote(entry.relative_path.as_str()),
115                        quote(destination_root.to_string_lossy().as_ref())
116                    );
117                    return Err(failure_with_cause(message.as_str(), None));
118                }
119            }
120        }
121    }
122    return Ok(absent);
123}
124
125/// What:
126///  Creates one batch's absent entries in manifest order, recording the claim before and the identities after.
127/// Why:
128///   The claim is durable before the first path exists, so an interruption leaves a record of every path that might be partial.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// async function installBatch({ snapshot, destinationRoot, batch, absent, created, journalState }): Promise<void>;
133/// ```
134fn install_batch(
135    stage_root: &Path,
136    destination_root: &Path,
137    batch: &[WorktreeCopyEntry],
138    absent: &BTreeSet<String>,
139    created: &mut Vec<InstalledPath>,
140    recorder: &mut dyn InstallRecorder,
141) -> Result<(), WorktreeCopyError> {
142    let claims: Vec<String> = batch
143        .iter()
144        .map(|entry| return entry.relative_path.clone())
145        .filter(|relative_path| return absent.contains(relative_path))
146        .collect();
147    recorder.record_intents(claims.as_slice())?;
148    let batch_start: usize = created.len();
149    for entry in batch {
150        ensure_parents(destination_root, entry, created)?;
151        let destination_path: PathBuf =
152            filesystem_path(destination_root, entry.relative_path.as_str())?;
153        // A path that appeared since preflight is never created over; the next settle step decides what it is.
154        if lstat_or_absent(destination_path.as_path())?.is_some() {
155            continue;
156        }
157        create_selected_entry(stage_root, destination_root, entry)?;
158        created.push(capture_installed_path(
159            destination_path.as_path(),
160            entry.relative_path.as_str(),
161            true,
162        )?);
163    }
164    recorder.record_creations(&created[batch_start..])?;
165    return Ok(());
166}
167
168/// What:
169///  Installs a whole stage into the destination, or rolls back its own paths and reports what it kept.
170/// Why:
171///   A failed installation ends with the destination as it was before, apart from paths the rollback could not prove were its own.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// async function installSnapshot({ snapshot, destinationRoot, journalState, priorCreations }): Promise<number>;
176/// ```
177pub fn install_snapshot(
178    stage_root: &Path,
179    destination_root: &Path,
180    entries: &[WorktreeCopyEntry],
181    claimed: &BTreeSet<String>,
182    prior: &[InstalledPath],
183    recorder: &mut dyn InstallRecorder,
184) -> Result<usize, WorktreeCopyError> {
185    let mut created: Vec<InstalledPath> = prior.to_vec();
186    match install_all(
187        stage_root,
188        destination_root,
189        entries,
190        claimed,
191        &mut created,
192        recorder,
193    ) {
194        Ok(copied) => return Ok(copied),
195        Err(error) => {
196            let retained: Vec<String> =
197                rollback_created(stage_root, destination_root, entries, created.as_slice())?;
198            let suffix: String = if retained.is_empty() {
199                String::new()
200            } else {
201                let names: Vec<String> = retained
202                    .iter()
203                    .map(|path| return quote(path.as_str()))
204                    .collect();
205                format!(" Rollback retained: {}.", names.join(", "))
206            };
207            let message: String = format!(
208                "cli-git: ignored-state installation failed for {}.{suffix}",
209                quote(destination_root.to_string_lossy().as_ref())
210            );
211            return Err(failure_with_cause(message.as_str(), Some(&error.0)));
212        }
213    }
214}
215
216/// What:
217///  The installation steps that run before rollback can apply: preflight, batches, and modes.
218/// Why:
219///   Separating them lets one rollback cover every failure after the first mutation.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// const absent = await preflightDestination(...); for (const batch of installBatches(...)) await installBatch(...); await applyEntryModes(...);
224/// ```
225fn install_all(
226    stage_root: &Path,
227    destination_root: &Path,
228    entries: &[WorktreeCopyEntry],
229    claimed: &BTreeSet<String>,
230    created: &mut Vec<InstalledPath>,
231    recorder: &mut dyn InstallRecorder,
232) -> Result<usize, WorktreeCopyError> {
233    let absent: BTreeSet<String> =
234        preflight_destination(stage_root, destination_root, entries, claimed)?;
235    for batch in entries.chunks(INSTALL_BATCH_ENTRIES) {
236        install_batch(
237            stage_root,
238            destination_root,
239            batch,
240            &absent,
241            created,
242            recorder,
243        )?;
244    }
245    apply_entry_modes(destination_root, entries)?;
246    let selected: usize = created
247        .iter()
248        .filter(|installed| return installed.selected)
249        .count();
250    return Ok(selected);
251}
252
253/// Installation controls stay out of the release executable.
254#[cfg(test)]
255#[path = "worktree_copy_install_tests.rs"]
256mod tests;