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, acquire_registry_lock, recover_registered_transactions};
38/// Isolated merges that died.
39use super::merge_isolation_recovery::{MergeRecoveryOutcome, merge_intents_to_recover, recover_interrupted_merges};
40/// The landing lock.
41use super::owner_lock::OwnerLock;
42/// Running real Git.
43use super::transaction_git::GitContext;
44/// Registry names.
45use super::transaction_registry::{
46    LANDING_LOCK_NAME, LEGACY_TRANSACTION_DIRECTORY_NAME, TRANSACTION_ROOT_NAME, ensure_transaction_root,
47};
48/// The repository shape the invocation selects.
49use super::worktree_identity::WorktreeIdentity;
50/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
51use std::path::{Path, PathBuf};
52
53/// What:
54///  Why recovery stopped the command.
55/// Why:
56///   The legacy directory and a recovery failure are reported differently.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// type RecoveryStop = { kind: 'legacy'; directory: string } | { kind: 'failure'; error: CommitTransactionRecoveryError };
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub enum RecoveryStop {
64    /// The legacy single-journal directory exists.
65    Legacy(PathBuf),
66    /// Recovery failed closed.
67    Failure(RecoveryError),
68}
69
70/// What:
71///  The message for the legacy single-journal directory.
72/// Why:
73///   The owner chose to stop with instructions instead of porting its recovery.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// `cli-git: ${directory} holds a commit journal in a format this executable does not recover. ...`
78/// ```
79pub fn legacy_notice(directory: &Path) -> String {
80    return format!(
81        "cli-git: {} holds a commit journal in a format this executable does not recover. \
82         Nothing was changed. Run any git command once with the previous cli-git executable to \
83         recover it, then run this command again.\n",
84        directory.display()
85    );
86}
87
88/// What:
89///  The outcomes as the incumbent's debug JSON.
90/// Why:
91///   `JSON.stringify(outcomes)` in the recovery debug line.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// JSON.stringify(outcomes)
96/// ```
97pub fn outcomes_json(outcomes: &[RecoveryOutcome]) -> String {
98    let mut items: Vec<String> = Vec::with_capacity(outcomes.len());
99    for outcome in outcomes {
100        let mut writer: ObjectWriter = ObjectWriter::new();
101        writer
102            .string("directory", outcome.directory.to_string_lossy().as_ref())
103            .raw("action", quote(action_name(outcome.action)).as_str());
104        items.push(writer.finish());
105    }
106    return format!("[{}]", items.join(","));
107}
108
109/// What:
110///  Whether a registry root holds nothing:
111///  absent or empty.
112/// Why:
113///   The common case needs no further reads and no Git process.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// (await readdirOrEmpty(root)).length === 0
118/// ```
119fn registry_is_empty(root: &Path) -> Result<bool, RecoveryError> {
120    let mut listing: std::fs::ReadDir = match std::fs::read_dir(root) {
121        Ok(found) => found,
122        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
123        Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
124            return Err(RecoveryError(format!(
125                "Unsafe transaction registry: {}",
126                root.display()
127            )));
128        }
129        Err(error) => return Err(io_failure("listing", root, &error)),
130    };
131    match listing.next() {
132        None => return Ok(true),
133        Some(Ok(_)) => return Ok(false),
134        Some(Err(error)) => return Err(io_failure("listing", root, &error)),
135    }
136}
137
138/// What:
139///  The Git directory whose registry this invocation recovers,
140///  or nothing outside a worktree
141///       and in a bare repository.
142/// Why:
143///   `resolveCommitTransactionTargets`:
144///  only worktrees hold transactions.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// identity.kind === 'outside-worktree' || identity.kind === 'bare-repository' ? RECOVERY_TARGET_NOT_APPLICABLE : identity.gitDir
149/// ```
150pub fn recovery_directories(identity: &WorktreeIdentity) -> Option<(&Path, &Path)> {
151    match identity {
152        WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
153        WorktreeIdentity::MainWorktree {
154            git_dir,
155            common_dir,
156            ..
157        }
158        | WorktreeIdentity::LinkedWorktree {
159            git_dir,
160            common_dir,
161            ..
162        } => return Some((git_dir.as_path(), common_dir.as_path())),
163    }
164}
165
166/// What:
167///  Recover every interrupted transaction of the invocation's worktree;
168///  returns the
169///       outcomes,
170///  empty when there was nothing to do.
171/// Why:
172///   `recoverCommitTransaction`,
173///  with the legacy directory stopping the command.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// async function recoverCommitTransaction({ args, gitPath, identity }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
178/// ```
179pub fn recover_commit_transactions(
180    git: &GitContext,
181    cwd: &Path,
182    identity: &WorktreeIdentity,
183) -> Result<Vec<RecoveryOutcome>, RecoveryStop> {
184    let Some((git_dir, common_dir)) = recovery_directories(identity) else {
185        return Ok(Vec::new());
186    };
187    let legacy: PathBuf = git_dir.join(LEGACY_TRANSACTION_DIRECTORY_NAME);
188    match recovery_path_exists(legacy.as_path()) {
189        Ok(true) => return Err(RecoveryStop::Legacy(legacy)),
190        Ok(false) => {}
191        Err(error) => return Err(RecoveryStop::Failure(error)),
192    }
193    let root: PathBuf = git_dir.join(TRANSACTION_ROOT_NAME);
194    let place: RecoveryPlace = RecoveryPlace {
195        git: git.clone(),
196        cwd: cwd.to_path_buf(),
197        common_dir: common_dir.to_path_buf(),
198    };
199    // An isolated merge whose process died is settled under the landing lock before anything else reads the
200    // checkout; a merge that still runs is left alone.
201    match merge_intents_to_recover(git_dir) {
202        Ok(true) => {
203            if let Err(error) = recover_merges_under_lock(&place, git_dir, root.as_path()) {
204                return Err(RecoveryStop::Failure(error));
205            }
206        }
207        Ok(false) => {}
208        Err(error) => return Err(RecoveryStop::Failure(error)),
209    }
210    match registry_is_empty(root.as_path()) {
211        Ok(true) => return Ok(Vec::new()),
212        Ok(false) => {}
213        Err(error) => return Err(RecoveryStop::Failure(error)),
214    }
215    let outcomes: Vec<RecoveryOutcome> =
216        match recover_registered_transactions(&place, root.as_path()) {
217            Ok(found) => found,
218            Err(error) => return Err(RecoveryStop::Failure(error)),
219        };
220    if !outcomes.is_empty() {
221        debug(
222            "recoverCommitTransaction",
223            format!(
224                "transaction recovery outcomes: {}",
225                outcomes_json(outcomes.as_slice())
226            )
227            .as_str(),
228        );
229    }
230    if has_recovered(outcomes.as_slice()) {
231        // A recovered transaction no longer pins landed-capture records.
232        prune_landed_captures(git_dir, root.as_path());
233    }
234    return Ok(outcomes);
235}
236
237/// What:
238///  Take the landing lock and settle the isolated merges whose owner died.
239/// Why:
240///   A merge holds the landing lock for its whole run, so under the lock every record left is a dead merge's.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// await using lock = await acquireOwnerLock(landingLock); await recoverInterruptedMerges({ place, gitDir });
245/// ```
246fn recover_merges_under_lock(place: &RecoveryPlace, git_dir: &Path, root: &Path) -> Result<(), RecoveryError> {
247    ensure_transaction_root(root)?;
248    let lock: OwnerLock = acquire_registry_lock(root, LANDING_LOCK_NAME)?;
249    let recovered: Result<Vec<MergeRecoveryOutcome>, RecoveryError> = recover_interrupted_merges(place, git_dir);
250    let released: Result<(), super::owner_lock::OwnerLockError> = lock.release();
251    let outcomes: Vec<MergeRecoveryOutcome> = recovered?;
252    if !outcomes.is_empty() {
253        debug("recoverCommitTransaction", format!("isolated merge recovery outcomes: {outcomes:?}").as_str());
254    }
255    if let Err(error) = released {
256        return Err(RecoveryError(error.to_string()));
257    }
258    return Ok(());
259}
260
261/// What:
262///  Whether any outcome did more than skip a live owner.
263/// Why:
264///   Pruning runs only after a recovery changed something.
265///
266/// In TS you'd write (pseudocode):
267/// ```ts
268/// outcomes.some((outcome) => outcome.action !== 'owner-active')
269/// ```
270fn has_recovered(outcomes: &[RecoveryOutcome]) -> bool {
271    for outcome in outcomes {
272        if outcome.action != RecoveryAction::OwnerActive {
273            return true;
274        }
275    }
276    return false;
277}
278
279/// What:
280///  Whether outcomes leave a transaction another process may still be running.
281/// Why:
282///   Until index writers coordinate with live transactions,
283///  a guarded command beside one is
284///       refused instead of racing it.
285///  A dead owner's kept staging candidate touched nothing and
286///       does not count;
287///  an unattributed one may still be written by a live owner.
288///
289/// In TS you'd write (pseudocode):
290/// ```ts
291/// outcomes.some((outcome) => outcome.action === 'owner-active' || outcome.action === 'staging-unattributed')
292/// ```
293pub fn leaves_live_transactions(outcomes: &[RecoveryOutcome]) -> bool {
294    for outcome in outcomes {
295        if matches!(
296            outcome.action,
297            RecoveryAction::OwnerActive | RecoveryAction::StagingUnattributed
298        ) {
299            return true;
300        }
301    }
302    return false;
303}
304
305/// Recovery entry controls stay out of the release executable.
306#[cfg(test)]
307#[path = "commit_recovery_tests.rs"]
308mod tests;