1use std::collections::BTreeSet;
2
3use camino::{Utf8Path, Utf8PathBuf};
4
5use crate::branch::BranchInstance;
6use crate::checkpoint::CheckpointLog;
7use crate::error::{NewgitError, Result};
8use crate::materializer::workspace_marker_path;
9use crate::resource::Ownership;
10use crate::store::{MetadataStore, read_subdirs_sorted};
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct CleanupOutcome {
17 pub dry_run: bool,
18 pub finalized: Vec<FinalizedInstance>,
20 pub orphan_workspaces: Vec<Utf8PathBuf>,
22 pub dead_state: Vec<Utf8PathBuf>,
24 pub purged_checkpoints: Vec<PurgedCheckpoints>,
27 pub pruned: Vec<PrunedRev>,
28 pub pinned_by_checkpoints: usize,
32 pub pinned_by_archived: usize,
35 pub warnings: Vec<String>,
36}
37
38impl CleanupOutcome {
39 pub fn is_empty(&self) -> bool {
40 self.finalized.is_empty()
41 && self.orphan_workspaces.is_empty()
42 && self.dead_state.is_empty()
43 && self.purged_checkpoints.is_empty()
44 && self.pruned.is_empty()
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ArchivedCheckpoints {
54 Keep,
55 Purge,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PurgedCheckpoints {
61 pub slug: String,
64 pub checkpoints: usize,
65 pub source_refs: usize,
67 pub dir: Utf8PathBuf,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct FinalizedInstance {
72 pub name: String,
73 pub workspace: Utf8PathBuf,
74 pub hooks: Vec<HookOutcome>,
75 pub archived_record: Option<Utf8PathBuf>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct HookOutcome {
82 pub resource: String,
83 pub ownership: Ownership,
84 pub detail: HookDetail,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum HookDetail {
89 Ran {
90 command: String,
91 ok: bool,
92 log: Utf8PathBuf,
93 },
94 WouldRun(String),
96 SkippedOwnership,
98 NoHook,
100 SkippedUnresolved {
103 command: String,
104 placeholder: String,
105 },
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PrunedRev {
110 pub tracker: String,
111 pub rev: String,
112 pub path: Utf8PathBuf,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct LaneRev {
118 pub tracker: String,
119 pub rev: String,
120 pub path: Utf8PathBuf,
121 pub is_staging: bool,
123}
124
125pub fn may_tear_down(ownership: Ownership) -> bool {
129 ownership.per_branch_teardown_may_touch()
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct SnapshotRoots {
141 pub bindings: BTreeSet<(String, String)>,
143 pub checkpoints: BTreeSet<(String, String)>,
145 pub archived_checkpoints: BTreeSet<(String, String)>,
149 pub lane_heads: BTreeSet<(String, String)>,
151}
152
153impl SnapshotRoots {
154 pub fn collect(
164 store: &MetadataStore,
165 branches: &[BranchInstance],
166 archived: ArchivedCheckpoints,
167 ) -> Result<Self> {
168 let mut roots = Self::default();
169
170 for branch in branches {
171 for (tracker, binding) in &branch.trackers {
172 if let Some(rev) = &binding.content_rev {
173 roots.bindings.insert((tracker.clone(), rev.clone()));
174 }
175 }
176 }
177
178 let live_slugs: BTreeSet<&str> =
179 branches.iter().map(|branch| branch.slug.as_str()).collect();
180 let mut live_claims: BTreeSet<(String, String)> = BTreeSet::new();
181 let mut archived_claims: BTreeSet<(String, String)> = BTreeSet::new();
182 for slug in store.checkpointed_slugs()? {
183 let claims = if live_slugs.contains(slug.as_str()) {
184 &mut live_claims
185 } else if archived == ArchivedCheckpoints::Purge {
186 continue;
187 } else {
188 &mut archived_claims
189 };
190 let log = CheckpointLog::new(store.checkpoint_dir(&slug), &slug);
191 for record in log.list()? {
192 for state in &record.tracker_states {
193 if let Some(rev) = &state.content_rev {
194 claims.insert((state.name.clone(), rev.clone()));
195 }
196 }
197 for state in &record.resource_states {
198 if let Some((tracker, rev)) =
199 state.state_ref.as_deref().and_then(parse_tracker_state_ref)
200 {
201 claims.insert((tracker.to_owned(), rev.to_owned()));
202 }
203 }
204 }
205 }
206 roots.archived_checkpoints = archived_claims.difference(&live_claims).cloned().collect();
209 roots.checkpoints = live_claims.union(&archived_claims).cloned().collect();
210
211 for lane in lane_names(&store.paths().snapshots)? {
212 if let Some(head) =
213 crate::lane::TrackerLane::new(&store.paths().snapshots, &lane).latest()
214 {
215 roots.lane_heads.insert((lane, head));
216 }
217 }
218
219 Ok(roots)
220 }
221
222 pub fn contains(&self, tracker: &str, rev: &str) -> bool {
223 let key = (tracker.to_owned(), rev.to_owned());
224 self.bindings.contains(&key)
225 || self.checkpoints.contains(&key)
226 || self.lane_heads.contains(&key)
227 }
228
229 pub fn pinned_only_by_checkpoints(&self) -> impl Iterator<Item = &(String, String)> {
232 self.checkpoints
233 .iter()
234 .filter(|key| !self.bindings.contains(*key) && !self.lane_heads.contains(*key))
235 }
236
237 pub fn pinned_only_by_archived_checkpoints(&self) -> impl Iterator<Item = &(String, String)> {
240 self.pinned_only_by_checkpoints()
241 .filter(|key| self.archived_checkpoints.contains(*key))
242 }
243}
244
245fn parse_tracker_state_ref(state_ref: &str) -> Option<(&str, &str)> {
247 state_ref.strip_prefix("tracker:")?.split_once('@')
248}
249
250pub fn lane_names(snapshots_root: &Utf8Path) -> Result<Vec<String>> {
251 Ok(read_subdirs_sorted(snapshots_root)?
252 .iter()
253 .filter_map(|dir| dir.file_name().map(ToOwned::to_owned))
254 .collect())
255}
256
257pub fn lane_revs(snapshots_root: &Utf8Path) -> Result<Vec<LaneRev>> {
260 let mut revs = Vec::new();
261 for tracker in lane_names(snapshots_root)? {
262 for dir in read_subdirs_sorted(&snapshots_root.join(&tracker))? {
263 let Some(name) = dir.file_name() else {
264 continue;
265 };
266 let (rev, is_staging) = match name.strip_suffix(".tmp") {
267 Some(rev) => (rev.to_owned(), true),
268 None => (name.to_owned(), false),
269 };
270 revs.push(LaneRev {
271 tracker: tracker.clone(),
272 rev,
273 path: dir,
274 is_staging,
275 });
276 }
277 }
278 Ok(revs)
279}
280
281pub fn orphan_workspaces(
292 workspace_root: &Utf8Path,
293 branches: &[BranchInstance],
294) -> Result<(Vec<Utf8PathBuf>, Vec<String>)> {
295 let claimed: BTreeSet<&Utf8Path> = branches
296 .iter()
297 .map(|branch| branch.workspace_path.as_path())
298 .collect();
299
300 let mut orphans = Vec::new();
301 let mut warnings = Vec::new();
302 for dir in read_subdirs_sorted(workspace_root)? {
303 if claimed.contains(dir.as_path()) {
304 continue;
305 }
306 if workspace_marker_path(&dir).is_file() || is_empty_dir(&dir)? {
307 orphans.push(dir);
308 } else {
309 warnings.push(format!(
310 "{dir} sits under the workspace root but has no binding record and no newgit \
311 workspace marker, so newgit did not remove it — delete it yourself if it is junk"
312 ));
313 }
314 }
315 Ok((orphans, warnings))
316}
317
318fn is_empty_dir(path: &Utf8Path) -> Result<bool> {
319 let mut entries = std::fs::read_dir(path).map_err(|source| NewgitError::io(path, source))?;
320 Ok(entries.next().is_none())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn tracker_state_refs_parse_and_others_are_ignored() {
329 assert_eq!(
330 parse_tracker_state_ref("tracker:db-snapshots@77e10b2c4451"),
331 Some(("db-snapshots", "77e10b2c4451"))
332 );
333 assert_eq!(parse_tracker_state_ref("hash:9921aa04d2e1"), None);
334 assert_eq!(parse_tracker_state_ref("pv_9"), None);
335 }
336
337 #[test]
338 fn ownership_gates_per_branch_teardown() {
339 assert!(may_tear_down(Ownership::Branch));
340 assert!(may_tear_down(Ownership::Workspace));
341 assert!(may_tear_down(Ownership::External));
342 assert!(!may_tear_down(Ownership::Project));
343 assert!(!may_tear_down(Ownership::User));
344 }
345
346 #[test]
347 fn lane_revs_flag_staging_directories() {
348 let temp = tempfile::tempdir().expect("tempdir");
349 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
350 std::fs::create_dir_all(root.join("runtime-env/abc123")).expect("mkdir");
351 std::fs::create_dir_all(root.join("runtime-env/def456.tmp")).expect("mkdir");
352 std::fs::write(root.join("runtime-env/LATEST"), "abc123\n").expect("write");
353
354 let revs = lane_revs(&root).expect("lane revs");
355 assert_eq!(revs.len(), 2, "LATEST is a file, not a rev");
356 assert_eq!(revs[0].rev, "abc123");
357 assert!(!revs[0].is_staging);
358 assert_eq!(revs[1].rev, "def456");
359 assert!(revs[1].is_staging);
360 }
361}