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