use crate::json_record::quote;
use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
use crate::worktree_copy_owner::current_copy_owner;
use crate::worktree_copy_stage::StagedSnapshot;
use crate::worktree_copy_stage_claim::{reap_abandoned_stages, stage_claims_exist};
use crate::worktree_copy_transaction::{Outcome, complete_journal, snapshot_from_journal};
use crate::worktree_copy_journal::{
PendingJournal, Phase, Role, adopt_journal, assert_private_path, journal_owner_is_dead, read_pending_journals,
remove_pending_journal,
};
use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Registration {
Registered,
Unregistered,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StagePresence {
Intact,
Missing,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoveryReport {
pub recovered: usize,
pub notices: Vec<String>,
}
fn read_registration(common_dir: &Path, destination: &Path) -> std::io::Result<Registration> {
if std::fs::canonicalize(destination)? != destination {
return Ok(Registration::Unregistered);
}
let pointer: String = std::fs::read_to_string(destination.join(".git"))?
.trim_end()
.to_string();
let Some(rest) = pointer.strip_prefix("gitdir: ") else {
return Ok(Registration::Unregistered);
};
let admin: PathBuf = std::fs::canonicalize(destination.join(rest))?;
let expected: PathBuf = std::fs::canonicalize(common_dir.join("worktrees"))?;
if admin.parent() == Some(expected.as_path()) {
return Ok(Registration::Registered);
}
return Ok(Registration::Unregistered);
}
pub fn destination_registration(
common_dir: &Path,
destination: &Path,
) -> Result<Registration, WorktreeCopyError> {
match read_registration(common_dir, destination) {
Ok(registration) => return Ok(registration),
Err(error)
if error.kind() == std::io::ErrorKind::NotFound
|| error.raw_os_error() == Some(libc::ENOTDIR) =>
{
return Ok(Registration::Unregistered);
}
Err(error) => {
let message: String = format!(
"cli-git: could not read worktree registration of {}.",
quote(destination.to_string_lossy().as_ref())
);
return Err(failure_with_cause(message.as_str(), Some(&error)));
}
}
}
pub fn validate_stage(pending: &PendingJournal) -> Result<StagePresence, WorktreeCopyError> {
let container: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
let payload: PathBuf = PathBuf::from(pending.journal.stage_root.as_str());
for path in [&container, &payload] {
match std::fs::symlink_metadata(path.as_path()) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(StagePresence::Missing);
}
Err(error) => {
let message: String = format!(
"cli-git: could not validate worktree-copy recovery state for {}.",
quote(pending.journal.destination_root.as_str())
);
return Err(failure_with_cause(message.as_str(), Some(&error)));
}
}
assert_private_path(path.as_path(), Role::PrivateStage)?;
}
return Ok(StagePresence::Intact);
}
fn recover_transaction(
common_dir: &Path,
pending: PendingJournal,
) -> Result<(bool, Option<String>), WorktreeCopyError> {
let destination: String = quote(pending.journal.destination_root.as_str());
let registration: Registration = destination_registration(
common_dir,
Path::new(pending.journal.destination_root.as_str()),
)?;
let stage: StagePresence = validate_stage(&pending)?;
if registration == Registration::Unregistered {
remove_pending_journal(&pending)?;
return Ok((
false,
Some(format!(
"cli-git: discarded an interrupted ignored-state copy for {destination}, which is no longer a registered worktree."
)),
));
}
if pending.journal.phase == Phase::Complete {
remove_pending_journal(&pending)?;
return Ok((true, None));
}
if stage == StagePresence::Missing {
remove_pending_journal(&pending)?;
return Ok((
false,
Some(format!(
"cli-git: discarded an interrupted ignored-state copy into {destination} because its private stage is missing; ignored files there may be incomplete."
)),
));
}
let journal_for_snapshot: PendingJournal = pending.clone();
let snapshot = move |intended: &BTreeSet<String>| -> Result<StagedSnapshot, WorktreeCopyError> {
return snapshot_from_journal(&journal_for_snapshot, intended);
};
match complete_journal(pending, &snapshot)? {
Outcome::Installed(_) => return Ok((true, None)),
Outcome::Ended(failure) => {
return Ok((
false,
Some(format!(
"cli-git: ended an interrupted ignored-state copy into {destination} without finishing it. {}",
failure.0
)),
));
}
}
}
pub fn recover_worktree_copy_transactions(
common_dir: &Path,
) -> Result<RecoveryReport, WorktreeCopyError> {
let pending: Vec<PendingJournal> = read_pending_journals(common_dir)?;
let owner = current_copy_owner()?;
let mut recovered: usize = 0;
let mut notices: Vec<String> = Vec::new();
for journal in pending {
if !journal_owner_is_dead(&journal) {
continue;
}
let Some(mine) = adopt_journal(&journal, &owner)? else {
continue;
};
let (completed, notice) = recover_transaction(common_dir, mine)?;
if completed {
recovered += 1;
}
if let Some(line) = notice {
notices.push(line);
}
}
notices.extend(reap_abandoned_stages(common_dir)?);
return Ok(RecoveryReport { recovered, notices });
}
pub fn report_recovered(
report: &RecoveryReport,
stderr: &mut dyn Write,
) -> Result<(), WorktreeCopyError> {
for notice in &report.notices {
writeln!(stderr, "{notice}").map_err(|error| return raw_failure(&error))?;
}
if report.recovered > 0 {
let noun: &str = if report.recovered == 1 {
"transaction"
} else {
"transactions"
};
writeln!(
stderr,
"cli-git: recovered ignored-state copies for {} worktree {noun}.",
report.recovered
)
.map_err(|error| return raw_failure(&error))?;
}
return Ok(());
}
pub fn recover_pending_worktree_copies(
common_dir: &Path,
stderr: &mut dyn Write,
) -> Result<(), WorktreeCopyError> {
if read_pending_journals(common_dir)?.is_empty() && !stage_claims_exist(common_dir)? {
return Ok(());
}
return report_recovered(&recover_worktree_copy_transactions(common_dir)?, stderr);
}
#[cfg(test)]
#[path = "worktree_copy_recovery_tests.rs"]
mod tests;