1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// SPDX-License-Identifier: Apache-2.0
//! Garbage collection command - clean up unreachable objects.
//!
//! **Redaction tombstones are structurally permanent.** They live in
//! `<heddle_dir>/redactions/<blob-hex>.bin`, *outside* the `objects/`
//! subtree GC operates on. `pack_objects` only walks loose blobs/trees
//! (see `crates/objects/src/store/fs/fs_pack.rs`); `prune_loose_objects`
//! only drops bytes whose canonical copy now lives in a pack. Neither
//! ever observes or touches a redaction file — they cannot be packed,
//! cannot be pruned, and cannot be lost to a `gc --prune --aggressive`
//! pass. We report the pinned count so the audit trail in `heddle gc`
//! output makes the invariant visible to operators.
use anyhow::Result;
use repo::Repository;
#[cfg(feature = "git-overlay")]
use crate::bridge::GitBridge;
use crate::cli::Cli;
pub fn cmd_gc(cli: &Cli, prune: bool, aggressive: bool, dry_run: bool) -> Result<()> {
let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
// Snapshot redactions before GC so we can both report the pinned
// count and (post-GC) assert that no record was disturbed. The
// assertion is defence-in-depth: GC structurally cannot reach
// these files, but the audit step costs O(redactions) and gives
// operators a hard guarantee in writing.
let redactions_before = repo.list_all_redactions().unwrap_or_default();
let pinned_redactions: usize = redactions_before
.iter()
.map(|(_, blob)| blob.redactions.len())
.sum();
if dry_run {
let blobs = repo.store().list_blobs()?;
let trees = repo.store().list_trees()?;
let total_objects = blobs.len() + trees.len();
println!(
"Would pack {} objects ({} blobs, {} trees)",
total_objects,
blobs.len(),
trees.len()
);
if prune {
println!("Would prune loose objects after packing");
}
if pinned_redactions > 0 {
println!("Pinned {pinned_redactions} redaction tombstone(s) — never collected by GC");
}
} else {
let (packed_count, bytes_saved) = repo.store().pack_objects(aggressive)?;
if packed_count > 0 {
println!(
"Packed {} objects (saved {} bytes)",
packed_count, bytes_saved
);
} else {
println!("No objects to pack");
}
repo.refs().pack_refs()?;
#[cfg(feature = "git-overlay")]
{
let mut bridge = GitBridge::new(&repo);
if bridge.is_initialized() {
let removed = bridge.prune_unreachable_mapping_entries()?;
if removed > 0 {
println!("Pruned {removed} stale Git-overlay mapping entries");
}
}
}
if prune {
let (removed, bytes_freed) = repo.store().prune_loose_objects()?;
if removed > 0 {
println!(
"Pruned {} loose objects (freed {} bytes)",
removed, bytes_freed
);
} else {
println!("No loose objects to prune");
}
}
// Post-GC invariant: every redaction we saw at the start of
// this run must still exist. We compare by (blob_hash,
// redaction_count) — if a record disappeared, GC's structural
// boundary was breached and the next reader would see secrets
// we promised to hide.
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 {
println!(
"Preserved {pinned_redactions} redaction tombstone(s) across GC \
(structurally outside the object store)"
);
}
}
Ok(())
}