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 pruned: Vec<PrunedRev>,
25 pub pinned_by_checkpoints: usize,
29 pub warnings: Vec<String>,
30}
31
32impl CleanupOutcome {
33 pub fn is_empty(&self) -> bool {
34 self.finalized.is_empty()
35 && self.orphan_workspaces.is_empty()
36 && self.dead_state.is_empty()
37 && self.pruned.is_empty()
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FinalizedInstance {
43 pub name: String,
44 pub workspace: Utf8PathBuf,
45 pub hooks: Vec<HookOutcome>,
46 pub archived_record: Option<Utf8PathBuf>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct HookOutcome {
53 pub resource: String,
54 pub ownership: Ownership,
55 pub detail: HookDetail,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum HookDetail {
60 Ran {
61 command: String,
62 ok: bool,
63 log: Utf8PathBuf,
64 },
65 WouldRun(String),
67 SkippedOwnership,
69 NoHook,
71 SkippedUnresolved {
74 command: String,
75 placeholder: String,
76 },
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PrunedRev {
81 pub tracker: String,
82 pub rev: String,
83 pub path: Utf8PathBuf,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct LaneRev {
89 pub tracker: String,
90 pub rev: String,
91 pub path: Utf8PathBuf,
92 pub is_staging: bool,
94}
95
96pub fn may_tear_down(ownership: Ownership) -> bool {
100 ownership.per_branch_teardown_may_touch()
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct SnapshotRoots {
112 pub bindings: BTreeSet<(String, String)>,
114 pub checkpoints: BTreeSet<(String, String)>,
116 pub lane_heads: BTreeSet<(String, String)>,
118}
119
120impl SnapshotRoots {
121 pub fn collect(store: &MetadataStore, branches: &[BranchInstance]) -> Result<Self> {
125 let mut roots = Self::default();
126
127 for branch in branches {
128 for (tracker, binding) in &branch.trackers {
129 if let Some(rev) = &binding.content_rev {
130 roots.bindings.insert((tracker.clone(), rev.clone()));
131 }
132 }
133 }
134
135 for slug in store.checkpointed_slugs()? {
136 let log = CheckpointLog::new(store.checkpoint_dir(&slug), &slug);
137 for record in log.list()? {
138 for state in &record.tracker_states {
139 if let Some(rev) = &state.content_rev {
140 roots.checkpoints.insert((state.name.clone(), rev.clone()));
141 }
142 }
143 for state in &record.resource_states {
144 if let Some((tracker, rev)) =
145 state.state_ref.as_deref().and_then(parse_tracker_state_ref)
146 {
147 roots
148 .checkpoints
149 .insert((tracker.to_owned(), rev.to_owned()));
150 }
151 }
152 }
153 }
154
155 for lane in lane_names(&store.paths().snapshots)? {
156 if let Some(head) =
157 crate::lane::TrackerLane::new(&store.paths().snapshots, &lane).latest()
158 {
159 roots.lane_heads.insert((lane, head));
160 }
161 }
162
163 Ok(roots)
164 }
165
166 pub fn contains(&self, tracker: &str, rev: &str) -> bool {
167 let key = (tracker.to_owned(), rev.to_owned());
168 self.bindings.contains(&key)
169 || self.checkpoints.contains(&key)
170 || self.lane_heads.contains(&key)
171 }
172
173 pub fn pinned_only_by_checkpoints(&self) -> impl Iterator<Item = &(String, String)> {
176 self.checkpoints
177 .iter()
178 .filter(|key| !self.bindings.contains(*key) && !self.lane_heads.contains(*key))
179 }
180}
181
182fn parse_tracker_state_ref(state_ref: &str) -> Option<(&str, &str)> {
184 state_ref.strip_prefix("tracker:")?.split_once('@')
185}
186
187pub fn lane_names(snapshots_root: &Utf8Path) -> Result<Vec<String>> {
188 Ok(read_subdirs_sorted(snapshots_root)?
189 .iter()
190 .filter_map(|dir| dir.file_name().map(ToOwned::to_owned))
191 .collect())
192}
193
194pub fn lane_revs(snapshots_root: &Utf8Path) -> Result<Vec<LaneRev>> {
197 let mut revs = Vec::new();
198 for tracker in lane_names(snapshots_root)? {
199 for dir in read_subdirs_sorted(&snapshots_root.join(&tracker))? {
200 let Some(name) = dir.file_name() else {
201 continue;
202 };
203 let (rev, is_staging) = match name.strip_suffix(".tmp") {
204 Some(rev) => (rev.to_owned(), true),
205 None => (name.to_owned(), false),
206 };
207 revs.push(LaneRev {
208 tracker: tracker.clone(),
209 rev,
210 path: dir,
211 is_staging,
212 });
213 }
214 }
215 Ok(revs)
216}
217
218pub fn orphan_workspaces(
229 workspace_root: &Utf8Path,
230 branches: &[BranchInstance],
231) -> Result<(Vec<Utf8PathBuf>, Vec<String>)> {
232 let claimed: BTreeSet<&Utf8Path> = branches
233 .iter()
234 .map(|branch| branch.workspace_path.as_path())
235 .collect();
236
237 let mut orphans = Vec::new();
238 let mut warnings = Vec::new();
239 for dir in read_subdirs_sorted(workspace_root)? {
240 if claimed.contains(dir.as_path()) {
241 continue;
242 }
243 if workspace_marker_path(&dir).is_file() || is_empty_dir(&dir)? {
244 orphans.push(dir);
245 } else {
246 warnings.push(format!(
247 "{dir} sits under the workspace root but has no binding record and no newgit \
248 workspace marker, so newgit did not remove it — delete it yourself if it is junk"
249 ));
250 }
251 }
252 Ok((orphans, warnings))
253}
254
255fn is_empty_dir(path: &Utf8Path) -> Result<bool> {
256 let mut entries = std::fs::read_dir(path).map_err(|source| NewgitError::io(path, source))?;
257 Ok(entries.next().is_none())
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn tracker_state_refs_parse_and_others_are_ignored() {
266 assert_eq!(
267 parse_tracker_state_ref("tracker:db-snapshots@77e10b2c4451"),
268 Some(("db-snapshots", "77e10b2c4451"))
269 );
270 assert_eq!(parse_tracker_state_ref("hash:9921aa04d2e1"), None);
271 assert_eq!(parse_tracker_state_ref("pv_9"), None);
272 }
273
274 #[test]
275 fn ownership_gates_per_branch_teardown() {
276 assert!(may_tear_down(Ownership::Branch));
277 assert!(may_tear_down(Ownership::Workspace));
278 assert!(may_tear_down(Ownership::External));
279 assert!(!may_tear_down(Ownership::Project));
280 assert!(!may_tear_down(Ownership::User));
281 }
282
283 #[test]
284 fn lane_revs_flag_staging_directories() {
285 let temp = tempfile::tempdir().expect("tempdir");
286 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
287 std::fs::create_dir_all(root.join("runtime-env/abc123")).expect("mkdir");
288 std::fs::create_dir_all(root.join("runtime-env/def456.tmp")).expect("mkdir");
289 std::fs::write(root.join("runtime-env/LATEST"), "abc123\n").expect("write");
290
291 let revs = lane_revs(&root).expect("lane revs");
292 assert_eq!(revs.len(), 2, "LATEST is a file, not a rev");
293 assert_eq!(revs[0].rev, "abc123");
294 assert!(!revs[0].is_staging);
295 assert_eq!(revs[1].rev, "def456");
296 assert!(revs[1].is_staging);
297 }
298}