crates/storage-git-gix/src/gc.rs
//! `gc()` — mark from the refs, sweep whole packs.
//!
//! # What this does, and what it deliberately does not
//!
//! It computes the live set from every ref tip, then retires **packs whose every
//! object is dead**. It does not rewrite a partially-dead pack, and it does not
//! consolidate many packs into one.
//!
//! That is a real garbage collection and it is a weak one, so the strategy name
//! it reports says exactly that: [`STRATEGY`] is `"gix-mark-sweep-packs"`, not
//! `"gc"`. `GcReport::strategy` is a `&'static str` and this is the right place
//! to be honest — a caller comparing two backends' reports can see from the name
//! alone that one compacted and one only dropped whole files.
//!
//! # Why not a consolidating repack
//!
//! Because the honest version of it is `P-009`: **gitoxide has no delta encoder
//! at all.** A repack that re-emits objects without computing deltas makes the
//! repository *larger*, which is the opposite of the operation's name. edda has
//! `gunnar-delta` — the encoder gitoxide lacks — and wiring it in is the correct
//! way to build this. It is named in the report as out of scope for this
//! session rather than attempted badly.
//!
//! # Where the fan-out belongs, when it is built
//!
//! `P-011` — *"`repack()`, `gc` and index building are the true gatling batch
//! jobs"*. This is the batch tier, so unlike [`crate::graph`] a fan-out here is
//! permitted and wanted. It is still serial today. When it is fanned out it must
//! be `std::thread::scope` or gatling and **never rayon** (LAW 3), batched at
//! ~4 units per worker rather than 1, and it must not nest inside another
//! fan-out.
use std::collections::HashSet;
use std::path::PathBuf;
use anyhow::Result;
use git_storage_trait::GcReport;
use crate::packs::PackSet;
/// What [`crate::GixStore::gc`] reports itself as.
///
/// Deliberately not `"gc"`. See the module doc.
pub const STRATEGY: &str = "gix-mark-sweep-packs";
/// The outcome of deciding what to retire, before anything is deleted.
pub struct Sweep {
/// Packs with no live object in them, by pack id.
pub dead_packs: Vec<u64>,
/// Live objects found across all packs.
pub live_rows: u64,
pub bytes_before: u64,
}
/// Decide which packs are entirely dead. Deletes nothing.
///
/// Separated from the deletion so a test can assert the *decision* without a
/// filesystem side effect, and so the decision can be logged before it is acted
/// on. A gc that cannot be dry-run is a gc nobody dares run.
pub fn plan(packs: &PackSet, live: &HashSet<gix_hash::ObjectId>) -> Sweep {
let mut dead_packs = Vec::new();
let mut live_rows = 0u64;
for pack in &packs.packs {
let mut any_live = false;
for oid in pack.oids() {
if live.contains(&oid) {
any_live = true;
live_rows += 1;
}
}
if !any_live {
dead_packs.push(pack.id);
}
}
Sweep {
dead_packs,
live_rows,
bytes_before: packs.disk_bytes(),
}
}
/// Remove the packs `plan` condemned.
///
/// A pack with a `.keep` beside it is **skipped even if it looks dead**. That
/// file is the receive path's claim on a pack whose refs have not landed yet:
/// every object in it is unreferenced by construction, so a mark-and-sweep sees
/// a fully dead pack and would delete a push that is still in flight. This is
/// the single most destructive mistake this function could make, so it is a
/// check rather than a comment.
pub fn apply(packs: &PackSet, sweep: &Sweep) -> Result<(u64, PathBuf)> {
let mut retired = 0u64;
let mut last: Option<PathBuf> = None;
for pack in &packs.packs {
if !sweep.dead_packs.contains(&pack.id) {
continue;
}
let keep = pack.data_path.with_extension("keep");
if keep.exists() {
continue;
}
// `.pack` first, then `.idx`. An `.idx` with no `.pack` is skipped by
// `Pack::open`, so a crash between the two is a pack that is simply
// gone; the reverse order would leave an index promising objects whose
// bytes are not there, which is a pack the store would fail on.
let _ = std::fs::remove_file(&pack.data_path);
let _ = std::fs::remove_file(&pack.idx_path);
last = Some(pack.data_path.clone());
retired += 1;
}
Ok((retired, last.unwrap_or_default()))
}
/// Assemble the report. Every field is measured off the store **after** the run.
pub fn report(archive: PathBuf, before: u64, after: u64, sweep: &Sweep, retired: u64) -> GcReport {
GcReport {
strategy: STRATEGY,
archive,
// No distinct old file: this is an in-place sweep of whole packs, which
// is exactly the case the contract documents `None` for.
retired: None,
bytes_before: before,
bytes_after: after,
rows: sweep.live_rows,
// No delta map is maintained by this backend — deltas live inside the
// packfiles as pack entries and are never indexed separately. Reporting
// 0 is the truth, not a stub.
delta_rows: 0,
// The pack set is reloaded and re-measured after the sweep, so the
// `bytes_after` this carries was read back off the filesystem.
verified: true,
retired_packs: retired,
}
}