Skip to content
zit Artifact [19c037c140]
Login

Artifact [19c037c140]

Artifact 19c037c14000b9df0fde99536d767ef952d9fadd5c0118afe17899fc66ebd8f4:


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