Skip to content
zit Artifact [a45f09e18e]
Login

Artifact [a45f09e18e]

Artifact a45f09e18e2d5648d52638c12714853ac76bb634e296546b0ffbd65a67538bcc:


//! zit history / read-only inspection verbs (stat, log, leaves, timeline,
//! branch, tag, info, ls, cat, artifact, annotate) and the helpers and tests
//! private to them.
//!
//! Extracted verbatim from src/main.zig (release step S4). Every declaration
//! below is byte-identical to its main.zig source; only this import prologue is
//! new. Helpers still shared with other verbs stay in main.zig and are aliased
//! back here so the moved bodies call them unqualified. main.zig's verb-dispatch
//! switch calls into this file (`cmd_history.runLog` ...); its test block imports
//! it so these inline tests keep running under `zig build test`.

const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const merge3 = @import("../merge3.zig");
const annotate_mod = @import("../annotate.zig");
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const storestat = @import("zit").storestat;
const Repository = @import("zit").Repository;

const Io = std.Io;
const File = Io.File;
const repostate = zit.repostate;
const managed = zit.managed;
const artifact = zit.artifact;

// Shared CLI helpers and test fixtures that stay in main.zig (other verbs use
// them too); aliased back so the moved code and tests reference them by their
// original unqualified names.
const main = @import("../main.zig");
const repo_filename = main.repo_filename;
const reasonOf = main.reasonOf;
const stateOom = main.stateOom;
const not_a_repo_msg = main.not_a_repo_msg;
const repoReadFail = main.repoReadFail;
const repoOpenFail = main.repoOpenFail;
const openRepo = main.openRepo;
const digest_len = main.digest_len;
const timeline_hash_display = main.timeline_hash_display;
const firstCommentLine = main.firstCommentLine;
const renderBranchList = main.renderBranchList;
const sym_prefix = main.sym_prefix;
const lessByHash = main.lessByHash;
const mintControlArtifact = main.mintControlArtifact;
const packSeedBlob = main.packSeedBlob;
const packSeedCheckin = main.packSeedCheckin;
const packEvolvingBlob = main.packEvolvingBlob;
const packOpenWritable = main.packOpenWritable;
const packOpenReadOnly = main.packOpenReadOnly;
const stat_help = main.stat_help;
const log_help = main.log_help;
const leaves_help = main.leaves_help;
const timeline_help = main.timeline_help;
const branch_help = main.branch_help;
const tag_help = main.tag_help;
const info_help = main.info_help;
const ls_help = main.ls_help;
const cat_help = main.cat_help;
const artifact_help = main.artifact_help;
const annotate_help = main.annotate_help;

/// Which artifacts `zit stat --artifacts` lists. `all` is the unfiltered roll;
/// `full` and `delta` partition it by storage form, which is the one property
/// the frame walk already knows and so costs nothing extra to filter on.
const StatType = enum { all, full, delta };

/// `zit stat` — answer questions about the store itself: how large it is, how
/// much of that is artifact content, and which artifacts it holds.
///
/// The honesty constraint that shapes this verb: `content-bytes` is MEASURED,
/// by summing each artifact frame's payload length during the walk. It is
/// never the `.zit` file size, which additionally carries the store header, a
/// header per frame, and the padding that aligns each frame to 8 bytes.
/// Reporting the file size as content would look like a result and be a
/// fabrication, so the two travel as separate fields and the overhead that
/// separates them is reported alongside, making the accounting checkable:
/// `header-bytes + content-bytes + frame-overhead-bytes + config-bytes +
/// shun-bytes` is the file, exactly — the shun table's frames are part of the
/// store, so leaving them out of the report would leave an unlabelled gap on
/// every repository an operator has shunned anything in.
///
/// Two traversals, both bounded by the store rather than by the artifact
/// count. `openReadOnly` is the first: it whole-frame-CRC-verifies every frame
/// and builds the in-memory index, which is what `--artifacts` enumerates and
/// what lets `storestat.walk` trust the prefix bytes it reads. The summary path
/// then runs the walk, a prefix-only second pass. The open is not skippable on
/// the summary path: unverified frame prefixes would make the totals a report
/// about bytes nobody checked. What is avoided is the per-artifact shape — no
/// invocation, and no payload decode, per stored artifact. Read-only; the
/// store is not modified.
pub fn runStat(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    var list_artifacts = false;
    var want: StatType = .all;
    var typed = false;
    for (verb_args) |tok| {
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(stat_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--artifacts")) {
            list_artifacts = true;
        } else if (std.mem.startsWith(u8, tok, "--type=")) {
            // A second --type is a mistake, not a refinement: silently keeping
            // the last one would answer a question the user did not ask.
            if (typed) return cli.usageError(streams, "stat", .repeated_option, "--type");
            const val = tok["--type=".len..];
            want = if (std.mem.eql(u8, val, "full"))
                .full
            else if (std.mem.eql(u8, val, "delta"))
                .delta
            else
                return cli.usageError(streams, "stat", .unknown_option, tok);
            typed = true;
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "stat", .unknown_option, tok);
        } else {
            return cli.usageError(streams, "stat", .extra_argument, tok);
        }
    }
    // `--type` selects among artifacts; without a listing there is nothing for
    // it to select, and silently ignoring it would hide a mistaken invocation.
    if (typed and !list_artifacts) {
        streams.err.writeAll("zit stat: --type needs --artifacts\n") catch {};
        return .usage;
    }

    const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "stat")) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    var file = cli.openStoreRead(io, root_dir) catch return repoReadFail(streams, "stat");
    defer file.close(io);
    const st = file.stat(io) catch return repoReadFail(streams, "stat");
    var fs = storage.FileStorage.init(io, file, st.size);
    var store = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) {
        error.OutOfMemory => return stateOom(streams, "stat"),
        else => {
            streams.err.print("zit stat: cannot read repository: {s}\n", .{reasonOf(e)}) catch {};
            return .failure;
        },
    };
    defer store.deinit(gpa);

    if (list_artifacts) return statArtifacts(gpa, &store, want, streams);

    var fs2 = storage.FileStorage.init(io, file, st.size);
    const bd = storestat.walk(fs2.storageFile(), st.size) catch |e| {
        streams.err.print("zit stat: cannot read repository: {s}\n", .{reasonOf(e)}) catch {};
        return .failure;
    };
    streams.out.print(
        \\store-bytes {d}
        \\header-bytes {d}
        \\content-bytes {d}
        \\frame-overhead-bytes {d}
        \\config-bytes {d}
        \\shun-bytes {d}
        \\shun-rows {d}
        \\artifacts {d}
        \\full-form {d}
        \\full-form-bytes {d}
        \\deltas {d}
        \\delta-bytes {d}
        \\
    , .{
        st.size,          logstore.header_len, bd.contentBytes(), bd.contentOverhead(),
        bd.configBytes(), bd.shunBytes(),      bd.shunRows(),     bd.artifacts(),
        bd.fullForm(),    bd.fullFormBytes(),  bd.deltas(),       bd.deltaBytes(),
    }) catch {};
    return .ok;
}

/// Emits one lowercase-hex artifact hash per line, in ascending hash order, so
/// two stores' rolls compare with `comm`. Reads the already-open store's index
/// — no further I/O, and no per-artifact process.
fn statArtifacts(
    gpa: std.mem.Allocator,
    store: *logstore.LogStore,
    want: StatType,
    streams: cli.Streams,
) cli.Exit {
    const rids = store.ridsInPersistedOrder(gpa) catch return stateOom(streams, "stat");
    defer gpa.free(rids);

    var hashes: std.ArrayListUnmanaged([]const u8) = .empty;
    defer hashes.deinit(gpa);
    hashes.ensureTotalCapacity(gpa, rids.len) catch return stateOom(streams, "stat");
    for (rids) |rid| {
        const kind = store.kindOf(rid);
        // The config record is repository metadata, not a stored artifact, so
        // it is absent from every listing and from the `artifacts` count.
        if (kind == .config) continue;
        const keep = switch (want) {
            .all => true,
            .delta => kind == .delta,
            .full => kind == .raw or kind == .zlib,
        };
        if (keep) hashes.appendAssumeCapacity(store.hashOf(rid));
    }
    // The listing is a filtered projection of the persisted rid set — never more
    // hashes than records walked.
    std.debug.assert(hashes.items.len <= rids.len);
    std.mem.sort([]const u8, hashes.items, {}, struct {
        fn lt(_: void, a: []const u8, b: []const u8) bool {
            return std.mem.order(u8, a, b) == .lt;
        }
    }.lt);
    for (hashes.items) |h| streams.out.print("{x}\n", .{h}) catch {};
    return .ok;
}

const LogWalkError = error{
    OutOfMemory,
    Storage,
    Corrupt,
    MissingRoot,
    InvalidRoot,
    InvalidTarget,
};

fn decodeFullId(id: []const u8, out: *[digest_len]u8) error{InvalidId}![]const u8 {
    if (!artifact.validateName(id)) return error.InvalidId;
    const decoded = out[0 .. id.len / 2];
    _ = std.fmt.hexToBytes(decoded, id) catch return error.InvalidId;
    // §3.6: `validateName` accepted a 40/64-hex token, so the decode is a whole
    // 20/32-byte digest that fits the fixed buffer — never a truncated prefix.
    std.debug.assert(decoded.len <= digest_len);
    return decoded;
}

/// The optional PATH restriction for a `zit log CHECKIN PATH` walk (B-log-2).
/// `repo` answers the one general-purpose §8 effective-set query (`fileAt`) the
/// change predicate consumes (B-log-5); `path` is the exact repo-relative name.
const LogPathFilter = struct {
    repo: *Repository,
    path: []const u8,
};

fn walkLog(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    root_id: []const u8,
    out: *Io.Writer,
    filter: ?LogPathFilter,
    limit: ?usize,
) LogWalkError!void {
    var pending: std.ArrayList([]u8) = .empty;
    defer {
        for (pending.items) |id| gpa.free(id);
        pending.deinit(gpa);
    }
    var visited: std.StringHashMapUnmanaged(void) = .empty;
    defer visited.deinit(gpa);
    var visited_ids: std.ArrayList([]u8) = .empty;
    defer {
        for (visited_ids.items) |id| gpa.free(id);
        visited_ids.deinit(gpa);
    }
    // `-n N` caps the ENTRIES NAMED, not the nodes traversed: with a PATH filter
    // most visited check-ins print nothing, and capping traversal would make the
    // answer depend on how much unrelated history sat in between. Counting the
    // lines the user actually sees is the only reading that stays stable.
    var emitted: usize = 0;

    const root_copy = try gpa.dupe(u8, root_id);
    pending.append(gpa, root_copy) catch {
        gpa.free(root_copy);
        return error.OutOfMemory;
    };
    while (pending.pop()) |id| {
        // `-n N` (N ≥ 1; `parseLimitValue` maps 0 → null) returns the moment the
        // Nth line is named, so at every loop entry fewer than N lines are out.
        if (limit) |n| std.debug.assert(emitted < n);
        if (visited.contains(id)) {
            gpa.free(id);
            continue;
        }
        visited_ids.append(gpa, id) catch {
            gpa.free(id);
            return error.OutOfMemory;
        };
        visited.put(gpa, id, {}) catch {
            _ = visited_ids.pop();
            gpa.free(id);
            return error.OutOfMemory;
        };
        const is_root = visited_ids.items.len == 1;

        var hash_buf: [digest_len]u8 = undefined;
        const decoded = decodeFullId(id, &hash_buf) catch
            return if (is_root) error.InvalidRoot else error.InvalidTarget;
        // A decoded id is a full §3.6 artifact name (40/64 hex), never a prefix —
        // it is exactly what every `checkin`/`phantom` line below renders.
        std.debug.assert(artifact.validateName(id));
        const rid = log.lookup(decoded) orelse {
            if (is_root) return error.MissingRoot;
            // A phantom's effective set is unknowable, so the PATH filter cannot
            // assert it changes PATH — it is silently skipped (the unfiltered
            // walk still names it once).
            if (filter == null) {
                out.print("phantom {s}\n", .{id}) catch {};
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
            continue;
        };
        const bytes = (try log.content(gpa, rid)) orelse {
            if (is_root) return error.MissingRoot;
            if (filter == null) {
                out.print("phantom {s}\n", .{id}) catch {};
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
            continue;
        };
        defer gpa.free(bytes);
        if (!artifact.verify(id, bytes)) return error.Corrupt;

        // Read path: the log-walk reads fossil-origin history, so it tolerates
        // the legacy F-card trailing separator.
        var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return if (is_root) error.InvalidRoot else error.InvalidTarget,
        };
        defer loaded.deinit(gpa);
        const manifest = &loaded.manifest;
        if (manifest.kind != .manifest)
            return if (is_root) error.InvalidRoot else error.InvalidTarget;

        // With no PATH the walk names every reachable check-in (B-log-1); with
        // PATH it names only the check-ins whose effective content for PATH
        // differs from their primary parent's (B-log-2).
        if (filter) |f| {
            if (try logChangesPath(gpa, f.repo, id, manifest, f.path)) {
                out.print("checkin {s}\n", .{id}) catch {};
                emitted += 1;
            }
        } else {
            out.print("checkin {s}\n", .{id}) catch {};
            emitted += 1;
        }
        if (limit) |n| if (emitted >= n) return;

        // Push in reverse wire order so the LIFO stack visits the first P hash
        // first. Reversing only this node's appended segment preserves DFS when
        // older sibling work is already below it.
        const parent_start = pending.items.len;
        for (manifest.p) |pc| {
            var hashes = pc.hashes();
            while (hashes.next()) |parent| {
                const owned = try gpa.dupe(u8, parent);
                pending.append(gpa, owned) catch {
                    gpa.free(owned);
                    return error.OutOfMemory;
                };
            }
        }
        std.mem.reverse([]u8, pending.items[parent_start..]);
    }
}

/// PATH's presence and effective content hash inside one check-in's file set.
/// The hash is a 40/64-char lowercase-hex F-card token (§4.7.6), copied into a
/// fixed buffer so the comparison outlives `fileAt`'s duped card.
const PathContent = struct {
    present: bool,
    hash: [2 * digest_len]u8 = undefined,
    hash_len: usize = 0,
};

/// The effective content of PATH in the check-in named `id` (artifact §8
/// reconstruction via `Repository.fileAt` — the one general-purpose query, so
/// no CLI-side re-derivation off raw F cards, B-log-5). `present` is false when
/// `id` is not interned (a phantom the derived view never crosslinked) or PATH
/// is not in its effective set (never added, or a delta delete marker §4.7.6).
fn effectivePathContent(
    gpa: std.mem.Allocator,
    repo: *Repository,
    id: []const u8,
    path: []const u8,
) LogWalkError!PathContent {
    const rid = repo.lookup(id) orelse return .{ .present = false };
    const card = (repo.fileAt(gpa, rid, path) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        // NotManifest/BaselineUnresolvable/BaselineIsDelta/Corrupt/NotARepo all
        // mean the reachable check-in cannot be reconstructed — the same class
        // the unfiltered walk reports as a corrupt/invalid target.
        else => error.Corrupt,
    }) orelse return .{ .present = false };
    defer Repository.freeFileCard(gpa, card);
    // The iterator behind `fileAt` suppresses delete markers, so a returned
    // card always carries a hash (§4.7.6).
    const h = card.hash.?;
    std.debug.assert(h.len <= 2 * digest_len);
    var out: PathContent = .{ .present = true, .hash_len = h.len };
    @memcpy(out.hash[0..h.len], h);
    return out;
}

/// Whether the check-in `id` changes PATH per the B-log change definition: its
/// effective content (or presence) for PATH differs from its PRIMARY parent's
/// (§3.2 first-listed P-card parent). A root has no parent, so presence alone is
/// a change; a rename surfaces at both names as a presence flip (no old-name
/// following, the B-merge-22 delete+add posture). `manifest` is the already-
/// parsed check-in the walk holds.
fn logChangesPath(
    gpa: std.mem.Allocator,
    repo: *Repository,
    id: []const u8,
    manifest: *const artifact.Manifest,
    path: []const u8,
) LogWalkError!bool {
    const here = try effectivePathContent(gpa, repo, id, path);

    // §3.2: the primary parent is the first hash of the first P card.
    var primary: ?[]const u8 = null;
    for (manifest.p) |pc| {
        var hashes = pc.hashes();
        if (hashes.next()) |first| {
            primary = first;
            break;
        }
    }
    const parent = if (primary) |pid|
        try effectivePathContent(gpa, repo, pid, path)
    else
        PathContent{ .present = false };

    if (here.present != parent.present) return true;
    if (!here.present) return false;
    return !std.mem.eql(u8, here.hash[0..here.hash_len], parent.hash[0..parent.hash_len]);
}

/// `zit log CHECKIN PATH` — the PATH-restricted ancestry walk (B-log-2). Opens
/// the `Repository` facade for the §8 effective-set query, then reuses the exact
/// unfiltered DFS (`walkLog`) so order and line layout match B-log-1, gating each
/// `checkin` line on the change predicate. `root_dir` is borrowed (the caller
/// closes it after this returns).
fn runLogPath(
    gpa: std.mem.Allocator,
    io: std.Io,
    streams: cli.Streams,
    root_dir: std.Io.Dir,
    root_id: []const u8,
    path: []const u8,
    limit: ?usize,
) cli.Exit {
    var repo: Repository = undefined;
    repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "log", e);
    defer repo.deinit(gpa);

    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "log", e);

    walkLog(gpa, log, root_id, streams.out, .{ .repo = &repo, .path = path }, limit) catch |e| {
        switch (e) {
            error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {},
            error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {},
            error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {},
            error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {},
            error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {},
            error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
        }
        return .failure;
    };
    return .ok;
}

pub fn runLog(
    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.parseLogArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "log", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "log", .extra_argument, tok),
        .bad_limit => |tok| return cli.usageError(streams, "log", .bad_limit, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(log_help) catch {};
        return .ok;
    }
    const name = parsed.checkin orelse
        return cli.usageError(streams, "log", .no_checkin, "");

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch {
        streams.err.writeAll("zit log: out of memory\n") catch {};
        return .failure;
    }) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // A full lowercase-hex ID walks exactly as it always has, without deriving
    // repo state. Anything else — a prefix, a branch, a tag, a reserved form —
    // goes through the §8.3 resolver `zit info` uses, then walks by the
    // resolved full ID.
    const root_id: []const u8 = if (artifact.validateName(name)) name else blk: {
        var repo: Repository = undefined;
        repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "log", e);
        defer repo.deinit(gpa);
        const rid = repo.resolve(name, .checkin) catch |e| {
            switch (e) {
                error.NotFound => streams.err.print("zit log: check-in '{s}' not found\n", .{name}) catch {},
                error.Ambiguous => streams.err.print("zit log: name '{s}' is ambiguous\n", .{name}) catch {},
                error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
            }
            return .failure;
        };
        break :blk arena.dupe(u8, repo.hashOf(rid)) catch {
            streams.err.writeAll("zit log: out of memory\n") catch {};
            return .failure;
        };
    };

    // With a PATH the walk is restricted to the check-ins that change it
    // (B-log-2), a route that needs the `Repository` facade for §8 effective-set
    // queries; without one the walk is byte-identical to the shipped behavior
    // (B-log-1), so the no-PATH path below is left exactly as it was.
    if (parsed.path) |path| return runLogPath(gpa, io, streams, root_dir, root_id, path, parsed.limit);

    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    defer file.close(io);
    const log_st = file.stat(io) catch {
        streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, log_st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| {
        switch (e) {
            error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
            else => streams.err.print("zit log: cannot read repository: {s}\n", .{reasonOf(e)}) catch {},
        }
        return .failure;
    };
    defer log.deinit(gpa);

    walkLog(gpa, &log, root_id, streams.out, null, parsed.limit) catch |e| {
        switch (e) {
            error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {},
            error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {},
            error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {},
            error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {},
            error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {},
            error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
        }
        return .failure;
    };
    return .ok;
}

/// `zit leaves [--all]` — list the open leaves of the check-in DAG (repo-state
/// §7.1/§7.4/§7.5). Opens the repository through `Repository` and renders each
/// leaf's full artifact ID and branch, mtime-descending (§7.5 SHOULD order),
/// marking the leaf that matches the current checkout with "*". `--all`
/// additionally lists closed leaves — §7.4: closed status is derived, so a
/// closed leaf stays a leaf-table member. Enumeration is distinct from name
/// resolution (§7.7): the listing is exactly the leaf table filtered by the
/// §7.4 open predicate. An unborn repository has no leaves: empty stdout, exit 0.
pub fn runLeaves(
    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.parseLeavesArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "leaves", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "leaves", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(leaves_help) catch {};
        return .ok;
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "leaves", amb.repo_override, parsed.no_cache, true, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // Resolve the current checkout's version to a rid so its leaf is marked
    // (§7.5). Absence/unknown/eventless leaves nothing marked — the mark is a
    // checkout convenience, never the source of the leaf set.
    const cur = repo.currentCheckoutRid();
    return renderLeaves(arena, &repo, parsed.all, cur, streams);
}

/// One row of the leaf listing, gathered before the §7.5 mtime-descending sort.
/// `hash` borrows the view's identity space (stable for the view's lifetime).
const LeafRow = struct {
    rid: repostate.Rid,
    mtime: repostate.Mtime,
    hash: []const u8,
    open: bool,
};

/// §7.5 order: modification time descending. Ties break by artifact ID ascending
/// so the listing is a deterministic total order (IDs are unique per check-in).
fn leafRowLess(_: void, a: LeafRow, b: LeafRow) bool {
    if (a.mtime != b.mtime) return a.mtime > b.mtime;
    return std.mem.order(u8, a.hash, b.hash) == .lt;
}

/// Gathers, sorts (§7.5), and prints the leaf listing to stdout. A row is
/// included when `all` is set or the leaf is open (§7.4 open predicate). Each
/// line is `<mark><id>  <branch>`, `<mark>` = "* " for the current-checkout leaf
/// else "  "; under `all` a closed leaf appends "  (closed)". Bounded by the
/// leaf-table size.
fn renderLeaves(
    arena: std.mem.Allocator,
    repo: *Repository,
    all: bool,
    cur: ?repostate.Rid,
    streams: cli.Streams,
) cli.Exit {
    var rows: std.ArrayList(LeafRow) = .empty;
    defer rows.deinit(arena);

    const bound = repo.state().leaves().rids.len;
    var scanned: usize = 0;
    var it = repo.state().leaves();
    while (it.next()) |rid| {
        scanned += 1;
        std.debug.assert(scanned <= bound);
        const open = repo.state().isOpenLeaf(rid);
        if (!all and !open) continue;
        // A leaf-table rid is always a crosslinked check-in, so it has an event.
        const mtime = repo.state().mtimeOf(rid) orelse unreachable;
        rows.append(arena, .{
            .rid = rid,
            .mtime = mtime,
            .hash = repo.hashOf(rid),
            .open = open,
        }) catch {
            streams.err.writeAll("zit leaves: out of memory\n") catch {};
            return .failure;
        };
    }
    std.debug.assert(rows.items.len <= bound);

    std.mem.sort(LeafRow, rows.items, {}, leafRowLess);

    for (rows.items) |row| {
        std.debug.assert(repo.state().isLeaf(row.rid));
        std.debug.assert(all or row.open);
        const mark = if (cur != null and cur.? == row.rid) "* " else "  ";
        const branch = repo.branchOf(row.rid);
        streams.out.print("{s}{s}  {s}", .{ mark, row.hash, branch }) catch {};
        if (all and !row.open) streams.out.writeAll("  (closed)") catch {};
        streams.out.writeAll("\n") catch {};
    }
    return .ok;
}

/// `zit timeline` — the global chronological history of every check-in
/// (repo-state §2/§8.3): one line per check-in, event.mtime descending, ties by
/// full artifact hash so the listing is byte-reproducible. Read-only: opens the
/// repository, enumerates through the single `checkinsByMtime` query, renders,
/// and mutates nothing. No positional, `-h`/`--help` only (the house
/// `parseNoPositionalArgs` grammar). (contracts/timeline.md B-timeline-1..13.)
pub fn runTimeline(
    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.parseTimelineArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "timeline", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "timeline", .extra_argument, tok),
        .bad_limit => |tok| return cli.usageError(streams, "timeline", .bad_limit, tok),
    };
    // --help wins over every effect (B-timeline-8).
    if (parsed.help) {
        streams.out.writeAll(timeline_help) catch {};
        return .ok;
    }

    // A cold `.full` verb: it reads adjacency (the §3.2 merge mark) and content
    // (the C-card comment), so it never takes the zero-parse image path.
    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "timeline", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);
    return renderTimeline(gpa, &repo, streams, parsed.limit);
}

/// §3.2: a check-in that joins two or more lines of development — one carrying
/// at least one merge parent (a second-or-later P-card hash), i.e. more than one
/// distinct parent. The mainline (primary-only) check-in has exactly one.
fn timelineIsMerge(state: *const repostate.RepoState, rid: repostate.Rid) bool {
    return state.parentCount(rid) >= 2;
}

/// Renders the timeline to stdout: for each check-in in `checkinsByMtime` order,
/// one line `<mark><hash12>  <ISO>  <branch>  <first-comment-line>`. `<mark>` is
/// "M " for a merge check-in (§3.2) else "  ". Bounded by the enumeration size;
/// each row reads and frees exactly one manifest (no unbounded retention). An
/// empty repository prints nothing (B-timeline-7).
fn renderTimeline(gpa: std.mem.Allocator, repo: *Repository, streams: cli.Streams, limit: ?usize) cli.Exit {
    const rids = repo.state().checkinsByMtime(gpa) catch return stateOom(streams, "timeline");
    defer gpa.free(rids);

    // `checkinsByMtime` is newest-first and totally ordered (ties are broken
    // deterministically, not by arrival), so "the most recent N" is the first N
    // of it — including when N check-ins share a one-second timestamp. A cap
    // past the end simply shows everything.
    const shown_count = if (limit) |n| @min(n, rids.len) else rids.len;
    // The window is a prefix of the walked set (`rids`), capped by `-n N` when set.
    std.debug.assert(shown_count <= rids.len);
    if (limit) |n| std.debug.assert(shown_count <= n);
    var i: usize = 0;
    while (i < shown_count) : (i += 1) {
        const rid = rids[i];
        // `checkinsByMtime` postcondition: every returned rid bears an event.
        const mtime = repo.state().mtimeOf(rid) orelse unreachable;
        // Mtime is the repository's millisecond timeline (repostate); the §4.6.4
        // writer form is whole-second, so drop the sub-second remainder.
        var iso_buf: [19]u8 = undefined;
        const iso = cli.formatWriterTimestamp(@intCast(mtime / 1000), &iso_buf);
        const branch = repo.branchOf(rid); // §6.1, config main-branch fallback
        const mark = if (timelineIsMerge(repo.state(), rid)) "M " else "  ";
        const hash = repo.hashOf(rid);
        const shown = hash[0..@min(hash.len, timeline_hash_display)];

        // The comment rides the crosslinked state (the record body carries the
        // C card), so no manifest is read or parsed per row — the cost that
        // dominated an unlimited timeline on a large store. The manifest-read
        // fallback covers a state that cannot answer (image backing); either
        // way a bodyless check-in degenerates to an empty comment, never a
        // failure (errors defined out of existence here).
        var maybe: ?Repository.OwnedManifest = null;
        defer if (maybe) |*m| m.deinit(gpa);
        const manifest_comment: []const u8 = if (repo.state().commentOf(rid)) |c|
            firstCommentLine(c)
        else blk: {
            maybe = repo.manifest(gpa, rid) catch |e| switch (e) {
                error.OutOfMemory => return stateOom(streams, "timeline"),
                error.NotARepo, error.CannotRead, error.Corrupt => return repoReadFail(streams, "timeline"),
            };
            break :blk if (maybe) |m|
                (if (m.manifest.c) |c| firstCommentLine(c.comment) else "")
            else
                "";
        };
        // A `zit amend --comment` override (repo-state §5.2) supersedes the
        // manifest's own comment in the display; absent one, the manifest comment
        // stands. The override value borrows the view's tagxref bytes (valid for
        // the whole render), so it is safe to print after the manifest is freed.
        const comment = repo.override(rid, "comment", manifest_comment);
        streams.out.print("{s}{s}  {s}  {s}  {s}\n", .{ mark, shown, iso, branch, comment }) catch {};
    }
    // Every intended row was rendered — the printed count matches the window.
    std.debug.assert(i == shown_count);
    return .ok;
}

/// `zit branch` — list the repository's branches (repo-state §6.3 derived
/// grouping) one per line, name-ascending, marking the branch of the current
/// checkout (§6.1/§6.4) with a leading "*". Pure presentation over two derived
/// queries (`branches`, `branchOf`); no branch-creation surface (§6.2 is a
/// later write-path slice). The default branch name comes from the view's
/// config-derived `main_branch`, never a literal.
pub fn runBranch(
    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.parseBranchArgs(verb_args)) {
        .ok => |a| a,
        .unknown_option => |tok| return cli.usageError(streams, "branch", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "branch", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(branch_help) catch {};
        return .ok;
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "branch", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // §6.3: the branch set is derived by grouping active branch tags — never
    // stored, never re-hardcoded here.
    const entries = repo.state().branches(gpa) catch return stateOom(streams, "branch");
    defer gpa.free(entries);

    // §6.1/§6.4: mark the checked-out check-in's branch, resolved through the
    // active-tag filter and the config main-branch. `currentCheckoutRid`
    // guarantees `cur` bears an event, so `branchOf`'s precondition holds.
    const cur = repo.currentCheckoutRid();
    const current_name: ?[]const u8 =
        if (cur) |r| repo.branchOf(r) else null;

    renderBranchList(streams.out, entries, current_name);
    return .ok;
}

/// `zit tag list [--raw] [CHECKIN]` (alias `ls`), `zit tag add [--raw]
/// [--propagate] NAME CHECKIN [VALUE]`, and `zit tag cancel [--raw] NAME
/// CHECKIN`.
///
/// `list` is read-only enumeration over the crosslinked repo-view (repo-state
/// §5.1/§5.2): with no CHECKIN, the distinct active tag names in use, symbolic
/// ('sym-') names surfaced with the prefix stripped (§2) and every other tag
/// omitted unless `--raw`; with a CHECKIN, resolve it (§8.3) and list the
/// active tags visible on it and their values — pure presentation over the
/// `tagNames`/`tagsOf` derivations.
///
/// `add` and `cancel` are the write paths (REGRESSION B6): each mints one
/// control artifact carrying a single T card.
pub fn runTag(
    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.parseTagArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "tag", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "tag", .extra_argument, tok),
        .bad_subcommand => |tok| return cli.usageError(streams, "tag", .bad_subcommand, tok),
        .no_name => return cli.usageError(streams, "tag", .no_name, ""),
        .no_checkin => return cli.usageError(streams, "tag", .no_checkin, ""),
        .option_not_for_subcommand => |tok| return cli.usageError(streams, "tag", .option_not_for_subcommand, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(tag_help) catch {};
        return .ok;
    }
    // The mutating arms own their own repository lifetime: the read view must
    // be CLOSED before the read-write append, so they cannot share the listing
    // path's `defer repo.deinit`.
    if (parsed.sub != .list) return runTagWrite(gpa, io, arena, parsed, streams, amb);

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "tag", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    if (parsed.checkin) |name| return runTagCheckin(gpa, &repo, name, streams);

    // §5.1/§2: the distinct active tag names in use — derived, never stored.
    const names = repo.state().tagNames(gpa) catch return stateOom(streams, "tag");
    defer gpa.free(names);
    renderTagNames(streams.out, names, parsed.raw);
    return .ok;
}

/// The `zit tag list CHECKIN` arm: resolve CHECKIN through the §8.3 procedure
/// and list the active tags visible on it. An unresolvable or ambiguous name
/// is a failure (exit 1, diagnostic to stderr); a resolved check-in with no
/// active tags lists nothing (empty stdout, exit 0).
fn runTagCheckin(
    gpa: std.mem.Allocator,
    repo: *Repository,
    name: []const u8,
    streams: cli.Streams,
) cli.Exit {
    const rid = repo.resolve(name, .checkin) catch |e| {
        switch (e) {
            error.NotFound => streams.err.print("zit tag: check-in '{s}' not found\n", .{name}) catch {},
            error.Ambiguous => streams.err.print("zit tag: check-in '{s}' is ambiguous\n", .{name}) catch {},
            // Settling an equal-mtime tie by ancestry allocates (`MtimeMaxPick`).
            error.OutOfMemory => return stateOom(streams, "tag"),
        }
        return .failure;
    };
    const tags = repo.state().tagsOf(gpa, rid) catch return stateOom(streams, "tag");
    defer gpa.free(tags);
    renderTagList(streams.out, tags);
    return .ok;
}

/// Tag-name prefixes fossil reserves for its own crosslinking of wiki pages,
/// tickets, and technotes. A T card in one of these namespaces asserts that
/// its target IS such an artifact, so minting one from the tag verb would
/// forge that association — fossil refuses them here for the same reason, and
/// matching the refusal keeps a zit-written repository readable by fossil
/// without surprises.
const reserved_tag_prefixes = [_][]const u8{ "wiki-", "tkt-", "event-" };

/// The `zit tag add` / `zit tag cancel` arm (REGRESSION B6): mint one control
/// artifact carrying a single `T <sign><name> <hash> [value]` card.
///
/// Mirrors `amend`'s shape, which is the point — both resolve a check-in, then
/// compose a T run, then mint. Resolution first, over a read view that is closed
/// before the read-write append (commit's durability pattern); the resolved hash
/// is duped onto `arena` so it outlives that view. A failure to resolve mints
/// NOTHING: every diagnostic below happens before the single fsync.
///
/// Default sign is SINGLETON (`+`), so a symbolic name pins to exactly this
/// check-in and is not inherited by its descendants — see `composeTagCards`
/// for why that sign is the whole correctness argument, and why `--propagate`
/// belongs to the raw namespace.
fn runTagWrite(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    parsed: cli.TagArgs,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const name = parsed.name.?; // the parser's `no_name` arm guarantees it
    const checkin = parsed.checkin.?; // likewise `no_checkin`
    // An empty NAME names nothing (repo-state §2), rejected before any store is
    // opened — the same early check `commit --branch` makes.
    if (name.len == 0) {
        streams.err.writeAll("zit tag: NAME must not be empty\n") catch {};
        return .failure;
    }
    // An empty VALUE cannot be written: §4.7.16's value is `[ SP value ]`, so
    // "present but empty" has no spelling on the wire. Omitting VALUE is how
    // you say valueless.
    if (parsed.value) |v| {
        if (v.len == 0) {
            streams.err.writeAll("zit tag: VALUE must not be empty; omit it for a valueless tag\n") catch {};
            return .failure;
        }
    }
    for (reserved_tag_prefixes) |prefix| {
        if (std.mem.startsWith(u8, name, prefix)) {
            streams.err.print(
                "zit tag: '{s}' is reserved for fossil's own crosslinking; pick a name outside it\n",
                .{prefix},
            ) catch {};
            return .failure;
        }
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "tag", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);

    var target_hash: []const u8 = undefined;
    {
        defer repo.deinit(gpa);
        const rid = repo.resolve(checkin, .checkin) catch |e| {
            switch (e) {
                error.NotFound => streams.err.print("zit tag: check-in '{s}' not found\n", .{checkin}) catch {},
                error.Ambiguous => streams.err.print("zit tag: check-in '{s}' is ambiguous\n", .{checkin}) catch {},
                // Settling an equal-mtime tie by ancestry allocates.
                error.OutOfMemory => return stateOom(streams, "tag"),
            }
            return .failure;
        };
        target_hash = arena.dupe(u8, repo.hashOf(rid)) catch return stateOom(streams, "tag");
    }
    // The mint targets a resolved check-in by its full §3.6 artifact id.
    std.debug.assert(artifact.validateName(target_hash));

    const sign: artifact.TSign = switch (parsed.sub) {
        .cancel => .cancel,
        .add => if (parsed.propagate) .propagate else .add,
        .list => unreachable, // the caller routes `.list` elsewhere
    };
    const tags = cli.composeTagCards(arena, target_hash, .{
        .sign = sign,
        .name = name,
        .raw = parsed.raw,
        .value = parsed.value,
    }) catch return stateOom(streams, "tag");
    var cname_buf: [64]u8 = undefined;
    const control_name = mintControlArtifact(gpa, io, root_dir, amb, tags, &cname_buf) catch |e|
        return tagLogFail(streams, e);

    // One confirmation line naming the control artifact, as `amend` reports its
    // mint. Every diagnostic already went to stderr.
    streams.out.print("tag {s}\n", .{control_name}) catch {};
    return .ok;
}

/// Renders a `tag add` mint failure. Exhaustive over the widest closed set
/// (`logstore.OpenError`); the narrower put/commit sets coerce into it.
fn tagLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit tag: out of memory\n") catch {},
        error.Storage => s.err.writeAll("zit tag: cannot write the tag: input/output error\n") catch {},
        error.Corrupt => s.err.writeAll("zit tag: the repository is corrupt\n") catch {},
    }
    return .failure;
}

/// §5.1/§2 no-arg listing: one tag name per line, ascending. By default only
/// symbolic ('sym-') tags are listed — they are the user-facing names, and are
/// surfaced with the prefix stripped. The machinery tags (`branch`, `closed`)
/// and other reserved prefixes are not listed as bare names: branch identity
/// is already present via its stripped 'sym-' tag.
///
/// `raw` lists every tag name verbatim, prefixes intact. It is the read half
/// of `tag add --raw`: a property tag is deliberately outside the name space,
/// so without this the tag you just wrote would be invisible here. Pure;
/// bounded by `names.len`.
fn renderTagNames(out: *std.Io.Writer, names: []const []const u8, raw: bool) void {
    for (names) |name| {
        if (raw) {
            out.writeAll(name) catch {};
        } else {
            if (!std.mem.startsWith(u8, name, sym_prefix)) continue;
            out.writeAll(name[sym_prefix.len..]) catch {};
        }
        out.writeAll("\n") catch {};
    }
}

/// §5.1/§5.2 CHECKIN listing: one active tag per line, ascending by raw name
/// (as `tagsOf` returns them). A 'sym-' tag prints with the prefix stripped;
/// every other tag (including the §6.4 `branch` tag and the `closed` marker)
/// prints its raw name. A tag carrying a value payload (§5.2) prints
/// `<name> <value>`; a valueless tag prints `<name>` alone. Pure; bounded by
/// `tags.len`.
fn renderTagList(out: *std.Io.Writer, tags: []const repostate.RepoState.TagEntry) void {
    for (tags) |tag| {
        const shown = if (std.mem.startsWith(u8, tag.name, sym_prefix))
            tag.name[sym_prefix.len..]
        else
            tag.name;
        out.writeAll(shown) catch {};
        if (tag.value) |v| {
            out.writeAll(" ") catch {};
            out.writeAll(v) catch {};
        }
        out.writeAll("\n") catch {};
    }
}

/// `zit info NAME` — resolve NAME through the repo-state §8 procedure and
/// describe the check-in it names: full artifact ID, branch (§6.1), leaf
/// status (§7), and parents (§3.2/§3.3). A thin renderer over the shared
/// resolver: it calls `resolve(name, .checkin)` exactly as `tag` does, never a
/// second resolver. §8.5: resolution is recency-based (§8.2 max-mtime), so a
/// name can name a closed or non-leaf check-in that `leaves` would not list —
/// name resolution is not open-leaf enumeration. Read-only; joins the
/// leaves/branch/tag family through `openRepo`.
pub fn runInfo(
    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.parseInfoArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "info", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "info", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(info_help) catch {};
        return .ok;
    }
    // NAME is required; the arg is a symbolic name, not a check-in ID.
    const name = parsed.name orelse return cli.usageError(streams, "info", .no_name, "");

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "info", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // §8.3 full dispatch. §8.2's two MUST-NOTs: closed-ness and leaf-ness of a
    // candidate never filter the selection — that lives entirely in `resolve`.
    const rid = repo.resolve(name, .checkin) catch |e| {
        switch (e) {
            error.NotFound => streams.err.print("zit info: check-in '{s}' not found\n", .{name}) catch {},
            error.Ambiguous => streams.err.print("zit info: name '{s}' is ambiguous\n", .{name}) catch {},
            error.OutOfMemory => return stateOom(streams, "info"),
        }
        return .failure;
    };
    return renderInfo(gpa, arena, &repo, rid, streams);
}

/// Renders the resolved check-in's facts to stdout only: its full artifact ID,
/// branch (§6.1, config-threaded main-branch as fallback), leaf status
/// (§7.1 membership + §7.4 open/closed), the primary parent (§3.2), and each
/// merge parent (§3.3). Values left-align at a fixed column. Data only — every
/// diagnostic already went to stderr. Bounded by the check-in's parent count.
fn renderInfo(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    rid: repostate.Rid,
    streams: cli.Streams,
) cli.Exit {
    // `resolve`'s postcondition: a resolved rid always bears an event, so every
    // derived query below has a defined answer — no failure path to invent.
    std.debug.assert(repo.state().mtimeOf(rid) != null);

    // Line 1: the canonical full artifact ID (echoes the resolved id even when
    // NAME was a prefix or a symbolic name).
    streams.out.print("{s}\n", .{repo.hashOf(rid)}) catch {};

    // §6.1: the active-tag branch value, config main-branch as the fallback —
    // never the literal "trunk".
    streams.out.print("branch:  {s}\n", .{repo.branchOf(rid)}) catch {};

    // §7.1/§7.4: open (a leaf with no active closed tag), closed (a leaf that
    // carries one), or no (not a leaf at all). open/closed is defined only over
    // leaves, so a non-leaf's closed tag — if any — is not surfaced here. The
    // three arms are total over {isLeaf, hasActiveClosed}.
    const leaf_word: []const u8 = if (repo.state().isOpenLeaf(rid))
        "open"
    else if (repo.state().isLeaf(rid))
        "closed"
    else
        "no";
    streams.out.print("leaf:    {s}\n", .{leaf_word}) catch {};

    // §3.2: the primary parent line (omitted for a root).
    const primary = repo.state().primaryParentOf(rid);
    if (primary) |p| streams.out.print("parent:  {s}\n", .{repo.hashOf(p)}) catch {};

    const mp = renderMergeParents(arena, repo, rid, primary, streams);
    if (mp != .ok) return mp;

    // §4.7.14 cherrypick/backout provenance (B-merge-41): a Q card on this
    // check-in surfaces as a `cherrypick:`/`backout:` line naming the picked
    // REV. Derived through the shipped artifact parse path (repo.manifest →
    // artifact.parse → parseQCard), never a bespoke re-parse; repostate keeps
    // no Q edge (REV is not a parent), so the manifest is the only source. A
    // check-in with no Q card prints nothing here — byte-identical to its
    // pre-B-merge-41 baseline (negative space). A parse fault skips the line
    // rather than failing the already-rendered facts. Bounded by the (v1:
    // single) Q-card count; the value column widens past the fixed lines
    // above because `cherrypick:` overflows their 8-space field.
    if (repo.manifest(gpa, rid) catch null) |owned| {
        var om = owned;
        defer om.deinit(gpa);
        for (om.manifest.q) |q| {
            const label: []const u8 = switch (q.sign) {
                .cherrypick => "cherrypick: ",
                .backout => "backout:    ",
            };
            streams.out.print("{s}{s}\n", .{ label, q.target }) catch {};
        }
    }

    // Display overrides (repo-state §5.2, minted by `zit amend`): emit a
    // `comment:`/`user:`/`date:` line for each override ONLY when it is active, so
    // an un-amended check-in's info output stays byte-identical to its B-info
    // baseline (negative space). This is the one render path that surfaces a
    // check-in's overridden user and date. Values align at the same column as the
    // fixed lines above (8-space field, so `comment:`+1, `user:`/`date:`+4).
    const overrides = [_][]const u8{ "comment", "user", "date" };
    for (overrides) |name| {
        std.debug.assert(name.len >= 1 and name.len <= 7);
        if (repo.state().activeTagValue(rid, name)) |v| {
            const pad = "        "; // 8 spaces; the value column lands at index 9
            streams.out.print("{s}:{s}{s}\n", .{ name, pad[0 .. 8 - name.len], v }) catch {};
        }
    }
    return .ok;
}

/// Renders the §3.3 merge parents of `rid` — every parent but the primary —
/// one `merge:` line each, sorted by artifact ID ascending. Gathered into an
/// arena vector bounded by the parent count; OOM → exit 1.
fn renderMergeParents(
    arena: std.mem.Allocator,
    repo: *Repository,
    rid: repostate.Rid,
    primary: ?repostate.Rid,
    streams: cli.Streams,
) cli.Exit {
    var merges: std.ArrayList([]const u8) = .empty;
    defer merges.deinit(arena);
    const bound = repo.state().parentCount(rid);
    var scanned: usize = 0;
    var it = repo.state().parentsOf(rid);
    while (it.next()) |parent| {
        scanned += 1;
        std.debug.assert(scanned <= bound);
        if (primary != null and parent == primary.?) continue;
        merges.append(arena, repo.hashOf(parent)) catch {
            streams.err.writeAll("zit info: out of memory\n") catch {};
            return .failure;
        };
    }
    // Negative space: exactly the parents minus the single §3.2 primary — no
    // parent dropped or doubled.
    std.debug.assert(merges.items.len == bound - @as(usize, if (primary == null) 0 else 1));
    std.mem.sort([]const u8, merges.items, {}, lessByHash);
    for (merges.items) |hash| streams.out.print("merge:   {s}\n", .{hash}) catch {};
    return .ok;
}

/// `zit ls [VERSION]` — list the effective file set of a resolved check-in, one
/// `<blob-id> <filename>` line per file in canonical §4.5.3 order. Thin over the
/// `Repository` facade: resolve VERSION (or fall back to the current checkout),
/// then stream `repo.files`' iterator. All the §8.2 merge and §8.3 baseline
/// policy lives in the library; this only selects the check-in and renders.
pub fn runLs(
    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.parseLsArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "ls", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "ls", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(ls_help) catch {};
        return .ok;
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "ls", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // An explicit VERSION resolves through the §8.3 name procedure; an omitted
    // VERSION lists the current checkout (§7.5). An unborn or absent checkout has
    // no check-in to list — a failure, not empty output.
    const rid = if (parsed.version) |name|
        repo.resolve(name, .checkin) catch |e| return lsResolveFail(streams, name, e)
    else
        repo.currentCheckoutRid() orelse return lsNoCheckout(streams);

    var list = (repo.files(gpa, rid) catch |e| return lsFilesFail(streams, e)) orelse
        return lsMissingContent(streams);
    defer list.deinit(gpa);

    // Data only: one line per effective file. Iteration is over in-memory cards
    // (no I/O), so nothing but the write itself can fail mid-stream.
    var it = list.iterator();
    while (it.next()) |f| {
        std.debug.assert(f.hash != null); // the iterator suppresses delete markers
        streams.out.print("{s} {s}\n", .{ f.hash.?, f.filename }) catch {};
    }
    return .ok;
}

/// Maps a `Repository.resolve` failure for `ls` to its diagnostic + exit 1.
fn lsResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit {
    switch (e) {
        error.NotFound => s.err.print("zit ls: check-in '{s}' not found\n", .{name}) catch {},
        error.Ambiguous => s.err.print("zit ls: name '{s}' is ambiguous\n", .{name}) catch {},
        error.OutOfMemory => s.err.writeAll("zit ls: out of memory\n") catch {},
    }
    return .failure;
}

/// Maps a `Repository.files` failure to its diagnostic + exit 1. Closed over
/// `FilesError` so the switch stays exhaustive; the open/content phrasings match
/// the shared `repoOpenFail` text.
fn lsFilesFail(s: cli.Streams, e: Repository.FilesError) cli.Exit {
    switch (e) {
        error.OutOfMemory => s.err.writeAll("zit ls: out of memory\n") catch {},
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.writeAll("zit ls: cannot read repository: input/output error\n") catch {},
        error.Corrupt => s.err.writeAll("zit ls: cannot read repository: repository is corrupt\n") catch {},
        error.NotManifest => s.err.writeAll("zit ls: not a check-in\n") catch {},
        error.BaselineUnresolvable => s.err.writeAll("zit ls: cannot resolve the baseline of a delta check-in\n") catch {},
        error.BaselineIsDelta => s.err.writeAll("zit ls: the baseline of a delta check-in is itself a delta\n") catch {},
    }
    return .failure;
}

fn lsNoCheckout(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit ls: no check-in to list (the working checkout is unborn); name a version\n") catch {};
    return .failure;
}

fn lsMissingContent(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit ls: check-in content is missing\n") catch {};
    return .failure;
}

/// `zit cat FILE [VERSION]` — emit one file's uncompressed content at a resolved
/// check-in. `ls` narrowed to a single file (§8.4 single-name lookup): resolve
/// the version the same way `ls` does, look the file up in the effective set,
/// reconstruct its blob (delta chain → uncompressed, hash-verified §7.2), and
/// write the bytes verbatim — no framing, no trailing newline (content is
/// `[]const u8`, so embedded NUL and arbitrary binary bytes pass through).
pub fn runCat(
    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.parseCatArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "cat", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "cat", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(cat_help) catch {};
        return .ok;
    }
    const file = parsed.file orelse return cli.usageError(streams, "cat", .no_file, "");

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "cat", amb.repo_override, parsed.no_cache, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // Version resolution mirrors `ls`: an explicit VERSION through the §8.3 name
    // procedure, an omitted one the current checkout (§7.5); an unborn/absent
    // checkout has no check-in to read.
    const rid = if (parsed.version) |name|
        repo.resolve(name, .checkin) catch |e| return catResolveFail(streams, name, e)
    else
        repo.currentCheckoutRid() orelse return catNoCheckout(streams);

    // §8.4 single-name lookup: null ⇒ the file is not in this check-in's
    // effective set (absent, or a delete marker suppresses it).
    const found = (repo.fileAt(gpa, rid, file) catch |e| return catFail(streams, e)) orelse
        return catNotFound(streams, file);
    defer Repository.freeFileCard(gpa, found);
    std.debug.assert(found.hash != null); // fileAt yields only hash-bearing cards (§4.7.6)

    // Reconstruct the blob and verify it hashes to its F-card id (§7.2). A null
    // blob is a stored-content gap for a named-but-absent file.
    const bytes = (repo.blob(gpa, found.hash.?) catch |e| return catFail(streams, e)) orelse
        return catMissingContent(streams, file);
    defer gpa.free(bytes);
    streams.out.writeAll(bytes) catch {};
    return .ok;
}

/// Maps a `Repository.resolve` failure for `cat` to its diagnostic + exit 1.
fn catResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit {
    switch (e) {
        error.NotFound => s.err.print("zit cat: check-in '{s}' not found\n", .{name}) catch {},
        error.Ambiguous => s.err.print("zit cat: name '{s}' is ambiguous\n", .{name}) catch {},
        error.OutOfMemory => s.err.writeAll("zit cat: out of memory\n") catch {},
    }
    return .failure;
}

/// Maps a `Repository.fileAt` / `blob` failure to its diagnostic + exit 1.
/// Switches the full `FilesError` set exhaustively; `blob`'s narrower `OpenError`
/// coerces into it, so one renderer covers both surfaces. The open/content
/// phrasings match the shared `repoOpenFail` text.
fn catFail(s: cli.Streams, e: Repository.FilesError) cli.Exit {
    switch (e) {
        error.OutOfMemory => s.err.writeAll("zit cat: out of memory\n") catch {},
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.writeAll("zit cat: cannot read repository: input/output error\n") catch {},
        error.Corrupt => s.err.writeAll("zit cat: cannot read repository: repository is corrupt\n") catch {},
        error.NotManifest => s.err.writeAll("zit cat: not a check-in\n") catch {},
        error.BaselineUnresolvable => s.err.writeAll("zit cat: cannot resolve the baseline of a delta check-in\n") catch {},
        error.BaselineIsDelta => s.err.writeAll("zit cat: the baseline of a delta check-in is itself a delta\n") catch {},
    }
    return .failure;
}

fn catNoCheckout(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit cat: no check-in to read (the working checkout is unborn); name a version\n") catch {};
    return .failure;
}

fn catNotFound(s: cli.Streams, file: []const u8) cli.Exit {
    s.err.print("zit cat: file '{s}' is not in the check-in\n", .{file}) catch {};
    return .failure;
}

fn catMissingContent(s: cli.Streams, file: []const u8) cli.Exit {
    s.err.print("zit cat: content for '{s}' is missing\n", .{file}) catch {};
    return .failure;
}

/// `zit artifact HASH [--raw]` — zit's plumbing window (contracts/artifact.md).
/// A strictly read-only describe/dump of ONE stored artifact addressed by its
/// hash. HASH covers every artifact kind (file blobs included), so it resolves
/// through the artifact store's prefix lookup, not the symbolic-name procedure
/// `info`/`ls`/`cat` use. No flag → the whatis summary; `--raw` → the exact
/// stored bytes. All card interpretation flows through the one `artifact`
/// parser — never a CLI-side re-parse.
pub fn runArtifact(
    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.parseArtifactArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "artifact", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "artifact", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(artifact_help) catch {};
        return .ok;
    }
    const hash_arg = parsed.hash orelse return cli.usageError(streams, "artifact", .no_hash, "");

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "artifact", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // Canonicalize HASH: lowercase, hex-only, length band 4..64 (§3.2/§3.6). A
    // syntactically invalid HASH is the SAME not-found family as an unknown one
    // (B-artifact-8) — no distinct error surface.
    var canon_buf: [2 * logstore.max_hash_len]u8 = undefined;
    const canon = canonicalizeHexPrefix(hash_arg, &canon_buf) orelse return artifactNotFound(streams, hash_arg);

    var full_buf: [2 * logstore.max_hash_len]u8 = undefined;
    const full = (repo.resolveArtifactPrefix(gpa, canon, &full_buf) catch |e| switch (e) {
        error.Ambiguous => return artifactAmbiguous(streams, hash_arg), // B-artifact-7
        error.OutOfMemory => return artifactOom(streams),
        error.NotARepo, error.CannotRead, error.Corrupt => |oe| return repoOpenFail(streams, "artifact", oe),
    }) orelse return artifactNotFound(streams, hash_arg); // B-artifact-8

    // §7.2-verified stored bytes. `resolveArtifactPrefix` named a real stored
    // artifact, so a null here would be a store race, not user input.
    const bytes = (repo.blob(gpa, full) catch |e| return repoOpenFail(streams, "artifact", e)) orelse
        return artifactNotFound(streams, hash_arg);
    defer gpa.free(bytes);
    // B-artifact-4 round-trip invariant: the emitted bytes hash back to HASH.
    std.debug.assert(artifact.verify(full, bytes));

    if (parsed.raw) {
        // B-artifact-4: verbatim, no trailing newline, binary-safe (mirrors cat).
        // Never parses or verifies Z.
        streams.out.writeAll(bytes) catch {};
        return .ok;
    }
    return renderArtifactSummary(streams, gpa, full, bytes);
}

/// Canonicalize a HASH argument into `out`: lowercase every byte, accept only
/// hex digits and only a 4..64-length token (§3.2 length band, §3.6 case-
/// insensitive prefix). Returns null for anything outside that — the caller
/// folds null into the not-found refusal, so a bad HASH never has its own
/// error surface (B-artifact-8). No allocation.
fn canonicalizeHexPrefix(arg: []const u8, out: *[2 * logstore.max_hash_len]u8) ?[]const u8 {
    if (arg.len < 4 or arg.len > out.len) return null;
    for (arg, 0..) |c, i| {
        const lc = std.ascii.toLower(c);
        if (!((lc >= '0' and lc <= '9') or (lc >= 'a' and lc <= 'f'))) return null;
        out[i] = lc;
    }
    return out[0..arg.len];
}

/// Render the whatis summary of a resolved artifact to stdout (data-only). Tries
/// the Z-tolerant `parseWindow`: on success the artifact is structural (its type,
/// size, one line per card, and the §6.2 Z verdict); on any framing/grammar/
/// classification fault it is an opaque blob (B-artifact-3) — type file, size,
/// and the NUL binary verdict, never an error. Exit 0 either way.
fn renderArtifactSummary(streams: cli.Streams, gpa: std.mem.Allocator, full: []const u8, bytes: []const u8) cli.Exit {
    streams.out.print("{s}\n", .{full}) catch {};

    var m = artifact.parseWindow(gpa, bytes) catch |e| switch (e) {
        error.OutOfMemory => return artifactOom(streams),
        else => {
            // A blob is a first-class answer (B-artifact-3), not a failure.
            streams.out.writeAll("type: file\n") catch {};
            streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {};
            streams.out.print("binary: {s}\n", .{if (merge3.isBinary(bytes)) "yes" else "no"}) catch {};
            return .ok;
        },
    };
    defer m.deinit();

    streams.out.print("type: {s}\n", .{m.kind.diagnosticName()}) catch {};
    streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {};
    renderCardLines(streams, m);

    // B-artifact-5: the Z verdict is REPORTED, not enforced — a mismatch stays
    // exit 0 (this is a window, not a gate). Emitted only when a Z card is
    // present; `parseWindow` succeeded so the framed content is recoverable.
    if (m.z != null) {
        const content = artifact.frameNoZ(bytes).?; // non-null: parseWindow framed it
        const verdict = artifact.verifyZCard(content);
        std.debug.assert(verdict != .absent); // a present Z card is structurally last
        streams.out.print("Z: {s}\n", .{if (verdict == .match) "verified" else "checksum mismatch"}) catch {};
    }
    return .ok;
}

/// One line per card of a structural artifact, in ascending card-letter order
/// (A..W; Z is the separate verdict line the caller prints). §4.4 makes wire
/// order non-decreasing, so lane order == stored order — this reads decoded
/// struct fields, never re-walking the bytes. Repeatable lanes (F/J/M/P/Q/T)
/// render in retained wire order, one line per card. Best-effort writes.
fn renderCardLines(s: cli.Streams, m: artifact.Manifest) void {
    if (m.a) |a| {
        s.out.print("A {s} {s}", .{ a.name, a.target }) catch {};
        if (a.source) |src| s.out.print(" {s}", .{src}) catch {};
        s.out.writeAll("\n") catch {};
    }
    if (m.b) |b| s.out.print("B {s}\n", .{b.hash}) catch {};
    if (m.c) |c| s.out.print("C {s}\n", .{c.comment}) catch {};
    if (m.d) |d| s.out.print("D {s}\n", .{d.timestamp}) catch {};
    if (m.e) |e| s.out.print("E {s} {s}\n", .{ e.timestamp, e.technote_id }) catch {};
    for (m.f) |f| {
        s.out.print("F {s}", .{f.filename}) catch {};
        if (f.hash) |h| s.out.print(" {s}", .{h}) catch {};
        if (f.perm) |p| s.out.print(" {s}", .{p}) catch {};
        if (f.old_name) |o| s.out.print(" {s}", .{o}) catch {};
        s.out.writeAll("\n") catch {};
    }
    if (m.g) |g| s.out.print("G {s}\n", .{g.hash}) catch {};
    if (m.h) |h| s.out.print("H {s}\n", .{h.title}) catch {};
    if (m.i) |i| s.out.print("I {s}\n", .{i.hash}) catch {};
    for (m.j) |j| s.out.print("J {s} {s}\n", .{ j.field, j.value }) catch {};
    if (m.k) |k| s.out.print("K {s}\n", .{k.ticket_id}) catch {};
    if (m.l) |l| s.out.print("L {s}\n", .{l.title}) catch {};
    for (m.m) |mc| s.out.print("M {s}\n", .{mc.hash}) catch {};
    if (m.n) |n| s.out.print("N {s}\n", .{n.mimetype}) catch {};
    for (m.p) |p| {
        s.out.writeAll("P") catch {};
        var it = p.hashes();
        while (it.next()) |hh| s.out.print(" {s}", .{hh}) catch {};
        s.out.writeAll("\n") catch {};
    }
    for (m.q) |q| {
        const sign_byte: u8 = if (q.sign == .cherrypick) '+' else '-';
        s.out.print("Q {c}{s}", .{ sign_byte, q.target }) catch {};
        if (q.baseline) |base| s.out.print(" {s}", .{base}) catch {};
        s.out.writeAll("\n") catch {};
    }
    if (m.r) |r| s.out.print("R {s}\n", .{r.checksum}) catch {};
    for (m.t) |t| {
        const tgt = t.target orelse "*"; // §4.7.16 self-referential tag
        s.out.print("T {c}{s} {s}", .{ t.sign.byte(), t.name, tgt }) catch {};
        if (t.value) |v| s.out.print(" {s}", .{v}) catch {};
        s.out.writeAll("\n") catch {};
    }
    if (m.u) |u| s.out.print("U {s}\n", .{u.login}) catch {};
    if (m.w) |w| s.out.print("W {d} bytes\n", .{w.len}) catch {};
}

fn artifactNotFound(s: cli.Streams, arg: []const u8) cli.Exit {
    s.err.print("zit artifact: no artifact matches '{s}'\n", .{arg}) catch {};
    return .failure;
}

fn artifactAmbiguous(s: cli.Streams, arg: []const u8) cli.Exit {
    s.err.print("zit artifact: hash '{s}' is ambiguous\n", .{arg}) catch {};
    return .failure;
}

fn artifactOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit artifact: out of memory\n") catch {};
    return .failure;
}

/// The narrow read-failure set the annotate content path collapses the
/// `Repository` file/blob errors into, so the renderer switches once.
const AnnotateReadError = error{ OutOfMemory, Storage, Corrupt };

/// PATH's effective content in the check-in `rid` (artifact §8 via
/// `Repository.fileAt` + `blob`), duplicated into `arena`, or null when PATH is
/// absent there (never added, or a delta delete). Transient `gpa` allocations
/// (the F card, the verified blob) are freed before returning; only the arena
/// copy survives, owned by the caller's arena.
fn annotateContentAt(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    rid: repostate.Rid,
    path: []const u8,
) AnnotateReadError!?[]const u8 {
    const maybe_card = repo.fileAt(gpa, rid, path) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        // NotARepo/Corrupt/NotManifest/BaselineUnresolvable/BaselineIsDelta all
        // mean this reachable check-in cannot be reconstructed — corrupt.
        else => error.Corrupt,
    };
    const card = maybe_card orelse return null;
    defer Repository.freeFileCard(gpa, card);
    // The effective-set iterator suppresses delete markers, so a returned card
    // always carries a hash (§4.7.6).
    const bytes = (repo.blob(gpa, card.hash.?) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        error.NotARepo, error.Corrupt => error.Corrupt,
    }) orelse return null;
    defer gpa.free(bytes);
    return try arena.dupe(u8, bytes);
}

/// One check-in in PATH's ancestry, in the processing order the repostate
/// topological linearization fixes (parent before child; B-annotate-5). `hash`
/// borrows the view's identity space (stable for the view's lifetime).
const AnnotateEntry = struct {
    rid: repostate.Rid,
    mtime: repostate.Mtime,
    hash: []const u8,
};

fn annotateReadFail(streams: cli.Streams, e: AnnotateReadError) cli.Exit {
    switch (e) {
        error.OutOfMemory => streams.err.writeAll("zit annotate: out of memory\n") catch {},
        error.Storage => streams.err.writeAll("zit annotate: cannot read repository: input/output error\n") catch {},
        error.Corrupt => streams.err.writeAll("zit annotate: cannot read repository: repository is corrupt\n") catch {},
    }
    return .failure;
}

/// The B-annotate-10 refusal: PATH is not in the current check-out's managed set.
/// Distinguishes an untracked-but-present working file from a nonexistent one so
/// the diagnostic tells the user which mistake they made. Exit 1, stdout empty.
fn annotateNotTracked(io: std.Io, root_dir: std.Io.Dir, path: []const u8, streams: cli.Streams) cli.Exit {
    const present = blk: {
        var f = root_dir.openFile(io, path, .{}) catch break :blk false;
        f.close(io);
        break :blk true;
    };
    if (present)
        streams.err.print("zit annotate: '{s}' is not tracked at the current check-out\n", .{path}) catch {}
    else
        streams.err.print("zit annotate: '{s}': no such tracked file\n", .{path}) catch {};
    return .failure;
}

/// `zit annotate PATH` — parse (one repo-relative positional via the shared
/// one-positional grammar), open the repository read-only, and render the
/// annotation. `-h`/`--help` wins; a missing PATH / unknown option / second
/// positional is a usage error (exit 2). (B-annotate-12/-13/-16.)
pub fn runAnnotate(
    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.parseAnnotateArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "annotate", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "annotate", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(annotate_help) catch {};
        return .ok;
    }
    const path = parsed.path orelse return cli.usageError(streams, "annotate", .no_file, "");

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    // A cold read: it walks ancestry (§3.3) and reconstructs effective sets, so
    // it takes the full derive, never the zero-parse image path.
    const rc = openRepo(gpa, io, arena, streams, "annotate", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    // VERSION resolves exactly as `cat`'s (the §8.3 name procedure); omitted
    // means the current checkout, so a bare repository (-R with no working
    // tree) names its target explicitly (issue #67).
    const at: ?repostate.Rid = if (parsed.version) |name|
        repo.resolve(name, .checkin) catch |e| return annotateResolveFail(streams, name, e)
    else
        null;

    return annotateFile(gpa, io, &repo, root_dir, path, at, parsed.full, streams);
}

/// Maps a `Repository.resolve` failure for `annotate` to its diagnostic + exit 1.
fn annotateResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit {
    switch (e) {
        error.NotFound => s.err.print("zit annotate: check-in '{s}' not found\n", .{name}) catch {},
        error.Ambiguous => s.err.print("zit annotate: name '{s}' is ambiguous\n", .{name}) catch {},
        error.OutOfMemory => s.err.writeAll("zit annotate: out of memory\n") catch {},
    }
    return .failure;
}

/// Gather PATH's version chain over the current check-out's full ancestry,
/// attribute each target line through the pure engine, and render. A scratch
/// arena owns every derived array and every materialized version body; the
/// repository content channel stays on `gpa` so it is freed by `repo.deinit`.
fn annotateFile(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    path: []const u8,
    at: ?repostate.Rid,
    full_ids: bool,
    streams: cli.Streams,
) cli.Exit {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // The annotated version is PATH at `at` (an explicit VERSION), else at the
    // current check-out. An unborn check-out tracks nothing (B-annotate-10 /
    // -14 boundary).
    const cur = at orelse repo.currentCheckoutRid() orelse
        return annotateNotTracked(io, root_dir, path, streams);

    // The full ancestry of `cur` (repo-state §3.3, merge parents included per
    // §3.2), already linearized into processing order by the ONE general-purpose
    // query — never a CLI-side walk or sort (the merge-base/bisect posture). The
    // order is a topological linearization of the ancestry DAG: every parent
    // precedes its child, so same-mtime and non-monotone parent/child pairs never
    // invert the attribution (ambiguities.md `annotate-attribution`).
    const ordered_rids = repo.ancestryLinearized(gpa, cur) catch return stateOom(streams, "annotate");
    defer gpa.free(ordered_rids);
    var entries: std.ArrayList(AnnotateEntry) = .empty;
    for (ordered_rids) |rid| {
        entries.append(arena, .{
            .rid = rid,
            .mtime = repo.state().mtimeOf(rid) orelse 0,
            .hash = repo.hashOf(rid),
        }) catch return stateOom(streams, "annotate");
    }

    // rid → node index in processing order, so parent links resolve to indices.
    var index_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty;
    for (entries.items, 0..) |e, i| index_of.put(arena, e.rid, @intCast(i)) catch return stateOom(streams, "annotate");

    // Build the pure engine's node view: content + §3.2 primary/merge parents.
    const nodes = arena.alloc(annotate_mod.Node, entries.items.len) catch return stateOom(streams, "annotate");
    for (entries.items, 0..) |e, i| {
        const content = annotateContentAt(gpa, arena, repo, e.rid, path) catch |err|
            return annotateReadFail(streams, err);

        const primary_rid = repo.state().primaryParentOf(e.rid);
        const primary: ?u32 = if (primary_rid) |pr| index_of.get(pr) else null;

        var merges: std.ArrayList(u32) = .empty;
        var it = repo.state().parentsOf(e.rid);
        while (it.next()) |p| {
            if (primary_rid != null and p == primary_rid.?) continue; // primary handled separately
            if (index_of.get(p)) |idx| merges.append(arena, idx) catch return stateOom(streams, "annotate");
        }
        nodes[i] = .{ .content = content, .primary = primary, .merges = merges.items };
    }

    const cur_index = index_of.get(cur).?; // cur is self-inclusive in its ancestry
    const target = nodes[cur_index].content orelse
        return annotateNotTracked(io, root_dir, path, streams);

    // Provenance is undefined over binary content (B-annotate-9, the merge3 NUL
    // predicate) — refuse before emitting anything.
    if (merge3.isBinary(target)) {
        streams.err.print("zit annotate: '{s}': cannot annotate binary content\n", .{path}) catch {};
        return .failure;
    }

    const origins = annotate_mod.attribute(arena, nodes, cur_index) catch return stateOom(streams, "annotate");

    // Render one output line per file line: the origin's 10-char artifact-hash
    // prefix, a space, the ISO date (UTC, from the origin's event mtime), a space,
    // then the file line verbatim (its own trailing newline carries the break).
    const lines = merge3.splitLines(arena, target) catch return stateOom(streams, "annotate");
    std.debug.assert(lines.len == origins.len);
    for (lines, origins) |line, origin| {
        const oe = entries.items[origin];
        const full = repo.hashOf(oe.rid);
        // --full: the whole artifact ID, safe as an index key; the default
        // 10-character prefix stays for eyeballs (issue #67).
        const shown = if (full_ids) full else full[0..@min(full.len, 10)];
        var iso_buf: [19]u8 = undefined;
        const ts = cli.formatWriterTimestamp(@intCast(oe.mtime / 1000), &iso_buf);
        const iso_date = ts[0..10]; // YYYY-MM-DD
        streams.out.print("{s} {s} {s}", .{ shown, iso_date, merge3.lineBytes(target, line) }) catch {};
    }
    return .ok;
}

/// Seed a `depth`-deep linear check-in chain into `log` (index 0 = the deepest
/// ancestor, index depth-1 = the tip), filling `names_out` with each check-in's
/// 64-hex name. The deepest check-in's sole P parent is a check-in NOT stored
/// here — a phantom. Used to witness `walkLog`'s windowed early exit: the
/// phantom line is emitted only if the walk descends past the deepest ancestor.
fn logSeedPhantomChain(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    comptime depth: usize,
    names_out: *[depth][64]u8,
) !void {
    try log.putConfig(.{ .hash_policy = 2, .default_branch = "trunk", .project_id = "pid", .project_code = "code-abc" });
    // A valid 64-hex name for content deliberately absent from the store.
    var phantom: [64]u8 = undefined;
    _ = zit.artifact.artifactName(.sha3_256, "unstored-parent", &phantom);
    var i: usize = 0;
    while (i < depth) : (i += 1) {
        const v = try packEvolvingBlob(gpa, @intCast(i + 1));
        defer gpa.free(v);
        const b = try packSeedBlob(log, v);
        const parent: []const u8 = if (i == 0) &phantom else &names_out[i - 1];
        var ts_buf: [32]u8 = undefined;
        const ts = try std.fmt.bufPrint(&ts_buf, "2020-01-{d:0>2}T00:00:00", .{i + 1});
        try packSeedCheckin(gpa, log, ts, parent, "f.txt", &b, &names_out[i]);
    }
    try log.commit();
}

/// Count the lines of `text` that begin with `prefix` (test-only line tally).
fn logCountLinePrefix(text: []const u8, prefix: []const u8) usize {
    var n: usize = 0;
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        if (std.mem.startsWith(u8, line, prefix)) n += 1;
    }
    return n;
}

test "log walk: `zit log CHECKIN` prints every reachable ancestor in P-card DFS order then the missing parent as a phantom" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const depth = 6;
    var names: [depth][64]u8 = undefined;
    {
        var wfs: storage.FileStorage = undefined;
        var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs);
        defer wfs.file.close(io);
        defer wlog.deinit(gpa);
        try logSeedPhantomChain(gpa, &wlog, depth, &names);
    }
    var sfs: storage.FileStorage = undefined;
    var log = try packOpenReadOnly(gpa, io, tmp.dir, &sfs);
    defer sfs.file.close(io);
    defer log.deinit(gpa);

    const tip: []const u8 = &names[depth - 1];

    // The unbounded walk prints all `depth` check-ins tip->root in P-card DFS
    // order, then the deepest ancestor's phantom parent — exactly once each.
    var buf: [4096]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    try walkLog(gpa, &log, tip, &w, null, null);
    const out = w.buffered();
    try std.testing.expectEqual(@as(usize, depth), logCountLinePrefix(out, "checkin "));
    try std.testing.expectEqual(@as(usize, 1), logCountLinePrefix(out, "phantom "));

    var ebuf: [4096]u8 = undefined;
    var stream = Io.Writer.fixed(&ebuf);
    var d: usize = depth;
    while (d > 0) : (d -= 1) stream.print("checkin {s}\n", .{names[d - 1][0..]}) catch unreachable;
    const want_checkins = stream.buffered();
    try std.testing.expectEqualStrings(want_checkins, out[0..want_checkins.len]);
}

// ---------------------------------------------------------------------------
// frames (#66): the incremental-consumer read seam. Emits every artifact
// appended after a cursor as whole raw bytes, self-framed, with a trailer
// carrying the new cursor and a prefix-validation token. The token is the
// store's own LogKey — the O(1) freshness proof the derived-cache tier
// already trusts — so appends keep a cursor alive and a pack/shun rewrite
// (a new generation, new layout) kills it detectably.
// ---------------------------------------------------------------------------

const frames_help = main.frames_help;

/// Decode a `--since` cursor: the decimal rid count a previous trailer
/// emitted. Null on any malformation — the caller folds it into the
/// cursor-invalid refusal (a forged cursor is indistinguishable from a dead
/// one, and both have the same remedy: re-read from zero).
fn framesParseCursor(text: []const u8) ?u64 {
    return std.fmt.parseInt(u64, text, 10) catch null;
}

/// Decode a `--verify` token: the four LogKey fields a previous trailer
/// emitted, dash-joined decimal. Null on any malformation.
fn framesParseToken(text: []const u8) ?logstore.LogStore.LogKey {
    var it = std.mem.splitScalar(u8, text, '-');
    const end = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null;
    const frame_count = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null;
    const last_frame_offset = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null;
    const last_header_crc = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null;
    if (it.next() != null) return null; // no residue
    return .{
        .end = end,
        .frame_count = frame_count,
        .last_frame_offset = last_frame_offset,
        .last_header_crc = last_header_crc,
    };
}

pub fn runFrames(
    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.parseFramesArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "frames", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "frames", .extra_argument, tok),
        .empty_value => |tok| return cli.usageError(streams, "frames", .unknown_option, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(frames_help) catch {};
        return .ok;
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "frames", amb.repo_override, false, true, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "frames", e);
    // PERSISTED (frame) order, not dense rid order: `intern` mints a rid for
    // a phantom before the frame that fills it lands, so a rid-ordered cursor
    // would pass a hole and never revisit it. Frame order is append order --
    // the same clock the LogKey prefix token describes.
    const persisted = log.ridsInPersistedOrder(gpa) catch return stateOom(streams, "frames");
    defer gpa.free(persisted);
    const count: u64 = persisted.len;

    // The consumed prefix: 0 (bootstrap) or the previous trailer's cursor.
    var since: u64 = 0;
    if (parsed.since) |text| {
        since = framesParseCursor(text) orelse return framesCursorInvalid(streams, "the cursor is not one this store issued");
        // A cursor past the store's end can only come from a rewritten
        // (shrunk) store or a foreign one -- dead either way.
        if (since > count) return framesCursorInvalid(streams, "the cursor names artifacts this store does not hold");
    }
    // Prefix validation: the token is the LogKey of the consumed prefix. A
    // rewrite (pack / shun reclaim) relocates or re-CRCs frames and fails
    // this check; plain appends never touch it.
    if (parsed.verify) |text| {
        const key = framesParseToken(text) orelse return framesCursorInvalid(streams, "the token is not one this store issued");
        if (!logstore.LogStore.validatePrefix(log.file, key)) {
            return framesCursorInvalid(streams, "the store was rewritten since the cursor was issued");
        }
    }

    // Emit rids since+1..count: whole reconstructed artifacts (the delta
    // chain and container layout stay private), each verified against its
    // name before a byte leaves — byte-identical to `artifact HASH --raw`.
    // Phantoms hold no bytes and shun-masked names read as absent; both are
    // skipped, and the cursor still advances over them (rid order is the
    // cursor's clock, presence is per-artifact).
    var emitted: u64 = 0;
    for (persisted[@intCast(since)..]) |r| {
        const bytes = (log.content(gpa, r) catch |e| return framesReadFail(streams, e)) orelse continue;
        defer gpa.free(bytes);
        var hex_buf: [2 * logstore.max_hash_len]u8 = undefined;
        const hex = std.fmt.bufPrint(&hex_buf, "{x}", .{log.hashOf(r)}) catch unreachable;
        if (!zit.artifact.verify(hex, bytes)) {
            streams.err.print("zit frames: stored artifact {s} does not hash to its name\n", .{hex}) catch {};
            return .failure;
        }
        streams.out.print("artifact {s} {d}\n", .{ hex, bytes.len }) catch {};
        streams.out.writeAll(bytes) catch {};
        streams.out.writeAll("\n") catch {};
        emitted += 1;
    }

    // The trailer: the new cursor and its token, emitted even when nothing
    // new exists so a bootstrap poll always learns its position.
    const key = if (count == 0)
        (log.logKey() catch |e| return framesKeyFail(streams, e))
    else
        (log.logKeyAt(persisted[persisted.len - 1]) catch |e| return framesKeyFail(streams, e));
    streams.out.print("cursor {d} {d}-{d}-{d}-{d}\n", .{
        count, key.end, key.frame_count, key.last_frame_offset, key.last_header_crc,
    }) catch {};

    if (emitted == 0) return .no_new;
    return .ok;
}

/// The #66 cursor-invalid refusal: a distinct exit code (4) so a consumer
/// distinguishes "re-read from zero" from every operational failure. The
/// message states the mechanism, never "corrupt".
fn framesCursorInvalid(s: cli.Streams, reason: []const u8) cli.Exit {
    s.err.print("zit frames: cursor invalid: {s}; re-read from --since=0\n", .{reason}) catch {};
    return .cursor_invalid;
}

fn framesReadFail(s: cli.Streams, e: logstore.ContentError) cli.Exit {
    switch (e) {
        error.OutOfMemory => s.err.writeAll("zit frames: out of memory\n") catch {},
        error.Storage => s.err.writeAll("zit frames: cannot read the repository\n") catch {},
        error.Corrupt => s.err.writeAll("zit frames: the repository is unreadable\n") catch {},
    }
    return .failure;
}

fn framesKeyFail(s: cli.Streams, e: logstore.LogStore.KeyError) cli.Exit {
    switch (e) {
        error.Storage => s.err.writeAll("zit frames: cannot read the repository\n") catch {},
        error.Corrupt => s.err.writeAll("zit frames: the repository is unreadable\n") catch {},
    }
    return .failure;
}