1use std::collections::BTreeSet;
32use std::fs;
33use std::io;
34use std::path::Path;
35
36use crate::hash::{self, Hash};
37use crate::index;
38use crate::layout::RepoLayout;
39use crate::store::{ObjectStore, StoreError};
40
41use super::conflict_state;
42use super::graph::reachable_closure_checked;
43use super::rebase;
44use super::recovery;
45use super::stash;
46use crate::refs::{self, HEADS_DIR, REMOTES_DIR, TAGS_DIR};
47
48const ATTESTATIONS_DIR: &str = "attestations";
52
53const MAX_REF_WALK_DEPTH: usize = 64;
57
58#[derive(Debug, thiserror::Error)]
65#[non_exhaustive]
66pub enum GcRootsError {
67 #[error("refs: {0}")]
68 Refs(#[from] refs::RefError),
69 #[error("stash: {0}")]
70 Stash(#[from] stash::StashError),
71 #[error("conflict state: {0}")]
72 ConflictState(#[from] conflict_state::ConflictStateError),
73 #[error("rebase state: {0}")]
74 Rebase(#[from] rebase::RebaseError),
75 #[error("recovery log: {0}")]
76 Recovery(#[from] recovery::RecoveryError),
77 #[error("staging index: {0}")]
78 Index(#[from] index::IndexError),
79 #[error("object store: {0}")]
80 Store(#[from] StoreError),
81 #[error("malformed object id on disk: {0}")]
82 BadHash(#[from] hash::FromHexError),
83 #[error("io: {0}")]
84 Io(#[from] io::Error),
85 #[error("object graph exceeds the reachability cap; refusing to compute a partial keep-set")]
89 Truncated,
90 #[error("ref tree too deep at {0} (fail closed)")]
92 RefTooDeep(String),
93 #[error("refusing to gc: {0} is a symlink (objects may live outside the repo)")]
97 SymlinkedStore(String),
98 #[error("worktree registry: {0}")]
102 Worktrees(#[from] crate::layout::DiscoverError),
103}
104
105pub fn collect_roots(layout: &RepoLayout) -> Result<BTreeSet<Hash>, GcRootsError> {
117 let mut roots: BTreeSet<Hash> = BTreeSet::new();
118 let add = |h: Hash, set: &mut BTreeSet<Hash>| {
119 if h != hash::ZERO {
120 set.insert(h);
121 }
122 };
123
124 for ns in [HEADS_DIR, TAGS_DIR, REMOTES_DIR] {
133 walk_ref_roots_strict(&layout.common_dir().join(ns), ns, 0, &mut roots)?;
134 }
135
136 for tree in crate::layout::all_state_layouts(layout)? {
143 collect_tree_roots(&tree, &add, &mut roots)?;
144 }
145
146 for h in attested_commits(layout.common_dir())? {
148 add(h, &mut roots);
149 }
150
151 for h in recovery::roots(layout)? {
156 add(h, &mut roots);
157 }
158
159 Ok(roots)
160}
161
162fn collect_tree_roots(
166 tree: &crate::layout::RepoLayout,
167 add: &impl Fn(Hash, &mut BTreeSet<Hash>),
168 roots: &mut BTreeSet<Hash>,
169) -> Result<(), GcRootsError> {
170 if let Some(h) = refs::resolve_head(tree)? {
172 add(h, roots);
173 }
174
175 for entry in stash::list(tree)?.entries {
177 add(entry.commit_hash, roots);
178 add(entry.parent_hash, roots);
179 }
180
181 for entry in index::read_index(tree)?.entries {
188 add(entry.object_hash, roots);
189 }
190
191 if let Some(h) = read_optional_hash(&tree.orig_head_file())? {
193 add(h, roots);
194 }
195
196 if let Some(m) = conflict_state::read_merge_state(tree)? {
198 add(m.merge_head, roots);
199 add(m.orig_head, roots);
200 }
201 if let Some(c) = conflict_state::read_cherry_pick_state(tree)? {
202 add(c.cherry_pick_head, roots);
203 add(c.orig_head, roots);
204 }
205 if let Some(r) = conflict_state::read_revert_state(tree)? {
206 add(r.revert_head, roots);
207 add(r.orig_head, roots);
208 }
209
210 if rebase::is_rebase_in_progress(tree) {
213 let st = rebase::read_state(tree)?;
214 add(st.orig_head, roots);
215 add(st.onto, roots);
216 for h in st.todo.into_iter().chain(st.done) {
217 add(h, roots);
218 }
219 }
220
221 for dir in [tree.worktree_state_dir().to_path_buf(), tree.rebase_dir()] {
227 for c in conflict_state::read_conflicts(&dir)? {
228 for h in [c.base_hash, c.ours_hash, c.theirs_hash]
229 .into_iter()
230 .flatten()
231 {
232 add(h, roots);
233 }
234 }
235 }
236 Ok(())
237}
238
239pub fn live_objects(
254 store: &ObjectStore,
255 layout: &RepoLayout,
256) -> Result<BTreeSet<Hash>, GcRootsError> {
257 let roots = collect_roots(layout)?;
258 let (live, truncated) = reachable_closure_checked(store, roots.iter())?;
259 if truncated {
260 return Err(GcRootsError::Truncated);
261 }
262 Ok(live)
263}
264
265#[derive(Debug, Default, Clone, Copy)]
267pub struct GcReport {
268 pub scanned: usize,
270 pub live: usize,
272 pub kept_recent: usize,
275 pub pruned: usize,
277 pub bytes_reclaimed: u64,
279 pub dry_run: bool,
281}
282
283pub fn run_gc(
298 store: &ObjectStore,
299 layout: &RepoLayout,
300 now_secs: u64,
301 grace_secs: u64,
302 dry_run: bool,
303) -> Result<GcReport, GcRootsError> {
304 reject_symlink(layout.common_dir())?;
309 reject_symlink(store.objects_root())?;
310
311 let live = live_objects(store, layout)?;
313 let all = store.iter_object_hashes()?;
314
315 let mut report = GcReport {
316 dry_run,
317 ..GcReport::default()
318 };
319 for h in all {
320 report.scanned += 1;
321 if live.contains(&h) {
322 report.live += 1;
323 continue;
324 }
325 let Ok(meta) = store.object_metadata(&h) else {
329 report.kept_recent += 1;
330 continue;
331 };
332 let age_known_old = meta
333 .modified()
334 .ok()
335 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
336 .map(|d| d.as_secs())
337 .is_some_and(|mtime| now_secs.saturating_sub(mtime) >= grace_secs);
338 if !age_known_old {
339 report.kept_recent += 1;
340 continue;
341 }
342 let len = meta.len();
343 if !dry_run {
344 store.remove_object(&h)?;
345 }
346 report.pruned += 1;
347 report.bytes_reclaimed += len;
348 }
349 Ok(report)
350}
351
352fn walk_ref_roots_strict(
362 dir: &Path,
363 rel: &str,
364 depth: usize,
365 roots: &mut BTreeSet<Hash>,
366) -> Result<(), GcRootsError> {
367 if depth > MAX_REF_WALK_DEPTH {
368 return Err(GcRootsError::RefTooDeep(rel.to_owned()));
369 }
370 let rd = match fs::read_dir(dir) {
371 Ok(rd) => rd,
372 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
373 Err(e) => return Err(e.into()),
374 };
375 for entry in rd {
376 let entry = entry?;
377 let name = entry.file_name();
378 let name = name.to_string_lossy();
379 let ft = entry.file_type()?;
380 if ft.is_dir() {
381 walk_ref_roots_strict(&entry.path(), &format!("{rel}/{name}"), depth + 1, roots)?;
382 continue;
383 }
384 if !ft.is_file() || name.starts_with('.') {
385 continue;
387 }
388 let raw = fs::read_to_string(entry.path())?;
390 let h = hash::from_hex(raw.trim())?;
391 if h != hash::ZERO {
392 roots.insert(h);
393 }
394 }
395 Ok(())
396}
397
398fn attested_commits(common_dir: &Path) -> Result<Vec<Hash>, io::Error> {
402 let dir = common_dir.join(ATTESTATIONS_DIR);
403 let mut out = Vec::new();
404 let rd = match fs::read_dir(&dir) {
405 Ok(rd) => rd,
406 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
407 Err(e) => return Err(e),
408 };
409 for entry in rd {
410 let entry = entry?;
411 if !entry.file_type()?.is_dir() {
412 continue;
413 }
414 if let Some(name) = entry.file_name().to_str()
415 && let Ok(h) = hash::from_hex(name)
416 {
417 out.push(h);
418 }
419 }
420 Ok(out)
421}
422
423fn read_optional_hash(path: &Path) -> Result<Option<Hash>, GcRootsError> {
426 let raw = match fs::read_to_string(path) {
427 Ok(s) => s,
428 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
429 Err(e) => return Err(e.into()),
430 };
431 let trimmed = raw.trim();
432 if trimmed.is_empty() {
433 return Ok(None);
434 }
435 Ok(Some(hash::from_hex(trimmed)?))
436}
437
438fn reject_symlink(path: &Path) -> Result<(), GcRootsError> {
445 match std::fs::symlink_metadata(path) {
446 Ok(m) if m.file_type().is_symlink() => {
447 Err(GcRootsError::SymlinkedStore(path.display().to_string()))
448 }
449 Ok(_) => Ok(()),
450 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
451 Err(e) => Err(e.into()),
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::object::EntryMode;
459 use crate::object::{Blob, Commit, Identity, Object, Tree, TreeEntry};
460 use crate::serialize;
461 use std::fs;
462 use tempfile::TempDir;
463
464 fn repo() -> (TempDir, ObjectStore) {
466 let d = TempDir::new().unwrap();
467 let store = ObjectStore::init(&RepoLayout::single(d.path())).unwrap();
468 refs::init(&RepoLayout::single(d.path())).unwrap();
469 (d, store)
470 }
471
472 fn layout(d: &TempDir) -> RepoLayout {
473 RepoLayout::single(d.path())
474 }
475
476 fn write_ref(md: &RepoLayout, rel: &str, h: &Hash) {
479 let path = md.common_dir().join(rel);
480 fs::create_dir_all(path.parent().unwrap()).unwrap();
481 fs::write(path, format!("{}\n", hash::to_hex(h))).unwrap();
482 }
483
484 fn write_blob(s: &ObjectStore, data: &[u8]) -> Hash {
485 s.write(
486 &serialize::serialize(&Object::Blob(Blob {
487 data: data.to_vec(),
488 }))
489 .unwrap(),
490 )
491 .unwrap()
492 }
493
494 fn commit_one(s: &ObjectStore, name: &[u8], data: &[u8], parents: Vec<Hash>) -> (Hash, Hash) {
496 let blob = write_blob(s, data);
497 let tree = s
498 .write(
499 &serialize::serialize(&Object::Tree(Tree {
500 entries: vec![TreeEntry {
501 name: name.to_vec(),
502 mode: EntryMode::Blob,
503 object_hash: blob,
504 }],
505 }))
506 .unwrap(),
507 )
508 .unwrap();
509 let commit = s
510 .write(
511 &serialize::serialize(&Object::Commit(Commit {
512 tree_hash: tree,
513 parents,
514 author: Identity::opaque(b"t".to_vec()),
515 signer: [0u8; 32],
516 message: name.to_vec(),
517 timestamp: name.len() as u64,
519 message_hash: [0u8; 32],
520 content_digest: [0u8; 32],
521 signature: [0u8; 64],
522 }))
523 .unwrap(),
524 )
525 .unwrap();
526 (commit, blob)
527 }
528
529 #[test]
530 fn collect_roots_includes_branches_and_tags() {
531 let (d, s) = repo();
532 let md = layout(&d);
533 let (c1, _) = commit_one(&s, b"a", b"a", vec![]);
534 let (c2, _) = commit_one(&s, b"b", b"b", vec![]);
535 write_ref(&md, "refs/heads/main", &c1);
536 write_ref(&md, "refs/tags/v1", &c2);
537
538 let roots = collect_roots(&md).unwrap();
539 assert!(roots.contains(&c1), "branch tip must be a root");
540 assert!(roots.contains(&c2), "tag target must be a root");
541 }
542
543 #[test]
544 fn collect_roots_includes_orig_head_and_attested_commit() {
545 let (d, s) = repo();
546 let md = layout(&d);
547 let (orig, _) = commit_one(&s, b"o", b"o", vec![]);
548 let (att, _) = commit_one(&s, b"x", b"x", vec![]);
549 fs::write(md.orig_head_file(), format!("{}\n", hash::to_hex(&orig))).unwrap();
550 fs::create_dir_all(md.attestations_dir().join(hash::to_hex(&att))).unwrap();
551
552 let roots = collect_roots(&md).unwrap();
553 assert!(roots.contains(&orig), "ORIG_HEAD must be a root");
554 assert!(roots.contains(&att), "attested commit must be a root");
555 }
556
557 #[test]
558 fn live_objects_keeps_only_reachable_closure() {
559 let (d, s) = repo();
560 let md = layout(&d);
561 let (kept, kept_blob) = commit_one(&s, b"keep", b"keep", vec![]);
562 let (orphan, orphan_blob) = commit_one(&s, b"orphan", b"orphan", vec![]);
564 write_ref(&md, "refs/heads/main", &kept);
565
566 let live = live_objects(&s, &md).unwrap();
567 assert!(
568 live.contains(&kept) && live.contains(&kept_blob),
569 "kept closure live"
570 );
571 assert!(
572 !live.contains(&orphan) && !live.contains(&orphan_blob),
573 "unreferenced objects must not be live"
574 );
575 }
576
577 fn live_objects_with_cap(
582 store: &ObjectStore,
583 layout: &RepoLayout,
584 cap: usize,
585 ) -> Result<BTreeSet<Hash>, GcRootsError> {
586 let roots = collect_roots(layout)?;
587 let (live, truncated) =
588 super::super::graph::reachable_closure_checked_with_cap(store, roots.iter(), cap)?;
589 if truncated {
590 return Err(GcRootsError::Truncated);
591 }
592 Ok(live)
593 }
594
595 #[test]
596 fn live_objects_aborts_truncated_when_closure_exceeds_cap() {
597 let (d, s) = repo();
604 let md = layout(&d);
605 let (c1, _) = commit_one(&s, b"a", b"a", vec![]);
606 let (c2, _) = commit_one(&s, b"b", b"b", vec![c1]);
607 write_ref(&md, "refs/heads/main", &c2);
608
609 let err = live_objects_with_cap(&s, &md, 2).unwrap_err();
610 assert!(matches!(err, GcRootsError::Truncated));
611
612 let live = live_objects_with_cap(&s, &md, 1000).unwrap();
615 assert!(live.contains(&c1) && live.contains(&c2));
616 }
617
618 #[test]
619 fn reachable_closure_is_union_of_single_root_closures() {
620 let (_d, s) = repo();
621 let (c1, b1) = commit_one(&s, b"a", b"a", vec![]);
622 let (c2, b2) = commit_one(&s, b"b", b"b", vec![]);
623 let multi = super::super::graph::reachable_closure(&s, [&c1, &c2]).unwrap();
624 let single1 = super::super::graph::reachable_objects(&s, &c1).unwrap();
625 let single2 = super::super::graph::reachable_objects(&s, &c2).unwrap();
626 let union: BTreeSet<Hash> = single1.union(&single2).copied().collect();
627 assert_eq!(multi, union);
628 assert!([c1, b1, c2, b2].iter().all(|h| multi.contains(h)));
629 }
630
631 #[test]
632 fn strict_walk_picks_up_nested_remote_ref() {
633 let (d, s) = repo();
634 let md = layout(&d);
635 let (c, _) = commit_one(&s, b"r", b"r", vec![]);
636 write_ref(&md, "refs/remotes/origin/main", &c);
637 assert!(
638 collect_roots(&md).unwrap().contains(&c),
639 "nested remote-tracking ref must be a root"
640 );
641 }
642
643 #[test]
644 fn run_gc_prunes_orphans_but_never_a_live_object() {
645 let (d, s) = repo();
646 let md = layout(&d);
647 let (kept, kept_blob) = commit_one(&s, b"keep", b"keep", vec![]);
649 write_ref(&md, "refs/heads/main", &kept);
650 let live = live_objects(&s, &md).unwrap();
651 let (orphan, orphan_blob) = commit_one(&s, b"orphan", b"orphan", vec![]);
653
654 let report = run_gc(&s, &md, u64::MAX, 0, false).unwrap();
656
657 for h in &live {
659 assert!(s.contains(h), "gc must never delete a live object");
660 }
661 assert!(
663 !s.contains(&orphan) && !s.contains(&orphan_blob),
664 "orphans pruned"
665 );
666 assert_eq!(report.live, live.len());
667 assert!(
668 report.pruned >= 2,
669 "orphan commit + blob pruned: {report:?}"
670 );
671 assert!(s.contains(&kept) && s.contains(&kept_blob));
673 }
674
675 #[test]
676 fn run_gc_keeps_staged_but_uncommitted_blobs() {
677 let (d, s) = repo();
678 let md = layout(&d);
679 let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
681 write_ref(&md, "refs/heads/main", &kept);
682 let staged = write_blob(&s, b"staged-only");
685 let idx = index::Index::from_entries(vec![index::IndexEntry {
686 path: "staged.txt".into(),
687 status: index::EntryStatus::Blob,
688 object_hash: staged,
689 mtime_ns: 0,
690 size: 0,
691 ino: 0,
692 ctime_ns: 0,
693 }]);
694 index::write_index(&md, &idx).unwrap();
695
696 assert!(
697 collect_roots(&md).unwrap().contains(&staged),
698 "staged blob must be a retention root"
699 );
700 run_gc(&s, &md, u64::MAX, 0, false).unwrap();
702 assert!(
703 s.contains(&staged),
704 "gc must never delete staged-but-uncommitted content"
705 );
706 }
707
708 #[test]
709 fn run_gc_grace_window_keeps_recent_orphans() {
710 let (d, s) = repo();
711 let md = layout(&d);
712 let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
713 write_ref(&md, "refs/heads/main", &kept);
714 let (orphan, _) = commit_one(&s, b"o", b"o", vec![]);
715
716 let report = run_gc(&s, &md, 0, u64::MAX, false).unwrap();
719 assert!(s.contains(&orphan), "recent orphan kept by grace window");
720 assert_eq!(report.pruned, 0);
721 assert!(report.kept_recent >= 1, "{report:?}");
722 }
723
724 #[cfg(unix)]
725 #[test]
726 fn run_gc_refuses_symlinked_objects_dir() {
727 use std::os::unix::fs::symlink;
728 let (d, s) = repo();
729 let md = layout(&d);
730 let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
731 write_ref(&md, "refs/heads/main", &kept);
732
733 let external = d.path().join("external-objects");
736 let real_objects = md.objects_dir();
737 fs::create_dir_all(&external).unwrap();
738 for entry in fs::read_dir(&real_objects).unwrap() {
740 let entry = entry.unwrap();
741 fs::rename(entry.path(), external.join(entry.file_name())).unwrap();
742 }
743 fs::remove_dir_all(&real_objects).unwrap();
744 symlink(&external, &real_objects).unwrap();
745
746 let err = run_gc(&s, &md, u64::MAX, 0, false).unwrap_err();
747 assert!(
748 matches!(err, GcRootsError::SymlinkedStore(_)),
749 "gc must refuse a symlinked objects dir, got {err:?}"
750 );
751 }
752
753 #[test]
754 fn run_gc_dry_run_deletes_nothing() {
755 let (d, s) = repo();
756 let md = layout(&d);
757 let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
758 write_ref(&md, "refs/heads/main", &kept);
759 let (orphan, _) = commit_one(&s, b"o", b"o", vec![]);
760
761 let report = run_gc(&s, &md, u64::MAX, 0, true).unwrap();
762 assert!(report.dry_run && report.pruned >= 1, "{report:?}");
763 assert!(s.contains(&orphan), "dry run must not delete the orphan");
764 }
765
766 #[test]
767 fn run_gc_keeps_recovery_logged_orphan() {
768 let (d, s) = repo();
769 let md = layout(&d);
770 let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
771 write_ref(&md, "refs/heads/main", &kept);
772 let (superseded, superseded_blob) = commit_one(&s, b"old", b"old", vec![]);
774 super::super::recovery::record(
775 &md,
776 &super::super::recovery::RecoveryEntry {
777 timestamp: 1,
778 op: "amend".into(),
779 superseded,
780 branch: "main".into(),
781 },
782 )
783 .unwrap();
784
785 run_gc(&s, &md, u64::MAX, 0, false).unwrap();
786 assert!(
787 s.contains(&superseded) && s.contains(&superseded_blob),
788 "a recovery-logged commit must not be pruned"
789 );
790 }
791
792 #[test]
793 fn collect_roots_includes_recovery_log_entries() {
794 let (d, s) = repo();
795 let md = layout(&d);
796 let (superseded, _) = commit_one(&s, b"old", b"old", vec![]);
797 super::super::recovery::record(
798 &md,
799 &super::super::recovery::RecoveryEntry {
800 timestamp: 1,
801 op: "amend".into(),
802 superseded,
803 branch: "main".into(),
804 },
805 )
806 .unwrap();
807 assert!(
808 collect_roots(&md).unwrap().contains(&superseded),
809 "a superseded commit in the recovery log must be a root"
810 );
811 }
812
813 #[test]
814 fn collect_roots_fails_closed_on_malformed_ref() {
815 let (d, _s) = repo();
816 let md = layout(&d);
817 let bad = md.heads_dir().join("corrupt");
820 fs::create_dir_all(bad.parent().unwrap()).unwrap();
821 fs::write(&bad, b"not-a-valid-object-id\n").unwrap();
822 assert!(
823 matches!(collect_roots(&md), Err(GcRootsError::BadHash(_))),
824 "malformed ref must fail closed"
825 );
826 }
827
828 #[test]
829 fn strict_walk_skips_lock_and_dotfile_cruft() {
830 let (d, s) = repo();
831 let md = layout(&d);
832 let (c, _) = commit_one(&s, b"m", b"m", vec![]);
833 write_ref(&md, "refs/heads/main", &c);
834 fs::write(md.heads_dir().join(".main.tmp.123.4"), b"garbage").unwrap();
837 let roots = collect_roots(&md).unwrap();
838 assert!(roots.contains(&c), "real ref still collected past cruft");
839 }
840}