Skip to content
zit Artifact [4e4ee27317]
Login

Artifact [4e4ee27317]

Artifact 4e4ee273175ac390949faff6f4b9e2e82a4ab9ce0cee05c5075282682d64f74e:


//! zit — the `Repository` facade: one deep module for opening a repository and
//! answering the derived and content questions every read verb asks.
//!
//! The north star names this the public repo surface. It folds the open
//! sequence that each read verb used to hand-wire — open `.zit`, take the O(1)
//! `.zit-cache` fast path (repo-state §1 memo) or derive the crosslinked
//! `RepoState` from the log, close the file — behind `open`, and serves the
//! derived queries (`resolve`, `branchOf`, `state`, `currentCheckoutRid`) plus
//! the lazily-opened content path (`manifest`, `blob`) that `ls`/`cat` lean on.
//!
//! Determinism seam (design-seams rule): this module touches no clock, RNG,
//! ambient cwd, or network. Discovery of the checkout root — which reads the
//! process cwd — stays in the CLI; `open` operates on an already-discovered
//! `root_dir` and the injected `std.Io`. The log is reopened lazily only when a
//! content request arrives, so view-only verbs keep the O(1) cache fast path.
//!
//! Pinning: `state` (inside the loaded `RepoView`) borrows the view's own
//! `MemStore`, and a lazily-opened `LogStore` borrows this struct's inline
//! `FileStorage`. `open` therefore fills the caller's `Repository` in place
//! (`self: *Repository`); a filled `Repository` must not be moved.

const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const logstore = @import("logstore.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
const repoview = @import("repoview.zig");
const repoimage = @import("repoimage.zig");
const repocache = @import("repocache.zig");

/// The repository file, beside `.zit-checkout` in the checkout root. Shared with
/// the CLI (re-exported from root.zig) so the one name has a single home.
pub const repo_filename: []const u8 = ".zit";

/// The lazily-opened content channel: the reopened `.zit` handle, the
/// `FileStorage` binding over it (pinned — the `LogStore` borrows it), and the
/// scanned read-only log. Absent until the first `manifest`/`blob`/`logStore`.
const Content = struct {
    file: std.Io.File,
    fs: storage.FileStorage,
    log: logstore.LogStore,
};

pub const Repository = struct {
    io: std.Io,
    /// The checkout root, discovered and owned by the caller (the caller closes
    /// it after `deinit`). Borrowed here to reopen `.zit` for lazy content.
    root_dir: std.Io.Dir,
    /// The crosslinked derived view, loaded in place by `open`. `state` borrows
    /// `&view.store`, so this whole struct is caller-pinned.
    view: repoview.RepoView,
    /// The content channel, opened lazily on the first content request. Held
    /// inline (pinned) because the `LogStore` borrows `&content.?.fs`.
    content: ?Content,

    /// The derived-query identity space: a `RepoState` rid (the view's store).
    pub const Rid = repostate.Rid;

    /// Options for `open`. `bypass_cache` skips the O(1) `.zit-cache` fast path
    /// and forces the authoritative full `openReadOnly` scan + derive (the
    /// `--no-cache` escape hatch); the derived view still rewrites a fresh cache
    /// afterward.
    pub const OpenOptions = struct {
        bypass_cache: bool = false,
        /// Which cache representation to serve. Defaults to `.full` (derive the
        /// complete `RepoState` — every query answerable), so every existing
        /// caller keeps today's behavior. The hot read verbs (`leaves` and the
        /// mtime-ordered listings) pass `.fast_read` to take the zero-parse
        /// image path, which answers their freed queries without re-deriving;
        /// the cold verbs (`info` ancestry, `branch`, `tag`, `ls`, `cat`) stay
        /// `.full` because they read adjacency and the full tag tables.
        mode: repocache.LoadMode = .full,
    };

    /// Every failure the content-and-open surface can surface, as a single
    /// closed set the CLI switches exhaustively. `NotARepo`: no `.zit` under the
    /// given root. `CannotRead`: an I/O error opening/reading it. `Corrupt`: a
    /// structurally invalid log or a stored artifact whose content does not
    /// match its name (artifact §7.2). `OutOfMemory`: allocation failure.
    pub const OpenError = error{ NotARepo, CannotRead, Corrupt, OutOfMemory };

    /// Name resolution failure (repo-state §8.3), forwarded unchanged from
    /// `RepoState.resolve` so the CLI keeps one exhaustive switch.
    pub const ResolveError = error{ NotFound, Ambiguous };

    /// Discoverless open: `root_dir` is the already-found checkout root. Opens
    /// `.zit`, serves the view from the `.zit-cache` fast path or derives it
    /// from the log, then closes the file (the view is self-contained). On
    /// success the caller owns the value and must `deinit` it; on any error
    /// nothing is retained and `deinit` must not be called.
    pub fn open(
        self: *Repository,
        gpa: std.mem.Allocator,
        io: std.Io,
        root_dir: std.Io.Dir,
        opts: OpenOptions,
    ) OpenError!void {
        self.io = io;
        self.root_dir = root_dir;
        self.content = null;

        var file = root_dir.openFile(io, repo_filename, .{}) catch |e| return openFileError(e);
        defer file.close(io);
        const st = file.stat(io) catch return error.CannotRead;
        var fs = storage.FileStorage.init(io, file, st.size);

        // Fast path (repo-state §1 memo): a `.zit-cache` still valid against the
        // live log's tail serves the whole view in O(1), no full scan. A miss
        // is never an error — fall through to the authoritative derive.
        if (!opts.bypass_cache and repocache.tryLoad(gpa, io, root_dir, fs.storageFile(), &self.view, opts.mode)) return;

        var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return openReadOnlyError(e);
        defer log.deinit(gpa);

        // Derive from the log, capturing the crosslink records, then materialize
        // the zero-parse image from the fresh derived state and rewrite the
        // cache atomically (best-effort — the derived view is correct either
        // way; a failed image serialize just stores an empty image section, so
        // the next `.fast_read` falls back to a derive rather than erroring).
        var body: std.ArrayList(u8) = .empty;
        defer body.deinit(gpa);
        self.view.loadCapturing(gpa, &log, &body) catch |e| return loadError(e);
        if (log.logKey()) |key| {
            var image: std.ArrayList(u8) = .empty;
            defer image.deinit(gpa);
            repoimage.serialize(gpa, &self.view.state, self.view.main_branch, &image) catch image.clearRetainingCapacity();
            repocache.store(gpa, io, root_dir, key, self.view.main_branch, body.items, image.items);
        } else |_| {}
    }

    /// Frees the derived view and closes the lazy content channel if it was
    /// opened. Does NOT close `root_dir` — the caller that discovered it owns
    /// its lifetime. Call only on a `Repository` that `open` returned `ok`.
    pub fn deinit(self: *Repository, gpa: std.mem.Allocator) void {
        if (self.content) |*c| {
            c.log.deinit(gpa);
            c.file.close(self.io);
        }
        self.view.deinit(gpa);
        self.* = undefined;
    }

    /// Eagerly materialize the `.zit-cache` for the repository at `root_dir` so
    /// the first read verb after a fresh clone takes the zero-parse image fast
    /// path (repo-state §7.1 precomputed leaf table) instead of paying the full
    /// cold derive. It runs `open`'s own read-miss path — derive from the log,
    /// serialize the image, store the cache — purely for that documented side
    /// effect, then discards the view; there is no new derivation path, so
    /// nothing is added to the derivation oracle DST already proves.
    ///
    /// Best-effort by construction (repo-state §1 memo / §7.3 rebuild fallback):
    /// a `.zit-cache` is disposable — deleting it never changes an answer — so
    /// any failure to open, derive, or write is swallowed. A caller that cannot
    /// warm the cache (unreadable `.zit`, OOM, a read-only directory) is left
    /// exactly as before: correct, with the first read deriving lazily. Warming a
    /// repository that already has a valid cache is a cheap hit-and-discard.
    pub fn warmCache(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir) void {
        var repo: Repository = undefined;
        // The miss path inside `open` writes the cache (image + records); on any
        // error nothing is retained, so there is nothing to deinit.
        repo.open(gpa, io, root_dir, .{}) catch return;
        repo.deinit(gpa);
    }

    /// The derived `RepoState` — the leaf table, branch/tag derivations, event
    /// times, and ancestry (repo-state §6-9). The view already hides the derive;
    /// exposing the query surface directly keeps this facade from becoming a
    /// shallow pass-through over a dozen `RepoState` methods.
    pub fn state(self: *const Repository) *const repostate.RepoState {
        return &self.view.state;
    }

    /// The repo-state §6.1 default-branch name (config `main-branch` when set,
    /// else `"trunk"`) — the fallback `branchOf` threads.
    pub fn mainBranch(self: *const Repository) []const u8 {
        return self.view.main_branch;
    }

    /// The lowercase-hex artifact name of a derived rid — what a verb prints and
    /// compares against the current checkout.
    pub fn hashOf(self: *Repository, rid: Rid) []const u8 {
        return self.view.hashOf(rid);
    }

    /// The derived rid for a lowercase-hex artifact name, or null when the name
    /// was never interned in this view.
    pub fn lookup(self: *Repository, hash: []const u8) ?Rid {
        return self.view.lookup(hash);
    }

    /// Resolve a symbolic name / id / prefix to a rid via the repo-state §8.3
    /// procedure (recency-based, §8.2 — closed-ness and leaf-ness never filter
    /// the candidate). Errors forwarded unchanged so the CLI switches once.
    pub fn resolve(self: *const Repository, name: []const u8, want: repostate.RepoState.NameType) ResolveError!Rid {
        return self.view.state.resolve(name, want);
    }

    /// The active-tag branch of `rid` (repo-state §6.1), config `main-branch` as
    /// the fallback. Folds the fallback so callers never pass it — precondition:
    /// `rid` bears an event (`state.mtimeOf(rid) != null`).
    pub fn branchOf(self: *const Repository, rid: Rid) []const u8 {
        return self.view.state.branchOf(rid, self.view.main_branch);
    }

    /// Merge base of `a` and `b` over full ancestry: the common ancestor with the
    /// greatest event mtime, ties broken by the least artifact hash, or null when
    /// they share no ancestor (repo-state `mergeBaseOf`; ambiguities.md
    /// `merge-base`). A thin pass-through over the one general-purpose query.
    pub fn mergeBaseOf(self: *const Repository, gpa: std.mem.Allocator, a: Rid, b: Rid) error{OutOfMemory}!?Rid {
        return self.view.state.mergeBaseOf(gpa, a, b);
    }

    /// Full-ancestry membership: whether `ancestor` is in the full ancestry of
    /// `descendant` (§3.3, merge parents included, self-inclusive). mtime-
    /// independent, unlike `mergeBaseOf` — the authoritative up-to-date test the
    /// `merge` verb keys the B-merge-16 refusal on. A thin pass-through.
    pub fn isAncestorOf(self: *const Repository, gpa: std.mem.Allocator, ancestor: Rid, descendant: Rid) error{OutOfMemory}!bool {
        return self.view.state.isAncestorOf(gpa, ancestor, descendant);
    }

    /// The current checkout's crosslinked rid, or null when there is nothing to
    /// mark: no `.zit-checkout`, an unborn checkout, a version this view never
    /// interned, or an interned-but-eventless phantom (repo-state §7.5). The
    /// `mtimeOf != null` guard is the postcondition `branchOf`'s callers lean on
    /// — a returned rid always bears an event.
    pub fn currentCheckoutRid(self: *Repository) ?Rid {
        var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        const ck = (checkout.read(self.io, self.root_dir, &ck_buf) catch return null) orelse return null;
        if (ck.version.len == 0) return null;
        const rid = self.view.lookup(ck.version) orelse return null;
        if (self.view.state.mtimeOf(rid) == null) return null;
        return rid;
    }

    /// The lazily-opened read-only log for content reads. Reopens `.zit` (closed
    /// after the view derive) on first use and memoizes it for the repository's
    /// lifetime; subsequent calls return the same store.
    pub fn logStore(self: *Repository, gpa: std.mem.Allocator) OpenError!*logstore.LogStore {
        if (self.content) |*c| return &c.log;

        var file = self.root_dir.openFile(self.io, repo_filename, .{}) catch |e| return openFileError(e);
        errdefer file.close(self.io);
        const st = file.stat(self.io) catch return error.CannotRead;
        self.content = .{
            .file = file,
            .fs = storage.FileStorage.init(self.io, file, st.size),
            .log = undefined,
        };
        self.content.?.log = logstore.LogStore.openReadOnly(gpa, self.content.?.fs.storageFile()) catch |e| {
            self.content = null; // discard the half-built channel; errdefer closes `file`
            return openReadOnlyError(e);
        };
        return &self.content.?.log;
    }

    /// The uncompressed content of the artifact named `name` (a lowercase-hex
    /// id), or null when no artifact of that name is stored. The bytes are
    /// verified to hash to `name` (artifact §7.2, hash-over-uncompressed) — a
    /// content/name mismatch or an undecodable frame is `Corrupt`. Caller frees
    /// the returned slice.
    pub fn blob(self: *Repository, gpa: std.mem.Allocator, name: []const u8) OpenError!?[]u8 {
        var digest_buf: [logstore.max_hash_len]u8 = undefined;
        const digest = decodeName(name, &digest_buf) orelse return null; // undecodable ⇒ never stored
        const log = try self.logStore(gpa);
        const rid = log.lookup(digest) orelse return null;
        const bytes = (log.content(gpa, rid) catch |e| return contentError(e)) orelse return null;
        errdefer gpa.free(bytes);
        if (!hashMatches(digest, bytes)) return error.Corrupt; // §7.2
        return bytes;
    }

    /// A parsed manifest bundled with the uncompressed bytes its cards borrow —
    /// `artifact.parse` slices the source rather than duping it, so the bytes
    /// must outlive the manifest. `deinit` frees both.
    pub const OwnedManifest = struct {
        bytes: []u8,
        manifest: artifact.Manifest,

        pub fn deinit(self: *OwnedManifest, gpa: std.mem.Allocator) void {
            self.manifest.deinit();
            gpa.free(self.bytes);
            self.* = undefined;
        }
    };

    /// The parsed manifest of the check-in `rid`, or null when its content is
    /// missing. `Corrupt` when the stored bytes are not a parseable artifact.
    /// Caller `deinit`s the returned `OwnedManifest`.
    pub fn manifest(self: *Repository, gpa: std.mem.Allocator, rid: Rid) OpenError!?OwnedManifest {
        const bytes = (try self.blob(gpa, self.view.hashOf(rid))) orelse return null;
        errdefer gpa.free(bytes);
        const m = artifact.parse(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return error.Corrupt,
        };
        return .{ .bytes = bytes, .manifest = m };
    }

    /// Every failure `files` can surface: the open/content set plus the three
    /// §8 enumeration faults. `NotManifest`: the resolved artifact is not a
    /// check-in manifest (artifact §5.1). `BaselineUnresolvable` (§8.3): a delta
    /// check-in whose B-card baseline cannot be located, is not a manifest, or
    /// is unparseable — the hard-failure discipline §8.3 mandates for the
    /// file-enumeration entry points. `BaselineIsDelta` (§4.7.3): the resolved
    /// baseline is itself a delta manifest (delta-of-delta, rejected).
    pub const FilesError = OpenError || error{ NotManifest, BaselineUnresolvable, BaselineIsDelta };

    /// The effective file set of one check-in, retaining every manifest whose
    /// F cards the walk borrows. A baseline check-in keeps only its own manifest
    /// (`baseline` null); a delta check-in additionally keeps the eagerly-read
    /// baseline, since the §8.2 merge walk borrows both card lists. Build it with
    /// `files`, read it with `iterator`, free it with `deinit`.
    pub const FileList = struct {
        target: OwnedManifest,
        baseline: ?OwnedManifest = null,
        /// The two pre-sorted (§8.1) F-card lists the §8.2 merge walk consumes:
        /// `base_files` is the baseline's set (the target's own set for a
        /// baseline check-in), `delta_files` the delta's authored cards (empty
        /// for a baseline check-in). Both borrow the retained manifests above,
        /// so they stay valid exactly as long as this `FileList`.
        base_files: []const artifact.FCard,
        delta_files: []const artifact.FCard,

        pub fn deinit(self: *FileList, gpa: std.mem.Allocator) void {
            if (self.baseline) |*b| b.deinit(gpa);
            self.target.deinit(gpa);
            self.* = undefined;
        }

        /// A fresh §8.2 merge walk over the retained card lists — allocation-free
        /// and repeatable, so a caller may enumerate more than once.
        pub fn iterator(self: *const FileList) EffectiveFiles {
            return .{ .walk = .{ .baseline = self.base_files, .delta = self.delta_files } };
        }
    };

    /// A §8.2 merge walk narrowed to the effective set: `next` yields each merged
    /// F card in canonical filename order (§4.5.3), suppressing the delete markers
    /// the raw walk emits verbatim (§8.2 step 3 / tail, §4.7.6) so every yielded
    /// card carries a hash.
    pub const EffectiveFiles = struct {
        walk: artifact.MergeWalk,

        pub fn next(self: *EffectiveFiles) ?artifact.FCard {
            while (self.walk.next()) |f| {
                if (f.hash != null) return f; // §4.7.6: a hash-less card is a delete marker
            }
            return null;
        }
    };

    /// The effective file set of the check-in `rid` (artifact §8.2): its own F
    /// cards for a baseline manifest, or the §8.2 merge of the eagerly-resolved
    /// baseline with the delta's cards for a delta manifest. Returns null when the
    /// check-in's own content is missing (an interned phantom). The §8.3/§4.7.3
    /// baseline policy stays in `artifact.enumerate`: this pre-reads only the one
    /// baseline the B card names and hands `enumerate` a resolver over it. Caller
    /// `deinit`s the returned `FileList`.
    pub fn files(self: *Repository, gpa: std.mem.Allocator, rid: Rid) FilesError!?FileList {
        var target = (try self.manifest(gpa, rid)) orelse return null;
        errdefer target.deinit(gpa);
        if (target.manifest.kind != .manifest) return error.NotManifest; // §5.1

        // Eagerly resolve the one baseline the B card names (a delta manifest
        // only). A located, parseable check-in manifest becomes the resolver's
        // view; anything else leaves it null, so `enumerate` raises
        // BaselineUnresolvable under the §8.3 hard-failure discipline.
        var baseline: ?OwnedManifest = null;
        errdefer if (baseline) |*b| b.deinit(gpa);
        var view: ?artifact.ManifestFiles = null;
        if (target.manifest.b) |b| {
            if (try self.blob(gpa, b.hash)) |bytes| {
                if (parseBaseline(gpa, bytes)) |bm| {
                    baseline = bm;
                    view = manifestFilesOf(&baseline.?.manifest);
                } else |e| switch (e) {
                    error.OutOfMemory => {
                        gpa.free(bytes);
                        return error.OutOfMemory;
                    },
                    error.NotManifest => gpa.free(bytes), // §8.3: not a manifest ⇒ unresolvable
                }
            }
        }

        var eager = EagerBaseline{
            .hash = if (target.manifest.b) |b| b.hash else null,
            .view = view,
        };
        const walk = artifact.enumerate(manifestFilesOf(&target.manifest), .{
            .ctx = &eager,
            .resolveFn = EagerBaseline.resolve,
        }) catch |e| switch (e) {
            error.BaselineUnresolvable => return error.BaselineUnresolvable, // §8.3
            error.BaselineIsDelta => return error.BaselineIsDelta, // §4.7.3
        };

        // Postcondition: a delta check-in retained its baseline (else `enumerate`
        // would have raised BaselineUnresolvable above); a baseline check-in did
        // not read one.
        std.debug.assert((target.manifest.b != null) == (baseline != null));
        return FileList{
            .target = target,
            .baseline = baseline,
            .base_files = walk.baseline,
            .delta_files = walk.delta,
        };
    }

    /// The effective F card for `name` in the check-in `rid` (artifact §8.4
    /// single-name lookup), or null when `name` is not in the effective set —
    /// either never present, or a delta delete marker (§4.7.6) suppresses it.
    /// Exact-name only: §8.4's best-match (closest-name) form is deliberately
    /// not offered, so it is never a candidate to suppress here.
    ///
    /// §8.4's discipline — search the delta's own cards first, a delta delete
    /// marker reports not-found without consulting the baseline, and the
    /// baseline is searched only when the name is absent from the delta — is
    /// exactly the §8.2 merge the `files` walk already performs. This narrows
    /// that walk to one name rather than re-deriving the rules, so the §8.3/
    /// §4.7.3 baseline faults surface unchanged. The returned card's slices are
    /// duplicated into `gpa` (independent of the walk's scratch, which is freed
    /// before returning); release it with `freeFileCard`.
    pub fn fileAt(self: *Repository, gpa: std.mem.Allocator, rid: Rid, name: []const u8) FilesError!?artifact.FCard {
        var list = (try self.files(gpa, rid)) orelse return null;
        defer list.deinit(gpa);
        var it = list.iterator();
        while (it.next()) |f| {
            std.debug.assert(f.hash != null); // §4.7.6: the walk suppresses delete markers
            if (std.mem.eql(u8, f.filename, name)) return try dupFileCard(gpa, f);
        }
        return null;
    }

    /// Release a card returned by `fileAt` — its present slices were each
    /// duplicated into `gpa`. A no-op-safe companion: every field that was
    /// null in the source stays null and is skipped.
    pub fn freeFileCard(gpa: std.mem.Allocator, f: artifact.FCard) void {
        gpa.free(f.filename);
        if (f.hash) |h| gpa.free(h);
        if (f.perm) |p| gpa.free(p);
        if (f.old_name) |o| gpa.free(o);
    }
};

/// Duplicate every present slice of `f` into `gpa`, yielding a card whose bytes
/// outlive the merge walk's scratch. Partial-failure-safe: an OOM after any dup
/// frees the slices already taken (`errdefer`), so no leak escapes. `mode` is a
/// value, copied for free. Freed by `Repository.freeFileCard`.
fn dupFileCard(gpa: std.mem.Allocator, f: artifact.FCard) error{OutOfMemory}!artifact.FCard {
    const filename = try gpa.dupe(u8, f.filename);
    errdefer gpa.free(filename);
    const hash = if (f.hash) |h| try gpa.dupe(u8, h) else null;
    errdefer if (hash) |h| gpa.free(h);
    const perm = if (f.perm) |p| try gpa.dupe(u8, p) else null;
    errdefer if (perm) |p| gpa.free(p);
    const old_name = if (f.old_name) |o| try gpa.dupe(u8, o) else null;
    return .{ .filename = filename, .hash = hash, .perm = perm, .old_name = old_name, .mode = f.mode };
}

/// The §8.2 `ManifestFiles` view of a parsed manifest: its B-card hash (null for
/// a baseline manifest, §8-p1) and its own F-card list (sorted at parse time).
fn manifestFilesOf(m: *const artifact.Manifest) artifact.ManifestFiles {
    return .{
        .baseline_hash = if (m.b) |b| b.hash else null,
        .files = m.f,
    };
}

/// Parse `bytes` (borrowed) as the eagerly-read baseline of a delta check-in
/// (§8.3). A successful parse of a check-in manifest becomes an `OwnedManifest`
/// that takes ownership of `bytes`; a parse OOM propagates; any other outcome —
/// a syntactically invalid artifact or one that is not a manifest — is
/// `NotManifest`, the §8.3 "located artifact is not a manifest" case (the caller
/// frees `bytes` and lets `enumerate` raise `BaselineUnresolvable`).
fn parseBaseline(gpa: std.mem.Allocator, bytes: []u8) error{ OutOfMemory, NotManifest }!Repository.OwnedManifest {
    var m = artifact.parse(gpa, bytes) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.NotManifest,
    };
    if (m.kind != .manifest) {
        m.deinit();
        return error.NotManifest;
    }
    return .{ .bytes = bytes, .manifest = m };
}

/// The §8.3 baseline resolver for `files`: the baseline was already located and
/// parsed (eagerly, exactly once), so `resolve` just hands back the pre-built
/// view — null when the baseline was unresolvable, which `enumerate` turns into
/// `BaselineUnresolvable`. `enumerate` calls it only for a delta manifest, always
/// with that manifest's single B-card hash — asserted here as negative space.
const EagerBaseline = struct {
    hash: ?[]const u8,
    view: ?artifact.ManifestFiles,

    fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
        const self: *EagerBaseline = @ptrCast(@alignCast(ctx));
        std.debug.assert(self.hash != null and std.mem.eql(u8, hash, self.hash.?));
        return self.view;
    }
};

/// Maps a `root_dir.openFile` failure: a missing `.zit` is "not a repository",
/// every other open error is an I/O read failure.
fn openFileError(e: anyerror) Repository.OpenError {
    return switch (e) {
        error.FileNotFound => error.NotARepo,
        error.OutOfMemory => error.OutOfMemory,
        else => error.CannotRead,
    };
}

/// Maps `LogStore.openReadOnly`'s closed error set into the facade's set.
fn openReadOnlyError(e: logstore.OpenError) Repository.OpenError {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Corrupt => error.Corrupt,
        error.Storage => error.CannotRead,
    };
}

/// Maps `RepoView.load`'s closed error set into the facade's set.
fn loadError(e: repoview.RepoView.LoadError) Repository.OpenError {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Corrupt => error.Corrupt,
        error.Storage => error.CannotRead,
    };
}

/// Maps `LogStore.content`'s closed error set into the facade's set.
fn contentError(e: logstore.ContentError) Repository.OpenError {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Corrupt => error.Corrupt,
        error.Storage => error.CannotRead,
    };
}

/// Decode a lowercase-hex artifact name to its raw digest into `out`. Returns
/// null for anything that cannot be a stored name — an odd length, a length
/// past `max_hash_len*2`, or a non-hex digit — so the caller treats it as "not
/// present" rather than trapping on wire input.
fn decodeName(name: []const u8, out: *[logstore.max_hash_len]u8) ?[]const u8 {
    if (name.len == 0 or name.len % 2 != 0) return null;
    const bytes = name.len / 2;
    if (bytes > logstore.max_hash_len) return null;
    _ = std.fmt.hexToBytes(out[0..bytes], name) catch return null;
    return out[0..bytes];
}

/// Whether `content` hashes to `digest` under the algorithm the digest length
/// selects (artifact §7.2: 20 bytes ⇒ sha1, 32 bytes ⇒ sha3-256). A digest of
/// any other length is not a name this build mints, so it cannot match.
fn hashMatches(digest: []const u8, content: []const u8) bool {
    switch (digest.len) {
        20 => {
            var out: [20]u8 = undefined;
            std.crypto.hash.Sha1.hash(content, &out, .{});
            return std.mem.eql(u8, &out, digest);
        },
        32 => {
            var out: [32]u8 = undefined;
            std.crypto.hash.sha3.Sha3_256.hash(content, &out, .{});
            return std.mem.eql(u8, &out, digest);
        },
        else => return false,
    }
}