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