//! `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.
const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const graveyard = zit.graveyard;
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 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 => runCheckins(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);
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 {};
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 = root_dir.openFile(io, repo_filename, .{}) catch return readFail(streams);
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;
}
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 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;
}