Skip to content
zit Artifact [9501b5da26]
Login

Artifact [9501b5da26]

Artifact 9501b5da265c1954208713cc1ce69e2470324496ab3b680aec4ba86eb7e16c75:


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

    /// 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)) 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 rewrite the
        // cache atomically (best-effort — the derived view is correct either way).
        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| {
            repocache.store(gpa, io, root_dir, key, self.view.main_branch, body.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;
    }

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

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