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    pub pruned: Vec<PrunedRev>,
25    /// Lane revs kept alive solely because a checkpoint still points at
26    /// them — the constraint pruning must never violate, surfaced so the
27    /// retained disk is explained rather than mysterious.
28    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    /// Where the binding record was archived to; None under `--dry-run`.
47    pub archived_record: Option<Utf8PathBuf>,
48}
49
50/// One resource's cleanup hook, and why it did or did not run.
51#[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    /// Would have run, but this was a dry run.
66    WouldRun(String),
67    /// `project` or `user` ownership: shared beyond this instance.
68    SkippedOwnership,
69    /// No `[cleanup] command` defined.
70    NoHook,
71    /// The hook's command still had an unresolved `{{...}}`, so running it
72    /// would have passed a literal placeholder to a destructive command.
73    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/// A tracker lane rev on disk.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct LaneRev {
89    pub tracker: String,
90    pub rev: String,
91    pub path: Utf8PathBuf,
92    /// Staging directory a crashed capture left behind (`<rev>.tmp`).
93    pub is_staging: bool,
94}
95
96/// Whether per-branch teardown may run this resource's cleanup hook at all.
97/// Ownership decides, not the presence of a command: a `user`-owned pnpm
98/// store with a cleanup command must still survive `newgit remove`.
99pub fn may_tear_down(ownership: Ownership) -> bool {
100    ownership.per_branch_teardown_may_touch()
101}
102
103/// Everything that keeps a lane rev alive, kept apart by where the claim
104/// came from so cleanup can explain retained disk instead of just retaining
105/// it.
106///
107/// Checkpoints are roots even for instances whose binding record has been
108/// archived. A checkpoint pointing at a pruned rev is not a smaller store,
109/// it is a broken undo.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct SnapshotRoots {
112    /// Claimed by a surviving instance's tracker binding.
113    pub bindings: BTreeSet<(String, String)>,
114    /// Claimed by any checkpoint record, live or archived.
115    pub checkpoints: BTreeSet<(String, String)>,
116    /// Claimed by a lane's own head (`LATEST`), which new instances project.
117    pub lane_heads: BTreeSet<(String, String)>,
118}
119
120impl SnapshotRoots {
121    /// `branches` is the set of instances that survive the cleanup pass, not
122    /// everything on disk — a record about to be archived must not keep its
123    /// unreferenced captures alive.
124    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    /// Revs held only because some checkpoint references them — the disk a
174    /// user might otherwise expect cleanup to have reclaimed.
175    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
182/// `tracker:<name>@<rev>` — the state ref an `into_tracker` deposit records.
183fn 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
194/// Every rev directory present in every lane, including leftover staging
195/// directories.
196pub 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
218/// Workspace directories under `workspace_root` that no binding record
219/// claims. A workspace is a cache, so an unclaimed one is just garbage —
220/// but only the records can say which ones are claimed.
221///
222/// Deletion is deliberately narrower than "unclaimed": a directory is
223/// removed only if newgit can prove it made it (it carries a workspace
224/// marker) or there is nothing to lose (it is empty). `[workspace] root` is
225/// user-configurable and might point somewhere shared, and "newgit deleted a
226/// directory it did not create" is not a failure mode worth risking to
227/// reclaim disk. Anything else is reported as unrecognized and left alone.
228pub 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}