Skip to content
zit Artifact [41145612ff]
Login

Artifact [41145612ff]

Artifact 41145612ff78e4d39d6a6cb0b33f26518afa7c9a467151d060f49629aa917986:


//! 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.
    /// `OutOfMemory` rides along because settling an equal-`event.mtime` tie by
    /// DAG ancestry allocates (repo-state `MtimeMaxPick`).
    pub const ResolveError = error{ NotFound, Ambiguous, OutOfMemory };

    /// 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;
        if (st.kind != .file) 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);
    }

    /// The active display-override value of tag `name` on `rid` (repo-state
    /// §5.2), or `fallback` when no override is live. A `zit amend` control
    /// artifact's singleton override lands in `tagxref` on the target rid at the
    /// next read (repo-state §4/§5), so the render sites read it here without any
    /// override-precedence logic of their own. General over the tag name — one
    /// accessor serves the comment/user/date overrides.
    pub fn override(self: *const Repository, rid: Rid, name: []const u8, fallback: []const u8) []const u8 {
        return self.view.state.activeTagValue(rid, name) orelse fallback;
    }

    /// 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 full ancestry of `rid` as a slice (§3.3, merge parents included,
    /// self-inclusive), the general-purpose query `annotate` builds its version
    /// chain over. A thin pass-through over the one shared ancestry walk — the
    /// verb owns no ancestry traversal of its own. Caller frees the slice.
    pub fn ancestorsOf(self: *const Repository, gpa: std.mem.Allocator, rid: Rid) error{OutOfMemory}![]Rid {
        return self.view.state.ancestorsOf(gpa, rid);
    }

    /// The full ancestry of `rid` (§3.3, merge parents included, self-inclusive)
    /// linearized into the annotate engine's processing order — a topological
    /// order in which every parent precedes its child (oldest first), so
    /// same-mtime and non-monotone parent/child pairs never invert. A thin
    /// pass-through over the one shared linearization; the verb owns no ancestry
    /// ordering of its own. Caller frees the slice.
    pub fn ancestryLinearized(self: *const Repository, gpa: std.mem.Allocator, rid: Rid) error{OutOfMemory}![]Rid {
        return self.view.state.ancestryLinearized(gpa, rid);
    }

    /// The bisect candidate span and next probe over full ancestry (repo-state
    /// `bisectSpan`; ambiguities.md `bisect-midpoint`): candidate set =
    /// ancestors(bad-anchor) minus every good's ancestry minus skipped; probe =
    /// lower median of untested candidates by ascending mtime, ties least hash;
    /// culprit = mtime-earliest marked-bad. Refuses `InvertedMarks` /
    /// `UnrelatedHistories`. A thin pass-through over the one general-purpose
    /// query — the bisect verb owns no ancestry walk of its own.
    pub fn bisectSpan(
        self: *const Repository,
        gpa: std.mem.Allocator,
        bad: []const Rid,
        good: []const Rid,
        skipped: []const Rid,
    ) repostate.RepoState.BisectError!repostate.RepoState.BisectOutcome {
        return self.view.state.bisectSpan(gpa, bad, good, skipped);
    }

    /// 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;
        if (st.kind != .file) 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;
    }

    /// Resolving an artifact-name prefix: the open set plus `Ambiguous` (a
    /// prefix matching more than one stored artifact — repo-state §8.3 item 8,
    /// never silently narrowed). A prefix matching nothing is `null`, not an
    /// error — the not-found family the CLI renders as a plain refusal.
    pub const ResolveArtifactError = OpenError || error{Ambiguous};

    /// Resolve a lowercase-hex artifact-name prefix (full or abbreviated) to
    /// the unique full lowercase-hex name of the stored artifact it addresses,
    /// written into `out` and returned as a sub-slice. Queries the LogStore —
    /// the one store holding EVERY stored artifact, file blobs included (the
    /// derived `repostate` interns only check-in/parent phantoms, which carry
    /// no content), so a blob prefix resolves here where a check-in-scoped name
    /// lookup would miss it. `null` = no match (not-found); `error.Ambiguous` =
    /// the §8.3-item-8 multi-match refusal. Hides the LogStore rid space from
    /// the caller, who sees only hashes. Precondition on `prefix` is the
    /// LogStore's: 4..64 lowercase-hex bytes (the CLI canonicalizes first).
    pub fn resolveArtifactPrefix(
        self: *Repository,
        gpa: std.mem.Allocator,
        prefix: []const u8,
        out: *[2 * logstore.max_hash_len]u8,
    ) ResolveArtifactError!?[]const u8 {
        const log = try self.logStore(gpa);
        const maybe_rid = log.resolveHexPrefix(prefix) catch |e| switch (e) {
            error.Ambiguous => return error.Ambiguous,
        };
        const rid = maybe_rid orelse return null;
        const raw = log.hashOf(rid);
        std.debug.assert(raw.len <= logstore.max_hash_len);
        const lower = "0123456789abcdef";
        for (raw, 0..) |b, i| {
            out[i * 2] = lower[b >> 4];
            out[i * 2 + 1] = lower[b & 0x0f];
        }
        return out[0 .. raw.len * 2];
    }

    /// A parsed manifest bundled with the uncompressed bytes its cards borrow —
    /// the parser slices its source rather than duping it, so that source must
    /// outlive the manifest. `bytes` is the reconstructed artifact (always
    /// owned); `normalized` is non-null only when the read-path legacy
    /// trailing-separator tolerance fired, in which case the manifest's cards
    /// borrow `normalized` instead of `bytes` (§4.2 read-interop). `deinit`
    /// frees all held allocations.
    pub const OwnedManifest = struct {
        bytes: []u8,
        normalized: ?[]u8 = null,
        manifest: artifact.Manifest,

        pub fn deinit(self: *OwnedManifest, gpa: std.mem.Allocator) void {
            self.manifest.deinit();
            if (self.normalized) |n| gpa.free(n);
            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.
    /// Parses through `parseForImport`, not the strict reference `parse`, so a
    /// fossil-origin check-in carrying the legacy dangling F-card separator
    /// (fossil's own 2008 cvs2fossil history) reads instead of being rejected —
    /// the same read-path interop the diff baseline already relies on. 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 im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return error.Corrupt,
        };
        return .{ .bytes = bytes, .normalized = im.normalized, .manifest = im.manifest };
    }

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

        // submanifests §5.1: a tree check-in's file set lives behind its S
        // cards, so walk it into a flat card list before §8 ever looks. §4.4
        // makes this exclusive with the B-card branch below — a manifest never
        // carries both B and S — so a check-in is walked or resolved, never both.
        try flattenIfTree(self, gpa, &target.manifest);

        // 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;
                    // submanifests §5.4: "a delta manifest MAY name a tree
                    // manifest in its B card ... the baseline cursor of the
                    // merge-walk ... is the enumeration of Section 5.1."
                    // Flattening here makes that composition free.
                    try flattenIfTree(self, gpa, &baseline.?.manifest);
                    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,
        };
    }

    /// Every failure `verifyChecksum` can surface: `files`' set plus the two
    /// gaps that only matter once the R aggregate is actually recomputed.
    /// `NoChecksum`: the check-in carries no R card, so there is nothing to
    /// verify against. `FileContentMissing`: a tracked file's blob is absent,
    /// so the §6.3.2 input cannot be formed at all.
    pub const VerifyChecksumError = FilesError || error{ NoChecksum, FileContentMissing };

    /// Recompute the R-card aggregate over the check-in's effective file set and
    /// report whether it matches the R card the manifest carries (artifact
    /// §6.3.2; submanifests §5.5). Null when the check-in's own content is
    /// missing, exactly as `files` reports it.
    ///
    /// The enumeration comes from `files`, so one path covers a flat manifest, a
    /// tree manifest (§5.5: "the R card MUST be computed over the walk
    /// enumeration of Section 5.1"), and a delta over either (§6.3.3) — §9.3's
    /// "one shared enumeration interface", not a second file-set walk that could
    /// drift from it. Contents stream through the aggregator one blob at a time,
    /// so verifying a large check-in never holds the whole tree in memory.
    pub fn verifyChecksum(self: *Repository, gpa: std.mem.Allocator, rid: Rid) VerifyChecksumError!?bool {
        var list = (try self.files(gpa, rid)) orelse return null;
        defer list.deinit(gpa);

        const r = list.target.manifest.r orelse return error.NoChecksum;

        var agg: artifact.ChecksumAggregator = .{};
        var it = list.iterator();
        while (it.next()) |f| {
            // `EffectiveFiles` already suppressed the §4.7.6 delete markers, so
            // every yielded card names stored content.
            const content = (try self.blob(gpa, f.hash.?)) orelse return error.FileContentMissing;
            defer gpa.free(content);
            agg.update(f.filename, content);
        }

        var out: [32]u8 = undefined;
        return std.mem.eql(u8, r.checksum, agg.final(&out));
    }

    /// 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 file set — its own F-card list (sorted at
/// parse time), or, for a tree manifest, the walked enumeration `flattenIfTree`
/// derived (submanifests §5.1). §5.1: downstream consumers "MUST NOT distinguish
/// a walked enumeration from a flat or delta one", which is exactly what this
/// substitution buys — the §8 machinery below never learns trees exist.
fn manifestFilesOf(m: *const artifact.Manifest) artifact.ManifestFiles {
    // submanifests §9.3 seam assertion: a tree manifest conveys its file set
    // entirely by S cards, so its `f` run is empty and a view built before the
    // walk would silently enumerate nothing — precisely the bypass §9.3 requires
    // implementations to route rather than tolerate.
    std.debug.assert(m.s.len == 0 or m.flattened != null);
    return .{
        .baseline_hash = if (m.b) |b| b.hash else null,
        .files = m.flattened orelse m.f,
    };
}

/// submanifests §5.2 resolver over the repository's artifact store: locate the
/// artifact an S card names, parse it, and hand back its view only when it
/// really is a SUBMANIFEST. Absent, unparseable, and wrong-type all collapse to
/// null — the one hard failure §5.2 defines, modelled on §8.3's B-card collapse.
///
/// Every sub-manifest parsed here is retained until `deinit`, because the walk
/// borrows its cards; `flattenTree` copies each emitted card into the manifest's
/// own arena, so this whole scratch set is freed the moment the flatten returns.
/// A genuine store fault (OOM, unreadable, corrupt bytes) is latched in `fault`
/// instead of degrading into "unresolvable", so the caller re-raises the real
/// error rather than reporting a missing sub-manifest that is in fact present.
const TreeStore = struct {
    repo: *Repository,
    gpa: std.mem.Allocator,
    scratch: std.ArrayList(Repository.OwnedManifest) = .empty,
    fault: ?Repository.OpenError = null,

    fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView {
        const self: *TreeStore = @ptrCast(@alignCast(ctx));
        if (self.fault != null) return null; // already faulted: read no further

        const bytes = (self.repo.blob(self.gpa, hash) catch |e| {
            self.fault = e;
            return null;
        }) orelse return null; // §5.2: absent

        var im = artifact.parseForImport(self.gpa, bytes) catch |e| {
            self.gpa.free(bytes);
            if (e == error.OutOfMemory) self.fault = error.OutOfMemory;
            return null; // §5.2: not a parseable artifact ⇒ unresolvable
        };
        if (im.manifest.kind != .submanifest) {
            im.deinit(self.gpa); // frees the manifest and any `normalized`
            self.gpa.free(bytes);
            return null; // §3.6: a reference to any other type fails the walk
        }

        var owned = Repository.OwnedManifest{
            .bytes = bytes,
            .normalized = im.normalized,
            .manifest = im.manifest,
        };
        self.scratch.append(self.gpa, owned) catch {
            owned.deinit(self.gpa);
            self.fault = error.OutOfMemory;
            return null;
        };
        const stored = &self.scratch.items[self.scratch.items.len - 1];
        return .{ .files = stored.manifest.f, .subs = stored.manifest.s };
    }

    fn deinit(self: *TreeStore) void {
        for (self.scratch.items) |*m| m.deinit(self.gpa);
        self.scratch.deinit(self.gpa);
        self.* = undefined;
    }
};

/// submanifests §5.1/§5.4: derive `m`'s complete file set when it is a tree
/// manifest, leaving a flat or delta manifest untouched. Runs before the §8
/// enumeration, on the manifest the façade already holds, so `enumerate` and the
/// merge walk stay byte-identical — §5.4: "the merge-walk itself is unchanged".
///
/// Every §5.1 fault becomes `BaselineUnresolvable`. §5.2 models S resolution on
/// §8.3's B resolution "under the same two disciplines" and mandates hard
/// failure without mandating a distinct code; the five-way `TreeError`
/// distinction the conformance vectors test lives at the artifact layer, where
/// it is observable. Widening this façade's error set would force every caller's
/// exhaustive switch to grow for a distinction no consumer of `files` acts on.
fn flattenIfTree(repo: *Repository, gpa: std.mem.Allocator, m: *artifact.Manifest) Repository.FilesError!void {
    if (m.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one

    var store = TreeStore{ .repo = repo, .gpa = gpa };
    defer store.deinit();
    m.flattenTree(.{ .ctx = &store, .resolveFn = TreeStore.resolve }) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => {
            if (store.fault) |f| return f; // a real store fault, not a §5.2 gap
            return error.BaselineUnresolvable; // §5.2 hard failure
        },
    };
    std.debug.assert(store.fault == null); // a latched fault always fails the walk
    std.debug.assert(m.flattened != null); // §5.2: never a partial file set
}

/// Parse `bytes` (borrowed) as the eagerly-read baseline of a delta check-in
/// (§8.3). Parses through `parseForImport`, not the strict reference `parse`, so
/// a fossil-origin baseline carrying the legacy dangling F-card separator
/// (fossil's own 2008 cvs2fossil history) is read instead of being rejected —
/// the same read-path interop the target manifest already relies on. A
/// successful parse of a check-in manifest becomes an `OwnedManifest` that takes
/// ownership of `bytes` (and the tolerance's `normalized` copy, if any); 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 im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.NotManifest,
    };
    if (im.manifest.kind != .manifest) {
        im.deinit(gpa); // frees the manifest and any `normalized`; caller frees `bytes`
        return error.NotManifest;
    }
    return .{ .bytes = bytes, .normalized = im.normalized, .manifest = im.manifest };
}

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