Skip to content
zit Artifact [772f8fe9bd]
Login

Artifact [772f8fe9bd]

Artifact 772f8fe9bd87378b0739e8a95d3ff717bc08ec4739ed713d5413c18bf9af769a:


//! One file's history, as `zit log CHECKIN PATH` lists it: the ancestry
//! walk restricted to the check-ins that change PATH (following renames
//! unless told not to), the file index that narrows it, and the per-row facts
//! `-F json` and the daemon's `log` report. The CLI and the daemon both
//! call this, so the two cannot list different check-ins or describe one
//! differently.

const std = @import("std");
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const repostate = @import("../repostate.zig");
const repoview = @import("../repoview.zig");
const repocache = @import("../repocache.zig");
const fileindex = @import("../fileindex.zig");
const Repository = @import("../repository.zig").Repository;
const feed = @import("feed.zig");

const digest_len = 32;

pub 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.
pub const LogPathFilter = struct {
    repo: *Repository,
    path: []const u8,
    /// Follow renames: below a check-in whose card for the followed name
    /// carries an old name, the walk continues under the old name. False is
    /// `--no-follow`, the name-literal history.
    follow: bool = true,
    /// The file index, when it can be trusted. When present the predicate
    /// starts as a set membership test and a check-in the index does not name
    /// is skipped without reading anything; when null the walk computes the
    /// same answer by reconstruction. The index is refused whenever it cannot
    /// be trusted whole (stale key, changed shun set), and a check-in whose
    /// change set it records as UNKNOWN is reconstructed individually, so the
    /// reconstruction is the correctness backstop, never a second definition
    /// of the predicate.
    index: ?*PathIndex = null,
};

/// A name a PATH walk follows, interned in a `NameTable`.
pub const NameId = u32;

/// One check-in under one of the names a walk follows. A rename makes the
/// same check-in reachable under two names, and each is a different question.
pub const NamedRid = struct { rid: repostate.Rid, name: NameId };

/// The names a following walk has met, interned so a (check-in, name) pair is
/// two integers. A walk meets one more name per rename it crosses, so this
/// grows with the file's rename history, not with the walk.
pub const NameTable = struct {
    arena: std.heap.ArenaAllocator,
    ids: std.StringHashMapUnmanaged(NameId) = .empty,
    names: std.ArrayListUnmanaged([]const u8) = .empty,

    pub fn init(gpa: std.mem.Allocator) NameTable {
        return .{ .arena = std.heap.ArenaAllocator.init(gpa) };
    }

    pub fn deinit(self: *NameTable, gpa: std.mem.Allocator) void {
        self.ids.deinit(gpa);
        self.names.deinit(gpa);
        self.arena.deinit();
    }

    pub fn intern(self: *NameTable, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!NameId {
        if (self.ids.get(name)) |id| return id;
        const copy = try self.arena.allocator().dupe(u8, name);
        const id: NameId = @intCast(self.names.items.len);
        try self.names.append(gpa, copy);
        errdefer _ = self.names.pop();
        try self.ids.put(gpa, copy, id);
        return id;
    }

    pub fn get(self: *const NameTable, id: NameId) []const u8 {
        return self.names.items[id];
    }
};

/// 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`.
///
/// `event_time` is the check-in's stored event time when derived state knows
/// it, which a date tag may have moved off the D card (repo-state §4.2 step
/// 6); it is printed in the writer form `info` and `timeline` use. Only the
/// walk with no derived state passes null and prints the D card itself.
pub fn logEmit(out: *std.Io.Writer, id: []const u8, manifest: *const artifact.Manifest, long: bool, event_time: ?repostate.Mtime) 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.
    var iso_buf: [19]u8 = undefined;
    const when = if (event_time) |mt|
        artifact.formatWriterTimestamp(@intCast(mt / 1000), &iso_buf)
    else 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 {};
}

/// `times`, when present, supplies each emitted check-in's stored event time
/// for `--long`; without it the row prints the D card.
pub fn eventTimeOf(times: ?*Repository, id: []const u8) ?repostate.Mtime {
    const repo = times orelse return null;
    const rid = repo.lookup(id) orelse return null;
    return repo.state().mtimeOf(rid);
}

/// One check-in a PATH walk listed: the name PATH had there and, on a rename
/// row, the name it was renamed from. `-F json` renders these; the strings
/// live in the caller's arena.
pub const LogRow = struct { id: []const u8, name: []const u8, prior: ?[]const u8 };

/// Where a PATH walk reports its rows besides the text lines.
pub const LogRows = struct {
    arena: std.mem.Allocator,
    rows: std.ArrayListUnmanaged(LogRow) = .empty,
    /// Resume after this check-in: the rows up to and including it are
    /// neither kept nor counted toward the walk's limit, so a page picks up
    /// where the previous one ended. Cleared once it is passed.
    after: ?[]const u8 = null,

    /// Whether the row counts toward the walk's limit.
    fn add(self: *LogRows, names: *const NameTable, id: []const u8, name: NameId, prior: ?NameId) error{OutOfMemory}!bool {
        if (self.after) |resume_at| {
            if (std.mem.eql(u8, resume_at, id)) self.after = null;
            return false;
        }
        try self.rows.append(self.arena, .{
            .id = try self.arena.dupe(u8, id),
            .name = try self.arena.dupe(u8, names.get(name)),
            .prior = if (prior) |p| try self.arena.dupe(u8, names.get(p)) else null,
        });
        return true;
    }
};

/// What one visited check-in means for the name a PATH walk follows there.
pub const PathStep = struct {
    /// List it: its content or presence for the name differs from its primary
    /// parent's, or (following) it renamed the file onto the name.
    hit: bool = false,
    /// Following, on a rename row: the name the parents are walked under.
    prior: ?NameId = null,
    /// The name is present here. Only meaningful when `known`.
    present: bool = false,
    /// The content was reconstructed; false for a check-in the index skipped.
    known: bool = false,
    /// The primary parent holds the parent-side name.
    parent_present: bool = false,
    /// Following, the walk ends on this side: the name was renamed away here
    /// after the walk had already met the file now at this name, so what lies
    /// below under this name is the renamed file's history, not this one's.
    stop: bool = false,
};

/// The step a PATH walk takes at check-in `rid` (null: not interned, a
/// phantom the derived view never crosslinked) under `name`. `primary` is its
/// §3.2 primary parent. `on_file` says whether the walk has already met the
/// file present under the name it follows.
pub fn pathStep(
    gpa: std.mem.Allocator,
    f: LogPathFilter,
    probe: *PathProbe,
    rid: ?repostate.Rid,
    primary: ?repostate.Rid,
    name: NameId,
    on_file: bool,
) LogWalkError!PathStep {
    // The index NARROWS; the reconstruction DECIDES. A check-in the index
    // does not name for this name is skipped without reading anything; one
    // it does name is still confirmed by reconstruction, because the two
    // relations are not identical: `captureFileFacts` diffs at F-card level,
    // so a delta manifest that restates a path with the hash it already had
    // reads as a change there while the path's effective content did not
    // move. On the SQLite mirror that is one check-in in 2,113 — an
    // over-report, never an omission — and confirming costs one
    // reconstruction per CANDIDATE instead of one per visited check-in.
    //
    // The direction being trusted is stated plainly: a check-in the index
    // omits is taken as unchanged. The differential test pins that against
    // the reconstructing walk so a regression in `captureFileFacts` cannot
    // silently drop a file's history.
    const ix: ?*PathIndex = if (f.index) |i| (if (rid) |r| (if (i.unknown.contains(r)) null else i) else null) else null;
    if (ix) |i| {
        const set = try i.changedSet(gpa, probe.names.get(name));
        if (!set.contains(rid.?)) {
            // Not a row: nothing to list. But until a following walk has met
            // the file, whether this check-in HOLDS the name is what decides
            // the walk below it (`stop`), and the reconstruction learns that
            // at every check-in it visits. Asked here too, the two walks
            // agree; it costs one read per check-in only on the stretch
            // above the point where the file is first met.
            if (!f.follow or on_file) return .{};
            const here = try effectivePathContent(gpa, f.repo, probe, rid, name);
            return .{ .present = here.present, .known = true };
        }
    }
    const here = try effectivePathContent(gpa, f.repo, probe, rid, name);
    // The rename edge: from the index when it answers this check-in, else
    // from the card. Both are keyed by THIS check-in and name, never by the
    // path alone, so a file later created at the old name is never routed
    // into the renamed file's history.
    const prior: ?NameId = if (!f.follow or !here.present)
        null
    else if (ix) |i|
        (if (try i.priorOf(gpa, rid.?, probe.names.get(name))) |old| try probe.names.intern(gpa, old) else null)
    else
        here.prior;
    const parent = if (primary) |p|
        try effectivePathContent(gpa, f.repo, probe, p, prior orelse name)
    else
        PathContent{ .present = false };
    const changed = here.present != parent.present or
        (here.present and !std.mem.eql(u8, here.hash[0..here.hash_len], parent.hash[0..parent.hash_len]));
    var step: PathStep = .{
        .hit = changed or prior != null,
        .prior = prior,
        .present = here.present,
        .known = true,
        .parent_present = parent.present,
    };
    if (f.follow and on_file and !here.present and parent.present) {
        const away = if (ix) |i|
            try i.renamedAway(gpa, rid.?, probe.names.get(name))
        else
            try renamedAway(gpa, f.repo, probe, rid.?, name);
        if (away) {
            step.hit = false;
            step.stop = true;
        }
    }
    return step;
}

/// Whether check-in `rid` renamed a file away from `name`: one of its own F
/// cards carries `name` as its old name. Read only at a check-in that removes
/// the name a walk follows, so the full file list is read rarely.
fn renamedAway(gpa: std.mem.Allocator, repo: *Repository, probe: *PathProbe, rid: repostate.Rid, name: NameId) LogWalkError!bool {
    var list = (repo.files(gpa, rid) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        else => error.Corrupt,
    }) orelse return false;
    defer list.deinit(gpa);
    const wanted = probe.names.get(name);
    var it = list.iterator();
    while (it.next()) |card| {
        if (card.old_name) |old| if (std.mem.eql(u8, old, wanted)) return true;
    }
    return false;
}

pub fn walkLog(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    root_id: []const u8,
    out: *std.Io.Writer,
    filter: ?LogPathFilter,
    limit: ?usize,
    long: bool,
    times: ?*Repository,
    rows: ?*LogRows,
) LogWalkError!void {
    const Pending = struct { id: []u8, name: NameId, on_file: bool };
    var pending: std.ArrayList(Pending) = .empty;
    defer {
        for (pending.items) |p| gpa.free(p.id);
        pending.deinit(gpa);
    }
    // Keyed by id, and by name too once a walk follows a rename: the same
    // check-in under another name is another question.
    var visited: std.StringHashMapUnmanaged(void) = .empty;
    defer visited.deinit(gpa);
    var visited_keys: std.ArrayList([]u8) = .empty;
    defer {
        for (visited_keys.items) |k| gpa.free(k);
        visited_keys.deinit(gpa);
    }
    var shown: std.StringHashMapUnmanaged(void) = .empty;
    defer shown.deinit(gpa);
    // The plaintext 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);
    // The PATH predicate reads through the SAME plaintext memo, plus
    // a baseline memo and an answer memo of its own. It runs twice per node
    // (here, and the primary parent), so it, not the walk, was the cost.
    var probe = PathProbe.init(gpa, &plaintext);
    defer probe.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;

    var root_name: NameId = 0;
    var root_on_file = false;
    if (filter) |f| {
        root_name = try probe.names.intern(gpa, f.path);
        root_on_file = (try effectivePathContent(gpa, f.repo, &probe, f.repo.lookup(root_id), root_name)).present;
    }
    const root_copy = try gpa.dupe(u8, root_id);
    pending.append(gpa, .{ .id = root_copy, .name = root_name, .on_file = root_on_file }) catch {
        gpa.free(root_copy);
        return error.OutOfMemory;
    };
    var first = true;
    while (pending.pop()) |entry| {
        const id = entry.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);
        const key: []u8 = if (entry.name == 0) id else blk: {
            defer gpa.free(id);
            break :blk try std.fmt.allocPrint(gpa, "{s}/{d}", .{ id, entry.name });
        };
        if (visited.contains(key)) {
            gpa.free(key);
            continue;
        }
        visited_keys.append(gpa, key) catch {
            gpa.free(key);
            return error.OutOfMemory;
        };
        visited.put(gpa, key, {}) catch {
            _ = visited_keys.pop();
            gpa.free(key);
            return error.OutOfMemory;
        };
        const hex = key[0 .. std.mem.indexOfScalar(u8, key, '/') orelse key.len];
        const is_root = first;
        first = false;

        var hash_buf: [digest_len]u8 = undefined;
        const decoded = decodeFullId(hex, &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(hex));
        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", .{hex}) 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", .{hex}) catch {};
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
            continue;
        };
        defer gpa.free(bytes);
        if (!artifact.verify(hex, 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), and, following, the
        // check-in that renamed the file onto the name it follows.
        var step: PathStep = .{ .hit = true };
        if (filter) |f| {
            // §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()) |p| {
                    primary = p;
                    break;
                }
            }
            const primary_rid: ?repostate.Rid = if (primary) |p| f.repo.lookup(p) else null;
            step = try pathStep(gpa, f, &probe, f.repo.lookup(hex), primary_rid, entry.name, entry.on_file);
        }
        if (step.hit and !shown.contains(key[0..hex.len])) {
            try shown.put(gpa, key[0..hex.len], {});
            logEmit(out, hex, manifest, long, eventTimeOf(times, hex));
            const counts = if (rows) |r| try r.add(&probe.names, hex, entry.name, step.prior) else true;
            if (counts) {
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
        }
        if (step.stop) continue;

        // 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_name = step.prior orelse entry.name;
        const on_file = entry.on_file or (step.known and step.present);
        const parent_start = pending.items.len;
        for (manifest.p) |pc| {
            var hashes = pc.hashes();
            while (hashes.next()) |parent| {
                // Across a rename, a parent that lacks the old name ends the
                // walk on that side (Fossil treats the file as new there).
                if (step.prior != null) {
                    const f = filter.?;
                    const held = (try effectivePathContent(gpa, f.repo, &probe, f.repo.lookup(parent), parent_name)).present;
                    if (!held) continue;
                }
                const owned = try gpa.dupe(u8, parent);
                pending.append(gpa, .{ .id = owned, .name = parent_name, .on_file = on_file }) catch {
                    gpa.free(owned);
                    return error.OutOfMemory;
                };
            }
        }
        std.mem.reverse(Pending, pending.items[parent_start..]);
    }
}

/// `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 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.
///
/// With a PATH that follows renames, a stack entry is a check-in under a
/// name: crossing the check-in that renamed the file, the parents are walked
/// under the old name, and the same check-in reached under another name is
/// visited again (a rename on a merged branch reaches the fork point under
/// both). Each check-in is still listed once, under the first name that
/// lists it.
pub fn walkLogDerived(
    gpa: std.mem.Allocator,
    repo: *Repository,
    root_id: []const u8,
    out: *std.Io.Writer,
    limit: ?usize,
    /// The PATH filter, answered from the file index. Null walks
    /// everything (B-log-1).
    filter: ?LogPathFilter,
    /// `--long` needs each row's own D/U/C cards, and derived state keeps the
    /// timestamp PARSED, not as the check-in wrote it. So a long row reads its
    /// own manifest — one read per EMITTED row, a page of them, rather than
    /// the per-visited-node read that made this verb slow. The bytes then come
    /// from the same place the manifest walk printed them, which is what keeps
    /// the line identical.
    long: bool,
    rows: ?*LogRows,
) 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;

    const Pending = struct { rid: repostate.Rid, name: NameId, on_file: bool };
    var pending: std.ArrayList(Pending) = .empty;
    defer pending.deinit(gpa);
    var visited: std.AutoHashMapUnmanaged(NamedRid, void) = .empty;
    defer visited.deinit(gpa);
    var shown: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty;
    defer shown.deinit(gpa);

    var emitted: usize = 0;
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);
    var probe = PathProbe.init(gpa, &plaintext);
    defer probe.deinit(gpa);
    var root_name: NameId = 0;
    var root_on_file = false;
    if (filter) |f| {
        root_name = try probe.names.intern(gpa, f.path);
        if (f.follow) root_on_file = (try effectivePathContent(gpa, repo, &probe, root_rid, root_name)).present;
    }
    try pending.append(gpa, .{ .rid = root_rid, .name = root_name, .on_file = root_on_file });
    while (pending.pop()) |entry| {
        const rid = entry.rid;
        if (limit) |n| std.debug.assert(emitted < n);
        if ((try visited.getOrPut(gpa, .{ .rid = rid, .name = entry.name })).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) {
            // A PATH filter cannot assert that a phantom changes PATH — its
            // effective set is unknowable — so it is skipped silently, which
            // is what the manifest walk does. Unfiltered, it is named.
            if (filter == null) {
                out.print("phantom {s}\n", .{repo.hashOf(rid)}) catch {};
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
            continue;
        }
        // A manifest is read ONLY for an emitted row under `--long`; the
        // predicate reads through `fileAt`, and only for the check-ins the
        // index names. Reading one per visited check-in is the cost the
        // index removed.
        var step: PathStep = .{ .hit = true };
        if (filter) |f| {
            step = try pathStep(gpa, f, &probe, rid, repo.state().primaryParentOf(rid), entry.name, entry.on_file);
        }
        if (step.hit and !(try shown.getOrPut(gpa, rid)).found_existing) {
            var owned: ?Repository.OwnedManifest = null;
            defer if (owned) |*o| o.deinit(gpa);
            if (long) {
                owned = repo.manifestCached(gpa, rid, &plaintext) catch |e| switch (e) {
                    error.OutOfMemory => return error.OutOfMemory,
                    else => null,
                };
            }
            if (owned) |*o| {
                logEmit(out, repo.hashOf(rid), &o.manifest, true, repo.state().mtimeOf(rid));
            } else {
                out.print("checkin {s}\n", .{repo.hashOf(rid)}) catch {};
            }
            const counts = if (rows) |r| try r.add(&probe.names, repo.hashOf(rid), entry.name, step.prior) else true;
            if (counts) {
                emitted += 1;
                if (limit) |n| if (emitted >= n) return;
            }
        }
        if (step.stop) continue;

        // Push the parents for the LIFO stack, reversing only this node's
        // own appended segment so the first P hash is visited first while
        // older sibling work stays below it.
        const parent_name = step.prior orelse entry.name;
        const on_file = entry.on_file or (step.known and step.present);
        const start = pending.items.len;
        var it = repo.state().parentsOf(rid);
        while (it.next()) |p| {
            // Across a rename, a parent that lacks the old name ends the walk
            // on that side (Fossil treats the file as new there).
            if (step.prior != null) {
                const held = if (repo.state().primaryParentOf(rid) == p)
                    step.parent_present
                else
                    (try effectivePathContent(gpa, repo, &probe, p, parent_name)).present;
                if (!held) continue;
            }
            try pending.append(gpa, .{ .rid = p, .name = parent_name, .on_file = on_file });
        }
        std.mem.reverse(Pending, pending.items[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. `prior` is
/// the card's own old name, which only the check-in that recorded a rename
/// carries (an inherited card never does).
pub const PathContent = struct {
    present: bool,
    hash: [2 * digest_len]u8 = undefined,
    hash_len: usize = 0,
    prior: ?NameId = null,
};

/// The memos the PATH predicate reads through. Two of them, because
/// the predicate repeats two different things:
///
///   * `plaintext` and `baseline` are the pair `annotate` already threads
///     through `fileAtMemo`. A check-in manifest is routinely stored as a
///     delta against its neighbour, so reconstructing one per `fileAt` call
///     re-resolves the SAME base chain per check-in, and a wide baseline is
///     re-parsed per check-in on top of that — the cost then grows with how
///     much history sits behind the node rather than with the node. The walk
///     built this memo for its own manifest reads and did not hand it to the
///     predicate, which is the part that runs twice per node.
///   * `content` memoizes the ANSWER by check-in and name. Every check-in is
///     asked for its content twice — once as `here` when it is visited, once
///     as `parent` when its child is — so in a mostly-linear history this
///     halves what is left. It is bounded by the nodes visited.
///
/// Neither changes what the predicate answers: both are pure memos over a
/// reconstruction that is a function of the store. `names` interns the names
/// a following walk meets.
pub const PathProbe = struct {
    plaintext: *logstore.PlaintextCache,
    baseline: ?*Repository.BaselineMemo = null,
    content: std.AutoHashMapUnmanaged(NamedRid, PathContent) = .empty,
    names: NameTable,

    pub fn init(gpa: std.mem.Allocator, plaintext: *logstore.PlaintextCache) PathProbe {
        return .{ .plaintext = plaintext, .names = NameTable.init(gpa) };
    }

    pub fn deinit(self: *PathProbe, gpa: std.mem.Allocator) void {
        if (self.baseline) |b| b.deinit(gpa);
        self.content.deinit(gpa);
        self.names.deinit(gpa);
    }
};

/// The effective content of `name` in check-in `rid` (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
/// `rid` is null (a phantom the derived view never crosslinked) or the name
/// is not in its effective set (never added, or a delta delete marker §4.7.6).
pub fn effectivePathContent(
    gpa: std.mem.Allocator,
    repo: *Repository,
    probe: *PathProbe,
    rid: ?repostate.Rid,
    name: NameId,
) LogWalkError!PathContent {
    const r = rid orelse return .{ .present = false };
    // Asked once as `here`, once as `parent` of the child below it.
    if (probe.content.get(.{ .rid = r, .name = name })) |hit| return hit;
    const answer = try effectivePathContentAt(gpa, repo, probe, r, name);
    try probe.content.put(gpa, .{ .rid = r, .name = name }, answer);
    return answer;
}

/// `effectivePathContent` past the memo: the actual §8 reconstruction for one
/// interned rid, through the caller's baseline and plaintext memos.
fn effectivePathContentAt(
    gpa: std.mem.Allocator,
    repo: *Repository,
    probe: *PathProbe,
    rid: repostate.Rid,
    name: NameId,
) LogWalkError!PathContent {
    const card = (repo.fileAtMemo(gpa, rid, probe.names.get(name), &probe.baseline, probe.plaintext) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        // NotManifest/BaselineUnresolvable/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);
    if (card.old_name) |old| out.prior = try probe.names.intern(gpa, old);
    return out;
}

/// Set from `$ZIT_NO_FILE_INDEX` at the one environment seam in
/// `main`. The differential test drives the reconstructing answer through it.
pub var no_file_index: bool = false;

/// Evidence seam — how many check-ins the adopted file index recorded
/// as UNKNOWN (reconstructed per query rather than answered). On a tree-
/// manifest store this was the whole log, which is how an index that was
/// valid, adopted and never rebuilt still cost 39 minutes a query. Printed
/// by `main` under `ZIT_FILEINDEX_WALK`, beside the walk count. Null when
/// no index was adopted at all — printed as `none`, so a test asserting
/// zero unknowns cannot pass on a query that never consulted an index.
pub var file_index_unknown: ?usize = null;

/// The file index as a PATH walk reads it: the check-ins that changed each
/// name the walk follows, filled on first use, and every rename edge keyed by
/// check-in and name. All rids are DERIVED rids.
pub const PathIndex = struct {
    repo: *Repository,
    log: *logstore.LogStore,
    /// Owned; `index` borrows it.
    blob: []u8,
    index: fileindex.Index,
    /// The names below are interned here, so the index is asked by string
    /// and never shares ids with a walk's own table.
    names: NameTable,
    /// The check-ins whose change set the index could not know. They are
    /// resolved ONE AT A TIME by reconstruction, rather than refusing the
    /// whole index: a real corpus carries a handful (the SQLite mirror has
    /// exactly one, a 2009 check-in whose parent is genuinely absent), and
    /// discarding 32,000 answers over one of them is how an index ends up
    /// built, stored, and never used.
    unknown: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
    changed: std.AutoHashMapUnmanaged(NameId, std.AutoHashMapUnmanaged(repostate.Rid, void)) = .empty,
    /// (check-in, new name) -> old name: the check-in renamed the file onto
    /// the new name from the old. Filled per name on first use, from the
    /// edges the index finds for that name alone (`from_loaded`).
    renamed_from: std.AutoHashMapUnmanaged(NamedRid, NameId) = .empty,
    from_loaded: std.AutoHashMapUnmanaged(NameId, void) = .empty,
    /// (check-in, old name): the check-in renamed a file away from the name,
    /// filled per name on first use (`away_loaded`).
    renamed_away: std.AutoHashMapUnmanaged(NamedRid, void) = .empty,
    away_loaded: std.AutoHashMapUnmanaged(NameId, void) = .empty,

    pub fn deinit(self: *PathIndex, gpa: std.mem.Allocator) void {
        var it = self.changed.valueIterator();
        while (it.next()) |set| set.deinit(gpa);
        self.changed.deinit(gpa);
        self.unknown.deinit(gpa);
        self.renamed_from.deinit(gpa);
        self.from_loaded.deinit(gpa);
        self.renamed_away.deinit(gpa);
        self.away_loaded.deinit(gpa);
        self.names.deinit(gpa);
        gpa.free(self.blob);
    }

    /// The check-ins the index says changed `name`.
    pub fn changedSet(self: *PathIndex, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!*const std.AutoHashMapUnmanaged(repostate.Rid, void) {
        const id = try self.names.intern(gpa, name);
        const gop = try self.changed.getOrPut(gpa, id);
        if (gop.found_existing) return gop.value_ptr;
        gop.value_ptr.* = .empty;
        var rids: std.ArrayListUnmanaged(u32) = .empty;
        defer rids.deinit(gpa);
        try self.index.changedBy(name, &rids, gpa);
        for (rids.items) |r| {
            if (ridOfLogRid(self.repo, self.log, r)) |view_rid| try gop.value_ptr.put(gpa, view_rid, {});
        }
        return gop.value_ptr;
    }

    /// The name check-in `rid` renamed `name` from, if it recorded a rename.
    /// Only `name`'s own edges are read, by bisection, the first time it is
    /// asked: a walk pays for the renames of the file it follows, not for
    /// every rename the repository holds.
    fn priorOf(self: *PathIndex, gpa: std.mem.Allocator, rid: repostate.Rid, name: []const u8) error{OutOfMemory}!?[]const u8 {
        const id = try self.names.intern(gpa, name);
        if (!(try self.from_loaded.getOrPut(gpa, id)).found_existing) {
            var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
            defer edges.deinit(gpa);
            try self.index.renamesInto(name, &edges, gpa);
            for (edges.items) |e| {
                const view_rid = ridOfLogRid(self.repo, self.log, e.rid) orelse continue;
                const old = try self.names.intern(gpa, e.old);
                try self.renamed_from.put(gpa, .{ .rid = view_rid, .name = id }, old);
            }
        }
        const old = self.renamed_from.get(.{ .rid = rid, .name = id }) orelse return null;
        return self.names.get(old);
    }

    /// Whether no check-in ever held `name`, so a PATH walk has nothing to
    /// find. A file present anywhere was added by some check-in, which then
    /// names it in its change set or is UNKNOWN; so it is enough that no change
    /// set names it, no rename leads onto it, and no unknown check-in holds it.
    /// The unknown check-ins asked are those the derived view can place; one
    /// it cannot, the walk could not visit either. A phantom filled after the
    /// index was built is neither known nor unknown, which the walk this
    /// replaces never saw either. Any doubt answers false and the walk decides.
    pub fn neverHeld(self: *PathIndex, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!bool {
        var rids: std.ArrayListUnmanaged(u32) = .empty;
        defer rids.deinit(gpa);
        try self.index.changedBy(name, &rids, gpa);
        if (rids.items.len != 0) return false;
        var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
        defer edges.deinit(gpa);
        try self.index.renamesInto(name, &edges, gpa);
        if (edges.items.len != 0) return false;
        // Each unknown check-in costs a read; past a handful the walk, which
        // reconstructs them anyway, is no slower than asking here.
        if (self.unknown.count() > max_unknown_probed) return false;
        var it = self.unknown.keyIterator();
        while (it.next()) |rid| {
            const card = self.repo.fileAt(gpa, rid.*, name) catch |e| switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                else => return false,
            };
            if (card) |c| {
                Repository.freeFileCard(gpa, c);
                return false;
            }
        }
        return true;
    }

    const max_unknown_probed = 64;

    /// Whether check-in `rid` renamed a file away from `name`, read the
    /// first time `name` is asked.
    fn renamedAway(self: *PathIndex, gpa: std.mem.Allocator, rid: repostate.Rid, name: []const u8) error{OutOfMemory}!bool {
        const id = try self.names.intern(gpa, name);
        if (!(try self.away_loaded.getOrPut(gpa, id)).found_existing) {
            var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
            defer edges.deinit(gpa);
            try self.index.renamesFrom(name, &edges, gpa);
            for (edges.items) |e| {
                const view_rid = ridOfLogRid(self.repo, self.log, e.rid) orelse continue;
                try self.renamed_away.put(gpa, .{ .rid = view_rid, .name = id }, {});
            }
        }
        return self.renamed_away.contains(.{ .rid = rid, .name = id });
    }
};

/// The file index for a PATH walk, or null when it cannot be trusted and the
/// walk must reconstruct the predicate.
///
/// Adopt-or-build, the shape every other `.zit-cache` member uses: a blob whose
/// key and shun fingerprint still validate is used as it stands; anything else
/// is rebuilt by one `repoview.eachChangeSet` pass and stored. The store is
/// advisory — a write that fails costs the next query a rebuild and nothing
/// else.
///
/// A check-in whose change set is UNKNOWN (a tree manifest, an absent or
/// shunned parent, an unresolvable baseline) is recorded in `unknown` and
/// reconstructed individually by the walk. Dropping such a check-in from the
/// index's answer would silently drop it from a file's history — a wrong
/// answer rather than a slow one.
pub fn loadPathIndex(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    repo: *Repository,
    log: *logstore.LogStore,
) error{OutOfMemory}!?PathIndex {
    // A test seam, and the operator's escape hatch: set, the query is answered
    // by reconstruction alone, which is what the differential test compares
    // the indexed answer against. Read from the environment in `main` and set
    // here, never read in a runner: that is the design-seams rule, and reading
    // it through libc also made the build refuse on any target that does not
    // link libc (every Linux runner, while macOS links it always).
    if (no_file_index) return null;
    var blob: ?[]u8 = repocache.tryLoadFileIndex(gpa, io, root_dir);
    errdefer if (blob) |b| gpa.free(b);
    var idx = if (blob) |b| fileindex.parse(b, log) else null;
    const have: u64 = if (idx) |i| i.n_rids else 0;
    const want: u64 = @intCast(log.entryCount());

    // Three cases, and only the first two cost anything. An index that
    // already covers the log is used as it stands; one that covers a prefix
    // is EXTENDED over the appended tail; anything else is built once.
    // Rebuilding on every append is what made a commit cost a full pass.
    if (idx == null or have < want) {
        var builder = fileindex.Builder.init(gpa);
        defer builder.deinit();
        if (idx) |*i| try builder.absorb(i);
        // The derive builds this index as it captures, so this is the fallback
        // for a store whose cache predates that. Walked in bounded chunks, each
        // checkpointed: a build a restart interrupts resumes from the last
        // chunk instead of from zero, so a forge that restarts more often than
        // the whole build takes still converges.
        // Known limit of this fallback, and the reason the derive-built index
        // is primary: coverage here is by entry count, so a phantom filled
        // after a build (its rid already below the count) is never revisited
        // by a later resume. The derive walks by frame offset and re-covers it.
        const chunk: u64 = 8192;
        var covered: u64 = have;
        while (covered < want) {
            const upto: u64 = @min(covered + chunk, want);
            try repoview.eachChangeSet(gpa, log, @intCast(covered), @intCast(upto), builder.sink());
            const fresh = (try fileindex.serialize(gpa, log, &builder, upto)) orelse {
                if (blob) |b| gpa.free(b);
                return null;
            };
            repocache.storeFileIndex(io, root_dir, fresh);
            if (blob) |b| gpa.free(b);
            blob = fresh;
            covered = upto;
        }
        const final = blob orelse return null; // an empty log: nothing to index
        idx = fileindex.parse(final, log);
    }
    const index = idx orelse {
        if (blob) |b| gpa.free(b);
        return null;
    };
    var out: PathIndex = .{ .repo = repo, .log = log, .blob = blob.?, .index = index, .names = NameTable.init(gpa) };
    errdefer out.deinit(gpa);
    blob = null; // owned by `out` from here
    var u: usize = 0;
    while (u < index.unknownCount()) : (u += 1) {
        if (ridOfLogRid(repo, log, index.unknownAt(u))) |view_rid| try out.unknown.put(gpa, view_rid, {});
    }
    file_index_unknown = index.unknownCount();
    return out;
}

/// Translate a LOG rid (a frame index, which is what the file index stores)
/// into the DERIVED rid the walk uses. The two spaces are not the same — the
/// derived space interns phantoms a P card named and orders by first sight —
/// so comparing one to the other silently matches unrelated check-ins. The
/// artifact name is the identity both agree on.
pub fn ridOfLogRid(repo: *Repository, log: *logstore.LogStore, log_rid: u32) ?repostate.Rid {
    const digest = log.hashOf(@intCast(log_rid));
    const hex: []const u8 = if (digest.len == 32)
        &std.fmt.bytesToHex(digest[0..32].*, .lower)
    else if (digest.len == 20)
        &std.fmt.bytesToHex(digest[0..20].*, .lower)
    else
        return null;
    return repo.lookup(hex);
}

/// What a PATH walk is asked: the file, how many rows (`-n`), whether rows
/// carry their D/U/C cards (`--long`), and whether renames are followed.
pub const PathQuery = struct {
    path: []const u8,
    limit: ?usize = null,
    long: bool = false,
    follow: bool = true,
};

/// `zit log ROOT PATH`'s walk, writing its text lines to `out` and, when
/// `rows` is given, its rows there too. The predicate is answered from
/// `index` when one is given; the derived adjacency is walked when the root
/// is crosslinked, and the manifest walk otherwise.
pub fn walkPath(
    gpa: std.mem.Allocator,
    repo: *Repository,
    log: *logstore.LogStore,
    index: ?*PathIndex,
    root_id: []const u8,
    q: PathQuery,
    out: *std.Io.Writer,
    rows: ?*LogRows,
) LogWalkError!void {
    const filter: LogPathFilter = .{
        .repo = repo,
        .path = q.path,
        .follow = q.follow,
        .index = index,
    };
    // Traverse the DERIVED adjacency, exactly as the unfiltered walk
    // does: same depth-first order, same phantom line, and no
    // artifact content read for traversal. With the predicate answered from
    // the index, that leaves nothing per visited check-in at all — the reads
    // that remain are one per EMITTED row under `--long`. The manifest walk
    // stays as the fallback for a root the derived state cannot resolve.
    const derived = repo.lookup(root_id) != null and repo.state().mtimeOf(repo.lookup(root_id).?) != null;
    // A following walk reads each check-in's file list until it meets the
    // file, so a name no check-in ever held would be read for in every
    // ancestor. The answer there is no rows, known without walking.
    if (derived) if (index) |ix| {
        if (try ix.neverHeld(gpa, q.path)) return;
    };
    if (derived)
        try walkLogDerived(gpa, repo, root_id, out, q.limit, filter, q.long, rows)
    else
        try walkLog(gpa, log, root_id, out, filter, q.limit, q.long, repo, rows);
}

/// One listed check-in as `log CHECKIN PATH -F json` describes it. Field
/// names and change words are Fossil's (`json_finfo.c`): `uuid` is the
/// file's content in the check-in, `parent` its content in the primary
/// parent, `state` one of added, modified, removed — a pure rename is
/// `modified`, as there. `name` is the file's name in that check-in, which
/// differs from the queried path below a rename, and `prior` is the name a
/// rename row moved it from. A version whose content is absent by policy
/// has a null `size` and names why in `content`.
pub const Entry = struct {
    checkin: []const u8,
    name: []const u8,
    prior: ?[]const u8,
    uuid: ?[]const u8,
    parent: ?[]const u8,
    state: []const u8,
    time_ms: ?repostate.Mtime,
    user: ?[]const u8,
    comment: ?[]const u8,
    size: ?u64,
    /// Purged, shunned or elided; null whenever the content is here.
    content: ?feed.Absence,
};

/// Why a blob the store names is not here, as the caller can tell: the
/// cone and horizon classifier lives with the sync machinery.
pub const Classify = struct {
    ctx: ?*anyopaque = null,
    absence: *const fn (ctx: ?*anyopaque, hash: []const u8) feed.Absence,
};

/// The facts of one row the walk listed. Strings live on `arena`. Content
/// missing for no recorded reason is damage: `error.Corrupt`.
pub fn entryOf(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    probe: *PathProbe,
    row: LogRow,
    classify: Classify,
) !Entry {
    const id = row.id;
    const bytes = (try repo.blob(gpa, id)) orelse return error.Corrupt;
    defer gpa.free(bytes);
    var loaded = try artifact.parseForImport(gpa, bytes);
    defer loaded.deinit(gpa);
    const m = &loaded.manifest;
    var primary: ?[]const u8 = null;
    for (m.p) |pc| {
        var hashes = pc.hashes();
        if (hashes.next()) |first| {
            primary = first;
            break;
        }
    }
    const name = try probe.names.intern(gpa, row.name);
    const parent_name = try probe.names.intern(gpa, row.prior orelse row.name);
    const here = try effectivePathContent(gpa, repo, probe, repo.lookup(id), name);
    const before: PathContent = if (primary) |pid| try effectivePathContent(gpa, repo, probe, repo.lookup(pid), parent_name) else .{ .present = false };
    var size: ?u64 = null;
    var content: ?feed.Absence = null;
    if (here.present) {
        const hash = here.hash[0..here.hash_len];
        if (try repo.blob(gpa, hash)) |blob| {
            defer gpa.free(blob);
            size = blob.len;
        } else {
            // A version purged, shunned or elided by a cone is still part
            // of the history; only its size is unknown. Content missing
            // for no recorded reason is damage and keeps failing.
            const why = classify.absence(classify.ctx, hash);
            if (why == .missing) return error.Corrupt;
            content = why;
        }
    }
    return .{
        .checkin = try arena.dupe(u8, id),
        .name = try arena.dupe(u8, row.name),
        .prior = if (row.prior) |p| try arena.dupe(u8, p) else null,
        .uuid = if (here.present) try arena.dupe(u8, here.hash[0..here.hash_len]) else null,
        .parent = if (before.present) try arena.dupe(u8, before.hash[0..before.hash_len]) else null,
        .state = if (!before.present) "added" else if (!here.present) "removed" else "modified",
        .time_ms = if (repo.lookup(id)) |rid| repo.state().mtimeOf(rid) else null,
        .user = if (m.u) |u| try arena.dupe(u8, u.login) else null,
        .comment = if (m.c) |c| try arena.dupe(u8, c.comment) else null,
        .size = size,
        .content = content,
    };
}

pub const TextWriter = fn (jw: *std.json.Stringify, s: []const u8) std.json.Stringify.Error!void;

/// A string as `std.json.Stringify` writes it.
pub fn plainText(jw: *std.json.Stringify, s: []const u8) std.json.Stringify.Error!void {
    return jw.write(s);
}

/// One row as a JSON object, in `log -F json`'s field order. `text` writes
/// the strings the store holds (names, user, comment), which need not be
/// UTF-8: the CLI writes them as they are, the daemon as valid JSON.
pub fn writeEntry(jw: *std.json.Stringify, e: Entry, comptime text: TextWriter) std.json.Stringify.Error!void {
    const opt = struct {
        fn write(w: *std.json.Stringify, s: ?[]const u8) std.json.Stringify.Error!void {
            if (s) |v| return text(w, v);
            return w.write(null);
        }
    }.write;
    try jw.beginObject();
    try jw.objectField("checkin");
    try jw.write(e.checkin);
    try jw.objectField("name");
    try text(jw, e.name);
    try jw.objectField("prior");
    try opt(jw, e.prior);
    try jw.objectField("uuid");
    try jw.write(e.uuid);
    try jw.objectField("parent");
    try jw.write(e.parent);
    try jw.objectField("state");
    try jw.write(e.state);
    try jw.objectField("time");
    if (e.time_ms) |t| {
        var buf: [24]u8 = undefined;
        try jw.write(rfc3339Millis(&buf, t));
    } else try jw.write(null);
    try jw.objectField("user");
    try opt(jw, e.user);
    try jw.objectField("comment");
    try opt(jw, e.comment);
    try jw.objectField("size");
    try jw.write(e.size);
    if (e.content) |why| {
        try jw.objectField("content");
        try jw.write(switch (why) {
            .purged => "purged",
            .shunned => "shunned",
            .elided => "elided",
            .missing => unreachable, // `entryOf` refuses it as damage
        });
    }
    try jw.endObject();
}

/// `mt` (milliseconds since the epoch) as RFC 3339 UTC with milliseconds.
pub fn rfc3339Millis(buf: *[24]u8, mt: repostate.Mtime) []const u8 {
    var iso_buf: [19]u8 = undefined;
    const secs = artifact.formatWriterTimestamp(@intCast(@divFloor(mt, 1000)), &iso_buf);
    return std.fmt.bufPrint(buf, "{s}.{d:0>3}Z", .{ secs, @as(u64, @intCast(@mod(mt, 1000))) }) catch unreachable; // 19 + 5 bytes
}

// ---------------------------------------------------------------------------
// Paging, for a resident reader

/// One page of a file's history and where the next one starts.
pub const Page = struct {
    entries: []const Entry,
    /// Null when nothing follows this page.
    cursor: ?[]const u8,
};

/// A page cursor: the check-in the walk started from, then the last row
/// listed. Rows are unique per check-in, so the last row's check-in alone
/// says where to resume; carrying the root keeps the walk the same one when
/// new check-ins land on the branch between pages.
pub const Cursor = struct { root: []const u8, after: []const u8 };

pub fn parseCursor(text: []const u8) ?Cursor {
    const colon = std.mem.indexOfScalar(u8, text, ':') orelse return null;
    const c: Cursor = .{ .root = text[0..colon], .after = text[colon + 1 ..] };
    if (!artifact.validateName(c.root) or !artifact.validateName(c.after)) return null;
    return c;
}

pub fn formatCursor(arena: std.mem.Allocator, root: []const u8, after: []const u8) error{OutOfMemory}![]const u8 {
    return std.fmt.allocPrint(arena, "{s}:{s}", .{ root, after });
}

pub const PageError = LogWalkError || error{
    /// The cursor's row is not in this walk: another path, another
    /// `follow`, or history the store no longer holds.
    CursorInvalid,
};

/// The rows after `after` (all of them when null), at most `limit` (all
/// when null), from the walk `zit log root_id PATH -F json` makes. Each
/// page re-walks from the root to its resume point, so a page costs what
/// the rows before it cost; nothing is held between pages.
pub fn page(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    log: *logstore.LogStore,
    index: ?*PathIndex,
    root_id: []const u8,
    after: ?[]const u8,
    q: PathQuery,
    classify: Classify,
) PageError!Page {
    std.debug.assert(!q.long and (q.limit orelse 1) >= 1);
    var rows: LogRows = .{ .arena = arena, .after = after };
    var discard: std.Io.Writer.Discarding = .init(&.{});
    // One row past the page says whether another page follows.
    var walk_q = q;
    if (q.limit) |n| walk_q.limit = n + 1;
    try walkPath(gpa, repo, log, index, root_id, walk_q, &discard.writer, &rows);
    if (rows.after != null) return error.CursorInvalid;
    const kept = rows.rows.items;
    const shown = if (q.limit) |n| kept[0..@min(n, kept.len)] else kept;
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);
    var probe = PathProbe.init(gpa, &plaintext);
    defer probe.deinit(gpa);
    const entries = try arena.alloc(Entry, shown.len);
    for (shown, entries) |row, *e| e.* = entryOf(gpa, arena, repo, &probe, row, classify) catch |err| return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        else => error.Corrupt,
    };
    const more = shown.len < kept.len;
    return .{
        .entries = entries,
        .cursor = if (more) try formatCursor(arena, root_id, shown[shown.len - 1].id) else null,
    };
}