prikk_store/
worktree_status.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct WorktreeStatusReport {
22 pub ref_name: String,
24 pub tracked_files: usize,
26 pub unchanged_files: usize,
28 pub changes: Vec<WorktreeChange>,
30}
31
32impl WorktreeStatusReport {
33 #[must_use]
35 pub fn is_clean(&self) -> bool {
36 self.changes.is_empty()
37 }
38
39 #[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#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WorktreeChange {
52 pub path: String,
54 pub kind: WorktreeChangeKind,
56 pub detail: String,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum WorktreeChangeKind {
63 Missing,
65 Modified,
67 Untracked,
69 UnsupportedPath,
71}
72
73impl WorktreeChangeKind {
74 #[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
86pub 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;