Skip to main content

git_policy_cli/
worktree_copy_transaction.rs

1//! What:
2//!  One destination's journaled installation to an end, and the synchronization of every created worktree.
3//!  The journal records the phase, the install log records each batch's claims and creations,
4//!  and a finished or failed installation always removes its journal and stage.
5//! Why:
6//!  A failed installation must not stay pending, because recovery would replay the same failure
7//!  before every later command; an interrupted one must resume from exactly what it recorded.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const outcome = await completeJournal({ pending, snapshot });
12//! ```
13
14use super::transaction_git::GitContext;
15use super::worktree_copy_error::{WorktreeCopyError, raw_failure};
16use super::worktree_copy_install::{InstallRecorder, install_snapshot};
17use super::worktree_copy_install_entry::InstalledPath;
18use super::worktree_copy_install_log::{InstallLog, open_install_log};
19use super::worktree_copy_journal::{
20    Journal, PendingJournal, Phase, create_journal, remove_pending_journal, write_journal,
21};
22use super::worktree_copy_manifest::{WorktreeCopyEntry, collect_entry_manifest};
23use super::worktree_copy_selection::CopySelection;
24use super::worktree_copy_stage::{StagedSnapshot, stage_ignored_snapshot};
25use crate::json_record::quote;
26use std::collections::BTreeSet;
27use std::path::{Path, PathBuf};
28
29/// What:
30///  The mutable durable state of one installation: the latest journal, the open log, and every claim and creation so far.
31/// Why:
32///   The installer reports claims and creations through this state, so each one reaches disk before the next step.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// type JournalState = { pending; log; intended: Set<string>; createdPaths: Set<string> };
37/// ```
38pub struct TransactionState {
39    /// The latest durable journal.
40    pub pending: PendingJournal,
41    /// The open append-only log of this stage.
42    pub log: InstallLog,
43    /// Every selected path claimed so far.
44    pub intended: BTreeSet<String>,
45    /// Every path whose creation has been recorded so far.
46    pub created_paths: BTreeSet<String>,
47}
48
49impl InstallRecorder for TransactionState {
50    /// Records the claims that no earlier record holds, then remembers them.
51    fn record_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
52        let unclaimed: Vec<String> = relative_paths
53            .iter()
54            .filter(|relative_path| return !self.intended.contains(relative_path.as_str()))
55            .cloned()
56            .collect();
57        self.log.append_intents(unclaimed.as_slice())?;
58        self.intended.extend(unclaimed);
59        return Ok(());
60    }
61
62    /// Records the creations no earlier record holds, then remembers them.
63    fn record_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
64        let unrecorded: Vec<InstalledPath> = entries
65            .iter()
66            .filter(|entry| return !self.created_paths.contains(entry.relative_path.as_str()))
67            .cloned()
68            .collect();
69        self.log.append_creations(unrecorded.as_slice())?;
70        self.created_paths.extend(
71            unrecorded
72                .into_iter()
73                .map(|entry| return entry.relative_path),
74        );
75        return Ok(());
76    }
77}
78
79/// What:
80///  Persists the installing phase before the destination is touched.
81/// Why:
82///   Recovery must know the installation had started, so it reads the log instead of discarding the stage.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// async function beginInstalling(state): Promise<void>;
87/// ```
88pub fn begin_installing(state: &mut TransactionState) -> Result<(), WorktreeCopyError> {
89    if state.pending.journal.phase == Phase::Installing {
90        return Ok(());
91    }
92    let installing: Journal = Journal {
93        phase: Phase::Installing,
94        ..state.pending.journal.clone()
95    };
96    write_journal(state.pending.path.as_path(), &installing)?;
97    state.pending.journal = installing;
98    return Ok(());
99}
100
101/// What:
102///  The validated staged payload a journal describes, rebuilt from its stage.
103/// Why:
104///   Recovery installs from the stage that exists now, and refuses a stage that lost a claimed path.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// async function snapshotFromJournal({ journal, intendedEntries }): Promise<StagedWorktreeSnapshot>;
109/// ```
110pub fn snapshot_from_journal(
111    pending: &PendingJournal,
112    intended: &BTreeSet<String>,
113) -> Result<StagedSnapshot, WorktreeCopyError> {
114    let stage_root: PathBuf = PathBuf::from(pending.journal.stage_root.as_str());
115    let outcome: Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> = (|| {
116        let entries: Vec<WorktreeCopyEntry> = collect_entry_manifest(
117            stage_root.as_path(),
118            pending.journal.selected_roots.as_slice(),
119            &[],
120        )?;
121        let present: BTreeSet<&str> = entries
122            .iter()
123            .map(|entry| return entry.relative_path.as_str())
124            .collect();
125        for claim in intended {
126            if !present.contains(claim.as_str()) {
127                let message: String = format!(
128                    "cli-git: worktree-copy journal intent is absent from private stage {}.",
129                    quote(pending.journal.stage_root.as_str())
130                );
131                return Err(super::worktree_copy_error::failure_with_cause(
132                    message.as_str(),
133                    None,
134                ));
135            }
136        }
137        return Ok(entries);
138    })();
139    // Every failure here reads as a failed recovery, with its own cause after it.
140    let entries: Vec<WorktreeCopyEntry> = match outcome {
141        Ok(entries) => entries,
142        Err(error) => {
143            let message: String = format!(
144                "cli-git: could not recover staged ignored state at {}.",
145                quote(pending.journal.stage_root.as_str())
146            );
147            return Err(super::worktree_copy_error::failure_with_cause(
148                message.as_str(),
149                Some(&error.0),
150            ));
151        }
152    };
153    return Ok(StagedSnapshot {
154        entries,
155        selected_roots: pending.journal.selected_roots.clone(),
156        source_root: PathBuf::from(pending.journal.source_root.as_str()),
157        stage_container: PathBuf::from(pending.journal.stage_container.as_str()),
158        stage_root,
159    });
160}
161
162/// What:
163///  The result of one installation attempt.
164/// Why:
165///   A failed installation is an outcome the caller reports, while a journal fault is an error that keeps the state.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// type InstallationOutcome = { kind: 'installed'; copiedEntries: number } | { kind: 'ended'; failure: WorktreeCopyError };
170/// ```
171#[derive(Debug)]
172pub enum Outcome {
173    /// Every selected entry exists; the count is the number this transaction created.
174    Installed(usize),
175    /// Installation failed after rollback; the journal and stage are removed.
176    Ended(WorktreeCopyError),
177}
178
179/// What:
180///  Runs one journaled installation: opens the log, seeds the claims, installs, and reports the latest journal.
181/// Why:
182///   Resumption and first attempts share this path, so an interrupted run's records are always honored.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// async function installRecorded({ pending, snapshot }): Promise<{ latest; result }>;
187/// ```
188fn install_recorded(
189    pending: PendingJournal,
190    snapshot: &dyn Fn(&BTreeSet<String>) -> Result<StagedSnapshot, WorktreeCopyError>,
191) -> Result<(PendingJournal, Outcome), WorktreeCopyError> {
192    let stage: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
193    let opened = open_install_log(stage.as_path())?;
194    let mut intended: BTreeSet<String> = pending.journal.intended_entries.iter().cloned().collect();
195    intended.extend(opened.recorded.intended.iter().cloned());
196    let mut created_paths: BTreeSet<String> = pending
197        .journal
198        .created_entries
199        .iter()
200        .map(|entry| return entry.relative_path.clone())
201        .collect();
202    created_paths.extend(
203        opened
204            .recorded
205            .created
206            .iter()
207            .map(|entry| return entry.relative_path.clone()),
208    );
209    let mut prior: Vec<InstalledPath> = pending.journal.created_entries.clone();
210    prior.extend(opened.recorded.created.iter().cloned());
211    let staged: StagedSnapshot = snapshot(&intended)?;
212    let mut state: TransactionState = TransactionState {
213        pending,
214        log: opened.log,
215        intended,
216        created_paths,
217    };
218    begin_installing(&mut state)?;
219    let claimed: BTreeSet<String> = state
220        .intended
221        .union(&state.created_paths)
222        .cloned()
223        .collect();
224    let destination: PathBuf = PathBuf::from(state.pending.journal.destination_root.as_str());
225    let result: Result<usize, WorktreeCopyError> = install_snapshot(
226        staged.stage_root.as_path(),
227        destination.as_path(),
228        staged.entries.as_slice(),
229        &claimed,
230        prior.as_slice(),
231        &mut state,
232    );
233    let TransactionState {
234        pending: latest, ..
235    } = state;
236    // `match` turns an installation failure into an outcome, and leaves journal faults as errors.
237    match result {
238        Ok(copied) => return Ok((latest, Outcome::Installed(copied))),
239        Err(error) => return Ok((latest, Outcome::Ended(error))),
240    }
241}
242
243/// What:
244///  Completes one journaled installation and removes its journal and stage, whatever the installation's outcome.
245/// Why:
246///   Only a journal that cannot be read or written stays behind, for inspection.
247///
248/// In TS you'd write (pseudocode):
249/// ```ts
250/// async function completeJournal({ pending, snapshot }): Promise<InstallationOutcome>;
251/// ```
252pub fn complete_journal(
253    pending: PendingJournal,
254    snapshot: &dyn Fn(&BTreeSet<String>) -> Result<StagedSnapshot, WorktreeCopyError>,
255) -> Result<Outcome, WorktreeCopyError> {
256    let (latest, outcome) = install_recorded(pending, snapshot)?;
257    remove_pending_journal(&latest)?;
258    return Ok(outcome);
259}
260
261/// What:
262///  Stages, journals and installs the ignored state of one newly created worktree.
263/// Why:
264///   The stage is removed if the journal cannot be created, so no unjournaled stage survives.
265///
266/// In TS you'd write (pseudocode):
267/// ```ts
268/// async function synchronizeCreatedWorktree({ commonDir, sourceRoot, destinationRoot, registeredRoots, gitPath }): Promise<number>;
269/// ```
270pub fn synchronize_created_worktree(
271    context: &GitContext,
272    common_dir: &Path,
273    source_root: &Path,
274    destination_root: &Path,
275    registered_roots: &[PathBuf],
276    selection: &CopySelection,
277) -> Result<usize, WorktreeCopyError> {
278    let snapshot: StagedSnapshot = stage_ignored_snapshot(
279        context,
280        source_root,
281        destination_root,
282        registered_roots,
283        selection,
284    )?;
285    let pending: PendingJournal = match create_journal(common_dir, destination_root, &snapshot) {
286        Ok(pending) => pending,
287        Err(error) => {
288            // A failed removal replaces the journal failure, as the incumbent's cleanup throws its own error there.
289            std::fs::remove_dir_all(snapshot.stage_container.as_path())
290                .map_err(|removal| return raw_failure(&removal))?;
291            return Err(error);
292        }
293    };
294    let staged: StagedSnapshot = snapshot.clone();
295    let outcome: Outcome = complete_journal(pending, &|_intended| return Ok(staged.clone()))?;
296    return match outcome {
297        Outcome::Installed(copied) => Ok(copied),
298        Outcome::Ended(error) => Err(error),
299    };
300}
301
302/// Transaction controls stay out of the release executable.
303#[cfg(test)]
304#[path = "worktree_copy_transaction_tests.rs"]
305mod tests;