Skip to main content

git_policy_cli/
commit_recovery.rs

1//! What:
2//!  Startup recovery of interrupted commit transactions for the invocation's worktree.
3//! Why:
4//!  Before configuration loads,
5//!  and before read-only commands that already asked Git for the
6//!      repository location,
7//!  every dead owner's transaction is recovered or fails closed;
8//!  live
9//!      owners are skipped.
10//!  The legacy single-journal directory is not recovered here:
11//!  the
12//!      command stops and says how to recover it with the previous executable (the owner's
13//!      verdict,
14//!  `doc/planning/cli-git-rust-open-decisions.md`,
15//!  "Legacy single-journal directory").
16//!      An empty registry costs one directory read and no Git process
17//!      (`src/policy-engine/commit-transaction-recovery.ts`).
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! await recoverCommitTransaction({ args, gitPath, identity });
22//! ```
23
24/// Landed-capture pruning.
25use super::capture_records::prune_landed_captures;
26/// Debug diagnostics.
27use super::diagnostic_log::debug;
28/// Record writers.
29use super::json_record::{ObjectWriter, quote};
30/// The fail-closed recovery failure.
31use super::recovery_error::{RecoveryError, io_failure};
32/// Existence probes.
33use super::recovery_files::recovery_path_exists;
34/// Action names and the recovery place.
35use super::recovery_landing::{RecoveryAction, RecoveryPlace, action_name};
36/// Registry recovery.
37use super::recovery_scan::{RecoveryOutcome, recover_registered_transactions};
38/// Running real Git.
39use super::transaction_git::GitContext;
40/// Registry names.
41use super::transaction_registry::{LEGACY_TRANSACTION_DIRECTORY_NAME, TRANSACTION_ROOT_NAME};
42/// The repository shape the invocation selects.
43use super::worktree_identity::WorktreeIdentity;
44/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
45use std::path::{Path, PathBuf};
46
47/// What:
48///  Why recovery stopped the command.
49/// Why:
50///   The legacy directory and a recovery failure are reported differently.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// type RecoveryStop = { kind: 'legacy'; directory: string } | { kind: 'failure'; error: CommitTransactionRecoveryError };
55/// ```
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum RecoveryStop {
58    /// The legacy single-journal directory exists.
59    Legacy(PathBuf),
60    /// Recovery failed closed.
61    Failure(RecoveryError),
62}
63
64/// What:
65///  The message for the legacy single-journal directory.
66/// Why:
67///   The owner chose to stop with instructions instead of porting its recovery.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// `cli-git: ${directory} holds a commit journal in a format this executable does not recover. ...`
72/// ```
73pub fn legacy_notice(directory: &Path) -> String {
74    return format!(
75        "cli-git: {} holds a commit journal in a format this executable does not recover. \
76         Nothing was changed. Run any git command once with the previous cli-git executable to \
77         recover it, then run this command again.\n",
78        directory.display()
79    );
80}
81
82/// What:
83///  The outcomes as the incumbent's debug JSON.
84/// Why:
85///   `JSON.stringify(outcomes)` in the recovery debug line.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// JSON.stringify(outcomes)
90/// ```
91pub fn outcomes_json(outcomes: &[RecoveryOutcome]) -> String {
92    let mut items: Vec<String> = Vec::with_capacity(outcomes.len());
93    for outcome in outcomes {
94        let mut writer: ObjectWriter = ObjectWriter::new();
95        writer
96            .string("directory", outcome.directory.to_string_lossy().as_ref())
97            .raw("action", quote(action_name(outcome.action)).as_str());
98        items.push(writer.finish());
99    }
100    return format!("[{}]", items.join(","));
101}
102
103/// What:
104///  Whether a registry root holds nothing:
105///  absent or empty.
106/// Why:
107///   The common case needs no further reads and no Git process.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// (await readdirOrEmpty(root)).length === 0
112/// ```
113fn registry_is_empty(root: &Path) -> Result<bool, RecoveryError> {
114    let mut listing: std::fs::ReadDir = match std::fs::read_dir(root) {
115        Ok(found) => found,
116        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
117        Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
118            return Err(RecoveryError(format!(
119                "Unsafe transaction registry: {}",
120                root.display()
121            )));
122        }
123        Err(error) => return Err(io_failure("listing", root, &error)),
124    };
125    match listing.next() {
126        None => return Ok(true),
127        Some(Ok(_)) => return Ok(false),
128        Some(Err(error)) => return Err(io_failure("listing", root, &error)),
129    }
130}
131
132/// What:
133///  The Git directory whose registry this invocation recovers,
134///  or nothing outside a worktree
135///       and in a bare repository.
136/// Why:
137///   `resolveCommitTransactionTargets`:
138///  only worktrees hold transactions.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// identity.kind === 'outside-worktree' || identity.kind === 'bare-repository' ? RECOVERY_TARGET_NOT_APPLICABLE : identity.gitDir
143/// ```
144pub fn recovery_directories(identity: &WorktreeIdentity) -> Option<(&Path, &Path)> {
145    match identity {
146        WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
147        WorktreeIdentity::MainWorktree {
148            git_dir,
149            common_dir,
150            ..
151        }
152        | WorktreeIdentity::LinkedWorktree {
153            git_dir,
154            common_dir,
155            ..
156        } => return Some((git_dir.as_path(), common_dir.as_path())),
157    }
158}
159
160/// What:
161///  Recover every interrupted transaction of the invocation's worktree;
162///  returns the
163///       outcomes,
164///  empty when there was nothing to do.
165/// Why:
166///   `recoverCommitTransaction`,
167///  with the legacy directory stopping the command.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// async function recoverCommitTransaction({ args, gitPath, identity }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
172/// ```
173pub fn recover_commit_transactions(
174    git: &GitContext,
175    cwd: &Path,
176    identity: &WorktreeIdentity,
177) -> Result<Vec<RecoveryOutcome>, RecoveryStop> {
178    let Some((git_dir, common_dir)) = recovery_directories(identity) else {
179        return Ok(Vec::new());
180    };
181    let legacy: PathBuf = git_dir.join(LEGACY_TRANSACTION_DIRECTORY_NAME);
182    match recovery_path_exists(legacy.as_path()) {
183        Ok(true) => return Err(RecoveryStop::Legacy(legacy)),
184        Ok(false) => {}
185        Err(error) => return Err(RecoveryStop::Failure(error)),
186    }
187    let root: PathBuf = git_dir.join(TRANSACTION_ROOT_NAME);
188    match registry_is_empty(root.as_path()) {
189        Ok(true) => return Ok(Vec::new()),
190        Ok(false) => {}
191        Err(error) => return Err(RecoveryStop::Failure(error)),
192    }
193    let place: RecoveryPlace = RecoveryPlace {
194        git: git.clone(),
195        cwd: cwd.to_path_buf(),
196        common_dir: common_dir.to_path_buf(),
197    };
198    let outcomes: Vec<RecoveryOutcome> =
199        match recover_registered_transactions(&place, root.as_path()) {
200            Ok(found) => found,
201            Err(error) => return Err(RecoveryStop::Failure(error)),
202        };
203    if !outcomes.is_empty() {
204        debug(
205            "recoverCommitTransaction",
206            format!(
207                "transaction recovery outcomes: {}",
208                outcomes_json(outcomes.as_slice())
209            )
210            .as_str(),
211        );
212    }
213    if has_recovered(outcomes.as_slice()) {
214        // A recovered transaction no longer pins landed-capture records.
215        prune_landed_captures(git_dir, root.as_path());
216    }
217    return Ok(outcomes);
218}
219
220/// What:
221///  Whether any outcome did more than skip a live owner.
222/// Why:
223///   Pruning runs only after a recovery changed something.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// outcomes.some((outcome) => outcome.action !== 'owner-active')
228/// ```
229fn has_recovered(outcomes: &[RecoveryOutcome]) -> bool {
230    for outcome in outcomes {
231        if outcome.action != RecoveryAction::OwnerActive {
232            return true;
233        }
234    }
235    return false;
236}
237
238/// What:
239///  Whether outcomes leave a transaction another process may still be running.
240/// Why:
241///   Until index writers coordinate with live transactions,
242///  a guarded command beside one is
243///       refused instead of racing it.
244///  A dead owner's kept staging candidate touched nothing and
245///       does not count;
246///  an unattributed one may still be written by a live owner.
247///
248/// In TS you'd write (pseudocode):
249/// ```ts
250/// outcomes.some((outcome) => outcome.action === 'owner-active' || outcome.action === 'staging-unattributed')
251/// ```
252pub fn leaves_live_transactions(outcomes: &[RecoveryOutcome]) -> bool {
253    for outcome in outcomes {
254        if matches!(
255            outcome.action,
256            RecoveryAction::OwnerActive | RecoveryAction::StagingUnattributed
257        ) {
258            return true;
259        }
260    }
261    return false;
262}
263
264/// Recovery entry controls stay out of the release executable.
265#[cfg(test)]
266#[path = "commit_recovery_tests.rs"]
267mod tests;