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