use std::fs;
use std::path::Path;
use prikk_error::{PrikkError, Result};
use crate::fsutil::sync_directory_best_effort;
use crate::layout::RepositoryLayout;
use crate::patch_replay::{PatchReplayDeletedFile, replay_supported_patch_chain};
use crate::path::join_repo_path_to_root;
use crate::worktree::materialize_manifest_entries;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchMaterializationReport {
pub ref_name: String,
pub block_count: usize,
pub patch_count: usize,
pub applied_operation_count: usize,
pub planned_files: usize,
pub written_files: usize,
pub unchanged_files: usize,
pub deleted_files: usize,
pub already_absent_deleted_files: usize,
pub deletion_conflicts: usize,
pub total_content_bytes: u64,
pub paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchDeletionPlan {
pub ref_name: String,
pub planned_deletions: usize,
pub deletable_files: usize,
pub already_absent_files: usize,
pub conflicts: Vec<PatchDeletionConflict>,
pub deletable_paths: Vec<String>,
}
impl PatchDeletionPlan {
#[must_use]
pub fn is_safe_to_apply(&self) -> bool {
self.conflicts.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchDeletionConflict {
pub path: String,
pub reason: String,
}
pub fn materialize_patch_checkout(
layout: &RepositoryLayout,
ref_name: &str,
) -> Result<PatchMaterializationReport> {
materialize_patch_checkout_inner(layout, ref_name, false)
}
pub fn materialize_patch_checkout_with_deletions(
layout: &RepositoryLayout,
ref_name: &str,
) -> Result<PatchMaterializationReport> {
materialize_patch_checkout_inner(layout, ref_name, true)
}
pub fn plan_patch_checkout_deletions(
layout: &RepositoryLayout,
ref_name: &str,
) -> Result<PatchDeletionPlan> {
let snapshot = replay_supported_patch_chain(layout, ref_name)?;
let analysis = analyze_deletions(layout.root(), &snapshot.deleted_files)?;
Ok(PatchDeletionPlan {
ref_name: snapshot.ref_name,
planned_deletions: snapshot.deleted_files.len(),
deletable_files: analysis.deletable.len(),
already_absent_files: analysis.already_absent,
conflicts: analysis.conflicts,
deletable_paths: analysis
.deletable
.iter()
.map(|entry| entry.path.path.as_str().to_string())
.collect(),
})
}
fn materialize_patch_checkout_inner(
layout: &RepositoryLayout,
ref_name: &str,
delete_removed: bool,
) -> Result<PatchMaterializationReport> {
let snapshot = replay_supported_patch_chain(layout, ref_name)?;
let deletion_analysis = analyze_deletions(layout.root(), &snapshot.deleted_files)?;
if delete_removed && !deletion_analysis.conflicts.is_empty() {
return Err(PrikkError::Integrity(format!(
"refusing checkout deletion because {} candidate(s) are unsafe",
deletion_analysis.conflicts.len()
)));
}
let write_report = materialize_manifest_entries(layout.root(), &snapshot.manifest)?;
let deleted_files = if delete_removed {
apply_deletions(&deletion_analysis.deletable)?
} else {
0
};
let paths = snapshot
.manifest
.files
.iter()
.map(|entry| entry.path.as_str().to_string())
.collect();
Ok(PatchMaterializationReport {
ref_name: snapshot.ref_name,
block_count: snapshot.block_count,
patch_count: snapshot.patch_count,
applied_operation_count: snapshot.applied_operation_count,
planned_files: snapshot.manifest.files.len(),
written_files: write_report.written_files,
unchanged_files: write_report.unchanged_files,
deleted_files,
already_absent_deleted_files: deletion_analysis.already_absent,
deletion_conflicts: deletion_analysis.conflicts.len(),
total_content_bytes: snapshot.manifest.total_content_bytes(),
paths,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DeletionAnalysis {
deletable: Vec<DeletableFile>,
already_absent: usize,
conflicts: Vec<PatchDeletionConflict>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DeletableFile {
path: PatchReplayDeletedFile,
target: std::path::PathBuf,
}
fn analyze_deletions(root: &Path, deleted: &[PatchReplayDeletedFile]) -> Result<DeletionAnalysis> {
let mut deletable = Vec::new();
let mut already_absent = 0_usize;
let mut conflicts = Vec::new();
for deleted_file in deleted {
let target = join_repo_path_to_root(&deleted_file.path, root);
if !target.starts_with(root) {
conflicts.push(PatchDeletionConflict {
path: deleted_file.path.as_str().to_string(),
reason: "target escapes repository root".to_string(),
});
continue;
}
let metadata = match fs::symlink_metadata(&target) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
already_absent += 1;
continue;
}
Err(err) => return Err(err.into()),
};
if metadata.file_type().is_symlink() {
conflicts.push(PatchDeletionConflict {
path: deleted_file.path.as_str().to_string(),
reason: "target is a symlink".to_string(),
});
continue;
}
if !metadata.is_file() {
conflicts.push(PatchDeletionConflict {
path: deleted_file.path.as_str().to_string(),
reason: "target is not a regular file".to_string(),
});
continue;
}
let current = fs::read(&target)?;
if current != deleted_file.old_bytes {
conflicts.push(PatchDeletionConflict {
path: deleted_file.path.as_str().to_string(),
reason: format!(
"current file bytes do not match delete precondition blob {}",
deleted_file.old_blob_id
),
});
continue;
}
deletable.push(DeletableFile {
path: deleted_file.clone(),
target,
});
}
Ok(DeletionAnalysis {
deletable,
already_absent,
conflicts,
})
}
fn apply_deletions(deletable: &[DeletableFile]) -> Result<usize> {
let mut removed = 0_usize;
for item in deletable {
fs::remove_file(&item.target)?;
sync_directory_best_effort(item.target.parent().unwrap_or_else(|| Path::new(".")))?;
removed += 1;
}
Ok(removed)
}
#[cfg(test)]
mod tests;