1use super::capture_records::record_landed_capture_or_warn;
26use super::conclusion_cleanup::reproduce_conclusion_cleanup;
28use super::diagnostic_log::debug;
30use super::pack_keep::remove_transaction_keeps;
32use super::recovery_completion::{IndexCompletion, commit_landed, complete_index};
34use super::recovery_error::RecoveryError;
36use super::recovery_evidence::{
38 LANDING_RECORD_PREFIX, RECORD_SUFFIX, attempt_numbers, directory_names, release_recorded_locks,
39 remove_dead_pid_file,
40};
41use super::recovery_files::{path_present, read_recovery_file, recovery_path_exists};
43use super::shadow_git::{remove_shadow_repository, shadow_repository_path};
45use super::transaction_git::{GitContext, GitRequest, run_git};
47use super::transaction_journal::{
49 Base, LandingOperation, LandingRecord, PREPARING_FILENAME, PreparingRecord,
50};
51use super::transaction_journal_parse::{parse_landing, parse_preparing};
53use super::transaction_registry::remove_transaction_directory;
55use super::worktree_completion::install_added_worktree_files;
57use std::path::{Path, PathBuf};
59
60pub const UNRELEASED_JOURNAL_FILENAME: &str = "journal.json";
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum RecoveryAction {
75 CommitNotCreated,
78 NormalizationInstalled,
80 IndexInstalled,
82 AlreadyInstalled,
84 OwnerActive,
87 Vanished,
89 StagingUnattributed,
91 StagingRetained,
93 RetiredRemoved,
95}
96
97pub fn action_name(action: RecoveryAction) -> &'static str {
107 match action {
108 RecoveryAction::CommitNotCreated => return "commit-not-created",
109 RecoveryAction::NormalizationInstalled => return "normalization-installed",
110 RecoveryAction::IndexInstalled => return "index-installed",
111 RecoveryAction::AlreadyInstalled => return "already-installed",
112 RecoveryAction::OwnerActive => return "owner-active",
113 RecoveryAction::Vanished => return "vanished",
114 RecoveryAction::StagingUnattributed => return "staging-unattributed",
115 RecoveryAction::StagingRetained => return "staging-retained",
116 RecoveryAction::RetiredRemoved => return "retired-removed",
117 }
118}
119
120#[derive(Clone, Debug)]
130pub struct RecoveryPlace {
131 pub git: GitContext,
133 pub cwd: PathBuf,
135 pub common_dir: PathBuf,
137}
138
139fn discard_transaction(
152 directory: &Path,
153 transaction_id: &str,
154 preparing: &PreparingRecord,
155) -> Result<(), RecoveryError> {
156 remove_transaction_keeps(
157 Path::new(preparing.object_directory.as_str()),
158 transaction_id,
159 )?;
160 remove_shadow_repository(Path::new(preparing.shadow_path.as_str()))?;
161 return remove_transaction_directory(directory);
162}
163
164fn read_preparing(directory: &Path) -> Result<Option<PreparingRecord>, RecoveryError> {
175 let path: PathBuf = directory.join(PREPARING_FILENAME);
176 if !recovery_path_exists(path.as_path())? {
177 return Ok(None);
178 }
179 return Ok(Some(parse_preparing(
180 read_recovery_file(path.as_path())?.as_slice(),
181 )?));
182}
183
184fn target_unmoved(place: &RecoveryPlace, preparing: &PreparingRecord, expected: &Base) -> bool {
195 let revision: String = format!("{}^{{commit}}", preparing.target_ref);
196 let current: Base = match run_git(
197 &place.git,
198 &GitRequest::new(
199 place.cwd.as_path(),
200 &["rev-parse", "--verify", "--quiet", revision.as_str()],
201 ),
202 ) {
203 Ok(output) if output.succeeded() => Base::Commit(
204 String::from_utf8_lossy(output.stdout.as_slice())
205 .trim()
206 .to_string(),
207 ),
208 _ => Base::Unborn,
209 };
210 return current == *expected;
211}
212
213pub fn recover_dead_transaction(
226 place: &RecoveryPlace,
227 directory: &Path,
228 transaction_id: &str,
229 owner_pid: i64,
230) -> Result<RecoveryAction, RecoveryError> {
231 let names: Vec<String> = directory_names(directory)?;
232 if names.contains(&String::from(UNRELEASED_JOURNAL_FILENAME)) {
233 return Err(RecoveryError(format!(
234 "Transaction directory uses an unreleased journal format; inspect it before removing it: {}",
235 directory.display()
236 )));
237 }
238 let Some(preparing) = read_preparing(directory)? else {
239 debug(
240 "recoverDeadTransaction",
241 format!("dead owner stopped before capture: {}", directory.display()).as_str(),
242 );
243 remove_shadow_repository(
244 shadow_repository_path(place.common_dir.as_path(), transaction_id).as_path(),
245 )?;
246 remove_transaction_directory(directory)?;
247 return Ok(RecoveryAction::CommitNotCreated);
248 };
249 let real_index: &Path = Path::new(preparing.real_index_path.as_str());
250 let Some(latest) = attempt_numbers(names.as_slice(), LANDING_RECORD_PREFIX)
251 .last()
252 .copied()
253 else {
254 release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
255 discard_transaction(directory, transaction_id, &preparing)?;
256 return Ok(RecoveryAction::CommitNotCreated);
257 };
258 let landing: LandingRecord = parse_landing(
259 read_recovery_file(
260 directory
261 .join(format!("{LANDING_RECORD_PREFIX}{latest}{RECORD_SUFFIX}"))
262 .as_path(),
263 )?
264 .as_slice(),
265 )?;
266 if landing.operation == LandingOperation::Commit
267 && !commit_landed(
268 &place.git,
269 place.cwd.as_path(),
270 directory,
271 &preparing,
272 &landing,
273 )?
274 {
275 debug(
276 "recoverDeadTransaction",
277 format!(
278 "landing attempt {latest} never advanced {}: {}",
279 preparing.target_ref,
280 directory.display()
281 )
282 .as_str(),
283 );
284 release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
285 discard_transaction(directory, transaction_id, &preparing)?;
286 return Ok(RecoveryAction::CommitNotCreated);
287 }
288 if landing.operation == LandingOperation::NormalizeOnly
289 && !target_unmoved(place, &preparing, &landing.expected_old)
290 {
291 return Err(RecoveryError(format!(
292 "{} moved after an interrupted normalization; recovery retained at {}",
293 preparing.target_ref,
294 directory.display()
295 )));
296 }
297 remove_transaction_keeps(
298 Path::new(preparing.object_directory.as_str()),
299 transaction_id,
300 )?;
301 if landing.operation == LandingOperation::Commit
302 && let Some(landed) = &landing.new_oid
303 {
304 record_landed_capture_or_warn(
307 Path::new(preparing.git_dir.as_str()),
308 directory,
309 transaction_id,
310 landed.as_str(),
311 );
312 }
313 let completion: IndexCompletion = complete_index(directory, &preparing, &landing)?;
314 remove_dead_pid_file(real_index, owner_pid)?;
315 let shadow: &Path = Path::new(preparing.shadow_path.as_str());
316 if landing.operation == LandingOperation::Commit && path_present(shadow)? {
317 reproduce_conclusion_cleanup(
318 &place.git,
319 place.cwd.as_path(),
320 Path::new(preparing.git_dir.as_str()),
321 shadow,
322 directory,
323 preparing.ref_format,
324 )?;
325 }
326 if path_present(shadow)? {
329 let mut records: Vec<super::transaction_journal::AddedPath> = landing.added_paths.clone();
330 records.extend(landing.selected_worktree_paths.iter().cloned());
331 install_added_worktree_files(
332 &place.git,
333 place.cwd.as_path(),
334 Path::new(preparing.repository_root.as_str()),
335 records.as_slice(),
336 Some(shadow.join("objects").as_path()),
337 )?;
338 }
339 remove_shadow_repository(shadow)?;
340 remove_transaction_directory(directory)?;
341 if landing.operation == LandingOperation::NormalizeOnly {
342 return Ok(RecoveryAction::NormalizationInstalled);
343 }
344 match completion {
345 IndexCompletion::Installed => return Ok(RecoveryAction::IndexInstalled),
346 IndexCompletion::AlreadyInstalled => return Ok(RecoveryAction::AlreadyInstalled),
347 }
348}
349
350#[cfg(test)]
352#[path = "recovery_landing_tests.rs"]
353pub(crate) mod tests;