//! `zit purge`: remove check-ins, keep them recoverable.
//!
//! The decision lives in `purge.zig` and the recovery store in `graveyard.zig`;
//! this file is the verb around them. It resolves the operator's names, renders
//! the plan, and — only under `--force` — buries the artifacts and hands the
//! drop set to the same `rewriteAndSwap` that `zit shun --force` uses.
//!
//! ORDER OF THE TWO DURABLE WRITES: the graveyard is written FIRST, then the
//! store is rewritten. A crash between them leaves a graveyard event whose
//! artifacts are still in the store — `purge undo` restores what is already
//! there, which is a no-op, and `purge obliterate` discards a copy of live
//! content. Both are harmless. The other order is not: a store rewritten before
//! its content was buried loses the content outright on a crash, which is
//! exactly the failure the graveyard exists to prevent.
//!
//! `purge artifacts` adds a third write, the thinned list, AFTER the swap. A
//! crash before it leaves a removed version unlisted, so a later pull may fetch
//! it back; the other order would list a version the store still holds, and
//! serve would then refuse it to peers. A failed rewrite rolls nothing back:
//! another purge may have written the graveyard since this one read it.
const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const graveyard = zit.graveyard;
const thinned = zit.thinned;
const logstore = zit.logstore;
const pack = zit.pack;
const purge = zit.purge;
const storage = zit.storage;
const main = @import("../main.zig");
const cmd_sync = @import("sync.zig");
const Repository = zit.Repository;
const artifact = zit.artifact;
const repo_filename = main.repo_filename;
pub fn runPurge(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
const parsed = switch (cli.parsePurgeArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "purge", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "purge", .extra_argument, tok),
.bad_subcommand => |tok| return cli.usageError(streams, "purge", .bad_subcommand, tok),
.bad_reason => |tok| return cli.usageError(streams, "purge", .unknown_option, tok),
.no_name => return cli.usageError(streams, "purge", .no_name, ""),
.option_not_for_subcommand => |tok| return cli.usageError(streams, "purge", .option_not_for_subcommand, tok),
};
if (parsed.help) {
streams.out.writeAll(main.purge_help) catch {};
return .ok;
}
// Every subcommand needs the store directory; only `checkins` needs name
// resolution, so the repository open that costs a derive is deferred into
// that arm rather than paid by `purge list`.
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch
return oom(streams)) orelse {
streams.err.writeAll(main.not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
// Worktrees: the graveyard and the rewrite both belong beside the STORE, so a
// detached worktree resolves its pointer once, here.
if (cli.storeDirOf(io, root_dir) catch return readFail(streams)) |resolved| {
root_dir.close(io);
root_dir = resolved;
}
return switch (parsed.sub) {
.list => runList(gpa, io, arena, root_dir, streams),
.undo => runUndo(gpa, io, arena, root_dir, parsed, streams),
.obliterate => runObliterate(gpa, io, arena, root_dir, parsed, streams),
.checkins => {
// `-R` names a store, and the one refusal with no `--force`
// override -- "the check-in this checkout is on" -- has nothing to
// guard there. Refused here rather than for the whole verb, as
// `landing accept` refuses it, so `artifacts` and the graveyard
// subcommands still serve a bare store.
if (amb.repo_override != null) {
streams.err.writeAll(
"zit purge: -R is not supported by 'purge checkins'; it guards the check-in a checkout is on, " ++
"and -R names a repository with no working tree\n",
) catch {};
return .usage;
}
return runCheckins(gpa, io, arena, root_dir, parsed, streams, amb);
},
.artifacts => runArtifacts(gpa, io, arena, root_dir, parsed, streams, amb),
};
}
fn runList(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
const events = graveyard.read(arena, io, root_dir) catch |e| return graveFail(streams, e);
// Data only, one line per event, newest last — the order they were buried
// in, which is the order an operator undoes them in.
for (events) |ev| {
streams.out.print("{d} {d} {d} {d} {s}\n", .{
ev.id,
ev.when,
ev.artifacts.len,
ev.size(),
ev.reason,
}) catch {};
}
_ = gpa;
return .ok;
}
fn runUndo(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
parsed: cli.PurgeArgs,
streams: cli.Streams,
) cli.Exit {
if (parsed.name_count != 1) {
streams.err.writeAll("zit purge undo: name exactly one event id\n") catch {};
return .failure;
}
const want = std.fmt.parseInt(u32, parsed.names[0], 10) catch {
streams.err.print("zit purge undo: '{s}' is not an event id\n", .{parsed.names[0]}) catch {};
return .failure;
};
const events = graveyard.read(arena, io, root_dir) catch |e| return graveFail(streams, e);
const at = indexOfEvent(events, want) orelse {
streams.err.print("zit purge undo: no buried event {d}\n", .{want}) catch {};
return .failure;
};
// Restoring is an ordinary append: the log is append-only, so putting the
// artifacts back is the same operation that stored them the first time, and
// the next open re-derives the check-in DAG from them. No rewrite runs.
//
// An append, so it takes the store's append lock like every other writer:
// unserialized, a concurrent commit or pull scans the same end and one of
// the two writes lands on top of the other with valid frames, which no
// checksum can tell from a clean write. `openStoreAppend` also resolves a
// detached worktree's store, and `LogStore.open` scans for the end only
// once the lock is held; a reader's handle (`openReadOnly`) cannot append.
// `hash_kind` 0 is a don't-care on a non-empty store: it is read from the
// header.
var store_append = cli.openStoreAppend(io, root_dir) catch |e| switch (e) {
error.StoreBusy => return .failure, // the lock notice already said it
else => return readFail(streams),
};
defer store_append.close(io);
const file = store_append.file;
const st = file.stat(io) catch return readFail(streams);
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch return readFail(streams);
defer log.deinit(gpa);
var restored: usize = 0;
for (events[at].artifacts) |a| {
// Already present: a crash between the two durable writes leaves
// exactly this state, so it is a no-op rather than an error.
if (log.lookup(a.hash) != null) continue;
_ = log.putArtifact(a.hash, a.bytes, null) catch return writeFail(streams);
restored += 1;
}
log.commit() catch return writeFail(streams);
// The event is dropped only after its content is durably back.
const kept = arena.alloc(graveyard.Event, events.len - 1) catch return oom(streams);
var n: usize = 0;
for (events, 0..) |ev, i| {
if (i == at) continue;
kept[n] = ev;
n += 1;
}
graveyard.write(gpa, io, root_dir, kept) catch |e| return graveFail(streams, e);
// The restored names are held again, so they are no longer thinned: a
// pull may fetch them and the readers read them. Names this event never
// thinned (a `purge checkins` event) are not in the list and change
// nothing.
{
const names = arena.alloc([]const u8, events[at].artifacts.len) catch return oom(streams);
for (events[at].artifacts, names) |a, *slot| {
slot.* = std.fmt.allocPrint(arena, "{x}", .{a.hash}) catch return oom(streams);
}
thinned.remove(gpa, io, root_dir, names) catch |e| return thinFail(streams, e);
}
streams.out.print("restored: {d} artifact{s} from event {d}\n", .{
restored,
plural(restored),
want,
}) catch {};
return .ok;
}
fn runObliterate(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
parsed: cli.PurgeArgs,
streams: cli.Streams,
) cli.Exit {
const events = graveyard.read(arena, io, root_dir) catch |e| return graveFail(streams, e);
// Resolved before anything is discarded, so an unknown id refuses the whole
// command rather than emptying the graves it did recognize.
var drop_ids = arena.alloc(u32, parsed.name_count) catch return oom(streams);
for (parsed.names[0..parsed.name_count], 0..) |tok, i| {
drop_ids[i] = std.fmt.parseInt(u32, tok, 10) catch {
streams.err.print("zit purge obliterate: '{s}' is not an event id\n", .{tok}) catch {};
return .failure;
};
if (indexOfEvent(events, drop_ids[i]) == null) {
streams.err.print("zit purge obliterate: no buried event {d}\n", .{drop_ids[i]}) catch {};
return .failure;
}
}
var bytes: u64 = 0;
var kept: std.ArrayList(graveyard.Event) = .empty;
for (events) |ev| {
if (contains(drop_ids, ev.id)) {
bytes += ev.size();
continue;
}
kept.append(arena, ev) catch return oom(streams);
}
// Refuse-by-default, like the purge itself: this is the one step in the
// whole verb that nothing can undo.
if (!parsed.force) {
streams.err.print(
"zit purge obliterate: would discard {d} event{s} holding {d} bytes, unrecoverably\n" ++
"nothing was discarded; pass --force to proceed\n",
.{ drop_ids.len, plural(drop_ids.len), bytes },
) catch {};
return .failure;
}
graveyard.write(gpa, io, root_dir, kept.items) catch |e| return graveFail(streams, e);
streams.out.print("obliterated: {d} event{s}, {d} bytes\n", .{
drop_ids.len,
plural(drop_ids.len),
bytes,
}) catch {};
return .ok;
}
fn runCheckins(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
parsed: cli.PurgeArgs,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
// Names resolve through the ordinary §8.3 procedure, so `zit purge
// checkins tip` means what it means everywhere else. The repository is
// opened for that alone and closed before the store is: the plan and the
// rewrite work from a read-only open of the log, exactly as `shun` does,
// and holding a derived state across a generation swap would leave it
// describing a store that no longer exists.
var digests = arena.alloc([]const u8, parsed.name_count) catch return oom(streams);
var checkout_digest: ?[]const u8 = null;
{
var repo: Repository = undefined;
var repo_dir: std.Io.Dir = undefined;
const rc = main.openRepo(gpa, io, arena, streams, "purge", amb.repo_override, parsed.no_cache, false, &repo, &repo_dir);
if (rc != .ok) return rc;
defer repo_dir.close(io);
defer repo.deinit(gpa);
for (parsed.names[0..parsed.name_count], 0..) |name, i| {
const rid = repo.resolve(name, .checkin) catch {
streams.err.print("zit purge: no check-in named '{s}'\n", .{name}) catch {};
cli.horizonNameHint(gpa, io, repo_dir, streams, "purge");
return .failure;
};
digests[i] = arena.dupe(u8, repo.hashOf(rid)) catch return oom(streams);
}
if (repo.currentCheckoutRid()) |rid| {
checkout_digest = arena.dupe(u8, repo.hashOf(rid)) catch return oom(streams);
}
}
var file = cli.openStoreRead(io, root_dir) catch |e| return main.storeReadFail(streams, "purge", e);
defer file.close(io);
const st = file.stat(io) catch return readFail(streams);
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return readFail(streams);
defer log.deinit(gpa);
const src_key = log.logKey() catch return readFail(streams);
var targets = arena.alloc(purge.Target, parsed.name_count) catch return oom(streams);
for (parsed.names[0..parsed.name_count], 0..) |name, i| {
targets[i] = .{
.name = name,
// The repository view is keyed by lowercase-hex artifact NAME while
// the log is keyed by binary digest, so the crossing goes through
// the hex resolver rather than `lookup`. Resolved above through the
// repository, so a name that got this far always has a rid here.
.rid = (log.resolveHexPrefix(digests[i]) catch null) orelse {
streams.err.print("zit purge: '{s}' names no artifact in this store\n", .{name}) catch {};
return .failure;
},
};
}
const checkout_rid: ?logstore.Rid = if (checkout_digest) |d|
(log.resolveHexPrefix(d) catch null)
else
null;
const plan = (purge.plan(gpa, arena, &log, targets, checkout_rid) catch |e|
return planFail(streams, e)) orelse {
renderBlockers(streams.err, targets);
return .failure;
};
renderPlan(streams.out, plan, parsed.explain, &log);
if (parsed.dry_run) return .ok;
if (!parsed.force) {
streams.err.writeAll(
"nothing was removed; pass --force to proceed\n" ++
"a purge is recoverable with `zit purge undo` until it is obliterated\n",
) catch {};
return .failure;
}
// Bury first — see the header note on the order of the two durable writes.
var buried = arena.alloc(graveyard.Artifact, plan.drop.len) catch return oom(streams);
for (plan.drop, 0..) |rid, i| {
const bytes = (log.content(arena, rid) catch return readFail(streams)) orelse {
streams.err.writeAll("zit purge: an artifact in the plan has no content to bury\n") catch {};
return .failure;
};
buried[i] = .{
.hash = arena.dupe(u8, log.hashOf(rid)) catch return oom(streams),
.bytes = bytes,
};
}
const events = graveyard.read(arena, io, root_dir) catch |e| return graveFail(streams, e);
var all = arena.alloc(graveyard.Event, events.len + 1) catch return oom(streams);
@memcpy(all[0..events.len], events);
all[events.len] = .{
.id = graveyard.nextId(events),
.when = amb.now_epoch,
.reason = parsed.reason orelse "",
.artifacts = buried,
};
graveyard.write(gpa, io, root_dir, all) catch |e| return graveFail(streams, e);
var set: pack.NameSet = .{};
defer set.deinit(gpa);
for (plan.drop) |rid| set.add(gpa, log.hashOf(rid)) catch |e| switch (e) {
error.OutOfMemory => return oom(streams),
// A stored artifact whose name is not a complete digest is a corrupt
// store, and that is the only place this can come from.
error.BadKey => return planFail(streams, error.Corrupt),
};
// No `extra_shuns`: a purge records no tombstone, which is exactly what
// makes it recoverable and exactly why it is local. `shun` is the verb that
// also stops the content coming back.
const stats = cmd_sync.rewriteAndSwap(gpa, io, root_dir, &log, src_key, set.filter(), &.{}) catch |e| switch (e) {
error.TailChanged => {
streams.err.writeAll(
"zit purge: the repository changed while it was being examined; nothing was removed\n",
) catch {};
return .failure;
},
error.RewriteBusy => {
streams.err.writeAll(
"zit purge: another repository rewrite is in progress; nothing was removed\n",
) catch {};
return .failure;
},
else => return planFail(streams, error.Storage),
};
streams.out.print("purged: {d} artifact{s}, {d} bytes reclaimed, event {d}\n", .{
plan.drop.len,
plural(plan.drop.len),
stats.bytes_before -| stats.bytes_after,
all[events.len].id,
}) catch {};
return .ok;
}
/// `zit purge artifacts HASH...`: thin chosen old file versions. The same
/// two durable writes as `checkins`, in the same order (graveyard, then the
/// rewrite), with the thinned list written between them: it records names the
/// rewrite is about to remove, so a crash after it leaves names whose content
/// is still here, which every reader still reads and a later `purge undo`
/// clears.
fn runArtifacts(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
parsed: cli.PurgeArgs,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
var thin = thinned.load(gpa, io, root_dir) catch |e| return thinFail(streams, e);
defer thin.deinit(gpa);
var file = cli.openStoreRead(io, root_dir) catch |e| return main.storeReadFail(streams, "purge", e);
defer file.close(io);
const st = file.stat(io) catch return readFail(streams);
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return readFail(streams);
defer log.deinit(gpa);
const src_key = log.logKey() catch return readFail(streams);
const n = parsed.name_count;
var targets = arena.alloc(purge.ArtifactTarget, n) catch return oom(streams);
const hexes = arena.alloc([]const u8, n) catch return oom(streams);
const skipped = arena.alloc(bool, n) catch return oom(streams);
const why_path = arena.alloc([]const u8, n) catch return oom(streams);
const why_leaf = arena.alloc([]const u8, n) catch return oom(streams);
for (parsed.names[0..n], 0..) |tok, i| {
targets[i] = .{ .name = tok };
hexes[i] = "";
skipped[i] = false;
why_path[i] = "";
why_leaf[i] = "";
const lower = std.ascii.allocLowerString(arena, tok) catch return oom(streams);
var hex_ok = lower.len >= 4 and lower.len <= 2 * logstore.max_hash_len;
for (lower) |c| hex_ok = hex_ok and std.ascii.isHex(c);
if (!hex_ok) {
targets[i].blocker = .not_found;
continue;
}
const rid = log.resolveHexPrefix(lower) catch {
targets[i].blocker = .ambiguous;
continue;
};
if (rid) |r| {
// A held name is purged even when the list carries it (a run that
// stopped before its swap); the list add is idempotent.
const full = std.fmt.allocPrint(arena, "{x}", .{log.hashOf(r)}) catch return oom(streams);
targets[i].rid = r;
hexes[i] = full;
// The same version named twice is removed once.
for (hexes[0..i], skipped[0..i], targets[0..i]) |h, sk, tg| {
if (!sk and tg.blocker == null and std.mem.eql(u8, h, full)) skipped[i] = true;
}
continue;
}
// Not held: already thinned when the list carries it (idempotent), else
// unknown.
if (thinnedByPrefix(&thin, lower)) |full| {
skipped[i] = true;
hexes[i] = full;
continue;
}
targets[i].blocker = .not_found;
}
// Classify against the store, then guard every open leaf and the checkout.
var live: std.ArrayList(purge.ArtifactTarget) = .empty;
var live_at: std.ArrayList(usize) = .empty;
for (targets, 0..) |tg, i| {
if (skipped[i] or tg.blocker != null) continue;
live.append(arena, tg) catch return oom(streams);
live_at.append(arena, i) catch return oom(streams);
}
purge.scanArtifacts(gpa, arena, &log, live.items) catch |e| return planFail(streams, e);
for (live.items, live_at.items) |tg, i| targets[i] = tg;
{
var repo: Repository = undefined;
var repo_dir: std.Io.Dir = undefined;
const rc = main.openRepo(gpa, io, arena, streams, "purge", amb.repo_override, parsed.no_cache, true, &repo, &repo_dir);
if (rc != .ok) return rc;
defer repo_dir.close(io);
defer repo.deinit(gpa);
if (guardTips(gpa, arena, &repo, targets, hexes, skipped, why_path, why_leaf, streams)) |exit| return exit;
}
var refused = false;
var drop: std.ArrayList(logstore.Rid) = .empty;
var drop_hex: std.ArrayList([]const u8) = .empty;
var bytes: u64 = 0;
for (targets, 0..) |tg, i| {
if (skipped[i]) continue;
if (tg.blocker != null) {
refused = true;
continue;
}
drop.append(arena, tg.rid) catch return oom(streams);
drop_hex.append(arena, hexes[i]) catch return oom(streams);
bytes += log.frameSpanOf(tg.rid);
}
streams.out.print("purge: {d} file version{s}, {d} bytes\n", .{ drop.items.len, plural(drop.items.len), bytes }) catch {};
for (targets, 0..) |tg, i| {
if (skipped[i]) {
if (hexes[i].len != 0 and thin.contains(hexes[i]))
streams.out.print(" already purged {s}\n", .{hexes[i]}) catch {};
continue;
}
if (tg.blocker != null) continue;
if (!(parsed.explain or parsed.dry_run)) continue;
streams.out.print(" remove {s}", .{hexes[i]}) catch {};
for (tg.paths, 0..) |p, k| streams.out.print("{s}{s}", .{ if (k == 0) " " else ", ", p }) catch {};
streams.out.writeAll("\n") catch {};
}
if (refused) {
streams.err.writeAll("zit purge: refused; nothing was removed\n") catch {};
for (targets, 0..) |tg, i| {
const b = tg.blocker orelse continue;
describeArtifactBlocker(streams.err, tg, b, why_path[i], why_leaf[i]);
}
return .failure;
}
if (parsed.dry_run or drop.items.len == 0) return .ok;
if (!parsed.force) {
streams.err.writeAll(
"nothing was removed; pass --force to proceed\n" ++
"a purge is recoverable with `zit purge undo` until it is obliterated\n",
) catch {};
return .failure;
}
// Bury first -- see the header note on the order of the durable writes.
const events = graveyard.read(arena, io, root_dir) catch |e| return graveFail(streams, e);
if (events.len >= graveyard.max_events) return graveyardFull(streams);
var buried = arena.alloc(graveyard.Artifact, drop.items.len) catch return oom(streams);
for (drop.items, 0..) |rid, i| {
const content = (log.content(arena, rid) catch return readFail(streams)) orelse {
streams.err.writeAll("zit purge: an artifact in the plan has no content to bury\n") catch {};
return .failure;
};
buried[i] = .{
.hash = arena.dupe(u8, log.hashOf(rid)) catch return oom(streams),
.bytes = content,
};
}
var all = arena.alloc(graveyard.Event, events.len + 1) catch return oom(streams);
@memcpy(all[0..events.len], events);
all[events.len] = .{
.id = graveyard.nextId(events),
.when = amb.now_epoch,
.reason = parsed.reason orelse "",
.artifacts = buried,
};
graveyard.write(gpa, io, root_dir, all) catch |e| return graveFail(streams, e);
var set: pack.NameSet = .{};
defer set.deinit(gpa);
for (drop.items) |rid| set.add(gpa, log.hashOf(rid)) catch |e| switch (e) {
error.OutOfMemory => return oom(streams),
error.BadKey => return planFail(streams, error.Corrupt),
};
// A kept version stored as a delta of a thinned one is re-encoded by the
// rewrite (the pack reclaim contract), so it stays readable.
const stats = cmd_sync.rewriteAndSwap(gpa, io, root_dir, &log, src_key, set.filter(), &.{}) catch |e| {
switch (e) {
error.TailChanged => streams.err.writeAll(
"zit purge: the repository changed while it was being examined; nothing was removed\n",
) catch {},
error.RewriteBusy => streams.err.writeAll(
"zit purge: another repository rewrite is in progress; nothing was removed\n",
) catch {},
else => return planFail(streams, error.Storage),
}
return .failure;
};
thinned.add(gpa, io, root_dir, drop_hex.items) catch |e| {
_ = thinFail(streams, e);
streams.err.writeAll(
"zit purge: the versions were removed but not listed; a pull may fetch them back\n",
) catch {};
return .failure;
};
streams.out.print("purged: {d} file version{s}, {d} bytes reclaimed, event {d}\n", .{
drop.items.len,
plural(drop.items.len),
stats.bytes_before -| stats.bytes_after,
all[events.len].id,
}) catch {};
return .ok;
}
/// The thinned name `prefix` abbreviates, when exactly one does.
fn thinnedByPrefix(thin: *const thinned.Set, prefix: []const u8) ?[]const u8 {
var found: ?[]const u8 = null;
var it = thin.names.keyIterator();
while (it.next()) |k| {
if (!std.mem.startsWith(u8, k.*, prefix)) continue;
if (found != null) return null;
found = k.*;
}
return found;
}
/// Refuse every target an open leaf or the current checkout still holds. Asks
/// each tip only for the paths the targets are stored under (the narrow
/// per-name lookup), never for its whole tree. A tip whose file set cannot be
/// read refuses the run: the guard could not be checked. Null to continue.
fn guardTips(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
targets: []purge.ArtifactTarget,
hexes: []const []const u8,
skipped: []const bool,
why_path: [][]const u8,
why_leaf: [][]const u8,
streams: cli.Streams,
) ?cli.Exit {
var named: std.ArrayList(Repository.NamedHash) = .empty;
for (targets, 0..) |tg, i| {
if (skipped[i] or tg.blocker != null) continue;
for (tg.paths) |p| {
for (named.items) |nh| {
if (std.mem.eql(u8, nh.name, p)) break;
} else named.append(arena, .{ .name = p }) catch return oom(streams);
}
}
if (named.items.len == 0) return null;
const Tip = struct { rid: logstore.Rid, checkout: bool };
var tips: std.ArrayList(Tip) = .empty;
const checkout_rid = repo.currentCheckoutRid();
if (checkout_rid) |c| tips.append(arena, .{ .rid = c, .checkout = true }) catch return oom(streams);
var it = repo.state().leaves();
while (it.next()) |rid| {
if (!repo.state().isOpenLeaf(rid)) continue;
if (checkout_rid != null and checkout_rid.? == rid) continue;
tips.append(arena, .{ .rid = rid, .checkout = false }) catch return oom(streams);
}
var memo: Repository.NamedMemo = .{};
defer memo.deinit(gpa);
for (tips.items) |tip| {
for (named.items) |*nh| nh.hash = null;
const held = repo.hashesNamed(gpa, arena, tip.rid, named.items, &memo, null) catch |e| switch (e) {
error.OutOfMemory => return oom(streams),
else => {
streams.err.print(
"zit purge: the file set of open leaf {s} could not be read, so the versions it holds are unknown; nothing was removed\n",
.{cli.shortHash(repo.hashOf(tip.rid))},
) catch {};
return .failure;
},
};
if (!held) continue; // a tip whose manifest this store does not hold names nothing here
for (named.items) |nh| {
const h = nh.hash orelse continue;
for (targets, 0..) |*tg, i| {
if (skipped[i] or tg.blocker != null) continue;
if (!std.mem.eql(u8, h, hexes[i])) continue;
tg.blocker = if (tip.checkout) .current_checkout else .open_leaf;
why_path[i] = nh.name;
why_leaf[i] = arena.dupe(u8, repo.hashOf(tip.rid)) catch return oom(streams);
}
}
}
return null;
}
fn describeArtifactBlocker(err: *std.Io.Writer, tg: purge.ArtifactTarget, b: purge.ArtifactBlocker, path: []const u8, leaf: []const u8) void {
switch (b) {
.not_found => err.print(" {s}: no such artifact\n", .{tg.name}) catch {},
.ambiguous => err.print(" {s}: ambiguous prefix\n", .{tg.name}) catch {},
.not_held => err.print(" {s}: this store does not hold its content\n", .{tg.name}) catch {},
.not_a_file => {
const kind = tg.kind orelse artifact.ArtifactType.cluster;
if (kind == .manifest) {
err.print(" {s}: a check-in, not a file version; use 'zit purge checkins' to remove a leaf check-in\n", .{tg.name}) catch {};
} else {
err.print(" {s}: a {s} artifact, not a file version; removing structure orphans history ('zit shun' removes it for good)\n", .{ tg.name, kind.externalName() }) catch {};
}
},
.open_leaf => err.print(" {s}: '{s}' at open leaf {s}; every branch tip keeps its files\n", .{ tg.name, path, cli.shortHash(leaf) }) catch {},
.current_checkout => err.print(" {s}: '{s}' at the current checkout {s}; the working tree keeps its files\n", .{ tg.name, path, cli.shortHash(leaf) }) catch {},
}
}
fn renderPlan(out: *std.Io.Writer, plan: purge.Plan, explain: bool, log: *logstore.LogStore) void {
out.print("purge: {d} check-in{s}, {d} tag{s}, {d} other artifact{s}, {d} bytes\n", .{
plan.checkins, plural(plan.checkins),
plan.controls, plural(plan.controls),
plan.others, plural(plan.others),
plan.bytes,
}) catch {};
if (!explain) return;
for (plan.drop) |rid| {
var buf: [2 * logstore.max_hash_len]u8 = undefined;
out.print(" {s}\n", .{std.fmt.bufPrint(&buf, "{x}", .{log.hashOf(rid)}) catch continue}) catch {};
}
}
fn renderBlockers(err: *std.Io.Writer, targets: []const purge.Target) void {
err.writeAll("zit purge: refused; nothing was removed\n") catch {};
for (targets) |t| {
const b = t.blocker orelse continue;
err.print(" {s}: {s}\n", .{ t.name, b.describe() }) catch {};
}
}
fn indexOfEvent(events: []const graveyard.Event, id: u32) ?usize {
for (events, 0..) |ev, i| if (ev.id == id) return i;
return null;
}
fn contains(haystack: []const u32, needle: u32) bool {
for (haystack) |h| if (h == needle) return true;
return false;
}
fn plural(n: usize) []const u8 {
return if (n == 1) "" else "s";
}
fn oom(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit purge: out of memory\n") catch {};
return .failure;
}
fn readFail(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit purge: this repository could not be read\n") catch {};
return .failure;
}
fn writeFail(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit purge: this repository could not be written\n") catch {};
return .failure;
}
fn graveFail(streams: cli.Streams, e: anyerror) cli.Exit {
streams.err.print("zit purge: the graveyard could not be read or written ({s})\n", .{@errorName(e)}) catch {};
return .failure;
}
fn thinFail(streams: cli.Streams, e: anyerror) cli.Exit {
streams.err.print("zit purge: the thinned list (.zit-thinned) could not be read or written ({s})\n", .{@errorName(e)}) catch {};
return .failure;
}
fn graveyardFull(streams: cli.Streams) cli.Exit {
streams.err.print(
"zit purge: the graveyard holds {d} events, its limit; nothing was removed\n" ++
"discard old ones with 'zit purge obliterate --force ID...' first\n",
.{graveyard.max_events},
) catch {};
return .failure;
}
fn planFail(streams: cli.Streams, e: anyerror) cli.Exit {
streams.err.print("zit purge: the plan could not be computed ({s})\n", .{@errorName(e)}) catch {};
return .failure;
}