1use std::path::Path;
8
9use prikk_error::{PrikkError, Result};
10
11use crate::fsutil::{EntryKind, inspect_entry, read_file_required, remove_worktree_file_required};
12use crate::layout::RepositoryLayout;
13use crate::patch_replay::{PatchReplayDeletedFile, replay_supported_patch_chain};
14use crate::path::join_repo_path_to_root;
15use crate::worktree::materialize_replay_manifest_entries;
16use crate::worktree_marker::{clear_worktree_dirty, mark_worktree_dirty};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct PatchMaterializationReport {
21 pub ref_name: String,
23 pub block_count: usize,
25 pub patch_count: usize,
27 pub applied_operation_count: usize,
29 pub planned_files: usize,
31 pub written_files: usize,
33 pub unchanged_files: usize,
35 pub deleted_files: usize,
37 pub already_absent_deleted_files: usize,
39 pub deletion_conflicts: usize,
41 pub total_content_bytes: u64,
43 pub paths: Vec<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PatchDeletionPlan {
50 pub ref_name: String,
52 pub planned_deletions: usize,
54 pub deletable_files: usize,
56 pub already_absent_files: usize,
58 pub conflicts: Vec<PatchDeletionConflict>,
60 pub deletable_paths: Vec<String>,
62}
63
64impl PatchDeletionPlan {
65 #[must_use]
67 pub fn is_safe_to_apply(&self) -> bool {
68 self.conflicts.is_empty()
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PatchDeletionConflict {
75 pub path: String,
77 pub reason: String,
79}
80
81pub fn materialize_patch_checkout(
91 layout: &RepositoryLayout,
92 ref_name: &str,
93) -> Result<PatchMaterializationReport> {
94 layout.require_current_format()?;
95 materialize_patch_checkout_inner(layout, ref_name, false)
96}
97
98pub fn materialize_patch_checkout_with_deletions(
104 layout: &RepositoryLayout,
105 ref_name: &str,
106) -> Result<PatchMaterializationReport> {
107 layout.require_current_format()?;
108 materialize_patch_checkout_inner(layout, ref_name, true)
109}
110
111pub fn plan_patch_checkout_deletions(
113 layout: &RepositoryLayout,
114 ref_name: &str,
115) -> Result<PatchDeletionPlan> {
116 let snapshot = replay_supported_patch_chain(layout, ref_name)?;
117 let analysis = analyze_deletions(layout, &snapshot.deleted_files)?;
118 Ok(PatchDeletionPlan {
119 ref_name: snapshot.ref_name,
120 planned_deletions: snapshot.deleted_files.len(),
121 deletable_files: analysis.deletable.len(),
122 already_absent_files: analysis.already_absent,
123 conflicts: analysis.conflicts,
124 deletable_paths: analysis
125 .deletable
126 .iter()
127 .map(|entry| entry.path.path.as_str().to_string())
128 .collect(),
129 })
130}
131
132fn materialize_patch_checkout_inner(
133 layout: &RepositoryLayout,
134 ref_name: &str,
135 delete_removed: bool,
136) -> Result<PatchMaterializationReport> {
137 let snapshot = replay_supported_patch_chain(layout, ref_name)?;
138 let deletion_analysis = analyze_deletions(layout, &snapshot.deleted_files)?;
139 if delete_removed && !deletion_analysis.conflicts.is_empty() {
140 return Err(PrikkError::Integrity(format!(
141 "refusing checkout deletion because {} candidate(s) are unsafe",
142 deletion_analysis.conflicts.len()
143 )));
144 }
145
146 mark_worktree_dirty(layout)?;
154 let write_report = materialize_replay_manifest_entries(layout, &snapshot.manifest)?;
155 let deleted_files = if delete_removed {
156 apply_deletions(
157 layout,
158 &deletion_analysis.deletable,
159 &deletion_analysis.already_absent_paths,
160 )?
161 } else {
162 0
163 };
164 clear_worktree_dirty(layout)?;
165 let paths = snapshot
166 .manifest
167 .files
168 .iter()
169 .map(|entry| entry.path.as_str().to_string())
170 .collect();
171 Ok(PatchMaterializationReport {
172 ref_name: snapshot.ref_name,
173 block_count: snapshot.block_count,
174 patch_count: snapshot.patch_count,
175 applied_operation_count: snapshot.applied_operation_count,
176 planned_files: snapshot.manifest.files.len(),
177 written_files: write_report.written_files,
178 unchanged_files: write_report.unchanged_files,
179 deleted_files,
180 already_absent_deleted_files: deletion_analysis.already_absent,
181 deletion_conflicts: deletion_analysis.conflicts.len(),
182 total_content_bytes: snapshot.manifest.total_content_bytes(),
183 paths,
184 })
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188struct DeletionAnalysis {
189 deletable: Vec<DeletableFile>,
190 already_absent: usize,
191 already_absent_paths: Vec<crate::path::RepoPath>,
192 conflicts: Vec<PatchDeletionConflict>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196struct DeletableFile {
197 path: PatchReplayDeletedFile,
198 target: std::path::PathBuf,
199}
200
201fn analyze_deletions(
202 layout: &RepositoryLayout,
203 deleted: &[PatchReplayDeletedFile],
204) -> Result<DeletionAnalysis> {
205 let mut deletable = Vec::new();
206 let mut already_absent = 0_usize;
207 let mut already_absent_paths = Vec::new();
208 let mut conflicts = Vec::new();
209 for deleted_file in deleted {
210 let target = join_repo_path_to_root(&deleted_file.path, layout.root());
211 let relative = Path::new(deleted_file.path.as_str());
212 match inspect_entry(layout.worktree_mutation_root(), relative)? {
213 None => {
214 already_absent += 1;
215 already_absent_paths.push(deleted_file.path.clone());
216 continue;
217 }
218 Some(EntryKind::Regular) => {}
219 Some(EntryKind::Symlink) => {
220 conflicts.push(PatchDeletionConflict {
221 path: deleted_file.path.as_str().to_string(),
222 reason: "target is a symlink".to_string(),
223 });
224 continue;
225 }
226 Some(EntryKind::Directory | EntryKind::Other) => {
227 conflicts.push(PatchDeletionConflict {
228 path: deleted_file.path.as_str().to_string(),
229 reason: "target is not a regular file".to_string(),
230 });
231 continue;
232 }
233 }
234 let current = read_file_required(layout.worktree_mutation_root(), relative)?;
235 if current != deleted_file.old_bytes {
236 conflicts.push(PatchDeletionConflict {
237 path: deleted_file.path.as_str().to_string(),
238 reason: format!(
239 "current file bytes do not match delete precondition blob {}",
240 deleted_file.old_blob_id
241 ),
242 });
243 continue;
244 }
245 deletable.push(DeletableFile {
246 path: deleted_file.clone(),
247 target,
248 });
249 }
250 Ok(DeletionAnalysis {
251 deletable,
252 already_absent,
253 already_absent_paths,
254 conflicts,
255 })
256}
257
258fn apply_deletions(
259 layout: &RepositoryLayout,
260 deletable: &[DeletableFile],
261 already_absent: &[crate::path::RepoPath],
262) -> Result<usize> {
263 let mut removed = 0_usize;
264 for item in deletable {
265 remove_worktree_file_required(
266 layout.worktree_mutation_root(),
267 Path::new(item.path.path.as_str()),
268 )?;
269 removed += 1;
270 }
271 for path in already_absent {
272 remove_worktree_file_required(layout.worktree_mutation_root(), Path::new(path.as_str()))?;
273 }
274 Ok(removed)
275}
276
277#[cfg(all(test, target_os = "linux"))]
280mod tests;