git_policy_cli/
worktree_copy_recovery.rs1use super::json_record::quote;
15use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
16use super::worktree_copy_lock::try_acquire_worktree_copy_lock;
17use super::worktree_copy_stage::StagedSnapshot;
18use super::worktree_copy_stage_claim::{reap_abandoned_stages, stage_claims_exist};
19use super::worktree_copy_transaction::{Outcome, complete_journal, snapshot_from_journal};
20use crate::worktree_copy_journal::{
21 PendingJournal, Phase, Role, assert_private_path, read_pending_journals, remove_pending_journal,
22};
23use std::collections::BTreeSet;
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum Registration {
38 Registered,
40 Unregistered,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub enum StagePresence {
55 Intact,
57 Missing,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct RecoveryReport {
72 pub recovered: usize,
74 pub notices: Vec<String>,
76}
77
78fn read_registration(common_dir: &Path, destination: &Path) -> std::io::Result<Registration> {
88 if std::fs::canonicalize(destination)? != destination {
89 return Ok(Registration::Unregistered);
90 }
91 let pointer: String = std::fs::read_to_string(destination.join(".git"))?
92 .trim_end()
93 .to_string();
94 let Some(rest) = pointer.strip_prefix("gitdir: ") else {
95 return Ok(Registration::Unregistered);
96 };
97 let admin: PathBuf = std::fs::canonicalize(destination.join(rest))?;
98 let expected: PathBuf = std::fs::canonicalize(common_dir.join("worktrees"))?;
99 if admin.parent() == Some(expected.as_path()) {
100 return Ok(Registration::Registered);
101 }
102 return Ok(Registration::Unregistered);
103}
104
105pub fn destination_registration(
115 common_dir: &Path,
116 destination: &Path,
117) -> Result<Registration, WorktreeCopyError> {
118 match read_registration(common_dir, destination) {
120 Ok(registration) => return Ok(registration),
121 Err(error)
122 if error.kind() == std::io::ErrorKind::NotFound
123 || error.raw_os_error() == Some(libc::ENOTDIR) =>
124 {
125 return Ok(Registration::Unregistered);
126 }
127 Err(error) => {
128 let message: String = format!(
129 "cli-git: could not read worktree registration of {}.",
130 quote(destination.to_string_lossy().as_ref())
131 );
132 return Err(failure_with_cause(message.as_str(), Some(&error)));
133 }
134 }
135}
136
137pub fn validate_stage(pending: &PendingJournal) -> Result<StagePresence, WorktreeCopyError> {
147 let container: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
148 let payload: PathBuf = PathBuf::from(pending.journal.stage_root.as_str());
149 for path in [&container, &payload] {
151 match std::fs::symlink_metadata(path.as_path()) {
152 Ok(_) => {}
153 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
154 return Ok(StagePresence::Missing);
155 }
156 Err(error) => {
157 let message: String = format!(
158 "cli-git: could not validate worktree-copy recovery state for {}.",
159 quote(pending.journal.destination_root.as_str())
160 );
161 return Err(failure_with_cause(message.as_str(), Some(&error)));
162 }
163 }
164 assert_private_path(path.as_path(), Role::PrivateStage)?;
165 }
166 return Ok(StagePresence::Intact);
167}
168
169fn recover_transaction(
179 common_dir: &Path,
180 pending: PendingJournal,
181) -> Result<(bool, Option<String>), WorktreeCopyError> {
182 let destination: String = quote(pending.journal.destination_root.as_str());
183 let registration: Registration = destination_registration(
184 common_dir,
185 Path::new(pending.journal.destination_root.as_str()),
186 )?;
187 let stage: StagePresence = validate_stage(&pending)?;
188 if registration == Registration::Unregistered {
189 remove_pending_journal(&pending)?;
190 return Ok((
191 false,
192 Some(format!(
193 "cli-git: discarded an interrupted ignored-state copy for {destination}, which is no longer a registered worktree."
194 )),
195 ));
196 }
197 if pending.journal.phase == Phase::Complete {
198 remove_pending_journal(&pending)?;
199 return Ok((true, None));
200 }
201 if stage == StagePresence::Missing {
202 remove_pending_journal(&pending)?;
203 return Ok((
204 false,
205 Some(format!(
206 "cli-git: discarded an interrupted ignored-state copy into {destination} because its private stage is missing; ignored files there may be incomplete."
207 )),
208 ));
209 }
210 let journal_for_snapshot: PendingJournal = pending.clone();
211 let snapshot = move |intended: &BTreeSet<String>| -> Result<StagedSnapshot, WorktreeCopyError> {
212 return snapshot_from_journal(&journal_for_snapshot, intended);
213 };
214 match complete_journal(pending, &snapshot)? {
216 Outcome::Installed(_) => return Ok((true, None)),
217 Outcome::Ended(failure) => {
218 return Ok((
219 false,
220 Some(format!(
221 "cli-git: ended an interrupted ignored-state copy into {destination} without finishing it. {}",
222 failure.0
223 )),
224 ));
225 }
226 }
227}
228
229pub fn recover_worktree_copy_transactions(
239 common_dir: &Path,
240) -> Result<RecoveryReport, WorktreeCopyError> {
241 let pending: Vec<PendingJournal> = read_pending_journals(common_dir)?;
242 let mut recovered: usize = 0;
243 let mut notices: Vec<String> = Vec::new();
244 for journal in pending {
245 let (completed, notice) = recover_transaction(common_dir, journal)?;
246 if completed {
247 recovered += 1;
248 }
249 if let Some(line) = notice {
250 notices.push(line);
251 }
252 }
253 notices.extend(reap_abandoned_stages(common_dir)?);
254 return Ok(RecoveryReport { recovered, notices });
255}
256
257pub fn report_recovered(
267 report: &RecoveryReport,
268 stderr: &mut dyn Write,
269) -> Result<(), WorktreeCopyError> {
270 for notice in &report.notices {
271 writeln!(stderr, "{notice}").map_err(|error| return raw_failure(&error))?;
272 }
273 if report.recovered > 0 {
274 let noun: &str = if report.recovered == 1 {
275 "transaction"
276 } else {
277 "transactions"
278 };
279 writeln!(
280 stderr,
281 "cli-git: recovered ignored-state copies for {} worktree {noun}.",
282 report.recovered
283 )
284 .map_err(|error| return raw_failure(&error))?;
285 }
286 return Ok(());
287}
288
289pub fn recover_pending_worktree_copies(
299 common_dir: &Path,
300 stderr: &mut dyn Write,
301) -> Result<(), WorktreeCopyError> {
302 if read_pending_journals(common_dir)?.is_empty() && !stage_claims_exist(common_dir)? {
303 return Ok(());
304 }
305 let Some(lease) = try_acquire_worktree_copy_lock(common_dir)? else {
306 return Ok(());
307 };
308 report_recovered(&recover_worktree_copy_transactions(common_dir)?, stderr)?;
309 return lease.release();
310}
311
312#[cfg(test)]
314#[path = "worktree_copy_recovery_tests.rs"]
315mod tests;