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;