Skip to content
zit Artifact [e6bc360dfd]
Login

Artifact [e6bc360dfd]

Artifact e6bc360dfd8c48ec3b29e3afb3d494f122d6ad5dde05d327c88f9293b27ffe79:


//! 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`), duplicated into `arena`, or null when PATH is
/// absent there (never added, or a delta delete). Transient `gpa` allocations
/// (the F card, the verified blob) are freed before returning; only the arena
/// copy survives, owned by the caller's arena.
fn annotateContentAt(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    cache: *logstore.PlaintextCache,
    memo: *?*Repository.BaselineMemo,
    rid: repostate.Rid,
    path: []const u8,
    absent_hash: *?[]const u8,
) ReadError!?[]const 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, arena, 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, duplicated into `arena`, 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,
    arena: std.mem.Allocator,
    repo: *Repository,
    cache: *logstore.PlaintextCache,
    hash: []const u8,
) ReadError!?[]const u8 {
    const bytes = (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,
    }) orelse return null;
    defer gpa.free(bytes);
    return try arena.dupe(u8, bytes);
}

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

pub const Options = struct {
    /// Look back at most this many check-ins (`-n`): the window is the tail
    /// of the linearization, and a parent outside it reads as the origin.
    limit: ?usize = null,
    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,
};

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,

    /// 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);
    // -n N: look back N check-ins. The linearization is oldest first
    // with the target last, so the window is its tail; a parent outside it
    // reads as the file's origin.
    const ordered_rids = if (opts.limit) |n| all_rids[all_rids.len - @min(n, all_rids.len) ..] else all_rids;
    var entries: std.ArrayList(AnnotateEntry) = .empty;
    for (ordered_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);
    var k = entries.items.len;
    while (k > 0) {
        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);
            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. A
                // parent the -n window leaves out is the file's origin by
                // request.
                const held = repo.state().mtimeOf(p) != null;
                const in_window = index_of.get(p);
                if (prior != null and (!held or (in_window == null and std.mem.indexOfScalar(repostate.Rid, all_rids, p) == 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_window 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, 0..) |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));
        }
    }

    // Build the pure engine's node view: content + §3.2 primary/merge parents.
    const nodes = try arena.alloc(engine.Node, node_keys.items.len);
    // 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);
    var any_gap = false;
    for (node_keys.items, 0..) |key, i| {
        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 };
                const c = try annotateContentByHash(gpa, arena, repo, &plaintext, h);
                break :blk .{ .c = c, .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; // outside -n window
            std.debug.assert(pi < i); // parents precede children
            // Unchanged from a gap is the same absent blob.
            break :blk .{ .c = nodes[pi].content, .hash = gap[pi] };
        };
        var absent_hash: ?[]const u8 = null;
        const content = if (known) |kn| blk: {
            if (kn.c == null) absent_hash = kn.hash;
            break :blk kn.c;
        } else try annotateContentAt(gpa, arena, repo, &plaintext, &baseline, key.rid, names.get(key.name), &absent_hash);
        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 != node_of.get(.{ .rid = cur, .name = path_name }).?;
            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 };
        }

        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;
                if (nodes[idx].primary) |gp| try merges.append(arena, gp);
                try merges.appendSlice(arena, nodes[idx].merges);
            } else try merges.append(arena, idx);
        }
        var through: ?u32 = primary;
        if (primary) |pi| if (gap[pi] != null) {
            bridged[i] = true;
            through = nodes[pi].primary;
            try merges.appendSlice(arena, nodes[pi].merges);
        };
        nodes[i] = .{ .content = content, .primary = through, .merges = merges.items };
    }

    const cur_index = node_of.get(.{ .rid = cur, .name = path_name }).?;
    const target = nodes[cur_index].content orelse {
        // 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 };
    };

    // Provenance is undefined over binary content (B-annotate-9, the merge3 NUL
    // predicate) — refuse before answering anything.
    if (merge3.isBinary(target)) return .{ .refused = .binary };

    const origin_nodes = try engine.attribute(arena, nodes, 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] };
    }
    return .{ .ok = .{ .target = target, .lines = lines, .origins = origins, .any_gap = any_gap } };
}

/// 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.?);
}