Artifact 112a01df4aea87c59bff2b7f6fa8ac0069eb0369c987434397a01b13f74cb694:
- File
src/repoview.zig
— part of check-in
[df99743ca4]
at
2026-09-16 22:11:23
on branch trunk
— file index: built by the derive, at ingest
fossil fills mlink as each check-in arrives and answers finfo by lookup. zit's derive computed the same row per check-in and dropped it; the index was rebuilt by a separate pass on the first PATH query, 6.8 min on pkgsrc and not resumable. The derive now hands each change set to an index builder as it links, stores it beside the checkpoint, and extends it over the tail on append; a cache hit computes nothing. The on-query build is the fallback and checkpoints every 8192 entries. Index format 4 records completeness so a partial checkpoint is never adopted whole. crosslink reports a tree's files instead of unknown. pkgsrc, fresh clone to first answer: 11.7 min -> 7.1 min + 1s. (user: dmestas, size: 140992) [annotate] [blame] [check-ins using]
//! 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 (zit#182): 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 (zit#182): 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 /// zit#182 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 (zit#182): 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 (zit#182). 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 (zit#182): 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` (zit#173): 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 zit#182 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 (zit#172). 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); // #109 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 (zit#176) } }, 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 (zit#172). 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 (#109) const n_picks = r.int(u16) catch return null; // Q cards (zit#177) 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 (zit#176) } }, 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 (#109): 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 (zit#176). 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 (zit#192), 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 (zit#109, zit#172): 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, /// zit#192 — 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 (zit#192): 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(); } /// zit#192 — 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 (zit#172): 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 (zit#172). 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; } }; /// zit#192 — 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 (zit#192; 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 / BaselineIsDelta: 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` (#109) — no list is ever materialized /// (zit#172). 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 (zit#172, 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. /// zit#188 — 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 (zit#188): 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 #109 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 (zit#172); 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 (zit#192), 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 /// zit#172 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 "zit#172: 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 "zit#172: 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; zit#177), and the #109 // 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; zit#176). 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 (zit#182): 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 (zit#177). 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 (zit#177). 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 (#109): 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); } /// 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 "zit#176: 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 }}); }