Skip to main content

git_policy_cli/worktree_copying/
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 crate::diagnostic_log::debug;
15use crate::transaction_git::GitContext;
16use crate::worktree_copy_error::{WorktreeCopyError, raw_failure};
17use crate::worktree_copy_install::{InstallRecorder, install_snapshot};
18use crate::worktree_copy_install_entry::InstalledPath;
19use crate::worktree_copy_install_log::{InstallLog, open_install_log};
20use crate::worktree_copy_journal::{
21    Journal, PendingJournal, Phase, create_journal, remove_pending_journal, write_journal,
22};
23use crate::worktree_copy_manifest::{WorktreeCopyEntry, collect_entry_manifest};
24use crate::worktree_copy_selection::CopySelection;
25use crate::worktree_copy_stage::{StagedSnapshot, stage_ignored_snapshot};
26use crate::worktree_copy_stage_claim::{StageClaim, claim_stage};
27use crate::json_record::quote;
28use std::collections::BTreeSet;
29use std::path::{Path, PathBuf};
30
31/// What:
32///  The mutable durable state of one installation: the latest journal, the open log, and every claim and creation so far.
33/// Why:
34///   The installer reports claims and creations through this state, so each one reaches disk before the next step.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// type JournalState = { pending; log; intended: Set<string>; createdPaths: Set<string> };
39/// ```
40pub struct TransactionState {
41    /// The latest durable journal.
42    pub pending: PendingJournal,
43    /// The open append-only log of this stage.
44    pub log: InstallLog,
45    /// Every selected path claimed so far.
46    pub intended: BTreeSet<String>,
47    /// Every path whose creation has been recorded so far.
48    pub created_paths: BTreeSet<String>,
49}
50
51impl InstallRecorder for TransactionState {
52    /// Records the claims that no earlier record holds, then remembers them.
53    fn record_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
54        let unclaimed: Vec<String> = relative_paths
55            .iter()
56            .filter(|relative_path| return !self.intended.contains(relative_path.as_str()))
57            .cloned()
58            .collect();
59        self.log.append_intents(unclaimed.as_slice())?;
60        self.intended.extend(unclaimed);
61        return Ok(());
62    }
63
64    /// Records the creations no earlier record holds, then remembers them.
65    fn record_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
66        let unrecorded: Vec<InstalledPath> = entries
67            .iter()
68            .filter(|entry| return !self.created_paths.contains(entry.relative_path.as_str()))
69            .cloned()
70            .collect();
71        self.log.append_creations(unrecorded.as_slice())?;
72        self.created_paths.extend(
73            unrecorded
74                .into_iter()
75                .map(|entry| return entry.relative_path),
76        );
77        return Ok(());
78    }
79}
80
81/// What:
82///  Persists the installing phase before the destination is touched.
83/// Why:
84///   Recovery must know the installation had started, so it reads the log instead of discarding the stage.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// async function beginInstalling(state): Promise<void>;
89/// ```
90pub fn begin_installing(state: &mut TransactionState) -> Result<(), WorktreeCopyError> {
91    if state.pending.journal.phase == Phase::Installing {
92        return Ok(());
93    }
94    let installing: Journal = Journal {
95        phase: Phase::Installing,
96        ..state.pending.journal.clone()
97    };
98    write_journal(state.pending.path.as_path(), &installing)?;
99    state.pending.journal = installing;
100    return Ok(());
101}
102
103/// What:
104///  The validated staged payload a journal describes, rebuilt from its stage.
105/// Why:
106///   Recovery installs from the stage that exists now, and refuses a stage that lost a claimed path.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// async function snapshotFromJournal({ journal, intendedEntries }): Promise<StagedWorktreeSnapshot>;
111/// ```
112pub fn snapshot_from_journal(
113    pending: &PendingJournal,
114    intended: &BTreeSet<String>,
115) -> Result<StagedSnapshot, WorktreeCopyError> {
116    let stage_root: PathBuf = PathBuf::from(pending.journal.stage_root.as_str());
117    let outcome: Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> = (|| {
118        let entries: Vec<WorktreeCopyEntry> = collect_entry_manifest(
119            stage_root.as_path(),
120            pending.journal.selected_roots.as_slice(),
121            &[],
122        )?;
123        let present: BTreeSet<&str> = entries
124            .iter()
125            .map(|entry| return entry.relative_path.as_str())
126            .collect();
127        for claim in intended {
128            if !present.contains(claim.as_str()) {
129                let message: String = format!(
130                    "cli-git: worktree-copy journal intent is absent from private stage {}.",
131                    quote(pending.journal.stage_root.as_str())
132                );
133                return Err(crate::worktree_copy_error::failure_with_cause(
134                    message.as_str(),
135                    None,
136                ));
137            }
138        }
139        return Ok(entries);
140    })();
141    // Every failure here reads as a failed recovery, with its own cause after it.
142    let entries: Vec<WorktreeCopyEntry> = match outcome {
143        Ok(entries) => entries,
144        Err(error) => {
145            let message: String = format!(
146                "cli-git: could not recover staged ignored state at {}.",
147                quote(pending.journal.stage_root.as_str())
148            );
149            return Err(crate::worktree_copy_error::failure_with_cause(
150                message.as_str(),
151                Some(&error.0),
152            ));
153        }
154    };
155    return Ok(StagedSnapshot {
156        entries,
157        selected_roots: pending.journal.selected_roots.clone(),
158        source_root: PathBuf::from(pending.journal.source_root.as_str()),
159        stage_container: PathBuf::from(pending.journal.stage_container.as_str()),
160        stage_root,
161        unreadable: Vec::new(),
162    });
163}
164
165/// What:
166///  The result of one installation attempt.
167/// Why:
168///   A failed installation is an outcome the caller reports, while a journal fault is an error that keeps the state.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// type InstallationOutcome = { kind: 'installed'; copiedEntries: number } | { kind: 'ended'; failure: WorktreeCopyError };
173/// ```
174#[derive(Debug)]
175pub enum Outcome {
176    /// Every selected entry exists; the count is the number this transaction created.
177    Installed(usize),
178    /// Installation failed after rollback; the journal and stage are removed.
179    Ended(WorktreeCopyError),
180}
181
182/// What:
183///  Runs one journaled installation: opens the log, seeds the claims, installs, and reports the latest journal.
184/// Why:
185///   Resumption and first attempts share this path, so an interrupted run's records are always honored.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// async function installRecorded({ pending, snapshot }): Promise<{ latest; result }>;
190/// ```
191fn install_recorded(
192    pending: PendingJournal,
193    snapshot: &dyn Fn(&BTreeSet<String>) -> Result<StagedSnapshot, WorktreeCopyError>,
194) -> Result<(PendingJournal, Outcome), WorktreeCopyError> {
195    let stage: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
196    let opened = open_install_log(stage.as_path())?;
197    let mut intended: BTreeSet<String> = pending.journal.intended_entries.iter().cloned().collect();
198    intended.extend(opened.recorded.intended.iter().cloned());
199    let mut created_paths: BTreeSet<String> = pending
200        .journal
201        .created_entries
202        .iter()
203        .map(|entry| return entry.relative_path.clone())
204        .collect();
205    created_paths.extend(
206        opened
207            .recorded
208            .created
209            .iter()
210            .map(|entry| return entry.relative_path.clone()),
211    );
212    let mut prior: Vec<InstalledPath> = pending.journal.created_entries.clone();
213    prior.extend(opened.recorded.created.iter().cloned());
214    let staged: StagedSnapshot = snapshot(&intended)?;
215    let mut state: TransactionState = TransactionState {
216        pending,
217        log: opened.log,
218        intended,
219        created_paths,
220    };
221    begin_installing(&mut state)?;
222    let claimed: BTreeSet<String> = state
223        .intended
224        .union(&state.created_paths)
225        .cloned()
226        .collect();
227    let destination: PathBuf = PathBuf::from(state.pending.journal.destination_root.as_str());
228    let result: Result<usize, WorktreeCopyError> = install_snapshot(
229        staged.stage_root.as_path(),
230        destination.as_path(),
231        staged.entries.as_slice(),
232        &claimed,
233        prior.as_slice(),
234        &mut state,
235    );
236    let TransactionState {
237        pending: latest, ..
238    } = state;
239    // `match` turns an installation failure into an outcome, and leaves journal faults as errors.
240    match result {
241        Ok(copied) => return Ok((latest, Outcome::Installed(copied))),
242        Err(error) => return Ok((latest, Outcome::Ended(error))),
243    }
244}
245
246/// What:
247///  Completes one journaled installation and removes its journal and stage, whatever the installation's outcome.
248/// Why:
249///   Only a journal that cannot be read or written stays behind, for inspection.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// async function completeJournal({ pending, snapshot }): Promise<InstallationOutcome>;
254/// ```
255pub fn complete_journal(
256    pending: PendingJournal,
257    snapshot: &dyn Fn(&BTreeSet<String>) -> Result<StagedSnapshot, WorktreeCopyError>,
258) -> Result<Outcome, WorktreeCopyError> {
259    let started: std::time::Instant = std::time::Instant::now();
260    let (latest, outcome) = install_recorded(pending, snapshot)?;
261    let recorded: std::time::Duration = started.elapsed();
262    remove_pending_journal(&latest)?;
263    debug(
264        "worktreeCopyTransaction",
265        format!(
266            "completed: install {} ms, stage and journal removal {} ms",
267            recorded.as_millis(),
268            (started.elapsed() - recorded).as_millis()
269        )
270        .as_str(),
271    );
272    return Ok(outcome);
273}
274
275/// What:
276///  Stages, journals and installs the ignored state of one newly created worktree.
277/// Why:
278///   The stage is removed if the journal cannot be created, and a process that dies while staging leaves a claim,
279///  so no stage survives without a record that recovery can act on.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// async function synchronizeCreatedWorktree({ commonDir, sourceRoot, destinationRoot, registeredRoots, gitPath }): Promise<number>;
284/// ```
285pub fn synchronize_created_worktree(
286    context: &GitContext,
287    common_dir: &Path,
288    source_root: &Path,
289    destination_root: &Path,
290    registered_roots: &[PathBuf],
291    selection: &CopySelection,
292) -> Result<usize, WorktreeCopyError> {
293    return synchronize_created_worktree_noting(
294        context,
295        common_dir,
296        source_root,
297        destination_root,
298        registered_roots,
299        selection,
300        &mut std::io::sink(),
301    );
302}
303
304/// How many unreadable paths the notice names before it says how many more there are.
305///  Exempt from a citation under `LQM` (class: display cap): it limits how much text a message, log field or record
306///  names, and no command result or duration depends on it. Classed once in `doc/planning/cli-git-shipped-limits-
307///  audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
308const NOTICE_PATHS: usize = 3;
309
310/// What:
311///  The one notice line for ignored entries the user could not read and the copy therefore left out.
312/// Why:
313///   The new worktree lacks them, so the person sees how many and which first ones, without a flood for a large set.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// `cli-git: left out ${count} unreadable ignored entries from ${source}: ${first.join(', ')}${more}.\n`
318/// ```
319fn unreadable_notice(source_root: &Path, unreadable: &[String]) -> String {
320    let named: Vec<String> = unreadable.iter().take(NOTICE_PATHS).map(|path| return quote(path.as_str())).collect();
321    let rest: usize = unreadable.len().saturating_sub(NOTICE_PATHS);
322    let more: String = if rest == 0 { String::new() } else { format!(" and {rest} more") };
323    let noun: &str = if unreadable.len() == 1 { "entry" } else { "entries" };
324    return format!(
325        "cli-git: left out {} unreadable ignored {noun} from {}: {}{more}.\n",
326        unreadable.len(),
327        quote(source_root.to_string_lossy().as_ref()),
328        named.join(", ")
329    );
330}
331
332/// What:
333///  Stages one source's ignored state, installs it into one created worktree, and writes a notice naming the
334///  entries the user could not read.
335/// Why:
336///   The notice goes to the writer the lifecycle holds for the person; the count of copied entries is the result.
337///
338/// In TS you'd write (pseudocode):
339/// ```ts
340/// async function synchronizeCreatedWorktreeNoting({ commonDir, sourceRoot, destinationRoot, registeredRoots, selection, notice }): Promise<number>;
341/// ```
342pub fn synchronize_created_worktree_noting(
343    context: &GitContext,
344    common_dir: &Path,
345    source_root: &Path,
346    destination_root: &Path,
347    registered_roots: &[PathBuf],
348    selection: &CopySelection,
349    notice: &mut dyn std::io::Write,
350) -> Result<usize, WorktreeCopyError> {
351    let started: std::time::Instant = std::time::Instant::now();
352    let claim: StageClaim = claim_stage(common_dir, destination_root)?;
353    // `match` on staging: a failure has already removed its container, so only the claim is left to release.
354    let snapshot: StagedSnapshot = match stage_ignored_snapshot(
355        context,
356        source_root,
357        claim.container(),
358        registered_roots,
359        selection,
360    ) {
361        Ok(snapshot) => snapshot,
362        Err(error) => {
363            claim.release()?;
364            return Err(error);
365        }
366    };
367    let staged_done: std::time::Duration = started.elapsed();
368    let journaling: std::time::Instant = std::time::Instant::now();
369    let pending: PendingJournal = match create_journal(common_dir, destination_root, &snapshot) {
370        Ok(pending) => pending,
371        Err(error) => {
372            // A failed removal replaces the journal failure, as the incumbent's cleanup throws its own error there.
373            std::fs::remove_dir_all(snapshot.stage_container.as_path())
374                .map_err(|removal| return raw_failure(&removal))?;
375            claim.release()?;
376            return Err(error);
377        }
378    };
379    debug(
380        "worktreeCopyTransaction",
381        format!("journal created in {} ms after the stage at {} ms", journaling.elapsed().as_millis(), staged_done.as_millis()).as_str(),
382    );
383    // The journal names the stage from here on, so the claim has done its work.
384    claim.release()?;
385    let staged_after: std::time::Duration = started.elapsed();
386    let staged: StagedSnapshot = snapshot.clone();
387    let outcome: Outcome = complete_journal(pending, &|_intended| return Ok(staged.clone()))?;
388    debug(
389        "worktreeCopyTransaction",
390        format!(
391            "settled {} entries in {} ms: stage and journal {} ms, install and completion {} ms",
392            snapshot.entries.len(),
393            started.elapsed().as_millis(),
394            staged_after.as_millis(),
395            (started.elapsed() - staged_after).as_millis()
396        )
397        .as_str(),
398    );
399    return match outcome {
400        Outcome::Installed(copied) => {
401            if !snapshot.unreadable.is_empty() {
402                // A closed standard error leaves nowhere to print; the log line of the stage still names the entries.
403                let _written = notice.write_all(unreadable_notice(source_root, snapshot.unreadable.as_slice()).as_bytes());
404            }
405            Ok(copied)
406        }
407        Outcome::Ended(error) => Err(error),
408    };
409}
410
411/// Transaction controls stay out of the release executable.
412#[cfg(test)]
413#[path = "worktree_copy_transaction_tests.rs"]
414mod tests;