Skip to main content

prikk_store/
worktree_status.rs

1//! Read-only worktree status against a snapshot baseline.
2//!
3//! PR-019 compares the current worktree with the snapshot manifest referenced by a published block.
4//! It is intentionally read-only and does not create patch operations yet.
5
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::Path;
9
10use prikk_error::{PrikkError, Result};
11use prikk_object::ObjectType;
12
13use crate::checkout::prepare_snapshot_checkout_plan;
14use crate::layout::RepositoryLayout;
15use crate::object_store::{FileObjectStore, ObjectReader};
16use crate::path::{RepoPath, join_repo_path_to_root};
17use crate::snapshot::SnapshotManifest;
18
19/// Read-only worktree status report against a snapshot baseline.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct WorktreeStatusReport {
22    /// Human-readable ref name used as the baseline.
23    pub ref_name: String,
24    /// Number of tracked files in the snapshot baseline.
25    pub tracked_files: usize,
26    /// Number of tracked files that match the baseline bytes.
27    pub unchanged_files: usize,
28    /// Worktree changes detected against the baseline.
29    pub changes: Vec<WorktreeChange>,
30}
31
32impl WorktreeStatusReport {
33    /// Return true when the worktree has no detected changes.
34    #[must_use]
35    pub fn is_clean(&self) -> bool {
36        self.changes.is_empty()
37    }
38
39    /// Count changes by kind.
40    #[must_use]
41    pub fn count_kind(&self, kind: WorktreeChangeKind) -> usize {
42        self.changes
43            .iter()
44            .filter(|change| change.kind == kind)
45            .count()
46    }
47}
48
49/// A single worktree change detected by the read-only status scanner.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WorktreeChange {
52    /// Repository-relative path, when it could be represented safely.
53    pub path: String,
54    /// Change kind.
55    pub kind: WorktreeChangeKind,
56    /// Short explanation intended for CLI display.
57    pub detail: String,
58}
59
60/// Worktree change kind.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum WorktreeChangeKind {
63    /// A tracked snapshot file is missing from the worktree.
64    Missing,
65    /// A tracked snapshot file exists but differs from the baseline bytes.
66    Modified,
67    /// A worktree file is not present in the snapshot baseline.
68    Untracked,
69    /// A worktree path could not be safely represented as a Prikk repo path.
70    UnsupportedPath,
71}
72
73impl WorktreeChangeKind {
74    /// Stable CLI label.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Missing => "missing",
79            Self::Modified => "modified",
80            Self::Untracked => "untracked",
81            Self::UnsupportedPath => "unsupported-path",
82        }
83    }
84}
85
86/// Compute read-only worktree status against the snapshot referenced by a ref.
87pub fn worktree_status(layout: &RepositoryLayout, ref_name: &str) -> Result<WorktreeStatusReport> {
88    let plan = prepare_snapshot_checkout_plan(layout, ref_name)?;
89    let manifest = load_snapshot_manifest(layout, plan.snapshot_blob_id)?;
90    let baseline_paths: BTreeSet<String> = manifest
91        .files
92        .iter()
93        .map(|entry| entry.path.as_str().to_string())
94        .collect();
95    let mut seen_paths = BTreeSet::new();
96    let mut changes = Vec::new();
97    let mut unchanged_files = 0_usize;
98
99    for entry in &manifest.files {
100        let path_text = entry.path.as_str().to_string();
101        let target = join_repo_path_to_root(&entry.path, layout.root());
102        seen_paths.insert(path_text.clone());
103        if !target.exists() {
104            changes.push(WorktreeChange {
105                path: path_text,
106                kind: WorktreeChangeKind::Missing,
107                detail: "tracked snapshot file is absent from the worktree".to_string(),
108            });
109            continue;
110        }
111        let metadata = fs::symlink_metadata(&target)?;
112        if metadata.file_type().is_symlink() || !metadata.is_file() {
113            changes.push(WorktreeChange {
114                path: path_text,
115                kind: WorktreeChangeKind::Modified,
116                detail: "tracked path is not a regular file".to_string(),
117            });
118            continue;
119        }
120        let bytes = fs::read(&target)?;
121        if bytes == entry.bytes {
122            unchanged_files += 1;
123        } else {
124            changes.push(WorktreeChange {
125                path: path_text,
126                kind: WorktreeChangeKind::Modified,
127                detail: "tracked file bytes differ from the snapshot baseline".to_string(),
128            });
129        }
130    }
131
132    scan_untracked(
133        layout.root(),
134        layout.root(),
135        &baseline_paths,
136        &seen_paths,
137        &mut changes,
138    )?;
139    changes.sort_by(|left, right| {
140        left.path
141            .cmp(&right.path)
142            .then(left.kind.as_str().cmp(right.kind.as_str()))
143    });
144
145    Ok(WorktreeStatusReport {
146        ref_name: ref_name.to_string(),
147        tracked_files: manifest.files.len(),
148        unchanged_files,
149        changes,
150    })
151}
152
153fn load_snapshot_manifest(
154    layout: &RepositoryLayout,
155    snapshot_blob_id: prikk_object::ObjectId,
156) -> Result<SnapshotManifest> {
157    let object_store = FileObjectStore::new(layout.clone());
158    let Some(envelope) = object_store.read_object(snapshot_blob_id)? else {
159        return Err(PrikkError::Integrity(format!(
160            "snapshot Blob {snapshot_blob_id} is missing"
161        )));
162    };
163    if envelope.object_type != ObjectType::Blob {
164        return Err(PrikkError::ObjectTypeMismatch {
165            expected: ObjectType::Blob.to_string(),
166            actual: envelope.object_type.to_string(),
167        });
168    }
169    let snapshot_content = crate::blob_access::decode_snapshot_blob(&envelope.canonical_payload)?;
170    SnapshotManifest::decode(&snapshot_content)
171}
172
173fn scan_untracked(
174    root: &Path,
175    current: &Path,
176    baseline_paths: &BTreeSet<String>,
177    seen_paths: &BTreeSet<String>,
178    changes: &mut Vec<WorktreeChange>,
179) -> Result<()> {
180    let entries = match fs::read_dir(current) {
181        Ok(entries) => entries,
182        Err(err) => return Err(PrikkError::Io(err.to_string())),
183    };
184    for entry in entries {
185        let entry = entry?;
186        let path = entry.path();
187        if is_prikk_metadata_path(root, &path) {
188            continue;
189        }
190        let metadata = fs::symlink_metadata(&path)?;
191        if metadata.is_dir() && !metadata.file_type().is_symlink() {
192            scan_untracked(root, &path, baseline_paths, seen_paths, changes)?;
193            continue;
194        }
195        let repo_path = path_to_repo_string(root, &path);
196        match repo_path.and_then(|text| RepoPath::parse(&text).map(|_| text)) {
197            Ok(text) => {
198                if !baseline_paths.contains(&text) && !seen_paths.contains(&text) {
199                    changes.push(WorktreeChange {
200                        path: text,
201                        kind: WorktreeChangeKind::Untracked,
202                        detail: "worktree file is not in the snapshot baseline".to_string(),
203                    });
204                }
205            }
206            Err(err) => {
207                changes.push(WorktreeChange {
208                    path: path.display().to_string(),
209                    kind: WorktreeChangeKind::UnsupportedPath,
210                    detail: format!(
211                        "worktree path is not representable as a safe Prikk path: {err}"
212                    ),
213                });
214            }
215        }
216    }
217    Ok(())
218}
219
220fn is_prikk_metadata_path(root: &Path, path: &Path) -> bool {
221    match path.strip_prefix(root) {
222        Ok(relative) => first_component_is_prikk(relative),
223        Err(_) => false,
224    }
225}
226
227fn first_component_is_prikk(relative: &Path) -> bool {
228    let Some(first) = relative.components().next() else {
229        return false;
230    };
231    first.as_os_str().to_str() == Some(".prikk")
232}
233
234fn path_to_repo_string(root: &Path, path: &Path) -> Result<String> {
235    let relative = path.strip_prefix(root).map_err(|_| {
236        PrikkError::Integrity(format!(
237            "worktree path escaped repository root: {}",
238            path.display()
239        ))
240    })?;
241    pathbuf_to_slash_string(relative)
242}
243
244fn pathbuf_to_slash_string(path: &Path) -> Result<String> {
245    let mut components = Vec::new();
246    for component in path.components() {
247        let text = component.as_os_str().to_str().ok_or_else(|| {
248            PrikkError::Integrity(format!("worktree path is not UTF-8: {}", path.display()))
249        })?;
250        components.push(text.to_string());
251    }
252    if components.is_empty() {
253        return Err(PrikkError::Integrity("empty worktree path".to_string()));
254    }
255    Ok(components.join("/"))
256}
257
258#[cfg(test)]
259mod tests;