Skip to main content

mkit_core/ops/
gc.rs

1//! GC retention roots — the complete set of object hashes that
2//! `mkit gc` (#233) must treat as live, plus the live-object closure
3//! over them.
4//!
5//! Pruning is only safe if the root set is **complete**: anything gc can
6//! reach from a root is kept; everything else is reclaimable. Missing a
7//! root means deleting a live object, so this collector is deliberately
8//! exhaustive and **fails closed** — if any source can't be read, the
9//! whole collection errors and the caller must abort rather than prune
10//! against an under-counted root set.
11//!
12//! Roots, by source:
13//! - **HEAD** (incl. detached) and every `refs/heads`, `refs/tags`, and
14//!   `refs/remotes/<remote>` ref.
15//! - **Stash** entries — each stashed commit and its recorded parent.
16//! - **In-progress operations** — merge (`MERGE_HEAD`), cherry-pick
17//!   (`CHERRY_PICK_HEAD`), rebase (`onto` + every `todo`/`done` commit),
18//!   the `ORIG_HEAD` saved by those ops and by `reset`, and the conflict
19//!   sidecar's base/ours/theirs blob hashes.
20//! - **Attestations** — every `attestations/<commit>/` directory pins
21//!   its commit so an attested commit is never orphaned.
22//! - **Recovery log** — every commit recorded as superseded by a
23//!   history-rewriting op (see [`super::recovery`]); retained so it stays
24//!   recoverable until `recovery::expire` drops it past the window.
25//!
26//! RECOVERY (#260): commits superseded by `commit --amend`, `reset`, or
27//! `rebase` are unrecoverable from the opaque-digest history journal, so
28//! [`super::recovery`] logs them (the commands record the old tip before
29//! moving the ref) and they are roots here.
30
31use 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
48/// Directory under `.mkit/` holding per-commit attestation envelopes.
49/// Owned here (not in `mkit-attest`) so the core collector stays free of
50/// a reverse crate dependency — it only reads directory *names*.
51const ATTESTATIONS_DIR: &str = "attestations";
52
53/// Depth cap for the strict ref walk. Refs nest by `/` in the name;
54/// anything deeper than this on disk is treated as an error (fail
55/// closed) rather than silently truncated.
56const MAX_REF_WALK_DEPTH: usize = 64;
57
58/// Errors from collecting the retention root set. Every underlying
59/// source error is wrapped so the collector can fail closed.
60///
61/// `#[non_exhaustive]`: new root sources (like the staging index, added
62/// in 0.2.0) come with new variants; downstream matches must keep a
63/// wildcard arm so those additions stay minor-version changes.
64#[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    /// The reachable-object walk hit [`super::graph::MAX_REACHABLE`]
86    /// before completing. The live set is incomplete, so a caller must
87    /// abort rather than treat beyond-cap objects as prunable.
88    #[error("object graph exceeds the reachability cap; refusing to compute a partial keep-set")]
89    Truncated,
90    /// A ref directory nested deeper than `MAX_REF_WALK_DEPTH`.
91    #[error("ref tree too deep at {0} (fail closed)")]
92    RefTooDeep(String),
93    /// `.mkit` or `.mkit/objects` is a symlink. A deletion-capable gc
94    /// refuses, since pruning would follow the link and unlink files
95    /// outside the repo.
96    #[error("refusing to gc: {0} is a symlink (objects may live outside the repo)")]
97    SymlinkedStore(String),
98    /// The linked-worktree registry could not be enumerated. Root
99    /// collection must union EVERY tree's per-tree state (#493 Phase
100    /// 3); a partial view would let a sibling's staged work be pruned.
101    #[error("worktree registry: {0}")]
102    Worktrees(#[from] crate::layout::DiscoverError),
103}
104
105/// Collect the complete set of GC retention roots for the repo at
106/// the repository described by `layout`. The returned hashes are roots,
107/// not the closure — feed them to `reachable_closure` (or use
108/// [`live_objects`]) to get the full keep-set.
109///
110/// The all-zero hash is filtered out (an unset ref / `ORIG_HEAD`).
111///
112/// # Errors
113///
114/// [`GcRootsError`] if any source (refs, stash, op state, attestation
115/// dir) cannot be read — the caller must then abort, never prune.
116pub 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    // Branches, tags, and remote-tracking refs. We deliberately do NOT
125    // use `refs::list_refs`/`list_tags`/`list_remote_refs` here: those
126    // are lenient (they yield `hash: None` for malformed content, skip
127    // unreadable files, and silently stop at a depth cap), which would
128    // let a corrupt ref drop out of the root set while collection still
129    // "succeeds" — exactly the fail-open hole gc cannot tolerate. The
130    // strict walk below errors on any unreadable / undecodable / too-deep
131    // ref instead.
132    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    // Per-tree sources, unioned over EVERY worktree of the repository
137    // (#493 Phase 3): the main tree plus each registered state dir —
138    // including prunable-but-unpruned ones, whose staged work stays
139    // pinned until `worktree prune` explicitly reaps it. Fail closed:
140    // a registry error or an unreadable sibling state aborts
141    // collection entirely.
142    for tree in crate::layout::all_state_layouts(layout)? {
143        collect_tree_roots(&tree, &add, &mut roots)?;
144    }
145
146    // Attested commits — pinned so an attestation never dangles.
147    for h in attested_commits(layout.common_dir())? {
148        add(h, &mut roots);
149    }
150
151    // Recovery log — commits superseded by amend/reset/rebase, retained
152    // so they stay recoverable. Clock-free here: `recovery::expire` (a
153    // gc maintenance step) drops entries past the retention window so
154    // they stop pinning objects.
155    for h in recovery::roots(layout)? {
156        add(h, &mut roots);
157    }
158
159    Ok(roots)
160}
161
162/// One worktree's per-tree retention roots: HEAD, staging index,
163/// `ORIG_HEAD`, in-progress merge/cherry-pick/revert/rebase state,
164/// conflict sidecars, and the tree-local stash.
165fn 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    // HEAD (covers a detached HEAD not present under refs/heads).
171    if let Some(h) = refs::resolve_head(tree)? {
172        add(h, roots);
173    }
174
175    // Stash: each stashed commit and the HEAD it was based on.
176    for entry in stash::list(tree)?.entries {
177        add(entry.commit_hash, roots);
178        add(entry.parent_hash, roots);
179    }
180
181    // Staging index — blobs recorded by `mkit add` but not yet
182    // committed. They are reachable from no ref, so without this root
183    // staged work would be pruned once it ages past the grace window
184    // (or immediately under `--grace-secs 0`). `read_index` is strict
185    // (errors on corrupt/oversized index), so a damaged index aborts
186    // gc instead of silently dropping roots.
187    for entry in index::read_index(tree)?.entries {
188        add(entry.object_hash, roots);
189    }
190
191    // ORIG_HEAD (written by reset and by the in-progress ops below).
192    if let Some(h) = read_optional_hash(&tree.orig_head_file())? {
193        add(h, roots);
194    }
195
196    // In-progress merge / cherry-pick / revert.
197    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    // In-progress rebase: target + every commit still to replay or
211    // already replayed onto the new base.
212    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    // Conflict sidecar: base/ours/theirs blobs needed to resolve an
222    // in-progress conflict. Merge/cherry-pick write `mkit-conflicts`
223    // in the state dir; rebase writes its sidecar inside
224    // `rebase-apply/`. Both are empty/absent when no conflict is
225    // recorded.
226    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
239/// The full live-object keep-set for `mkit gc`: the reachable closure
240/// over every retention root from [`collect_roots`].
241///
242/// Does not verify leaf (blob/delta) content integrity as part of the
243/// walk — see [`super::graph::reachable_closure`]'s "Content integrity
244/// of leaves" doc section. `gc` is therefore no longer an incidental
245/// corruption-detection pass over blob content the way it was before
246/// #636; it still fails closed on a *missing* object, just not on a
247/// present-but-corrupted one.
248///
249/// # Errors
250///
251/// [`GcRootsError`] if roots cannot be collected, or a [`StoreError`]
252/// (e.g. a root or referenced object missing) during the walk.
253pub 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/// Outcome of a [`run_gc`] sweep.
266#[derive(Debug, Default, Clone, Copy)]
267pub struct GcReport {
268    /// Objects examined in the store.
269    pub scanned: usize,
270    /// Objects retained because they are reachable from a root.
271    pub live: usize,
272    /// Unreachable objects retained anyway — within the grace window, or
273    /// whose age could not be determined (kept fail-safe).
274    pub kept_recent: usize,
275    /// Unreachable objects pruned (or that *would* be pruned in a dry run).
276    pub pruned: usize,
277    /// Bytes reclaimed by the pruned objects.
278    pub bytes_reclaimed: u64,
279    /// True if this was a dry run (nothing deleted).
280    pub dry_run: bool,
281}
282
283/// Mark-and-sweep prune: keep every object reachable from the retention
284/// roots ([`live_objects`]) plus every unreachable object younger than
285/// `grace_secs` (relative to `now_secs`); delete the rest. With
286/// `dry_run`, computes the report without deleting anything.
287///
288/// **Fail closed / fail safe.** If the live set can't be computed (a
289/// missing/corrupt root, a malformed ref, or the reachability cap), this
290/// returns an error and deletes nothing. An object whose age can't be
291/// read is kept, never pruned. The caller MUST hold the repo lock so the
292/// live set can't shift mid-sweep (see [`super::recovery`]); `gc` runs
293/// `recovery::expire` then this, all under that lock.
294///
295/// # Errors
296/// [`GcRootsError`] from [`live_objects`], store enumeration, or a delete.
297pub 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    // Refuse to delete through a symlinked store: if `.mkit` or
305    // `.mkit/objects` is a symlink, `remove_object` would unlink the
306    // link target's files — potentially outside the repo. (Dry runs are
307    // safe but we reject uniformly so a preview matches the real run.)
308    reject_symlink(layout.common_dir())?;
309    reject_symlink(store.objects_root())?;
310
311    // Compute the keep-set FIRST; if this fails we delete nothing.
312    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        // Unreachable. Keep it if it is within the grace window, or if
326        // its age cannot be determined (fail safe — never delete when
327        // uncertain).
328        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
352/// Strict, fail-closed walk of a ref namespace directory (e.g.
353/// `refs/heads`), inserting every ref's target hash into `roots`.
354///
355/// Unlike `refs::list_refs`, this errors instead of skipping on:
356/// unreadable files ([`io::Error`]), undecodable content
357/// ([`hash::FromHexError`]), and excessive nesting
358/// ([`GcRootsError::RefTooDeep`]). Dot-files are skipped (lock/temp
359/// cruft), and an absent namespace dir yields no roots. The all-zero
360/// hash (an unset ref) is excluded.
361fn 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            // Skip non-files and lock/temp cruft (e.g. `*.lock`, dotfiles).
386            continue;
387        }
388        // Strict: read + decode, erroring (fail closed) on any failure.
389        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
398/// Commit hashes that have at least one attestation envelope, taken from
399/// the `attestations/<commit-hex>/` directory names. Non-hex directory
400/// names are ignored (defensive); a missing dir yields an empty set.
401fn 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
423/// Read a single 64-hex object id from `path`, trimming trailing
424/// whitespace. `Ok(None)` if the file is absent.
425fn 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
438// =====================================================================
439// Tests
440// =====================================================================
441
442/// Error if `path` is a symlink — a deletion-capable gc must not follow
443/// it (the target may be outside the repo). Absent path is fine.
444fn 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    /// A repo with an initialized `.mkit` dir + object store.
465    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    /// Write a loose ref file (e.g. `refs/heads/main`) — the on-disk
477    /// form `list_refs`/`list_tags` read.
478    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    /// Commit a single-file tree; returns `(commit, blob)` hashes.
495    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                    // Per-commit divergence so distinct fixtures don't dedup.
518                    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        // An unreferenced commit + blob: reachable from no root.
563        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    /// Test-only mirror of [`live_objects`] with an injectable
578    /// reachability cap, so the `GcRootsError::Truncated` fail-closed
579    /// abort can be exercised without constructing `MAX_REACHABLE` (10
580    /// million) objects.
581    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        // gc's fail-closed contract: beyond the reachability cap the
598        // "unreachable" verdict is unsound, so live_objects (and
599        // therefore run_gc) must abort rather than prune against a
600        // partial closure. Each commit_one call adds 3 objects
601        // (commit + tree + blob); two commits give 6 reachable objects,
602        // comfortably over an injected cap of 2.
603        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        // Sanity: the same closure with a generous cap succeeds and
613        // contains every object from both commits.
614        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        // Live: a branch commit + its tree + blob.
648        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        // Orphans: unreferenced commit + its tree + blob.
652        let (orphan, orphan_blob) = commit_one(&s, b"orphan", b"orphan", vec![]);
653
654        // grace=0 → all unreachable objects are old enough to prune.
655        let report = run_gc(&s, &md, u64::MAX, 0, false).unwrap();
656
657        // The safety invariant: every live object still present.
658        for h in &live {
659            assert!(s.contains(h), "gc must never delete a live object");
660        }
661        // Orphan closure gone.
662        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        // Sanity: kept objects accounted as live.
672        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        // A committed branch so the repo has a normal ref-side root.
680        let (kept, _) = commit_one(&s, b"k", b"k", vec![]);
681        write_ref(&md, "refs/heads/main", &kept);
682        // Stage a blob no commit references — what `mkit add` leaves
683        // behind: the object in the store + an index entry.
684        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        // grace=0 → anything unrooted is pruned immediately.
701        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        // Huge grace window with now=0 → nothing is "old", so the orphan
717        // is kept despite being unreachable.
718        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        // Replace `.mkit/objects` with a symlink to an external dir. A
734        // deletion-capable gc must refuse rather than prune through it.
735        let external = d.path().join("external-objects");
736        let real_objects = md.objects_dir();
737        fs::create_dir_all(&external).unwrap();
738        // Move existing shards out so the symlink target holds them.
739        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        // An orphan that is recorded in the recovery log must survive gc.
773        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        // A corrupt ref file (not 64-hex) must error, never be silently
818        // dropped — else gc could prune the object it should pin.
819        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        // Atomic-write temp files are dotfiles; a stale one must not
835        // break collection.
836        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}