use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use crate::hash::{self, Hash};
use crate::index;
use crate::layout::RepoLayout;
use crate::store::{ObjectStore, StoreError};
use super::conflict_state;
use super::graph::reachable_closure_checked;
use super::rebase;
use super::recovery;
use super::stash;
use crate::refs::{self, HEADS_DIR, REMOTES_DIR, TAGS_DIR};
const ATTESTATIONS_DIR: &str = "attestations";
const MAX_REF_WALK_DEPTH: usize = 64;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GcRootsError {
#[error("refs: {0}")]
Refs(#[from] refs::RefError),
#[error("stash: {0}")]
Stash(#[from] stash::StashError),
#[error("conflict state: {0}")]
ConflictState(#[from] conflict_state::ConflictStateError),
#[error("rebase state: {0}")]
Rebase(#[from] rebase::RebaseError),
#[error("recovery log: {0}")]
Recovery(#[from] recovery::RecoveryError),
#[error("staging index: {0}")]
Index(#[from] index::IndexError),
#[error("object store: {0}")]
Store(#[from] StoreError),
#[error("malformed object id on disk: {0}")]
BadHash(#[from] hash::FromHexError),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("object graph exceeds the reachability cap; refusing to compute a partial keep-set")]
Truncated,
#[error("ref tree too deep at {0} (fail closed)")]
RefTooDeep(String),
#[error("refusing to gc: {0} is a symlink (objects may live outside the repo)")]
SymlinkedStore(String),
#[error("worktree registry: {0}")]
Worktrees(#[from] crate::layout::DiscoverError),
}
pub fn collect_roots(layout: &RepoLayout) -> Result<BTreeSet<Hash>, GcRootsError> {
let mut roots: BTreeSet<Hash> = BTreeSet::new();
let add = |h: Hash, set: &mut BTreeSet<Hash>| {
if h != hash::ZERO {
set.insert(h);
}
};
for ns in [HEADS_DIR, TAGS_DIR, REMOTES_DIR] {
walk_ref_roots_strict(&layout.common_dir().join(ns), ns, 0, &mut roots)?;
}
for tree in crate::layout::all_state_layouts(layout)? {
collect_tree_roots(&tree, &add, &mut roots)?;
}
for h in attested_commits(layout.common_dir())? {
add(h, &mut roots);
}
for h in recovery::roots(layout)? {
add(h, &mut roots);
}
Ok(roots)
}
fn collect_tree_roots(
tree: &crate::layout::RepoLayout,
add: &impl Fn(Hash, &mut BTreeSet<Hash>),
roots: &mut BTreeSet<Hash>,
) -> Result<(), GcRootsError> {
if let Some(h) = refs::resolve_head(tree)? {
add(h, roots);
}
for entry in stash::list(tree)?.entries {
add(entry.commit_hash, roots);
add(entry.parent_hash, roots);
}
for entry in index::read_index(tree)?.entries {
add(entry.object_hash, roots);
}
if let Some(h) = read_optional_hash(&tree.orig_head_file())? {
add(h, roots);
}
if let Some(m) = conflict_state::read_merge_state(tree)? {
add(m.merge_head, roots);
add(m.orig_head, roots);
}
if let Some(c) = conflict_state::read_cherry_pick_state(tree)? {
add(c.cherry_pick_head, roots);
add(c.orig_head, roots);
}
if let Some(r) = conflict_state::read_revert_state(tree)? {
add(r.revert_head, roots);
add(r.orig_head, roots);
}
if rebase::is_rebase_in_progress(tree) {
let st = rebase::read_state(tree)?;
add(st.orig_head, roots);
add(st.onto, roots);
for h in st.todo.into_iter().chain(st.done) {
add(h, roots);
}
}
for dir in [tree.worktree_state_dir().to_path_buf(), tree.rebase_dir()] {
for c in conflict_state::read_conflicts(&dir)? {
for h in [c.base_hash, c.ours_hash, c.theirs_hash]
.into_iter()
.flatten()
{
add(h, roots);
}
}
}
Ok(())
}
pub fn live_objects(
store: &ObjectStore,
layout: &RepoLayout,
) -> Result<BTreeSet<Hash>, GcRootsError> {
let roots = collect_roots(layout)?;
let (live, truncated) = reachable_closure_checked(store, roots.iter())?;
if truncated {
return Err(GcRootsError::Truncated);
}
Ok(live)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct GcReport {
pub scanned: usize,
pub live: usize,
pub kept_recent: usize,
pub pruned: usize,
pub bytes_reclaimed: u64,
pub dry_run: bool,
}
pub fn run_gc(
store: &ObjectStore,
layout: &RepoLayout,
now_secs: u64,
grace_secs: u64,
dry_run: bool,
) -> Result<GcReport, GcRootsError> {
reject_symlink(layout.common_dir())?;
reject_symlink(store.objects_root())?;
let live = live_objects(store, layout)?;
let all = store.iter_object_hashes()?;
let mut report = GcReport {
dry_run,
..GcReport::default()
};
for h in all {
report.scanned += 1;
if live.contains(&h) {
report.live += 1;
continue;
}
let Ok(meta) = store.object_metadata(&h) else {
report.kept_recent += 1;
continue;
};
let age_known_old = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.is_some_and(|mtime| now_secs.saturating_sub(mtime) >= grace_secs);
if !age_known_old {
report.kept_recent += 1;
continue;
}
let len = meta.len();
if !dry_run {
store.remove_object(&h)?;
}
report.pruned += 1;
report.bytes_reclaimed += len;
}
Ok(report)
}
fn walk_ref_roots_strict(
dir: &Path,
rel: &str,
depth: usize,
roots: &mut BTreeSet<Hash>,
) -> Result<(), GcRootsError> {
if depth > MAX_REF_WALK_DEPTH {
return Err(GcRootsError::RefTooDeep(rel.to_owned()));
}
let rd = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
for entry in rd {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
let ft = entry.file_type()?;
if ft.is_dir() {
walk_ref_roots_strict(&entry.path(), &format!("{rel}/{name}"), depth + 1, roots)?;
continue;
}
if !ft.is_file() || name.starts_with('.') {
continue;
}
let raw = fs::read_to_string(entry.path())?;
let h = hash::from_hex(raw.trim())?;
if h != hash::ZERO {
roots.insert(h);
}
}
Ok(())
}
fn attested_commits(common_dir: &Path) -> Result<Vec<Hash>, io::Error> {
let dir = common_dir.join(ATTESTATIONS_DIR);
let mut out = Vec::new();
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};
for entry in rd {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str()
&& let Ok(h) = hash::from_hex(name)
{
out.push(h);
}
}
Ok(out)
}
fn read_optional_hash(path: &Path) -> Result<Option<Hash>, GcRootsError> {
let raw = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(hash::from_hex(trimmed)?))
}
fn reject_symlink(path: &Path) -> Result<(), GcRootsError> {
match std::fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => {
Err(GcRootsError::SymlinkedStore(path.display().to_string()))
}
Ok(_) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object::EntryMode;
use crate::object::{Blob, Commit, Identity, Object, Tree, TreeEntry};
use crate::serialize;
use std::fs;
use tempfile::TempDir;
fn repo() -> (TempDir, ObjectStore) {
let d = TempDir::new().unwrap();
let store = ObjectStore::init(&RepoLayout::single(d.path())).unwrap();
refs::init(&RepoLayout::single(d.path())).unwrap();
(d, store)
}
fn layout(d: &TempDir) -> RepoLayout {
RepoLayout::single(d.path())
}
fn write_ref(md: &RepoLayout, rel: &str, h: &Hash) {
let path = md.common_dir().join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, format!("{}\n", hash::to_hex(h))).unwrap();
}
fn write_blob(s: &ObjectStore, data: &[u8]) -> Hash {
s.write(
&serialize::serialize(&Object::Blob(Blob {
data: data.to_vec(),
}))
.unwrap(),
)
.unwrap()
}
fn commit_one(s: &ObjectStore, name: &[u8], data: &[u8], parents: Vec<Hash>) -> (Hash, Hash) {
let blob = write_blob(s, data);
let tree = s
.write(
&serialize::serialize(&Object::Tree(Tree {
entries: vec![TreeEntry {
name: name.to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
}))
.unwrap(),
)
.unwrap();
let commit = s
.write(
&serialize::serialize(&Object::Commit(Commit {
tree_hash: tree,
parents,
author: Identity::opaque(b"t".to_vec()),
signer: [0u8; 32],
message: name.to_vec(),
timestamp: name.len() as u64,
message_hash: [0u8; 32],
content_digest: [0u8; 32],
signature: [0u8; 64],
}))
.unwrap(),
)
.unwrap();
(commit, blob)
}
#[test]
fn collect_roots_includes_branches_and_tags() {
let (d, s) = repo();
let md = layout(&d);
let (c1, _) = commit_one(&s, b"a", b"a", vec![]);
let (c2, _) = commit_one(&s, b"b", b"b", vec![]);
write_ref(&md, "refs/heads/main", &c1);
write_ref(&md, "refs/tags/v1", &c2);
let roots = collect_roots(&md).unwrap();
assert!(roots.contains(&c1), "branch tip must be a root");
assert!(roots.contains(&c2), "tag target must be a root");
}
#[test]
fn collect_roots_includes_orig_head_and_attested_commit() {
let (d, s) = repo();
let md = layout(&d);
let (orig, _) = commit_one(&s, b"o", b"o", vec![]);
let (att, _) = commit_one(&s, b"x", b"x", vec![]);
fs::write(md.orig_head_file(), format!("{}\n", hash::to_hex(&orig))).unwrap();
fs::create_dir_all(md.attestations_dir().join(hash::to_hex(&att))).unwrap();
let roots = collect_roots(&md).unwrap();
assert!(roots.contains(&orig), "ORIG_HEAD must be a root");
assert!(roots.contains(&att), "attested commit must be a root");
}
#[test]
fn live_objects_keeps_only_reachable_closure() {
let (d, s) = repo();
let md = layout(&d);
let (kept, kept_blob) = commit_one(&s, b"keep", b"keep", vec![]);
let (orphan, orphan_blob) = commit_one(&s, b"orphan", b"orphan", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let live = live_objects(&s, &md).unwrap();
assert!(
live.contains(&kept) && live.contains(&kept_blob),
"kept closure live"
);
assert!(
!live.contains(&orphan) && !live.contains(&orphan_blob),
"unreferenced objects must not be live"
);
}
fn live_objects_with_cap(
store: &ObjectStore,
layout: &RepoLayout,
cap: usize,
) -> Result<BTreeSet<Hash>, GcRootsError> {
let roots = collect_roots(layout)?;
let (live, truncated) =
super::super::graph::reachable_closure_checked_with_cap(store, roots.iter(), cap)?;
if truncated {
return Err(GcRootsError::Truncated);
}
Ok(live)
}
#[test]
fn live_objects_aborts_truncated_when_closure_exceeds_cap() {
let (d, s) = repo();
let md = layout(&d);
let (c1, _) = commit_one(&s, b"a", b"a", vec![]);
let (c2, _) = commit_one(&s, b"b", b"b", vec![c1]);
write_ref(&md, "refs/heads/main", &c2);
let err = live_objects_with_cap(&s, &md, 2).unwrap_err();
assert!(matches!(err, GcRootsError::Truncated));
let live = live_objects_with_cap(&s, &md, 1000).unwrap();
assert!(live.contains(&c1) && live.contains(&c2));
}
#[test]
fn reachable_closure_is_union_of_single_root_closures() {
let (_d, s) = repo();
let (c1, b1) = commit_one(&s, b"a", b"a", vec![]);
let (c2, b2) = commit_one(&s, b"b", b"b", vec![]);
let multi = super::super::graph::reachable_closure(&s, [&c1, &c2]).unwrap();
let single1 = super::super::graph::reachable_objects(&s, &c1).unwrap();
let single2 = super::super::graph::reachable_objects(&s, &c2).unwrap();
let union: BTreeSet<Hash> = single1.union(&single2).copied().collect();
assert_eq!(multi, union);
assert!([c1, b1, c2, b2].iter().all(|h| multi.contains(h)));
}
#[test]
fn strict_walk_picks_up_nested_remote_ref() {
let (d, s) = repo();
let md = layout(&d);
let (c, _) = commit_one(&s, b"r", b"r", vec![]);
write_ref(&md, "refs/remotes/origin/main", &c);
assert!(
collect_roots(&md).unwrap().contains(&c),
"nested remote-tracking ref must be a root"
);
}
#[test]
fn run_gc_prunes_orphans_but_never_a_live_object() {
let (d, s) = repo();
let md = layout(&d);
let (kept, kept_blob) = commit_one(&s, b"keep", b"keep", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let live = live_objects(&s, &md).unwrap();
let (orphan, orphan_blob) = commit_one(&s, b"orphan", b"orphan", vec![]);
let report = run_gc(&s, &md, u64::MAX, 0, false).unwrap();
for h in &live {
assert!(s.contains(h), "gc must never delete a live object");
}
assert!(
!s.contains(&orphan) && !s.contains(&orphan_blob),
"orphans pruned"
);
assert_eq!(report.live, live.len());
assert!(
report.pruned >= 2,
"orphan commit + blob pruned: {report:?}"
);
assert!(s.contains(&kept) && s.contains(&kept_blob));
}
#[test]
fn run_gc_keeps_staged_but_uncommitted_blobs() {
let (d, s) = repo();
let md = layout(&d);
let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let staged = write_blob(&s, b"staged-only");
let idx = index::Index::from_entries(vec![index::IndexEntry {
path: "staged.txt".into(),
status: index::EntryStatus::Blob,
object_hash: staged,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
}]);
index::write_index(&md, &idx).unwrap();
assert!(
collect_roots(&md).unwrap().contains(&staged),
"staged blob must be a retention root"
);
run_gc(&s, &md, u64::MAX, 0, false).unwrap();
assert!(
s.contains(&staged),
"gc must never delete staged-but-uncommitted content"
);
}
#[test]
fn run_gc_grace_window_keeps_recent_orphans() {
let (d, s) = repo();
let md = layout(&d);
let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let (orphan, _) = commit_one(&s, b"o", b"o", vec![]);
let report = run_gc(&s, &md, 0, u64::MAX, false).unwrap();
assert!(s.contains(&orphan), "recent orphan kept by grace window");
assert_eq!(report.pruned, 0);
assert!(report.kept_recent >= 1, "{report:?}");
}
#[cfg(unix)]
#[test]
fn run_gc_refuses_symlinked_objects_dir() {
use std::os::unix::fs::symlink;
let (d, s) = repo();
let md = layout(&d);
let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let external = d.path().join("external-objects");
let real_objects = md.objects_dir();
fs::create_dir_all(&external).unwrap();
for entry in fs::read_dir(&real_objects).unwrap() {
let entry = entry.unwrap();
fs::rename(entry.path(), external.join(entry.file_name())).unwrap();
}
fs::remove_dir_all(&real_objects).unwrap();
symlink(&external, &real_objects).unwrap();
let err = run_gc(&s, &md, u64::MAX, 0, false).unwrap_err();
assert!(
matches!(err, GcRootsError::SymlinkedStore(_)),
"gc must refuse a symlinked objects dir, got {err:?}"
);
}
#[test]
fn run_gc_dry_run_deletes_nothing() {
let (d, s) = repo();
let md = layout(&d);
let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let (orphan, _) = commit_one(&s, b"o", b"o", vec![]);
let report = run_gc(&s, &md, u64::MAX, 0, true).unwrap();
assert!(report.dry_run && report.pruned >= 1, "{report:?}");
assert!(s.contains(&orphan), "dry run must not delete the orphan");
}
#[test]
fn run_gc_keeps_recovery_logged_orphan() {
let (d, s) = repo();
let md = layout(&d);
let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
write_ref(&md, "refs/heads/main", &kept);
let (superseded, superseded_blob) = commit_one(&s, b"old", b"old", vec![]);
super::super::recovery::record(
&md,
&super::super::recovery::RecoveryEntry {
timestamp: 1,
op: "amend".into(),
superseded,
branch: "main".into(),
},
)
.unwrap();
run_gc(&s, &md, u64::MAX, 0, false).unwrap();
assert!(
s.contains(&superseded) && s.contains(&superseded_blob),
"a recovery-logged commit must not be pruned"
);
}
#[test]
fn collect_roots_includes_recovery_log_entries() {
let (d, s) = repo();
let md = layout(&d);
let (superseded, _) = commit_one(&s, b"old", b"old", vec![]);
super::super::recovery::record(
&md,
&super::super::recovery::RecoveryEntry {
timestamp: 1,
op: "amend".into(),
superseded,
branch: "main".into(),
},
)
.unwrap();
assert!(
collect_roots(&md).unwrap().contains(&superseded),
"a superseded commit in the recovery log must be a root"
);
}
#[test]
fn collect_roots_fails_closed_on_malformed_ref() {
let (d, _s) = repo();
let md = layout(&d);
let bad = md.heads_dir().join("corrupt");
fs::create_dir_all(bad.parent().unwrap()).unwrap();
fs::write(&bad, b"not-a-valid-object-id\n").unwrap();
assert!(
matches!(collect_roots(&md), Err(GcRootsError::BadHash(_))),
"malformed ref must fail closed"
);
}
#[test]
fn strict_walk_skips_lock_and_dotfile_cruft() {
let (d, s) = repo();
let md = layout(&d);
let (c, _) = commit_one(&s, b"m", b"m", vec![]);
write_ref(&md, "refs/heads/main", &c);
fs::write(md.heads_dir().join(".main.tmp.123.4"), b"garbage").unwrap();
let roots = collect_roots(&md).unwrap();
assert!(roots.contains(&c), "real ref still collected past cruft");
}
}