Skip to content
zit Artifact [c4d0df0d8e]
Login

Artifact [c4d0df0d8e]

Artifact c4d0df0d8ec241eb4a6bc261e5471b4f3512143cbeab8546a6ea5570ca17937d:


//! `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;
}