Skip to main content

prikk_store/
patch_checkout.rs

1//! Conservative patch-replay worktree materialization.
2//!
3//! PR-022 extends patch materialization with an explicit, opt-in deletion path. Deletion remains
4//! narrowly scoped: only files removed by replayed `DeleteFile` operations are eligible, and the
5//! current worktree bytes must still match the delete precondition bytes before removal.
6
7use 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/// Result of an opt-in patch replay materialization.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct PatchMaterializationReport {
21    /// Human-readable ref name.
22    pub ref_name: String,
23    /// Number of blocks replayed from oldest to newest.
24    pub block_count: usize,
25    /// Number of patch objects replayed.
26    pub patch_count: usize,
27    /// Number of supported operations applied.
28    pub applied_operation_count: usize,
29    /// Number of files in the final replay manifest.
30    pub planned_files: usize,
31    /// Number of files written by this invocation.
32    pub written_files: usize,
33    /// Number of files already present with identical bytes.
34    pub unchanged_files: usize,
35    /// Number of explicit patch-deleted files removed by this invocation.
36    pub deleted_files: usize,
37    /// Number of explicit patch-deleted files that were already absent.
38    pub already_absent_deleted_files: usize,
39    /// Number of explicit patch deletions refused by preflight checks.
40    pub deletion_conflicts: usize,
41    /// Total content bytes represented by the replayed manifest.
42    pub total_content_bytes: u64,
43    /// Repository-relative paths in materialization order.
44    pub paths: Vec<String>,
45}
46
47/// Read-only plan for explicit patch checkout deletions.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PatchDeletionPlan {
50    /// Human-readable ref name.
51    pub ref_name: String,
52    /// Number of files explicitly deleted by the replayed patch chain.
53    pub planned_deletions: usize,
54    /// Number of deletion candidates that can be removed safely.
55    pub deletable_files: usize,
56    /// Number of deletion candidates that are already absent.
57    pub already_absent_files: usize,
58    /// Refused deletion candidates.
59    pub conflicts: Vec<PatchDeletionConflict>,
60    /// Deletable repository-relative paths.
61    pub deletable_paths: Vec<String>,
62}
63
64impl PatchDeletionPlan {
65    /// Return true when all explicit deletions are either safe or already complete.
66    #[must_use]
67    pub fn is_safe_to_apply(&self) -> bool {
68        self.conflicts.is_empty()
69    }
70}
71
72/// A deletion candidate that cannot be safely removed.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PatchDeletionConflict {
75    /// Repository-relative path.
76    pub path: String,
77    /// Human-readable reason for refusing deletion.
78    pub reason: String,
79}
80
81/// Materialize the supported patch replay result into the repository worktree.
82///
83/// This is deliberately conservative:
84///
85/// - it supports only the `CreateFile`, `DeleteFile`, and `ReplaceBinary` subset already handled by
86///   patch replay planning;
87/// - it refuses conflicting existing files via the shared safe materializer;
88/// - it does not remove files absent from the replay result;
89/// - it remains separate from full patch algebra and conflict handling.
90pub 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
98/// Materialize the supported patch replay result and remove explicit patch-deleted files.
99///
100/// This opt-in path removes only files for which the replayed patch chain contains a `DeleteFile`
101/// operation and the current worktree bytes still match the operation's `old_blob_id` bytes. It
102/// never removes arbitrary untracked files.
103pub 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
111/// Prepare a read-only deletion plan for explicit patch-deleted files.
112pub 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    // RFC 102 Stage 1: brackets the write phase *and* the deletion phase in one dirty/clean cycle,
147    // deliberately, not just the writes. `apply_deletions`' own targets are precondition-verified
148    // against sealed history (`analyze_deletions`), not inferred from absence, so an interrupted
149    // deletion is not itself a T12-class false-signed-deletion risk -- but a worktree left partway
150    // through either phase does not match either the pre- or post-checkout baseline, and bracketing
151    // both under one marker cycle is simpler to reason about than two different granularities with
152    // two different soundness arguments.
153    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// DC-71: every test here sets up its scenario via real repository mutation (RepositoryLayout::init
278// or equivalent), which is Linux-only; the module never compiles a non-Linux-meaningful test.
279#[cfg(all(test, target_os = "linux"))]
280mod tests;