Skip to main content

git_policy_cli/
recovery_completion.rs

1//! What:
2//!  Deciding whether a dead transaction's landing attempt landed,
3//!  and installing or
4//!       recognizing its recorded post-index.
5//! Why:
6//!  Before `ref-updated.json` the only proof of a landing is the nonce entry in the target's
7//!      reflog;
8//!  missing or contradictory evidence fails closed.
9//!  Artifacts are pinned under
10//!      stable names by inode before use (`src/policy-engine/commit-transaction-recovery-completion.ts`).
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! if (await commitLanded({ ... })) await completeIndex({ directory, preparing, landing });
15//! ```
16
17/// The fail-closed recovery failure.
18use super::recovery_error::RecoveryError;
19/// Exact reads,
20///  owned links,
21///  lock release and index installation.
22use super::recovery_files::{
23    create_owned_file_link, files_equal, install_recovered_index, read_recovery_file,
24    recovery_path_exists, release_owned_lock, remove_file_if_present,
25};
26/// The reflog nonce search.
27use super::recovery_reflog::list_nonce_reflog_oids;
28/// Running real Git.
29use super::transaction_git::{GitContext, GitRequest, run_git};
30/// Journal records and artifact names.
31use super::transaction_journal::{
32    FileIdentity, INDEX_INSTALLED_FILENAME, LandingRecord, PreparingRecord, REF_UPDATED_FILENAME,
33    post_index_filename, pre_landing_index_filename,
34};
35/// The `ref-updated.json` parser.
36use super::transaction_journal_parse::parse_ref_updated;
37/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
38use std::path::{Path, PathBuf};
39
40/// What:
41///  What recovery did to the real index of a landed commit.
42/// Why:
43///   Reported per transaction,
44///  as the incumbent's recovery action names.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type IndexCompletion = 'index-installed' | 'already-installed';
49/// ```
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum IndexCompletion {
52    /// The recorded post-index was installed now.
53    Installed,
54    /// The post-index was installed before the owner died.
55    AlreadyInstalled,
56}
57
58/// What:
59///  The nonce-bearing reflog subject prefix of a transaction.
60/// Why:
61///   The landing writes `commit (cli-git <nonce>): <subject>`.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// `commit (cli-git ${transactionId}):`
66/// ```
67pub fn nonce_subject_prefix(transaction_id: &str) -> String {
68    return format!("commit (cli-git {transaction_id}):");
69}
70
71/// What:
72///  Decide whether a commit landing record's attempt landed.
73///       `Ok(true)` means landed;
74///  `Ok(false)` means the target never moved to it.
75/// Why:
76///   `commitLanded`:
77///  `ref-updated.json` is exact proof;
78///  otherwise one nonce entry naming the
79///       recorded commit is proof,
80///  several are contradictory,
81///  one naming another commit is
82///       contradictory,
83///  and none while the commit is reachable anyway is contradictory too.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// async function commitLanded({ gitPath, cwd, directory, preparing, landing }): Promise<boolean>;
88/// ```
89pub fn commit_landed(
90    context: &GitContext,
91    cwd: &Path,
92    directory: &Path,
93    preparing: &PreparingRecord,
94    landing: &LandingRecord,
95) -> Result<bool, RecoveryError> {
96    let marker: PathBuf = directory.join(REF_UPDATED_FILENAME);
97    let new_oid: &str = landing.new_oid.as_deref().unwrap_or("undefined");
98    if recovery_path_exists(marker.as_path())? {
99        let landed: String = parse_ref_updated(read_recovery_file(marker.as_path())?.as_slice())?;
100        if landing.new_oid.as_deref() != Some(landed.as_str()) {
101            return Err(RecoveryError(format!(
102                "Landed marker names another commit than the landing record: {}",
103                directory.display()
104            )));
105        }
106        return Ok(true);
107    }
108    let oids: Vec<String> = list_nonce_reflog_oids(
109        context,
110        cwd,
111        preparing.target_ref.as_str(),
112        nonce_subject_prefix(preparing.transaction_id.as_str()).as_str(),
113    )?;
114    if oids.len() > 1 {
115        return Err(RecoveryError(format!(
116            "{} reflog names several commits for transaction {}; recovery retained at {}",
117            preparing.target_ref,
118            preparing.transaction_id,
119            directory.display()
120        )));
121    }
122    if let Some(found) = oids.first() {
123        if found != new_oid {
124            return Err(RecoveryError(format!(
125                "{} reflog nonce names {found}, not the recorded {new_oid}; recovery retained at {}",
126                preparing.target_ref,
127                directory.display()
128            )));
129        }
130        return Ok(true);
131    }
132    let reachable: bool = match run_git(
133        context,
134        &GitRequest::new(
135            cwd,
136            &[
137                "merge-base",
138                "--is-ancestor",
139                new_oid,
140                preparing.target_ref.as_str(),
141            ],
142        ),
143    ) {
144        Ok(output) => output.succeeded(),
145        Err(_) => false,
146    };
147    if reachable {
148        return Err(RecoveryError(format!(
149            "{} contains the transaction commit {new_oid} but its reflog lacks the nonce entry; \
150             recovery retained at {}",
151            preparing.target_ref,
152            directory.display()
153        )));
154    }
155    return Ok(false);
156}
157
158/// What:
159///  Pin a transaction artifact under `<name>.recovery` after proving its recorded identity.
160/// Why:
161///   `stabilizeArtifact`:
162///  the stable name keeps the exact inode even if the original name
163///       is replaced meanwhile.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// async function stabilizeArtifact({ directory, filename, identity }): Promise<string>;
168/// ```
169fn stabilize_artifact(
170    directory: &Path,
171    filename: &str,
172    identity: &FileIdentity,
173) -> Result<PathBuf, RecoveryError> {
174    let stable: PathBuf = directory.join(format!("{filename}.recovery"));
175    remove_file_if_present(stable.as_path())?;
176    create_owned_file_link(
177        directory.join(filename).as_path(),
178        stable.as_path(),
179        identity.device.as_str(),
180        identity.inode.as_str(),
181    )?;
182    return Ok(stable);
183}
184
185/// What:
186///  Install or recognize the recorded post-index after a landing.
187/// Why:
188///   `completeIndex`:
189///  a present `index-installed` marker or a real index already equal to
190///       the post-index means done (the owned lock is released);
191///  otherwise the real index must
192///       still equal the pre-landing snapshot,
193///  or recovery fails closed.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// async function completeIndex({ directory, preparing, landing }): Promise<'index-installed' | 'already-installed'>;
198/// ```
199pub fn complete_index(
200    directory: &Path,
201    preparing: &PreparingRecord,
202    landing: &LandingRecord,
203) -> Result<IndexCompletion, RecoveryError> {
204    let pre_landing: PathBuf = stabilize_artifact(
205        directory,
206        pre_landing_index_filename(landing.attempt).as_str(),
207        &landing.pre_landing_index,
208    )?;
209    let post: PathBuf = stabilize_artifact(
210        directory,
211        post_index_filename(landing.attempt).as_str(),
212        &landing.post_index,
213    )?;
214    let real_index: &Path = Path::new(preparing.real_index_path.as_str());
215    let lock_path: PathBuf = PathBuf::from(format!("{}.lock", preparing.real_index_path));
216    if recovery_path_exists(directory.join(INDEX_INSTALLED_FILENAME).as_path())?
217        || files_equal(post.as_path(), real_index)?
218    {
219        release_owned_lock(&landing.lock, lock_path.as_path())?;
220        return Ok(IndexCompletion::AlreadyInstalled);
221    }
222    if !files_equal(pre_landing.as_path(), real_index)? {
223        return Err(RecoveryError(format!(
224            "Real index no longer matches the pre-landing snapshot; recovery retained at {}",
225            directory.display()
226        )));
227    }
228    install_recovered_index(
229        lock_path.as_path(),
230        real_index,
231        post.as_path(),
232        &landing.lock,
233    )?;
234    return Ok(IndexCompletion::Installed);
235}
236
237/// Landed-or-not and index completion controls stay out of the release executable.
238#[cfg(test)]
239#[path = "recovery_completion_tests.rs"]
240mod tests;