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_contradiction::refuse_unreconciled_landing;
34use super::recovery_completion::{IndexCompletion, commit_landed, complete_index};
36use super::recovery_error::RecoveryError;
38use super::recovery_record_failure::{RecordKind, name_record_failure};
40use super::recovery_evidence::{
42 LANDING_RECORD_PREFIX, RECORD_SUFFIX, attempt_numbers, directory_names, release_recorded_locks,
43 remove_dead_pid_file,
44};
45use super::recovery_files::{path_present, read_recovery_file, recovery_path_exists};
47use super::shadow_git::{remove_shadow_repository, shadow_repository_path};
49use super::transaction_git::{GitContext, GitRequest, run_git};
51use super::transaction_journal::{
53 Base, LandingOperation, LandingRecord, PREPARING_FILENAME, PreparingRecord,
54};
55use super::transaction_journal_parse::{parse_landing, parse_preparing};
57use super::transaction_registry::remove_transaction_directory;
59use super::worktree_completion::install_added_worktree_files;
61use std::path::{Path, PathBuf};
63
64pub const UNRELEASED_JOURNAL_FILENAME: &str = "journal.json";
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum RecoveryAction {
79 CommitNotCreated,
82 NormalizationInstalled,
84 IndexInstalled,
86 AlreadyInstalled,
88 OwnerActive,
91 Vanished,
93 StagingUnattributed,
95 StagingRetained,
97 RetiredRemoved,
99}
100
101pub fn action_name(action: RecoveryAction) -> &'static str {
111 match action {
112 RecoveryAction::CommitNotCreated => return "commit-not-created",
113 RecoveryAction::NormalizationInstalled => return "normalization-installed",
114 RecoveryAction::IndexInstalled => return "index-installed",
115 RecoveryAction::AlreadyInstalled => return "already-installed",
116 RecoveryAction::OwnerActive => return "owner-active",
117 RecoveryAction::Vanished => return "vanished",
118 RecoveryAction::StagingUnattributed => return "staging-unattributed",
119 RecoveryAction::StagingRetained => return "staging-retained",
120 RecoveryAction::RetiredRemoved => return "retired-removed",
121 }
122}
123
124#[derive(Clone, Debug)]
134pub struct RecoveryPlace {
135 pub git: GitContext,
137 pub cwd: PathBuf,
139 pub common_dir: PathBuf,
141}
142
143fn discard_transaction(
156 directory: &Path,
157 transaction_id: &str,
158 preparing: &PreparingRecord,
159) -> Result<(), RecoveryError> {
160 remove_transaction_keeps(
161 Path::new(preparing.object_directory.as_str()),
162 transaction_id,
163 )?;
164 remove_shadow_repository(Path::new(preparing.shadow_path.as_str()))?;
165 return remove_transaction_directory(directory);
166}
167
168fn read_preparing(directory: &Path) -> Result<Option<PreparingRecord>, RecoveryError> {
179 let path: PathBuf = directory.join(PREPARING_FILENAME);
180 if !recovery_path_exists(path.as_path())? {
181 return Ok(None);
182 }
183 let parsed: PreparingRecord = parse_preparing(read_recovery_file(path.as_path())?.as_slice())
184 .map_err(|error| { return name_record_failure(error, path.as_path(), RecordKind::Preparing); })?;
185 return Ok(Some(parsed));
186}
187
188fn target_unmoved(place: &RecoveryPlace, preparing: &PreparingRecord, expected: &Base) -> bool {
199 let revision: String = format!("{}^{{commit}}", preparing.target_ref);
200 let current: Base = match run_git(
201 &place.git,
202 &GitRequest::new(
203 place.cwd.as_path(),
204 &["rev-parse", "--verify", "--quiet", revision.as_str()],
205 ),
206 ) {
207 Ok(output) if output.succeeded() => Base::Commit(
208 String::from_utf8_lossy(output.stdout.as_slice())
209 .trim()
210 .to_string(),
211 ),
212 _ => Base::Unborn,
213 };
214 return current == *expected;
215}
216
217pub fn recover_dead_transaction(
230 place: &RecoveryPlace,
231 directory: &Path,
232 transaction_id: &str,
233 owner_pid: i64,
234) -> Result<RecoveryAction, RecoveryError> {
235 let names: Vec<String> = directory_names(directory)?;
236 if names.contains(&String::from(UNRELEASED_JOURNAL_FILENAME)) {
237 return Err(RecoveryError(format!(
238 "Transaction directory uses an unreleased journal format; inspect it before removing it: {}",
239 directory.display()
240 )));
241 }
242 let preparing_record: Option<PreparingRecord> = read_preparing(directory)?;
243 refuse_unreconciled_landing(&place.git, place.cwd.as_path(), directory, names.as_slice(), preparing_record.as_ref())?;
244 let Some(preparing) = preparing_record else {
245 debug(
246 "recoverDeadTransaction",
247 format!("dead owner stopped before capture: {}", directory.display()).as_str(),
248 );
249 remove_shadow_repository(
250 shadow_repository_path(place.common_dir.as_path(), transaction_id).as_path(),
251 )?;
252 remove_transaction_directory(directory)?;
253 return Ok(RecoveryAction::CommitNotCreated);
254 };
255 let real_index: &Path = Path::new(preparing.real_index_path.as_str());
256 let Some(latest) = attempt_numbers(names.as_slice(), LANDING_RECORD_PREFIX)
257 .last()
258 .copied()
259 else {
260 release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
261 discard_transaction(directory, transaction_id, &preparing)?;
262 return Ok(RecoveryAction::CommitNotCreated);
263 };
264 let landing_path: PathBuf = directory.join(format!("{LANDING_RECORD_PREFIX}{latest}{RECORD_SUFFIX}"));
265 let landing: LandingRecord = parse_landing(read_recovery_file(landing_path.as_path())?.as_slice())
266 .map_err(|error| { return name_record_failure(error, landing_path.as_path(), RecordKind::Landing); })?;
267 if landing.operation == LandingOperation::Commit
268 && !commit_landed(
269 &place.git,
270 place.cwd.as_path(),
271 directory,
272 &preparing,
273 &landing,
274 )?
275 {
276 debug(
277 "recoverDeadTransaction",
278 format!(
279 "landing attempt {latest} never advanced {}: {}",
280 preparing.target_ref,
281 directory.display()
282 )
283 .as_str(),
284 );
285 release_recorded_locks(directory, names.as_slice(), real_index, owner_pid)?;
286 discard_transaction(directory, transaction_id, &preparing)?;
287 return Ok(RecoveryAction::CommitNotCreated);
288 }
289 if landing.operation == LandingOperation::NormalizeOnly
290 && !target_unmoved(place, &preparing, &landing.expected_old)
291 {
292 return Err(RecoveryError(format!(
293 "{} moved after an interrupted normalization; recovery retained at {}",
294 preparing.target_ref,
295 directory.display()
296 )));
297 }
298 remove_transaction_keeps(
299 Path::new(preparing.object_directory.as_str()),
300 transaction_id,
301 )?;
302 if landing.operation == LandingOperation::Commit
303 && let Some(landed) = &landing.new_oid
304 {
305 record_landed_capture_or_warn(
308 Path::new(preparing.git_dir.as_str()),
309 directory,
310 transaction_id,
311 landed.as_str(),
312 );
313 }
314 let completion: IndexCompletion = complete_index(directory, &preparing, &landing)?;
315 remove_dead_pid_file(real_index, owner_pid)?;
316 let shadow: &Path = Path::new(preparing.shadow_path.as_str());
317 if landing.operation == LandingOperation::Commit && path_present(shadow)? {
318 reproduce_conclusion_cleanup(
319 &place.git,
320 place.cwd.as_path(),
321 Path::new(preparing.git_dir.as_str()),
322 shadow,
323 directory,
324 preparing.ref_format,
325 )?;
326 }
327 if path_present(shadow)? {
330 let mut records: Vec<super::transaction_journal::AddedPath> = landing.added_paths.clone();
331 records.extend(landing.selected_worktree_paths.iter().cloned());
332 install_added_worktree_files(
333 &place.git,
334 place.cwd.as_path(),
335 Path::new(preparing.repository_root.as_str()),
336 records.as_slice(),
337 Some(shadow.join("objects").as_path()),
338 )?;
339 }
340 remove_shadow_repository(shadow)?;
341 remove_transaction_directory(directory)?;
342 if landing.operation == LandingOperation::NormalizeOnly {
343 return Ok(RecoveryAction::NormalizationInstalled);
344 }
345 match completion {
346 IndexCompletion::Installed => return Ok(RecoveryAction::IndexInstalled),
347 IndexCompletion::AlreadyInstalled => return Ok(RecoveryAction::AlreadyInstalled),
348 }
349}
350
351#[cfg(test)]
353#[path = "recovery_landing_tests.rs"]
354pub(crate) mod tests;