Skip to content
zit Artifact [4f124af59b]
Login

Artifact [4f124af59b]

Artifact 4f124af59bb36c619c9e7cb4b52e08eebf77643ea2a369dd49b6f9e90c4c1aa7:


//! One file's annotation, as `zit annotate [--partial]` attributes it: the
//! version chain over the target's ancestry (following renames unless told
//! not to), each line credited to the check-in that introduced it. The CLI
//! renders the answer as text and the daemon's `annotate` as JSON; both call
//! `annotate` here, so the attribution cannot differ between them. What the
//! CLI prints on a refusal is the caller's: this returns which refusal.

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 thinned = @import("../thinned.zig");
const merge3 = @import("../merge3.zig");
const engine = @import("../annotate.zig");
const Repository = @import("../repository.zig").Repository;
const filelog = @import("filelog.zig");

const NameId = filelog.NameId;
const NamedRid = filelog.NamedRid;
const NameTable = filelog.NameTable;

/// Which of PATH's ancestors actually CHANGED it, read from the derived
/// cache's record body in one sequential pass. Fossil answers this
/// from its mlink table; the equivalent here is the changed-file list
/// every record carries. A record's list is a pure function of
/// content-addressed artifacts — the diff of a manifest against its primary
/// parent over their shared baseline — so even a cache older than the log is
/// CORRECT for the check-ins it covers; coverage, not freshness, is the only
/// question, and `covered` answers it. Null when there is no usable cache:
/// the caller then reads a manifest per ancestor, exactly as before. Annotate
/// asks over PATH's ancestry; grep asks over the whole store.
pub const AnnotateTouched = struct {
    /// Check-ins with a record whose file facts are known.
    covered: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
    /// The rows of covered records for the names asked about, by check-in
    /// and name.
    touched: std.AutoHashMapUnmanaged(NamedRid, TouchedRow) = .empty,
    /// The names whose rows were kept. A name outside it (an old name a
    /// rename at an uncovered check-in reached) has no rows here even where
    /// the record changed it, so it must be read from the manifest.
    names: std.AutoHashMapUnmanaged(NameId, void) = .empty,

    /// Whether the record body answers `name` at check-in `rid`: absence of
    /// a row then means "unchanged against the primary parent".
    pub fn answers(self: *const AnnotateTouched, rid: repostate.Rid, name: NameId) bool {
        return self.covered.contains(rid) and self.names.contains(name);
    }

    pub fn deinit(self: *AnnotateTouched, gpa: std.mem.Allocator) void {
        self.covered.deinit(gpa);
        self.names.deinit(gpa);
        var it = self.touched.valueIterator();
        while (it.next()) |row| if (row.hash) |b| gpa.free(b);
        self.touched.deinit(gpa);
    }
};

/// One record row: the hash the check-in gives the file (null when it
/// deletes it) and, on a rename, the name it renamed the file from.
const TouchedRow = struct { hash: ?[]u8, prior: ?NameId };

/// `AnnotateTouched` for `path` and, when `follow`, every name a rename
/// chain reaches from it: the record body's rename rows are read first and
/// closed over from `path`, so the rows of every older name are kept in the
/// same pass. The closure is by name, which may keep a few rows no walk asks
/// for; the walk asks by check-in and name, so an unrelated file that once
/// held one of these names is never mistaken for this one.
pub fn annotateTouchedFromCache(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    names: *NameTable,
    path: []const u8,
    follow: bool,
) ?AnnotateTouched {
    var raw = repocache.loadForExtension(io, repo.root_dir) orelse return null;
    defer raw.deinit();
    var wanted: std.StringHashMapUnmanaged(void) = .empty;
    defer wanted.deinit(gpa);
    wanted.put(gpa, path, {}) catch return null;
    if (follow) {
        var edges: std.ArrayListUnmanaged([2][]const u8) = .empty;
        defer edges.deinit(gpa);
        var it = repoview.RecordIter.init(raw.body);
        while (true) {
            var rec = (it.next(gpa) catch break) orelse break;
            defer rec.deinit(gpa);
            if (rec.kind != .manifest or !rec.files_known) continue;
            for (rec.files.items) |f| {
                if (f.prior) |old| edges.append(gpa, .{ f.path, old }) catch return null;
            }
        }
        // Each pass adds at least one name or ends, and there are no more
        // names than edges.
        var grew = true;
        while (grew) {
            grew = false;
            for (edges.items) |e| {
                if (wanted.contains(e[0]) and !wanted.contains(e[1])) {
                    wanted.put(gpa, e[1], {}) catch return null;
                    grew = true;
                }
            }
        }
    }
    var out: AnnotateTouched = .{};
    errdefer out.deinit(gpa);
    var wanted_it = wanted.keyIterator();
    while (wanted_it.next()) |w| {
        const id = names.intern(gpa, w.*) catch return null;
        out.names.put(gpa, id, {}) catch return null;
    }
    var it = repoview.RecordIter.init(raw.body);
    while (true) {
        var rec = (it.next(gpa) catch {
            // A short or undecodable tail: keep what decoded — every kept
            // record is still individually correct — and let the uncovered
            // rids take the manifest fallback.
            break;
        }) orelse break;
        defer rec.deinit(gpa);
        if (rec.kind != .manifest or !rec.files_known) continue;
        const rid = repo.lookup(rec.hash) orelse continue;
        out.covered.put(gpa, rid, {}) catch return null;
        for (rec.files.items) |f| {
            if (!wanted.contains(f.path)) continue;
            const name = names.intern(gpa, f.path) catch return null;
            const prior: ?NameId = if (follow and f.prior != null) (names.intern(gpa, f.prior.?) catch return null) else null;
            const hash: ?[]u8 = if (f.hash) |h| (gpa.dupe(u8, h) catch return null) else null;
            out.touched.put(gpa, .{ .rid = rid, .name = name }, .{ .hash = hash, .prior = prior }) catch {
                if (hash) |h| gpa.free(h);
                return null;
            };
        }
    }
    return out;
}

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

/// PATH's effective content in the check-in `rid` (artifact §8 via
/// `Repository.fileAt` + `blob`), owned by the caller on `gpa`, or null when
/// PATH is absent there (never added, or a delta delete). The F card is freed
/// before returning; only the absent blob's name, when there is one, goes on
/// `arena`.
fn annotateContentAt(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    cache: *logstore.PlaintextCache,
    memo: *?*Repository.BaselineMemo,
    rid: repostate.Rid,
    path: []const u8,
    absent_hash: *?[]const u8,
) ReadError!?[]u8 {
    const maybe_card = repo.fileAtMemo(gpa, rid, path, memo, cache) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        // NotARepo/Corrupt/NotManifest/BaselineUnresolvable 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 content = try annotateContentByHash(gpa, repo, cache, card.hash.?);
    // PATH is in this check-in but its blob is not here: say which blob, so the
    // caller can tell an absence from "not added yet".
    if (content == null) absent_hash.* = try arena.dupe(u8, card.hash.?);
    return content;
}

/// The blob `hash` names, owned by the caller on `gpa`, or null when the
/// store lacks it. Through the plaintext memo: adjacent versions of PATH are
/// deltas over shared bases, and without the memo every version re-walks its
/// whole chain -- on SQLite's shell.c.in that walk, not the diff, was most of
/// the remaining time once the diff core went O(ND).
fn annotateContentByHash(
    gpa: std.mem.Allocator,
    repo: *Repository,
    cache: *logstore.PlaintextCache,
    hash: []const u8,
) ReadError!?[]u8 {
    return repo.blobCached(gpa, hash, cache) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead, error.ImportPending => error.Storage,
        error.NotARepo, error.Corrupt => error.Corrupt,
    };
}

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

pub const Options = struct {
    /// Fossil's `-n N`: walk back over at most N versions of the file (the
    /// check-ins that changed it), newest first in the linearization, so the
    /// cost follows N rather than the history. Like Fossil, at least three
    /// are walked, and 0 means its default count of 30. A line the oldest
    /// version reached already carried, when older versions exist, has no
    /// origin the walk can name: its `Origin.beyond` is set.
    limit: ?usize = null,
    /// Fill `Annotation.versions` even without a limit (`-l`). Off, and
    /// without a limit, no version is told from an unchanged check-in.
    list_versions: bool = false,
    follow: bool = true,
    /// A version purged or shunned is a gap the annotation reads through,
    /// rather than a refusal; lines claimed across it are approximate.
    partial: bool = false,
};

/// How a blob the store names but lacks sits against its cone: `elided`
/// when the cone or horizon leaves it out, `horizon` when the store has a
/// horizon at all. Null from the hook when the store has no cone.
pub const Elision = struct { elided: bool, horizon: bool };

/// The cone classifier lives with the sync machinery; the caller supplies it.
pub const ElisionHook = struct {
    ctx: ?*anyopaque = null,
    of: *const fn (ctx: ?*anyopaque, gpa: std.mem.Allocator, hash: []const u8) ?Elision,
};

/// Why there is no annotation. Each names what the CLI's message needs.
pub const Refusal = union(enum) {
    /// PATH is not in the target check-in.
    not_tracked,
    /// The target's content is binary: provenance is undefined over it.
    binary,
    /// A version's content was purged or shunned, and `partial` was not
    /// asked (or it is the target itself). Exit 8.
    absent: struct { why: enum { purged, shunned }, name: []const u8, rid: repostate.Rid },
    /// A version's content is gone for no recorded reason. Exit 1.
    missing: struct { name: []const u8, rid: repostate.Rid },
    /// The cone or horizon elides `name`'s blob `hash`. Exit 8.
    elided: struct { name: []const u8, hash: []const u8 },
    /// A rename crosses this store's horizon: `name` (the old name) lies
    /// below it, and blob `hash` is the renamed file's. Exit 8.
    elided_below: struct { name: []const u8, hash: []const u8 },
};

pub const Origin = struct {
    rid: repostate.Rid,
    /// The origin's event time, milliseconds since the epoch.
    mtime: repostate.Mtime,
    /// Claimed across a purged or shunned version (`--partial`'s `~`).
    approx: bool,
    /// Older than the versions `limit` let the walk reach: `rid` is only
    /// where the walk stopped, and Fossil prints no attribution for it.
    beyond: bool = false,
};

pub const Annotation = struct {
    /// The target's content; `lines` index into it.
    target: []const u8,
    lines: []const merge3.Line,
    /// One per line.
    origins: []const Origin,
    /// Some version was read through as a gap.
    any_gap: bool,
    /// The versions of the file walked, newest first, when a limit or
    /// `list_versions` asked for them.
    versions: []const repostate.Rid,

    /// Line `i`'s bytes, its line break included.
    pub fn text(self: *const Annotation, i: usize) []const u8 {
        return merge3.lineBytes(self.target, self.lines[i]);
    }
};

pub const Result = union(enum) {
    ok: Annotation,
    refused: Refusal,
};

/// Annotate `path` as of check-in `cur`, whose ancestry `all_rids` is
/// (`Repository.ancestryLinearized`: oldest first, `cur` last). Everything
/// returned lives on `arena`; `gpa` carries the repository's own reads.
///
/// Following renames, a node is a check-in under a NAME: below the check-in
/// whose card renamed the file, its parents are read under the old name, so
/// lines from before the rename keep the check-ins that wrote them. The same
/// check-in can be a node under two names (a rename on a branch merged back
/// reaches the fork point under both); a parent that lacks the old name
/// holds nothing under it and so attributes nothing, which is Fossil's
/// "treat as new" on that side.
///
/// With `partial`, a version purged or shunned is a gap rather than a
/// refusal: its children read through it to its surviving parents, and a
/// line a child claims across a gap is marked approximate, since the absent
/// version may have been the one that wrote it.
pub fn annotate(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    path: []const u8,
    cur: repostate.Rid,
    all_rids: []const repostate.Rid,
    opts: Options,
    hook: ElisionHook,
) ReadError!Result {
    const follow = opts.follow;
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);
    // Consecutive versions of PATH sit in delta check-ins over one baseline;
    // the memo parses that baseline once instead of once per version.
    var baseline: ?*Repository.BaselineMemo = null;
    defer if (baseline) |b| b.deinit(gpa);
    var names = NameTable.init(gpa);
    defer names.deinit(gpa);
    const path_name = try names.intern(gpa, path);
    // Fossil's annotate_file: at least three versions, and 0 for its 30.
    const limit: ?usize = if (opts.limit) |n| (if (n == 0) 30 else @max(n, 3)) else null;
    var entries: std.ArrayList(AnnotateEntry) = .empty;
    for (all_rids) |rid| {
        try entries.append(arena, .{
            .rid = rid,
            .mtime = repo.state().mtimeOf(rid) orelse 0,
            .hash = repo.hashOf(rid),
        });
    }

    // rid → position in processing order, so parent links resolve to indices.
    var index_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty;
    for (entries.items, 0..) |e, i| try index_of.put(arena, e.rid, @intCast(i));

    // Which ancestors changed PATH, from the cache's record body.
    // Reading a manifest per ancestor made annotate proportional to the
    // store: SQLite's history is ~36k check-ins of which ~200 touch a given
    // file, and fossil (whose mlink table is the same precomputation) answers
    // in milliseconds where this took minutes. With the set in hand, an
    // ancestor that did not change PATH inherits its primary parent's
    // content outright — the record's diff IS against the primary parent, so
    // "not listed" means "byte-equal there" — a changed ancestor reads the blob
    // the record names, and only an uncovered one opens its manifest.
    var touched_opt = annotateTouchedFromCache(gpa, io, repo, &names, path, follow);
    defer if (touched_opt) |*t| t.deinit(gpa);

    // The names each position is read under, from the target down: a
    // child's name passes to its parents, switched to the old name at the
    // check-in that renamed the file. Children follow their parents in the
    // linearization, so walking it backwards meets every child of a
    // position before the position itself.
    const names_at = try arena.alloc(std.ArrayListUnmanaged(NameId), entries.items.len);
    for (names_at) |*l| l.* = .empty;
    var parent_name_of: std.AutoHashMapUnmanaged(NamedRid, NameId) = .empty;
    // Nodes a rename reached: an absence there is the old name's, and if the
    // store elided its content the honest answer is to say so.
    var crossed: std.AutoHashMapUnmanaged(NamedRid, void) = .empty;
    const cur_pos = index_of.get(cur).?; // cur is self-inclusive in its ancestry
    try names_at[cur_pos].append(arena, path_name);
    // The versions met so far, newest first. With a limit, the walk ends at
    // the position of the last one it may read: everything below it is
    // never opened, so the window is the linearization's tail from there.
    var versions: std.ArrayList(repostate.Rid) = .empty;
    var window_start: usize = 0;
    var k = entries.items.len;
    while (k > 0) {
        if (limit) |n| if (versions.items.len >= n) {
            window_start = k;
            break;
        };
        k -= 1;
        const rid = entries.items[k].rid;
        for (names_at[k].items) |n| {
            const prior: ?NameId = if (!follow) null else blk: {
                if (touched_opt) |*t| if (t.answers(rid, n)) {
                    // A rename is always a row, so no row means no rename.
                    break :blk if (t.touched.get(.{ .rid = rid, .name = n })) |row| row.prior else null;
                };
                const card = (repo.fileAtMemo(gpa, rid, names.get(n), &baseline, &plaintext) catch |e| return switch (e) {
                    error.OutOfMemory => error.OutOfMemory,
                    error.CannotRead => error.Storage,
                    else => error.Corrupt,
                }) orelse break :blk null;
                defer Repository.freeFileCard(gpa, card);
                break :blk if (card.old_name) |old| try names.intern(gpa, old) else null;
            };
            const pn = prior orelse n;
            try parent_name_of.put(arena, .{ .rid = rid, .name = n }, pn);
            if ((limit != null or opts.list_versions) and try changedHere(gpa, repo, &names, if (touched_opt) |*t| t else null, rid, n, pn, &baseline, &plaintext)) {
                try versions.append(arena, rid);
            }
            var it = repo.state().parentsOf(rid);
            while (it.next()) |p| {
                // A parent the store does not hold, across a rename, is
                // history below its horizon: the old name's lines cannot be
                // read, and attributing them to the rename would be wrong.
                const held = repo.state().mtimeOf(p) != null;
                const in_ancestry = index_of.get(p);
                if (prior != null and (!held or in_ancestry == null)) {
                    if (try elidedBelow(gpa, arena, repo, hook, rid, names.get(n))) |hash|
                        return .{ .refused = .{ .elided_below = .{ .name = try arena.dupe(u8, names.get(pn)), .hash = hash } } };
                }
                const pi = in_ancestry orelse continue;
                if (std.mem.indexOfScalar(NameId, names_at[pi].items, pn) == null) {
                    try names_at[pi].append(arena, pn);
                }
                if (prior != null) try crossed.put(arena, .{ .rid = p, .name = pn }, {});
            }
        }
    }

    // One engine node per (position, name), in position order so parents
    // still precede children.
    var node_of: std.AutoHashMapUnmanaged(NamedRid, u32) = .empty;
    var node_keys: std.ArrayList(NamedRid) = .empty;
    var node_pos: std.ArrayList(u32) = .empty;
    for (names_at[window_start..], window_start..) |l, pos| {
        for (l.items) |n| {
            const key: NamedRid = .{ .rid = entries.items[pos].rid, .name = n };
            try node_of.put(arena, key, @intCast(node_keys.items.len));
            try node_keys.append(arena, key);
            try node_pos.append(arena, @intCast(pos));
        }
    }

    const cur_index = node_of.get(.{ .rid = cur, .name = path_name }).?;
    // Each node's version is dropped once the last node that reads it as a
    // parent is attributed, so what is live is the frontier of the walk, not
    // the history behind it. The target is kept for the answer.
    const last_use = try arena.alloc(u32, node_keys.items.len);
    for (node_keys.items, 0..) |key, i| {
        last_use[i] = @intCast(i);
        const pn = parent_name_of.get(key).?;
        var it = repo.state().parentsOf(key.rid);
        while (it.next()) |p| {
            const idx = node_of.get(.{ .rid = p, .name = pn }) orelse continue;
            last_use[idx] = @max(last_use[idx], @as(u32, @intCast(i)));
        }
    }
    last_use[cur_index] = std.math.maxInt(u32);
    // Provenance is undefined over binary content (B-annotate-9, the merge3
    // NUL predicate). A binary target is still walked for the refusals the
    // history owes first, but its versions are not diffed: binary "lines"
    // can make the line diff quadratic. A target that cannot be read here
    // is read again in the walk, which reports why.
    const target_binary = blk: {
        var absent: ?[]const u8 = null;
        const bytes = (annotateContentAt(gpa, arena, repo, &plaintext, &baseline, cur, path, &absent) catch null) orelse break :blk false;
        defer gpa.free(bytes);
        // The memo's default budget is sized for a store-wide sweep; here it
        // filled with old versions of this one file, tens of megabytes held
        // per request. A few dozen of the file's own size keeps the hot
        // bases of its delta chains.
        plaintext.max_bytes = @min(plaintext.max_bytes, @max(4 << 20, 16 * bytes.len));
        break :blk merge3.isBinary(bytes);
    };

    var walk = try engine.Walk.init(gpa, node_keys.items.len, true);
    defer walk.deinit();
    // §3.2 primary/merge parents of each node, as the engine was given them.
    const links = try arena.alloc(struct { primary: ?u32, merges: []const u32 }, node_keys.items.len);
    // Whether each node holds content: the walk's own record, except for a
    // binary target's versions, which never reach it.
    const present = try arena.alloc(bool, node_keys.items.len);
    @memset(present, false);
    // Whether this store carries a cone or horizon, asked once and only if
    // some version's content turns out to be absent.
    var has_cone: ?bool = null;
    // partial: the blob of each node absent by policy (null otherwise), and
    // whether a node's parents were read through such a gap.
    const gap = try arena.alloc(?[]const u8, node_keys.items.len);
    @memset(gap, null);
    const bridged = try arena.alloc(bool, node_keys.items.len);
    @memset(bridged, false);
    // Whether a node has a parent below the window that holds the file: its
    // lines may come from there, so the walk cannot name their origin.
    const cut = try arena.alloc(bool, node_keys.items.len);
    @memset(cut, false);
    var any_gap = false;
    for (node_keys.items, 0..) |key, i| {
        const ni: u32 = @intCast(i);
        // Bytes read for this node, freed here unless the walk takes them.
        var owned: ?[]u8 = null;
        defer if (owned) |o| gpa.free(o);
        const pn = parent_name_of.get(key).?;
        const primary_rid = repo.state().primaryParentOf(key.rid);
        const primary: ?u32 = if (primary_rid) |pr| node_of.get(.{ .rid = pr, .name = pn }) else null;
        const known: ?struct { c: ?[]const u8, hash: ?[]const u8 } = blk: {
            const t = &(touched_opt orelse break :blk null);
            if (!t.answers(key.rid, key.name)) break :blk null; // no record for it: open the manifest
            if (t.touched.get(key)) |row| {
                // Changed here: the record names the blob (a delete names none).
                const h = row.hash orelse break :blk .{ .c = null, .hash = null };
                owned = try annotateContentByHash(gpa, repo, &plaintext, h);
                break :blk .{ .c = owned, .hash = h };
            }
            // Unchanged here, and a rename is always a row, so the primary
            // parent holds the same content under the same name.
            if (pn != key.name) break :blk null;
            const pi = primary orelse break :blk null; // below the -n window
            std.debug.assert(pi < i); // parents precede children
            // Unchanged from a gap is the same absent blob. Under a binary
            // target only presence matters, and the parent's bytes are gone.
            const c: ?[]const u8 = if (!present[pi]) null else walk.content(pi) orelse "";
            break :blk .{ .c = c, .hash = gap[pi] };
        };
        var absent_hash: ?[]const u8 = null;
        const content: ?[]const u8 = if (known) |kn| blk: {
            if (kn.c == null) absent_hash = kn.hash;
            break :blk kn.c;
        } else blk: {
            owned = try annotateContentAt(gpa, arena, repo, &plaintext, &baseline, key.rid, names.get(key.name), &absent_hash);
            break :blk owned;
        };
        if (content == null) if (absent_hash) |h| {
            // PATH is in this version but its content is not here. Handing the
            // engine a null would read as "not added yet" and credit every
            // line it carried to the next version that holds content -- a
            // wrong answer reported as a success. Refuse unless the absence
            // is the store's own cut, below which the origin answers.
            // partial bridges a gap anywhere but the annotated version.
            const may_bridge = opts.partial and i != cur_index;
            switch (try absentVerdict(gpa, io, repo, &has_cone, h, may_bridge)) {
                .origin => {},
                .gap => {
                    gap[i] = h;
                    any_gap = true;
                },
                .purged, .shunned, .missing => |v| {
                    const name = try arena.dupe(u8, names.get(key.name));
                    return .{ .refused = switch (v) {
                        .purged => .{ .absent = .{ .why = .purged, .name = name, .rid = key.rid } },
                        .shunned => .{ .absent = .{ .why = .shunned, .name = name, .rid = key.rid } },
                        else => .{ .missing = .{ .name = name, .rid = key.rid } },
                    } };
                },
            }
        };
        if (content == null and crossed.contains(key)) {
            // Across a rename, an old version this store elides ends the
            // annotation with the elision message rather than handing its
            // lines to the check-in that renamed the file.
            const hash: ?[]const u8 = if (known) |kn| kn.hash else null;
            if (try elidedAt(gpa, arena, repo, hook, key.rid, names.get(key.name), hash)) |r| return .{ .refused = r };
        }

        if (window_start > 0) {
            var below = repo.state().parentsOf(key.rid);
            while (below.next()) |p| {
                const pos = index_of.get(p) orelse continue;
                if (pos >= window_start or cut[i]) continue;
                if (try cardAt(gpa, repo, p, names.get(pn), &baseline, &plaintext)) |c| {
                    Repository.freeFileCard(gpa, c);
                    cut[i] = true;
                }
            }
        }

        var merges: std.ArrayList(u32) = .empty;
        var it = repo.state().parentsOf(key.rid);
        while (it.next()) |p| {
            if (primary_rid != null and p == primary_rid.?) continue; // primary handled separately
            const idx = node_of.get(.{ .rid = p, .name = pn }) orelse continue;
            if (gap[idx] != null) {
                // A gap node already reads through to its own surviving parents.
                bridged[i] = true;
                cut[i] = cut[i] or cut[idx];
                if (links[idx].primary) |gp| try merges.append(arena, gp);
                try merges.appendSlice(arena, links[idx].merges);
            } else try merges.append(arena, idx);
        }
        var through: ?u32 = primary;
        if (primary) |pi| if (gap[pi] != null) {
            bridged[i] = true;
            cut[i] = cut[i] or cut[pi];
            through = links[pi].primary;
            try merges.appendSlice(arena, links[pi].merges);
        };
        links[i] = .{ .primary = through, .merges = merges.items };
        if (gap[i] != null) {
            // Its children read through it to these, so they live as long.
            if (through) |p| last_use[p] = @max(last_use[p], last_use[i]);
            for (merges.items) |p| last_use[p] = @max(last_use[p], last_use[i]);
        }

        present[i] = content != null;
        if (!target_binary) {
            owned = null; // the walk's now, even if it fails
            try walk.add(ni, .{ .content = content, .primary = through, .merges = merges.items });
        }
        // Every holder of a version is this node or one of its parents.
        if (last_use[i] == ni) walk.release(ni);
        if (through) |p| if (last_use[p] == ni) walk.release(p);
        for (merges.items) |p| if (last_use[p] == ni) walk.release(p);
    }

    if (!present[cur_index]) {
        // Sparse-cone RFC section 6.4 / horizon draft section 5.5 -- a file
        // the check-in names whose blob this store elides is an absence of a
        // named class, not an untracked path.
        if (try elidedAt(gpa, arena, repo, hook, cur, path, null)) |r| return .{ .refused = r };
        return .{ .refused = .not_tracked };
    }
    if (target_binary) return .{ .refused = .binary };
    const target_bytes = walk.content(cur_index).?;
    // Refuse before answering anything.
    if (merge3.isBinary(target_bytes)) return .{ .refused = .binary };

    const target = try arena.dupe(u8, target_bytes);
    const origin_nodes = walk.origins(cur_index).?;
    const lines = try merge3.splitLines(arena, target);
    std.debug.assert(lines.len == origin_nodes.len);
    const origins = try arena.alloc(Origin, lines.len);
    for (origin_nodes, origins) |node, *o| {
        const e = entries.items[node_pos.items[node]];
        o.* = .{ .rid = e.rid, .mtime = e.mtime, .approx = bridged[node] and !cut[node], .beyond = cut[node] };
    }
    return .{ .ok = .{ .target = target, .lines = lines, .origins = origins, .any_gap = any_gap, .versions = versions.items } };
}

/// Whether check-in `rid` changed the file it holds under `name` against its
/// primary parent's `parent_name`: a version, in Fossil's sense of a row in
/// its mlink table. A delete is not one; it holds no content to walk.
fn changedHere(
    gpa: std.mem.Allocator,
    repo: *Repository,
    names: *const NameTable,
    touched: ?*const AnnotateTouched,
    rid: repostate.Rid,
    name: NameId,
    parent_name: NameId,
    baseline: *?*Repository.BaselineMemo,
    plaintext: *logstore.PlaintextCache,
) ReadError!bool {
    if (touched) |t| if (t.answers(rid, name)) {
        const row = t.touched.get(.{ .rid = rid, .name = name }) orelse return false;
        return row.hash != null;
    };
    const card = (try cardAt(gpa, repo, rid, names.get(name), baseline, plaintext)) orelse return false;
    defer Repository.freeFileCard(gpa, card);
    const pr = repo.state().primaryParentOf(rid) orelse return true;
    const parent = (try cardAt(gpa, repo, pr, names.get(parent_name), baseline, plaintext)) orelse return true;
    defer Repository.freeFileCard(gpa, parent);
    return !std.mem.eql(u8, card.hash.?, parent.hash.?);
}

fn cardAt(
    gpa: std.mem.Allocator,
    repo: *Repository,
    rid: repostate.Rid,
    name: []const u8,
    baseline: *?*Repository.BaselineMemo,
    plaintext: *logstore.PlaintextCache,
) ReadError!?artifact.FCard {
    return repo.fileAtMemo(gpa, rid, name, baseline, plaintext) catch |e| switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.CannotRead => error.Storage,
        else => error.Corrupt,
    };
}

/// The committing user of check-in `rid` (its U card), on `arena`; null
/// when its manifest cannot be read or carries none.
pub fn userOf(gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid) error{OutOfMemory}!?[]const u8 {
    var om = (repo.manifest(gpa, rid) catch null) orelse return null;
    defer om.deinit(gpa);
    const u = om.manifest.u orelse return null;
    return try arena.dupe(u8, u.login);
}

const Verdict = enum {
    /// The store's own cut: the version reads as the file's origin.
    origin,
    /// Purged or shunned, and the caller reads through it.
    gap,
    purged,
    shunned,
    missing,
};

/// A version names blob `hash`, and the blob is not here. Purged or shunned
/// is a refusal (or a gap when `bridge`); with a cone or horizon the
/// absence is the store's own cut, the history below it is answered by the
/// origin, and the version reads as the file's origin; otherwise it is
/// missing.
fn absentVerdict(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    has_cone: *?bool,
    hash: []const u8,
    bridge: bool,
) error{OutOfMemory}!Verdict {
    if (thinned.contains(gpa, io, repo.root_dir, hash)) return if (bridge) .gap else .purged;
    shun: {
        var buf: [logstore.max_hash_len]u8 = undefined;
        if (hash.len != 40 and hash.len != 64) break :shun;
        const digest = std.fmt.hexToBytes(buf[0 .. hash.len / 2], hash) catch break :shun;
        const log = repo.logStore(gpa) catch break :shun;
        if (log.isShunned(digest)) return if (bridge) .gap else .shunned;
    }
    if (has_cone.* == null) {
        var cone = repocache.readStoreCone(gpa, io, repo.root_dir);
        has_cone.* = cone != null;
        if (cone) |*c| c.deinit(gpa);
    }
    if (has_cone.*.?) return .origin;
    return .missing;
}

/// Sparse-cone RFC section 6.4 / horizon draft section 5.5: `name` in
/// check-in `rid` names a blob this store elides. The refusal, or null when
/// the content is absent for any other reason. `hash` is the blob when the
/// caller already knows it, else the check-in's card is read.
fn elidedAt(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    hook: ElisionHook,
    rid: repostate.Rid,
    name: []const u8,
    hash: ?[]const u8,
) error{OutOfMemory}!?Refusal {
    var card: ?artifact.FCard = null;
    defer if (card) |c| Repository.freeFileCard(gpa, c);
    const h = hash orelse blk: {
        card = repo.fileAt(gpa, rid, name) catch null;
        break :blk (card orelse return null).hash orelse return null;
    };
    const e = hook.of(hook.ctx, gpa, h) orelse return null;
    if (!e.elided) return null;
    return .{ .elided = .{ .name = try arena.dupe(u8, name), .hash = try arena.dupe(u8, h) } };
}

/// Check-in `rid` renamed `new_name` from an old name, and a parent that
/// held the old name lies below this store's horizon: the renamed file's
/// blob, which names the cut, or null when the store has no horizon (the
/// parent is then simply missing, and the walk goes on).
fn elidedBelow(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    hook: ElisionHook,
    rid: repostate.Rid,
    new_name: []const u8,
) error{OutOfMemory}!?[]const u8 {
    const card = (repo.fileAt(gpa, rid, new_name) catch null) orelse return null;
    defer Repository.freeFileCard(gpa, card);
    const e = hook.of(hook.ctx, gpa, card.hash.?) orelse return null;
    if (!e.horizon) return null;
    return try arena.dupe(u8, card.hash.?);
}