Skip to main content

git_policy_cli/
recovery_landing.rs

1//! What:
2//!  Recovery of one dead-owner transaction from its schema-version-2 records.
3//! Why:
4//!  Without a landing record the real index and refs were never touched:
5//!  recovery releases
6//!      the `index.lock` the transaction provably created and removes its `.keep` files,
7//!  shadow and
8//!      directory.
9//!  With a landing record (the caller holds the landing lock) it decides from
10//!      `ref-updated.json` or the reflog nonce whether the commit landed,
11//!  then discards the
12//!      attempt or completes it:
13//!  index,
14//!  conclusion cleanup,
15//!  added-path copies.
16//!  Missing evidence
17//!      fails closed and keeps the directory (`src/policy-engine/commit-transaction-recovery-landing.ts`).
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! await recoverDeadTransaction({ directory, transactionId, ownerPid, gitPath, effectiveCwd });
22//! ```
23
24/// Landed-capture recording.
25use super::capture_records::record_landed_capture_or_warn;
26/// Native conclusion cleanup.
27use super::conclusion_cleanup::reproduce_conclusion_cleanup;
28/// Debug diagnostics.
29use super::diagnostic_log::debug;
30/// Transaction keeps.
31use super::pack_keep::remove_transaction_keeps;
32/// The landed-or-not decision and index completion.
33use super::recovery_completion::{IndexCompletion, commit_landed, complete_index};
34/// The fail-closed recovery failure.
35use super::recovery_error::RecoveryError;
36/// Attempt evidence.
37use super::recovery_evidence::{
38    LANDING_RECORD_PREFIX, RECORD_SUFFIX, attempt_numbers, directory_names, release_recorded_locks,
39    remove_dead_pid_file,
40};
41/// Exact reads and existence probes.
42use super::recovery_files::{path_present, read_recovery_file, recovery_path_exists};
43/// Shadow location and removal.
44use super::shadow_git::{remove_shadow_repository, shadow_repository_path};
45/// Running real Git.
46use super::transaction_git::{GitContext, GitRequest, run_git};
47/// Journal records.
48use super::transaction_journal::{
49    Base, LandingOperation, LandingRecord, PREPARING_FILENAME, PreparingRecord,
50};
51/// Journal parsers.
52use super::transaction_journal_parse::{parse_landing, parse_preparing};
53/// Directory removal.
54use super::transaction_registry::remove_transaction_directory;
55/// Added-path worktree completion.
56use super::worktree_completion::install_added_worktree_files;
57/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
58use std::path::{Path, PathBuf};
59
60/// Prepared-journal filename written only by unreleased builds in registry directories.
61pub const UNRELEASED_JOURNAL_FILENAME: &str = "journal.json";
62
63/// What:
64///  What recovery did for one transaction directory.
65/// Why:
66///   `CommitTransactionRecoveryAction`,
67///  reported in debug output.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// type CommitTransactionRecoveryAction = 'commit-not-created' | 'normalization-installed' | ...;
72/// ```
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum RecoveryAction {
75    /// The commit never landed;
76    ///  the attempt was discarded.
77    CommitNotCreated,
78    /// An interrupted normalization's index was completed.
79    NormalizationInstalled,
80    /// A landed commit's post-index was installed now.
81    IndexInstalled,
82    /// A landed commit's post-index was already installed.
83    AlreadyInstalled,
84    /// The owner still runs;
85    ///  nothing was touched.
86    OwnerActive,
87    /// The directory disappeared while recovery listed it.
88    Vanished,
89    /// A staging candidate without a complete owner record was left in place.
90    StagingUnattributed,
91    /// A dead owner's unpublished candidate was kept for diagnosis.
92    StagingRetained,
93    /// A retired directory whose removal was interrupted was removed.
94    RetiredRemoved,
95}
96
97/// What:
98///  The incumbent's name of an action.
99/// Why:
100///   Debug output lists outcomes by these names.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// action // 'commit-not-created' | ...
105/// ```
106pub fn action_name(action: RecoveryAction) -> &'static str {
107    match action {
108        RecoveryAction::CommitNotCreated => return "commit-not-created",
109        RecoveryAction::NormalizationInstalled => return "normalization-installed",
110        RecoveryAction::IndexInstalled => return "index-installed",
111        RecoveryAction::AlreadyInstalled => return "already-installed",
112        RecoveryAction::OwnerActive => return "owner-active",
113        RecoveryAction::Vanished => return "vanished",
114        RecoveryAction::StagingUnattributed => return "staging-unattributed",
115        RecoveryAction::StagingRetained => return "staging-retained",
116        RecoveryAction::RetiredRemoved => return "retired-removed",
117    }
118}
119
120/// What:
121///  Where recovery runs Git and finds the shared Git directory.
122/// Why:
123///   Every recovery step asks Git in the invocation's repository.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// type RecoveryPlace = { gitPath: string; effectiveCwd: string; commonDir: string };
128/// ```
129#[derive(Clone, Debug)]
130pub struct RecoveryPlace {
131    /// Real Git and its environment.
132    pub git: GitContext,
133    /// Working directory of every Git command.
134    pub cwd: PathBuf,
135    /// The repository's common Git directory.
136    pub common_dir: PathBuf,
137}
138
139/// What:
140///  Remove everything a dead transaction left:
141///  `.keep` files,
142///  the shadow,
143///  the directory.
144/// Why:
145///   `discardTransaction`.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// await discardTransaction({ directory, transactionId, objectDirectory, shadowPath });
150/// ```
151fn discard_transaction(
152    directory: &Path,
153    transaction_id: &str,
154    preparing: &PreparingRecord,
155) -> Result<(), RecoveryError> {
156    remove_transaction_keeps(
157        Path::new(preparing.object_directory.as_str()),
158        transaction_id,
159    )?;
160    remove_shadow_repository(Path::new(preparing.shadow_path.as_str()))?;
161    return remove_transaction_directory(directory);
162}
163
164/// What:
165///  The invocation capture record,
166///  or nothing when the owner died before writing it.
167/// Why:
168///   `readPreparing`.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// async function readPreparing(directory: string): Promise<PreparingRecord | typeof PREPARING_ABSENT>;
173/// ```
174fn read_preparing(directory: &Path) -> Result<Option<PreparingRecord>, RecoveryError> {
175    let path: PathBuf = directory.join(PREPARING_FILENAME);
176    if !recovery_path_exists(path.as_path())? {
177        return Ok(None);
178    }
179    return Ok(Some(parse_preparing(
180        read_recovery_file(path.as_path())?.as_slice(),
181    )?));
182}
183
184/// What:
185///  Whether the target still resolves to the base an interrupted normalization recorded.
186/// Why:
187///   A normalization never moves the target;
188///  one that moved belongs to someone else.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// const current = await resolveRefCommit({ gitPath, cwd, ref: preparing.targetRef });
193/// ```
194fn target_unmoved(place: &RecoveryPlace, preparing: &PreparingRecord, expected: &Base) -> bool {
195    let revision: String = format!("{}^{{commit}}", preparing.target_ref);
196    let current: Base = match run_git(
197        &place.git,
198        &GitRequest::new(
199            place.cwd.as_path(),
200            &["rev-parse", "--verify", "--quiet", revision.as_str()],
201        ),
202    ) {
203        Ok(output) if output.succeeded() => Base::Commit(
204            String::from_utf8_lossy(output.stdout.as_slice())
205                .trim()
206                .to_string(),
207        ),
208        _ => Base::Unborn,
209    };
210    return current == *expected;
211}
212
213/// What:
214///  Recover one dead-owner transaction;
215///  a caller recovering one with a landing record must
216///       hold the landing lock.
217/// Why:
218///   `recoverDeadTransaction`,
219///  step for step.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// async function recoverDeadTransaction({ directory, transactionId, ownerPid, gitPath, effectiveCwd }): Promise<CommitTransactionRecoveryAction>;
224/// ```
225pub fn recover_dead_transaction(
226    place: &RecoveryPlace,
227    directory: &Path,
228    transaction_id: &str,
229    owner_pid: i64,
230) -> Result<RecoveryAction, RecoveryError> {
231    let names: Vec<String> = directory_names(directory)?;
232    if names.contains(&String::from(UNRELEASED_JOURNAL_FILENAME)) {
233        return Err(RecoveryError(format!(
234            "Transaction directory uses an unreleased journal format; inspect it before removing it: {}",
235            directory.display()
236        )));
237    }
238    let Some(preparing) = read_preparing(directory)? else {
239        debug(
240            "recoverDeadTransaction",
241            format!("dead owner stopped before capture: {}", directory.display()).as_str(),
242        );
243        remove_shadow_repository(
244            shadow_repository_path(place.common_dir.as_path(), transaction_id).as_path(),
245        )?;
246        remove_transaction_directory(directory)?;
247        return Ok(RecoveryAction::CommitNotCreated);
248    };
249    let real_index: &Path = Path::new(preparing.real_index_path.as_str());
250    let Some(latest) = attempt_numbers(names.as_slice(), LANDING_RECORD_PREFIX)
251        .last()
252        .copied()
253    else {
254        release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
255        discard_transaction(directory, transaction_id, &preparing)?;
256        return Ok(RecoveryAction::CommitNotCreated);
257    };
258    let landing: LandingRecord = parse_landing(
259        read_recovery_file(
260            directory
261                .join(format!("{LANDING_RECORD_PREFIX}{latest}{RECORD_SUFFIX}"))
262                .as_path(),
263        )?
264        .as_slice(),
265    )?;
266    if landing.operation == LandingOperation::Commit
267        && !commit_landed(
268            &place.git,
269            place.cwd.as_path(),
270            directory,
271            &preparing,
272            &landing,
273        )?
274    {
275        debug(
276            "recoverDeadTransaction",
277            format!(
278                "landing attempt {latest} never advanced {}: {}",
279                preparing.target_ref,
280                directory.display()
281            )
282            .as_str(),
283        );
284        release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
285        discard_transaction(directory, transaction_id, &preparing)?;
286        return Ok(RecoveryAction::CommitNotCreated);
287    }
288    if landing.operation == LandingOperation::NormalizeOnly
289        && !target_unmoved(place, &preparing, &landing.expected_old)
290    {
291        return Err(RecoveryError(format!(
292            "{} moved after an interrupted normalization; recovery retained at {}",
293            preparing.target_ref,
294            directory.display()
295        )));
296    }
297    remove_transaction_keeps(
298        Path::new(preparing.object_directory.as_str()),
299        transaction_id,
300    )?;
301    if landing.operation == LandingOperation::Commit
302        && let Some(landed) = &landing.new_oid
303    {
304        // Before any other landing takes the landing lock, so no replay moves over this commit
305        // without its record.
306        record_landed_capture_or_warn(
307            Path::new(preparing.git_dir.as_str()),
308            directory,
309            transaction_id,
310            landed.as_str(),
311        );
312    }
313    let completion: IndexCompletion = complete_index(directory, &preparing, &landing)?;
314    remove_dead_pid_file(real_index, owner_pid)?;
315    let shadow: &Path = Path::new(preparing.shadow_path.as_str());
316    if landing.operation == LandingOperation::Commit && path_present(shadow)? {
317        reproduce_conclusion_cleanup(
318            &place.git,
319            place.cwd.as_path(),
320            Path::new(preparing.git_dir.as_str()),
321            shadow,
322            directory,
323            preparing.ref_format,
324        )?;
325    }
326    // Completion precedes shadow removal, so a missing shadow means an earlier run already
327    // finished the copies; the shadow store still holds originals that never landed.
328    if path_present(shadow)? {
329        let mut records: Vec<super::transaction_journal::AddedPath> = landing.added_paths.clone();
330        records.extend(landing.selected_worktree_paths.iter().cloned());
331        install_added_worktree_files(
332            &place.git,
333            place.cwd.as_path(),
334            Path::new(preparing.repository_root.as_str()),
335            records.as_slice(),
336            Some(shadow.join("objects").as_path()),
337        )?;
338    }
339    remove_shadow_repository(shadow)?;
340    remove_transaction_directory(directory)?;
341    if landing.operation == LandingOperation::NormalizeOnly {
342        return Ok(RecoveryAction::NormalizationInstalled);
343    }
344    match completion {
345        IndexCompletion::Installed => return Ok(RecoveryAction::IndexInstalled),
346        IndexCompletion::AlreadyInstalled => return Ok(RecoveryAction::AlreadyInstalled),
347    }
348}
349
350/// Dead-transaction recovery controls stay out of the release executable.
351#[cfg(test)]
352#[path = "recovery_landing_tests.rs"]
353pub(crate) mod tests;