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