Skip to content
zit Artifact [fbe1bdf508]
Login

Artifact [fbe1bdf508]

Artifact fbe1bdf508089704403c749a298e4920de51e6c3304794f54ba958f8fcfe101e:


//! `zit wiki` / `zit ticket` — record wiki and ticket-change artifacts.
//!
//! MACHINE VERBS. Quarry's subprocess boundary (quarry RFC 0001 §2) makes zit
//! the sole writer of `.zit` bytes, so quarry's wiki and ticket pages call
//! these instead of constructing artifact bytes themselves. That consumer
//! shapes every contract here: stdout carries ONLY the machine-read result
//! (the artifact hash, plus the minted ticket id for `ticket new`), human
//! diagnostics go to stderr, and the exit codes are exact — 0 recorded,
//! 1 user error, 2 usage. It also shapes their VISIBILITY: neither verb is
//! listed in the human `zit help` command list, deliberately — they are
//! plumbing, and a person browsing the porcelain should not meet them there.
//! Each still answers `zit wiki --help` / `zit help wiki` in full.
//!
//! Both operate on a bare store: `-R <repo>` needs no working tree, because
//! quarry serves bare stores. The write lands through `cli.openStoreAppend`,
//! so two racing quarry requests serialize on the store's own lock —
//! last-write-wins per fold semantics, never a torn log.
//!
//! The bytes come from `artifact.create`, the one canonical writer, and are
//! round-tripped through `artifact.parse` before storing IN EVERY BUILD MODE:
//! commit's parse self-check is safety-mode-only because its inputs are the
//! checkout's own state, but these inputs arrive from another process, and a
//! malformed artifact minted here would be quarry's data-loss bug to discover.

const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const artifact = @import("zit").artifact;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const cmd_sync = @import("sync.zig");

const main = @import("../main.zig");
const stateOom = main.stateOom;
const not_a_repo_msg = main.not_a_repo_msg;
const wiki_help = main.wiki_help;
const ticket_help = main.ticket_help;
const forum_help = main.forum_help;
const technote_help = main.technote_help;
const attach_help = main.attach_help;
const digest_len = main.digest_len;
const hexToDigest = main.hexToDigest;

/// One bound for both bodies: a wiki page or ticket change beyond this is not
/// authoring, it is a bulk import that should arrive some other way.
const max_body_bytes: usize = 16 * 1024 * 1024;

/// Upper bound on `technote add --tag` repetitions (TigerStyle: every loop and
/// buffer is finite by inspection). Fossil's own technote editor takes one
/// space-separated field, so a note wearing dozens of tags is already past any
/// real use; the cap exists so the T-run buffer is a fixed array.
const max_technote_tags: usize = 64;

/// The N card fossil would write, or null when fossil would omit it.
///
/// The rule is PER TYPE, and the divergence is fossil's, not an inconsistency
/// to tidy away (artifact-format section 4.7.12; verified against the source,
/// not inferred from the draft -- the draft states the READ default, which is
/// a different rule from when the card is WRITTEN):
///
///   * wiki (`wiki.c:2084`) and forum (`forum.c:1784`) write N only when the
///     mimetype is present, non-empty, AND NOT `text/x-fossil-wiki`. The
///     default is never spelled out redundantly.
///   * technote (`event.c:284`) writes N whenever it is present and non-empty,
///     with no default-suppression at all.
///
/// Emitting `N text/x-fossil-wiki` on a wiki page or forum post would produce
/// different BYTES, and so a different artifact id, than fossil mints from the
/// same input -- which is the whole compatibility claim.
pub fn nCard(mimetype: ?[]const u8, suppress_default: bool) ?artifact.NCard {
    const mt = mimetype orelse return null;
    if (mt.len == 0) return null; // fossil tests zMimetype[0]
    if (suppress_default and std.mem.eql(u8, mt, fossil_wiki_mimetype)) return null;
    return .{ .mimetype = mt };
}

/// The mimetype every W-card-bearing type falls back to when no N card is
/// present (artifact-format section 4.7.12).
pub const fossil_wiki_mimetype = "text/x-fossil-wiki";

pub fn runWiki(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var title: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var saw_commit = false;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(wiki_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "wiki", .unknown_option, tok);
        } else if (!saw_commit) {
            if (!std.mem.eql(u8, tok, "commit")) return cli.usageError(streams, "wiki", .unknown_option, tok);
            saw_commit = true;
        } else if (title == null) {
            title = tok;
        } else {
            return cli.usageError(streams, "wiki", .extra_argument, tok);
        }
    }
    if (!saw_commit or title == null) {
        streams.err.writeAll("zit wiki: usage: zit wiki commit <title> [-R repo] [--user NAME] [--mimetype TYPE] < body\n") catch {};
        return .usage;
    }
    if (title.?.len == 0) {
        streams.err.writeAll("zit wiki: the title must not be empty\n") catch {};
        return .failure;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit wiki: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    // The body IS stdin, whole: quarry pipes the page and closes the write
    // end, exactly the read-to-EOF contract this states.
    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit wiki: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .wiki,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = .{ .title = title.? },
        .n = nCard(mimetype, true), // wiki: suppresses the default
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "wiki", &m, null, null);
}

pub fn runTicket(
    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 Mode = enum { new, set };
    var mode: ?Mode = null;
    var ticket_id: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var fields: std.ArrayList(artifact.JCard) = .empty;
    defer fields.deinit(gpa);
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(ticket_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "ticket", .unknown_option, "--user");
            user = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "ticket", .unknown_option, tok);
        } else if (mode == null) {
            if (std.mem.eql(u8, tok, "new")) {
                mode = .new;
            } else if (std.mem.eql(u8, tok, "set")) {
                mode = .set;
            } else {
                return cli.usageError(streams, "ticket", .unknown_option, tok);
            }
        } else if (mode == .set and ticket_id == null) {
            ticket_id = tok;
        } else if (std.mem.indexOfScalar(u8, tok, '=')) |eq| {
            const field = tok[0..eq];
            const value = tok[eq + 1 ..];
            if (field.len == 0) {
                streams.err.print("zit ticket: empty field name in '{s}'\n", .{tok}) catch {};
                return .failure;
            }
            fields.append(gpa, .{ .field = field, .value = value }) catch return stateOom(streams, "ticket");
        } else {
            return cli.usageError(streams, "ticket", .extra_argument, tok);
        }
    }
    const chosen = mode orelse {
        streams.err.writeAll("zit ticket: usage: zit ticket new|set <ticket-id> [-R repo] [--user NAME] field=value ...\n") catch {};
        return .usage;
    };
    if (chosen == .set and ticket_id == null) {
        streams.err.writeAll("zit ticket: set needs a <ticket-id>\n") catch {};
        return .usage;
    }
    if (fields.items.len == 0) {
        streams.err.writeAll("zit ticket: at least one field=value is required\n") catch {};
        return .failure;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit ticket: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    // §4.5.2: J cards strictly ascend by field name. Sorting here is the
    // service; a DUPLICATE field is the caller's ambiguity ("which value
    // wins?") and is refused rather than resolved by position.
    std.mem.sort(artifact.JCard, fields.items, {}, jLessThan);
    var fi: usize = 1;
    while (fi < fields.items.len) : (fi += 1) {
        if (std.mem.eql(u8, fields.items[fi - 1].field, fields.items[fi].field)) {
            streams.err.print("zit ticket: field '{s}' given twice\n", .{fields.items[fi].field}) catch {};
            return .failure;
        }
    }

    // The id: minted for `new` (20 random bytes as 40-hex, fossil's shape),
    // validated and REQUIRED KNOWN for `set` — a typo'd id must not silently
    // open a second ticket.
    var id_buf: [40]u8 = undefined;
    const id: []const u8 = switch (chosen) {
        .new => blk: {
            var raw: [20]u8 = undefined;
            io.randomSecure(&raw) catch {
                streams.err.writeAll("zit ticket: no entropy source for a fresh ticket id\n") catch {};
                return .failure;
            };
            const hex = std.fmt.bytesToHex(raw, .lower);
            @memcpy(&id_buf, &hex);
            break :blk &id_buf;
        },
        .set => ticket_id.?,
    };
    if (id.len != 40 or !isLowerHex(id)) {
        streams.err.print("zit ticket: '{s}' is not a ticket id (40 lowercase hex)\n", .{id}) catch {};
        return .failure;
    }

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .ticket,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = .{ .ticket_id = id },
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        .f = &.{},
        .j = fields.items,
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "ticket", &m, if (chosen == .new) id else null, null);
}

fn jLessThan(_: void, a: artifact.JCard, b: artifact.JCard) bool {
    return artifact.compareBytes(a.field, b.field) == .lt;
}

fn isLowerHex(s: []const u8) bool {
    for (s) |c| {
        const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
        if (!ok) return false;
    }
    return true;
}

/// The author: `--user` wins, else the ambient OS user exactly as `commit`
/// defaults it. Empty is a refusal either way — real-world stores carry
/// manifests with empty U tokens from other writers' bugs, and this writer
/// will not mint more.
fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 {
    const chosen = user orelse (amb.user_env orelse return null);
    if (chosen.len == 0) return null;
    return chosen;
}

/// Serialize, self-check, store under the lock, report. `mint` is the fresh
/// ticket id `ticket new` prepends to its stdout line; everything else prints
/// the artifact hash alone. `verb` names the diagnostics.
fn recordAndPrint(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    verb: []const u8,
    m: *artifact.Manifest,
    mint: ?[]const u8,
    /// A blob to store before the record, in the same log open and
    /// commit, so an attachment never names bytes the store does not hold.
    /// Its hash is printed after the record's.
    pre_blob: ?[]const u8,
) cli.Exit {
    const bytes = artifact.create(m, gpa) catch return stateOom(streams, verb);
    defer gpa.free(bytes);

    // Round-trip in EVERY build mode (module doc says why). classify() ran
    // inside parse; the kind equality is the whole claim.
    var check = artifact.parse(gpa, bytes) catch {
        streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
        return .failure;
    };
    const kind_ok = check.kind == m.kind;
    check.deinit();
    if (!kind_ok) {
        streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
        return .failure;
    }

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, verb)) orelse {
        main.reportNoRoot(streams, io, verb, amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // `set` only exists for a ticket that exists: scan the store for any
    // artifact carrying this K card before appending. A full-store read, and
    // deliberately so — the store has no ticket index yet, and a wrong
    // "unknown" that silently minted a SECOND ticket would be worse than a
    // slow check. Quarry-scale stores read in milliseconds.
    // (For `new` the freshly minted id is absent by construction.)
    const knowledge_needed = mint == null and m.kind == .ticket;

    var file = cli.openStoreAppend(io, root_dir) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    defer log.deinit(gpa);

    if (knowledge_needed) {
        const known = ticketKnown(gpa, &log, m.k.?.ticket_id) catch return stateOom(streams, verb);
        if (!known) {
            streams.err.print("zit ticket: unknown ticket '{s}'\n", .{m.k.?.ticket_id}) catch {};
            return .failure;
        }
    }

    var digest: [32]u8 = undefined;
    var blob_name_buf: [64]u8 = undefined;
    var blob_name: ?[]const u8 = null;
    if (pre_blob) |blob| {
        var blob_digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(blob, &blob_digest, .{});
        blob_name = artifact.artifactName(.sha3_256, blob, &blob_name_buf);
        _ = log.putArtifact(&blob_digest, blob, null) catch {
            streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
            return .failure;
        };
    }
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
    _ = log.putArtifact(&digest, bytes, null) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    log.commit() catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };

    if (mint) |id| {
        streams.out.print("{s} {s}\n", .{ id, name }) catch {};
    } else {
        streams.out.print("{s}\n", .{name}) catch {};
    }
    if (blob_name) |b| streams.out.print("{s}\n", .{b}) catch {};
    return .ok;
}

/// Whether any stored artifact carries `K <id>`. The canonical writer puts
/// exactly one space and a trailing LF around the id and a D card always
/// precedes K, so the byte pattern `\nK <id>\n` is exact — no parse needed.
fn ticketKnown(gpa: std.mem.Allocator, log: *logstore.LogStore, id: []const u8) error{OutOfMemory}!bool {
    std.debug.assert(id.len == 40);
    var needle_buf: [44]u8 = undefined;
    needle_buf[0] = '\n';
    needle_buf[1] = 'K';
    needle_buf[2] = ' ';
    @memcpy(needle_buf[3..43], id);
    needle_buf[43] = '\n';
    const needle = needle_buf[0..44];
    var rid: logstore.Rid = 1;
    const count: logstore.Rid = @intCast(log.entries.items.len);
    while (rid <= count) : (rid += 1) {
        const bytes = (log.content(gpa, rid) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            // A damaged or phantom entry cannot be the ticket's birth; skip
            // it rather than failing the write — verify owns damage reports.
            else => continue,
        }) orelse continue;
        defer gpa.free(bytes);
        if (std.mem.indexOf(u8, bytes, needle) != null) return true;
    }
    return false;
}

pub fn runForum(
    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 Mode = enum { post, reply };
    var mode: ?Mode = null;
    var reply_to: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var title: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(forum_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--title")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--title");
            title = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "forum", .unknown_option, tok);
        } else if (mode == null) {
            if (std.mem.eql(u8, tok, "post")) {
                mode = .post;
            } else if (std.mem.eql(u8, tok, "reply")) {
                mode = .reply;
            } else {
                return cli.usageError(streams, "forum", .unknown_option, tok);
            }
        } else if (mode == .reply and reply_to == null) {
            reply_to = tok;
        } else {
            return cli.usageError(streams, "forum", .extra_argument, tok);
        }
    }
    const chosen = mode orelse {
        streams.err.writeAll("zit forum: usage: zit forum post|reply <post-hash> [-R repo] [--user NAME] [--title TITLE] [--mimetype TYPE] < body\n") catch {};
        return .usage;
    };
    if (chosen == .reply and reply_to == null) {
        streams.err.writeAll("zit forum: reply needs a <post-hash>\n") catch {};
        return .usage;
    }
    if (chosen == .reply and title != null) {
        // §5.4 — a forum artifact carries H XOR I: a thread has one title, on
        // its root. Refused rather than dropped, so the caller learns it.
        streams.err.writeAll("zit forum: --title belongs to 'post'; a reply inherits its thread's title\n") catch {};
        return .usage;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit forum: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit forum: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);
    if (body.len == 0) {
        streams.err.writeAll("zit forum: the body must not be empty\n") catch {};
        return .failure;
    }

    // A reply names its thread: G is the thread ROOT (the replied-to post's
    // own G when it has one, else that post itself), I is the replied-to
    // post. Resolved against the store before anything is built, so an
    // unknown hash is a clean exit-1 refusal.
    var root_hash_buf: [64]u8 = undefined;
    var g_hash: ?[]const u8 = null;
    if (chosen == .reply) {
        g_hash = resolveThreadRoot(gpa, io, arena, streams, amb, "forum", reply_to.?, &root_hash_buf) orelse return .failure;
    }

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .forum,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = if (g_hash) |g| .{ .hash = g } else null,
        .h = if (title) |t| .{ .title = t } else null,
        .i = if (chosen == .reply) .{ .hash = reply_to.? } else null,
        .k = null,
        .l = null,
        .n = nCard(mimetype, true), // forum: suppresses the default
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "forum", &m, null, null);
}

pub fn runTechnote(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var saw_add = false;
    var user: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var event_id: ?[]const u8 = null;
    var when: ?[]const u8 = null;
    var comment: ?[]const u8 = null;
    var tag_names: [max_technote_tags][]const u8 = undefined;
    var tag_count: usize = 0;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(technote_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--comment")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--comment");
            comment = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--tag")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--tag");
            if (tag_count == max_technote_tags) {
                streams.err.print("zit technote: at most {d} --tag values\n", .{max_technote_tags}) catch {};
                return .usage;
            }
            tag_names[tag_count] = verb_args[i];
            tag_count += 1;
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--event")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--event");
            event_id = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--time")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--time");
            when = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "technote", .unknown_option, tok);
        } else if (!saw_add) {
            if (!std.mem.eql(u8, tok, "add")) return cli.usageError(streams, "technote", .unknown_option, tok);
            saw_add = true;
        } else {
            return cli.usageError(streams, "technote", .extra_argument, tok);
        }
    }
    if (!saw_add) {
        streams.err.writeAll("zit technote: usage: zit technote add [-R repo] [--user NAME] [--comment TEXT] [--tag NAME] [--mimetype TYPE] [--event EVENT-ID] [--time TIMESTAMP] < body\n") catch {};
        return .usage;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit technote: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };
    // The C card is what a timeline renders as the note's title, and no later
    // verb can attach one — so an empty value is refused here rather than
    // minting a note that is titled with the empty string forever.
    if (comment) |c| {
        if (c.len == 0) {
            streams.err.writeAll("zit technote: --comment must not be empty\n") catch {};
            return .failure;
        }
    }
    for (tag_names[0..tag_count]) |name| {
        if (!technoteTagUsable(name)) {
            streams.err.print("zit technote: '{s}' is not usable as a tag (empty, or contains whitespace)\n", .{name}) catch {};
            return .failure;
        }
    }
    const tag_cards = composeTechnoteTags(arena, tag_names[0..tag_count]) catch |e| switch (e) {
        error.OutOfMemory => {
            streams.err.writeAll("zit technote: out of memory\n") catch {};
            return .failure;
        },
        error.DuplicateTag => {
            streams.err.writeAll("zit technote: the same --tag was given twice\n") catch {};
            return .failure;
        },
    };

    // The event id: minted fresh, or the caller's (an edit of an existing
    // technote — latest wins at fold time, which the millisecond D cards
    // keep honest).
    var id_buf: [40]u8 = undefined;
    const id: []const u8 = event_id orelse blk: {
        var raw: [20]u8 = undefined;
        io.randomSecure(&raw) catch {
            streams.err.writeAll("zit technote: no entropy source for a fresh event id\n") catch {};
            return .failure;
        };
        const hex = std.fmt.bytesToHex(raw, .lower);
        @memcpy(&id_buf, &hex);
        break :blk &id_buf;
    };
    if (!((id.len == 40 or id.len == 64) and isLowerHex(id))) {
        streams.err.print("zit technote: '{s}' is not an event id (40 or 64 lowercase hex)\n", .{id}) catch {};
        return .failure;
    }

    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit technote: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);

    var ts_buf: [23]u8 = undefined;
    const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var e_ts_buf: [23]u8 = undefined;
    const e_ts: []const u8 = if (when) |w| blk: {
        if (artifact.classifyTimestamp(w) == null) {
            streams.err.print("zit technote: '{s}' is not a timestamp the format accepts\n", .{w}) catch {};
            return .failure;
        }
        break :blk w;
    } else cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &e_ts_buf);

    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .event,
        .a = null,
        .b = null,
        .c = if (comment) |c| .{ .comment = c } else null,
        .d = .{ .timestamp = d_ts },
        .e = .{ .timestamp = e_ts, .technote_id = id },
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = nCard(mimetype, false), // technote: fossil does NOT suppress here
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = tag_cards,
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "technote", &m, id, null);
}

// ---------------------------------------------------------------------------
// `zit attach` — the write side of the A card, and the stub-aware
// listing.
// ---------------------------------------------------------------------------

/// The most `attach add` will read from FILE or stdin before the size limit
/// is even consulted; a limit above this is the same as unlimited.
const max_attachment_read: usize = 1 << 30;

pub fn runAttach(
    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 Mode = enum { add, rm, list };
    var mode: ?Mode = null;
    var user: ?[]const u8 = null;
    var name_opt: ?[]const u8 = null;
    var comment: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var pos: [2]?[]const u8 = .{ null, null };
    var npos: usize = 0;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(attach_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--name")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--name");
            name_opt = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--comment")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--comment");
            comment = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            // A lone `-` is FILE meaning stdin, not an option.
            return cli.usageError(streams, "attach", .unknown_option, tok);
        } else if (mode == null) {
            if (std.mem.eql(u8, tok, "add")) {
                mode = .add;
            } else if (std.mem.eql(u8, tok, "rm")) {
                mode = .rm;
            } else if (std.mem.eql(u8, tok, "list")) {
                mode = .list;
            } else {
                return cli.usageError(streams, "attach", .unknown_option, tok);
            }
        } else if (npos < pos.len) {
            pos[npos] = tok;
            npos += 1;
        } else {
            return cli.usageError(streams, "attach", .extra_argument, tok);
        }
    }
    const chosen = mode orelse {
        streams.err.writeAll("zit attach: usage: zit attach add|rm|list ... (see zit attach --help)\n") catch {};
        return .usage;
    };
    return switch (chosen) {
        .add => attachAdd(gpa, io, arena, streams, amb, pos[0], pos[1], name_opt, comment, mimetype, user),
        .rm => attachRm(gpa, io, arena, streams, amb, pos[0], pos[1], user),
        .list => attachList(gpa, io, arena, streams, amb, pos[0]),
    };
}

/// The effective `attachment-size-limit` in bytes (0 = unlimited):
/// the resolved setting parsed fossil's way, or zit's 1 MiB default when it
/// is unset. An unparsable value is refused rather than read as unlimited.
fn attachmentSizeLimit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    amb: cli.Ambient,
    streams: cli.Streams,
) error{ OutOfMemory, Refused }!u64 {
    const resolved = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .attachment_size_limit) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return zit.settings.attachment_size_limit_default,
    };
    return zit.settings.parseByteLimit(resolved.value) orelse {
        streams.err.print("zit attach: attachment-size-limit '{s}' is not a byte count\n", .{resolved.value}) catch {};
        return error.Refused;
    };
}

fn attachAdd(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    target_opt: ?[]const u8,
    file_opt: ?[]const u8,
    name_opt: ?[]const u8,
    comment: ?[]const u8,
    mimetype: ?[]const u8,
    user: ?[]const u8,
) cli.Exit {
    const target = target_opt orelse {
        streams.err.writeAll("zit attach: add needs TARGET and FILE\n") catch {};
        return .usage;
    };
    const file = file_opt orelse {
        streams.err.writeAll("zit attach: add needs TARGET and FILE\n") catch {};
        return .usage;
    };
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit attach: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };
    const from_stdin = std.mem.eql(u8, file, "-");
    // §4.7.2 — only the final path component is the stored name; a directory
    // prefix, from --name or from FILE, is discarded.
    const raw_name = name_opt orelse blk: {
        if (from_stdin) {
            streams.err.writeAll("zit attach: --name is required when FILE is -\n") catch {};
            return .usage;
        }
        break :blk file;
    };
    const name = std.fs.path.basename(raw_name);
    if (name.len == 0) {
        streams.err.print("zit attach: '{s}' has no file name\n", .{raw_name}) catch {};
        return .failure;
    }

    const bytes: []u8 = if (from_stdin) blk: {
        var stdin = std.Io.File.stdin();
        var read_buf: [64 * 1024]u8 = undefined;
        var reader = stdin.readerStreaming(io, &read_buf);
        break :blk reader.interface.allocRemaining(gpa, .limited(max_attachment_read)) catch {
            streams.err.writeAll("zit attach: cannot read the attachment from stdin\n") catch {};
            return .failure;
        };
    } else std.Io.Dir.cwd().readFileAlloc(io, file, gpa, .limited(max_attachment_read)) catch {
        streams.err.print("zit attach: cannot read '{s}'\n", .{file}) catch {};
        return .failure;
    };
    defer gpa.free(bytes);
    // Fossil: "attachments must have a non-0 size".
    if (bytes.len == 0) {
        streams.err.print("zit attach: '{s}' is empty; an attachment must have content\n", .{if (from_stdin) name else file}) catch {};
        return .failure;
    }

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "attach")) orelse {
        main.reportNoRoot(streams, io, "attach", amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    const limit = attachmentSizeLimit(gpa, io, arena, root_dir, amb, streams) catch |e| switch (e) {
        error.OutOfMemory => return stateOom(streams, "attach"),
        error.Refused => return .failure,
    };
    if (limit != 0 and bytes.len > limit) {
        streams.err.print(
            "zit attach: '{s}' is {d} bytes; attachment-size-limit is {d} bytes (zit settings attachment-size-limit 0 lifts it)\n",
            .{ name, bytes.len, limit },
        ) catch {};
        return .failure;
    }

    var blob_name_buf: [64]u8 = undefined;
    const blob_name = artifact.artifactName(.sha3_256, bytes, &blob_name_buf);
    const a_card: artifact.ACard = .{ .name = name, .target = target, .source = blob_name };
    var ts_buf: [23]u8 = undefined;
    const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .attachment,
        .a = a_card,
        .b = null,
        .c = if (comment) |c| .{ .comment = c } else null,
        .d = .{ .timestamp = d_ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = nCard(mimetype, false),
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "attach", &m, null, bytes);
}

fn attachRm(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    target_opt: ?[]const u8,
    name_opt: ?[]const u8,
    user: ?[]const u8,
) cli.Exit {
    const target = target_opt orelse {
        streams.err.writeAll("zit attach: rm needs TARGET and NAME\n") catch {};
        return .usage;
    };
    const raw_name = name_opt orelse {
        streams.err.writeAll("zit attach: rm needs TARGET and NAME\n") catch {};
        return .usage;
    };
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit attach: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };
    const name = std.fs.path.basename(raw_name);
    if (name.len == 0) {
        streams.err.print("zit attach: '{s}' has no file name\n", .{raw_name}) catch {};
        return .failure;
    }
    // A sourceless A card: the association ends; nothing leaves the store.
    const a_card: artifact.ACard = .{ .name = name, .target = target, .source = null };
    const comment: ?[]const u8 = null;
    const mimetype: ?[]const u8 = null;
    var ts_buf: [23]u8 = undefined;
    const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .attachment,
        .a = a_card,
        .b = null,
        .c = if (comment) |c| .{ .comment = c } else null,
        .d = .{ .timestamp = d_ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = nCard(mimetype, false),
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "attach", &m, null, null);
}

/// One live attachment after the latest-wins fold: the newest A card for a
/// (target, name), by D timestamp then persisted order.
const LiveAttachment = struct {
    target: []const u8,
    name: []const u8,
    source: ?[]const u8,
    date: []const u8,
    user: []const u8,
    mimetype: ?[]const u8,
    rid: logstore.Rid,
};

fn liveLess(_: void, a: LiveAttachment, b: LiveAttachment) bool {
    const t = std.mem.order(u8, a.target, b.target);
    if (t != .eq) return t == .lt;
    return std.mem.lessThan(u8, a.name, b.name);
}

/// A listed TARGET matches a stored one exactly, or — when both are
/// hash-shaped — as a prefix, the way every hash argument abbreviates.
fn targetMatches(stored: []const u8, wanted: []const u8) bool {
    if (std.mem.eql(u8, stored, wanted)) return true;
    if (wanted.len < 4 or wanted.len > stored.len) return false;
    for (wanted) |c| if (!std.ascii.isHex(c)) return false;
    for (stored) |c| if (!std.ascii.isHex(c)) return false;
    return std.mem.startsWith(u8, stored, wanted);
}

fn attachList(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    target_opt: ?[]const u8,
) cli.Exit {
    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "attach")) orelse {
        main.reportNoRoot(streams, io, "attach", amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
        return .failure;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) {
        error.OutOfMemory => return stateOom(streams, "attach"),
        else => {
            streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
            return .failure;
        },
    };
    defer log.deinit(gpa);

    // Latest-wins fold over every attachment artifact. An attachment's first
    // card is A (letter order), so two bytes reject everything else before a
    // parse.
    var latest: std.StringHashMapUnmanaged(LiveAttachment) = .empty;
    defer latest.deinit(gpa);
    var rid: logstore.Rid = 1;
    const count: logstore.Rid = @intCast(log.entries.items.len);
    while (rid <= count) : (rid += 1) {
        const bytes = (log.content(gpa, rid) catch |e| switch (e) {
            error.OutOfMemory => return stateOom(streams, "attach"),
            else => continue,
        }) orelse continue;
        defer gpa.free(bytes);
        if (bytes.len < 2 or bytes[0] != 'A' or bytes[1] != ' ') continue;
        var m = artifact.parse(gpa, bytes) catch continue;
        defer m.deinit();
        if (m.kind != .attachment) continue;
        const a = m.a orelse continue;
        const date = if (m.d) |d| d.timestamp else "";
        const key = std.mem.concat(arena, u8, &.{ a.target, "\x00", a.name }) catch return stateOom(streams, "attach");
        const gop = latest.getOrPut(gpa, key) catch return stateOom(streams, "attach");
        if (gop.found_existing) {
            const have = gop.value_ptr.*;
            const newer = switch (std.mem.order(u8, date, have.date)) {
                .gt => true,
                .lt => false,
                .eq => rid > have.rid,
            };
            if (!newer) continue;
        }
        gop.value_ptr.* = .{
            .target = arena.dupe(u8, a.target) catch return stateOom(streams, "attach"),
            .name = arena.dupe(u8, a.name) catch return stateOom(streams, "attach"),
            .source = if (a.source) |src| (arena.dupe(u8, src) catch return stateOom(streams, "attach")) else null,
            .date = arena.dupe(u8, date) catch return stateOom(streams, "attach"),
            .user = arena.dupe(u8, if (m.u) |u| u.login else "") catch return stateOom(streams, "attach"),
            .mimetype = if (m.n) |n| (arena.dupe(u8, n.mimetype) catch return stateOom(streams, "attach")) else null,
            .rid = rid,
        };
    }

    var live: std.ArrayListUnmanaged(LiveAttachment) = .empty;
    defer live.deinit(gpa);
    var it = latest.valueIterator();
    while (it.next()) |v| {
        if (v.source == null) continue; // a sourceless card means gone
        if (target_opt) |t| if (!targetMatches(v.target, t)) continue;
        live.append(gpa, v.*) catch return stateOom(streams, "attach");
    }
    std.mem.sort(LiveAttachment, live.items, {}, liveLess);

    for (live.items) |v| {
        const src = v.source.?;
        // The blob's size, or — for a quarry stub — what it points at.
        var size: u64 = 0;
        var external: ?zit.attachstub.Stub = null;
        var blob: ?[]u8 = null;
        defer if (blob) |b| gpa.free(b);
        var digest_buf: [32]u8 = undefined;
        if (std.fmt.hexToBytes(&digest_buf, src)) |digest| {
            if (log.lookup(digest)) |brid| {
                blob = log.content(gpa, brid) catch null;
                if (blob) |b| {
                    size = b.len;
                    external = zit.attachstub.parse(b);
                }
            }
        } else |_| {}
        if (target_opt == null) streams.out.print("{s}  ", .{v.target}) catch {};
        if (external) |ext| {
            streams.out.print("{s}  {d}  {s}  external:sha256:{s}  {s}  {s}\n", .{
                v.name, ext.size, if (ext.mimetype.len != 0) ext.mimetype else (v.mimetype orelse "-"), ext.sha256, v.date, v.user,
            }) catch {};
        } else {
            streams.out.print("{s}  {d}  {s}  {s}  {s}  {s}\n", .{
                v.name, size, v.mimetype orelse "-", src, v.date, v.user,
            }) catch {};
        }
    }
    return .ok;
}

/// Compose a technote's T run from `--tag` values: one `T +sym-NAME *` card
/// each, sorted ascending and rejecting duplicates.
///
/// The shape is fossil's, byte for byte — authoring a technote there with
/// `--technote-tags "alpha beta"` yields `T +sym-alpha *` / `T +sym-beta *`.
/// Singleton (`+`) because a technote has no descendants to propagate
/// through; self-referential target (null, written `*`) because §4.7.16's
/// target is "the artifact carrying the card" in that spelling; `sym-`
/// prefixed because these are names a technote is found by.
///
/// Sorted and deduplicated because §4.5.2 requires a strictly ascending run:
/// a repeated `--tag` would otherwise mint an artifact that fails its own
/// admission check. Pure; bounded by `max_technote_tags`.
fn composeTechnoteTags(
    arena: std.mem.Allocator,
    names: []const []const u8,
) error{ OutOfMemory, DuplicateTag }![]artifact.TCard {
    std.debug.assert(names.len <= max_technote_tags);
    const cards = try arena.alloc(artifact.TCard, names.len);
    for (names, 0..) |name, idx| {
        cards[idx] = .{
            .sign = .add,
            .name = try std.mem.concat(arena, u8, &.{ artifact.sym_tag_prefix, name }),
            .target = null,
            .value = null,
        };
    }
    std.mem.sort(artifact.TCard, cards, {}, artifact.tagTokenLess);
    var idx: usize = 1;
    while (idx < cards.len) : (idx += 1) {
        if (std.mem.eql(u8, cards[idx - 1].name, cards[idx].name)) return error.DuplicateTag;
    }
    return cards;
}

/// Whether `name` is usable as a technote tag: non-empty, and free of the
/// whitespace fossil's own space-separated tag field could never carry (it
/// would re-split such a name on read, so accepting it would author a note
/// fossil and zit disagree about).
fn technoteTagUsable(name: []const u8) bool {
    if (name.len == 0) return false;
    for (name) |c| {
        if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return false;
    }
    return true;
}

/// The thread root a reply's G card names: the replied-to post's own G when
/// it carries one, else the post itself. Null (with the diagnostic printed)
/// when the hash is unknown or does not parse as a forum post — replying
/// into the void must be a refusal, not a dangling thread.
pub fn resolveThreadRoot(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    cmd: []const u8,
    post_hash: []const u8,
    out: *[64]u8,
) ?[]const u8 {
    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return null) orelse {
        main.reportNoRoot(streams, io, "forum", amb.repo_override);
        return null;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
        return null;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
        return null;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
        streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
        return null;
    };
    defer log.deinit(gpa);

    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexToDigest(post_hash, &digest_buf) orelse {
        streams.err.print("zit {s}: '{s}' is not an artifact hash\n", .{ cmd, post_hash }) catch {};
        return null;
    };
    const rid = log.lookup(digest) orelse {
        streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
        return null;
    };
    const bytes = (log.content(gpa, rid) catch {
        streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
        return null;
    }) orelse {
        streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
        return null;
    };
    defer gpa.free(bytes);
    var parsed = artifact.parse(gpa, bytes) catch {
        streams.err.print("zit {s}: '{s}' is not a forum post\n", .{ cmd, post_hash }) catch {};
        return null;
    };
    defer parsed.deinit();
    if (parsed.kind != .forum) {
        streams.err.print("zit {s}: '{s}' is not a forum post\n", .{ cmd, post_hash }) catch {};
        return null;
    }
    const chosen: []const u8 = if (parsed.g) |g| g.hash else post_hash;
    std.debug.assert(chosen.len <= out.len);
    @memcpy(out[0..chosen.len], chosen);
    return out[0..chosen.len];
}