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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
//! `mkit gc` — reclaim unreachable objects (mark-and-sweep prune).
//!
//! Under the repo lock: expire the recovery log, compute the live-object
//! keep-set (every object reachable from the retention roots — refs,
//! stash, in-progress op state, attestations, and the recovery log), then
//! delete unreachable objects that are older than the grace window.
//!
//! Safety: the live set is computed **before** anything is deleted and
//! the whole run is **fail-closed** — a missing/corrupt root, a malformed
//! ref, or the reachability cap aborts with nothing removed (see
//! `mkit_core::ops::gc`). Unreachable objects younger than the grace
//! window (default 14 days) are kept as a belt-and-suspenders against
//! objects written just before a reference that points at them. Use
//! `--dry-run` to preview, and `--grace-secs 0` to prune every
//! unreachable object regardless of age.
//!
//! Concurrency: gc holds the repo lock for its whole run, and the
//! root-publishing paths now take the same lock around their object-write +
//! ref/attestation-publish window — `tag` (annotated/signed), `fetch` /
//! `pull`, and `attest` (#267) — so they are serialized against gc. The
//! grace window remains the belt-and-suspenders net (like Git's default
//! `gc.pruneExpire`, vs `prune --expire=now`): `--grace-secs 0` bypasses it
//! and prints a warning, but with the publishers now locked it is safe even
//! under concurrency.
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::Parser;
use mkit_core::ops::recovery::{self, RetentionPolicy};
use mkit_core::ops::run_gc;
use mkit_core::store::ObjectStore;
use crate::clap_shim;
use crate::exit;
/// Default object grace window: 14 days, matching Git's `gc.pruneExpire`.
const DEFAULT_GRACE_SECS: u64 = 14 * 24 * 60 * 60;
#[derive(Debug, Parser)]
#[command(
name = "mkit gc",
about = "Reclaim unreachable objects (delete unreachable objects older than the grace window)."
)]
struct GcOpts {
/// Show what would be pruned without deleting anything.
#[arg(short = 'n', long = "dry-run")]
dry_run: bool,
/// Keep unreachable objects younger than this many seconds (default
/// 14 days). `0` prunes every unreachable object, but bypasses the
/// grace window that protects in-flight objects — only safe when no
/// other mkit process is operating on the repo.
#[arg(long = "grace-secs", value_name = "SECS", default_value_t = DEFAULT_GRACE_SECS)]
grace_secs: u64,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<GcOpts>("mkit gc", args) {
Ok(o) => o,
Err(code) => return code,
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
// Hold EVERY worktree's lock for the whole run (#493 Phase 3) —
// the shared lock spanning trees. Root collection unions each
// tree's HEAD/index/op-state, so gc must serialize against
// worktree/index-mutating commands in ALL trees, not just the
// invoking one, plus other gc runs. Acquisition order is
// deterministic (main first, then registry ids ascending, from
// `all_state_layouts`) so concurrent multi-lock takers cannot
// deadlock. It still does NOT serialize against the non-worktree
// root publishers (`tag`, `fetch`, `attest`) — those don't take
// this lock (#267); the grace window protects their in-flight
// objects, exactly as in the single-tree case.
// Registry lock FIRST (global lock order: worktrees.lock before
// any per-tree worktree.lock, see SPEC-WORKTREE §4.3): freezes the
// worktree set for the whole run, so a `worktree add` cannot
// register a fresh tree — and start staging into it — between
// enumeration and the sweep.
let _registry_lock = match super::acquire_worktrees_registry_lock(&layout) {
Ok(l) => l,
Err(code) => return code,
};
let state_layouts = match mkit_core::layout::all_state_layouts(&layout) {
Ok(l) => l,
Err(e) => return super::error(&format!("worktree registry: {e}"), exit::DATAERR),
};
let mut locks = Vec::with_capacity(state_layouts.len());
for tree in &state_layouts {
match super::acquire_worktree_lock(tree) {
Ok(l) => locks.push(l),
Err(code) => return code,
}
}
let _locks = locks;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if opts.grace_secs == 0 && !opts.dry_run {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: --grace-secs 0 prunes every unreachable object, bypassing the grace window; \
ensure no other mkit process is operating on this repo"
);
}
// Expire stale recovery entries first so they stop pinning objects;
// abort on error (fail closed — don't prune against a half-expired log).
// A dry run must not mutate state, so it only *counts* what would
// expire (and therefore reports a conservative prune set, since those
// soon-to-expire commits are still pinned during the preview).
let policy = RetentionPolicy::default();
let expired = if opts.dry_run {
match recovery::would_expire(&layout, now, &policy) {
Ok(n) => n,
Err(e) => return emit_err(&format!("recovery log: {e}"), exit::GENERAL_ERROR),
}
} else {
match recovery::expire(&layout, now, &policy) {
Ok(n) => n,
Err(e) => return emit_err(&format!("expire recovery log: {e}"), exit::CANTCREAT),
}
};
let report = match run_gc(&store, &layout, now, opts.grace_secs, opts.dry_run) {
Ok(r) => r,
Err(e) => return emit_err(&format!("gc: {e}"), exit::GENERAL_ERROR),
};
let mut stderr = std::io::stderr().lock();
let (prune_verb, expire_verb) = if report.dry_run {
("would prune", "would expire")
} else {
("pruned", "expired")
};
let _ = writeln!(
stderr,
"gc{}: {prune_verb} {} object(s), {} bytes; scanned {}, live {}, kept-recent {}; {expire_verb} {} recovery entr{}",
if report.dry_run { " (dry run)" } else { "" },
report.pruned,
report.bytes_reclaimed,
report.scanned,
report.live,
report.kept_recent,
expired,
if expired == 1 { "y" } else { "ies" },
);
exit::OK
}
use super::error as emit_err;