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;