Artifact b578f25ea9a2deed5686b262ca9bf826e79451f31f1e4af45369dd091969b8cc:
- File
src/repository.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: 142925) [annotate] [blame] [check-ins using]
//! zit — the `Repository` facade: one deep module for opening a repository and //! answering the derived and content questions every read verb asks. //! //! The north star names this the public repo surface. It folds the open //! sequence that each read verb used to hand-wire — open `.zit`, take the O(1) //! `.zit-cache` fast path (repo-state §1 memo) or derive the crosslinked //! `RepoState` from the log, close the file — behind `open`, and serves the //! derived queries (`resolve`, `branchOf`, `state`, `currentCheckoutRid`) plus //! the lazily-opened content path (`manifest`, `blob`) that `ls`/`cat` lean on. //! //! Determinism seam (design-seams rule): this module touches no clock, RNG, //! ambient cwd, or network. Discovery of the checkout root — which reads the //! process cwd — stays in the CLI; `open` operates on an already-discovered //! `root_dir` and the injected `std.Io`. The log is reopened lazily only when a //! content request arrives, so view-only verbs keep the O(1) cache fast path. //! //! Pinning: `state` (inside the loaded `RepoView`) borrows the view's own //! `MemStore`, and a lazily-opened `LogStore` borrows this struct's inline //! `FileStorage`. `open` therefore fills the caller's `Repository` in place //! (`self: *Repository`); a filled `Repository` must not be moved. const std = @import("std"); const artifact = @import("artifact.zig"); const repostate = @import("repostate.zig"); const logstore = @import("logstore.zig"); const storage = @import("storage.zig"); const checkout = @import("checkout.zig"); const repoview = @import("repoview.zig"); const repoimage = @import("repoimage.zig"); const repocache = @import("repocache.zig"); const fileindex = @import("fileindex.zig"); const checkpoint = @import("checkpoint.zig"); const cone_mod = @import("cone.zig"); /// The repository file, beside `.zit-checkout` in the checkout root. Shared with /// the CLI (re-exported from root.zig) so the one name has a single home. pub const repo_filename: []const u8 = ".zit"; /// draft-fossil-submanifests-00 §6.3/§8.1 — the writer-side sub-manifest /// settings, and the two decisions they determine: whether a commit emits a /// tree, and with which k. /// /// It lives beside the repository facade rather than inside `RepoView` on /// purpose. `RepoView`'s fields are exactly what the `.zit-cache` fast path /// must reproduce without reading the log, so putting a setting there means /// widening the cache record format; and no DERIVED query — leaf table, branch /// tags, ancestry — reads these. They are read once, by the writer, at the /// moment it decides what to emit, straight from the config frame it already /// holds. `main_branch` is threaded through the view because `branchOf` needs it /// on every derived answer; this does not. pub const submanifest = struct { /// The settings as stored, before any range judgement. pub const Settings = struct { /// §8.1's `enable-submanifests` — "a project-scope boolean, default false". enabled: bool, /// §6.3's pinned `submanifest-k`, exactly as stored. 0 means unpinned. k: u8, /// The state of a repository that has never been opted in — what every /// repository predating this draft decodes to. pub const off: Settings = .{ .enabled = false, .k = 0 }; pub fn of(cfg: logstore.Config) Settings { return .{ .enabled = cfg.enable_submanifests, .k = cfg.submanifest_k }; } /// §8.1: "an implementation MUST NOT enable emission on a repository /// with no pinned parameter." The predicate an enable surface checks /// BEFORE writing the setting, so the refusal is stated once. pub fn pinned(self: Settings) bool { return self.k != 0; } }; /// §6.3's range test: "a writer ... MUST support every value from 1 to 12 /// inclusive (the pinned value MUST lie in that range)". The one place the /// bound is spelled; both the pin surface and the emission decision read it. pub fn inRange(k: u8) bool { return k >= artifact.min_k and k <= artifact.max_k; } /// What a commit does about sub-manifests, given the repository's settings. /// /// Three outcomes, not two, because §6.3's out-of-range fallback is NOT the /// same event as the setting being off: "on receiving a value outside 1..12 /// it MUST fall back to emitting flat manifests rather than substitute a /// parameter of its own, and SHOULD report the fallback to the operator." /// Off is silent and is what every repository does; the fallback is a /// misconfiguration the operator is told about. Collapsing them into one /// `?u4` would lose exactly the distinction the SHOULD asks for. pub const Emission = union(enum) { /// §8.1 — the setting is false. The default, and silent. off, /// §6.3 — enabled, but the pinned value is outside 1..12 (0 included, /// which is "enabled with nothing pinned", a state §8.1 forbids /// creating and this reports rather than repairs). Payload is the /// offending value, for the operator report. out_of_range: u8, /// §6.2/§6.3 — emit the canonical tree, partitioned at this k. tree: u4, /// The k a writer partitions at, or null when this commit emits a flat /// manifest. One accessor so a call site reads the decision once (the /// report in the `out_of_range` arm is a side effect of PRODUCING the /// decision, so evaluating it twice would print it twice). pub fn treeK(self: Emission) ?u4 { return switch (self) { .tree => |k| k, .off, .out_of_range => null, }; } }; /// The §8.1 + §6.3 emission decision. Pure; the writer's only input beyond /// the file list. /// /// §8.1's second conjunct — "MUST NOT emit one in a commit against a server /// from which it has received the avoid-submanifests pragma in the current /// sync session" — is deliberately NOT folded in here. A zit commit is a /// purely local act against no server (`sync.clientMayEmitSCards` is where /// that conjunct lives, on the Client that received the pragma), so there is /// no session for this call to consult. If a commit-against-a-server verb is /// ever added, it reads both. pub fn emission(s: Settings) Emission { if (!s.enabled) return .off; if (!inRange(s.k)) return .{ .out_of_range = s.k }; return .{ .tree = @intCast(s.k) }; } /// §6.3's operator report for the out-of-range fallback ("SHOULD report the /// fallback to the operator"). Named here so the writer and any future /// status surface print one text. pub const out_of_range_report_prefix = "sub-manifests: pinned submanifest-k is out of range"; // ----------------------------------------------------------------------- // §6.3/§6.4 — the analysis pass. // ----------------------------------------------------------------------- /// draft-fossil-submanifests-00 §6.3 defines C(k) from serialized leaf /// bytes and S cards naming leaves. This fixed-width SHA-256 cache key /// records every F-card property that can change that cost. Hash values /// themselves are deliberately excluded: only their token width affects /// serialized F and S card sizes. /// /// The digest is a local optimization, not an RFC artifact or setting. pub const CostShapeDigest = [32]u8; /// Hash the framed draft-fossil-submanifests-00 §6.3 cost inputs in /// canonical F-card order. Every variable field carries its length, so /// adjacent values cannot alias a different sequence of fields. Callers /// own `out` and may persist it as a settled analysis memo. pub fn costShapeDigest( entries: []const artifact.FCard, out: *CostShapeDigest, ) []const u8 { var hasher = std.crypto.hash.sha2.Sha256.init(.{}); hashShapeLength(&hasher, entries.len); for (entries) |entry| { hashShapeEntry(&hasher, entry); } hasher.final(out); std.debug.assert(out.len == 32); return out; } fn hashShapeEntry(hasher: *std.crypto.hash.sha2.Sha256, entry: artifact.FCard) void { hashShapeBytes(hasher, entry.filename); hashShapeOptionalBytes(hasher, entry.perm, false); hashShapeOptionalBytes(hasher, entry.old_name, true); hashShapeLength(hasher, if (entry.hash) |hash| hash.len else 0); } fn hashShapeOptionalBytes( hasher: *std.crypto.hash.sha2.Sha256, value: ?[]const u8, canonical: bool, ) void { const present: [1]u8 = .{@intFromBool(value != null)}; hasher.update(&present); const bytes = value orelse return; const encoded_len = if (canonical) canonicalLen(bytes) else bytes.len; hashShapeLength(hasher, encoded_len); hasher.update(bytes); } fn hashShapeBytes(hasher: *std.crypto.hash.sha2.Sha256, bytes: []const u8) void { hashShapeLength(hasher, bytes.len); hasher.update(bytes); } fn hashShapeLength(hasher: *std.crypto.hash.sha2.Sha256, len: usize) void { std.debug.assert(@as(u128, len) <= std.math.maxInt(u64)); var buf: [8]u8 = undefined; std.mem.writeInt(u64, &buf, @intCast(len), .little); hasher.update(&buf); } /// §6.3's cost for one candidate k over one name set: "choose the value /// minimizing the S card bytes referencing the leaves plus the size-weighted /// mean leaf bytes". pub const Cost = struct { /// The candidate. k: u4, /// §6.2 step 2's flat outcome — fewer than two groups, so there are no /// leaves and no S cards. `total` is 0 and the value never wins a /// comparison against a real tree (see `best`). flat: bool, /// Number of height-0 groups (leaf sub-manifests). leaves: usize, /// Total tree depth in sub-manifest artifacts, leaf counted (0 if flat). heights: usize, /// "the S card bytes referencing the leaves" — the serialized bytes of /// every S card whose hash token names a LEAF, wherever that card lives /// (an interior sub-manifest, or the manifest's own S run for a /// depth-one tree). A total, as the wording says. s_bytes: usize, /// "the size-weighted mean leaf bytes" — sum(size^2)/sum(size) over the /// leaf artifacts. Read literally: the weight IS the size, so this is /// the expected leaf size seen by a uniformly-chosen leaf BYTE. (The /// neighbouring reading — weight by member count, the expected leaf size /// seen by a uniformly-chosen FILE — differs only where leaf size and /// member count diverge, and the draft names size, not membership.) weighted_mean_leaf_bytes: f64, /// The §6.3 objective: `s_bytes + weighted_mean_leaf_bytes`. total: f64, /// Flat F-card bytes for the same entries, for the §6.5 comparison. Not /// part of the objective; reported so a table shows what the tree costs /// against. flat_bytes: usize, }; /// Run §6.3's cost function for one k over `entries` (the same canonical, /// hashed, sorted F-card list `buildTree` takes). Advisory only: "The /// selection is advisory rather than canonical -- any pinned k yields /// conforming artifacts". pub fn cost( gpa: std.mem.Allocator, entries: []const artifact.FCard, k: u4, algo: artifact.HashAlgo, ) std.mem.Allocator.Error!Cost { std.debug.assert(inRange(k)); var flat_bytes: usize = 0; for (entries) |e| flat_bytes += flatFCardBytes(e); var built = try artifact.buildTree(gpa, entries, k, algo); defer built.deinit(); if (built.isFlat()) { return .{ .k = k, .flat = true, .leaves = 0, .heights = 0, .s_bytes = 0, .weighted_mean_leaf_bytes = 0, .total = 0, .flat_bytes = flat_bytes, }; } // A leaf sub-manifest's first card is F, an interior's is S (§6.2 steps // 2 and 3, and the card order the serializer emits). `subs` is height-0 // first, so the leaves are the run at the front — but keying on the card // letter rather than on a position the caller cannot see keeps this // independent of `TreeBuild`'s internal layout. var leaves: usize = 0; var size_sum: f64 = 0; var sq_sum: f64 = 0; for (built.subs) |sub| { std.debug.assert(sub.bytes.len != 0); const is_leaf = sub.bytes[0] == 'F'; std.debug.assert(is_leaf or sub.bytes[0] == 'S'); if (!is_leaf) continue; leaves += 1; const sz: f64 = @floatFromInt(sub.bytes.len); size_sum += sz; sq_sum += sz * sz; } std.debug.assert(leaves >= 2); // a non-flat build partitions at least in two // "the S card bytes referencing the leaves": walk every S card the tree // emits — the manifest's own run plus every interior sub-manifest's run // — and count the bytes of those whose hash token names a leaf. Bounded // by the tree's total card count. var s_bytes: usize = 0; for (built.s) |card| { if (namesLeaf(built.subs, card.hash)) s_bytes += sCardBytes(card); } for (built.subs) |sub| { if (sub.bytes[0] != 'S') continue; var it = std.mem.splitScalar(u8, sub.bytes, '\n'); while (it.next()) |line| { if (line.len < 2 or line[0] != 'S' or line[1] != ' ') continue; const sp = std.mem.lastIndexOfScalar(u8, line, ' ') orelse continue; if (namesLeaf(built.subs, line[sp + 1 ..])) s_bytes += line.len + 1; // + the card's LF } } const mean: f64 = if (size_sum == 0) 0 else sq_sum / size_sum; const heights = treeDepth(built.subs, built.s); return .{ .k = k, .flat = false, .leaves = leaves, .heights = heights, .s_bytes = s_bytes, .weighted_mean_leaf_bytes = mean, .total = @as(f64, @floatFromInt(s_bytes)) + mean, .flat_bytes = flat_bytes, }; } /// §6.3's selection over the whole supported range: the k minimizing /// `Cost.total`, or null when no k yields a tree at all (a name set too /// small to partition, §6.2 step 2). Ties go to the smaller k — arbitrary, /// but fixed, so the pass is deterministic. /// /// Advisory ONLY. §6.4: "A re-tune MUST be manual and operator-initiated; an /// implementation MAY advise of drift from the analyzed optimum but MUST NOT /// re-tune automatically". Nothing calls this from a commit path. /// §6.3: "The implementation SHOULD choose the candidate with the lowest /// C(k), and MUST choose the lowest k among candidates with equal minimum /// cost." The tie-break is the MUST, so the comparison is strict `<` and /// `costs` is walked in ascending k: an equal total never displaces the /// incumbent, which is the lower k. A tie is not a free choice — the pin is /// the repository's identity for content sharing (§6.4), so two writers /// breaking ties differently would silently stop sharing sub-manifests. pub fn best(costs: []const Cost) ?Cost { var winner: ?Cost = null; for (costs) |c| { if (c.flat) continue; if (winner) |w| { std.debug.assert(c.k > w.k); // ascending order: what makes `<` the tie-break if (c.total < w.total) winner = c; } else winner = c; } return winner; } /// One candidate k's replayed outcome over the window (#41). Both cost /// axes, with the bootstrap — the one-time first-emission of the whole /// forest — split from the steady state, so the verdict does not become a /// monotone function of the window length. pub const ReplayArm = struct { k: u4, /// Every window commit built flat at this k (§6.2 step 2): the arm is /// byte-identical to the flat history and carries no evidence either way. all_flat: bool, /// Sub-arm stored bytes charged to the window's FIRST commit — the /// forest bootstrap plus that commit's root. bootstrap_store: u64, /// Sub-arm stored bytes over the remaining commits: new/changed nodes /// at the writer's real choice of full-vs-delta container, plus roots. steady_store: u64, /// Sub-arm materialization bytes (plaintext a derive must produce): /// roots plus first-appearance nodes — memo hits are free, which is /// the §7.4 mechanism the whole bet rests on. bootstrap_full: u64, steady_full: u64, }; /// The #41 materiality reference: average flat-arm materialization per /// commit below which the F-card wall sits inside the format's own /// documented design envelope (~20k files, ~1.5 MB manifests — the load /// its designer said it was built for). NEVER a gate term: the evidence /// verdict is the operator's history versus itself, and an informed /// operator outranks this tool's opinion of what is worth caring about. /// It is printed beside a positive verdict so the operator weighs the /// ABSOLUTE cost — a 90% saving on 85 KB/commit is a different decision /// than 98% of 5 MB/commit — with the one-way door's price in view. pub const replay_materiality_reference: u64 = 1 << 20; /// The #41 replay: the operator's own last-`window` check-ins run through /// the real tree builder and the real delta encoder, per candidate k, /// against the flat arm's REAL, already-paid bytes. History predicts, it /// does not guarantee — but this is the strongest evidence the /// intertemporal structure of the enable decision permits. pub const Replay = struct { /// Check-ins actually replayed (≤ the requested window; capped by /// history, and commits whose file set cannot be enumerated are /// skipped and uncounted). commits: usize, /// The flat arm: real stored frame bytes of those manifests. flat_store: u64, /// The flat arm's materialization bytes (manifest plaintexts). flat_full: u64, /// The same two, excluding the first replayed commit — the steady /// state the sub arms' steady figures compare against. flat_store_steady: u64, flat_full_steady: u64, arms: []ReplayArm, pub fn deinit(self: *Replay, gpa: std.mem.Allocator) void { gpa.free(self.arms); self.* = undefined; } /// Average flat-arm materialization per steady-state commit — the /// absolute cost the materiality note reports. pub fn flatFullSteadyPerCommit(self: *const Replay) u64 { if (self.commits < 2) return 0; return self.flat_full_steady / @as(u64, @intCast(self.commits - 1)); } /// Whether this history sits inside the format's design envelope — /// report-only context beside a positive verdict, never a refusal. pub fn insideDesignEnvelope(self: *const Replay) bool { return self.flatFullSteadyPerCommit() < replay_materiality_reference; } /// The evidence verdict: the arm with the lowest steady-state /// materialization cost, or null when every arm stayed flat. pub fn bestArm(self: *const Replay) ?ReplayArm { var winner: ?ReplayArm = null; for (self.arms) |a| { if (a.all_flat) continue; if (winner == null or a.steady_full < winner.?.steady_full) winner = a; } return winner; } }; /// Run the replay over the last `window` check-ins (walk order — the /// order they were persisted) for each candidate in `ks`. See `Replay`. /// The flat arm is measured, never simulated: stored frame spans and /// manifest plaintext lengths from the live log. The sub arm runs /// `artifact.buildTree` per commit per k, credits content-addressed /// dedupe (an unchanged node costs nothing), and costs a changed node at /// the writer's REAL choice — the §4.1 container of the full form or of a /// [FossilDelta] against the same-position previous version, whichever is /// smaller — so the delta win the whole question turns on is neither /// assumed nor denied. Roots are re-serialized with the real writer (the /// S run replacing the F run) and costed the same two-way way. pub fn replay( gpa: std.mem.Allocator, repo: *Repository, window: usize, ks: []const u4, ) error{ OutOfMemory, Storage, Corrupt }!Replay { const codec = @import("container.zig"); const delta_mod = @import("delta.zig"); const all = repo.view.state.events.keys(); if (all.len == 0 or window == 0) return .{ .commits = 0, .flat_store = 0, .flat_full = 0, .flat_store_steady = 0, .flat_full_steady = 0, .arms = try gpa.alloc(ReplayArm, 0), }; const start_at = all.len -| window; const rids = all[start_at..]; // The repository's identity space names the algorithm: 40-hex is SHA-1. const algo: artifact.HashAlgo = if (repo.hashOf(rids[rids.len - 1]).len == 40) .sha1 else .sha3_256; const log = repo.logStore(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; var out: Replay = .{ .commits = 0, .flat_store = 0, .flat_full = 0, .flat_store_steady = 0, .flat_full_steady = 0, .arms = try gpa.alloc(ReplayArm, ks.len), }; errdefer gpa.free(out.arms); for (out.arms, ks) |*a, k| a.* = .{ .k = k, .all_flat = true, .bootstrap_store = 0, .steady_store = 0, .bootstrap_full = 0, .steady_full = 0 }; // Per-arm cross-commit memory: node names ever stored (dedupe), the // previous version of every (height, key) position (delta bases), and // the previous root. All owned copies — a build's arena dies with its // commit. const ArmState = struct { seen: std.StringHashMapUnmanaged(void) = .empty, prev_by_pos: std.StringHashMapUnmanaged([]u8) = .empty, prev_root: ?[]u8 = null, }; const arm_states = try gpa.alloc(ArmState, ks.len); defer { for (arm_states) |*st| { var kit = st.seen.keyIterator(); while (kit.next()) |kk| gpa.free(kk.*); st.seen.deinit(gpa); var pit = st.prev_by_pos.iterator(); while (pit.next()) |e| { gpa.free(e.key_ptr.*); gpa.free(e.value_ptr.*); } st.prev_by_pos.deinit(gpa); if (st.prev_root) |r| gpa.free(r); } gpa.free(arm_states); } for (arm_states) |*st| st.* = .{}; const raw_hash_len: u64 = if (algo == .sha1) 20 else 32; for (rids) |rid| { // The flat arm, measured. const c_hex = repo.hashOf(rid); var digest_buf: [logstore.max_hash_len]u8 = undefined; const c_raw = digest_buf[0 .. c_hex.len / 2]; _ = std.fmt.hexToBytes(c_raw, c_hex) catch continue; const c_logrid = log.lookup(c_raw) orelse continue; var owned = (repo.manifest(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => continue, }) orelse continue; defer owned.deinit(gpa); var fl = (repo.files(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => continue, }) orelse continue; defer fl.deinit(gpa); var entries: std.ArrayList(artifact.FCard) = .empty; defer entries.deinit(gpa); var it = fl.iterator(); while (it.next()) |f| try entries.append(gpa, f); const first = out.commits == 0; out.commits += 1; const flat_span = log.frameSpanOf(c_logrid); // The full-form axis charges what producing this commit's FILE // SET actually materializes. For a baseline manifest that is its // own plaintext; for a B-card DELTA manifest it is the delta PLUS // its baseline — "resolving a delta manifest requires loading, // parsing, and holding its full baseline in memory" (the reason // delta manifests never rescued the F-card wall), and `files` // above really did read both (`FileList.baseline`). Charging the // delta's own bytes alone hid a ~400x cost on a fossil-style // delta-manifest history and mis-verdicted the one corpus the // S-card design was written for. const flat_materialized: u64 = owned.bytes.len + (if (fl.baseline) |b| b.bytes.len else 0); out.flat_store += flat_span; out.flat_full += flat_materialized; if (!first) { out.flat_store_steady += flat_span; out.flat_full_steady += flat_materialized; } // The sub arms. for (out.arms, arm_states) |*arm, *st| { var build = try artifact.buildTree(gpa, entries.items, arm.k, algo); defer build.deinit(); if (build.isFlat()) { // §6.2 step 2: this commit stays flat at this k — the arm // pays exactly what the flat history paid. if (first) { arm.bootstrap_store += flat_span; arm.bootstrap_full += owned.bytes.len; } else { arm.steady_store += flat_span; arm.steady_full += owned.bytes.len; } continue; } arm.all_flat = false; var store_bytes: u64 = 0; var full_bytes: u64 = 0; // The root, with the real writer: S run in, F run out. var root_manifest = owned.manifest; root_manifest.f = &.{}; root_manifest.s = build.s; // A tree manifest is a BASELINE by construction (§4.4: never // both B and S): a replayed commit whose real manifest was a // delta still emits its tree root over the flattened set. root_manifest.b = null; const root_bytes = artifact.create(&root_manifest, gpa) catch return error.OutOfMemory; defer gpa.free(root_bytes); full_bytes += root_bytes.len; store_bytes += try replayFrameCost(gpa, codec, delta_mod, raw_hash_len, root_bytes, st.prev_root); if (st.prev_root) |r| gpa.free(r); st.prev_root = try gpa.dupe(u8, root_bytes); for (build.subs) |sub| { if (st.seen.contains(sub.name)) continue; // content-addressed dedupe: free full_bytes += sub.bytes.len; var poskey_buf: [8]u8 = undefined; const pos_prefix = std.fmt.bufPrint(&poskey_buf, "{d}:", .{sub.height}) catch unreachable; const poskey = try std.mem.concat(gpa, u8, &.{ pos_prefix, sub.key }); defer gpa.free(poskey); const prev = st.prev_by_pos.get(poskey); store_bytes += try replayFrameCost(gpa, codec, delta_mod, raw_hash_len, sub.bytes, prev); // Update cross-commit memory. const name_owned = try gpa.dupe(u8, sub.name); st.seen.put(gpa, name_owned, {}) catch |e| { gpa.free(name_owned); return e; }; const bytes_owned = try gpa.dupe(u8, sub.bytes); const gop = st.prev_by_pos.getOrPut(gpa, poskey) catch |e| { gpa.free(bytes_owned); return e; }; if (gop.found_existing) { gpa.free(gop.value_ptr.*); gop.value_ptr.* = bytes_owned; } else { gop.key_ptr.* = try gpa.dupe(u8, poskey); gop.value_ptr.* = bytes_owned; } } if (first) { arm.bootstrap_store += store_bytes; arm.bootstrap_full += full_bytes; } else { arm.steady_store += store_bytes; arm.steady_full += full_bytes; } } } return out; } /// The stored-frame cost of one replayed artifact: the writer's real /// choice between the full-form §4.1 container and a [FossilDelta] /// container against `prev` (when a previous same-position version /// exists), plus the log's frame overhead for the winner. fn replayFrameCost( gpa: std.mem.Allocator, comptime codec: type, comptime delta_mod: type, raw_hash_len: u64, bytes: []const u8, prev: ?[]const u8, ) error{OutOfMemory}!u64 { const full = codec.encode(gpa, bytes, .default) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return logstore.frameSpan(raw_hash_len, bytes.len), // uncodable: charge plaintext }; defer gpa.free(full); var best_len: usize = full.len; if (prev) |p| { if (delta_mod.create(gpa, p, bytes)) |d| { defer gpa.free(d); const dc = codec.encode(gpa, d, .default) catch null; if (dc) |c| { defer gpa.free(c); if (c.len < best_len) best_len = c.len; } } else |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => {}, } } return logstore.frameSpan(raw_hash_len, best_len); } /// §6.5's informative prediction: "the analyzed optimum tracks roughly half /// the base-2 logarithm of the file count". Reported beside the measurement /// so a table shows predicted against measured; never an input to a default. pub fn predictedK(file_count: usize) f64 { if (file_count < 2) return 0; return 0.5 * @log2(@as(f64, @floatFromInt(file_count))); } /// draft-fossil-submanifests-00 §6.4's relative margin `m`, expressed as a /// percentage of the pinned value's own §6.3 objective: "The writer MUST /// retain the current value unless the candidate is distinct and /// C_candidate < (1 - m) * C_current, where m is a documented, /// implementation-defined relative margin satisfying 0 < m < 1." This /// comment is that documentation. Grounded in two measurements: a real /// "fossil" project corpus (983 files) shows k=4 vs k=5 within ~5% of /// each other -- "close to arbitrary on cost grounds", and MUST be /// refused; a zit-repo-shaped synthetic set (112 files) shows the pinned /// k=1 vs the true k=3 optimum at ~57.8% -- real drift, and MUST be /// recommended. 15% clears the first case with room to spare while still /// catching the second (every measured real-drift case lands far /// larger). §6.4 leaves m implementation-defined and calls the selection /// objective and margin "advisory rather than canonical": an /// implementation choice, not a canonical value. /// /// §6.4 requires m to be DOCUMENTED, and a source comment an operator /// never reads does not discharge that. The normative operator-facing /// statement of this value is the sub-manifest advisory paragraph of /// `commit_help` (`main.zig`), which `zit commit --help` prints; `e2e.zig` /// holds an independent transcription of that block. /// /// Two separate gates keep the three in agreement, because the pair of them /// is what the first draft of this comment wrongly claimed of one: the e2e /// help comparison pins `commit_help` against the `e2e.zig` transcription, /// and the unit test "commit help states the live §6.4 margin, not a copy /// of it" (`main.zig`) renders THIS constant and requires `commit_help` to /// contain it. Without the second, both help texts could keep saying "15%" /// after this line changed and every lane would stay green. pub const drift_margin_pct: f64 = 15.0; /// The result of a drift check that recommends a re-tune: the winning /// `Cost` (so the caller has the candidate k and its measurements /// without a second pass) and the margin by which it beats the pinned /// value's own objective, as a percentage of the pinned objective. pub const DriftCheck = struct { recommended: Cost, margin_pct: f64, }; /// draft-fossil-submanifests-00 §6.4's drift-check core: whether /// `pinned_k`'s current §6.3 objective has drifted far enough from the /// analyzed optimum to RECOMMEND a re-tune. Pure and side-effect-free -- /// it does not retune, write config, or prompt anyone; the caller /// decides what to do with the answer. §6.4: "An implementation MAY advise /// of drift from the analyzed optimum at any time." It is also the margin /// gate §6.4 requires of a writer comparing the deterministic candidate /// against the current pinned value, so the automatic re-tune path -- which /// §6.4 permits only while `submanifest-auto-retune` is true -- consults /// this same answer instead of computing a second one. /// /// Costs every k in 1..12 (exactly 12 `cost` calls -- §6.3's whole /// supported range, the same "operator-invoked measurement, not a /// commit-path cost" `namesLeaf` already accepts) and returns null when: /// - no k partitions at all (§6.2 step 2 -- nothing to compare against), /// - the pinned k itself does not partition (out of this check's /// scope -- that is §6.3's flat-fallback case, not a re-tune), /// - the pinned k already IS the analyzed optimum (nothing to /// recommend), or /// - the pinned k's margin against the optimum does not EXCEED /// `drift_margin_pct` (an exact-margin case is refused, not accepted: /// §6.4's gate is the strict "C_candidate < (1 - m) * C_current", so /// equality leaves the current pin in place). /// /// Confirmed contract decision, not a unilateral shortcut: this always /// runs the full 1..12 §6.3 cost scan and never gates on `predictedK`. /// A cheap tier keyed on round(predictedK(file_count)) == pinned_k was /// considered and rejected -- it would return null without computing a /// single real cost whenever file count alone happened to imply the /// pinned k, which is a false negative in the one thing this check /// exists to catch: the real §6.3 objective depends on leaf SIZE /// distribution, not just count, so a count-matching k can still carry /// real, margin-clearing drift. This is evidenced, not asserted -- the /// test "submanifests §6.4: predictedK never gates the check -- a /// count-matching k with real drift still recommends" exhibits a real /// (n, pinned_k) pair where exactly that happens. The scan stays cheap /// in absolute terms regardless -- exactly 12 `cost` calls, the same /// "operator-invoked measurement, not a commit-path cost" `namesLeaf` /// already accepts -- so there is no efficiency case left for accepting /// the false-negative risk. pub fn driftCheck( gpa: std.mem.Allocator, entries: []const artifact.FCard, pinned_k: u4, algo: artifact.HashAlgo, ) std.mem.Allocator.Error!?DriftCheck { std.debug.assert(inRange(pinned_k)); var costs: [artifact.max_k - artifact.min_k + 1]Cost = undefined; var n: usize = 0; var pinned_cost: ?Cost = null; var k: u4 = artifact.min_k; while (true) : (k += 1) { const c = try cost(gpa, entries, k, algo); if (k == pinned_k) pinned_cost = c; costs[n] = c; n += 1; if (k == artifact.max_k) break; } const winner = best(costs[0..n]) orelse return null; const pinned = pinned_cost.?; // pinned_k in 1..12, scanned above if (pinned.flat) return null; if (winner.k == pinned.k) return null; const margin_pct = marginGate(pinned.total, winner.total) orelse return null; return .{ .recommended = winner, .margin_pct = margin_pct }; } /// The exact boundary arithmetic `driftCheck` gates on, pulled out as its /// own pure function so the strict (not `>=`) exact-margin-percent /// comparison is unit-testable with a manufactured exact value -- real /// hash-derived costs essentially never land on exactly /// `drift_margin_pct` by chance, so a test built only from real `cost()` /// output could never exercise this boundary. Returns null when the /// margin does not EXCEED `drift_margin_pct` (an exact-margin case is /// refused, not accepted: §6.4's gate is the strict "C_candidate < /// (1 - m) * C_current", so equality leaves the current pin in place), /// else the margin percentage. /// /// `winner_total` is always `<= pinned_total` at every call site (`best` /// minimizes `.total` over the same non-flat scan `pinned` is drawn /// from), so the division below can never go negative or by zero -- /// asserted, not merely assumed. fn marginGate(pinned_total: f64, winner_total: f64) ?f64 { std.debug.assert(pinned_total > 0.0); std.debug.assert(winner_total <= pinned_total); const margin_pct = (pinned_total - winner_total) / pinned_total * 100.0; std.debug.assert(margin_pct >= 0.0); if (margin_pct <= drift_margin_pct) return null; return margin_pct; } /// Serialized byte length of one S card including its LF: `S <name> <hash>\n` /// under §6.2's canonical (minimal) escaping. fn sCardBytes(card: artifact.SCard) usize { return 1 + 1 + canonicalLen(card.filename) + 1 + card.hash.len + 1; } /// Serialized byte length of one flat manifest F card including its LF, for /// the §6.5 comparison baseline. Same shape the manifest writer emits. fn flatFCardBytes(f: artifact.FCard) usize { var n: usize = 1 + 1 + canonicalLen(f.filename); // "F" SP name if (f.hash) |h| n += 1 + h.len; if (f.perm) |p| n += 1 + p.len; if (f.old_name) |o| n += 1 + canonicalLen(o); return n + 1; // LF } /// §6.2's canonical encoding is minimal escaping — SP, LF and backslash /// each become two bytes, every other octet is emitted raw — so the encoded /// length is countable without materializing the encoding. fn canonicalLen(name: []const u8) usize { var n: usize = 0; for (name) |c| n += if (c == ' ' or c == '\n' or c == '\\') @as(usize, 2) else 1; return n; } /// Whether `hash` names one of the tree's LEAF sub-manifests. Linear over /// `subs`; the analysis pass is an operator-invoked measurement, not a /// commit-path cost, so the simple scan is the right trade. fn namesLeaf(subs: []const artifact.SubArtifact, hash: []const u8) bool { for (subs) |sub| { if (std.mem.eql(u8, sub.name, hash)) return sub.bytes[0] == 'F'; } return false; } /// Artifacts on the longest root-to-leaf path — what §5.1's depth bound /// counts, the leaf included and the manifest excluded. /// /// The construction is uniform in height, so every path has the same length /// and descending the FIRST child of each level is the whole answer: start /// at the manifest's own S run, and step down while the node it names is /// interior. Bounded by `artifact.max_construct_height`, the height cap /// §6.2 step 3 enforces. fn treeDepth(subs: []const artifact.SubArtifact, s: []const artifact.SCard) usize { std.debug.assert(s.len >= 2); var name: []const u8 = s[0].hash; var depth: usize = 0; while (depth < artifact.max_construct_height) { const sub = findSub(subs, name) orelse break; depth += 1; if (sub.bytes[0] != 'S') break; // a leaf ends the path const first = firstSCardHash(sub.bytes) orelse break; name = first; } std.debug.assert(depth >= 1 and depth <= artifact.max_construct_height); return depth; } fn findSub(subs: []const artifact.SubArtifact, name: []const u8) ?artifact.SubArtifact { for (subs) |sub| { if (std.mem.eql(u8, sub.name, name)) return sub; } return null; } /// The hash token of the first S card in an interior sub-manifest's bytes. /// An S card is `S <name> <hash>` and SP delimits tokens, so the hash is the /// text after the LAST SP of the line (§5.6 makes exactly this argument for /// the shell walk). fn firstSCardHash(bytes: []const u8) ?[]const u8 { const nl = std.mem.indexOfScalar(u8, bytes, '\n') orelse return null; const line = bytes[0..nl]; if (line.len < 2 or line[0] != 'S' or line[1] != ' ') return null; const sp = std.mem.lastIndexOfScalar(u8, line, ' ') orelse return null; return line[sp + 1 ..]; } }; /// The lazily-opened content channel: the reopened `.zit` handle, the /// `FileStorage` binding over it (pinned — the `LogStore` borrows it), and the /// scanned read-only log. Absent until the first `manifest`/`blob`/`logStore`. const Content = struct { file: std.Io.File, fs: storage.FileStorage, log: logstore.LogStore, }; pub const Repository = struct { /// Evidence seam (zit#172): every manifest read through the facade — a /// store read, an inflate, and a parse apiece. A listing that renders one /// row per check-in must not grow this with the row count: the derived /// state answers comment, user, branch and tags, and a read here means a /// verb fell back to the artifact for something already materialized. /// /// Counted rather than timed. Wall clock on a shared machine varies by a /// third run to run, which is why `readpath_test.zig` calls a timing /// assertion "a flaky one"; an integer does not vary at all. `zit` reports /// it on exit under `ZIT_MANIFEST_READS`, and the e2e suite pins that the /// count stays flat as history grows. pub var manifest_reads: u64 = 0; io: std.Io, /// The checkout root, discovered and owned by the caller (the caller closes /// it after `deinit`). Borrowed here to reopen `.zit` for lazy content. /// /// #59 — for a DETACHED worktree this is the RESOLVED STORE root, not the /// checkout the caller discovered: `open` follows the checkout's /// `store_location` once, and every later store and `.zit-cache` access /// in this struct inherits the resolution. `owns_root_dir` records that /// the handle is this struct's to close. root_dir: std.Io.Dir, owns_root_dir: bool, checkout_root: std.Io.Dir, /// The directory the repository was OPENED FROM -- the invoking checkout's /// root, before `storeDirOf` resolution. Borrowed from the caller, whose /// deinit order (repo first, dir after) makes that safe. Checkout-state /// reads (`currentCheckoutRid`) go here, never `root_dir`: for a detached /// worktree the store dir IS another checkout's home, and reading /// `.zit-checkout` there made `leaves`/`branch`/`ls`/`update`/`merge` in /// worktree B answer with worktree A's branch as "current" while `status` /// (which reads the invoking dir) told the truth -- the tree-vs-status /// disagreement of #116. /// The crosslinked derived view, loaded in place by `open`. `state` borrows /// `&view.store`, so this whole struct is caller-pinned. view: repoview.RepoView, /// The content channel, opened lazily on the first content request. Held /// inline (pinned) because the `LogStore` borrows `&content.?.fs`. content: ?Content, /// The `open` call's `bypass_cache`, retained so the LAZY surfaces opened /// after `open` returns — the content channel and its persisted scan index /// — honor the same "trust nothing derived" promise (`--no-cache` is one /// flag with one meaning, not a per-sidecar toggle). bypass_cache: bool, /// submanifests §7.4 — "Implementations SHOULD cache per-sub-manifest /// enumeration results keyed by artifact name: unchanged subtrees are shared /// by hash across check-ins ... A naive implementation obtains the byte /// reduction of this format but not the parse-count reduction that motivated /// it." /// /// Repository-lifetime, not per-call, because ACROSS CHECK-INS is where the /// sharing is: §5.1's repeated-artifact check forbids one enumeration from /// visiting the same sub-manifest twice, and §4.4 makes B and S exclusive so /// a single `files` call flattens either a target or a baseline, never both. /// The parse-count reduction therefore only exists for a caller that /// enumerates several check-ins from one open repository — the merge path /// (`cmd/checkout`, three `files` calls over a base, a current and an other) /// being the standing case. Opened lazily: a repository with no tree /// manifest never allocates it. /// /// Safe because content addressing makes a POSITIVE answer immutable, and /// the memo caches nothing else; see `repostate.SubManifestMemo`. sub_memo: ?repostate.SubManifestMemo, /// The checkout's check-in, when the caller has one (zit#161): what the /// Fossil names `current`/`ckout`, `prev`/`previous` and `next` are /// relative to. Null for a bare store, where those names are NotFound. current_hint: ?Rid = null, /// The derived-query identity space: a `RepoState` rid (the view's store). pub const Rid = repostate.Rid; /// Options for `open`. `bypass_cache` bypasses BOTH derived sidecars — /// the O(1) `.zit-cache` fast path (and its extension) AND the /// `.zit-cache/checkpoint` prefix resume — forcing the authoritative full /// `openReadOnly` scan + derive (the `--no-cache` escape hatch: one flag, /// one meaning — trust nothing derived); the derived view still rewrites /// a fresh cache afterward. pub const OpenOptions = struct { bypass_cache: bool = false, /// Which cache representation to serve. Defaults to `.full` (derive the /// complete `RepoState` — every query answerable), so a caller that /// says nothing keeps the conservative behavior. Since #98 the image /// carries adjacency and the resolved §5 tag surface, so the read verbs /// pass `.fast_read` and take the zero-parse path — `ls`, `cat`, /// `info`, `branch`, `tag`, `leaves`, `artifact` and `diff` among them. /// `.full` is for the WRITE verbs, which cannot use a read-only image, /// and for the few readers that genuinely need what the image does not /// carry: `timeline` reads the §3.2 merge mark off adjacency. /// /// This defaulting is a trap worth naming, because #141 was exactly it: /// `diff` opened the repository directly rather than through /// `main.openRepo`, inherited `.full` by omission, and paid a /// whole-store derive per invocation for two file lists. Nothing failed /// and no test noticed — it was merely 9x slower on a large store. A /// verb that reaches past the funnel is choosing this default blind. mode: repocache.LoadMode = .full, }; /// Every failure the content-and-open surface can surface, as a single /// closed set the CLI switches exhaustively. `NotARepo`: no `.zit` under the /// given root. `CannotRead`: an I/O error opening/reading it. `Corrupt`: a /// structurally invalid log or a stored artifact whose content does not /// match its name (artifact §7.2). `OutOfMemory`: allocation failure. pub const OpenError = error{ NotARepo, CannotRead, Corrupt, OutOfMemory }; /// Name resolution failure (repo-state §8.3), forwarded unchanged from /// `RepoState.resolve` so the CLI keeps one exhaustive switch. /// `OutOfMemory` rides along because settling an equal-`event.mtime` tie by /// DAG ancestry allocates (repo-state `MtimeMaxPick`). pub const ResolveError = error{ NotFound, Ambiguous, OutOfMemory }; /// Discoverless open: `root_dir` is the already-found checkout root. Opens /// `.zit`, serves the view from the `.zit-cache` fast path or derives it /// from the log, then closes the file (the view is self-contained). On /// success the caller owns the value and must `deinit` it; on any error /// nothing is retained and `deinit` must not be called. pub fn open( self: *Repository, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, opts: OpenOptions, ) OpenError!void { self.io = io; // One resolution, inherited by every store and cache access below. A // checkout whose pointer cannot be followed is unreadable, the same // verdict as a missing `.zit`. self.checkout_root = root_dir; if (checkout.storeDirOf(io, root_dir) catch return error.CannotRead) |resolved| { self.root_dir = resolved; self.owns_root_dir = true; } else { self.root_dir = root_dir; self.owns_root_dir = false; } errdefer if (self.owns_root_dir) self.root_dir.close(io); self.content = null; self.bypass_cache = opts.bypass_cache; self.sub_memo = null; // submanifests §7.4 — opened on the first tree walk self.current_hint = null; // zit#161 — set by the CLI when a checkout exists // Cleared per attempt, not per view: `Repository` is declared `undefined` // by its callers, so a stale stack value here would name an artifact // this open never looked at. self.view.fault = null; self.view.finfo_sink = null; var file = self.root_dir.openFile(io, repo_filename, .{}) catch |e| return openFileError(e); defer file.close(io); const st = file.stat(io) catch return error.CannotRead; if (st.kind != .file) return error.CannotRead; var fs = storage.FileStorage.init(io, file, st.size); // Fast path (repo-state §1 memo): a `.zit-cache` still valid against the // live log's tail serves the whole view in O(1), no full scan. A miss // is never an error — fall through to the authoritative derive. if (!opts.bypass_cache and repocache.tryLoad(gpa, io, self.root_dir, fs.storageFile(), &self.view, opts.mode)) return; var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return openReadOnlyError(e); defer log.deinit(gpa); // Extension path: the cache missed on its exact tail, but if the log // merely GREW past it -- commit, pull -- the stored record body is // still a faithful distillate of the prefix, and only the tail needs // deriving. Replay the body, fold the new frames, rewrite the cache. // This is what keeps the full-store derive a once-per-repository cost // instead of a once-per-write one: every append is healed by the next // read at O(tail), not O(store). // // Refused whenever the fold could be wrong rather than merely stale: // a shun-table change (the body may carry a record the mask must now // hide) or a rewritten log (pack, shun --force -- the old tail no // longer verifies at its offset). Both fall through to the // authoritative full derive below. if (!opts.bypass_cache) { if (repocache.loadForExtension(gpa, io, self.root_dir)) |raw_const| { var raw = raw_const; defer raw.deinit(gpa); if (raw.shun_fp == log.shunFingerprint() and logstore.LogStore.validatePrefix(fs.storageFile(), raw.key) and self.extendFromPrefix(gpa, io, self.root_dir, &log, &raw)) { checkpoint.advance(gpa, io, self, self.root_dir); return; } } } // Checkpoint path (hybrid-history DESIGN §4): no serviceable cache, // but a validated `.zit-cache/checkpoint` lets the derive REPLAY the // flat prefix's record body and walk only the frames past it. Guards, // each a silent fall-through to the full derive: the container and // prefix key validate (`checkpoint.tryLoad`), the shun table has not // moved since capture, and the anchor check-in still resolves in the // store under the §5.2 hard-failure discipline. if (!opts.bypass_cache) { if (checkpoint.tryLoad(gpa, io, self.root_dir, fs.storageFile())) |loaded_const| { var loaded = loaded_const; defer loaded.deinit(gpa); if (loaded.claim.shun_fp == log.shunFingerprint() and checkpointAnchorsResolve(gpa, &log, &loaded.claim) and self.resumeFromCheckpoint(gpa, io, self.root_dir, &log, &loaded)) { checkpoint.advance(gpa, io, self, self.root_dir); return; } } } // Derive from the log, capturing the crosslink records, then materialize // the zero-parse image from the fresh derived state and rewrite the // cache atomically (best-effort — the derived view is correct either // way; a failed image serialize just stores an empty image section, so // the next `.fast_read` falls back to a derive rather than erroring). var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); // The disposition sidecar (zit#182): a valid map lets the derive skip // reconstructing every frame that cannot contribute a record — file // blobs and tree nodes, the bulk of a wide store's walk. The walk // distills a fresh map either way, stored beside the cache below. const disp_blob = repocache.tryLoadDispositions(gpa, io, self.root_dir); defer if (disp_blob) |b| gpa.free(b); const disp: ?repoview.Dispositions = if (disp_blob) |b| repoview.parseDispositions(b, &log) else null; var disp_built: ?repoview.BuiltDispositions = null; defer if (disp_built) |*b| b.deinit(gpa); // fossil's mlink at ingest: the derive hands each check-in's change set // to this builder as it links it, and the index is stored beside the // cache — so a file's history is an index lookup from the first query, // and the whole-repo cost is paid here, once, where fossil pays it too. var finfo = fileindex.Builder.init(gpa); defer finfo.deinit(); self.view.loadCapturingDispositions(gpa, &log, &body, if (disp) |*d| d else null, &disp_built, finfo.sink()) catch |e| return loadError(e); storeCache(gpa, io, self.root_dir, &log, &self.view, body.items); storeFileIndexFrom(gpa, io, self.root_dir, &log, &finfo); if (disp_built) |*b| { if (repoview.serializeDispositions(gpa, &log, b) catch null) |disp_bytes| { defer gpa.free(disp_bytes); repocache.storeDispositions(io, self.root_dir, disp_bytes); } } // The healing paths keep an existing checkpoint fresh (sidecar-only, // behind growth and anchor-progress gates — see `checkpoint.advance`), // and the full derive PLANTS one where none exists — this is the one // event that proves a checkpoint would have paid for itself, and the // one place its payload is already in hand. The O(1) cache fast path // above never pays either: a stale checkpoint implies a // stale-or-missing cache, so staleness always surfaces here. if (!opts.bypass_cache) { checkpoint.autoCreate(gpa, io, self, self.root_dir, &log, body.items); checkpoint.advance(gpa, io, self, self.root_dir); } } /// §5.2 hard-failure discipline on the claim's anchor: C must be present, /// non-phantom, verify as its own name, and parse as a check-in manifest. /// Absent, unverifiable, unparseable and wrong-type all collapse to one /// refusal, exactly the shape `update.TreeStore.resolve` holds tree /// resolution to. fn checkpointAnchorsResolve(gpa: std.mem.Allocator, log: *logstore.LogStore, claim: *const checkpoint.Claim) bool { return anchorResolves(gpa, log, claim.checkin_hash, .manifest); } fn anchorResolves(gpa: std.mem.Allocator, log: *logstore.LogStore, hex: []const u8, want: artifact.ArtifactType) bool { var digest: [logstore.max_hash_len]u8 = undefined; if (hex.len == 0 or hex.len / 2 > digest.len) return false; const raw = digest[0 .. hex.len / 2]; _ = std.fmt.hexToBytes(raw, hex) catch return false; const rid = log.lookup(raw) orelse return false; const bytes = (log.content(gpa, rid) catch return false) orelse return false; // phantom defer gpa.free(bytes); if (!artifact.verify(hex, bytes)) return false; var im = artifact.parseForImport(gpa, bytes) catch return false; defer im.deinit(gpa); return im.manifest.kind == want; } /// The checkpointed derive: replay the prefix record body, walk only the /// tail, rewrite the `.zit-cache` from the combined body — which /// `RepoView.resumeFrom` guarantees is byte-identical to a full derive's, /// so the checkpoint stays invisible to every downstream consumer. /// Returns false on any failure with the view torn down, so `open` falls /// through to the full derive: the checkpoint is an optimization and must /// never be a new way to fail. fn resumeFromCheckpoint( self: *Repository, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *logstore.LogStore, loaded: *const checkpoint.Loaded, ) bool { var combined: std.ArrayList(u8) = .empty; defer combined.deinit(gpa); self.view.resumeFrom(gpa, log, loaded.body, loaded.claim.prefix_key.end, &combined) catch return false; storeCache(gpa, io, root_dir, log, &self.view, combined.items); return true; } /// The extension itself: replay the prefix body, fold the tail, store the /// combined cache. Returns false on any failure with the view torn back /// down, so `open` falls through to the full derive -- extension is an /// optimization and must never be a new way to fail. fn extendFromPrefix( self: *Repository, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *logstore.LogStore, raw: *const repocache.RawCache, ) bool { // The CURRENT config's main branch, not the cached one: a config frame // in the tail may have renamed it, and the fold itself never depends // on the name -- it only labels queries. var oc = log.readConfig(gpa) catch return false; defer if (oc) |*c| c.deinit(gpa); const main_branch = if (oc) |c| (if (c.config.default_branch.len != 0) c.config.default_branch else raw.main_branch) else raw.main_branch; self.view.buildFromRecordBody(gpa, raw.body, main_branch) catch return false; var tail: std.ArrayList(u8) = .empty; defer tail.deinit(gpa); // Extend the file index over the same tail, from the stored one when it // covers exactly what the checkpoint covers; otherwise leave it to the // lazy build, which extends from wherever it actually stopped. var finfo = adoptFileIndex(gpa, io, root_dir, log, raw.key.frame_count); defer if (finfo) |*b| b.deinit(); self.view.extendCapturing(gpa, log, raw.key.end, &tail, if (finfo) |*b| b.sink() else null) catch { self.view.deinit(gpa); return false; }; const combined = std.mem.concat(gpa, u8, &.{ raw.body, tail.items }) catch { // The view is complete and correct; only the cache rewrite is // lost. Serve the view and let the next reader try again. return true; }; defer gpa.free(combined); storeCache(gpa, io, root_dir, log, &self.view, combined); if (finfo) |*b| storeFileIndexFrom(gpa, io, root_dir, log, b); return true; } /// The stored file index as a builder to extend over a tail, or null when /// there is none to adopt — absent, stale, unreadable, or covering less /// than the checkpoint does. That last guard is load-bearing: the tail's /// rows must land on an index that already holds everything before them, /// or the result would claim coverage over a gap and omit history. With /// nothing to adopt, the lazy on-query build stands in and extends from /// wherever the stored index actually stopped. fn adoptFileIndex(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *logstore.LogStore, covered: u64) ?fileindex.Builder { const stored = repocache.tryLoadFileIndex(gpa, io, root_dir) orelse return null; defer gpa.free(stored); const idx = fileindex.parse(stored, log) orelse return null; // Like for like: the index records the log key's frame count at build // time, the same unit the checkpoint's key carries. Its entry count is // a different unit and must not be compared here. if (idx.key_frame_count != covered) return null; // And complete: the on-query build checkpoints PARTIAL indexes under // the live log's key so it can resume, and one of those whose key // matched the checkpoint's was once adopted here as if whole, which // claimed coverage over the gap and silently omitted history. A partial // index is left for that build to resume from where it stopped. if (!idx.complete()) return null; var b = fileindex.Builder.init(gpa); b.absorb(&idx) catch { b.deinit(); return null; }; return b; } /// Serialize a derive's file index over the whole log and store it beside /// the cache. Best-effort like every cache member: a refused serialize /// stores nothing, and the lazy on-query build makes one on demand. fn storeFileIndexFrom(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *logstore.LogStore, b: *fileindex.Builder) void { const bytes = (fileindex.serialize(gpa, log, b, @intCast(log.entryCount())) catch null) orelse return; defer gpa.free(bytes); repocache.storeFileIndex(io, root_dir, bytes); } /// Frees the derived view and closes the lazy content channel if it was /// opened. Does NOT close `root_dir` — the caller that discovered it owns /// its lifetime. Call only on a `Repository` that `open` returned `ok`. pub fn deinit(self: *Repository, gpa: std.mem.Allocator) void { if (self.content) |*c| { c.log.deinit(gpa); c.file.close(self.io); } if (self.sub_memo) |*m| m.deinit(); if (self.owns_root_dir) self.root_dir.close(self.io); self.view.deinit(gpa); self.* = undefined; } /// Eagerly materialize the `.zit-cache` for the repository at `root_dir` so /// the first read verb after a fresh clone takes the zero-parse image fast /// path instead of paying the full cold derive. It runs `open`'s own /// read-miss path purely for its cache-writing side effect, then discards the /// view. The cache is disposable, so all failures are deliberately ignored. pub fn warmCache(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir) void { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch return; repo.deinit(gpa); } /// Best-effort cache warming from a caller-owned, already-open log. This is /// for write paths that have committed their log and must not reopen it just /// to derive the cache. It uses the same capture, image, and cache writer as /// `open`; cache-write or derivation failures leave the repository unchanged. pub fn warmCacheFromLog( gpa: std.mem.Allocator, io: std.Io, checkout_root: std.Io.Dir, log: *logstore.LogStore, ) void { // Same one-time resolution as `open`: the cache lives with the STORE. // Failure to resolve just skips the warm — it is an optimization. var resolved = checkout.storeDirOf(io, checkout_root) catch return; const root_dir = if (resolved) |*r| r.* else checkout_root; defer if (resolved != null) root_dir.close(io); var view: repoview.RepoView = undefined; var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); // Distill the disposition sidecar during the one walk that must touch // everything anyway (zit#182), so the reopen below and every later // full derive skip the irrelevant frames. Nothing to consume here: a // fresh clone has no map yet. var disp_built: ?repoview.BuiltDispositions = null; defer if (disp_built) |*b| b.deinit(gpa); // The one walk that touches everything builds the file index too, so a // fresh clone answers a file's history from its first query. var finfo = fileindex.Builder.init(gpa); defer finfo.deinit(); view.fault = null; view.loadCapturingDispositions(gpa, log, &body, null, &disp_built, finfo.sink()) catch return; storeCache(gpa, io, root_dir, log, &view, body.items); storeFileIndexFrom(gpa, io, root_dir, log, &finfo); if (disp_built) |*b| { if (repoview.serializeDispositions(gpa, log, b) catch null) |disp_bytes| { defer gpa.free(disp_bytes); repocache.storeDispositions(io, root_dir, disp_bytes); } } // The view's only consumer was the cache write just made; free it // BEFORE the reopen below builds its own, so the derived state is // never resident twice (zit#182). The reopen and the checkpoint read // everything they need from the cache and from `body`. view.deinit(gpa); // The derive body is in hand — the same free-plant moment `open`'s // full-derive path exploits — so a clone (or any warmed write path) // ends fully warm: cache, checkpoint, and the content-scan index the // fingerprint pays for, which a first read would have paid anyway. // The facade reopen takes the just-written cache's fast path. var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch return; defer repo.deinit(gpa); checkpoint.autoCreate(gpa, io, &repo, root_dir, log, body.items); } fn storeCache( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *const logstore.LogStore, view: *const repoview.RepoView, records: []const u8, ) void { if (log.logKey()) |key| { var image: std.ArrayList(u8) = .empty; defer image.deinit(gpa); repoimage.serialize(gpa, &view.state, view.main_branch, &image) catch image.clearRetainingCapacity(); repocache.store(gpa, io, root_dir, key, log.shunFingerprint(), view.main_branch, records, image.items); } else |_| {} } /// The derived `RepoState` — the leaf table, branch/tag derivations, event /// times, and ancestry (repo-state §6-9). The view already hides the derive; /// exposing the query surface directly keeps this facade from becoming a /// shallow pass-through over a dozen `RepoState` methods. pub fn state(self: *const Repository) *const repostate.RepoState { return &self.view.state; } /// The repo-state §6.1 default-branch name (config `main-branch` when set, /// else `"trunk"`) — the fallback `branchOf` threads. pub fn mainBranch(self: *const Repository) []const u8 { return self.view.main_branch; } /// The lowercase-hex artifact name of a derived rid — what a verb prints and /// compares against the current checkout. pub fn hashOf(self: *Repository, rid: Rid) []const u8 { return self.view.hashOf(rid); } /// The derived rid for a lowercase-hex artifact name, or null when the name /// was never interned in this view. pub fn lookup(self: *Repository, hash: []const u8) ?Rid { return self.view.lookup(hash); } /// Resolve a symbolic name / id / prefix to a rid via the repo-state §8.3 /// procedure (recency-based, §8.2 — closed-ness and leaf-ness never filter /// the candidate). Errors forwarded unchanged so the CLI switches once. pub fn resolve(self: *const Repository, name: []const u8, want: repostate.RepoState.NameType) ResolveError!Rid { // The store's resolver requires a non-empty name (a precondition, not // a lookup); an empty argument from the command line is simply a name // nothing matches (zit#171). if (name.len == 0) return error.NotFound; // Fossil's checkout-relative names (zit#161), answered before the // repository-wide procedure so a branch or tag spelled `current` // cannot shadow them. Nothing here changes what §8.3 does with every // other name. if (self.current_hint) |cur| { if (std.mem.eql(u8, name, "current") or std.mem.eql(u8, name, "ckout")) return cur; if (std.mem.eql(u8, name, "prev") or std.mem.eql(u8, name, "previous")) { var pit = self.view.state.parentsOf(cur); while (pit.next()) |p| if (self.view.state.mtimeOf(p) != null) return p; return error.NotFound; } if (std.mem.eql(u8, name, "next")) { // The youngest child, as Fossil defines it. var best: ?Rid = null; var best_mtime: repostate.Mtime = 0; var kit = self.view.state.edgesOf(.children, cur); while (kit.next()) |edge| { const m = self.view.state.mtimeOf(edge.rid) orelse continue; if (best == null or m > best_mtime) { best = edge.rid; best_mtime = m; } } return best orelse error.NotFound; } } return self.view.state.resolve(name, want); } /// Record the checkout's check-in for the checkout-relative names; a /// hash the store does not hold leaves the hint unset. pub fn setCurrentHint(self: *Repository, version_hex: []const u8) void { if (version_hex.len == 0) return; // Through the same procedure a hash name takes, so the hint lives in // the rid space the resolver and every reader use. self.current_hint = self.view.state.resolve(version_hex, .checkin) catch null; } /// The active-tag branch of `rid` (repo-state §6.1), config `main-branch` as /// the fallback. Folds the fallback so callers never pass it — precondition: /// `rid` bears an event (`state.mtimeOf(rid) != null`). pub fn branchOf(self: *const Repository, rid: Rid) []const u8 { return self.view.state.branchOf(rid, self.view.main_branch); } /// The active display-override value of tag `name` on `rid` (repo-state /// §5.2), or `fallback` when no override is live. A `zit amend` control /// artifact's singleton override lands in `tagxref` on the target rid at the /// next read (repo-state §4/§5), so the render sites read it here without any /// override-precedence logic of their own. General over the tag name — one /// accessor serves the comment/user/date overrides. pub fn override(self: *const Repository, rid: Rid, kind: repoimage.DisplayOverride, fallback: []const u8) []const u8 { return self.view.state.displayOverride(rid, kind) orelse fallback; } /// Merge base of `a` and `b` over full ancestry: the common ancestor with the /// greatest event mtime, ties broken by the least artifact hash, or null when /// they share no ancestor (repo-state `mergeBaseOf`; ambiguities.md /// `merge-base`). A thin pass-through over the one general-purpose query. pub fn mergeBaseOf(self: *const Repository, gpa: std.mem.Allocator, a: Rid, b: Rid) error{OutOfMemory}!?Rid { return self.view.state.mergeBaseOf(gpa, a, b); } /// Full-ancestry membership: whether `ancestor` is in the full ancestry of /// `descendant` (§3.3, merge parents included, self-inclusive). mtime- /// independent, unlike `mergeBaseOf` — the authoritative up-to-date test the /// `merge` verb keys the B-merge-16 refusal on. A thin pass-through. pub fn isAncestorOf(self: *const Repository, gpa: std.mem.Allocator, ancestor: Rid, descendant: Rid) error{OutOfMemory}!bool { return self.view.state.isAncestorOf(gpa, ancestor, descendant); } /// The full ancestry of `rid` as a slice (§3.3, merge parents included, /// self-inclusive), the general-purpose query `annotate` builds its version /// chain over. A thin pass-through over the one shared ancestry walk — the /// verb owns no ancestry traversal of its own. Caller frees the slice. pub fn ancestorsOf(self: *const Repository, gpa: std.mem.Allocator, rid: Rid) error{OutOfMemory}![]Rid { return self.view.state.ancestorsOf(gpa, rid); } /// The full ancestry of `rid` (§3.3, merge parents included, self-inclusive) /// linearized into the annotate engine's processing order — a topological /// order in which every parent precedes its child (oldest first), so /// same-mtime and non-monotone parent/child pairs never invert. A thin /// pass-through over the one shared linearization; the verb owns no ancestry /// ordering of its own. Caller frees the slice. pub fn ancestryLinearized(self: *const Repository, gpa: std.mem.Allocator, rid: Rid) error{OutOfMemory}![]Rid { return self.view.state.ancestryLinearized(gpa, rid); } /// The bisect candidate span and next probe over full ancestry (repo-state /// `bisectSpan`; ambiguities.md `bisect-midpoint`): candidate set = /// ancestors(bad-anchor) minus every good's ancestry minus skipped; probe = /// lower median of untested candidates by ascending mtime, ties least hash; /// culprit = mtime-earliest marked-bad. Refuses `InvertedMarks` / /// `UnrelatedHistories`. A thin pass-through over the one general-purpose /// query — the bisect verb owns no ancestry walk of its own. pub fn bisectSpan( self: *const Repository, gpa: std.mem.Allocator, bad: []const Rid, good: []const Rid, skipped: []const Rid, ) repostate.RepoState.BisectError!repostate.RepoState.BisectOutcome { return self.view.state.bisectSpan(gpa, bad, good, skipped); } /// The current checkout's crosslinked rid, or null when there is nothing to /// mark: no `.zit-checkout`, an unborn checkout, a version this view never /// interned, or an interned-but-eventless phantom (repo-state §7.5). The /// `mtimeOf != null` guard is the postcondition `branchOf`'s callers lean on /// — a returned rid always bears an event. pub fn currentCheckoutRid(self: *Repository) ?Rid { var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(self.io, self.checkout_root, &ck_buf) catch return null) orelse return null; if (ck.version.len == 0) return null; const rid = self.view.lookup(ck.version) orelse return null; if (self.view.state.mtimeOf(rid) == null) return null; return rid; } /// The lazily-opened read-only log for content reads. Reopens `.zit` (closed /// after the view derive) on first use and memoizes it for the repository's /// lifetime; subsequent calls return the same store. pub fn logStore(self: *Repository, gpa: std.mem.Allocator) OpenError!*logstore.LogStore { if (self.content) |*c| return &c.log; var file = self.root_dir.openFile(self.io, repo_filename, .{}) catch |e| return openFileError(e); errdefer file.close(self.io); const st = file.stat(self.io) catch return error.CannotRead; if (st.kind != .file) return error.CannotRead; self.content = .{ .file = file, .fs = storage.FileStorage.init(self.io, file, st.size), .log = undefined, }; const sf = self.content.?.fs.storageFile(); // Indexed fast path: adopt the persisted scan index instead of // re-walking every frame header (O(index) vs O(store)). Any refusal — // absent, stale, corrupt — falls through to the authoritative scan; // `--no-cache` never consults it at all. A grown log resumes the walk // over the tail alone, and the sidecar is refreshed so the next open // is O(index) again. if (!self.bypass_cache) blk: { const idx = repocache.tryLoadLogIndex(gpa, self.io, self.root_dir) orelse break :blk; // `openReadOnlyIndexed` takes `idx` unconditionally (#143) -- the // adopted store's entry hashes are slices of it, and every refusal // frees it there. Nothing to free here on any path. var extended = false; const adopted = (logstore.LogStore.openReadOnlyIndexed(gpa, sf, idx, &extended) catch break :blk) orelse break :blk; self.content.?.log = adopted; if (extended) refreshLogIndex(gpa, self.io, self.root_dir, &self.content.?.log); return &self.content.?.log; } self.content.?.log = logstore.LogStore.openReadOnly(gpa, sf) catch |e| { self.content = null; // discard the half-built channel; errdefer closes `file` return openReadOnlyError(e); }; // Write-on-miss: the scan just paid for the product the index persists. if (!self.bypass_cache) refreshLogIndex(gpa, self.io, self.root_dir, &self.content.?.log); return &self.content.?.log; } /// Serialize and atomically store the content log's scan index. /// Best-effort end to end: a failure costs the next open one scan. fn refreshLogIndex(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, log: *const logstore.LogStore) void { const idx = log.serializeIndex(gpa) catch return; defer gpa.free(idx); repocache.storeLogIndex(io, root_dir, idx); } /// The uncompressed content of the artifact named `name` (a lowercase-hex /// id), or null when no artifact of that name is stored. The bytes are /// verified to hash to `name` (artifact §7.2, hash-over-uncompressed) — a /// content/name mismatch or an undecodable frame is `Corrupt`. Caller frees /// the returned slice. pub fn blob(self: *Repository, gpa: std.mem.Allocator, name: []const u8) OpenError!?[]u8 { var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeName(name, &digest_buf) orelse return null; // undecodable ⇒ never stored const log = try self.logStore(gpa); const rid = log.lookup(digest) orelse return null; const bytes = (log.content(gpa, rid) catch |e| return contentError(e)) orelse return null; errdefer gpa.free(bytes); if (!hashMatches(digest, bytes)) return error.Corrupt; // §7.2 return bytes; } /// `blob` through a caller-held plaintext memo (`LogStore.contentCached`): /// same verification, same ownership; the memo only spares a sweep over /// many artifacts from re-walking the delta chains they share. pub fn blobCached(self: *Repository, gpa: std.mem.Allocator, name: []const u8, cache: *logstore.PlaintextCache) OpenError!?[]u8 { var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeName(name, &digest_buf) orelse return null; const log = try self.logStore(gpa); const rid = log.lookup(digest) orelse return null; const bytes = (log.contentCached(gpa, rid, cache) catch |e| return contentError(e)) orelse return null; errdefer gpa.free(bytes); if (!hashMatches(digest, bytes)) return error.Corrupt; // §7.2 return bytes; } /// Whether `name`'s content is actually HELD here, answered without /// reconstructing it. A sparse store interns an elided blob's name while /// holding no bytes for it, so interning is not presence; the phantom flag /// is. #95's archiver needs this to preview a member set — and to report /// which members a sparse store cannot supply — without paying a delta /// walk and an inflate per file to find out. pub fn blobPresent(self: *Repository, gpa: std.mem.Allocator, name: []const u8) OpenError!bool { var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeName(name, &digest_buf) orelse return false; const log = try self.logStore(gpa); const rid = log.lookup(digest) orelse return false; return !log.isPhantom(rid); } /// Resolving an artifact-name prefix: the open set plus `Ambiguous` (a /// prefix matching more than one stored artifact — repo-state §8.3 item 8, /// never silently narrowed). A prefix matching nothing is `null`, not an /// error — the not-found family the CLI renders as a plain refusal. pub const ResolveArtifactError = OpenError || error{Ambiguous}; /// Resolve a lowercase-hex artifact-name prefix (full or abbreviated) to /// the unique full lowercase-hex name of the stored artifact it addresses, /// written into `out` and returned as a sub-slice. Queries the LogStore — /// the one store holding EVERY stored artifact, file blobs included (the /// derived `repostate` interns only check-in/parent phantoms, which carry /// no content), so a blob prefix resolves here where a check-in-scoped name /// lookup would miss it. `null` = no match (not-found); `error.Ambiguous` = /// the §8.3-item-8 multi-match refusal. Hides the LogStore rid space from /// the caller, who sees only hashes. Precondition on `prefix` is the /// LogStore's: 4..64 lowercase-hex bytes (the CLI canonicalizes first). pub fn resolveArtifactPrefix( self: *Repository, gpa: std.mem.Allocator, prefix: []const u8, out: *[2 * logstore.max_hash_len]u8, ) ResolveArtifactError!?[]const u8 { const log = try self.logStore(gpa); const maybe_rid = log.resolveHexPrefix(prefix) catch |e| switch (e) { error.Ambiguous => return error.Ambiguous, }; const rid = maybe_rid orelse return null; const raw = log.hashOf(rid); std.debug.assert(raw.len <= logstore.max_hash_len); const lower = "0123456789abcdef"; for (raw, 0..) |b, i| { out[i * 2] = lower[b >> 4]; out[i * 2 + 1] = lower[b & 0x0f]; } return out[0 .. raw.len * 2]; } /// A parsed manifest bundled with the uncompressed bytes its cards borrow — /// the parser slices its source rather than duping it, so that source must /// outlive the manifest. `bytes` is the reconstructed artifact (always /// owned); `normalized` is non-null only when the read-path legacy /// trailing-separator tolerance fired, in which case the manifest's cards /// borrow `normalized` instead of `bytes` (§4.2 read-interop). `deinit` /// frees all held allocations. pub const OwnedManifest = struct { bytes: []u8, normalized: ?[]u8 = null, manifest: artifact.Manifest, pub fn deinit(self: *OwnedManifest, gpa: std.mem.Allocator) void { self.manifest.deinit(); if (self.normalized) |n| gpa.free(n); gpa.free(self.bytes); self.* = undefined; } }; /// The parsed manifest of the check-in `rid`, or null when its content is /// missing. `Corrupt` when the stored bytes are not a parseable artifact. /// Parses through `parseForImport`, not the strict reference `parse`, so a /// fossil-origin check-in carrying the legacy dangling F-card separator /// (fossil's own 2008 cvs2fossil history) reads instead of being rejected — /// the same read-path interop the diff baseline already relies on. Caller /// `deinit`s the returned `OwnedManifest`. pub fn manifest(self: *Repository, gpa: std.mem.Allocator, rid: Rid) OpenError!?OwnedManifest { return self.manifestImpl(gpa, rid, null); } /// `manifest` through a caller-owned plaintext memo. Manifests delta /// against their neighbours, so a sweep that reads many of them re-walks /// the same chains without one (zit#176). pub fn manifestCached(self: *Repository, gpa: std.mem.Allocator, rid: Rid, cache: *logstore.PlaintextCache) OpenError!?OwnedManifest { return self.manifestImpl(gpa, rid, cache); } fn manifestImpl(self: *Repository, gpa: std.mem.Allocator, rid: Rid, cache: ?*logstore.PlaintextCache) OpenError!?OwnedManifest { manifest_reads += 1; const hash = self.view.hashOf(rid); const bytes = (if (cache) |c| try self.blobCached(gpa, hash, c) else try self.blob(gpa, hash)) orelse return null; errdefer gpa.free(bytes); const im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, }; return .{ .bytes = bytes, .normalized = im.normalized, .manifest = im.manifest }; } /// Every failure `files` can surface: the open/content set plus the three /// §8 enumeration faults. `NotManifest`: the resolved artifact is not a /// check-in manifest (artifact §5.1). `BaselineUnresolvable` (§8.3): a delta /// check-in whose B-card baseline cannot be located, is not a manifest, or /// is unparseable — the hard-failure discipline §8.3 mandates for the /// file-enumeration entry points. `BaselineIsDelta` (§4.7.3): the resolved /// baseline is itself a delta manifest (delta-of-delta, rejected). pub const FilesError = OpenError || error{ NotManifest, BaselineUnresolvable, BaselineIsDelta }; /// The effective file set of one check-in, retaining every manifest whose /// F cards the walk borrows. A baseline check-in keeps only its own manifest /// (`baseline` null); a delta check-in additionally keeps the eagerly-read /// baseline, since the §8.2 merge walk borrows both card lists. Build it with /// `files`, read it with `iterator`, free it with `deinit`. pub const FileList = struct { target: OwnedManifest, /// Owned here, or null when the baseline was borrowed from a caller's /// `BaselineMemo` (`filesMemo`) — then `base_files` borrows the memo's /// manifest and stays valid only while that memo entry does. baseline: ?OwnedManifest = null, /// The two pre-sorted (§8.1) F-card lists the §8.2 merge walk consumes: /// `base_files` is the baseline's set (the target's own set for a /// baseline check-in), `delta_files` the delta's authored cards (empty /// for a baseline check-in). Both borrow the retained manifests above, /// so they stay valid exactly as long as this `FileList`. base_files: []const artifact.FCard, delta_files: []const artifact.FCard, pub fn deinit(self: *FileList, gpa: std.mem.Allocator) void { if (self.baseline) |*b| b.deinit(gpa); self.target.deinit(gpa); self.* = undefined; } /// A fresh §8.2 merge walk over the retained card lists — allocation-free /// and repeatable, so a caller may enumerate more than once. pub fn iterator(self: *const FileList) EffectiveFiles { return .{ .walk = .{ .baseline = self.base_files, .delta = self.delta_files } }; } }; /// A §8.2 merge walk narrowed to the effective set: `next` yields each merged /// F card in canonical filename order (§4.5.3), suppressing the delete markers /// the raw walk emits verbatim (§8.2 step 3 / tail, §4.7.6) so every yielded /// card carries a hash. pub const EffectiveFiles = struct { walk: artifact.MergeWalk, pub fn next(self: *EffectiveFiles) ?artifact.FCard { while (self.walk.next()) |f| { if (f.hash != null) return f; // §4.7.6: a hash-less card is a delete marker } return null; } }; /// The effective file set of the check-in `rid` (artifact §8.2): its own F /// cards for a baseline manifest, or the §8.2 merge of the eagerly-resolved /// baseline with the delta's cards for a delta manifest. Returns null when the /// check-in's own content is missing (an interned phantom). The §8.3/§4.7.3 /// baseline policy stays in `artifact.enumerate`: this pre-reads only the one /// baseline the B card names and hands `enumerate` a resolver over it. Caller /// `deinit`s the returned `FileList`. /// A caller-owned memo of ONE parsed baseline manifest, keyed by its /// artifact name (zit#167). Consecutive delta check-ins overwhelmingly /// share a baseline, and re-reading and re-parsing a wide baseline per /// check-in was 79% of a large conversion's mint phase. A `FileList` /// built through `filesMemo` borrows the entry: it must not outlive the /// next `filesMemo` call that replaces it. pub const BaselineMemo = struct { hash: []u8, owned: OwnedManifest, pub fn deinit(self: *BaselineMemo, gpa: std.mem.Allocator) void { self.owned.deinit(gpa); gpa.free(self.hash); gpa.destroy(self); } }; pub fn files(self: *Repository, gpa: std.mem.Allocator, rid: Rid) FilesError!?FileList { return self.filesImpl(gpa, rid, null, null); } /// `files` with a caller-owned baseline memo; see `BaselineMemo`. pub fn filesMemo(self: *Repository, gpa: std.mem.Allocator, rid: Rid, memo: *?*BaselineMemo) FilesError!?FileList { return self.filesImpl(gpa, rid, memo, null); } fn filesImpl(self: *Repository, gpa: std.mem.Allocator, rid: Rid, memo: ?*?*BaselineMemo, cache: ?*logstore.PlaintextCache) FilesError!?FileList { var target = (try self.manifestImpl(gpa, rid, cache)) orelse return null; errdefer target.deinit(gpa); if (target.manifest.kind != .manifest) return error.NotManifest; // §5.1 // submanifests §5.1: a tree check-in's file set lives behind its S // cards, so walk it into a flat card list before §8 ever looks. §4.4 // makes this exclusive with the B-card branch below — a manifest never // carries both B and S — so a check-in is walked or resolved, never both. try flattenIfTree(self, gpa, &target.manifest); // Eagerly resolve the one baseline the B card names (a delta manifest // only). A located, parseable check-in manifest becomes the resolver's // view; anything else leaves it null, so `enumerate` raises // BaselineUnresolvable under the §8.3 hard-failure discipline. var baseline: ?OwnedManifest = null; errdefer if (baseline) |*b| b.deinit(gpa); var view: ?artifact.ManifestFiles = null; var borrowed = false; if (target.manifest.b) |b| { // A memo hit: the same baseline the previous call parsed. if (memo) |slot| { if (slot.*) |entry| { if (std.mem.eql(u8, entry.hash, b.hash)) { view = manifestFilesOf(&entry.owned.manifest); borrowed = true; } } } if (!borrowed) { if (try self.blob(gpa, b.hash)) |bytes| { if (parseBaseline(gpa, bytes)) |bm| { baseline = bm; // submanifests §5.4: "a delta manifest MAY name a tree // manifest in its B card ... the baseline cursor of the // merge-walk ... is the enumeration of Section 5.1." // Flattening here makes that composition free. try flattenIfTree(self, gpa, &baseline.?.manifest); if (memo) |slot| { // Retain in the memo and borrow from it, so the // next check-in over this baseline reads nothing. const key = try gpa.dupe(u8, b.hash); errdefer gpa.free(key); const entry = try gpa.create(BaselineMemo); entry.* = .{ .hash = key, .owned = baseline.? }; baseline = null; if (slot.*) |old| old.deinit(gpa); slot.* = entry; view = manifestFilesOf(&entry.owned.manifest); borrowed = true; } else { view = manifestFilesOf(&baseline.?.manifest); } } else |e| switch (e) { error.OutOfMemory => { gpa.free(bytes); return error.OutOfMemory; }, error.NotManifest => gpa.free(bytes), // §8.3: not a manifest ⇒ unresolvable } } } } var eager = EagerBaseline{ .hash = if (target.manifest.b) |b| b.hash else null, .view = view, }; const walk = artifact.enumerate(manifestFilesOf(&target.manifest), .{ .ctx = &eager, .resolveFn = EagerBaseline.resolve, }) catch |e| switch (e) { error.BaselineUnresolvable => return error.BaselineUnresolvable, // §8.3 error.BaselineIsDelta => return error.BaselineIsDelta, // §4.7.3 }; // Postcondition: a delta check-in retained its baseline (else `enumerate` // would have raised BaselineUnresolvable above); a baseline check-in did // not read one. std.debug.assert((target.manifest.b != null) == (baseline != null or borrowed)); return FileList{ .target = target, .baseline = baseline, .base_files = walk.baseline, .delta_files = walk.delta, }; } /// Every failure `verifyChecksum` can surface: `files`' set plus the two /// gaps that only matter once the R aggregate is actually recomputed. /// `NoChecksum`: the check-in carries no R card, so there is nothing to /// verify against. `FileContentMissing`: a tracked file's blob is absent, /// so the §6.3.2 input cannot be formed at all. pub const VerifyChecksumError = FilesError || error{ NoChecksum, FileContentMissing }; /// Recompute the R-card aggregate over the check-in's effective file set and /// report whether it matches the R card the manifest carries (artifact /// §6.3.2; submanifests §5.5). Null when the check-in's own content is /// missing, exactly as `files` reports it. /// /// The enumeration comes from `files`, so one path covers a flat manifest, a /// tree manifest (§5.5: "the R card MUST be computed over the walk /// enumeration of Section 5.1"), and a delta over either (§6.3.3) — §9.3's /// "one shared enumeration interface", not a second file-set walk that could /// drift from it. Contents stream through the aggregator one blob at a time, /// so verifying a large check-in never holds the whole tree in memory. pub fn verifyChecksum(self: *Repository, gpa: std.mem.Allocator, rid: Rid) VerifyChecksumError!?bool { var list = (try self.files(gpa, rid)) orelse return null; defer list.deinit(gpa); const r = list.target.manifest.r orelse return error.NoChecksum; var agg: artifact.ChecksumAggregator = .{}; var it = list.iterator(); while (it.next()) |f| { // `EffectiveFiles` already suppressed the §4.7.6 delete markers, so // every yielded card names stored content. const content = (try self.blob(gpa, f.hash.?)) orelse return error.FileContentMissing; defer gpa.free(content); agg.update(f.filename, content); } var out: [32]u8 = undefined; return std.mem.eql(u8, r.checksum, agg.final(&out)); } /// The effective F card for `name` in the check-in `rid` (artifact §8.4 /// single-name lookup), or null when `name` is not in the effective set — /// either never present, or a delta delete marker (§4.7.6) suppresses it. /// Exact-name only: §8.4's best-match (closest-name) form is deliberately /// not offered, so it is never a candidate to suppress here. /// /// §8.4's discipline — search the delta's own cards first, a delta delete /// marker reports not-found without consulting the baseline, and the /// baseline is searched only when the name is absent from the delta — is /// exactly the §8.2 merge the `files` walk already performs. This narrows /// that walk to one name rather than re-deriving the rules, so the §8.3/ /// §4.7.3 baseline faults surface unchanged. The returned card's slices are /// duplicated into `gpa` (independent of the walk's scratch, which is freed /// before returning); release it with `freeFileCard`. pub fn fileAt(self: *Repository, gpa: std.mem.Allocator, rid: Rid, name: []const u8) FilesError!?artifact.FCard { return self.fileAtImpl(gpa, rid, name, null, null); } /// `fileAt` with a caller-owned baseline memo (`BaselineMemo`) and /// plaintext memo, for a sweep that asks the same name of many /// check-ins -- annotate asks it of every version that touched PATH. pub fn fileAtMemo(self: *Repository, gpa: std.mem.Allocator, rid: Rid, name: []const u8, memo: *?*BaselineMemo, cache: *logstore.PlaintextCache) FilesError!?artifact.FCard { return self.fileAtImpl(gpa, rid, name, memo, cache); } fn fileAtImpl(self: *Repository, gpa: std.mem.Allocator, rid: Rid, name: []const u8, memo: ?*?*BaselineMemo, cache: ?*logstore.PlaintextCache) FilesError!?artifact.FCard { var list = (try self.filesImpl(gpa, rid, memo, cache)) orelse return null; defer list.deinit(gpa); var it = list.iterator(); while (it.next()) |f| { std.debug.assert(f.hash != null); // §4.7.6: the walk suppresses delete markers if (std.mem.eql(u8, f.filename, name)) return try dupFileCard(gpa, f); } return null; } /// Release a card returned by `fileAt` — its present slices were each /// duplicated into `gpa`. A no-op-safe companion: every field that was /// null in the source stays null and is skipped. pub fn freeFileCard(gpa: std.mem.Allocator, f: artifact.FCard) void { gpa.free(f.filename); if (f.hash) |h| gpa.free(h); if (f.perm) |p| gpa.free(p); if (f.old_name) |o| gpa.free(o); } /// draft-fossil-submanifests-00 §§5.1 and 5.2: enumerate a tree manifest's /// complete F-set through an already-open source, before any caller writes. pub fn flattenTreeFromLog( gpa: std.mem.Allocator, log: *logstore.LogStore, tree_manifest: *artifact.Manifest, ) TreeFlattenError!void { return flattenTreeFromLogImpl(gpa, log, tree_manifest); } /// `flattenTreeFromLog` with a caller-owned memo. See the file-scope /// function for why a run of check-ins needs one. pub fn flattenTreeFromLogMemo( gpa: std.mem.Allocator, log: *logstore.LogStore, tree_manifest: *artifact.Manifest, slot: *?repostate.SubManifestMemo, ) TreeFlattenError!void { return flattenTreeFromLogMemoImpl(gpa, log, tree_manifest, slot); } }; /// Duplicate every present slice of `f` into `gpa`, yielding a card whose bytes /// outlive the merge walk's scratch. Partial-failure-safe: an OOM after any dup /// frees the slices already taken (`errdefer`), so no leak escapes. `mode` is a /// value, copied for free. Freed by `Repository.freeFileCard`. fn dupFileCard(gpa: std.mem.Allocator, f: artifact.FCard) error{OutOfMemory}!artifact.FCard { const filename = try gpa.dupe(u8, f.filename); errdefer gpa.free(filename); const hash = if (f.hash) |h| try gpa.dupe(u8, h) else null; errdefer if (hash) |h| gpa.free(h); const perm = if (f.perm) |p| try gpa.dupe(u8, p) else null; errdefer if (perm) |p| gpa.free(p); const old_name = if (f.old_name) |o| try gpa.dupe(u8, o) else null; return .{ .filename = filename, .hash = hash, .perm = perm, .old_name = old_name, .mode = f.mode }; } /// The §8.2 `ManifestFiles` view of a parsed manifest: its B-card hash (null for /// a baseline manifest, §8-p1) and its file set — its own F-card list (sorted at /// parse time), or, for a tree manifest, the walked enumeration `flattenIfTree` /// derived (submanifests §5.1). §5.1: downstream consumers "MUST NOT distinguish /// a walked enumeration from a flat or delta one", which is exactly what this /// substitution buys — the §8 machinery below never learns trees exist. /// /// `pub` so `update.zig` builds its §8.2 view through this one seam assertion /// rather than a second copy of it — the same reason its §5.2 walk routes /// through `flattenTreeFromLog`. pub fn manifestFilesOf(m: *const artifact.Manifest) artifact.ManifestFiles { // submanifests §9.3 seam assertion: a tree manifest conveys its file set // entirely by S cards, so its `f` run is empty and a view built before the // walk would silently enumerate nothing — precisely the bypass §9.3 requires // implementations to route rather than tolerate. std.debug.assert(m.s.len == 0 or m.flattened != null); return .{ .baseline_hash = if (m.b) |b| b.hash else null, .files = m.flattened orelse m.f, }; } /// draft-fossil-submanifests-00 §§5.1–5.2: tree-walk resolution distinguishes /// an absent or wrong-type S target from unreadable/corrupt storage before a /// caller writes. pub const TreeFlattenError = artifact.TreeError || error{ OutOfMemory, CannotRead, Corrupt, }; const TreeSource = union(enum) { repository: *Repository, log: *logstore.LogStore, fn blob(self: TreeSource, gpa: std.mem.Allocator, name: []const u8) TreeFlattenError!?[]u8 { return switch (self) { .repository => |repo| repo.blob(gpa, name) catch |err| switch (err) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.CannotRead, error.NotARepo => error.CannotRead, }, .log => |log| treeLogBlob(gpa, log, name), }; } }; fn treeLogBlob( gpa: std.mem.Allocator, log: *logstore.LogStore, name: []const u8, ) TreeFlattenError!?[]u8 { var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeName(name, &digest_buf) orelse return null; const rid = log.lookup(digest) orelse return null; const bytes = (log.content(gpa, rid) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt => return error.Corrupt, error.Storage => return error.CannotRead, }) orelse return null; errdefer gpa.free(bytes); if (!hashMatches(digest, bytes)) return error.Corrupt; return bytes; } /// Shared resolver owner for both a Repository's lazy content channel and an /// already-open LogStore. It owns bytes and parsed cards until a completed walk /// copies them into the target manifest's arena. const TreeResolver = struct { source: TreeSource, gpa: std.mem.Allocator, scratch: std.ArrayList(Repository.OwnedManifest) = .empty, fault: ?TreeFlattenError = null, fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView { const self: *TreeResolver = @ptrCast(@alignCast(ctx)); if (self.fault != null) return null; const bytes = (self.source.blob(self.gpa, hash) catch |err| { self.fault = err; return null; }) orelse return null; var im = artifact.parseForImport(self.gpa, bytes) catch |err| { self.gpa.free(bytes); if (err == error.OutOfMemory) self.fault = error.OutOfMemory; return null; }; if (im.manifest.kind != .submanifest) { im.deinit(self.gpa); self.gpa.free(bytes); return null; } var owned = Repository.OwnedManifest{ .bytes = bytes, .normalized = im.normalized, .manifest = im.manifest, }; self.scratch.append(self.gpa, owned) catch { owned.deinit(self.gpa); self.fault = error.OutOfMemory; return null; }; const stored = &self.scratch.items[self.scratch.items.len - 1]; return .{ .files = stored.manifest.f, .subs = stored.manifest.s }; } fn resolver(self: *TreeResolver) artifact.SubResolver { return .{ .ctx = self, .resolveFn = resolve }; } fn deinit(self: *TreeResolver) void { for (self.scratch.items) |*manifest| manifest.deinit(self.gpa); self.scratch.deinit(self.gpa); self.* = undefined; } }; /// A memo-backed sub-manifest resolver over an open log, for a caller that /// walks two trees WITHOUT flattening either — descending only into the /// sub-manifests whose hash differs between them, so the work is proportional /// to what changed rather than to the tree's size. The same memo and the same /// rebind-and-trim rule as `flattenTreeFromLogMemo`: `begin` binds a fresh /// scratch resolver for one walk, the memo answers from deep copies in its own /// arena, and those views stay valid until the next `begin`. `end` frees the /// scratch; call it once the walk is done with every view. pub const TreeDiffSeam = struct { owner: TreeResolver, memo: *repostate.SubManifestMemo, /// Bind in place. `self` must already sit at its final address: the memo's /// inner resolver borrows `self.owner`, so a seam returned by value would /// hand the memo a pointer into a dead frame. pub fn begin(self: *TreeDiffSeam, gpa: std.mem.Allocator, log: *logstore.LogStore, slot: *?repostate.SubManifestMemo) void { self.owner = .{ .source = .{ .log = log }, .gpa = gpa }; if (slot.* == null) slot.* = repostate.SubManifestMemo.init(gpa, self.owner.resolver()); self.memo = &slot.*.?; // Rebind and trim in one call, as `flattenTreeFromLogMemo` does: the // previous walk's scratch is gone, so nothing the memo handed out is // still live and eviction is safe. self.memo.beginWalk(self.owner.resolver()); } /// Resolves an S-card hash to its sub-manifest's F and S runs, or null when /// it is absent, not a sub-manifest, or unreadable — `fault` says which. pub fn resolver(self: *TreeDiffSeam) artifact.SubResolver { return self.memo.resolver(); } pub fn fault(self: *const TreeDiffSeam) ?TreeFlattenError { return self.owner.fault; } pub fn end(self: *TreeDiffSeam) void { self.owner.deinit(); } }; /// Flatten an S-bearing manifest through an already-open LogStore. Commit uses /// this before capture so a resolver fault cannot leave any newly captured blob /// or manifest frame behind. /// `flattenTreeFromLog` with a memo the CALLER owns, so it survives between /// calls. A sub-manifest tree shares nearly all of its nodes with its parent's /// -- that is the premise of the format -- so a caller flattening many /// check-ins in a row re-resolves the same nodes once per check-in, and a node /// near the root is re-fetched, re-inflated and re-parsed by every descendant. /// `Repository` avoids that with its own lifetime memo (`flattenIfTree`); a /// log-only caller had no way to, because the resolver here is scratch that /// dies with the call. /// /// Pass the same `slot` across a run of check-ins and the walk keeps §7.4's /// distinct-artifact cost instead of paying the naive one. The memo answers /// from deep copies in its own arena and rebinds `inner` to each call's fresh /// scratch resolver, which is what makes reuse across walks sound; it is /// keyed by artifact name, so a hit is the same artifact by content address, /// never a positional guess. fn flattenTreeFromLogMemoImpl( gpa: std.mem.Allocator, log: *logstore.LogStore, manifest: *artifact.Manifest, slot: *?repostate.SubManifestMemo, ) TreeFlattenError!void { if (manifest.s.len == 0) return; var owner = TreeResolver{ .source = .{ .log = log }, .gpa = gpa }; defer owner.deinit(); const seam = owner.resolver(); if (slot.* == null) slot.* = repostate.SubManifestMemo.init(gpa, seam); const memo = &slot.*.?; // Rebind AND trim in one call: `owner` above is this walk's scratch // resolver, and the previous walk's has already been destroyed, so nothing // the memo handed out is still live. That is the only moment eviction is // safe (see `beginWalk`), which is why the seam is not assigned directly. memo.beginWalk(seam); manifest.flattenTree(memo.resolver()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { if (owner.fault) |fault| return fault; return err; }, }; std.debug.assert(owner.fault == null); std.debug.assert(manifest.flattened != null); } fn flattenTreeFromLogImpl( gpa: std.mem.Allocator, log: *logstore.LogStore, manifest: *artifact.Manifest, ) TreeFlattenError!void { if (manifest.s.len == 0) return; var owner = TreeResolver{ .source = .{ .log = log }, .gpa = gpa }; defer owner.deinit(); manifest.flattenTree(owner.resolver()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { if (owner.fault) |fault| return fault; return err; }, }; std.debug.assert(owner.fault == null); std.debug.assert(manifest.flattened != null); } /// submanifests §5.1/§5.4: derive `m`'s complete file set when it is a tree /// manifest, leaving a flat or delta manifest untouched. The Repository keeps /// its lifetime memo while sharing the same parser/retention owner as LogStore. fn flattenIfTree( repo: *Repository, gpa: std.mem.Allocator, manifest: *artifact.Manifest, ) Repository.FilesError!void { if (manifest.s.len == 0) return; var owner = TreeResolver{ .source = .{ .repository = repo }, .gpa = gpa }; defer owner.deinit(); const seam = owner.resolver(); if (repo.sub_memo == null) repo.sub_memo = repostate.SubManifestMemo.init(gpa, seam); const memo = &repo.sub_memo.?; memo.beginWalk(seam); // rebind + trim; see `beginWalk` on why they are one call manifest.flattenTree(memo.resolver()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { if (owner.fault) |fault| return switch (fault) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.CannotRead => error.CannotRead, else => unreachable, }; return error.BaselineUnresolvable; }, }; std.debug.assert(owner.fault == null); std.debug.assert(manifest.flattened != null); } /// Parse `bytes` (borrowed) as the eagerly-read baseline of a delta check-in /// (§8.3). Parses through `parseForImport`, not the strict reference `parse`, so /// a fossil-origin baseline carrying the legacy dangling F-card separator /// (fossil's own 2008 cvs2fossil history) is read instead of being rejected — /// the same read-path interop the target manifest already relies on. A /// successful parse of a check-in manifest becomes an `OwnedManifest` that takes /// ownership of `bytes` (and the tolerance's `normalized` copy, if any); a parse /// OOM propagates; any other outcome — a syntactically invalid artifact or one /// that is not a manifest — is `NotManifest`, the §8.3 "located artifact is not a /// manifest" case (the caller frees `bytes` and lets `enumerate` raise /// `BaselineUnresolvable`). fn parseBaseline(gpa: std.mem.Allocator, bytes: []u8) error{ OutOfMemory, NotManifest }!Repository.OwnedManifest { var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.NotManifest, }; if (im.manifest.kind != .manifest) { im.deinit(gpa); // frees the manifest and any `normalized`; caller frees `bytes` return error.NotManifest; } return .{ .bytes = bytes, .normalized = im.normalized, .manifest = im.manifest }; } /// The §8.3 baseline resolver for `files`: the baseline was already located and /// parsed (eagerly, exactly once), so `resolve` just hands back the pre-built /// view — null when the baseline was unresolvable, which `enumerate` turns into /// `BaselineUnresolvable`. `enumerate` calls it only for a delta manifest, always /// with that manifest's single B-card hash — asserted here as negative space. const EagerBaseline = struct { hash: ?[]const u8, view: ?artifact.ManifestFiles, fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const self: *EagerBaseline = @ptrCast(@alignCast(ctx)); std.debug.assert(self.hash != null and std.mem.eql(u8, hash, self.hash.?)); return self.view; } }; /// Maps a `root_dir.openFile` failure: a missing `.zit` is "not a repository", /// every other open error is an I/O read failure. fn openFileError(e: anyerror) Repository.OpenError { return switch (e) { error.FileNotFound => error.NotARepo, error.OutOfMemory => error.OutOfMemory, else => error.CannotRead, }; } /// Maps `LogStore.openReadOnly`'s closed error set into the facade's set. fn openReadOnlyError(e: logstore.OpenError) Repository.OpenError { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.Storage => error.CannotRead, }; } /// Maps `RepoView.load`'s closed error set into the facade's set. fn loadError(e: repoview.RepoView.LoadError) Repository.OpenError { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.Storage => error.CannotRead, }; } /// Maps `LogStore.content`'s closed error set into the facade's set. fn contentError(e: logstore.ContentError) Repository.OpenError { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.Storage => error.CannotRead, }; } /// Decode a lowercase-hex artifact name to its raw digest into `out`. Returns /// null for anything that cannot be a stored name — an odd length, a length /// past `max_hash_len*2`, or a non-hex digit — so the caller treats it as "not /// present" rather than trapping on wire input. fn decodeName(name: []const u8, out: *[logstore.max_hash_len]u8) ?[]const u8 { if (name.len == 0 or name.len % 2 != 0) return null; const bytes = name.len / 2; if (bytes > logstore.max_hash_len) return null; _ = std.fmt.hexToBytes(out[0..bytes], name) catch return null; return out[0..bytes]; } /// Whether `content` hashes to `digest` under the algorithm the digest length /// selects (artifact §7.2: 20 bytes ⇒ sha1, 32 bytes ⇒ sha3-256). A digest of /// any other length is not a name this build mints, so it cannot match. fn hashMatches(digest: []const u8, content: []const u8) bool { switch (digest.len) { 20 => { var out: [20]u8 = undefined; std.crypto.hash.Sha1.hash(content, &out, .{}); return std.mem.eql(u8, &out, digest); }, 32 => { var out: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &out, .{}); return std.mem.eql(u8, &out, digest); }, else => return false, } } // =========================================================================== // Tests — draft-fossil-submanifests-00 §6.3/§6.5/§8.1, the settings semantics // and the analysis pass. Pure over F-card lists; no repository is opened. // =========================================================================== test "submanifests §8.1: emission is off by default and off is the whole default state" { // The acceptance claim, stated where it cannot drift: a `Settings` with // nothing named is off and unpinned, and yields `.off`. const s: submanifest.Settings = .off; try std.testing.expect(!s.enabled); try std.testing.expect(!s.pinned()); try std.testing.expectEqual(submanifest.Emission.off, submanifest.emission(s)); try std.testing.expect(submanifest.emission(s).treeK() == null); // And the same from a config carrying no submanifest fields at all — the // shape every repository predating this draft decodes to. const from_cfg: submanifest.Settings = .of(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "" }); try std.testing.expectEqual(submanifest.Emission.off, submanifest.emission(from_cfg)); } test "submanifests §8.1: the setting off pins nothing on — even with a valid k" { // §8.1's conjunction is not symmetric: a pinned parameter is necessary for // enabling, never sufficient. A repository that was enabled, ran, and was // disabled again keeps its pin (so re-enabling is not a re-tune) and must // still emit flat manifests. const s: submanifest.Settings = .{ .enabled = false, .k = 3 }; try std.testing.expect(s.pinned()); try std.testing.expectEqual(submanifest.Emission.off, submanifest.emission(s)); } test "submanifests §6.3: every value 1..12 is supported and yields that exact k" { // "MUST support every value from 1 to 12 inclusive". Walked, not sampled: // the range is the requirement. var k: u8 = artifact.min_k; while (k <= artifact.max_k) : (k += 1) { try std.testing.expect(submanifest.inRange(k)); const d = submanifest.emission(.{ .enabled = true, .k = k }); try std.testing.expectEqual(@as(?u4, @intCast(k)), d.treeK()); } } test "submanifests §6.3: a k outside 1..12 falls back to flat and is reported, never substituted" { // "on receiving a value outside 1..12 it MUST fall back to emitting flat // manifests rather than substitute a parameter of its own, and SHOULD report // the fallback to the operator." Both halves: no tree (`treeK` is null) and // a distinguishable outcome carrying the offending value (so the report can // name it). 0 is included — "enabled with nothing pinned", the state §8.1 // forbids creating, which must fall back rather than silently pick a value. for ([_]u8{ 0, 13, 14, 100, 255 }) |k| { try std.testing.expect(!submanifest.inRange(k)); const d = submanifest.emission(.{ .enabled = true, .k = k }); try std.testing.expect(d.treeK() == null); // no substituted parameter switch (d) { .out_of_range => |v| try std.testing.expectEqual(k, v), // reportable else => return error.TestUnexpectedResult, } } try std.testing.expect(submanifest.out_of_range_report_prefix.len != 0); } /// A canonical, hashed, strictly-ascending F-card list of `n` entries on /// `arena` — the shape `buildTree` and `submanifest.cost` both require. Names /// are fixed-width so ordering is lexicographic and the byte accounting is /// uniform; hashes are distinct 64-hex (the SHA3-256 width) but never read by /// the partition, which §6.1 fixes to the filename alone. fn testEntries(arena: std.mem.Allocator, n: usize) ![]const artifact.FCard { var list: std.ArrayList(artifact.FCard) = .empty; for (0..n) |i| { const name = try std.fmt.allocPrint(arena, "src/file{d:0>5}.zig", .{i}); var digest: [32]u8 = undefined; std.mem.writeInt(u64, digest[0..8], i + 1, .little); @memset(digest[8..], 0); const hex = try arena.alloc(u8, 64); _ = try std.fmt.bufPrint(hex, "{x}", .{&digest}); try list.append(arena, .{ .filename = name, .hash = hex, .perm = null, .old_name = null, .mode = .regular }); } return list.items; } /// Every S card the tree emits, counted the way `submanifest.cost` counts them /// but WITHOUT the leaf filter — the manifest's own run plus each interior /// sub-manifest's run. The difference against `Cost.s_bytes` is exactly the /// bytes of the cards that name interior nodes. fn allSCardBytes(built: *const artifact.TreeBuild) usize { var total: usize = 0; for (built.s) |card| total += submanifest.sCardBytes(card); for (built.subs) |sub| { if (sub.bytes[0] != 'S') continue; var it = std.mem.splitScalar(u8, sub.bytes, '\n'); while (it.next()) |line| { if (line.len < 2 or line[0] != 'S' or line[1] != ' ') continue; total += line.len + 1; // + the card's LF, as `cost` counts it } } return total; } // §6.3's objective is "the S card bytes referencing THE LEAVES" plus the // weighted mean leaf — not every S card the tree emits. The two coincide on a // depth-1 tree and diverge the moment there is an interior node, so a cost that // stopped telling them apart would still look self-consistent: `total` is // recomputed from whatever `s_bytes` holds, and the pass would keep returning a // plausible optimum for the wrong objective. Sum the tree both ways. test "submanifests §6.3: the objective counts only the S cards that name leaves" { const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 400); var saw_interior = false; var saw_depth1 = false; var k: u4 = artifact.min_k; while (true) : (k += 1) { const c = try submanifest.cost(gpa, entries, k, .sha3_256); if (!c.flat) { var built = try artifact.buildTree(gpa, entries, k, .sha3_256); defer built.deinit(); const all = allSCardBytes(&built); try std.testing.expect(all > 0); if (c.heights >= 2) { // Interior nodes exist, so some card names one. Those bytes are // not part of the objective — strictly fewer than every card. try std.testing.expect(c.s_bytes < all); saw_interior = true; } else { // Depth 1: every card names a leaf, so the filter drops nothing // and the two agree to the byte. try std.testing.expectEqual(all, c.s_bytes); saw_depth1 = true; } } if (k == artifact.max_k) break; } // Both arms are load-bearing. Without an interior tree the strict // inequality never runs; without a depth-1 tree the equality never pins // that the filter drops nothing BUT the interior cards. try std.testing.expect(saw_interior); try std.testing.expect(saw_depth1); } test "submanifests §6.3: the analysis pass costs every k and its optimum is a real tree" { const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 400); var costs: [artifact.max_k - artifact.min_k + 1]submanifest.Cost = undefined; var n: usize = 0; var k: u4 = artifact.min_k; while (true) : (k += 1) { costs[n] = try submanifest.cost(gpa, entries, k, .sha3_256); try std.testing.expectEqual(k, costs[n].k); // The objective is exactly §6.3's sum, not a proxy. try std.testing.expectEqual( @as(f64, @floatFromInt(costs[n].s_bytes)) + costs[n].weighted_mean_leaf_bytes, costs[n].total, ); // Flat F-card bytes do not depend on k — the same baseline every row. try std.testing.expectEqual(costs[0].flat_bytes, costs[n].flat_bytes); n += 1; if (k == artifact.max_k) break; } try std.testing.expectEqual(@as(usize, artifact.max_k - artifact.min_k + 1), n); const winner = submanifest.best(costs[0..n]) orelse return error.TestUnexpectedResult; try std.testing.expect(!winner.flat); try std.testing.expect(winner.leaves >= 2); // §6.2 step 2 — a tree partitions at least in two try std.testing.expect(winner.heights >= 1 and winner.heights <= artifact.max_construct_height); try std.testing.expect(winner.s_bytes > 0); // It really is the minimum over the analyzed range. for (costs[0..n]) |c| { if (c.flat) continue; try std.testing.expect(winner.total <= c.total); } } // §6.3: "The implementation SHOULD choose the candidate with the lowest C(k), // and MUST choose the lowest k among candidates with equal minimum cost." The // SHOULD is the objective; the tie-break is the MUST, and it is the half a real // name set is unlikely to exercise — so it is pinned directly on `best` rather // than left to whichever corpus happens to tie. Built as literals because a tie // is what is under test, not the arithmetic that produced one. test "submanifests §6.3: an equal minimum cost selects the lowest k" { const at = struct { fn c(k: u4, total: f64) submanifest.Cost { return .{ .k = k, .flat = false, .leaves = 2, .heights = 1, .s_bytes = 100, .weighted_mean_leaf_bytes = total - 100.0, .total = total, .flat_bytes = 1000, }; } }; // Three-way tie at the minimum: the lowest k takes it. { const costs = [_]submanifest.Cost{ at.c(1, 500.0), at.c(2, 500.0), at.c(3, 500.0) }; const w = submanifest.best(&costs) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u4, 1), w.k); } // A tie at the minimum that is NOT the first entry: a strictly cheaper k=2 // wins, and k=3's equal cost must not displace it. { const costs = [_]submanifest.Cost{ at.c(1, 900.0), at.c(2, 500.0), at.c(3, 500.0) }; const w = submanifest.best(&costs) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u4, 2), w.k); } // The tie-break never overrides the objective: a strictly lower cost at a // higher k still wins. { const costs = [_]submanifest.Cost{ at.c(1, 500.0), at.c(2, 499.0) }; const w = submanifest.best(&costs) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u4, 2), w.k); } // A flat candidate is not a candidate at all, even at cost 0 — §6.2 step 2. { var flat = at.c(1, 0.0); flat.flat = true; const costs = [_]submanifest.Cost{ flat, at.c(2, 500.0) }; const w = submanifest.best(&costs) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u4, 2), w.k); } } test "submanifests §6.3: the leaf count falls and the mean leaf grows as k rises" { // The tradeoff §6.5 describes — "group sizes follow the geometric // distribution of the boundary test, mean 2^k members" — is what makes the // objective have an interior minimum at all. If either side were flat the // pass would just pick an endpoint, so pin the monotone shape rather than a // measured number (which is a MEASUREMENT, never a test input). const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 600); const c1 = try submanifest.cost(gpa, entries, 1, .sha3_256); const c3 = try submanifest.cost(gpa, entries, 3, .sha3_256); const c5 = try submanifest.cost(gpa, entries, 5, .sha3_256); try std.testing.expect(c1.leaves > c3.leaves); try std.testing.expect(c3.leaves > c5.leaves); try std.testing.expect(c1.weighted_mean_leaf_bytes < c3.weighted_mean_leaf_bytes); try std.testing.expect(c3.weighted_mean_leaf_bytes < c5.weighted_mean_leaf_bytes); try std.testing.expect(c1.s_bytes > c3.s_bytes); try std.testing.expect(c3.s_bytes > c5.s_bytes); } test "submanifests §6.2 step 2: a name set too small to partition costs as flat at every k" { const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 1); var costs: [artifact.max_k - artifact.min_k + 1]submanifest.Cost = undefined; var n: usize = 0; var k: u4 = artifact.min_k; while (true) : (k += 1) { costs[n] = try submanifest.cost(gpa, entries, k, .sha3_256); try std.testing.expect(costs[n].flat); try std.testing.expectEqual(@as(usize, 0), costs[n].leaves); try std.testing.expect(costs[n].flat_bytes > 0); n += 1; if (k == artifact.max_k) break; } // `best` never proposes a k that would not produce a tree. try std.testing.expect(submanifest.best(costs[0..n]) == null); } test "submanifests §6.5: the prediction is half the base-2 log of the file count" { // Informative, and reported beside the measurement — so pin the formula // rather than any measured optimum. try std.testing.expectApproxEqAbs(@as(f64, 5.0), submanifest.predictedK(1024), 1e-9); try std.testing.expectApproxEqAbs(@as(f64, 8.0), submanifest.predictedK(65536), 1e-9); try std.testing.expectEqual(@as(f64, 0), submanifest.predictedK(0)); try std.testing.expectEqual(@as(f64, 0), submanifest.predictedK(1)); } test "submanifests §6.3: the analysis pass is advisory and writes nothing — it is pure over its input" { // "The selection is advisory rather than canonical -- any pinned k yields // conforming artifacts", and §6.4 "MUST NOT re-tune automatically". The // structural guarantee behind both is that `cost` takes no repository and // no settings: it cannot pin anything. Repeat it and get the same answer. const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 300); const a = try submanifest.cost(gpa, entries, 3, .sha3_256); const b = try submanifest.cost(gpa, entries, 3, .sha3_256); try std.testing.expectEqual(a.leaves, b.leaves); try std.testing.expectEqual(a.s_bytes, b.s_bytes); try std.testing.expectEqual(a.total, b.total); } test "submanifests §6.3: S-card and F-card byte counts follow §6.2's canonical minimal escaping" { // §6.2: "Canonical emission is minimal escaping: exactly SP (0x20) as \\s, // LF (0x0A) as \\n, and backslash (0x5C) as \\\; every other octet ... is // emitted raw". A byte count that escaped more (or less) would misprice // every name set carrying one of those three octets. const hash = "0" ** 64; // `S ` + name + ` ` + hash + LF, with `a b` encoding as `a\sb` (4 bytes). try std.testing.expectEqual( @as(usize, 2 + 4 + 1 + 64 + 1), submanifest.sCardBytes(.{ .filename = "a b", .hash = hash }), ); // A TAB is one of the decoder's other escape octets and stays RAW (3 bytes). try std.testing.expectEqual( @as(usize, 2 + 3 + 1 + 64 + 1), submanifest.sCardBytes(.{ .filename = "a\tb", .hash = hash }), ); // `F ` + name + ` ` + hash + ` ` + perm + LF. try std.testing.expectEqual( @as(usize, 2 + 3 + 1 + 64 + 1 + 1 + 1), submanifest.flatFCardBytes(.{ .filename = "a.z", .hash = hash, .perm = "x", .old_name = null, .mode = .executable }), ); } // =========================================================================== // Tests — draft-fossil-submanifests-00 §6.4's drift-check core. Pure over // F-card lists, same as the §6.3 analysis pass above; `driftCheck` itself // writes nothing and decides nothing about WHETHER to retune -- it only // answers whether a retune would be recommended. // =========================================================================== test "submanifests §6.4: a tie-zone drift is refused — margin search over testEntries" { // §6.4 makes the writer "retain the current value unless the candidate is // distinct and C_candidate < (1 - m) * C_current", so a drift that does not // clear the margin leaves the pin alone. The real grounding case is the "fossil" project // corpus (983 files, k=4 vs k=5 ~5% apart) but that corpus isn't // available to this test -- so search synthetic `testEntries` sizes for a // REAL near-tie pair (computed here, not asserted from thin air), pin // driftCheck to the worse of that pair, and confirm it refuses. const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var found = false; var found_n: usize = 0; var found_pinned_k: u4 = 0; var found_margin: f64 = 0; var n: usize = 50; search: while (n <= 2000) : (n += 63) { const entries = try testEntries(arena, n); var costs: [artifact.max_k - artifact.min_k + 1]submanifest.Cost = undefined; var cn: usize = 0; var k: u4 = artifact.min_k; while (true) : (k += 1) { costs[cn] = try submanifest.cost(gpa, entries, k, .sha3_256); cn += 1; if (k == artifact.max_k) break; } const winner = submanifest.best(costs[0..cn]) orelse continue; for (costs[0..cn]) |c| { if (c.flat or c.k == winner.k) continue; // Same formula driftCheck uses: the pinned (candidate) objective // is the denominator, matching "margin over the pinned value's // objective" (§6.4). const margin = (c.total - winner.total) / c.total * 100.0; if (margin > 0 and margin < submanifest.drift_margin_pct) { found = true; found_n = n; found_pinned_k = c.k; found_margin = margin; break :search; } } } // The search must have actually found a real near-tie pair -- otherwise // this test would be vacuous. try std.testing.expect(found); try std.testing.expect(found_margin > 0 and found_margin < submanifest.drift_margin_pct); const entries = try testEntries(arena, found_n); const result = try submanifest.driftCheck(gpa, entries, found_pinned_k, .sha3_256); try std.testing.expect(result == null); } test "submanifests §6.4: real drift past the margin is recommended — pinned k=1 vs testEntries(112)'s optimum" { // Measured this session on a zit-repo-shaped synthetic set: pinned to // k=1 totals ~5903.1, the true optimum (k=3) totals ~2490.9 -- about // 57.8%, comfortably past the 15% margin. Assert the SHAPE (which k wins, // that the margin clears the gate by a wide berth), not the exact floats // -- those are a measurement, not a test input. const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 112); const result = try submanifest.driftCheck(gpa, entries, 1, .sha3_256); const dc = result orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u4, 3), dc.recommended.k); try std.testing.expect(dc.margin_pct > 45.0 and dc.margin_pct < 70.0); } test "submanifests §6.4: predictedK never gates the check — a count-matching k with real drift still recommends" { // Design-review erratum: `predictedK` is documented (see its doc comment // above) as informative-only, "never an input to a default", because it // depends on file count alone while the real §6.3 objective depends on // leaf SIZE distribution too. A cheap-tier short-circuit that skipped the // expensive scan whenever round(predictedK(n)) == pinned_k would silently // swallow real drift whenever count alone happened to agree with the // pin -- a false negative in the one thing this check exists to catch, // and a repurposing of predictedK as a decision input. Search for a real // (n, pinned_k) pair where the count-only proxy agrees with pinned_k but // the real, size-aware objective still clears the margin, and confirm // driftCheck recommends anyway -- proving the expensive tier always runs. const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var found: ?submanifest.DriftCheck = null; var n: usize = 50; search: while (n <= 2000) : (n += 37) { const predicted = submanifest.predictedK(n); const rounded = @as(i64, @intFromFloat(@round(predicted))); if (rounded < artifact.min_k or rounded > artifact.max_k) continue; const pinned_k: u4 = @intCast(rounded); const entries = try testEntries(arena, n); const result = try submanifest.driftCheck(gpa, entries, pinned_k, .sha3_256); if (result) |dc| { if (dc.margin_pct > submanifest.drift_margin_pct) { found = dc; break :search; } } } const dc = found orelse return error.TestUnexpectedResult; try std.testing.expect(dc.margin_pct > submanifest.drift_margin_pct); } test "submanifests §6.4: an exact-margin case is refused, not accepted — the strict boundary" { // Real hash-derived costs essentially never land on exactly // `drift_margin_pct` by chance, so this exercises the boundary arithmetic // directly with manufactured totals rather than searching for a // coincidence. 100.0 -> 85.0 is exactly 15% -- must be refused (the // margin does not EXCEED the gate) -- and one float ULP past it, on // either side, flips the outcome. try std.testing.expect(submanifest.marginGate(100.0, 85.0) == null); const just_over = submanifest.marginGate(100.0, std.math.nextAfter(f64, 85.0, 0.0)); try std.testing.expect(just_over != null); try std.testing.expect(just_over.? > submanifest.drift_margin_pct); try std.testing.expect(submanifest.marginGate(100.0, std.math.nextAfter(f64, 85.0, 100.0)) == null); } test "submanifests §6.4: pinned k already at the optimum is never flagged" { const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 400); var costs: [artifact.max_k - artifact.min_k + 1]submanifest.Cost = undefined; var n: usize = 0; var k: u4 = artifact.min_k; while (true) : (k += 1) { costs[n] = try submanifest.cost(gpa, entries, k, .sha3_256); n += 1; if (k == artifact.max_k) break; } const winner = submanifest.best(costs[0..n]) orelse return error.TestUnexpectedResult; // Pinning exactly to the analyzed optimum leaves nothing to recommend -- // margin against yourself is 0, which does not exceed the gate. const result = try submanifest.driftCheck(gpa, entries, winner.k, .sha3_256); try std.testing.expect(result == null); } test "submanifests §6.2 step 2 / §6.4: a name set too small to partition never crashes and never recommends" { // Every k costs flat (see the sibling §6.2 step 2 test above), so `best` // returns null and driftCheck has nothing to compare against -- null, not // a crash or a spurious recommendation. const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = try testEntries(arena_state.allocator(), 1); const result = try submanifest.driftCheck(gpa, entries, 1, .sha3_256); try std.testing.expect(result == null); }