Skip to main content

newgit_core/
cleanup.rs

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/// What one cleanup pass did, or would do under `--dry-run`. Garbage
13/// collection has to be reportable to be trustworthy: every list here is
14/// something that disappeared (or would).
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct CleanupOutcome {
17    pub dry_run: bool,
18    /// Instances whose workspace was gone, so newgit finished the teardown.
19    pub finalized: Vec<FinalizedInstance>,
20    /// Workspace directories with no binding record at all.
21    pub orphan_workspaces: Vec<Utf8PathBuf>,
22    /// Dead PID files and state directories for instances that are gone.
23    pub dead_state: Vec<Utf8PathBuf>,
24    /// Checkpoint logs discarded because their instance is archived and the
25    /// caller asked for it. Empty unless purging was requested.
26    pub purged_checkpoints: Vec<PurgedCheckpoints>,
27    pub pruned: Vec<PrunedRev>,
28    /// Lane revs kept alive solely because a checkpoint still points at
29    /// them — the constraint pruning must never violate, surfaced so the
30    /// retained disk is explained rather than mysterious.
31    pub pinned_by_checkpoints: usize,
32    /// How many of those belong to instances that are already archived — the
33    /// ones `--purge-archived` can release.
34    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/// Whether an operation that archives an instance also discards the
49/// checkpoints it leaves behind. Keeping them is the default: a checkpoint
50/// pins the lane revs its undo would need, and newgit never breaks an undo
51/// on its own initiative. Purging says the undo will never be wanted.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ArchivedCheckpoints {
54    Keep,
55    Purge,
56}
57
58/// One instance's discarded checkpoint history.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PurgedCheckpoints {
61    /// The instance's slug — its binding record is already archived, so this
62    /// is the only name that still exists on disk.
63    pub slug: String,
64    pub checkpoints: usize,
65    /// Store refs the checkpoints held (`refs/newgit/checkpoints/<slug>/*`).
66    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    /// Where the binding record was archived to; None under `--dry-run`.
76    pub archived_record: Option<Utf8PathBuf>,
77}
78
79/// One resource's cleanup hook, and why it did or did not run.
80#[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    /// Would have run, but this was a dry run.
95    WouldRun(String),
96    /// `project` or `user` ownership: shared beyond this instance.
97    SkippedOwnership,
98    /// No `[cleanup] command` defined.
99    NoHook,
100    /// The hook's command still had an unresolved `{{...}}`, so running it
101    /// would have passed a literal placeholder to a destructive command.
102    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/// A tracker lane rev on disk.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct LaneRev {
118    pub tracker: String,
119    pub rev: String,
120    pub path: Utf8PathBuf,
121    /// Staging directory a crashed capture left behind (`<rev>.tmp`).
122    pub is_staging: bool,
123}
124
125/// Whether per-branch teardown may run this resource's cleanup hook at all.
126/// Ownership decides, not the presence of a command: a `user`-owned pnpm
127/// store with a cleanup command must still survive `newgit remove`.
128pub fn may_tear_down(ownership: Ownership) -> bool {
129    ownership.per_branch_teardown_may_touch()
130}
131
132/// Everything that keeps a lane rev alive, kept apart by where the claim
133/// came from so cleanup can explain retained disk instead of just retaining
134/// it.
135///
136/// Checkpoints are roots even for instances whose binding record has been
137/// archived. A checkpoint pointing at a pruned rev is not a smaller store,
138/// it is a broken undo.
139#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct SnapshotRoots {
141    /// Claimed by a surviving instance's tracker binding.
142    pub bindings: BTreeSet<(String, String)>,
143    /// Claimed by any checkpoint record, live or archived.
144    pub checkpoints: BTreeSet<(String, String)>,
145    /// The subset of `checkpoints` claimed only by instances whose binding
146    /// record is gone. These are the claims a purge can release, so cleanup
147    /// can say so instead of reporting retained disk with no way out.
148    pub archived_checkpoints: BTreeSet<(String, String)>,
149    /// Claimed by a lane's own head (`LATEST`), which new instances project.
150    pub lane_heads: BTreeSet<(String, String)>,
151}
152
153impl SnapshotRoots {
154    /// `branches` is the set of instances that survive the cleanup pass, not
155    /// everything on disk — a record about to be archived must not keep its
156    /// unreferenced captures alive.
157    ///
158    /// `archived` says what to do with the checkpoint logs of instances that
159    /// have no surviving record: `Keep` (the default) treats them as roots
160    /// like any other checkpoint, `Purge` ignores them, because the caller is
161    /// discarding them in the same pass — which is what makes a purging dry
162    /// run report exactly what the real run would remove.
163    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        // A rev a live instance's checkpoint also claims is not something a
207        // purge could release, so it does not count as archived-held.
208        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    /// Revs held only because some checkpoint references them — the disk a
230    /// user might otherwise expect cleanup to have reclaimed.
231    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    /// Of those, the ones no live instance's checkpoint also claims: purging
238    /// the archived logs would release exactly these.
239    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
245/// `tracker:<name>@<rev>` — the state ref an `into_tracker` deposit records.
246fn 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
257/// Every rev directory present in every lane, including leftover staging
258/// directories.
259pub 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
281/// Workspace directories under `workspace_root` that no binding record
282/// claims. A workspace is a cache, so an unclaimed one is just garbage —
283/// but only the records can say which ones are claimed.
284///
285/// Deletion is deliberately narrower than "unclaimed": a directory is
286/// removed only if newgit can prove it made it (it carries a workspace
287/// marker) or there is nothing to lose (it is empty). `[workspace] root` is
288/// user-configurable and might point somewhere shared, and "newgit deleted a
289/// directory it did not create" is not a failure mode worth risking to
290/// reclaim disk. Anything else is reported as unrecognized and left alone.
291pub 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}