Skip to content
zit Artifact [f2de9d8b6a]
Login

Artifact [f2de9d8b6a]

Artifact f2de9d8b6a7e2f73fdcb6429b4ef976f8ec8d25520eaa154095eb938c4c49f30:


//! 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 grep_help = main.grep_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,
    amb: cli.Ambient,
) 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;
    }

    // #85 — `-R` joins the read-only family here. `stat` was refused it for a
    // plumbing reason (its handler took no redirected repository), never a
    // policy one, and the cost of that accident was real: a repository's
    // project code was reachable ONLY by speaking the sync protocol to a server
    // you started yourself, so a consumer needing one constant had to spawn a
    // subprocess and a listening socket per repository.
    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch
        return stateOom(streams, "stat")) orelse {
        main.reportNoRoot(streams, io, "stat", amb.repo_override);
        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;
    };
    // #85 — the repository's IDENTITY, printed first because it is what a
    // consumer most often came for. `project-code` is the RFC section 6.3 shared
    // secret's first input; before this it was obtainable only by speaking the
    // sync protocol to a server you started yourself, which cost a subprocess, a
    // listening socket and a startup stall per repository.
    //
    // Emitted unconditionally, with an empty value on a repository that has
    // none, so a parser reads one stable shape: a missing LINE and an empty
    // VALUE are different facts, and only one of them means "this repository
    // predates project codes".
    var identity_oc = store.readConfig(gpa) catch null;
    defer if (identity_oc) |*oc| oc.deinit(gpa);
    const project_code: []const u8 = if (identity_oc) |oc| oc.config.project_code else "";
    // The stored byte is opaque to storage (it round-trips whatever it was
    // given), so a value outside the enum is reported as the raw number rather
    // than guessed at: a repository written by something else is a fact worth
    // seeing, not one to normalise away.
    var policy_buf: [16]u8 = undefined;
    const hash_policy: []const u8 = if (identity_oc) |oc| blk: {
        const raw = oc.config.hash_policy;
        break :blk if (std.enums.fromInt(artifact.HashPolicy, raw)) |p|
            @tagName(p)
        else
            std.fmt.bufPrint(&policy_buf, "{d}", .{raw}) catch "";
    } else "";
    streams.out.print("project-code {s}\nhash-policy {s}\n", .{ project_code, hash_policy }) catch {};

    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,
};

/// #93 — emit each check-in's own D/U/C cards beside its id. The manifest is
/// already parsed at the emit site to walk its P cards, so the facts are free
/// here and cost a subprocess per row anywhere else: a consumer drawing a file
/// history page (`log CHECKIN PATH`) otherwise runs `zit info` once per entry.
/// Same defect, and same fix, as `ls --long` (#96).
fn logEmit(out: *Io.Writer, id: []const u8, manifest: *const artifact.Manifest, long: bool) void {
    if (!long) {
        out.print("checkin {s}\n", .{id}) catch {};
        return;
    }
    // Comment last: it is the only field that may contain spaces, so every
    // earlier column stays splittable. A check-in with no C card prints an
    // empty trailing field rather than a short line, so the arity is fixed.
    const when = if (manifest.d) |d| d.timestamp else "-";
    const who = if (manifest.u) |u| u.login else "-";
    const what = if (manifest.c) |c| c.comment else "";
    out.print("checkin {s} {s} {s} {s}\n", .{ id, when, who, what }) catch {};
}

fn walkLog(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    root_id: []const u8,
    out: *Io.Writer,
    filter: ?LogPathFilter,
    limit: ?usize,
    long: bool,
) 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);
    }
    // #84's memo, and this walk is the case it exists for. Check-in manifests
    // are routinely stored as deltas against their neighbours, so reconstructing
    // each one from `content` alone re-resolves the SAME base chain once per
    // check-in — work that grows with how much history sits behind the node
    // rather than with the node, which is why the per-check-in cost climbed with
    // repository size instead of staying flat. Every other read path in this
    // file already takes the memo; this one did not.
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.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.contentCached(gpa, rid, &plaintext)) 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)) {
                logEmit(out, id, manifest, long);
                emitted += 1;
            }
        } else {
            logEmit(out, id, manifest, long);
            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..]);
    }
}

/// #98/`plink` — walk the check-in DAG from DERIVED ADJACENCY instead of
/// re-parsing every manifest, which is what fossil does and what this walk
/// used to not do.
///
/// Fossil parses a manifest exactly ONCE, when it crosslinks it, and records
/// the edge in a persistent table:
///
///     CREATE TABLE plink(pid, cid, isprim, mtime, baseid, UNIQUE(pid,cid));
///     CREATE INDEX plink_i2 ON plink(cid,pid);
///
/// Every later traversal is an indexed query over that table -- `timeline.c`'s
/// ancestor walk is a recursive CTE reading `plink.cid ... WHERE plink.pid=dx.id`
/// and touches no manifest at all. The tell is fossil's parsed-manifest cache:
/// `#define MX_MANIFEST_CACHE 6`, six entries, and its comment says it is there
/// for `rebuild`. Nothing on fossil's read path re-parses manifests, so nothing
/// needed a cache big enough to make that fast.
///
/// zit's derived state holds the same relation -- `parents_by_child`, carrying
/// the `is_primary` flag `plink.isprim` carries -- and since #98 the zero-parse
/// image carries it too, so this walk reads no artifact content whatever.
///
/// WHAT THIS GIVES UP, stated plainly: the manifest walk verified every
/// reachable artifact's name hash and re-validated its card grammar, so a
/// corrupt frame or a wrong-ID blob surfaced as a `log` failure. This does not.
/// That is fossil's behaviour too -- validation happens once at crosslink, and
/// `fossil test-integrity` is the verb that re-checks a repository, exactly as
/// `zit verify` is here. Paying a full-history re-verification on every history
/// listing was the anomaly, not the guarantee.
///
/// Order is preserved exactly: depth-first, parents pushed in reverse wire
/// order so the LIFO stack visits the first P hash first, first visit winning
/// across a diamond. A parent the store never received is `phantom`, the same
/// line the manifest walk printed for it.
fn walkLogDerived(
    gpa: std.mem.Allocator,
    repo: *Repository,
    root_id: []const u8,
    out: *Io.Writer,
    limit: ?usize,
) LogWalkError!void {
    const root_rid = repo.lookup(root_id) orelse return error.MissingRoot;
    // A crosslinked check-in has an event row; a rid interned only because some
    // P card named it does not. That is the derive's record of "we actually
    // received this", and it is what distinguishes a check-in from a phantom
    // here -- NOT the view's own store, whose `held` answers about the identity
    // space rather than about artifact bytes.
    if (repo.state().mtimeOf(root_rid) == null) return error.MissingRoot;

    var pending: std.ArrayList(repostate.Rid) = .empty;
    defer pending.deinit(gpa);
    var visited: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty;
    defer visited.deinit(gpa);

    try pending.append(gpa, root_rid);
    var emitted: usize = 0;
    while (pending.pop()) |rid| {
        if (limit) |n| std.debug.assert(emitted < n);
        if ((try visited.getOrPut(gpa, rid)).found_existing) continue;

        // A rid interned from a P card that never arrived. The manifest walk
        // reached the same verdict by failing to read its content.
        if (repo.state().mtimeOf(rid) == null) {
            out.print("phantom {s}\n", .{repo.hashOf(rid)}) catch {};
            emitted += 1;
            if (limit) |n| if (emitted >= n) return;
            continue;
        }
        out.print("checkin {s}\n", .{repo.hashOf(rid)}) catch {};
        emitted += 1;
        if (limit) |n| if (emitted >= n) return;

        // Reverse only this node's appended segment, so the LIFO stack takes
        // the first P hash first while older sibling work stays below it.
        const parent_start = pending.items.len;
        var it = repo.state().parentsOf(rid);
        while (it.next()) |p| try pending.append(gpa, p);
        std.mem.reverse(repostate.Rid, 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,
    long: bool,
) 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, long) 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, parsed.long);

    // The default listing prints an ID per check-in and reads nothing else, so
    // it takes the derived adjacency and touches no artifact content -- see
    // `walkLogDerived` for why that is the fossil-shaped answer. `--long` needs
    // the D/U/C cards, which only the manifest carries (the derive keeps the
    // comment and mtime but not the user), so it stays on the reading walk.
    if (!parsed.long) {
        var repo: Repository = undefined;
        if (repo.open(gpa, io, root_dir, .{})) |_| {
            defer repo.deinit(gpa);
            if (repo.lookup(root_id) != null) {
                walkLogDerived(gpa, &repo, root_id, streams.out, parsed.limit) catch |e| {
                    switch (e) {
                        error.MissingRoot => {
                            streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {};
                            return .failure;
                        },
                        error.OutOfMemory => {
                            streams.err.writeAll("zit log: out of memory\n") catch {};
                            return .failure;
                        },
                        else => return .failure,
                    }
                };
                return .ok;
            }
        } else |_| {}
        // No usable derived state (an unreadable or absent cache, or a root the
        // derive never saw): fall through to the reading walk, which needs no
        // derived state at all.
    }

    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, parsed.long) 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, true, &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): without `--raw`, only singleton `sym-` tags are shown, with the
/// prefix stripped, so branch machinery, raw properties, and inherited names
/// cannot masquerade as one-check-in release names. With `--raw`, every active
/// tag is shown with its stored prefix. The views are pure presentation over
/// the `tagNames`/`tagNamesOfType`/`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, true, &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, parsed.raw, streams);

    // §5.1/§2: plain output is the singleton symbolic release namespace;
    // --raw retains the complete active tag-name view.
    const names = if (parsed.raw)
        repo.state().tagNames(gpa)
    else
        repo.state().tagNamesOfType(gpa, .singleton);
    const owned = names catch return stateOom(streams, "tag");
    defer gpa.free(owned);
    renderTagNames(streams.out, owned, parsed.raw);
    return .ok;
}

/// The `zit tag list CHECKIN` arm: resolve CHECKIN through the §8.3 procedure
/// and list tags visible on it. Plain output is restricted to singleton
/// symbolic release names; `raw` exposes every active tag with its stored
/// prefix and value. An unresolvable or ambiguous name is a failure (exit 1,
/// diagnostic to stderr); a resolved check-in with no matching tags lists
/// nothing (empty stdout, exit 0).
fn runTagCheckin(
    gpa: std.mem.Allocator,
    repo: *Repository,
    name: []const u8,
    raw: bool,
    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, raw);
    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, true, &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: active tags in raw-name order, as `tagsOf`
/// returns them. Plain output includes only singleton `sym-` tags, stripped to
/// their user-facing names. Raw output includes every active tag with its
/// stored prefix intact. 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,
    raw: bool,
) void {
    for (tags) |tag| {
        if (!raw and (tag.tagtype != .singleton or !std.mem.startsWith(u8, tag.name, sym_prefix))) {
            continue;
        }
        const shown = if (raw) tag.name else tag.name[sym_prefix.len..];
        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, true, &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).
    // Read through `displayOverride` so this works on either backing (#98). The
    // image carries these three resolved; `activeTagValue` would refuse here.
    for (std.enums.values(zit.repoimage.DisplayOverride)) |kind| {
        const name = zit.repoimage.override_names[@intFromEnum(kind)];
        std.debug.assert(name.len >= 1 and name.len <= 7);
        if (repo.state().displayOverride(rid, kind)) |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, true, &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);

    // #96 — `--long` needs each blob's bytes for size and binary class, so it
    // opens the store; the default listing does not and must not start paying
    // for one. The memo is #84's: consecutive files in a tree routinely share
    // delta bases, so reconstructing without it re-resolves the same base once
    // per file.
    var log: ?*logstore.LogStore = null;
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);
    if (parsed.long or parsed.size) {
        log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "ls", e);
    }

    // Data only: one line per effective file, in canonical filename order.
    var it = list.iterator();
    while (it.next()) |f| {
        std.debug.assert(f.hash != null); // the iterator suppresses delete markers
        if (parsed.under) |prefix| {
            if (!lsUnder(f.filename, prefix)) continue;
        }
        if (!parsed.long and !parsed.size) {
            streams.out.print("{s} {s}\n", .{ f.hash.?, f.filename }) catch {};
            continue;
        }
        // The permission token is the one fact a consumer cannot recover by
        // spending more: it lives in the MANIFEST's F card, and the blob it
        // names does not know it is executable or a symlink.
        const perm = if (f.perm) |p| p else "-";

        // `--size` reads the length out of the frame header instead of
        // reconstructing the blob to measure it. `absent` covers the same three
        // conditions `--long` reports it for — a sparse elision, a shun, or a
        // phantom — and a size of 0 there means nothing was measured, not that
        // the file is empty.
        if (parsed.size) {
            if (lsBlobSize(gpa, log.?, f.hash.?)) |n| {
                streams.out.print("{s} {d} {s} {s}\n", .{ f.hash.?, n, perm, f.filename }) catch {};
            } else {
                streams.out.print("{s} absent {s} {s}\n", .{ f.hash.?, perm, f.filename }) catch {};
            }
            continue;
        }
        var size: u64 = 0;
        var class: []const u8 = "text";
        if (lsBlobBytes(gpa, log.?, &plaintext, f.hash.?)) |bytes| {
            defer gpa.free(bytes);
            size = bytes.len;
            if (zit.textdiff.isBinary(bytes)) class = "binary";
        } else {
            // Elided by a sparse store, shunned, or a phantom. Say so rather
            // than reporting a zero-byte text file, which is a lie that looks
            // like data.
            class = "absent";
        }
        streams.out.print("{s} {d} {s} {s} {s}\n", .{ f.hash.?, size, perm, class, f.filename }) catch {};
    }
    return .ok;
}

/// Whether `path` is at or beneath `prefix`, on a path-segment boundary so
/// `--under=src` never matches `src-other`. A prefix naming the file itself
/// matches, which is how a consumer asks about one entry.
fn lsUnder(path: []const u8, prefix: []const u8) bool {
    const p = std.mem.trimEnd(u8, prefix, "/");
    if (p.len == 0) return true; // `--under=/` is the whole tree
    if (!std.mem.startsWith(u8, path, p)) return false;
    if (path.len == p.len) return true;
    return path[p.len] == '/';
}

/// One blob's plaintext, or null when the store cannot produce it (a sparse
/// elision, a shun, or a phantom). Never fails the listing: a file whose bytes
/// are unavailable is reported as such, because `ls` answers about the
/// MANIFEST, and the manifest is intact whatever the store holds.
fn lsBlobBytes(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    hash: []const u8,
) ?[]u8 {
    // The F card carries the 64-hex artifact NAME (§4.7.1 wire form); the
    // store's index is keyed by the binary digest, so the hex resolver is the
    // right door. A full name can never be ambiguous, but the error is handled
    // rather than asserted — a listing must not crash on a damaged store.
    const rid = (log.resolveHexPrefix(hash) catch return null) orelse return null;
    return (log.contentCached(gpa, rid, cache) catch return null) orelse null;
}

/// One blob's plaintext SIZE, without reconstructing it (#96), or null when
/// the store cannot produce it. Same door and same null cases as
/// `lsBlobBytes`; the difference is that this never resolves a delta chain,
/// because every frame kind already carries its plaintext length.
fn lsBlobSize(gpa: std.mem.Allocator, log: *logstore.LogStore, hash: []const u8) ?u64 {
    const rid = (log.resolveHexPrefix(hash) catch return null) orelse return null;
    return (log.plaintextSize(gpa, rid) catch return null) orelse null;
}

/// 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, true, &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 {};
    // #72 — PRESENCE, never validity: reporting "valid" would mean shelling
    // to gpg from a read verb and inheriting its keyring's answer, which
    // makes an offline or key-less reader print alarming things about
    // artifacts that are fine. Presence is what the store knows; validity is
    // the operator's own `gpg --verify` on `artifact --raw` output.
    if (artifact.isSignatureWrapped(bytes)) {
        streams.out.writeAll("signature: clearsigned (cards read from the stripped body)\n") 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, false);
    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,
    };
}

// --- frames: verifying a batch across workers (#84) -------------------------
//
// Reconstruction stays on ONE thread and keeps the plaintext memo, because the
// memo is what made the sequential stream fast: consecutive artifacts chain
// through the same base, so fanning reconstruction out would either share a
// locked cache or redo base work. Verification has neither property — an
// artifact's hash depends on its own bytes and nothing else — and it is the
// larger half of the remaining cost (#84 profile: 59% verify, 38% reconstruct;
// deleting the verify call took a libfossil stream from 2.25s to 0.88s user).
//
// So the split is: the main thread reconstructs a bounded batch, workers hash
// it, the main thread emits it in order. Workers touch no store, no cache, and
// no allocator — only `verify(name, bytes)` over a slot they alone own. There
// is no queue, no lock, and no atomic: each worker walks a fixed STRIDE of the
// batch, so the writes land in disjoint slots and `join` supplies the ordering.

/// One artifact waiting to be verified and then emitted. `bytes` is owned and
/// freed by the emitter; the name is stored inline because the fill loop keeps
/// no per-slot buffer of its own.
const FrameJob = struct {
    hex_buf: [2 * logstore.max_hash_len]u8 = undefined,
    hex_len: usize = 0,
    bytes: []u8 = &.{},
    ok: bool = false,

    fn hex(self: *const FrameJob) []const u8 {
        return self.hex_buf[0..self.hex_len];
    }
};

/// Batch bounds (TigerStyle: both fixed; the byte budget is the one that binds
/// on a corpus with large artifacts). The count keeps per-batch thread spawns
/// amortised — 17,449 artifacts is ~69 batches, well under a percent of the run.
const frames_batch_max: usize = 256;
const frames_batch_bytes: usize = 16 << 20;

/// Upper bound on verify workers. Past #84's Amdahl ceiling (61% parallel, so
/// 2.6x however many cores exist) more threads buy nothing, and a fixed bound
/// keeps the thread array a comptime-sized stack value.
const frames_max_workers: usize = 8;

/// Verify every `stride`-th job from `start`. Pure and allocation-free: the
/// only writes are to `jobs[i].ok`, and the strides partition the batch, so no
/// two threads ever touch one slot.
fn framesVerifyStride(jobs: []FrameJob, start: usize, stride: usize) void {
    std.debug.assert(stride >= 1);
    std.debug.assert(start < stride);
    var i = start;
    while (i < jobs.len) : (i += stride) {
        jobs[i].ok = zit.artifact.verify(jobs[i].hex(), jobs[i].bytes);
    }
}

/// Hash a whole batch, using `workers` threads when that is worth it. Falls
/// back in-thread for a small batch, a single-core host, or a spawn that
/// fails — a stride whose thread never started is walked by the caller's own
/// thread, so a partial spawn can never leave a slot unverified, which would
/// read as a false corruption report.
fn framesVerifyBatch(jobs: []FrameJob, workers: usize) void {
    std.debug.assert(workers >= 1 and workers <= frames_max_workers);
    if (workers == 1 or jobs.len < 2) {
        framesVerifyStride(jobs, 0, 1);
        return;
    }
    const stride = @min(workers, jobs.len);
    var threads: [frames_max_workers - 1]std.Thread = undefined;
    var spawned: usize = 0;
    while (spawned + 1 < stride) : (spawned += 1) {
        threads[spawned] = std.Thread.spawn(.{}, framesVerifyStride, .{ jobs, spawned + 1, stride }) catch break;
    }
    // Stride 0 is always ours, and so is any stride whose spawn did not happen.
    var s: usize = 0;
    while (s < stride) : (s += 1) {
        if (s == 0 or s > spawned) framesVerifyStride(jobs, s, stride);
    }
    for (threads[0..spawned]) |t| t.join();
}

/// Verify a filled batch, then write it out in index order. Returns false once
/// an artifact fails to hash to its name, having already named it on stderr —
/// the caller frees the batch and exits. Emission order is the batch's own
/// order, which is persisted order, so the worker count never reaches the
/// stream.
fn framesFlushBatch(streams: cli.Streams, jobs: []FrameJob, workers: usize) bool {
    framesVerifyBatch(jobs, workers);
    for (jobs) |*job| {
        if (!job.ok) {
            streams.err.print("zit frames: stored artifact {s} does not hash to its name\n", .{job.hex()}) catch {};
            return false;
        }
        streams.out.print("artifact {s} {d}\n", .{ job.hex(), job.bytes.len }) catch {};
        streams.out.writeAll(job.bytes) catch {};
        streams.out.writeAll("\n") catch {};
    }
    return true;
}

/// How many verify workers this host should use: one per core, bounded, and
/// exactly 1 when the count cannot be determined — an unknown machine takes
/// the sequential path rather than a guess.
fn framesWorkerCount() usize {
    const cpus = std.Thread.getCpuCount() catch return 1;
    return @max(1, @min(cpus, frames_max_workers));
}

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).
    // A full-history stream is the read `contentCached` exists for. Nine
    // artifacts in ten are deltas in an imported fossil corpus (#84 measured
    // 89% on libfossil, 91% on sqlite), and consecutive artifacts in persisted
    // order routinely chain through the same base — so the uncached reader
    // reconstructed each base once per referencing delta, which is what made a
    // linear stream cost superlinear work. `memoize_undeltaed` stays OFF: this
    // sweep reads each artifact exactly once, so memoizing top-level raw/zlib
    // plaintext would duplicate bytes nothing asks for again. Bases are
    // memoized regardless of their own frame kind, which is the whole win.
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);

    // Reconstruct into a bounded batch, hash the batch across workers, then
    // emit it in persisted order. Order is the batch's own index order, so the
    // stream is byte-identical to the single-threaded read whatever the worker
    // count — a property the #84 e2e pins by hashing the whole stream.
    const jobs = gpa.alloc(FrameJob, frames_batch_max) catch return stateOom(streams, "frames");
    defer gpa.free(jobs);
    const workers = framesWorkerCount();

    var emitted: u64 = 0;
    var filled: usize = 0;
    var batch_bytes: usize = 0;
    // Whatever exits this scope — a read fault, a verification failure, or the
    // clean end — the batch still holds owned plaintext that has not been
    // emitted. One place frees it.
    defer for (jobs[0..filled]) |job| gpa.free(job.bytes);

    for (persisted[@intCast(since)..]) |r| {
        const bytes = (log.contentCached(gpa, r, &plaintext) catch |e| return framesReadFail(streams, e)) orelse continue;
        // --structural (#108): emit only what the crosslink itself would link.
        // The gate is the SAME parse (`artifact.parseForImport`), applied to
        // the reconstructed bytes -- never a cache lookup, so the filtered
        // stream cannot disagree with the log. Skipped frames are neither
        // hashed nor emitted; the cursor advances over them regardless, so a
        // later poll -- with or without the flag -- resumes identically.
        if (parsed.structural) {
            // Reject-fast pre-screen. A structural artifact's first bytes are
            // either a card line, `<LETTER><SP>` (artifact-format section
            // 4.2), or a signature wrapper, `-----BEGIN ...` (section 4.1.1)
            // -- the sqlite corpus holds 1,087 clearsigned manifests, which is
            // how the first draft of this screen (card shape only) was caught
            // by the stream-equality check. The screen only ever REJECTS;
            // every accept still takes the full parse below, so nothing the
            // crosslink would refuse can be admitted. What it saves is the
            // whole-bytes Z-checksum a parse attempt pays before rejecting a
            // content blob.
            const card_shaped = bytes.len >= 2 and bytes[0] >= 'A' and bytes[0] <= 'Z' and bytes[1] == ' ';
            const wrapper_shaped = std.mem.startsWith(u8, bytes, "-----BEGIN ");
            if (!card_shaped and !wrapper_shaped) {
                gpa.free(bytes);
                continue;
            }
            if (artifact.parseForImport(gpa, bytes)) |im_const| {
                var im = im_const;
                im.deinit(gpa);
            } else |e| switch (e) {
                error.OutOfMemory => {
                    gpa.free(bytes);
                    return stateOom(streams, "frames");
                },
                else => { // not a structural artifact: a content blob
                    gpa.free(bytes);
                    continue;
                },
            }
        }
        jobs[filled] = .{ .bytes = bytes };
        const hex = std.fmt.bufPrint(&jobs[filled].hex_buf, "{x}", .{log.hashOf(r)}) catch unreachable;
        jobs[filled].hex_len = hex.len;
        filled += 1;
        batch_bytes += bytes.len;

        if (filled == frames_batch_max or batch_bytes >= frames_batch_bytes) {
            if (!framesFlushBatch(streams, jobs[0..filled], workers)) return .failure;
            emitted += filled;
            for (jobs[0..filled]) |job| gpa.free(job.bytes);
            filled = 0;
            batch_bytes = 0;
        }
    }
    if (filled != 0) {
        if (!framesFlushBatch(streams, jobs[0..filled], workers)) return .failure;
        emitted += filled;
        for (jobs[0..filled]) |job| gpa.free(job.bytes);
        filled = 0;
    }

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

test "frames #84: strided verification agrees with the sequential answer at every worker count" {
    // The concurrency's whole safety argument is that the strides PARTITION
    // the batch: each slot is written by exactly one thread, and `join`
    // orders those writes before the emitter reads them. This drives the
    // partition at every legal worker count over a batch carrying both good
    // and corrupt names, and demands the identical verdict vector each time —
    // a stride that skipped a slot, double-covered one, or lost a spawn would
    // show up as a `false` where the sequential run says `true`.
    const gpa = std.testing.allocator;
    const count = 37; // deliberately coprime with every worker count below

    var jobs = try gpa.alloc(FrameJob, count);
    defer gpa.free(jobs);
    var bodies = try gpa.alloc([]u8, count);
    defer {
        for (bodies) |b| gpa.free(b);
        gpa.free(bodies);
    }

    // Every third artifact is given a name it does not hash to, so the
    // expected verdict vector is a non-trivial pattern rather than all-true.
    for (0..count) |i| {
        bodies[i] = try std.fmt.allocPrint(gpa, "artifact body number {d}\n", .{i});
        jobs[i] = .{ .bytes = bodies[i] };
        var name_buf: [64]u8 = undefined;
        const name = zit.artifact.artifactName(.sha3_256, bodies[i], &name_buf);
        @memcpy(jobs[i].hex_buf[0..name.len], name);
        jobs[i].hex_len = name.len;
        if (i % 3 == 0) jobs[i].hex_buf[0] = if (name[0] == 'a') 'b' else 'a'; // corrupt the name
    }

    var expected: [count]bool = undefined;
    framesVerifyBatch(jobs, 1);
    for (jobs, 0..) |job, i| expected[i] = job.ok;
    // The pattern must be mixed, or this test would pass on a no-op.
    var trues: usize = 0;
    for (expected) |e| {
        if (e) trues += 1;
    }
    try std.testing.expect(trues > 0 and trues < count);

    var workers: usize = 1;
    while (workers <= frames_max_workers) : (workers += 1) {
        for (jobs) |*job| job.ok = false;
        framesVerifyBatch(jobs, workers);
        for (jobs, 0..) |job, i| try std.testing.expectEqual(expected[i], job.ok);
    }

    // A batch smaller than the worker count takes the same partition path.
    for (jobs[0..1]) |*job| job.ok = false;
    framesVerifyBatch(jobs[0..1], frames_max_workers);
    try std.testing.expectEqual(expected[0], jobs[0].ok);
}

/// `zit grep PATTERN PATH...` (#92) — search a file's HISTORY for a literal.
///
/// The gap this closes is that zit had no content search at all: not `git log
/// -S`'s pickaxe, and not `fossil grep`'s file-scoped walk. Working-tree search
/// is deliberately NOT here — ripgrep does that faster than zit would, and the
/// bytes are on disk for it to read. What only zit can do is search content
/// that is not on disk at all, because it lives as delta chains inside `.zit`.
///
/// Semantics are `fossil grep`'s: walk the named file's historic versions
/// newest-first and report each DISTINCT version whose content matches. A run
/// of check-ins that left the file untouched reports once, not once per
/// check-in, because they share one blob.
pub fn runGrep(
    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.parseGrepArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "grep", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "grep", .extra_argument, tok),
        .bad_limit => |tok| return cli.usageError(streams, "grep", .bad_limit, tok),
        .no_name => return cli.usageError(streams, "grep", .no_name, ""),
        .no_paths => return cli.usageError(streams, "grep", .no_paths, ""),
        .both_pickaxes => {
            streams.err.writeAll("zit grep: -S and -G ask different questions; give one\n") catch {};
            return .usage;
        },
    };
    if (parsed.help) {
        streams.out.writeAll(grep_help) catch {};
        return .ok;
    }
    const pattern = parsed.pattern.?;
    if (pattern.len == 0) {
        streams.err.writeAll("zit grep: PATTERN must not be empty\n") catch {};
        return .failure;
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "grep", amb.repo_override, parsed.no_cache, false, &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, "grep", e);
    if (parsed.pickaxe or parsed.diff_match) return runPickaxe(gpa, arena, &repo, log, parsed, streams);
    // #84's memo: successive check-ins share delta bases, and this walk reads
    // one blob per file per version. Without it each read re-resolves the same
    // base chain.
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);

    // Newest-first and totally ordered, which is the order `fossil grep`
    // reports and the order an operator reads history in.
    const rids = repo.state().checkinsByMtime(gpa) catch return stateOom(streams, "grep");
    defer gpa.free(rids);

    var matched: usize = 0;
    var unreadable: usize = 0;
    for (parsed.paths[0..parsed.path_count]) |path| {
        // The blob reported on the previous (newer) check-in, so an unchanged
        // run of check-ins is one version rather than many.
        var seen_hash: ?[]const u8 = null;
        for (rids) |rid| {
            if (parsed.limit) |n| if (n != 0 and matched >= n) break;
            var list = (repo.files(gpa, rid) catch continue) orelse continue;
            defer list.deinit(gpa);

            var hash: ?[]const u8 = null;
            var it = list.iterator();
            while (it.next()) |f| {
                if (std.mem.eql(u8, f.filename, path)) {
                    hash = f.hash;
                    break;
                }
            }
            const h = hash orelse continue; // absent at this check-in
            if (seen_hash) |prev| {
                if (std.mem.eql(u8, prev, h)) continue; // same version, already judged
            }
            // Dupe: `list` owns the card bytes and is freed at the loop's end.
            seen_hash = arena.dupe(u8, h) catch return stateOom(streams, "grep");

            const bytes = grepBlobBytes(gpa, log, &plaintext, h) orelse {
                // Elided by a sparse store, shunned, or a phantom. Counted and
                // reported at the end rather than skipped in silence: a search
                // that quietly examined less than it claimed is the failure
                // mode that looks exactly like "no matches".
                unreadable += 1;
                continue;
            };
            defer gpa.free(bytes);
            if (zit.textdiff.isBinary(bytes)) continue; // a NUL-bearing blob has no lines
            if (grepReport(streams.out, repo.hashOf(rid), path, bytes, pattern)) matched += 1;
        }
    }

    if (unreadable != 0) {
        streams.err.print(
            "zit grep: {d} version(s) could not be read (sparse, shunned, or phantom) and were not searched\n",
            .{unreadable},
        ) catch {};
    }
    // `0` found something, `1` found nothing — the shell convention every grep
    // follows, so this composes in a conditional.
    return if (matched != 0) .ok else .failure;
}

/// #92 slice 2 — the pickaxe: report the check-ins where the NUMBER OF
/// OCCURRENCES of PATTERN changed, which is `git log -S`.
///
/// The distinction from slice 1 is the question, not the mechanism. `zit grep`
/// answers "which versions contain this", and a string that sat unchanged
/// through two hundred check-ins is reported for each version that held it.
/// The pickaxe answers "when did this appear, and when did it go" — for that
/// same string, exactly two lines.
///
/// THE COST THE ISSUE FEARED IS AVOIDABLE. #92 supposed this "needs a diff per
/// revision rather than a match per revision". It does not: a path whose blob
/// hash is IDENTICAL in a check-in and its parent cannot have changed the
/// count, so the walk compares F-card hashes first and reads blobs only for the
/// paths that actually differ. On ordinary history that is a handful of files
/// per check-in rather than the whole tree, and it is the same observation that
/// lets slice 1 share one blob across a run of untouching check-ins.
///
/// MERGES are compared against the PRIMARY parent only, so content arriving
/// through a merge is attributed to the merge check-in. `git log -S` does the
/// same under its default history simplification, and the alternative — a
/// check-in counted against every parent — reports one appearance twice.
fn runPickaxe(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    log: *logstore.LogStore,
    parsed: cli.GrepArgs,
    streams: cli.Streams,
) cli.Exit {
    const pattern = parsed.pattern.?;
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);

    const rids = repo.state().checkinsByMtime(gpa) catch return stateOom(streams, "grep");
    defer gpa.free(rids);

    var matched: usize = 0;
    var unreadable: usize = 0;
    for (rids) |rid| {
        if (parsed.limit) |n| if (n != 0 and matched >= n) break;

        // The parent's path -> blob-hash map, which is what makes "did this
        // path change" a string comparison instead of a blob read.
        var parent_of: std.StringHashMapUnmanaged([]const u8) = .empty;
        defer parent_of.deinit(gpa);
        var parent_list: ?Repository.FileList = null;
        defer if (parent_list) |*pl| pl.deinit(gpa);
        if (repo.state().primaryParentOf(rid)) |prid| {
            if (repo.files(gpa, prid) catch null) |pl| {
                parent_list = pl;
                var pit = parent_list.?.iterator();
                while (pit.next()) |f| {
                    const h = f.hash orelse continue;
                    parent_of.put(gpa, f.filename, h) catch return stateOom(streams, "grep");
                }
            }
        }

        var list = (repo.files(gpa, rid) catch continue) orelse continue;
        defer list.deinit(gpa);

        // Signed, because the two directions are the whole point: a check-in
        // that adds two occurrences and removes two has not changed the count
        // and is not a hit, exactly as `git log -S` treats it. Under `-G` this
        // instead counts the changed LINES that mention the pattern, which is
        // why that case is unsigned and reported with a different marker.
        var delta: isize = 0;
        var hit_path: ?[]const u8 = null;
        var it = list.iterator();
        while (it.next()) |f| {
            const h = f.hash orelse continue;
            if (!pathWanted(parsed, f.filename)) continue;
            const before = parent_of.get(f.filename);
            if (before) |b| if (std.mem.eql(u8, b, h)) continue; // untouched: neither answer can move
            if (parsed.diff_match) {
                const n = diffMentions(gpa, log, &plaintext, before, h, pattern) orelse {
                    unreadable += 1;
                    continue;
                };
                if (n != 0) {
                    delta += @intCast(n);
                    if (hit_path == null) hit_path = arena.dupe(u8, f.filename) catch null;
                }
                continue;
            }
            const now = pickaxeCount(gpa, log, &plaintext, h, pattern) orelse {
                unreadable += 1;
                continue;
            };
            const was = if (before) |b|
                pickaxeCount(gpa, log, &plaintext, b, pattern) orelse blk: {
                    unreadable += 1;
                    break :blk 0;
                }
            else
                0;
            if (now != was) {
                delta += @as(isize, @intCast(now)) - @as(isize, @intCast(was));
                if (hit_path == null) hit_path = arena.dupe(u8, f.filename) catch null;
            }
        }
        // A path the parent had and this check-in does not: its occurrences are
        // gone, and a deletion is the half of "when did it go" that a walk over
        // only the child's files would miss entirely.
        var pit = parent_of.iterator();
        while (pit.next()) |e| {
            const name = e.key_ptr.*;
            if (!pathWanted(parsed, name)) continue;
            if (listHolds(&list, name)) continue;
            if (parsed.diff_match) {
                // The file is gone, so every line it had is a removed line.
                const n = diffMentions(gpa, log, &plaintext, e.value_ptr.*, null, pattern) orelse {
                    unreadable += 1;
                    continue;
                };
                if (n != 0) {
                    delta += @intCast(n);
                    if (hit_path == null) hit_path = arena.dupe(u8, name) catch null;
                }
                continue;
            }
            const was = pickaxeCount(gpa, log, &plaintext, e.value_ptr.*, pattern) orelse {
                unreadable += 1;
                continue;
            };
            if (was != 0) {
                delta -= @as(isize, @intCast(was));
                if (hit_path == null) hit_path = arena.dupe(u8, name) catch null;
            }
        }

        if (delta == 0) continue;
        const id = repo.hashOf(rid);
        // Data only, one line per check-in: id, signed delta, and the first
        // path that moved. #71's machine-verb shape, because quarry indexes
        // this repository and a search it cannot parse is a search it cannot use.
        streams.out.print("{s} {s}{d} {s}\n", .{
            id[0..@min(id.len, 12)],
            if (parsed.diff_match) "~" else if (delta > 0) "+" else "-",
            @abs(delta),
            hit_path orelse "",
        }) catch {};
        matched += 1;
    }

    if (unreadable != 0) {
        streams.err.print(
            "zit grep: {d} version(s) could not be read (sparse, shunned, or phantom) and were not searched\n",
            .{unreadable},
        ) catch {};
    }
    return if (matched != 0) .ok else .failure;
}

/// Under `-S`, PATH operands narrow the walk rather than drive it; with none
/// given every path is wanted.
fn pathWanted(parsed: cli.GrepArgs, name: []const u8) bool {
    if (parsed.path_count == 0) return true;
    for (parsed.paths[0..parsed.path_count]) |p| {
        if (std.mem.eql(u8, p, name)) return true;
        // A directory operand covers its subtree, on the segment boundary so
        // `src` never matches `src-vendor`.
        if (name.len > p.len and name[p.len] == '/' and std.mem.startsWith(u8, name, p)) return true;
    }
    return false;
}

fn listHolds(list: anytype, name: []const u8) bool {
    var it = list.iterator();
    while (it.next()) |f| {
        if (std.mem.eql(u8, f.filename, name)) return true;
    }
    return false;
}

/// `-G`: how many CHANGED lines between two versions of one path mention
/// `pattern`. Null when either blob is unreadable. A null `to` means the path
/// was deleted, so every line it had is a removed line.
///
/// This is where `-G` earns its existence. Renaming `x = SECRET` to
/// `y = SECRET` leaves the occurrence count at one, so `-S` reports nothing —
/// correctly, by its own definition — while the line plainly changed. Asking
/// the diff rather than the counts is the only way to see that, and it is why
/// this half is the expensive one: it runs the diff engine per changed path
/// instead of two substring scans.
fn diffMentions(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    from: ?[]const u8,
    to: ?[]const u8,
    pattern: []const u8,
) ?usize {
    const a = if (from) |h| grepBlobBytes(gpa, log, cache, h) orelse return null else null;
    defer if (a) |bytes| gpa.free(bytes);
    const b = if (to) |h| grepBlobBytes(gpa, log, cache, h) orelse return null else null;
    defer if (b) |bytes| gpa.free(bytes);

    const a_bytes: []const u8 = a orelse "";
    const b_bytes: []const u8 = b orelse "";
    // A NUL-bearing version has no lines to change, matching the rule the rest
    // of this verb applies.
    if (zit.textdiff.isBinary(a_bytes) or zit.textdiff.isBinary(b_bytes)) return 0;

    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(gpa);
    // Context 0: only the lines that actually changed are candidates, so no
    // unchanged neighbour can be mistaken for one.
    zit.textdiff.unified(gpa, a_bytes, b_bytes, 0, &buf) catch return null;

    var n: usize = 0;
    var rest: []const u8 = buf.items;
    while (rest.len != 0) {
        const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
        const line = rest[0..nl];
        if (nl == rest.len) rest = "" else rest = rest[nl + 1 ..];
        if (line.len == 0) continue;
        // `+`/`-` are changed lines; `+++`/`---` are the file headers and
        // carry the PATH, which would match a pattern naming a filename and
        // report every check-in that touched it.
        if (line[0] != '+' and line[0] != '-') continue;
        if (std.mem.startsWith(u8, line, "+++") or std.mem.startsWith(u8, line, "---")) continue;
        if (std.mem.indexOf(u8, line[1..], pattern) != null) n += 1;
    }
    return n;
}

/// Non-overlapping occurrences of `pattern` in one blob, or null when the store
/// cannot produce it. A binary blob counts zero rather than being skipped: for
/// the pickaxe "it is not there" and "we did not look" must not be the same
/// answer, and a NUL-bearing blob has no lines to report anyway.
fn pickaxeCount(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    hash: []const u8,
    pattern: []const u8,
) ?usize {
    const bytes = grepBlobBytes(gpa, log, cache, hash) orelse return null;
    defer gpa.free(bytes);
    if (zit.textdiff.isBinary(bytes)) return 0;
    var n: usize = 0;
    var at: usize = 0;
    while (std.mem.indexOfPos(u8, bytes, at, pattern)) |i| {
        n += 1;
        at = i + pattern.len;
    }
    return n;
}

/// Print every matching line of one version. Returns whether any matched, so
/// the caller counts VERSIONS rather than lines.
fn grepReport(out: *std.Io.Writer, checkin: []const u8, path: []const u8, bytes: []const u8, pattern: []const u8) bool {
    var any = false;
    var lineno: usize = 1;
    var rest = bytes;
    while (rest.len != 0) {
        const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
        const line = rest[0..nl];
        if (std.mem.indexOf(u8, line, pattern) != null) {
            out.print("{s} {s}:{d}: {s}\n", .{ checkin[0..@min(checkin.len, 12)], path, lineno, line }) catch {};
            any = true;
        }
        if (nl == rest.len) break;
        rest = rest[nl + 1 ..];
        lineno += 1;
    }
    return any;
}

/// One blob's plaintext, or null when the store cannot produce it.
fn grepBlobBytes(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    cache: *logstore.PlaintextCache,
    hash: []const u8,
) ?[]u8 {
    const rid = (log.resolveHexPrefix(hash) catch return null) orelse return null;
    return (log.contentCached(gpa, rid, cache) catch return null) orelse null;
}