use anyhow::Result;
use heddle_core::{
gc_plan::{
gc_consolidated_mirror_message, gc_dry_run_messages, gc_pack_message,
gc_preserved_redactions_message, gc_prune_loose_message, gc_pruned_git_mapping_message,
gc_status_token, plan_gc_dry_run,
},
maintenance_plan::{pack_install_recover_line, unpaired_packs_pruned_line},
};
#[cfg(feature = "git-overlay")]
use heddle_git_projection::GitProjection;
use objects::store::{
AnyStore, ObjectStore, PackInstallMetricsSnapshot, pack_install_metrics_snapshot,
recover_pack_install_intents,
};
use repo::TimelineStore;
use serde::Serialize;
use crate::cli::{Cli, render::write_json_stdout, should_output_json};
#[derive(Serialize, Default)]
struct GcOutput {
output_kind: &'static str,
action: &'static str,
status: &'static str,
dry_run: bool,
prune: bool,
packed_count: u64,
bytes_saved: u64,
pruned_loose: u64,
bytes_freed: u64,
timeline_packed_count: u64,
timeline_bytes_saved: u64,
timeline_pruned_loose: u64,
timeline_bytes_freed: u64,
timeline_unpaired_packs_pruned: u64,
unpaired_packs_pruned: u64,
pack_install_intents_completed: u64,
pack_install_intents_aborted: u64,
pack_install_metrics: PackInstallMetricsSnapshot,
pinned_redactions: usize,
preserved_redactions: usize,
#[cfg(feature = "git-overlay")]
pruned_git_mapping_entries: usize,
#[cfg(feature = "git-overlay")]
consolidated_mirror_loose: usize,
}
pub fn cmd_gc(cli: &Cli, prune: bool, aggressive: bool, dry_run: bool) -> Result<()> {
let repo = cli.open_repo()?;
let json = should_output_json(cli, Some(repo.config()));
let mut summary = GcOutput {
output_kind: "gc",
action: "gc",
status: gc_status_token(dry_run),
dry_run,
prune,
..Default::default()
};
let redactions_before = repo.list_all_redactions().unwrap_or_default();
let timeline = TimelineStore::open(repo.heddle_dir())?;
let pinned_redactions: usize = redactions_before
.iter()
.map(|(_, blob)| blob.redactions.len())
.sum();
summary.pinned_redactions = pinned_redactions;
if dry_run {
let blobs = repo.store().list_blobs()?;
let trees = repo.store().list_trees()?;
let plan = plan_gc_dry_run(blobs.len(), trees.len());
summary.packed_count = plan.packed_count;
summary.timeline_packed_count = timeline.loose_operation_count()?;
summary.status = plan.status;
if !json {
let _ = prune;
for line in gc_dry_run_messages(blobs.len(), trees.len(), pinned_redactions) {
println!("{line}");
}
println!(
"Would pack {} loose timeline operations and prune their redundant copies",
summary.timeline_packed_count
);
}
} else {
let delta_search = aggressive || repo.config().storage.delta_search.gc;
let (packed_count, bytes_saved) = repo.store().pack_objects(delta_search)?;
summary.packed_count = packed_count;
summary.bytes_saved = bytes_saved;
if !json {
println!("{}", gc_pack_message(packed_count, bytes_saved));
}
let (timeline_packed_count, timeline_bytes_saved) = timeline.pack_operations(aggressive)?;
summary.timeline_packed_count = timeline_packed_count;
summary.timeline_bytes_saved = timeline_bytes_saved;
if !json {
println!(
"Packed {timeline_packed_count} timeline operations ({timeline_bytes_saved} bytes saved)"
);
}
let (timeline_pruned_loose, timeline_bytes_freed) = timeline.prune_loose_operations()?;
summary.timeline_pruned_loose = timeline_pruned_loose;
summary.timeline_bytes_freed = timeline_bytes_freed;
if !json {
println!(
"Pruned {timeline_pruned_loose} loose timeline operations ({timeline_bytes_freed} bytes freed)"
);
}
let (timeline_unpaired_removed, timeline_unpaired_bytes) =
timeline.prune_unpaired_packs()?;
summary.timeline_unpaired_packs_pruned = timeline_unpaired_removed;
if !json && timeline_unpaired_removed > 0 {
println!(
"Pruned {timeline_unpaired_removed} unpaired timeline packs ({timeline_unpaired_bytes} bytes freed)"
);
}
repo.refs().pack_refs()?;
#[cfg(feature = "git-overlay")]
{
let mut bridge = GitProjection::new(&repo);
if bridge.is_initialized() {
let removed = bridge.prune_unreachable_mapping_entries()?;
summary.pruned_git_mapping_entries = removed;
if !json && let Some(msg) = gc_pruned_git_mapping_message(removed) {
println!("{msg}");
}
let consolidated = bridge.consolidate_mirror()?;
summary.consolidated_mirror_loose = consolidated;
if !json && let Some(msg) = gc_consolidated_mirror_message(consolidated) {
println!("{msg}");
}
}
}
let _ = prune;
let (removed, bytes_freed) = repo.store().prune_loose_objects()?;
summary.pruned_loose = removed;
summary.bytes_freed = bytes_freed;
if !json {
println!("{}", gc_prune_loose_message(removed, bytes_freed));
}
let packs = repo.heddle_dir().join("packs");
let recover = recover_pack_install_intents(&packs)?;
summary.pack_install_intents_completed = recover.completed;
summary.pack_install_intents_aborted = recover.aborted;
summary.pack_install_metrics = pack_install_metrics_snapshot();
if !json {
println!(
"{}",
pack_install_recover_line(recover.completed, recover.aborted)
);
}
let (unpaired_removed, unpaired_bytes) = match repo.store() {
AnyStore::Fs(fs) => fs.prune_unpaired_packs()?,
};
summary.unpaired_packs_pruned = unpaired_removed;
if !json {
println!(
"{}",
unpaired_packs_pruned_line(unpaired_removed, unpaired_bytes)
);
}
let redactions_after = repo.list_all_redactions().unwrap_or_default();
let before_index: std::collections::HashMap<_, _> = redactions_before
.iter()
.map(|(blob, b)| (*blob, b.redactions.len()))
.collect();
for (blob, after_blob) in &redactions_after {
let before_count = before_index.get(blob).copied().unwrap_or(0);
if after_blob.redactions.len() < before_count {
anyhow::bail!(
"GC invariant violated: redactions on blob {} dropped from {} to {} — \
refusing to claim a successful GC",
blob.short(),
before_count,
after_blob.redactions.len()
);
}
}
for (blob, _) in &redactions_before {
if !redactions_after.iter().any(|(b, _)| b == blob) {
anyhow::bail!(
"GC invariant violated: redactions file for blob {} disappeared — \
refusing to claim a successful GC",
blob.short()
);
}
}
if pinned_redactions > 0 {
summary.preserved_redactions = pinned_redactions;
if !json && let Some(msg) = gc_preserved_redactions_message(pinned_redactions) {
println!("{msg}");
}
}
}
if json {
write_json_stdout(&summary)?;
}
Ok(())
}