OXYGENHUB

Garbage collection: operation-log retention, pruning with grace period, pack rewriting; oxy gc --now ba160f60219e

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 20:26 UTC
Signature
✔ signed by Andy McCall
Hash
ba160f60219ec658e83908930d89808a751fc9fc44a39c5afe381bbafae167b3
Depends on
1983686fb7a7 2b5ee371060e 614541c3cebd 83e0677b07fe 982864068a59 d02e545dc265 fce44ae9ad29 ff0af1ee299d
Depended on by
c397a8c36a9f 1ec6fdbc6bcb ef7d4ea6f33b 767ac6cd0545 f53cbe7a204e 3a4b3e018326 b689779e9e74 9d580f2132f3 d6e0d5b8b339 42ccf504aa50 648189388f1a 9ae4d15a0e4b d8d9367878a6 b30a880fa409 ff1f62fee654 a17fc622ca43 da652d6392d8 6f8aeb996558 fc1a466a1e66 afaba5f1306d 62794ecc7f74 387335ff7f4d 75167672ebcd b3c5a59a875a 8f10a07a1dbe 21b2aec279ff 6d3eefbce2f0 132b884b9a8d 2863456a0f6d 135212de5370 252e8cb23fbc c3069537d7c5 79ab8528ff62
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
33 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/oxy-core/src/repo/gc.rs +109 −0
1 +
//! Garbage collection (spec §21): pack loose objects, then delete objects
2 +
//! nothing reachable refers to.
3 +
//!
4 +
//! Roots: the current view, the views of every operation newer than
5 +
//! `core.op-retention`, reference attestations, and the policy. Operation
6 +
//! objects are always kept so the log stays walkable, but an operation older
7 +
//! than the retention window no longer keeps its states alive, so undoing
8 +
//! that far back may fail once its objects are gone.
9 +
10 +
use super::*;
11 +
use crate::store::{PackReport, PruneReport};
12 +
use std::collections::HashSet;
13 +
14 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
15 +
pub struct GcReport {
16 +
    pub packed: PackReport,
17 +
    pub pruned: PruneReport,
18 +
    pub reachable: usize,
19 +
}
20 +
21 +
impl Repo {
22 +
    /// Pack loose objects and prune unreachable ones older than the grace
23 +
    /// period (`now` ignores the grace period).
24 +
    pub fn gc(&self, now: bool) -> Result<GcReport> {
25 +
        let packed = self.store.pack_loose()?;
26 +
        let keep = self.reachable()?;
27 +
        let grace = if now { std::time::Duration::ZERO } else { std::time::Duration::from_secs(self.config.gc_grace_days * 86_400) };
28 +
        let pruned = self.store.prune(&keep, grace)?;
29 +
        Ok(GcReport { packed, pruned, reachable: keep.len() })
30 +
    }
31 +
32 +
    /// Every object that must survive garbage collection.
33 +
    pub fn reachable(&self) -> Result<HashSet<ObjectRef>> {
34 +
        let mut keep: HashSet<ObjectRef> = HashSet::new();
35 +
        let mut states: Vec<ObjectRef> = Vec::new();
36 +
        let mut changes: Vec<ChangeHash> = Vec::new();
37 +
        let mut attests: Vec<ObjectRef> = Vec::new();
38 +
        let mut policies: Vec<ObjectRef> = Vec::new();
39 +
40 +
        // Operations: always kept; views within retention are roots.
41 +
        let cutoff = Hlc::now().wall_ms.saturating_sub(self.config.op_retention_days * 86_400_000);
42 +
        for (i, (r, op)) in self.op_log()?.into_iter().enumerate() {
43 +
            keep.insert(r);
44 +
            if i == 0 || op.hlc.wall_ms >= cutoff {
45 +
                states.extend(op.view.bookmarks.values().copied());
46 +
                states.extend(op.view.remotes.values().flat_map(|b| b.values().copied()));
47 +
                changes.extend(op.view.workspaces.values().copied());
48 +
                policies.extend(op.view.policy);
49 +
            }
50 +
        }
51 +
        // Reference attestations.
52 +
        for name in self.view()?.bookmarks.keys() {
53 +
            if let Some((r, _)) = self.latest_ref_attest(name)? {
54 +
                attests.push(r);
55 +
            }
56 +
        }
57 +
        for st in states {
58 +
            if keep.insert(st) {
59 +
                changes.extend(self.store.get_state(&st)?.heads);
60 +
            }
61 +
        }
62 +
        for p in policies {
63 +
            if keep.insert(p) {
64 +
                let policy = crate::policy::Policy::decode(&self.store.get_typed(&p, ObjectType::Policy)?)?;
65 +
                for id in &policy.identities {
66 +
                    keep.insert(id.hash());
67 +
                }
68 +
                for sig in self.store.signatures(&p)? {
69 +
                    keep.insert(sig.key);
70 +
                }
71 +
            }
72 +
        }
73 +
        // Changes: their closure, identities, blobs, chunks, seals.
74 +
        let mut stack = changes;
75 +
        while let Some(h) = stack.pop() {
76 +
            if !keep.insert(h) {
77 +
                continue;
78 +
            }
79 +
            let c = self.store.get_change(&h)?;
80 +
            stack.extend(c.deps.iter().copied());
81 +
            if !c.author.is_zero() {
82 +
                keep.insert(c.author);
83 +
            }
84 +
            for sig in self.store.signatures(&h)? {
85 +
                keep.insert(sig.key);
86 +
            }
87 +
            for e in &c.edits {
88 +
                if let Edit::Replace { old, new, .. } = e {
89 +
                    for b in [old, new].into_iter().flatten() {
90 +
                        if keep.insert(*b) {
91 +
                            if let Ok(bytes) = self.store.get_typed(b, ObjectType::Blob) {
92 +
                                keep.extend(Blob::decode(&bytes)?.chunks);
93 +
                            }
94 +
                        }
95 +
                    }
96 +
                }
97 +
            }
98 +
            attests.extend(self.store.seals_for(&h)?);
99 +
        }
100 +
        for a in attests {
101 +
            if keep.insert(a) {
102 +
                for sig in self.store.signatures(&a)? {
103 +
                    keep.insert(sig.key);
104 +
                }
105 +
            }
106 +
        }
107 +
        Ok(keep)
108 +
    }
109 +
}
modified crates/oxy-core/src/repo/config.rs +13 −0
…
22 unchanged lines
23 23
    pub remote_tokens: BTreeMap<String, String>,
24 24
    /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote.
25 25
    pub remote_cas: BTreeMap<String, String>,
26 +
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
27 +
    pub op_retention_days: u64,
28 +
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
29 +
    pub gc_grace_days: u64,
26 30
}
27 31
28 32
impl Config {
…
9 unchanged lines
38 42
            remotes: BTreeMap::new(),
39 43
            remote_tokens: BTreeMap::new(),
40 44
            remote_cas: BTreeMap::new(),
45 +
            op_retention_days: 90,
46 +
            gc_grace_days: 14,
41 47
        }
42 48
    }
43 49
…
43 unchanged lines
87 93
                }
88 94
            }
89 95
            "workspace.bookmark" => self.bookmark = value.into(),
96 +
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
97 +
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
90 98
            k if k.starts_with("remote.") => {
91 99
                let rest = &k["remote.".len()..];
92 100
                let Some((name, field)) = rest.rsplit_once('.') else {
…
49 unchanged lines
142 150
    }
143 151
}
144 152
153 +
/// A count of days, optionally suffixed `d`.
154 +
fn parse_days(v: &str) -> Result<u64> {
155 +
    v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
156 +
}
157 +
145 158
/// Read `key = "value"` lines; a missing file is empty.
146 159
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
147 160
    let text = match fs::read_to_string(path) {
…
21 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −7
…
52 unchanged lines
53 53
54 54
mod checkout;
55 55
mod config;
56 +
mod gc;
56 57
mod history;
57 58
mod identity;
58 59
mod policy;
…
4 unchanged lines
63 64
64 65
pub use checkout::CheckoutReport;
65 66
pub use config::{read_config_file, Config};
67 +
pub use gc::GcReport;
66 68
pub use identity::SignatureStatus;
67 69
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
68 70
pub use sync::{FetchReport, PullReport};
…
151 unchanged lines
220 222
        write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?;
221 223
        write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?;
222 224
        Ok(())
223 -
    }
224 -
225 -
    /// Pack every loose object (spec §13.4). Pruning unreachable objects
226 -
    /// waits for operation-log retention, since every operation keeps its
227 -
    /// states reachable for now.
228 -
    pub fn gc(&self) -> Result<crate::store::PackReport> {
229 -
        Ok(self.store.pack_loose()?)
230 225
    }
231 226
232 227
    /// Pack automatically once many loose objects have accumulated.
…
345 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +33 −7
…
762 unchanged lines
763 763
    write(root, "a.txt", b"two\n");
764 764
    write(root, "big.bin", &vec![0u8; 100_000]);
765 765
    let h2 = repo.commit("two", &[]).unwrap();
766 +
    // Several snapshots leave superseded working-copy versions behind.
767 +
    for i in 0..5 {
768 +
        write(root, "a.txt", format!("two\n{i}\n").as_bytes());
769 +
        repo.snapshot("status").unwrap();
770 +
    }
771 +
    write(root, "a.txt", b"two\n");
772 +
    repo.snapshot("status").unwrap();
766 773
    let before = repo.store().list().unwrap();
767 774
    assert!(repo.store().loose_count().unwrap() > 0);
768 -
    let r = repo.gc().unwrap();
769 -
    assert_eq!(r.objects, before.len());
775 +
776 +
    // With the default retention every operation's view is a root: nothing prunes.
777 +
    let r = repo.gc(true).unwrap();
778 +
    assert_eq!(r.packed.objects, before.len());
779 +
    assert_eq!(r.pruned.objects, 0);
770 780
    assert_eq!(repo.store().loose_count().unwrap(), 0);
771 781
    assert_eq!(repo.store().list().unwrap(), before);
772 -
    // Reopen: reads come from the pack; signatures, states, ops all resolve.
782 +
783 +
    // With zero retention only the current view is a root: superseded
784 +
    // working copies go, everything reachable stays.
785 +
    repo.config.set("core.op-retention", "0").unwrap();
786 +
    repo.save_config().unwrap();
773 787
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
788 +
    let r = repo.gc(true).unwrap();
789 +
    assert!(r.pruned.objects >= 5, "{r:?}");
790 +
    let after = repo.store().list().unwrap();
791 +
    assert!(after.len() < before.len());
792 +
    assert!(after.contains(&h1) && after.contains(&h2));
793 +
    for (_, op) in repo.op_log().unwrap() {
794 +
        assert!(!op.command.is_empty(), "operation objects are all kept");
795 +
    }
796 +
    // Everything still works from the pruned, packed store.
774 797
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
775 798
    assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. }));
776 799
    assert_eq!(repo.log().unwrap().len(), 3);
…
2 unchanged lines
779 802
    repo.checkout(&[h2], false, "checkout").unwrap();
780 803
    assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000);
781 804
    assert!(!repo.snapshot("status").unwrap().changed);
782 -
    // New work after packing goes loose and commits fine.
805 +
    // Grace period defers pruning of young garbage.
783 806
    write(root, "a.txt", b"three\n");
784 807
    repo.commit("three", &[]).unwrap();
785 -
    assert!(repo.store().loose_count().unwrap() > 0);
786 -
    assert_eq!(repo.gc().unwrap().objects, repo.store().list().unwrap().len() - before.len());
787 -
    assert_eq!(repo.store().pack_count(), 2);
808 +
    let r = repo.gc(false).unwrap();
809 +
    assert_eq!(r.pruned.objects, 0);
810 +
    assert!(r.pruned.deferred > 0);
811 +
    let r = repo.gc(true).unwrap();
812 +
    assert!(r.pruned.objects > 0);
813 +
    assert_eq!(repo.log().unwrap().len(), 4);
788 814
}
789 815
790 816
#[test]
…
57 unchanged lines
modified crates/oxy-core/src/store.rs +136 −4
…
106 unchanged lines
107 107
    pub packed_bytes: u64,
108 108
}
109 109
110 +
/// What `prune` did.
111 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
112 +
pub struct PruneReport {
113 +
    pub objects: usize,
114 +
    pub bytes: u64,
115 +
    /// Unreachable objects left alone because they are younger than the grace period.
116 +
    pub deferred: usize,
117 +
}
118 +
110 119
#[derive(Debug, thiserror::Error)]
111 120
pub enum StoreError {
112 121
    #[error("io: {0}")]
…
80 unchanged lines
193 202
    /// deleted only after the pack and its index are durable.
194 203
    pub fn pack_loose(&self) -> Result<PackReport> {
195 204
        let loose = self.loose_objects()?;
196 -
        if loose.is_empty() {
205 +
        self.write_pack(&loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), &[])
206 +
    }
207 +
208 +
    /// Delete every object not in `keep` (spec §21). Loose objects and packs
209 +
    /// younger than `grace` are left alone, since a concurrent writer may not
210 +
    /// have recorded its references yet. Packs holding unreachable objects
211 +
    /// are rewritten without them. Signature sidecars and seal-index entries
212 +
    /// of deleted objects go too.
213 +
    pub fn prune(&self, keep: &std::collections::HashSet<ObjectRef>, grace: std::time::Duration) -> Result<PruneReport> {
214 +
        let now = std::time::SystemTime::now();
215 +
        let old_enough = |m: &fs::Metadata| m.modified().map(|t| now.duration_since(t).unwrap_or_default() >= grace).unwrap_or(true);
216 +
        let mut report = PruneReport::default();
217 +
        // Loose objects.
218 +
        for (r, path) in self.loose_objects()? {
219 +
            if keep.contains(&r) {
220 +
                continue;
221 +
            }
222 +
            let meta = fs::metadata(&path)?;
223 +
            if !old_enough(&meta) {
224 +
                report.deferred += 1;
225 +
                continue;
226 +
            }
227 +
            report.bytes += meta.len();
228 +
            report.objects += 1;
229 +
            fs::remove_file(&path)?;
230 +
            let _ = fs::remove_dir(path.parent().unwrap());
231 +
            self.remove_sidecars(&r)?;
232 +
        }
233 +
        // Packs: rewrite those that hold unreachable objects.
234 +
        let mut keep_refs: Vec<ObjectRef> = Vec::new();
235 +
        let mut old_paths: Vec<PathBuf> = Vec::new();
236 +
        let mut rewrite = false;
237 +
        for p in self.packs.borrow().iter() {
238 +
            let meta = fs::metadata(&p.path)?;
239 +
            let unreachable = p.refs.iter().filter(|r| !keep.contains(r)).count();
240 +
            if unreachable == 0 {
241 +
                continue;
242 +
            }
243 +
            if !old_enough(&meta) {
244 +
                report.deferred += unreachable;
245 +
                continue;
246 +
            }
247 +
            rewrite = true;
248 +
            old_paths.push(p.path.clone());
249 +
            for (r, e) in p.refs.iter().zip(p.entries.iter()) {
250 +
                if keep.contains(r) {
251 +
                    keep_refs.push(*r);
252 +
                } else {
253 +
                    report.objects += 1;
254 +
                    report.bytes += e.stored_len as u64;
255 +
                }
256 +
            }
257 +
        }
258 +
        if rewrite {
259 +
            let dropped: Vec<ObjectRef> = {
260 +
                let packs = self.packs.borrow();
261 +
                packs.iter().filter(|p| old_paths.contains(&p.path)).flat_map(|p| p.refs.iter().copied()).filter(|r| !keep.contains(r)).collect()
262 +
            };
263 +
            self.write_pack(&keep_refs, &[], &old_paths)?;
264 +
            for r in dropped {
265 +
                self.remove_sidecars(&r)?;
266 +
            }
267 +
        }
268 +
        Ok(report)
269 +
    }
270 +
271 +
    fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> {
272 +
        for path in [self.sig_path(r), self.seal_index_path(r)] {
273 +
            match fs::remove_file(&path) {
274 +
                Ok(()) => {
275 +
                    let _ = fs::remove_dir(path.parent().unwrap());
276 +
                }
277 +
                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
278 +
                Err(e) => return Err(e.into()),
279 +
            }
280 +
        }
281 +
        Ok(())
282 +
    }
283 +
284 +
    /// Write `refs` into a new pack, then delete `loose_paths` and `old_packs`.
285 +
    fn write_pack(&self, refs: &[ObjectRef], loose_paths: &[PathBuf], old_packs: &[PathBuf]) -> Result<PackReport> {
286 +
        if refs.is_empty() {
287 +
            for p in old_packs {
288 +
                fs::remove_file(p)?;
289 +
                fs::remove_file(p.with_extension("idx"))?;
290 +
            }
291 +
            self.reload_packs()?;
197 292
            return Ok(PackReport::default());
198 293
        }
294 +
        let mut refs: Vec<ObjectRef> = refs.to_vec();
295 +
        refs.sort();
296 +
        refs.dedup();
199 297
        let mut nonce = [0u8; 8];
200 298
        rand::rngs::OsRng.fill_bytes(&mut nonce);
201 299
        let id = hex::encode(nonce);
…
2 unchanged lines
204 302
        let tmp_pack = self.dir.join(format!("tmp-{id}.pack"));
205 303
        let tmp_idx = self.dir.join(format!("tmp-{id}.idx"));
206 304
        let mut report = PackReport::default();
207 -
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(loose.len());
305 +
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(refs.len());
208 306
        {
209 307
            let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?);
210 308
            f.write_all(PACK_MAGIC)?;
211 309
            let mut offset = PACK_MAGIC.len() as u64;
212 -
            for (r, _) in &loose {
310 +
            for r in &refs {
213 311
                let (ty, body) = self.get(r)?;
214 312
                let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
215 313
                let (flags, stored) = if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) };
…
28 unchanged lines
244 342
        }
245 343
        fs::rename(&tmp_pack, &pack_path)?;
246 344
        fs::rename(&tmp_idx, &idx_path)?;
345 +
        for p in old_packs {
346 +
            fs::remove_file(p)?;
347 +
            fs::remove_file(p.with_extension("idx"))?;
348 +
        }
247 349
        self.reload_packs()?;
248 -
        for (_, path) in &loose {
350 +
        for path in loose_paths {
249 351
            fs::remove_file(path)?;
250 352
            if let Some(dir) = path.parent() {
251 353
                let _ = fs::remove_dir(dir);
…
265 unchanged lines
517 619
    }
518 620
519 621
    #[test]
622 +
    fn prune_removes_unreachable_loose_and_packed_objects() {
623 +
        use std::collections::HashSet;
624 +
        let dir = tempfile::tempdir().unwrap();
625 +
        let s = Store::open(dir.path()).unwrap();
626 +
        let keep_a = s.put(ObjectType::Chunk, b"keep a").unwrap();
627 +
        let drop_a = s.put(ObjectType::Chunk, b"drop a").unwrap();
628 +
        s.pack_loose().unwrap();
629 +
        let keep_b = s.put(ObjectType::Chunk, b"keep b").unwrap();
630 +
        let drop_b = s.put(ObjectType::Chunk, b"drop b").unwrap();
631 +
        let sk = crate::identity::SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
632 +
        let id = sk.identity("a", "b", 0);
633 +
        let key = s.put_identity(&id).unwrap();
634 +
        s.put_signature(&drop_b, &sk.sign(crate::identity::Domain::Change, &key, &drop_b)).unwrap();
635 +
        s.index_seal(&drop_a, &ObjectRef([3; 32])).unwrap();
636 +
        let keep: HashSet<ObjectRef> = [keep_a, keep_b, key].into_iter().collect();
637 +
        // Everything is too young under a grace period.
638 +
        let r = s.prune(&keep, std::time::Duration::from_secs(3600)).unwrap();
639 +
        assert_eq!((r.objects, r.deferred), (0, 2));
640 +
        let r = s.prune(&keep, std::time::Duration::ZERO).unwrap();
641 +
        assert_eq!(r.objects, 2, "{r:?}");
642 +
        assert!(s.has(&keep_a) && s.has(&keep_b) && s.has(&key));
643 +
        assert!(!s.has(&drop_a) && !s.has(&drop_b));
644 +
        assert!(s.signatures(&drop_b).unwrap().is_empty());
645 +
        assert!(s.seals_for(&drop_a).unwrap().is_empty());
646 +
        assert_eq!(s.pack_count(), 1);
647 +
        assert_eq!(s.get(&keep_a).unwrap().1, b"keep a");
648 +
        assert_eq!(s.list().unwrap().len(), 3);
649 +
    }
650 +
651 +
    #[test]
520 652
    fn signatures_are_stored_beside_objects() {
521 653
        use crate::identity::{Domain, SecretKey};
522 654
        let dir = tempfile::tempdir().unwrap();
…
25 unchanged lines
modified src/cli/mod.rs +8 −4
…
62 unchanged lines
63 63
    Resolve,
64 64
    /// Undo the last operation
65 65
    Undo,
66 -
    /// Pack loose objects into a compressed pack
67 -
    Gc,
66 +
    /// Pack loose objects and prune unreachable ones older than the grace period
67 +
    Gc {
68 +
        /// Ignore the grace period (core.gc-grace) and prune immediately
69 +
        #[arg(long)]
70 +
        now: bool,
71 +
    },
68 72
    /// List operations
69 73
    #[command(name = "op")]
70 74
    Op {
…
45 unchanged lines
116 120
        #[command(subcommand)]
117 121
        cmd: PolicyCmd,
118 122
    },
119 -
    /// Set a configuration key (author.*, core.sign, workspace.bookmark, remote.<name>.url|token|ca)
123 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, workspace.bookmark, remote.<name>.url|token|ca)
120 124
    Config {
121 125
        key: String,
122 126
        value: String,
…
71 unchanged lines
194 198
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
195 199
        Cmd::Resolve => workspace::resolve(),
196 200
        Cmd::Undo => workspace::undo(),
197 -
        Cmd::Gc => workspace::gc(),
201 +
        Cmd::Gc { now } => workspace::gc(now),
198 202
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
199 203
        Cmd::Clone { url, dest } => sync::clone(&url, dest),
200 204
        Cmd::Remote { cmd } => match cmd {
…
23 unchanged lines
modified src/cli/workspace.rs +15 −11
…
183 unchanged lines
184 184
    Ok(0)
185 185
}
186 186
187 -
pub fn gc() -> Result<i32> {
187 +
pub fn gc(now: bool) -> Result<i32> {
188 188
    let repo = open()?;
189 -
    let r = repo.gc()?;
190 -
    if r.objects == 0 {
191 -
        println!("Nothing to pack; {} pack(s) present.", repo.store().pack_count());
192 -
    } else {
189 +
    let r = repo.gc(now)?;
190 +
    if r.packed.objects > 0 {
193 191
        println!(
194 -
            "Packed {} into one pack: {} bytes -> {} bytes ({:.0}%); {} pack(s) present.",
195 -
            plural(r.objects, "object"),
196 -
            r.raw_bytes,
197 -
            r.packed_bytes,
198 -
            100.0 * r.packed_bytes as f64 / r.raw_bytes.max(1) as f64,
199 -
            repo.store().pack_count()
192 +
            "Packed {}: {} bytes -> {} bytes ({:.0}%).",
193 +
            plural(r.packed.objects, "loose object"),
194 +
            r.packed.raw_bytes,
195 +
            r.packed.packed_bytes,
196 +
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
200 197
        );
201 198
    }
199 +
    println!("{} reachable; pruned {} ({} bytes){}; {} present.",
200 +
        plural(r.reachable, "object"),
201 +
        plural(r.pruned.objects, "unreachable object"),
202 +
        r.pruned.bytes,
203 +
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
204 +
        plural(repo.store().pack_count(), "pack")
205 +
    );
202 206
    Ok(0)
203 207
}
204 208
…
38 unchanged lines