Skip to main content

git_policy_cli/
worktree_copy_recovery.rs

1//! What:
2//!  Recovery of interrupted linked-worktree copies: every pending journal is resumed, completed, or discarded
3//!  with a notice; malformed state fails closed and stays; the pending check that forwarded commands run first.
4//! Why:
5//!  A copy interrupted by a crash must not leave a worktree half-filled forever, but recovery may only act on
6//!  journals whose destination is still a registration of this repository and whose stage is still private.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const report = await recoverWorktreeCopyTransactions(commonDir);
11//! await recoverPendingWorktreeCopies(commonDir);
12//! ```
13
14use super::json_record::quote;
15use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
16use super::worktree_copy_lock::try_acquire_worktree_copy_lock;
17use super::worktree_copy_stage::StagedSnapshot;
18use super::worktree_copy_transaction::{Outcome, complete_journal, snapshot_from_journal};
19use crate::worktree_copy_journal::{
20    PendingJournal, Phase, Role, assert_private_path, read_pending_journals, remove_pending_journal,
21};
22use std::collections::BTreeSet;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26/// What:
27///  Whether a journal's destination is still a linked worktree of the journal's repository.
28/// Why:
29///   A destination that was removed, moved, pruned or re-pointed can receive nothing, so its journal is discarded.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// type DestinationRegistration = 'registered' | 'unregistered';
34/// ```
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub enum Registration {
37    /// The destination is registered under the common directory.
38    Registered,
39    /// The destination is not registered under the common directory.
40    Unregistered,
41}
42
43/// What:
44///  Whether a journal's private stage and payload still exist.
45/// Why:
46///   Installation reads the payload, so a missing payload ends recovery with a notice instead.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type StagePresence = 'stage-intact' | 'stage-missing';
51/// ```
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum StagePresence {
54    /// The container and payload exist and are private.
55    Intact,
56    /// The container or payload is gone.
57    Missing,
58}
59
60/// What:
61///  One pass of recovery: how many transactions completed and one notice per transaction ended otherwise.
62/// Why:
63///   The caller prints the notices and the summary; the counts come from this one value.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// type RecoveryReport = { recovered: number; notices: string[] };
68/// ```
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct RecoveryReport {
71    /// Transactions that completed, including completed cleanups.
72    pub recovered: usize,
73    /// One line for each transaction that ended without completing.
74    pub notices: Vec<String>,
75}
76
77/// What:
78///  The raw registration check before missing paths are turned into `unregistered`.
79/// Why:
80///   A missing path on the way to the registration is an answer, not a failure.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// async function readRegistration({ commonDir, destinationRoot }): Promise<DestinationRegistration>;
85/// ```
86fn read_registration(common_dir: &Path, destination: &Path) -> std::io::Result<Registration> {
87    if std::fs::canonicalize(destination)? != destination {
88        return Ok(Registration::Unregistered);
89    }
90    let pointer: String = std::fs::read_to_string(destination.join(".git"))?
91        .trim_end()
92        .to_string();
93    let Some(rest) = pointer.strip_prefix("gitdir: ") else {
94        return Ok(Registration::Unregistered);
95    };
96    let admin: PathBuf = std::fs::canonicalize(destination.join(rest))?;
97    let expected: PathBuf = std::fs::canonicalize(common_dir.join("worktrees"))?;
98    if admin.parent() == Some(expected.as_path()) {
99        return Ok(Registration::Registered);
100    }
101    return Ok(Registration::Unregistered);
102}
103
104/// What:
105///  Whether a destination is still a linked worktree of the common directory.
106/// Why:
107///   Recovery decides whether to finish or discard a copy from this answer.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// async function destinationRegistration({ commonDir, destinationRoot }): Promise<DestinationRegistration>;
112/// ```
113pub fn destination_registration(
114    common_dir: &Path,
115    destination: &Path,
116) -> Result<Registration, WorktreeCopyError> {
117    // `match` on the failure kind: a missing path means unregistered, anything else is a fault to report.
118    match read_registration(common_dir, destination) {
119        Ok(registration) => return Ok(registration),
120        Err(error)
121            if error.kind() == std::io::ErrorKind::NotFound
122                || error.raw_os_error() == Some(libc::ENOTDIR) =>
123        {
124            return Ok(Registration::Unregistered);
125        }
126        Err(error) => {
127            let message: String = format!(
128                "cli-git: could not read worktree registration of {}.",
129                quote(destination.to_string_lossy().as_ref())
130            );
131            return Err(failure_with_cause(message.as_str(), Some(&error)));
132        }
133    }
134}
135
136/// What:
137///  Checks that a journal's stage container and payload are private directories, or that they are gone.
138/// Why:
139///   Installation copies from the payload, so a planted or replaced stage must never be read.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// async function validateJournalStage(record): Promise<StagePresence>;
144/// ```
145pub fn validate_stage(pending: &PendingJournal) -> Result<StagePresence, WorktreeCopyError> {
146    let container: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
147    let payload: PathBuf = PathBuf::from(pending.journal.stage_root.as_str());
148    // `match` on the no-follow lookup: an absent path is reported as missing, and any other failure names the destination.
149    for path in [&container, &payload] {
150        match std::fs::symlink_metadata(path.as_path()) {
151            Ok(_) => {}
152            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
153                return Ok(StagePresence::Missing);
154            }
155            Err(error) => {
156                let message: String = format!(
157                    "cli-git: could not validate worktree-copy recovery state for {}.",
158                    quote(pending.journal.destination_root.as_str())
159                );
160                return Err(failure_with_cause(message.as_str(), Some(&error)));
161            }
162        }
163        assert_private_path(path.as_path(), Role::PrivateStage)?;
164    }
165    return Ok(StagePresence::Intact);
166}
167
168/// What:
169///  Brings one pending transaction to an end.
170/// Why:
171///   Every journal ends as completed, resumed-and-completed, or discarded with its notice; only unsafe state stays.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// async function recoverTransaction({ commonDir, journal }): Promise<RecoveryStep>;
176/// ```
177fn recover_transaction(
178    common_dir: &Path,
179    pending: PendingJournal,
180) -> Result<(bool, Option<String>), WorktreeCopyError> {
181    let destination: String = quote(pending.journal.destination_root.as_str());
182    let registration: Registration = destination_registration(
183        common_dir,
184        Path::new(pending.journal.destination_root.as_str()),
185    )?;
186    let stage: StagePresence = validate_stage(&pending)?;
187    if registration == Registration::Unregistered {
188        remove_pending_journal(&pending)?;
189        return Ok((
190            false,
191            Some(format!(
192                "cli-git: discarded an interrupted ignored-state copy for {destination}, which is no longer a registered worktree."
193            )),
194        ));
195    }
196    if pending.journal.phase == Phase::Complete {
197        remove_pending_journal(&pending)?;
198        return Ok((true, None));
199    }
200    if stage == StagePresence::Missing {
201        remove_pending_journal(&pending)?;
202        return Ok((
203            false,
204            Some(format!(
205                "cli-git: discarded an interrupted ignored-state copy into {destination} because its private stage is missing; ignored files there may be incomplete."
206            )),
207        ));
208    }
209    let journal_for_snapshot: PendingJournal = pending.clone();
210    let snapshot = move |intended: &BTreeSet<String>| -> Result<StagedSnapshot, WorktreeCopyError> {
211        return snapshot_from_journal(&journal_for_snapshot, intended);
212    };
213    // `match` on the outcome: a completed installation is recovered; a failed one becomes a notice.
214    match complete_journal(pending, &snapshot)? {
215        Outcome::Installed(_) => return Ok((true, None)),
216        Outcome::Ended(failure) => {
217            return Ok((
218                false,
219                Some(format!(
220                    "cli-git: ended an interrupted ignored-state copy into {destination} without finishing it. {}",
221                    failure.0
222                )),
223            ));
224        }
225    }
226}
227
228/// What:
229///  Recovers every pending journal of one repository, in journal order.
230/// Why:
231///   A journal that cannot be read or proven stops the pass, so its evidence is never acted on blindly.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// await recoverWorktreeCopyTransactions(commonDir);
236/// ```
237pub fn recover_worktree_copy_transactions(
238    common_dir: &Path,
239) -> Result<RecoveryReport, WorktreeCopyError> {
240    let pending: Vec<PendingJournal> = read_pending_journals(common_dir)?;
241    let mut recovered: usize = 0;
242    let mut notices: Vec<String> = Vec::new();
243    for journal in pending {
244        let (completed, notice) = recover_transaction(common_dir, journal)?;
245        if completed {
246            recovered += 1;
247        }
248        if let Some(line) = notice {
249            notices.push(line);
250        }
251    }
252    return Ok(RecoveryReport { recovered, notices });
253}
254
255/// What:
256///  Writes each notice on its own line, then the summary line when any transaction recovered.
257/// Why:
258///   The user sees why each discarded copy was discarded, and how many resumed copies completed.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// reportRecoveredWorktreeCopies(report);
263/// ```
264pub fn report_recovered(
265    report: &RecoveryReport,
266    stderr: &mut dyn Write,
267) -> Result<(), WorktreeCopyError> {
268    for notice in &report.notices {
269        writeln!(stderr, "{notice}").map_err(|error| return raw_failure(&error))?;
270    }
271    if report.recovered > 0 {
272        let noun: &str = if report.recovered == 1 {
273            "transaction"
274        } else {
275            "transactions"
276        };
277        writeln!(
278            stderr,
279            "cli-git: recovered ignored-state copies for {} worktree {noun}.",
280            report.recovered
281        )
282        .map_err(|error| return raw_failure(&error))?;
283    }
284    return Ok(());
285}
286
287/// What:
288///  The pending check forwarded commands run first: takes the settlement lock only when a journal exists and no live owner holds it.
289/// Why:
290///   A command that creates no worktrees must not wait behind a copy, but still must not pass a half-recovered repository.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// await recoverPendingWorktreeCopies(commonDir);
295/// ```
296pub fn recover_pending_worktree_copies(
297    common_dir: &Path,
298    stderr: &mut dyn Write,
299) -> Result<(), WorktreeCopyError> {
300    if read_pending_journals(common_dir)?.is_empty() {
301        return Ok(());
302    }
303    let Some(lease) = try_acquire_worktree_copy_lock(common_dir)? else {
304        return Ok(());
305    };
306    report_recovered(&recover_worktree_copy_transactions(common_dir)?, stderr)?;
307    return lease.release();
308}
309
310/// Recovery controls stay out of the release executable.
311#[cfg(test)]
312#[path = "worktree_copy_recovery_tests.rs"]
313mod tests;