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