Skip to main content

cadvm_core/
gc.rs

1//! Garbage collection: drop objects unreachable from any ref.
2//!
3//! Conservative by design — `plan` only *reports* what is unreferenced; objects
4//! are removed only when `prune` is explicitly requested.
5
6use std::collections::HashSet;
7
8use cadvm_store::{Category, ObjectId};
9
10use crate::error::Result;
11use crate::repo::{Head, Repository};
12
13/// What GC found: the unreferenced objects in each category.
14#[derive(Debug, Clone, Default)]
15pub struct GcPlan {
16    pub commits: Vec<ObjectId>,
17    pub manifests: Vec<ObjectId>,
18    pub blobs: Vec<ObjectId>,
19    pub chunks: Vec<ObjectId>,
20}
21
22impl GcPlan {
23    /// Total number of objects that would be removed.
24    pub fn total(&self) -> usize {
25        self.commits.len() + self.manifests.len() + self.blobs.len() + self.chunks.len()
26    }
27
28    pub fn is_empty(&self) -> bool {
29        self.total() == 0
30    }
31}
32
33/// The set of objects reachable from every ref (and a detached HEAD).
34#[derive(Debug, Default)]
35struct Reachable {
36    commits: HashSet<ObjectId>,
37    manifests: HashSet<ObjectId>,
38    blobs: HashSet<ObjectId>,
39    chunks: HashSet<ObjectId>,
40}
41
42fn reachable(repo: &Repository) -> Result<Reachable> {
43    let mut seen = Reachable::default();
44
45    // Seed from all branch tips and a detached HEAD.
46    let mut frontier: Vec<ObjectId> = Vec::new();
47    for branch in repo.list_branches()? {
48        if let Some(id) = repo.read_ref(&branch)? {
49            frontier.push(id);
50        }
51    }
52    if let Head::Detached(id) = repo.read_head()? {
53        frontier.push(id);
54    }
55
56    // Walk the full commit DAG (all parents), collecting referenced objects.
57    while let Some(commit_id) = frontier.pop() {
58        if !seen.commits.insert(commit_id.clone()) {
59            continue;
60        }
61        let commit = repo.read_commit(&commit_id)?;
62        seen.manifests.insert(commit.manifest.clone());
63        let manifest = repo.read_manifest(&commit.manifest)?;
64        for entry in manifest.files.values() {
65            // V2 stores file content chunk-only: `raw_hash` is just an identity,
66            // not a stored blob, so it is deliberately NOT treated as a live blob.
67            // This lets gc reclaim raw blobs written by the legacy V1 scheme.
68            for chunk in &entry.blob_ref.chunks {
69                seen.chunks.insert(chunk.hash.clone());
70            }
71        }
72        for parent in commit.parents {
73            frontier.push(parent);
74        }
75    }
76
77    Ok(seen)
78}
79
80/// Compute which stored objects are unreferenced.
81pub fn plan(repo: &Repository) -> Result<GcPlan> {
82    let live = reachable(repo)?;
83    let store = repo.store();
84
85    let unref = |cat: Category, live: &HashSet<ObjectId>| -> Result<Vec<ObjectId>> {
86        Ok(store
87            .list(cat)?
88            .into_iter()
89            .filter(|id| !live.contains(id))
90            .collect())
91    };
92
93    Ok(GcPlan {
94        commits: unref(Category::Commit, &live.commits)?,
95        manifests: unref(Category::Manifest, &live.manifests)?,
96        blobs: unref(Category::Blob, &live.blobs)?,
97        chunks: unref(Category::Chunk, &live.chunks)?,
98    })
99}
100
101/// Delete every object named in `plan`. Returns the number removed.
102pub fn prune(repo: &Repository, plan: &GcPlan) -> Result<usize> {
103    let store = repo.store();
104    let mut removed = 0usize;
105    for id in &plan.commits {
106        removed += store.remove(Category::Commit, id)? as usize;
107    }
108    for id in &plan.manifests {
109        removed += store.remove(Category::Manifest, id)? as usize;
110    }
111    for id in &plan.blobs {
112        removed += store.remove(Category::Blob, id)? as usize;
113    }
114    for id in &plan.chunks {
115        removed += store.remove(Category::Chunk, id)? as usize;
116    }
117    Ok(removed)
118}