//! zit — the shared repo-view loader (a `zit` library module, re-exported from
//! root.zig and used internally by `Repository`).
//!
//! It bridges the persisted `LogStore` (bytes) to the derived `RepoState`
//! (branches, leaves, tags, current-trunk) that the query verbs — `leaves`
//! first, then `branch`/`tag`/`info` — read through the `Repository` facade.
//! One `load`, one `deinit`: a caller builds the whole crosslinked view in a
//! single call and asks its questions of `RepoState` directly, never
//! re-deriving.
//!
//! Determinism seam: this module touches no clock, RNG, filesystem, or network.
//! It operates on an already-open `LogStore` (whose I/O capability was injected
//! at construction), so the effectful file handling stays in the CLI layer.
const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const repoimage = @import("repoimage.zig");
const logstore = @import("logstore.zig");
const repository = @import("repository.zig");
const crc32 = @import("crc32.zig");
/// Lowercase-hex identity space. The `RepoState`/`MemStore` identity for a
/// check-in is its 40/64-char artifact name — the lowercase hex of the log's
/// binary digest. P-card parents and T-card targets are already that hex on the
/// wire, so encoding `LogStore.hashOf` to hex puts every reference in one space.
const hex_digits = "0123456789abcdef";
/// The RFC repo-state §6.1 default-branch fallback: `main-branch` is used only
/// when the config setting is unset. Never the branch of a check-in that
/// carries its own active branch tag.
const default_branch: []const u8 = "trunk";
/// Evidence seam: every artifact-parse attempt the frame walk makes
/// (`linkOne`'s `parseForImport`) increments this. The checkpoint tests reset
/// it and assert a resumed derivation performs ZERO parses for frames below
/// the resume offset — a counter is evidence where wall time is only
/// suggestion (and the Task-11 benchmark gates on the same number). The derive
/// is single-threaded, like everything else in this module.
pub var parsed_artifacts: u64 = 0;
/// The disposition sidecar: one bit per log frame — is this frame's
/// artifact CROSSLINK-RELEVANT (`repostate.sweepDisposition(kind) ==
/// .crosslink`, i.e. a check-in manifest or a control artifact)? A full derive
/// reconstructs every frame's plaintext only to discard the ~80% that cannot
/// contribute a record — file blobs, and on a wide store the SUBMANIFEST tree
/// nodes; consulting the map skips that reconstruction outright. Advisory, in
/// the log-scan index member's trust tier: self-checksummed, keyed to a
/// validated log prefix, and stamped with the shun fingerprint it was
/// distilled under — a frame masked by a shun records bit 0, so lifting the
/// shun MUST discard the map or a revived check-in would silently vanish
/// from the derived view. Any validation failure reads as absent; the full
/// derive is always the fallback and always rebuilds the map.
pub const Dispositions = struct {
/// Frames with rid <= n_rids are covered; later frames take the full path.
n_rids: u64,
/// One bit per covered rid, LSB-first within each byte; borrows the blob.
bits: []const u8,
pub fn covers(self: *const Dispositions, rid: logstore.Rid) bool {
return rid >= 1 and rid <= self.n_rids;
}
pub fn isRelevant(self: *const Dispositions, rid: logstore.Rid) bool {
std.debug.assert(self.covers(rid));
const i: usize = @intCast(rid - 1);
return (self.bits[i / 8] >> @intCast(i % 8)) & 1 == 1;
}
};
/// The bits a full derive distills, to be serialized and stored beside the
/// cache. Owned by the caller; `n_rids` is the rid space at derive time.
pub const BuiltDispositions = struct {
n_rids: u64,
bits: []u8,
fn set(self: *BuiltDispositions, rid: logstore.Rid) void {
std.debug.assert(rid >= 1 and rid <= self.n_rids);
const i: usize = @intCast(rid - 1);
self.bits[i / 8] |= @as(u8, 1) << @intCast(i % 8);
}
pub fn deinit(self: *BuiltDispositions, gpa: std.mem.Allocator) void {
gpa.free(self.bits);
self.* = undefined;
}
};
const dispositions_magic = "zitD";
const dispositions_version: u16 = 1;
/// magic(4) + version(2) + end(8) + frame_count(8) + last_frame_offset(8) +
/// last_header_crc(4) + shun_fp(4) + n_rids(8).
const dispositions_header_len: usize = 46;
/// Serialize a freshly built map for storage. The key is the LIVE log's, so
/// the map claims exactly the prefix the derive just walked. Caller frees.
pub fn serializeDispositions(
gpa: std.mem.Allocator,
log: *const logstore.LogStore,
built: *const BuiltDispositions,
) error{OutOfMemory}!?[]u8 {
const key = log.logKey() catch return null;
const blob = try gpa.alloc(u8, dispositions_header_len + built.bits.len + 4);
errdefer gpa.free(blob);
@memcpy(blob[0..4], dispositions_magic);
std.mem.writeInt(u16, blob[4..6], dispositions_version, .little);
std.mem.writeInt(u64, blob[6..14], key.end, .little);
std.mem.writeInt(u64, blob[14..22], key.frame_count, .little);
std.mem.writeInt(u64, blob[22..30], key.last_frame_offset, .little);
std.mem.writeInt(u32, blob[30..34], key.last_header_crc, .little);
std.mem.writeInt(u32, blob[34..38], log.shunFingerprint(), .little);
std.mem.writeInt(u64, blob[38..46], built.n_rids, .little);
@memcpy(blob[dispositions_header_len..][0..built.bits.len], built.bits);
var crc = crc32.Crc32.init();
crc.update(blob[0 .. blob.len - 4]);
std.mem.writeInt(u32, blob[blob.len - 4 ..][0..4], crc.final(), .little);
return blob;
}
/// Validate a loaded dispositions blob against the LIVE log, or null — a
/// failed magic, version, checksum, prefix key, shun fingerprint, or bound
/// all read as absent, never as an error. The returned view borrows `blob`.
pub fn parseDispositions(blob: []const u8, log: *const logstore.LogStore) ?Dispositions {
if (blob.len < dispositions_header_len + 4) return null;
if (!std.mem.eql(u8, blob[0..4], dispositions_magic)) return null;
if (std.mem.readInt(u16, blob[4..6], .little) != dispositions_version) return null;
var crc = crc32.Crc32.init();
crc.update(blob[0 .. blob.len - 4]);
if (std.mem.readInt(u32, blob[blob.len - 4 ..][0..4], .little) != crc.final()) return null;
const key = logstore.LogStore.LogKey{
.end = std.mem.readInt(u64, blob[6..14], .little),
.frame_count = std.mem.readInt(u64, blob[14..22], .little),
.last_frame_offset = std.mem.readInt(u64, blob[22..30], .little),
.last_header_crc = std.mem.readInt(u32, blob[30..34], .little),
};
if (!logstore.LogStore.validatePrefix(log.file, key)) return null;
if (std.mem.readInt(u32, blob[34..38], .little) != log.shunFingerprint()) return null;
const n_rids = std.mem.readInt(u64, blob[38..46], .little);
if (n_rids > key.frame_count) return null;
const bits = blob[dispositions_header_len .. blob.len - 4];
if (bits.len != (n_rids + 7) / 8) return null;
return .{ .n_rids = n_rids, .bits = bits };
}
/// Evidence seam: what the derive's captured record body is MADE of,
/// counted at the one place records are encoded, plus the sink's peak
/// capacity (an ArrayList grows by doubling, so the transient can be ~1.5x the
/// final length). `ZIT_DERIVE_FOOTPRINT` prints these at exit; a rung of the
/// evidence ladder is bought or declined on these numbers, never on a guess.
/// Single-threaded like every counter in this module.
pub var derive_records: u64 = 0;
pub var derive_body_peak_capacity: u64 = 0;
pub var derive_hash_bytes: u64 = 0;
pub var derive_parent_bytes: u64 = 0;
pub var derive_tag_bytes: u64 = 0;
pub var derive_comment_bytes: u64 = 0;
pub var derive_user_bytes: u64 = 0;
pub var derive_path_bytes: u64 = 0;
pub var derive_filehash_bytes: u64 = 0;
/// A fully crosslinked view of a repository, ready for the derived queries of
/// repo-state §6-9. Pinned in place by the caller: `state` holds an
/// `ArtifactStore` that borrows `&self.store`, so the value must not be moved
/// after `load`.
pub const RepoView = struct {
/// Identity space only: interned hashes (hex names), no content bytes. Leaf
/// derivation reads no content, so the store stays a pure hash↔rid map.
store: repostate.MemStore,
/// The derived state every query reads: leaf table, branch tags, closed
/// markers, event times. Borrows `&self.store`.
state: repostate.RepoState,
/// The live config frame, retained so `main_branch` can borrow its bytes.
/// null when the log carries no config (repo-state §6.1: caller owns the
/// default).
owned_config: ?logstore.OwnedConfig,
/// The repo-state §6.1 default-branch name: the config `main-branch` value
/// when set and non-empty, else `"trunk"`. Threaded into `branchOf`; never a
/// hardcoded literal in the presence of a setting.
main_branch: []const u8,
/// Set only on the cache-hit path (`buildFromRecordBody`), where there is no
/// `owned_config` to borrow the name from: an owned copy of `main_branch`
/// that `deinit` frees. Null on the normal load path, where `main_branch`
/// borrows `owned_config` (or is the `"trunk"` literal).
owned_main_branch: ?[]u8 = null,
/// Set only on the zero-parse image path (`buildFromImage`): the owned cache
/// buffer whose image section `state`'s columns view. Freed by `deinit`
/// after `state` (which borrows into it) is torn down. Null on every derive
/// path, where the state owns its own hashmaps.
image_bytes: ?[]u8 = null,
/// Set by the derive when it rejects an artifact; read only after a failed
/// `load`/`open`. `Repository.open` clears it before every attempt, because
/// a `Repository` is declared `undefined` and this field would otherwise
/// carry whatever was on the stack.
fault: ?Fault = null,
/// Where a capturing derive hands each check-in's change set, inverted by
/// path, as it links the check-in — fossil's mlink, written at ingest. The
/// caller that owns the index builder sets this before the derive and
/// stores the result beside the checkpoint; null means the derive keeps
/// no index. Set by `Repository.open` on every attempt, like `fault`,
/// because the view starts as undefined memory.
finfo_sink: ?ChangeSetSink = null,
/// Every failure `load` can surface: allocation, a storage device error, or
/// a structurally corrupt check-in (a manifest/control with no parseable
/// timestamp). A closed set so the CLI can switch exhaustively.
pub const LoadError = error{ OutOfMemory, Storage, Corrupt };
/// WHICH artifact the derive rejected, and why.
///
/// `error.Corrupt` alone says a repository cannot be read; it does not say
/// what in it is unreadable, and the walk that discovers this runs over
/// every artifact in the store — minutes on a large one. Without the name,
/// the only way to find the offending artifact is to bisect the store or
/// attach a debugger, neither of which is available to whoever is holding
/// the broken repository.
///
/// Plain data with no allocation, so it survives the `errdefer` that tears
/// down a partially built view: the caller reads it after `open` has already
/// failed, at which point nothing else on the view is valid.
pub const Fault = struct {
/// Lowercase-hex artifact name, as `hexName` renders it.
name: [64]u8 = [_]u8{0} ** 64,
name_len: u8 = 0,
reason: Reason,
pub const Reason = enum {
/// A manifest or control artifact with no D card at all.
missing_timestamp,
/// A D card whose timestamp `parseMtime` refuses.
unparsable_timestamp,
};
pub fn artifactName(self: *const Fault) []const u8 {
return self.name[0..self.name_len];
}
};
/// Every failure `buildFromRecordBody` can surface: allocation, or a record
/// body whose framing is malformed (a length that runs past the buffer, a
/// bad tag/kind byte). A malformed body is `Corrupt` so the cache layer
/// treats it as a miss and rebuilds — never a trusted partial view.
pub const BuildError = error{ OutOfMemory, Corrupt };
/// Crosslink every persisted check-in and control artifact into a derived
/// `RepoState`. `self` is initialized in place; `log` is borrowed for the
/// duration and still owned by the caller. Transactional: on success the
/// caller owns the view and must `deinit` it; on any error `load` tears down
/// everything it built, so the caller neither can nor should `deinit`.
///
/// Sequence: read the config (resolving `main_branch`), then walk the frames
/// in persisted order. A frame whose bytes are not a structural artifact (a
/// file blob) is skipped; a check-in manifest is crosslinked with its
/// parents and tags; a control artifact's tags are ingested; every other
/// artifact kind has no effect on the check-in DAG.
pub fn load(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore) LoadError!void {
return self.loadInner(gpa, log, null, null, null, null, null);
}
/// Identical to `load`, but also serializes every crosslink input record
/// (in persisted order) into `sink` — the exact byte body a `.zit-cache`
/// stores. Replaying that body through `buildFromRecordBody` reconstructs a
/// byte-identical view without touching the log, which is what makes a cache
/// hit provably equal to a from-scratch derivation: the same records feed
/// the same `crosslink`/`ingestControlTags` calls in the same order. `sink`
/// is caller-owned; on failure the view tears itself down and `sink` (which
/// may hold a partial body) is the caller's to discard.
pub fn loadCapturing(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: *std.ArrayList(u8)) LoadError!void {
return self.loadInner(gpa, log, sink, null, null, null, null);
}
/// `loadCapturing` with the disposition sidecar: frames a valid
/// `consume` map marks irrelevant are skipped without reconstruction, and
/// when `build` is non-null the walk distills a fresh full-coverage map
/// into it for the caller to store. The captured body and derived state
/// are byte-identical with or without a map — the map only removes work,
/// pinned by test.
pub fn loadCapturingDispositions(
self: *RepoView,
gpa: std.mem.Allocator,
log: *logstore.LogStore,
sink: *std.ArrayList(u8),
consume: ?*const Dispositions,
build: ?*?BuiltDispositions,
/// Where each check-in's change set goes, inverted by path, as it is
/// linked — the file index a capturing derive builds; null keeps none.
finfo: ?ChangeSetSink,
) LoadError!void {
return self.loadInner(gpa, log, sink, null, consume, build, finfo);
}
/// `loadCapturing` restricted to the log PREFIX strictly below
/// `end_offset` (a frame boundary the caller proved with
/// `logstore.logKeyAt`): the checkpoint builder captures the prefix
/// record body this way, and `resumeFrom` later replays exactly these
/// bytes. The view it builds answers only for the prefix — callers wanting
/// the whole log use `loadCapturing`.
pub fn loadCapturingPrefix(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: *std.ArrayList(u8), end_offset: u64) LoadError!void {
return self.loadInner(gpa, log, sink, end_offset, null, null, null);
}
/// Sets §6.1's main-branch name on the view AND on the state that answers
/// branch queries. One setter because they are one fact: `branchOf` reads
/// the view's copy through its parameter while `branchValueOf` reads the
/// state's field, and a check-in with no active branch tag resolves through
/// whichever one the caller happened to take. Assigning them separately is
/// how they drift, and drift here means the display and the §7.2 leaf test
/// disagree about which branch an untagged check-in is on.
fn setMainBranch(self: *RepoView, name: []const u8) void {
std.debug.assert(name.len != 0);
self.main_branch = name;
self.state.main_branch = name;
}
fn loadInner(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: ?*std.ArrayList(u8), below: ?u64, consume: ?*const Dispositions, build: ?*?BuiltDispositions, finfo: ?ChangeSetSink) LoadError!void {
self.store = repostate.MemStore.init(gpa);
self.state = repostate.RepoState.init(gpa, self.store.store());
self.owned_config = null;
self.owned_main_branch = null;
self.image_bytes = null;
// Set here, by the load itself, from an explicit argument: the view
// starts as undefined memory, and a field read in `link` that no load
// path wrote would be whatever was on the stack. Held for the load
// only: the builder behind it is the caller's and dies with the call,
// so the view must not carry the pointer out.
self.finfo_sink = finfo;
defer self.finfo_sink = null;
self.setMainBranch(default_branch);
// Self-clean on failure: a partially built view frees itself so the
// caller's contract is "deinit only a view that loaded".
errdefer {
if (self.owned_config) |*oc| oc.deinit(gpa);
self.state.deinit();
self.store.deinit();
}
self.owned_config = try log.readConfig(gpa);
if (self.owned_config) |oc| {
// An empty stored value is treated as unset (defensive: commit
// always writes a real name), so `main_branch` is never empty and
// `branchOf`'s `len != 0` precondition always holds.
if (oc.config.default_branch.len != 0) self.setMainBranch(oc.config.default_branch);
}
std.debug.assert(self.main_branch.len != 0);
const rids = try log.ridsInPersistedOrder(gpa);
defer gpa.free(rids);
// A bounded plaintext memo for this one sweep: bases persist strictly
// before their deltas, so resolving rids in persisted order keeps each
// base hot and collapses delta-chain read-amplification to ~1x. Costs
// nothing on an all-raw log (raw content is never cached).
var cache = logstore.PlaintextCache.init();
defer cache.deinit(gpa);
var memo: ListMemo = .{};
defer memo.deinit(gpa);
// A fresh full-coverage map to distill while walking. Sized
// to the rid space; bits start 0, so a skip records exactly what the
// consumed map already proved.
var built: ?BuiltDispositions = null;
errdefer if (built) |*b| b.deinit(gpa);
if (build != null) {
var max_rid: logstore.Rid = 0;
for (rids) |rid| max_rid = @max(max_rid, rid);
const bits = try gpa.alloc(u8, (@as(usize, @intCast(max_rid)) + 7) / 8);
@memset(bits, 0);
built = .{ .n_rids = max_rid, .bits = bits };
}
// Bounded by the persisted artifact count; each rid is visited once.
for (rids) |rid| {
if (below) |b| {
if (log.offsetOf(rid) >= b) continue;
}
// The sidecar's whole point: a frame a valid map marks
// irrelevant is skipped without reconstructing its plaintext.
if (consume) |d| {
if (d.covers(rid) and !d.isRelevant(rid)) continue;
}
const relevant = try self.linkOne(gpa, log, rid, &cache, &memo, sink);
if (relevant) {
if (built) |*b| b.set(rid);
}
}
if (build) |out| {
out.* = built;
built = null;
}
}
/// Fold the log's TAIL into an already-loaded view, capturing the new
/// records into `sink` exactly as `loadCapturing` would have. `self` must
/// hold a view built from a record body that covers every frame strictly
/// below `from_offset` -- the caller proves that with
/// `logstore.validatePrefix` before calling. Rids are visited in persisted
/// order, so the fold sees the tail exactly as a from-scratch derive would
/// have seen it after the prefix, and the combined body (old ++ sink)
/// replays to the same state a cold load over the whole log produces.
///
/// NOT valid across a shun-table change: the prefix body was distilled
/// under the OLD mask, and a record it carries may now be one the view
/// must read as absent. The caller compares shun fingerprints and takes
/// the full derive on any difference.
pub fn extendCapturing(
self: *RepoView,
gpa: std.mem.Allocator,
log: *logstore.LogStore,
from_offset: u64,
sink: *std.ArrayList(u8),
/// The file index to extend over the same tail, or null to keep none.
finfo: ?ChangeSetSink,
) LoadError!void {
self.finfo_sink = finfo;
defer self.finfo_sink = null;
const rids = try log.ridsInPersistedOrder(gpa);
defer gpa.free(rids);
var cache = logstore.PlaintextCache.init();
defer cache.deinit(gpa);
var memo: ListMemo = .{};
defer memo.deinit(gpa);
for (rids) |rid| {
if (log.offsetOf(rid) < from_offset) continue;
_ = try self.linkOne(gpa, log, rid, &cache, &memo, sink);
}
}
/// Build this view by replaying a VALIDATED prefix record body (the bytes
/// `buildFromRecordBody` accepts) and then continuing the ordinary frame
/// walk from `from_offset`, capturing the COMBINED record body into `sink`
/// (prefix body first, then the resumed walk's records in persisted
/// order). The result must be indistinguishable from `loadCapturing` over
/// the same log — same view, same captured body — so a `.zit-cache`
/// written from `sink` is byte-identical to one written by a full derive.
///
/// The caller owns proving that `from_offset` really is the boundary the
/// body was captured at (`logstore.validatePrefix`) and that the shun
/// table has not moved since capture (fingerprint compare) —
/// checkpoint.zig does both. The LIVE log's config outranks whatever the
/// prefix knew: a config frame in the tail renames the main branch here
/// exactly as it would on a cold load. Transactional like `load`: on any
/// error the view has torn itself down and only `sink` (possibly partial)
/// is the caller's to discard.
pub fn resumeFrom(
self: *RepoView,
gpa: std.mem.Allocator,
log: *logstore.LogStore,
prefix_body: []const u8,
from_offset: u64,
sink: *std.ArrayList(u8),
) LoadError!void {
var oc = try log.readConfig(gpa);
const main_branch = if (oc) |c|
(if (c.config.default_branch.len != 0) c.config.default_branch else default_branch)
else
default_branch;
self.buildFromRecordBody(gpa, prefix_body, main_branch) catch |e| {
if (oc) |*c| c.deinit(gpa);
return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.Corrupt => error.Corrupt,
};
};
// The view owns the config from here (deinit frees it), matching a
// cold load's bookkeeping — so every later failure tears down whole.
self.owned_config = oc;
sink.appendSlice(gpa, prefix_body) catch {
self.deinit(gpa);
return error.OutOfMemory;
};
self.extendCapturing(gpa, log, from_offset, sink, null) catch |e| {
self.deinit(gpa);
return e;
};
}
/// Reconstruct a view from a `.zit-cache` record body (as produced by
/// `loadCapturing`) plus the resolved `main_branch`, WITHOUT reading the
/// log. Replays each record in stored order through the same
/// `crosslink`/`ingestControlTags` procedure the live load uses, so the
/// resulting `RepoState`/`MemStore` are byte-identical to a from-scratch
/// derivation over the same log prefix. `body` and `main_branch` borrow the
/// caller's cache buffer only for the duration of the call (every hash, tag
/// name, and value is duped into the store), so the buffer may be freed on
/// return. Transactional like `load`: on any error the view is torn down.
pub fn buildFromRecordBody(self: *RepoView, gpa: std.mem.Allocator, body: []const u8, main_branch: []const u8) BuildError!void {
std.debug.assert(main_branch.len != 0);
self.store = repostate.MemStore.init(gpa);
self.state = repostate.RepoState.init(gpa, self.store.store());
self.owned_config = null;
self.finfo_sink = null;
self.owned_main_branch = null;
self.image_bytes = null;
self.setMainBranch(default_branch);
errdefer {
if (self.owned_main_branch) |b| gpa.free(b);
self.state.deinit();
self.store.deinit();
}
self.owned_main_branch = try gpa.dupe(u8, main_branch);
self.setMainBranch(self.owned_main_branch.?);
var r: RecordReader = .{ .body = body };
// Bounded: each record consumes at least one byte, so the loop runs at
// most `body.len` times and every decode strictly advances `pos`.
while (!r.atEnd()) {
const before = r.pos;
try self.replayRecord(gpa, &r);
std.debug.assert(r.pos > before);
}
}
/// Reconstruct a view from a `.zit-cache` image section (as produced by
/// `repoimage.serialize`) with ZERO derivation. On success `self` takes
/// ownership of `buf` (the whole cache buffer) — the loaded image's columns
/// are offset windows into `image`, a sub-slice of `buf`, so the buffer must
/// outlive the view. A malformed image is `Corrupt`, which the cache layer
/// treats as a miss and rebuilds; on any error `self` is torn down and `buf`
/// is NOT taken (the caller frees it). Transactional like `buildFromRecordBody`.
pub fn buildFromImage(self: *RepoView, gpa: std.mem.Allocator, buf: []u8, image: []const u8, main_branch: []const u8) BuildError!void {
std.debug.assert(main_branch.len != 0);
const loaded = (repoimage.Image.load(gpa, image) catch return error.OutOfMemory) orelse return error.Corrupt;
self.store = repostate.MemStore.init(gpa);
self.finfo_sink = null;
// The state carries the trap store, not `self.store`: image
// backing answers identity through the doors, and any raw read panics.
self.state = repostate.RepoState.fromImage(gpa, loaded); // state.img now owns `loaded`
self.owned_config = null;
self.owned_main_branch = null;
self.image_bytes = null;
self.setMainBranch(default_branch);
errdefer {
self.state.deinit(); // frees the image's owned leaf-rid slice
self.store.deinit();
}
self.owned_main_branch = try gpa.dupe(u8, main_branch);
self.setMainBranch(self.owned_main_branch.?);
self.image_bytes = buf; // ownership transferred; freed in deinit after state
}
/// Frees the config frame and the derived state. Call only on a view that
/// `load` returned successfully — a failed `load` already tore itself down.
/// `state` borrows `store` (and, in the image backing, borrows `image_bytes`),
/// so it is torn down first, then the owned cache buffer.
pub fn deinit(self: *RepoView, gpa: std.mem.Allocator) void {
if (self.owned_config) |*oc| oc.deinit(gpa);
if (self.owned_main_branch) |b| gpa.free(b);
self.state.deinit();
self.store.deinit();
if (self.image_bytes) |b| gpa.free(b);
self.* = undefined;
}
/// The lowercase-hex artifact name of `rid` in this view's identity space —
/// what a verb prints and compares against the current checkout. `rid` is a
/// `RepoState` rid (the `MemStore` space), not a `LogStore` rid.
pub fn hashOf(self: *RepoView, rid: repostate.Rid) []const u8 {
if (self.state.img) |*img| return img.hashOf(rid);
return self.store.store().hashOf(rid);
}
/// The `RepoState` rid for a lowercase-hex artifact name, or null when the
/// name was never interned in this view. A verb resolves the current
/// checkout's version this way to mark the corresponding leaf (§7.5).
pub fn lookup(self: *RepoView, hash: []const u8) ?repostate.Rid {
if (self.state.img) |*img| return img.lookup(hash);
return self.store.store().lookup(hash);
}
/// Crosslink or ingest one persisted frame. A parse failure means the bytes
/// are not a structural artifact (a file blob) — skipped, not an error,
/// matching the "crosslink whatever parses" model. Allocation failure
/// propagates.
/// Returns true when the frame's artifact is crosslink-relevant — the
/// disposition the sidecar records. A masked (shunned/phantom)
/// frame reads false; the map that stores it is stamped with the shun
/// fingerprint, so a lifted shun discards the map rather than the record.
fn linkOne(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, cache: *logstore.PlaintextCache, memo: *ListMemo, sink: ?*std.ArrayList(u8)) LoadError!bool {
// Null ⇒ nothing to crosslink: a phantom (no frame) or an artifact in the
// repository shun table (frame on disk, masked by `contentCached`). The
// second is deliberate and consequential — a shunned check-in manifest
// contributes no timeline event, no leaf and no tag, i.e. it leaves the
// derived view exactly as if the repository never held it, which is what
// "reads as ABSENT" has to mean for a view.
// A parent already parsed for a child that arrived first is not parsed
// twice. This skips the `contentCached` call above, which is
// what masks shunned and phantom artifacts — safe only because nothing
// enters the memo except through that same call, so a masked artifact
// is never resident to be found here.
{
var hex_buf: [64]u8 = undefined;
if (memo.cachedMut(hexName(log.hashOf(rid), &hex_buf))) |m| {
parsed_artifacts += 1; // the walk reached this frame all the same
std.debug.assert(repostate.sweepDisposition(m.kind) == .crosslink);
try self.link(gpa, log, rid, m, cache, memo, sink);
return true;
}
}
const bytes = (try log.contentCached(gpa, rid, cache)) orelse return false;
defer gpa.free(bytes);
// Read path: tolerate the legacy F-card trailing-separator quirk in
// fossil-origin history (artifact.parseForImport), never on the writer.
parsed_artifacts += 1; // evidence seam: a resumed derive must not reach here for prefix frames
var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return false, // not a structural artifact — a file blob, skipped
};
defer im.deinit(gpa);
const m = &im.manifest;
// Only check-in manifests and control artifacts affect the check-in DAG
// and its tags; clusters, wiki, tickets, technotes, and attachments do
// not.
//
// draft-fossil-submanifests-00 §9.2/§7.3 — the admission rule is stated
// ONCE, over the artifact type, in `repostate.sweepDisposition`. §7.3: a
// SUBMANIFEST "has no direct crosslink effect ... it carries no
// state-bearing card and contributes to repository state only through
// the manifests whose trees contain it". Reading the rule from there
// rather than restating it here is what §9.2 is asking for: this sweep
// covers every persisted frame, and a hand-written condition is exactly
// where a new type silently acquires the wrong disposition. The switch
// is exhaustive, so adding an artifact type cannot compile without
// deciding this.
if (repostate.sweepDisposition(m.kind) != .crosslink) return false;
try self.link(gpa, log, rid, m, cache, memo, sink);
return true;
}
/// Record which artifact the derive is rejecting, then fail. Returns the
/// error so callers read `return self.faulted(...)` — the name is captured
/// on the same line that gives up, which is the only way it stays accurate
/// as new rejection sites are added.
fn faulted(self: *RepoView, hash: []const u8, reason: Fault.Reason) error{Corrupt} {
var f: Fault = .{ .reason = reason };
const n = @min(hash.len, f.name.len);
@memcpy(f.name[0..n], hash[0..n]);
f.name_len = @intCast(n);
self.fault = f;
return error.Corrupt;
}
/// Feed one manifest or control artifact into `RepoState`. Both carry a hash,
/// an event timestamp, and tag cards; a manifest additionally carries its
/// parent references (crosslinked into the DAG), while a control's hash gains
/// no event — only its tags are ingested (repo-state §4.2, §7).
fn link(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, m: *artifact.Manifest, cache: *logstore.PlaintextCache, memo: *ListMemo, sink: ?*std.ArrayList(u8)) LoadError!void {
var hex_buf: [64]u8 = undefined;
const hash = hexName(log.hashOf(rid), &hex_buf);
// A check-in or control artifact without a parseable D-card timestamp is
// structurally corrupt — no silent skip of a malformed check-in.
const d = m.d orelse return self.faulted(hash, .missing_timestamp);
const mtime = repostate.parseMtime(d.timestamp) orelse
return self.faulted(hash, .unparsable_timestamp);
var tags: std.ArrayList(repostate.TagOp) = .empty;
defer tags.deinit(gpa);
// Bounded by the manifest's own T-card count.
for (m.t) |tc| try tags.append(gpa, tagOpOf(tc));
switch (m.kind) {
.manifest => {
var parents: std.ArrayList([]const u8) = .empty;
defer parents.deinit(gpa);
// Bounded by the manifest's own P-card token count; each `next`
// strictly shrinks the iterator.
for (m.p) |pc| {
var it = pc.hashes();
while (it.next()) |parent| try parents.append(gpa, parent);
}
const comment: []const u8 = if (m.c) |c| c.comment else "";
var picks: std.ArrayList(repostate.Pick) = .empty;
defer picks.deinit(gpa);
try picksOf(gpa, m, &picks);
if (sink != null or self.finfo_sink != null) {
var changed: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &changed);
// fossil's mlink, at ingest: a check-in's change set against
// its primary parent is computed here, once, as the check-in
// is derived, and kept — in the record body and, inverted by
// path, in the file index. A tree diffs by S card, so this
// costs the derive about what changed rather than what
// exists; a tree that cannot be resolved stays unknown. A
// plain non-capturing derive computes nothing here.
const known = try captureChangeSet(gpa, log, cache, memo, m, parents.items, &changed);
if (sink) |s| {
const user: []const u8 = if (m.u) |u| u.login else "";
try encodeRecord(gpa, s, .manifest, hash, mtime, parents.items, tags.items, comment, user, picks.items, if (known) changed.items else null);
}
if (self.finfo_sink) |fs| try fs.emit(fs.ctx, rid, if (known) changed.items else null);
}
_ = try self.state.crosslink(.{
.hash = hash,
.mtime = mtime,
.parents = parents.items,
.tags = tags.items,
.comment = comment,
.user = if (m.u) |u| u.login else "",
.picks = picks.items,
});
},
.control => {
if (sink) |s| try encodeRecord(gpa, s, .control, hash, mtime, &[_][]const u8{}, tags.items, "", "", &.{}, null);
_ = try self.state.ingestControlTags(hash, mtime, tags.items);
},
else => unreachable, // linkOne admits only manifest/control
}
}
/// Decode and replay the next record at `r`, driving the same derivation the
/// live path drives. A manifest record crosslinks; a control record ingests
/// tags. Every wire length is validated against the body bound before it is
/// used to slice (TigerStyle: a claimed-huge length is `Corrupt`, never an
/// out-of-bounds read), and the temporary parent/tag slices borrow the body,
/// which the crosslink step dupes.
fn replayRecord(self: *RepoView, gpa: std.mem.Allocator, r: *RecordReader) BuildError!void {
const kind_byte = try r.byte();
const hash = try r.lenPrefixed(u16);
if (hash.len == 0) return error.Corrupt; // a record hash is never empty
const mtime = try r.int(u64);
const parent_count = try r.int(u32);
var parents: std.ArrayList([]const u8) = .empty;
defer parents.deinit(gpa);
// Bounded: `parent_count` slices are each ≥ 2 body bytes, so the reader
// exhausts the buffer before an oversized count can over-allocate.
var pi: u32 = 0;
while (pi < parent_count) : (pi += 1) {
const p = try r.lenPrefixed(u16);
if (p.len == 0) return error.Corrupt;
try parents.append(gpa, p);
}
const tag_count = try r.int(u32);
var tags: std.ArrayList(repostate.TagOp) = .empty;
defer tags.deinit(gpa);
var ti: u32 = 0;
while (ti < tag_count) : (ti += 1) {
const tt: repostate.TagType = switch (try r.byte()) {
0 => .cancel,
1 => .singleton,
2 => .propagate,
else => return error.Corrupt,
};
const name = try r.lenPrefixed(u16);
if (name.len == 0) return error.Corrupt;
const target: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u16) else null;
const value: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u32) else null;
try tags.append(gpa, .{ .type = tt, .name = name, .target = target, .value = value });
}
switch (kind_byte) {
0 => {
const comment = try r.lenPrefixed(u32);
// The record tail: the user the state now carries (listings render
// it without re-reading a manifest), then the changed-file
// set, which the state does not consume -- leaf/branch/tag
// derivation reads no file data -- but which the replay walks
// and VALIDATES, or a corrupt tail would slice out of the
// record. Kept in lockstep with `encodeRecord` and
// `RecordIter`.
const user = try r.lenPrefixed(u16);
var picks: std.ArrayList(repostate.Pick) = .empty;
defer picks.deinit(gpa);
try readPicks(gpa, r, &picks);
switch (try r.byte()) {
0 => {
const n = try r.int(u32);
var fi: u32 = 0;
while (fi < n) : (fi += 1) {
const deleted = switch (try r.byte()) {
'A', 'M' => false,
'D' => true,
else => return error.Corrupt,
};
if ((try r.lenPrefixed(u16)).len == 0) return error.Corrupt; // path
if (((try r.lenPrefixed(u16)).len == 0) != deleted) return error.Corrupt; // hash
}
},
1 => {},
else => return error.Corrupt,
}
_ = self.state.crosslink(.{ .hash = hash, .mtime = mtime, .parents = parents.items, .tags = tags.items, .comment = comment, .user = user, .picks = picks.items }) catch |e| return e;
},
1 => _ = self.state.ingestControlTags(hash, mtime, tags.items) catch |e| return e,
else => return error.Corrupt,
}
}
};
/// The kind byte a record carries: 0 = a check-in manifest (crosslinked with
/// its parents), 1 = a control artifact (tags only). Matches `artifact.Kind`'s
/// two structural cases the loader admits.
pub const RecordKind = enum(u8) { manifest = 0, control = 1 };
/// Append the crosslink input records for every artifact whose frame lies in
/// `[from_offset, below_offset)` to `sink`, WITHOUT deriving any state: the
/// record stream is a pure function of the artifacts in persisted order (the
/// same extraction `link` performs, minus the crosslink), so the emitted
/// bytes are identical to what `loadCapturingPrefix` would have captured over
/// the same span — which is what lets a checkpoint ADVANCE by appending a
/// tail capture to its stored body instead of re-deriving the whole prefix.
/// The §9.2 disposition rule is read from `repostate.sweepDisposition`, never
/// restated. A structurally corrupt check-in (missing/unparsable timestamp)
/// is `Corrupt`, exactly as the deriving walk treats it.
pub fn captureRecords(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
from_offset: u64,
below_offset: u64,
sink: *std.ArrayList(u8),
) RepoView.LoadError!void {
std.debug.assert(from_offset <= below_offset);
const rids = try log.ridsInPersistedOrder(gpa);
defer gpa.free(rids);
var cache = logstore.PlaintextCache.init();
defer cache.deinit(gpa);
var memo: ListMemo = .{};
defer memo.deinit(gpa);
for (rids) |rid| {
const off = log.offsetOf(rid);
if (off < from_offset or off >= below_offset) continue;
var hex_buf: [64]u8 = undefined;
const hash = hexName(log.hashOf(rid), &hex_buf);
var im: artifact.ImportedManifest = undefined;
var have_im = false;
defer if (have_im) im.deinit(gpa);
var bytes_owned: ?[]u8 = null; // the parse may borrow them: freed after the record
defer if (bytes_owned) |b| gpa.free(b);
const m: *artifact.Manifest = blk: {
// A parent parsed for its child is not parsed again.
if (memo.cachedMut(hash)) |mm| {
parsed_artifacts += 1;
break :blk mm;
}
const bytes = (try log.contentCached(gpa, rid, &cache)) orelse continue;
bytes_owned = bytes;
parsed_artifacts += 1; // the same evidence seam the deriving walk counts through
im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => continue, // not a structural artifact — a file blob, skipped
};
have_im = true;
break :blk &im.manifest;
};
if (repostate.sweepDisposition(m.kind) != .crosslink) continue;
const d = m.d orelse return error.Corrupt; // the deriving walk faults here too
const mtime = repostate.parseMtime(d.timestamp) orelse return error.Corrupt;
var tags: std.ArrayList(repostate.TagOp) = .empty;
defer tags.deinit(gpa);
for (m.t) |tc| try tags.append(gpa, tagOpOf(tc));
switch (m.kind) {
.manifest => {
var parents: std.ArrayList([]const u8) = .empty;
defer parents.deinit(gpa);
for (m.p) |pc| {
var it = pc.hashes();
while (it.next()) |parent| try parents.append(gpa, parent);
}
const comment: []const u8 = if (m.c) |c| c.comment else "";
const user: []const u8 = if (m.u) |u| u.login else "";
var changed: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &changed);
const known = try captureChangeSet(gpa, log, &cache, &memo, m, parents.items, &changed);
var picks: std.ArrayList(repostate.Pick) = .empty;
defer picks.deinit(gpa);
try picksOf(gpa, m, &picks);
try encodeRecord(gpa, sink, .manifest, hash, mtime, parents.items, tags.items, comment, user, picks.items, if (known) changed.items else null);
},
.control => try encodeRecord(gpa, sink, .control, hash, mtime, &[_][]const u8{}, tags.items, "", "", &.{}, null),
else => unreachable, // sweepDisposition admits only these two to crosslink
}
}
}
/// Return the byte-prefix of `body` holding exactly the records for artifacts
/// whose frames lie below `below_offset` — records are emitted in persisted
/// order, so a whole-log capture's prefix IS the prefix capture (the split
/// property `repocache`'s differential test proves). Walks record framing
/// only, never parses artifacts; null when the body is corrupt or names an
/// unknown artifact — the caller treats that as "no prefix available".
pub fn recordPrefixBelow(log: *logstore.LogStore, body: []const u8, below_offset: u64) ?[]const u8 {
var r = RecordReader{ .body = body };
var raw: [logstore.max_hash_len]u8 = undefined;
while (!r.atEnd()) {
const start = r.pos;
const kind_byte = r.byte() catch return null;
const hash = r.lenPrefixed(u16) catch return null;
if (hash.len == 0 or hash.len % 2 != 0 or hash.len / 2 > raw.len) return null;
_ = r.int(u64) catch return null; // mtime
const parent_count = r.int(u32) catch return null;
var pi: u32 = 0;
while (pi < parent_count) : (pi += 1) _ = r.lenPrefixed(u16) catch return null;
const tag_count = r.int(u32) catch return null;
var ti: u32 = 0;
while (ti < tag_count) : (ti += 1) {
_ = r.byte() catch return null; // type
_ = r.lenPrefixed(u16) catch return null; // name
if ((r.byte() catch return null) != 0) _ = r.lenPrefixed(u16) catch return null;
if ((r.byte() catch return null) != 0) _ = r.lenPrefixed(u32) catch return null;
}
switch (kind_byte) {
0 => {
_ = r.lenPrefixed(u32) catch return null; // comment
_ = r.lenPrefixed(u16) catch return null; // user
const n_picks = r.int(u16) catch return null; // Q cards
var qi: u16 = 0;
while (qi < n_picks) : (qi += 1) {
_ = r.byte() catch return null; // sign
_ = r.lenPrefixed(u16) catch return null; // target
}
switch (r.byte() catch return null) {
0 => {
const n = r.int(u32) catch return null;
var fi: u32 = 0;
while (fi < n) : (fi += 1) {
_ = r.byte() catch return null; // disposition
_ = r.lenPrefixed(u16) catch return null; // path
_ = r.lenPrefixed(u16) catch return null; // hash
}
},
1 => {},
else => return null,
}
},
1 => {},
else => return null,
}
const raw_hash = raw[0 .. hash.len / 2];
_ = std.fmt.hexToBytes(raw_hash, hash) catch return null;
const rid = log.lookup(raw_hash) orelse return null;
if (log.offsetOf(rid) >= below_offset) return body[0..start];
}
return body; // every record lies below the boundary
}
/// One entry of a check-in's changed-file set: what this check-in did
/// to one path relative to its PRIMARY parent. A rename records as a delete
/// of the old path plus an add of the new one.
pub const FileDisp = enum(u8) { added = 'A', modified = 'M', deleted = 'D' };
/// One changed path and, unless deleted, the artifact hash the check-in gives
/// it -- Fossil's `mlink` row. A reader that wants one file's
/// content in one check-in reads the blob by this hash and never opens the
/// manifest.
pub const ChangedFile = struct { disp: FileDisp, path: []const u8, hash: ?[]const u8 };
/// A parsed manifest the diff step keeps warm: the bytes it borrows and the
/// parse, heap-allocated so a pointer to it survives other insertions.
const ParsedEntry = struct {
bytes: []u8,
im: artifact.ImportedManifest,
tick: u64,
/// What this entry costs against the byte bound: its raw bytes plus, for
/// a flattened tree, the file set the flatten copied into the
/// manifest's arena — which for a wide tree dwarfs the manifest itself.
charge: usize,
fn destroy(self: *ParsedEntry, gpa: std.mem.Allocator) void {
self.im.deinit(gpa);
gpa.free(self.bytes);
gpa.destroy(self);
}
};
/// The diff step's working set: a bounded cache of PARSED
/// manifests keyed by hex hash — the baseline every delta in a run shares,
/// and the parent a child arrives before (a clone's persisted order is
/// newest-first, so the parent is usually still unwalked when its child is
/// diffed; the walk then takes it from here instead of parsing it again).
/// Fossil keeps six parsed manifests warm for the same reason. Both bounds
/// are stated (TigerStyle): entries, and the bytes they borrow — a baseline
/// of a large tree runs to megabytes, and evicting one to admit its own
/// deltas is the thrash this cache exists to prevent. Least recently used
/// out; whichever bound binds first evicts.
const ListMemo = struct {
parsed: std.StringArrayHashMapUnmanaged(*ParsedEntry) = .empty,
bytes: usize = 0,
tick: u64 = 0,
/// Entries touched after this tick belong to the capture step now running
/// and are NEVER evicted. One step holds live pointers into a parent, a
/// child and their baselines at the same time, and evicting any of them
/// under it would leave a merge-walk reading freed memory — reachable
/// through the byte bound alone, since one oversized baseline can ask for
/// the whole budget. Raised at the start of every step, so at most the
/// four manifests a step touches can sit above the bound, and only until
/// that step ends.
floor: u64 = 0,
/// The sub-manifest node memo shared by every tree this memo
/// flattens. A tree shares nearly all its nodes with its parent's, so one
/// memo across a run of check-ins is what keeps flattening at §7.4's
/// distinct-artifact cost rather than the naive per-check-in one.
tree_memo: ?repostate.SubManifestMemo = null,
const max_parsed: usize = 64;
/// The bound on resident entries' `charge`. Two things sit above it by
/// design: a tree is flattened before the eviction loop admits
/// it, so one wide tree's file set is resident over the bound until the
/// loop runs — the same headroom a pinned step already needs — and
/// `tree_memo` keeps its own `submanifest_memo_budget` of nodes beside
/// this. The working set is therefore this bound plus that budget plus
/// one tree, not this bound alone.
const max_bytes: usize = 256 << 20;
fn deinit(self: *ListMemo, gpa: std.mem.Allocator) void {
for (self.parsed.keys()) |k| gpa.free(k);
for (self.parsed.values()) |e| e.destroy(gpa);
self.parsed.deinit(gpa);
if (self.tree_memo) |*t| t.deinit();
}
/// Derive a tree manifest's complete file set in place, through
/// the same seam the Repository facade uses, so the diff machinery below
/// sees a flat list and never learns trees exist (`manifestFilesOf`). A
/// flat or delta manifest is untouched. A tree that cannot be flattened
/// — a sub-manifest absent, shunned or corrupt — is left with `flattened`
/// null, which every consumer reads as UNKNOWN: the honest degrade the
/// old unconditional refusal was protecting, kept for exactly that case.
fn flattenIfTree(self: *ListMemo, gpa: std.mem.Allocator, log: *logstore.LogStore, m: *artifact.Manifest) error{OutOfMemory}!void {
if (m.s.len == 0) return;
repository.Repository.flattenTreeFromLogMemo(gpa, log, m, &self.tree_memo) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return, // unknown, never an empty file set
};
// §5.1 yields canonical order, which the two-pointer diff depends on.
if (std.debug.runtime_safety) artifact.assertSorted(m.flattened.?);
// The derive hands this a memo-resident child (the walk parsed it for
// an earlier check-in); the flatten just grew that entry's arena, and
// the byte bound has to see it. Bounded by the entry cap, not the log.
for (self.parsed.values()) |e| {
if (&e.im.manifest == m) {
const extra = flattenedCharge(m.flattened);
e.charge += extra;
self.bytes += extra;
break;
}
}
}
/// On-demand flatten of a cached tree, for the flat/tree pairing the
/// S-card diff cannot take. Loads the entry if it is not resident, then
/// recharges it: the flatten copies the file set into the manifest's
/// arena, and the byte bound has to see that.
fn flattenCached(self: *ListMemo, gpa: std.mem.Allocator, log: *logstore.LogStore, cache: *logstore.PlaintextCache, hex: []const u8) error{OutOfMemory}!void {
_ = (try self.manifest(gpa, log, cache, hex)) orelse return; // absent: the diff reports unknown
const e = self.parsed.get(hex) orelse return;
if (e.im.manifest.s.len == 0 or e.im.manifest.flattened != null) return;
// `flattenIfTree` recharges a resident entry itself.
try self.flattenIfTree(gpa, log, &e.im.manifest);
}
/// Open a capture step: the entry touched most recently — the caller
/// already holds it — and everything the step touches from here are
/// pinned until the next step opens. Inclusive on purpose: `linkOne`
/// hands a cached manifest straight to the step that then diffs it.
fn beginStep(self: *ListMemo) void {
self.floor = self.tick;
}
/// A check-in already parsed for a child it arrived after: the
/// walk takes it from here instead of parsing the same bytes twice.
fn cached(self: *ListMemo, hex: []const u8) ?*const artifact.Manifest {
return self.cachedMut(hex);
}
/// `cached`, mutable: the entry is heap-owned, so flattening it in place —
/// the one mutation a consumer makes — moves no other entry.
fn cachedMut(self: *ListMemo, hex: []const u8) ?*artifact.Manifest {
const e = self.parsed.get(hex) orelse return null;
self.tick += 1;
e.tick = self.tick;
return &e.im.manifest;
}
/// The check-in manifest named by hex `hex`, parsed once and kept warm;
/// null when it is absent from the log, shunned, unparseable, or not a
/// check-in. The pointer stays valid across further lookups in the same
/// capture step: an entry just touched carries the newest tick and is
/// never the one evicted.
fn manifest(self: *ListMemo, gpa: std.mem.Allocator, log: *logstore.LogStore, cache: *logstore.PlaintextCache, hex: []const u8) error{OutOfMemory}!?*const artifact.Manifest {
if (self.cached(hex)) |m| return m;
var raw: [logstore.max_hash_len]u8 = undefined;
if (hex.len == 0 or hex.len % 2 != 0 or hex.len / 2 > raw.len) return null;
const raw_hash = raw[0 .. hex.len / 2];
_ = std.fmt.hexToBytes(raw_hash, hex) catch return null;
const rid = log.lookup(raw_hash) orelse return null;
const bytes = (log.contentCached(gpa, rid, cache) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return null,
}) orelse return null;
var bytes_owned = true;
defer if (bytes_owned) gpa.free(bytes);
var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return null,
};
var im_owned = true;
defer if (im_owned) im.deinit(gpa);
if (im.manifest.kind != .manifest) return null;
// §4.5.3 checked once per parse, not once per diff: `deltaDiff` binary
// searches a baseline run and merges two delta runs directly, and
// re-walking a baseline of a large tree per check-in is the O(files)
// cost this whole path exists to avoid.
if (std.debug.runtime_safety) artifact.assertSorted(im.manifest.f);
// A tree is NOT flattened here. Flattening is the full-enumeration
// cost, and this memo also serves the derive feed on cold open; the
// index build flattens on demand only where a flat diff needs it
// (`flattenCached`), and diffs a tree against a tree by S card.
const charge = bytes.len;
while (self.parsed.count() >= max_parsed or (self.bytes + charge > max_bytes and self.parsed.count() > 0)) {
var oldest: ?usize = null;
for (self.parsed.values(), 0..) |e, i| {
if (e.tick >= self.floor) continue; // pinned by the running step
if (oldest == null or e.tick < self.parsed.values()[oldest.?].tick) oldest = i;
}
const idx = oldest orelse break; // every resident entry is in use
const k = self.parsed.keys()[idx];
const e = self.parsed.values()[idx];
self.parsed.orderedRemoveAt(idx);
self.bytes -= e.charge;
gpa.free(k);
e.destroy(gpa);
}
const entry = try gpa.create(ParsedEntry);
errdefer gpa.destroy(entry);
const key = try gpa.dupe(u8, hex);
errdefer gpa.free(key);
try self.parsed.put(gpa, key, entry);
self.tick += 1;
entry.* = .{ .bytes = bytes, .im = im, .tick = self.tick, .charge = charge };
self.bytes += charge;
bytes_owned = false;
im_owned = false;
return &entry.im.manifest;
}
};
/// The byte cost of a flattened file set, for the memo's bound. Each
/// card is its struct plus the decoded filename the flatten copied into the
/// arena; the hash slices borrow the sub-manifest bytes, which `flattenTree`
/// also copies, so they are counted through the filename term's headroom
/// rather than exactly. A bound is a bound; this errs generous.
fn flattenedCharge(flattened: ?[]const artifact.FCard) usize {
const cards = flattened orelse return 0;
var n: usize = cards.len * (@sizeOf(artifact.FCard) + logstore.max_hash_len * 2);
for (cards) |c| {
n += c.filename.len;
if (c.old_name) |o| n += o.len; // a rename carries a second decoded name
}
return n;
}
/// The section 8.3 eager-baseline resolver, mirroring `repository.EagerBaseline`:
/// exactly one baseline was pre-read, and the resolver hands it over.
const EagerView = struct {
hash: ?[]const u8,
view: ?artifact.ManifestFiles,
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const self: *EagerView = @ptrCast(@alignCast(ctx));
std.debug.assert(self.hash != null and std.mem.eql(u8, hash, self.hash.?));
return self.view;
}
};
/// Begin the section 4.7 merge-walk over `m`'s effective file list — its own
/// F cards over its baseline's, resolved through the memo — or null when the
/// list cannot be known here: a tree (S-card) manifest the memo could not
/// flatten (a wrong list is worse than an unknown one), or a
/// baseline that is absent, shunned, unparseable, or an unflattened tree.
/// A flattened tree walks as a full manifest: `manifestFilesOf` substitutes
/// its file set, and nothing below this line learns trees exist. `eager`
/// must outlive the walk: the walk resolves through it.
fn beginWalk(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
cache: *logstore.PlaintextCache,
memo: *ListMemo,
m: *const artifact.Manifest,
eager: *EagerView,
) error{OutOfMemory}!?artifact.MergeWalk {
if (m.s.len != 0 and m.flattened == null) return null; // unflattened tree: unknown
eager.* = .{ .hash = null, .view = null };
if (m.b) |b| {
eager.hash = b.hash;
const base = (try memo.manifest(gpa, log, cache, b.hash)) orelse return null;
if (base.s.len != 0 and base.flattened == null) return null; // unflattened tree baseline: unknown
eager.view = repository.manifestFilesOf(base);
}
return artifact.enumerate(repository.manifestFilesOf(m), .{
.ctx = eager,
.resolveFn = EagerView.resolve,
}) catch null; // BaselineUnresolvable: unknown
}
/// The next tracked file a walk yields — a section 4.7.6 delete marker is
/// not a file and is skipped.
fn nextFile(walk: *artifact.MergeWalk, prev: ?artifact.FCard) ?artifact.FCard {
while (walk.next()) |f| {
if (f.hash == null) continue;
// §4.5.3: the merge-walk yields canonical path order, and the
// two-pointer merge below depends on it.
if (std.debug.runtime_safety) {
if (prev) |p| std.debug.assert(std.mem.order(u8, p.filename, f.filename) == .lt);
}
return f;
}
return null;
}
/// Two-pointer diff of two effective file lists as their walks yield them,
/// in section 4.5.3 order, into `out` — no list is ever materialized.
/// Every path is DUPLICATED into `out`; the caller frees them via
/// `freeChanged`. A null parent is the root check-in: everything is an add.
fn diffWalks(
gpa: std.mem.Allocator,
parent: ?*artifact.MergeWalk,
child: *artifact.MergeWalk,
out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!void {
var p: ?artifact.FCard = if (parent) |pw| nextFile(pw, null) else null;
var c: ?artifact.FCard = nextFile(child, null);
while (p != null or c != null) {
if (p == null) {
try appendChange(gpa, out, .added, c.?.filename, c.?.hash.?);
c = nextFile(child, c);
} else if (c == null) {
try appendChange(gpa, out, .deleted, p.?.filename, null);
p = nextFile(parent.?, p);
} else switch (std.mem.order(u8, p.?.filename, c.?.filename)) {
.lt => {
try appendChange(gpa, out, .deleted, p.?.filename, null);
p = nextFile(parent.?, p);
},
.gt => {
try appendChange(gpa, out, .added, c.?.filename, c.?.hash.?);
c = nextFile(child, c);
},
.eq => {
if (!std.mem.eql(u8, p.?.hash.?, c.?.hash.?) or p.?.mode != c.?.mode) {
try appendChange(gpa, out, .modified, c.?.filename, c.?.hash.?);
}
p = nextFile(parent.?, p);
c = nextFile(child, c);
},
}
}
}
/// The card `name` resolves to in a sorted F-card run, by binary search.
fn seekCard(cards: []const artifact.FCard, name: []const u8) ?*const artifact.FCard {
var lo: usize = 0;
var hi: usize = cards.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
switch (std.mem.order(u8, cards[mid].filename, name)) {
.lt => lo = mid + 1,
.gt => hi = mid,
.eq => return &cards[mid],
}
}
return null;
}
/// What a delta manifest says about `name` once its baseline is applied: its
/// own card when it has one (a section 4.7.6 delete marker resolves to
/// nothing), else the baseline's.
fn effectiveCard(delta: ?*const artifact.FCard, base: ?*const artifact.FCard) ?*const artifact.FCard {
if (delta) |d| return if (d.hash != null) d else null;
return base;
}
/// Diff two delta manifests that share one baseline over their own F cards
/// alone (the shape of Fossil's `add_mlink`): a path neither delta
/// names is the baseline's on both sides, so only the union of the two
/// deltas' cards can differ. One merge over two sorted runs, a binary search
/// into the baseline per name; output in path order, exactly what
/// `diffWalks` would have emitted.
fn deltaDiff(
gpa: std.mem.Allocator,
child: []const artifact.FCard,
parent: []const artifact.FCard,
base: []const artifact.FCard,
out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!void {
// `enumerate` is bypassed here, so §4.5.3 is asserted at this door
// instead. `parent` and `base` were checked as they entered the manifest
// cache; only the child's own run arrives unchecked, and it is the small
// one — a delta's changed files, never a whole tree.
if (std.debug.runtime_safety) artifact.assertSorted(child);
var i: usize = 0;
var j: usize = 0;
while (i < child.len or j < parent.len) {
const take_child = j >= parent.len or (i < child.len and std.mem.order(u8, child[i].filename, parent[j].filename) != .gt);
const take_parent = i >= child.len or (j < parent.len and std.mem.order(u8, parent[j].filename, child[i].filename) != .gt);
std.debug.assert(take_child or take_parent);
const name = if (take_child) child[i].filename else parent[j].filename;
const c: ?*const artifact.FCard = if (take_child) &child[i] else null;
const p: ?*const artifact.FCard = if (take_parent) &parent[j] else null;
if (take_child) i += 1;
if (take_parent) j += 1;
const b = seekCard(base, name);
const eff_c = effectiveCard(c, b);
const eff_p = effectiveCard(p, b);
if (eff_c == null and eff_p == null) continue;
if (eff_p == null) {
try appendChange(gpa, out, .added, name, eff_c.?.hash.?);
} else if (eff_c == null) {
try appendChange(gpa, out, .deleted, name, null);
} else if (!std.mem.eql(u8, eff_c.?.hash.?, eff_p.?.hash.?) or eff_c.?.mode != eff_p.?.mode) {
try appendChange(gpa, out, .modified, name, eff_c.?.hash.?);
}
}
}
/// Duplicate `path` and append the change — one owner from birth, so an
/// append that fails under OOM injection cannot strand the dupe (the
/// checkAllAllocationFailures pins caught exactly that in the first draft).
fn appendChange(gpa: std.mem.Allocator, out: *std.ArrayList(ChangedFile), disp: FileDisp, path: []const u8, hash: ?[]const u8) error{OutOfMemory}!void {
std.debug.assert((disp == .deleted) == (hash == null));
const owned = try gpa.dupe(u8, path);
errdefer gpa.free(owned);
const owned_hash: ?[]const u8 = if (hash) |h| try gpa.dupe(u8, h) else null;
errdefer if (owned_hash) |h| gpa.free(h);
try out.append(gpa, .{ .disp = disp, .path = owned, .hash = owned_hash });
}
/// Free a change list the diff filled.
/// One check-in's change set against its primary parent, handed to
/// a caller. `files` null means UNKNOWN for that check-in — a tree manifest,
/// an absent or shunned parent, an unresolvable baseline — which is
/// `captureFileFacts`'s honest degrade and which an index MUST record as
/// unknown rather than as "changed nothing": an empty change set is a claim.
/// Evidence seam: check-ins `eachChangeSet` actually visited. The
/// file index is built from this walk, so the count answers the question that
/// matters after a commit — did the next query EXTEND the index over the new
/// check-ins, or rebuild it over all of them. `ZIT_FILEINDEX_WALK` at exit.
pub var change_set_visits: u64 = 0;
pub const ChangeSetSink = struct {
ctx: *anyopaque,
emit: *const fn (ctx: *anyopaque, rid: logstore.Rid, files: ?[]const ChangedFile) error{OutOfMemory}!void,
};
/// Drive `captureFileFacts` over every check-in the log holds, in persisted
/// order — the same per-check-in change set the `crosslink` feed streams
/// (`ChangedFile` is Fossil's `mlink` row), without encoding it to bytes.
///
/// This exists so a file-history index can be built in ONE pass that reads
/// each manifest once, instead of the per-query walk that reconstructed one
/// manifest per visited check-in. Both memos are the ones the derive itself
/// uses and both are bounded, so the pass costs one traversal and a fixed
/// working set, never the repository's size in resident bytes.
///
/// A frame that cannot be read or parsed is skipped rather than fatal: this
/// builds an accelerator, and the answer it accelerates must stay derivable
/// from the log without it.
pub fn eachChangeSet(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
/// Resume point: entries at or below this are already accounted for by
/// whatever the caller adopted. Zero walks the whole log.
after: logstore.Rid,
/// Inclusive upper bound on the entries walked; zero means the whole log.
/// A caller checkpointing a long build walks it in bounded chunks, so an
/// interrupted build resumes from the last chunk instead of from zero.
until: logstore.Rid,
sink: ChangeSetSink,
) error{OutOfMemory}!void {
var cache = logstore.PlaintextCache.init();
defer cache.deinit(gpa);
var memo: ListMemo = .{};
defer memo.deinit(gpa);
const n: logstore.Rid = @intCast(log.entryCount());
const end: logstore.Rid = if (until == 0) n else @min(until, n);
var rid: logstore.Rid = after + 1;
while (rid <= end) : (rid += 1) {
if (log.isPhantom(rid)) continue;
const bytes = (log.contentCached(gpa, rid, &cache) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => continue,
}) orelse continue;
defer gpa.free(bytes);
var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => continue,
};
defer loaded.deinit(gpa);
const m = &loaded.manifest;
if (m.kind != .manifest) continue;
var parents: std.ArrayList([]const u8) = .empty;
defer parents.deinit(gpa);
for (m.p) |pc| {
var it = pc.hashes();
while (it.next()) |parent| try parents.append(gpa, parent);
}
var changed: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &changed);
const known = try captureChangeSet(gpa, log, &cache, &memo, m, parents.items, &changed);
change_set_visits += 1;
try sink.emit(sink.ctx, rid, if (known) changed.items else null);
}
}
/// One check-in's change set: fossil's mlink row, computed once wherever a
/// check-in is captured — the derive as it links, the crosslink stream, the
/// on-query index fallback. A tree against a tree diffs by S card,
/// proportional to what changed. Every other pairing takes the flat diff,
/// flattening a tree side on demand first: the root tree (everything added)
/// and the flat/tree boundary are full enumerations by nature. Cold open is
/// untouched because a cache hit never captures and a plain load computes
/// nothing here; only a capturing derive — the cache build, or its extension
/// over an appended tail — pays, once, per check-in.
fn captureChangeSet(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
cache: *logstore.PlaintextCache,
memo: *ListMemo,
m: *artifact.Manifest,
parents: []const []const u8,
out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!bool {
// Pin what this step touches: the parent looked up below must survive the
// whole diff. The tree diff inserts into the sub-manifest memo, never into
// this one, so it could not evict the parent anyway; the flat diffs open
// their own step again, which is harmless.
memo.beginStep();
const child_tree = m.s.len != 0;
if (parents.len != 0 and child_tree) {
const parent = (try memo.manifest(gpa, log, cache, parents[0])) orelse return false;
if (parent.s.len != 0) return captureTreeFacts(gpa, log, memo, parent, m, out);
try memo.flattenIfTree(gpa, log, m); // flat parent, tree child
if (parent.b) |b| try memo.flattenCached(gpa, log, cache, b.hash);
return captureFileFacts(gpa, log, cache, memo, m, parents, out);
}
if (child_tree) {
try memo.flattenIfTree(gpa, log, m); // a root tree: every file is an add
return captureFileFacts(gpa, log, cache, memo, m, parents, out);
}
// A flat child. Its parent may be a tree, and (submanifests section 5.4) a
// delta on either side may name a tree as its baseline; the flat diff
// walks through the baseline, so each is flattened on demand here.
if (m.b) |b| try memo.flattenCached(gpa, log, cache, b.hash);
if (parents.len != 0) {
try memo.flattenCached(gpa, log, cache, parents[0]);
if (try memo.manifest(gpa, log, cache, parents[0])) |parent| {
if (parent.b) |b| try memo.flattenCached(gpa, log, cache, b.hash);
}
}
return captureFileFacts(gpa, log, cache, memo, m, parents, out);
}
/// How many sub-manifests the S-card diff resolved this process — the cost
/// that walk is bounded by. Printed beside the walk count under the same seam.
pub var tree_subs_resolved: u64 = 0;
/// The nesting the diff will descend. Partitions run a dozen levels at most
/// (k <= 12); this is headroom, and a deeper tree is refused as unknown rather
/// than recursed without bound.
const max_tree_depth: u32 = 64;
/// One check-in's S-card diff in progress. `seen` keeps the index's one-row-
/// per-path invariant: a partition key that moved between the two trees puts
/// the same file under different keys on the two sides, and it must not be
/// reported twice.
const TreeDiff = struct {
gpa: std.mem.Allocator,
resolver: artifact.SubResolver,
out: *std.ArrayList(ChangedFile),
seen: std.StringHashMapUnmanaged(void) = .empty,
/// Deletes are held until the whole diff has run, then flushed through the
/// same dedup. A partition key that moved puts a live file under an old
/// key on the parent side and a new key on the child side; walked in key
/// order the old key can come first, and a delete recorded then would
/// make a file the child still holds look gone. Adds and modifies are
/// emitted as met, so by flush time every live file is already seen and
/// only a file the child really lacks is recorded as deleted. The cards
/// borrow the sub-manifest views, which live until the seam ends.
pending_deletes: std.ArrayListUnmanaged(artifact.FCard) = .empty,
fn deinit(self: *TreeDiff) void {
self.seen.deinit(self.gpa);
self.pending_deletes.deinit(self.gpa);
}
fn emit(self: *TreeDiff, disp: FileDisp, f: artifact.FCard) error{OutOfMemory}!void {
if (disp == .deleted) return self.pending_deletes.append(self.gpa, f);
try self.record(disp, f);
}
fn record(self: *TreeDiff, disp: FileDisp, f: artifact.FCard) error{OutOfMemory}!void {
const gop = try self.seen.getOrPut(self.gpa, f.filename);
if (gop.found_existing) return;
try appendChange(self.gpa, self.out, disp, f.filename, if (disp == .deleted) null else f.hash);
// The set borrows the row's own copy of the path, which outlives it.
gop.key_ptr.* = self.out.items[self.out.items.len - 1].path;
}
/// Record the held deletes, after every add and modify has been seen.
fn flush(self: *TreeDiff) error{OutOfMemory}!void {
for (self.pending_deletes.items) |f| try self.record(.deleted, f);
self.pending_deletes.clearRetainingCapacity();
}
fn resolve(self: *TreeDiff, hash: []const u8) ?artifact.SubManifestView {
tree_subs_resolved += 1;
return self.resolver.resolveFn(self.resolver.ctx, hash);
}
};
/// Every file under a sub-manifest, recursively, as `disp` — a subtree that
/// exists on one side only. Bounded by that subtree's size. False when a
/// sub-manifest is unresolvable or the tree is deeper than the cap.
fn emitSubtree(d: *TreeDiff, view: artifact.SubManifestView, disp: FileDisp, depth: u32) error{OutOfMemory}!bool {
if (depth > max_tree_depth) return false;
for (view.files) |f| {
if (f.hash == null) continue; // a delete marker is not a file
try d.emit(disp, f);
}
for (view.subs) |s| {
const sub = d.resolve(s.hash) orelse return false;
if (!try emitSubtree(d, sub, disp, depth + 1)) return false;
}
return true;
}
/// Two-pointer diff of two F runs, both in section 4.5.3 order: added,
/// deleted, and modified by hash or mode — the same rule the flat diff
/// applies. An unchanged file is not a row.
fn diffFiles(d: *TreeDiff, a: []const artifact.FCard, b: []const artifact.FCard) error{OutOfMemory}!void {
var i: usize = 0;
var j: usize = 0;
while (i < a.len or j < b.len) {
if (i < a.len and a[i].hash == null) {
i += 1;
continue;
}
if (j < b.len and b[j].hash == null) {
j += 1;
continue;
}
if (j >= b.len) {
try d.emit(.deleted, a[i]);
i += 1;
continue;
}
if (i >= a.len) {
try d.emit(.added, b[j]);
j += 1;
continue;
}
switch (std.mem.order(u8, a[i].filename, b[j].filename)) {
.lt => {
try d.emit(.deleted, a[i]);
i += 1;
},
.gt => {
try d.emit(.added, b[j]);
j += 1;
},
.eq => {
if (!std.mem.eql(u8, a[i].hash.?, b[j].hash.?) or a[i].mode != b[j].mode) try d.emit(.modified, b[j]);
i += 1;
j += 1;
},
}
}
}
/// Two-pointer diff of two S runs, both ascending by filename (submanifests
/// section 3.4). A sub-manifest on both sides with the same hash is skipped
/// whole — that is the proportional-cost step: neither side is resolved. Only
/// where the hash differs are both resolved, their files diffed, and the walk
/// recurses. A key present on one side only is an added or deleted subtree,
/// enumerated whole. When a partition's key moved between the trees the same
/// file sits under different keys on the two sides; it is reported from the
/// first side seen, an over-report the change predicate confirms, never an
/// omission.
fn diffSubs(d: *TreeDiff, a: []const artifact.SCard, b: []const artifact.SCard, depth: u32) error{OutOfMemory}!bool {
if (depth > max_tree_depth) return false;
var i: usize = 0;
var j: usize = 0;
while (i < a.len or j < b.len) {
const take_a = j >= b.len or (i < a.len and std.mem.order(u8, a[i].filename, b[j].filename) == .lt);
const take_b = i >= a.len or (j < b.len and std.mem.order(u8, a[i].filename, b[j].filename) == .gt);
if (take_a) {
const v = d.resolve(a[i].hash) orelse return false;
if (!try emitSubtree(d, v, .deleted, depth + 1)) return false;
i += 1;
} else if (take_b) {
const v = d.resolve(b[j].hash) orelse return false;
if (!try emitSubtree(d, v, .added, depth + 1)) return false;
j += 1;
} else {
if (!std.mem.eql(u8, a[i].hash, b[j].hash)) {
const va = d.resolve(a[i].hash) orelse return false;
const vb = d.resolve(b[j].hash) orelse return false;
try diffFiles(d, va.files, vb.files);
if (!try diffSubs(d, va.subs, vb.subs, depth + 1)) return false;
}
i += 1;
j += 1;
}
}
return true;
}
/// The change set of a tree check-in against a tree parent, by S-card diff,
/// into `out`. A tree carries its whole file set by S cards and no F cards, so
/// the two S runs are the whole comparison. False — unknown — when a needed
/// sub-manifest is unresolvable; the caller discards `out` in that case.
fn captureTreeFacts(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
memo: *ListMemo,
parent: *const artifact.Manifest,
child: *const artifact.Manifest,
out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!bool {
std.debug.assert(parent.s.len != 0 and child.s.len != 0);
var seam: repository.TreeDiffSeam = undefined;
seam.begin(gpa, log, &memo.tree_memo);
defer seam.end();
var d: TreeDiff = .{ .gpa = gpa, .resolver = seam.resolver(), .out = out };
defer d.deinit();
const ok = try diffSubs(&d, parent.s, child.s, 0);
if (seam.fault()) |f| {
if (f == error.OutOfMemory) return error.OutOfMemory;
return false;
}
if (!ok) return false;
try d.flush();
return true;
}
const FakeSubEntry = struct { hash: []const u8, view: artifact.SubManifestView };
/// A sub-manifest resolver over a fixed table, counting every resolution: the
/// proportional-cost claim is exactly "the unchanged subtree is never asked
/// for", and this is what measures it.
const FakeSubs = struct {
entries: []const FakeSubEntry,
resolved: usize = 0,
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView {
const self: *FakeSubs = @ptrCast(@alignCast(ctx));
self.resolved += 1;
for (self.entries) |e| if (std.mem.eql(u8, e.hash, hash)) return e.view;
return null;
}
};
fn fcard(name: []const u8, hash: []const u8) artifact.FCard {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}
test "tree diff: an unchanged sub-manifest is never resolved; a changed one yields exactly its changed files; a new one is enumerated" {
const gpa = std.testing.allocator;
const a1 = [_]artifact.FCard{fcard("a/x.txt", "1" ** 40)};
const a2 = [_]artifact.FCard{fcard("a/x.txt", "2" ** 40)};
const b1 = [_]artifact.FCard{fcard("b/y.txt", "3" ** 40)};
const d1 = [_]artifact.FCard{ fcard("d/p.txt", "4" ** 40), fcard("d/q.txt", "5" ** 40) };
const none = [_]artifact.SCard{};
var fake = FakeSubs{ .entries = &.{
.{ .hash = "hA1", .view = .{ .files = &a1, .subs = &none } },
.{ .hash = "hA2", .view = .{ .files = &a2, .subs = &none } },
.{ .hash = "hB", .view = .{ .files = &b1, .subs = &none } },
.{ .hash = "hD", .view = .{ .files = &d1, .subs = &none } },
} };
// Parent: a (v1), b. Child: a (v2), b (same hash), d (new).
const parent = [_]artifact.SCard{ .{ .filename = "a/x.txt", .hash = "hA1" }, .{ .filename = "b/y.txt", .hash = "hB" } };
const child = [_]artifact.SCard{ .{ .filename = "a/x.txt", .hash = "hA2" }, .{ .filename = "b/y.txt", .hash = "hB" }, .{ .filename = "d/p.txt", .hash = "hD" } };
var out: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &out);
var d: TreeDiff = .{ .gpa = gpa, .resolver = .{ .ctx = &fake, .resolveFn = FakeSubs.resolve }, .out = &out };
defer d.deinit();
try std.testing.expect(try diffSubs(&d, &parent, &child, 0));
// b's hash did not move: neither side was resolved. a: both versions. d: once.
try std.testing.expectEqual(@as(usize, 3), fake.resolved);
try std.testing.expectEqual(@as(usize, 3), out.items.len);
try std.testing.expectEqual(FileDisp.modified, out.items[0].disp);
try std.testing.expectEqualStrings("a/x.txt", out.items[0].path);
try std.testing.expectEqualStrings("2" ** 40, out.items[0].hash.?);
try std.testing.expectEqual(FileDisp.added, out.items[1].disp);
try std.testing.expectEqualStrings("d/p.txt", out.items[1].path);
try std.testing.expectEqual(FileDisp.added, out.items[2].disp);
try std.testing.expectEqualStrings("d/q.txt", out.items[2].path);
// A partition key that moved: a/x.txt was removed, so the partition once
// keyed by it is now keyed by a/y.txt on the child side. Walked in key
// order the parent's old key comes first; a delete recorded there would
// make a/y.txt look gone while the child still holds it. Deletes are held
// to the end, so a/y.txt is recorded from the child side and only a/x.txt
// — which the child really lacks — is a delete.
const drift_parent = [_]artifact.FCard{ fcard("a/x.txt", "1" ** 40), fcard("a/y.txt", "6" ** 40) };
const drift_child = [_]artifact.FCard{fcard("a/y.txt", "6" ** 40)};
var fake_drift = FakeSubs{ .entries = &.{
.{ .hash = "hOld", .view = .{ .files = &drift_parent, .subs = &none } },
.{ .hash = "hNew", .view = .{ .files = &drift_child, .subs = &none } },
} };
const p_drift = [_]artifact.SCard{.{ .filename = "a/x.txt", .hash = "hOld" }};
const c_drift = [_]artifact.SCard{.{ .filename = "a/y.txt", .hash = "hNew" }};
var out3: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &out3);
var d3: TreeDiff = .{ .gpa = gpa, .resolver = .{ .ctx = &fake_drift, .resolveFn = FakeSubs.resolve }, .out = &out3 };
defer d3.deinit();
try std.testing.expect(try diffSubs(&d3, &p_drift, &c_drift, 0));
try d3.flush();
try std.testing.expectEqual(@as(usize, 2), out3.items.len);
try std.testing.expectEqualStrings("a/y.txt", out3.items[0].path);
try std.testing.expect(out3.items[0].disp != .deleted); // live in the child
try std.testing.expectEqualStrings("6" ** 40, out3.items[0].hash.?);
try std.testing.expectEqualStrings("a/x.txt", out3.items[1].path);
try std.testing.expectEqual(FileDisp.deleted, out3.items[1].disp);
// An unresolvable sub-manifest is unknown, never an empty change set.
const broken = [_]artifact.SCard{.{ .filename = "a/x.txt", .hash = "missing" }};
var out2: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &out2);
var d2: TreeDiff = .{ .gpa = gpa, .resolver = .{ .ctx = &fake, .resolveFn = FakeSubs.resolve }, .out = &out2 };
defer d2.deinit();
try std.testing.expect(!try diffSubs(&d2, &parent, &broken, 0));
}
fn freeChanged(gpa: std.mem.Allocator, changed: *std.ArrayList(ChangedFile)) void {
for (changed.items) |f| {
gpa.free(f.path);
if (f.hash) |h| gpa.free(h);
}
changed.deinit(gpa);
}
/// The capture step for one check-in manifest: the change set against
/// the PRIMARY parent into `out`. Two deltas over one baseline diff over their
/// own F cards; otherwise — a root, a baseline change, a full
/// manifest on either side — the two effective lists are walked side by side.
/// Nothing is materialized either way. Returns false -- files unknown --
/// whenever any side cannot be known: a tree manifest the memo could not
/// flatten, an absent or shunned parent, an unresolvable baseline.
/// Unknown is the honest degrade; an empty change set is a claim. A
/// flattened tree diffs as a full manifest; it can carry no B card, so the
/// delta shortcut skips itself and the side-by-side walk runs.
fn captureFileFacts(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
cache: *logstore.PlaintextCache,
memo: *ListMemo,
m: *const artifact.Manifest,
parents: []const []const u8,
out: *std.ArrayList(ChangedFile),
) error{OutOfMemory}!bool {
memo.beginStep();
if (m.s.len != 0 and m.flattened == null) return false; // unflattened tree: unknown
var child_eager: EagerView = undefined;
if (parents.len == 0) {
var child = (try beginWalk(gpa, log, cache, memo, m, &child_eager)) orelse return false;
try diffWalks(gpa, null, &child, out);
return true;
}
const parent = (try memo.manifest(gpa, log, cache, parents[0])) orelse return false;
if (parent.s.len != 0 and parent.flattened == null) return false;
if (m.b != null and parent.b != null and std.mem.eql(u8, m.b.?.hash, parent.b.?.hash)) {
if (try memo.manifest(gpa, log, cache, m.b.?.hash)) |base| {
if (base.s.len == 0 and base.b == null) {
try deltaDiff(gpa, m.f, parent.f, base.f, out);
return true;
}
}
}
var parent_eager: EagerView = undefined;
var pw = (try beginWalk(gpa, log, cache, memo, parent, &parent_eager)) orelse return false;
var cw = (try beginWalk(gpa, log, cache, memo, m, &child_eager)) orelse return false;
try diffWalks(gpa, &pw, &cw, out);
return true;
}
test "deltaDiff over one baseline matches the effective-list diff" {
const gpa = std.testing.allocator;
const F = artifact.FCard;
const card = struct {
fn f(name: []const u8, hash: ?[]const u8) F {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}
};
const base = [_]F{ card.f("a", "h1"), card.f("b", "h2"), card.f("c", "h3") };
// parent: edits b, deletes c, adds d; child: edits b differently, restores c, adds e, says nothing of d.
const parent = [_]F{ card.f("b", "h2p"), card.f("c", null), card.f("d", "h4") };
const child = [_]F{ card.f("b", "h2c"), card.f("c", "h3"), card.f("e", "h5") };
var out: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &out);
try deltaDiff(gpa, &child, &parent, &base, &out);
try std.testing.expectEqual(@as(usize, 4), out.items.len);
try std.testing.expectEqualStrings("b", out.items[0].path);
try std.testing.expectEqual(FileDisp.modified, out.items[0].disp);
try std.testing.expectEqualStrings("h2c", out.items[0].hash.?);
try std.testing.expectEqualStrings("c", out.items[1].path);
try std.testing.expectEqual(FileDisp.added, out.items[1].disp);
try std.testing.expectEqualStrings("h3", out.items[1].hash.?); // restored: the baseline's hash
try std.testing.expectEqualStrings("d", out.items[2].path);
try std.testing.expectEqual(FileDisp.deleted, out.items[2].disp);
try std.testing.expect(out.items[2].hash == null);
try std.testing.expectEqualStrings("e", out.items[3].path);
try std.testing.expectEqual(FileDisp.added, out.items[3].disp);
try std.testing.expectEqualStrings("h5", out.items[3].hash.?);
// The same edit on both sides is silent; a delete of what the baseline lacks is silent.
const p2 = [_]F{ card.f("b", "h2x"), card.f("zz", null) };
const c2 = [_]F{card.f("b", "h2x")};
var out2: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &out2);
try deltaDiff(gpa, &c2, &p2, &base, &out2);
try std.testing.expectEqual(@as(usize, 0), out2.items.len);
}
test "diffWalks over two baseline-resolved walks emits the same changes as deltaDiff" {
const gpa = std.testing.allocator;
const F = artifact.FCard;
const card = struct {
fn f(name: []const u8, hash: ?[]const u8) F {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}
};
const base = [_]F{ card.f("a", "h1"), card.f("b", "h2"), card.f("c", "h3") };
const parent = [_]F{ card.f("b", "h2p"), card.f("c", null), card.f("d", "h4") };
const child = [_]F{ card.f("b", "h2c"), card.f("c", "h3"), card.f("e", "h5") };
const base_files: artifact.ManifestFiles = .{ .baseline_hash = null, .files = &base };
const Resolver = struct {
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
_ = hash;
return @as(*const artifact.ManifestFiles, @ptrCast(@alignCast(ctx))).*;
}
};
var ctx = base_files;
var pw = try artifact.enumerate(.{ .baseline_hash = "base", .files = &parent }, .{ .ctx = &ctx, .resolveFn = Resolver.resolve });
var cw = try artifact.enumerate(.{ .baseline_hash = "base", .files = &child }, .{ .ctx = &ctx, .resolveFn = Resolver.resolve });
var walked: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &walked);
try diffWalks(gpa, &pw, &cw, &walked);
var direct: std.ArrayList(ChangedFile) = .empty;
defer freeChanged(gpa, &direct);
try deltaDiff(gpa, &child, &parent, &base, &direct);
try std.testing.expectEqual(direct.items.len, walked.items.len);
for (direct.items, walked.items) |d, w| {
try std.testing.expectEqual(d.disp, w.disp);
try std.testing.expectEqualStrings(d.path, w.path);
try std.testing.expectEqual(d.hash == null, w.hash == null);
if (d.hash) |h| try std.testing.expectEqualStrings(h, w.hash.?);
}
}
/// Append one crosslink input record to `s` in the `.zit-cache` wire format:
/// `kind u8 | hash(u16-len) | mtime u64 | parent_count u32 [parent(u16-len)]… |
/// tag_count u32 [type u8, name(u16-len), has_target u8 [target(u16-len)],
/// has_value u8 [value(u32-len)]]…`. All little-endian, self-delimiting, so the
/// decoder needs no separate record count. Deterministic: the fields come only
/// from the persisted artifact, in persisted order — no clock, no RNG.
fn encodeRecord(
gpa: std.mem.Allocator,
s: *std.ArrayList(u8),
kind: RecordKind,
hash: []const u8,
mtime: repostate.Mtime,
parents: []const []const u8,
tags: []const repostate.TagOp,
comment: []const u8,
user: []const u8,
picks: []const repostate.Pick,
files: ?[]const ChangedFile,
) error{OutOfMemory}!void {
try s.append(gpa, @intFromEnum(kind));
try appendLenPrefixed(gpa, s, u16, hash);
try appendInt(gpa, s, u64, mtime);
try appendInt(gpa, s, u32, @intCast(parents.len));
for (parents) |p| try appendLenPrefixed(gpa, s, u16, p);
try appendInt(gpa, s, u32, @intCast(tags.len));
for (tags) |t| {
try s.append(gpa, @intFromEnum(t.type));
try appendLenPrefixed(gpa, s, u16, t.name);
if (t.target) |target| {
try s.append(gpa, 1);
try appendLenPrefixed(gpa, s, u16, target);
} else try s.append(gpa, 0);
if (t.value) |value| {
try s.append(gpa, 1);
try appendLenPrefixed(gpa, s, u32, value);
} else try s.append(gpa, 0);
}
// A manifest record closes with its C-card comment (u32-len, "" for
// none), the U-card user (u16-len, "" for none), its Q cards (u16 count,
// then per card a sign byte and u16-len target), and the
// changed-file set: a state byte (0 = known, 1 = unknown), then when known
// a count and per-file disposition byte, path, and the file's artifact
// hash (u16-len, empty for a delete). A control record carries
// none of these.
if (kind == .manifest) {
try appendLenPrefixed(gpa, s, u32, comment);
try appendLenPrefixed(gpa, s, u16, user);
try appendInt(gpa, s, u16, @intCast(picks.len));
for (picks) |pk| {
try s.append(gpa, @intFromEnum(pk.sign));
try appendLenPrefixed(gpa, s, u16, pk.target);
}
if (files) |fl| {
try s.append(gpa, 0);
try appendInt(gpa, s, u32, @intCast(fl.len));
for (fl) |f| {
try s.append(gpa, @intFromEnum(f.disp));
try appendLenPrefixed(gpa, s, u16, f.path);
try appendLenPrefixed(gpa, s, u16, f.hash orelse "");
derive_path_bytes += f.path.len;
if (f.hash) |h| derive_filehash_bytes += h.len;
}
} else try s.append(gpa, 1);
derive_comment_bytes += comment.len;
derive_user_bytes += user.len;
}
// Evidence seam: composition and the sink's high-water mark.
derive_records += 1;
derive_hash_bytes += hash.len;
for (parents) |p| derive_parent_bytes += p.len;
for (tags) |t| {
derive_tag_bytes += t.name.len;
if (t.target) |target| derive_tag_bytes += target.len;
if (t.value) |value| derive_tag_bytes += value.len;
}
if (s.capacity > derive_body_peak_capacity) derive_body_peak_capacity = s.capacity;
}
/// The Q cards of a parsed manifest in the shape the state carries.
fn picksOf(gpa: std.mem.Allocator, m: *const artifact.Manifest, out: *std.ArrayList(repostate.Pick)) error{OutOfMemory}!void {
for (m.q) |q| try out.append(gpa, .{ .sign = q.sign, .target = q.target });
}
/// Decode the Q-card run `encodeRecord` writes after the user.
fn readPicks(gpa: std.mem.Allocator, r: *RecordReader, out: *std.ArrayList(repostate.Pick)) RepoView.BuildError!void {
const n = try r.int(u16);
var i: u16 = 0;
while (i < n) : (i += 1) {
const sign: artifact.QSign = switch (try r.byte()) {
0 => .cherrypick,
1 => .backout,
else => return error.Corrupt,
};
const target = try r.lenPrefixed(u16);
if (target.len == 0) return error.Corrupt;
try out.append(gpa, .{ .sign = sign, .target = target });
}
}
fn appendInt(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void {
var buf: [@sizeOf(T)]u8 = undefined;
std.mem.writeInt(T, &buf, v, .little);
try s.appendSlice(gpa, &buf);
}
fn appendLenPrefixed(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime L: type, bytes: []const u8) error{OutOfMemory}!void {
std.debug.assert(bytes.len <= std.math.maxInt(L));
try appendInt(gpa, s, L, @intCast(bytes.len));
try s.appendSlice(gpa, bytes);
}
/// One decoded crosslink record: everything `encodeRecord` wrote.
/// Slices borrow the body buffer; the per-record lists are owned (`deinit`
/// after use). `files_known == false` is the record saying the changed-file
/// set could not be determined at capture time -- a tree manifest, or a
/// parent absent from the store -- which is distinct from an empty set.
pub const DecodedRecord = struct {
kind: RecordKind,
hash: []const u8,
mtime: repostate.Mtime,
parents: std.ArrayList([]const u8),
tags: std.ArrayList(repostate.TagOp),
comment: []const u8,
user: []const u8,
picks: std.ArrayList(repostate.Pick),
files_known: bool,
files: std.ArrayList(ChangedFile),
pub fn deinit(self: *DecodedRecord, gpa: std.mem.Allocator) void {
self.parents.deinit(gpa);
self.tags.deinit(gpa);
self.picks.deinit(gpa);
self.files.deinit(gpa);
self.* = undefined;
}
};
/// Iterate a record body, yielding each record fully decoded -- the read seam
/// `zit crosslink` streams through. Three readers walk this framing
/// (`replayRecord`, `recordPrefixBelow`, and this); all three MUST move in
/// lockstep with `encodeRecord`, and the crosslink verb's cache-vs-derive
/// byte-equality pin is the system-level check that they do.
pub const RecordIter = struct {
r: RecordReader,
pub fn init(body: []const u8) RecordIter {
return .{ .r = .{ .body = body } };
}
pub fn next(self: *RecordIter, gpa: std.mem.Allocator) RepoView.BuildError!?DecodedRecord {
const r = &self.r;
if (r.atEnd()) return null;
const kind: RecordKind = switch (try r.byte()) {
0 => .manifest,
1 => .control,
else => return error.Corrupt,
};
const hash = try r.lenPrefixed(u16);
if (hash.len == 0) return error.Corrupt;
var rec: DecodedRecord = .{
.kind = kind,
.hash = hash,
.mtime = try r.int(u64),
.parents = .empty,
.tags = .empty,
.comment = "",
.user = "",
.picks = .empty,
.files_known = false,
.files = .empty,
};
errdefer rec.deinit(gpa);
const parent_count = try r.int(u32);
var pi: u32 = 0;
while (pi < parent_count) : (pi += 1) {
const parent = try r.lenPrefixed(u16);
if (parent.len == 0) return error.Corrupt;
try rec.parents.append(gpa, parent);
}
const tag_count = try r.int(u32);
var ti: u32 = 0;
while (ti < tag_count) : (ti += 1) {
const tt: repostate.TagType = switch (try r.byte()) {
0 => .cancel,
1 => .singleton,
2 => .propagate,
else => return error.Corrupt,
};
const name = try r.lenPrefixed(u16);
if (name.len == 0) return error.Corrupt;
const target: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u16) else null;
const value: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u32) else null;
try rec.tags.append(gpa, .{ .type = tt, .name = name, .target = target, .value = value });
}
if (kind == .manifest) {
rec.comment = try r.lenPrefixed(u32);
rec.user = try r.lenPrefixed(u16);
try readPicks(gpa, r, &rec.picks);
switch (try r.byte()) {
0 => {
rec.files_known = true;
const n = try r.int(u32);
var fi: u32 = 0;
while (fi < n) : (fi += 1) {
const disp: FileDisp = switch (try r.byte()) {
'A' => .added,
'M' => .modified,
'D' => .deleted,
else => return error.Corrupt,
};
const path = try r.lenPrefixed(u16);
if (path.len == 0) return error.Corrupt;
const fhash = try r.lenPrefixed(u16);
if ((fhash.len == 0) != (disp == .deleted)) return error.Corrupt;
try rec.files.append(gpa, .{ .disp = disp, .path = path, .hash = if (fhash.len == 0) null else fhash });
}
},
1 => {},
else => return error.Corrupt,
}
}
return rec;
}
};
/// A bounds-checked cursor over a `.zit-cache` record body. Every read validates
/// against the remaining bytes before advancing, so a truncated or lying body
/// yields `error.Corrupt` rather than an out-of-bounds slice.
const RecordReader = struct {
body: []const u8,
pos: usize = 0,
fn atEnd(self: *const RecordReader) bool {
return self.pos >= self.body.len;
}
fn byte(self: *RecordReader) error{Corrupt}!u8 {
if (self.pos + 1 > self.body.len) return error.Corrupt;
const b = self.body[self.pos];
self.pos += 1;
return b;
}
fn int(self: *RecordReader, comptime T: type) error{Corrupt}!T {
const n = @sizeOf(T);
if (self.pos + n > self.body.len) return error.Corrupt;
const v = std.mem.readInt(T, self.body[self.pos..][0..n], .little);
self.pos += n;
return v;
}
fn lenPrefixed(self: *RecordReader, comptime L: type) error{Corrupt}![]const u8 {
const len: usize = try self.int(L);
if (self.pos + len > self.body.len) return error.Corrupt;
const out = self.body[self.pos..][0..len];
self.pos += len;
return out;
}
};
/// Map a parsed T card (repo-state §5.1 sign syntax) to a `RepoState` tag op:
/// `+` singleton, `-` cancel, `*` propagate. `name` is the sign-stripped,
/// escape-decoded tag name; `target` is null for a `*` self-tag; `value` passes
/// through. Total over `TSign`.
fn tagOpOf(tc: artifact.TCard) repostate.TagOp {
return .{
.type = switch (tc.sign) {
.add => .singleton,
.cancel => .cancel,
.propagate => .propagate,
},
.name = tc.name,
.target = tc.target,
.value = tc.value,
};
}
/// Encode a binary digest to its lowercase-hex artifact name into `out`,
/// returning the used prefix. A 20-byte (sha1) or 32-byte (sha3-256) digest
/// yields a 40- or 64-char name — always ≤ 64.
fn hexName(raw: []const u8, out: *[64]u8) []const u8 {
std.debug.assert(raw.len >= 1 and raw.len <= 32);
for (raw, 0..) |b, i| {
out[i * 2] = hex_digits[b >> 4];
out[i * 2 + 1] = hex_digits[b & 0x0f];
}
return out[0 .. raw.len * 2];
}
// ===========================================================================
// Tests — the loader's job is to reproduce the repo-state §6-7 derived views
// from persisted bytes. Fixtures are assembled with `artifact.create` (the
// library serializer) and stored through a real `LogStore`; every expected
// value comes from the repo-state RFC text, never from Fossil.
// ===========================================================================
const storage = @import("storage.zig");
const testing = std.testing;
/// A tmp-backed writable `LogStore` fixture plus the file handle it borrows.
/// Mirrors the FileStorage/tmp pattern the logstore tests use. `deinit` tears
/// the store down; the tmp dir is the caller's to clean up.
const Fixture = struct {
fs: storage.FileStorage,
log: logstore.LogStore,
/// Opens a fresh empty `.zit`-style log in `tmp`. `hash_kind` 0 accepts the
/// 32-byte sha3-256 digests the fixtures use. Initialized in place: the
/// store's `StorageFile` borrows `&self.fs`, so `self` must not be moved
/// after this call.
fn init(self: *Fixture, tmp: *std.testing.TmpDir, io: std.Io) !void {
const raw = try tmp.dir.createFile(io, "log", .{ .read = true });
self.fs = storage.FileStorage.init(io, raw, 0);
self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0);
}
fn deinit(self: *Fixture) void {
self.log.deinit(testing.allocator);
// The store holds the writer lock; leaving the file open would leave it
// held, and the next fixture on the same path would wait on it forever.
self.fs.file.close(self.fs.io);
}
/// Serialize `m`, store it under its own sha3-256 digest (the content-address
/// commit uses), and return the 64-hex artifact name (caller frees).
fn put(self: *Fixture, m: *const artifact.Manifest) ![]u8 {
const bytes = try artifact.create(m, testing.allocator);
defer testing.allocator.free(bytes);
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
_ = try self.log.put(&digest, bytes);
var name_buf: [64]u8 = undefined;
return testing.allocator.dupe(u8, hexName(&digest, &name_buf));
}
};
/// A skeleton check-in manifest (repo-state §6.1: a root carries the propagating
/// branch + sym tags; a child carries a P card and inherits by propagation).
/// `parent` null ⇒ a root check-in with the `trunk` branch tags; non-null ⇒ a
/// child of that parent name. `branch_value`/`extra_tags` let a test place an
/// explicit branch or closed tag. Files are irrelevant to leaf derivation, so
/// there are none.
const CheckinSpec = struct {
ts: []const u8,
parent: ?[]const u8 = null,
/// When set, the root's branch/sym tags use this value instead of "trunk".
branch_value: []const u8 = "trunk",
/// Extra T cards appended after the branch tags (e.g. a self closed tag).
extra_tags: []const artifact.TCard = &.{},
/// F cards of a full (non-delta) manifest; empty for the leaf-only tests.
files: []const artifact.FCard = &.{},
};
fn manifestOf(spec: CheckinSpec, tag_buf: []artifact.TCard, parent_buf: []artifact.PCard) artifact.Manifest {
const is_root = spec.parent == null;
var n: usize = 0;
if (is_root) {
tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = spec.branch_value };
n += 1;
}
for (spec.extra_tags) |t| {
tag_buf[n] = t;
n += 1;
}
var p_slice: []const artifact.PCard = &.{};
if (spec.parent) |parent| {
parent_buf[0] = .{ .parents = parent, .count = 1 };
p_slice = parent_buf[0..1];
}
return .{
.arena_state = std.heap.ArenaAllocator.init(testing.allocator),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "c" },
.d = .{ .timestamp = spec.ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = spec.files,
.j = &.{},
.m = &.{},
.p = p_slice,
.q = &.{},
.t = tag_buf[0..n],
};
}
/// Store a check-in built from `spec`, returning its 64-hex name (caller frees).
fn putCheckin(fx: *Fixture, spec: CheckinSpec) ![]u8 {
var tag_buf: [8]artifact.TCard = undefined;
var parent_buf: [1]artifact.PCard = undefined;
var m = manifestOf(spec, &tag_buf, &parent_buf);
defer m.deinit();
return fx.put(&m);
}
/// Store a control artifact carrying `tags` (repo-state §4.2/§7.4 tag mutation).
/// `create` classifies a D/T/U artifact with no C card and no parents as a
/// control (tag) artifact.
fn putControl(fx: *Fixture, ts: []const u8, tags: []const artifact.TCard) ![]u8 {
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(testing.allocator),
.kind = .control,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = tags,
};
defer m.deinit();
return fx.put(&m);
}
/// Load a view over `fx.log`. Caller pins `view` and `deinit`s it.
fn loadView(view: *RepoView, fx: *Fixture) !void {
try view.load(testing.allocator, &fx.log);
}
/// Resolve a 64-hex name to its `RepoState` rid via the view's identity space.
fn ridOf(view: *RepoView, name: []const u8) ?repostate.Rid {
return view.store.store().lookup(name);
}
/// Whether `name` appears among the view's open leaves.
fn openLeafPresent(view: *RepoView, name: []const u8) bool {
const rid = ridOf(view, name) orelse return false;
var it = view.state.leaves();
while (it.next()) |leaf| {
if (leaf == rid) return view.state.isOpenLeaf(rid);
}
return false;
}
test "repoview: a rejected artifact is NAMED, not just reported as a corrupt repository" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const good = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(good);
// Well-formed enough to be stored, unparsable as a timestamp: `parseMtime`
// wants 19-23 characters once byte 10 is `T`, and this is 16. That is the
// shape that gets past artifact validation and dies in the derive.
const bad = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00" });
defer testing.allocator.free(bad);
var view: RepoView = undefined;
view.fault = null;
if (view.load(testing.allocator, &fx.log)) |_| {
view.deinit(testing.allocator); // loaded after all: tear down before failing
return error.TestExpectedCorrupt;
} else |e| {
try testing.expectEqual(error.Corrupt, e);
}
// The point of the whole mechanism: the walk that found this runs over every
// artifact in the store — minutes on a real one — so failing without saying
// WHICH artifact leaves bisecting the store as the only way to find out.
const f = view.fault orelse return error.TestExpectedFault;
try testing.expectEqual(RepoView.Fault.Reason.unparsable_timestamp, f.reason);
try testing.expectEqualStrings(bad, f.artifactName());
}
test "repoview §7.1: a single-commit repo loads its lone root as the open leaf" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
const rid = ridOf(&view, root).?;
try testing.expect(view.state.isLeaf(rid)); // §7.1: no same-branch child
try testing.expect(view.state.isOpenLeaf(rid)); // §7.4: no closed tag
try testing.expectEqualStrings("trunk", view.state.branchOf(rid, view.main_branch));
}
test "repoview §7.1: a closed leaf stays in leaves() but isOpenLeaf is false (closed derived, not stored)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root that self-closes: an active +closed tag on itself (§7.4).
const closed = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = null, .value = null }};
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &closed });
defer testing.allocator.free(root);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
const rid = ridOf(&view, root).?;
try testing.expect(view.state.isLeaf(rid)); // §7.1: still a leaf-table member
try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: closed ⇒ not open
}
test "repoview §7.2: a check-in with a same-branch primary child is not a leaf" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // §7.2: has a same-branch child
try testing.expect(view.state.isLeaf(ridOf(&view, child).?)); // the tip is the leaf
}
test "repoview §7.2: a same-branch child via a non-primary (merge) P-card disqualifies the parent leaf" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root and a second independent root, both on trunk; then a merge child
// whose PRIMARY parent is `mainline` and whose SECOND (merge, non-primary)
// parent is `side`. §7.2: the merge edge still disqualifies `side` as a leaf.
const mainline = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(mainline);
const side = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" });
defer testing.allocator.free(side);
// Build the merge child by hand: two P tokens, primary then merge.
var pbuf: [130]u8 = undefined;
const joined = try std.fmt.bufPrint(&pbuf, "{s} {s}", .{ mainline, side });
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(testing.allocator),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "merge" },
.d = .{ .timestamp = "2020-01-03T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{.{ .parents = joined, .count = 2 }},
.q = &.{},
.t = &.{},
};
defer m.deinit();
const child = try fx.put(&m);
defer testing.allocator.free(child);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
// Both parents are on trunk (default fallback); the merge child is on trunk.
// §7.2: neither parent is a leaf — `side` is reached only via a merge edge.
try testing.expect(!view.state.isLeaf(ridOf(&view, mainline).?));
try testing.expect(!view.state.isLeaf(ridOf(&view, side).?));
try testing.expect(view.state.isLeaf(ridOf(&view, child).?));
}
test "repoview §7.3: open builds the full leaf table over every crosslinked check-in" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A linear chain of three: only the tip is a leaf; the two interior
// check-ins are excluded because each has a same-branch child (§7.3 full
// rebuild over every check-in, roots included).
const a = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(a);
const b = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = a });
defer testing.allocator.free(b);
const c = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = b });
defer testing.allocator.free(c);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
var count: usize = 0;
var it = view.state.leaves();
while (it.next()) |_| count += 1;
try testing.expectEqual(@as(usize, 1), count);
try testing.expect(view.state.isLeaf(ridOf(&view, c).?));
}
test "repoview §7.3: a root check-in with no parent loads as a leaf" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// Two independent roots (no parent at all): §7.3 says a parentless check-in
// is a leaf when it has no same-branch child, and MUST be included.
const r1 = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(r1);
const r2 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" });
defer testing.allocator.free(r2);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(view.state.isLeaf(ridOf(&view, r1).?));
try testing.expect(view.state.isLeaf(ridOf(&view, r2).?));
}
test "repoview §7.4: a leaf without a closed tag is open; a control artifact closes it" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
// Open before any closed tag.
{
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // §7.4
}
// A control artifact applies an active +closed tag to the leaf (§7.4: the
// closed status is an active tag-cross-reference row, tagtype > 0).
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
const ctl = try putControl(&fx, "2020-01-02T00:00:00", &close);
defer testing.allocator.free(ctl);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
const rid = ridOf(&view, root).?;
try testing.expect(view.state.isLeaf(rid)); // still a leaf
try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: now closed
}
test "repoview §7.4: a cancel (-closed) control reopens a previously closed leaf" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
const ctl_close = try putControl(&fx, "2020-01-02T00:00:00", &close);
defer testing.allocator.free(ctl_close);
// A later cancel row (tagtype 0) removes the active closed marker (§7.4:
// closed is only "an assignment, not a cancellation").
const reopen = [_]artifact.TCard{.{ .sign = .cancel, .name = "closed", .target = root, .value = null }};
const ctl_open = try putControl(&fx, "2020-01-03T00:00:00", &reopen);
defer testing.allocator.free(ctl_open);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // reopened
}
test "repoview §7.5: two open leaves on one branch both load (ambiguous tip)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root with two children on the same branch: a fork. §7.5: both children
// are open leaves of trunk, so each is an ambiguous leaf of the other.
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const c1 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(c1);
const c2 = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = root });
defer testing.allocator.free(c2);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
const r1 = ridOf(&view, c1).?;
const r2 = ridOf(&view, c2).?;
try testing.expect(view.state.isOpenLeaf(r1));
try testing.expect(view.state.isOpenLeaf(r2));
try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // parent forked
try testing.expect(view.state.isAmbiguousLeaf(r1)); // §7.5: another open leaf on the branch
try testing.expect(view.state.isAmbiguousLeaf(r2));
}
test "repoview §6.1: the configured main-branch names the default branch; absent config falls back to trunk" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
// (a) No config frame: a tagless check-in falls back to "trunk". But our
// root carries a propagating branch tag, so assert on a value-carrying root
// named "trunk" and on the fallback via main_branch.
{
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expectEqualStrings("trunk", view.main_branch); // §6.1: unset ⇒ trunk
}
// (b) A config frame naming a different main-branch: the loader reads it and
// threads it into main_branch (§6.1: MUST read the configured value).
{
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
try fx.log.putConfig(.{ .hash_policy = 0, .default_branch = "mainline", .project_id = "p" });
try fx.log.commit();
// A check-in with NO branch tag (a bare control-free child is awkward, so
// use a root but assert the fallback name only when a tagless check-in is
// present). Here assert on main_branch itself — the §6.1 configured value.
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "mainline" });
defer testing.allocator.free(root);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expectEqualStrings("mainline", view.main_branch); // §6.1: configured value
try testing.expectEqualStrings("mainline", view.state.branchOf(ridOf(&view, root).?, view.main_branch));
}
}
test "repoview §6.1: a cancelled branch tag falls back to the configured main branch (tagtype>0 filter)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root on trunk, then a control that CANCELS its branch tag. §6.1: only an
// active (tagtype > 0) branch tag names a branch; a cancelled one falls back
// to the default branch exactly like a missing one.
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "feature" });
defer testing.allocator.free(root);
const cancel = [_]artifact.TCard{.{ .sign = .cancel, .name = "branch", .target = root, .value = "feature" }};
const ctl = try putControl(&fx, "2020-01-02T00:00:00", &cancel);
defer testing.allocator.free(ctl);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
// The cancelled branch tag is inactive, so branchOf returns the fallback.
try testing.expectEqualStrings("trunk", view.state.branchOf(ridOf(&view, root).?, view.main_branch));
}
test "repoview §6.1: a check-in's absent P-card parent is an eventless phantom" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A child whose P card names a parent hash never stored: the loader interns
// that name as a phantom rid with no event row. §6.1: such a rid has no
// mtime, so it is not a resolvable tip — the guard predicate a checkout-mark
// resolver relies on (mtimeOf == null ⇒ nothing to mark).
const absent = "a" ** 64;
const child = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .parent = absent });
defer testing.allocator.free(child);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
const phantom = view.lookup(absent) orelse return error.PhantomNotInterned;
try testing.expect(view.state.mtimeOf(phantom) == null); // eventless
try testing.expect(view.state.mtimeOf(ridOf(&view, child).?) != null); // child bears an event
}
test "repoview §7.7: a closed non-leaf does not appear among open leaves (enumeration ≠ name resolution)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root, then a child that is the tip. Close the root (a non-leaf). §7.7:
// leaf enumeration answers "no same-branch descendant and still open"; the
// closed root is neither a leaf nor an open leaf regardless of any name it
// might resolve.
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
defer testing.allocator.free(ctl);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(!openLeafPresent(&view, root)); // not a leaf at all
try testing.expect(openLeafPresent(&view, child)); // the actual open leaf
}
test "repoview: a non-artifact file blob is skipped, not an error" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// Store raw non-artifact bytes (a file blob) alongside one real check-in.
// The loader skips the blob (parse fails) and still derives the check-in.
const blob = "this is not a manifest\n";
var bdigest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(blob, &bdigest, .{});
_ = try fx.log.put(&bdigest, blob);
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
var view: RepoView = undefined;
try loadView(&view, &fx);
defer view.deinit(testing.allocator);
try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?));
}
test "repoview: load is leak-free under every allocation failure" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }};
const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
defer testing.allocator.free(ctl);
const Ctx = struct {
fx: *Fixture,
fn run(a: std.mem.Allocator, self: @This()) !void {
var view: RepoView = undefined;
try view.load(a, &self.fx.log);
view.deinit(a);
}
};
try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .fx = &fx }});
}
test "repoview fuzz: load never crashes on adversarial or mutated artifact bytes" {
// Invariant (repo-state §7): building a view over arbitrary persisted bytes
// returns only a declared LoadError or a consistent state — never a crash,
// out-of-bounds read, or unhandled trap. Non-artifact frames are skipped;
// a structurally corrupt check-in is a declared error.
var prng = std.Random.DefaultPrng.init(0x1eaf5eed);
const rand = prng.random();
// A valid baseline manifest to mutate.
var base_tag: [8]artifact.TCard = undefined;
var base_p: [1]artifact.PCard = undefined;
var base_m = manifestOf(.{ .ts = "2020-01-01T00:00:00" }, &base_tag, &base_p);
const base = try artifact.create(&base_m, testing.allocator);
base_m.deinit();
defer testing.allocator.free(base);
var round: usize = 0;
while (round < 256) : (round += 1) {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// Put a handful of frames: some random bytes, some byte-flipped copies of
// the valid baseline. Each is stored under its own digest.
var k: usize = 0;
const frames = rand.intRangeAtMost(usize, 1, 5);
while (k < frames) : (k += 1) {
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(testing.allocator);
if (rand.boolean()) {
const len = rand.intRangeAtMost(usize, 1, 128);
try payload.resize(testing.allocator, len);
rand.bytes(payload.items);
} else {
try payload.appendSlice(testing.allocator, base);
const flips = rand.intRangeAtMost(usize, 1, 6);
var f: usize = 0;
while (f < flips) : (f += 1) {
const idx = rand.intRangeLessThan(usize, 0, payload.items.len);
payload.items[idx] ^= rand.int(u8);
}
}
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(payload.items, &digest, .{});
_ = fx.log.put(&digest, payload.items) catch continue;
}
var view: RepoView = undefined;
view.load(testing.allocator, &fx.log) catch |e| {
// Only the declared, closed error set may surface; a failed load has
// already torn itself down, so no deinit here.
switch (e) {
error.OutOfMemory, error.Storage, error.Corrupt => {},
}
continue;
};
view.deinit(testing.allocator);
}
}
test "repoview §1: a captured record body replays to a view equal to from-scratch load" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
// A root, a same-branch child, a fork, and a control close — enough shape to
// exercise leaves, propagation, and tags in the round-trip.
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const fork = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = root });
defer testing.allocator.free(fork);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = fork, .value = null }};
const ctl = try putControl(&fx, "2020-01-04T00:00:00", &close);
defer testing.allocator.free(ctl);
// Reference: a plain from-scratch derivation.
var ref: RepoView = undefined;
try ref.load(testing.allocator, &fx.log);
defer ref.deinit(testing.allocator);
// Capture the record body, then replay it WITHOUT the log.
var body: std.ArrayList(u8) = .empty;
defer body.deinit(testing.allocator);
var cap: RepoView = undefined;
try cap.loadCapturing(testing.allocator, &fx.log, &body);
cap.deinit(testing.allocator); // we only needed the body
var replay: RepoView = undefined;
try replay.buildFromRecordBody(testing.allocator, body.items, ref.main_branch);
defer replay.deinit(testing.allocator);
// §1: the replayed view is observably identical — same rids (same intern
// order), leaves, open/closed status, branches, and mtimes.
try testing.expectEqualStrings(ref.main_branch, replay.main_branch);
try testing.expectEqual(ref.store.store().count(), replay.store.store().count());
var lr = ref.state.leaves();
var lp = replay.state.leaves();
while (lr.next()) |a| {
const b = lp.next() orelse return error.LeafCountMismatch;
try testing.expectEqual(a, b);
try testing.expectEqual(ref.state.isOpenLeaf(a), replay.state.isOpenLeaf(b));
try testing.expectEqualStrings(ref.state.branchOf(a, ref.main_branch), replay.state.branchOf(b, replay.main_branch));
}
try testing.expect(lp.next() == null);
var rid: repostate.Rid = 1;
const count = ref.store.store().count();
while (rid <= count) : (rid += 1) {
try testing.expectEqualStrings(ref.hashOf(rid), replay.hashOf(rid));
try testing.expectEqual(ref.state.mtimeOf(rid), replay.state.mtimeOf(rid));
try testing.expectEqual(ref.state.isLeaf(rid), replay.state.isLeaf(rid));
}
}
test "a record carrying A/M/D files round-trips its per-file hashes through all three decoders" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const h1 = "1" ** 64;
const h2 = "2" ** 64;
const h3 = "3" ** 64;
const reg = artifact.FileMode.regular;
const root_files = [_]artifact.FCard{
.{ .filename = "a.txt", .hash = h1, .perm = null, .old_name = null, .mode = reg },
.{ .filename = "b.txt", .hash = h2, .perm = null, .old_name = null, .mode = reg },
};
// Child: a.txt modified (h1 -> h3), b.txt deleted, c.txt added (h2).
const child_files = [_]artifact.FCard{
.{ .filename = "a.txt", .hash = h3, .perm = null, .old_name = null, .mode = reg },
.{ .filename = "c.txt", .hash = h2, .perm = null, .old_name = null, .mode = reg },
};
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .files = &root_files });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root, .files = &child_files });
defer testing.allocator.free(child);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(testing.allocator);
var cap: RepoView = undefined;
try cap.loadCapturing(testing.allocator, &fx.log, &body);
cap.deinit(testing.allocator);
// Decoder 1: the replay path the cache open uses. Before the hash field
// was read here, this failed and every open silently re-derived.
var replay: RepoView = undefined;
try replay.buildFromRecordBody(testing.allocator, body.items, "trunk");
defer replay.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), replay.store.store().count());
// Decoder 2: the framing-only skip must walk the whole body.
const whole = recordPrefixBelow(&fx.log, body.items, std.math.maxInt(u64)) orelse return error.PrefixSkipFailed;
try testing.expectEqual(body.items.len, whole.len);
// Decoder 3: the crosslink reader sees the hashes the encoder wrote.
var it = RecordIter.init(body.items);
var first = (try it.next(testing.allocator)) orelse return error.MissingRecord;
defer first.deinit(testing.allocator);
try testing.expect(first.files_known);
try testing.expectEqual(@as(usize, 2), first.files.items.len);
try testing.expectEqualStrings(h1, first.files.items[0].hash.?);
try testing.expectEqualStrings(h2, first.files.items[1].hash.?);
var second = (try it.next(testing.allocator)) orelse return error.MissingRecord;
defer second.deinit(testing.allocator);
try testing.expect(second.files_known);
try testing.expectEqual(@as(usize, 3), second.files.items.len);
for (second.files.items) |f| {
if (std.mem.eql(u8, f.path, "a.txt")) {
try testing.expectEqual(FileDisp.modified, f.disp);
try testing.expectEqualStrings(h3, f.hash.?);
} else if (std.mem.eql(u8, f.path, "b.txt")) {
try testing.expectEqual(FileDisp.deleted, f.disp);
try testing.expect(f.hash == null);
} else {
try testing.expectEqualStrings("c.txt", f.path);
try testing.expectEqual(FileDisp.added, f.disp);
try testing.expectEqualStrings(h2, f.hash.?);
}
}
try testing.expect((try it.next(testing.allocator)) == null);
}
test "repoview: buildFromRecordBody is leak-free under every allocation failure" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.init(&tmp, testing.io);
defer fx.deinit();
const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }};
const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close);
defer testing.allocator.free(ctl);
// Capture a valid body once with the real allocator; then replay it under
// injected OOM at every allocation site.
var body: std.ArrayList(u8) = .empty;
defer body.deinit(testing.allocator);
var cap: RepoView = undefined;
try cap.loadCapturing(testing.allocator, &fx.log, &body);
cap.deinit(testing.allocator);
const Ctx = struct {
body: []const u8,
fn run(a: std.mem.Allocator, self: @This()) !void {
var view: RepoView = undefined;
view.buildFromRecordBody(a, self.body, "trunk") catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Corrupt => unreachable, // a valid body never decodes as corrupt
};
view.deinit(a);
}
};
try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .body = body.items }});
}