Artifact 18f84230f1da19c0c3894f570c4a7bdf2cabd59950340ae5f25b72dca87f46f9:
- File
src/repository.zig
— part of check-in
[92a40960d1]
at
2026-07-28 07:02:03
on branch sub-manifest
— Sub-manifest: settings, the emission gate, and the analysis pass
Emission is off by default and turning it on is an explicit act that must name the partition parameter: there is no fallback value, enabling without one is refused, and a pinned value outside the supported range emits flat manifests and says so rather than substituting a parameter of its own. Turning it off returns the next commit to flat. The analysis pass reports what the parameter would cost over a name set; it pins nothing.
A server whose setting is off now sends the avoid-submanifests pragma, which the receiving side already latched. It leads the pragma run in both the ordinary and the clone reply, so six tests that read a reply's first card were updated to account for it; each still asserts the same content it did before, behind the new card. Fossil 2.28 tolerates the pragma and the interoperability suite is unchanged.
The parameter is repository-local. The specification requires it to propagate to clones, since sharing by content address holds only while every writer partitions identically; that shortfall is recorded rather than implied away. (user: dmestas, size: 70133) [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"); /// 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. // ----------------------------------------------------------------------- /// §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. pub fn best(costs: []const Cost) ?Cost { var winner: ?Cost = null; for (costs) |c| { if (c.flat) continue; if (winner) |w| { if (c.total < w.total) winner = c; } else winner = c; } return winner; } /// §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))); } /// 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 { io: std.Io, /// The checkout root, discovered and owned by the caller (the caller closes /// it after `deinit`). Borrowed here to reopen `.zit` for lazy content. root_dir: std.Io.Dir, /// The crosslinked derived view, loaded in place by `open`. `state` borrows /// `&view.store`, so this whole struct is caller-pinned. view: repoview.RepoView, /// The content channel, opened lazily on the first content request. Held /// inline (pinned) because the `LogStore` borrows `&content.?.fs`. content: ?Content, /// 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 derived-query identity space: a `RepoState` rid (the view's store). pub const Rid = repostate.Rid; /// Options for `open`. `bypass_cache` skips the O(1) `.zit-cache` fast path /// and forces the authoritative full `openReadOnly` scan + derive (the /// `--no-cache` escape hatch); the derived view still rewrites a fresh cache /// afterward. pub const OpenOptions = struct { bypass_cache: bool = false, /// Which cache representation to serve. Defaults to `.full` (derive the /// complete `RepoState` — every query answerable), so every existing /// caller keeps today's behavior. The hot read verbs (`leaves` and the /// mtime-ordered listings) pass `.fast_read` to take the zero-parse /// image path, which answers their freed queries without re-deriving; /// the cold verbs (`info` ancestry, `branch`, `tag`, `ls`, `cat`) stay /// `.full` because they read adjacency and the full tag tables. 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; self.root_dir = root_dir; self.content = null; self.sub_memo = null; // submanifests §7.4 — opened on the first tree walk var file = root_dir.openFile(io, repo_filename, .{}) catch |e| return openFileError(e); defer file.close(io); const st = file.stat(io) catch return error.CannotRead; 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, 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); // 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); self.view.loadCapturing(gpa, &log, &body) catch |e| return loadError(e); if (log.logKey()) |key| { var image: std.ArrayList(u8) = .empty; defer image.deinit(gpa); repoimage.serialize(gpa, &self.view.state, self.view.main_branch, &image) catch image.clearRetainingCapacity(); repocache.store(gpa, io, root_dir, key, self.view.main_branch, body.items, image.items); } else |_| {} } /// Frees the derived view and closes the lazy content channel if it was /// opened. Does NOT close `root_dir` — the caller that discovered it owns /// its lifetime. Call only on a `Repository` that `open` returned `ok`. pub fn deinit(self: *Repository, gpa: std.mem.Allocator) void { if (self.content) |*c| { c.log.deinit(gpa); c.file.close(self.io); } if (self.sub_memo) |*m| m.deinit(); 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 (repo-state §7.1 precomputed leaf table) instead of paying the full /// cold derive. It runs `open`'s own read-miss path — derive from the log, /// serialize the image, store the cache — purely for that documented side /// effect, then discards the view; there is no new derivation path, so /// nothing is added to the derivation oracle DST already proves. /// /// Best-effort by construction (repo-state §1 memo / §7.3 rebuild fallback): /// a `.zit-cache` is disposable — deleting it never changes an answer — so /// any failure to open, derive, or write is swallowed. A caller that cannot /// warm the cache (unreadable `.zit`, OOM, a read-only directory) is left /// exactly as before: correct, with the first read deriving lazily. Warming a /// repository that already has a valid cache is a cheap hit-and-discard. pub fn warmCache(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir) void { var repo: Repository = undefined; // The miss path inside `open` writes the cache (image + records); on any // error nothing is retained, so there is nothing to deinit. repo.open(gpa, io, root_dir, .{}) catch return; repo.deinit(gpa); } /// The derived `RepoState` — the leaf table, branch/tag derivations, event /// times, and ancestry (repo-state §6-9). The view already hides the derive; /// exposing the query surface directly keeps this facade from becoming a /// shallow pass-through over a dozen `RepoState` methods. pub fn state(self: *const Repository) *const repostate.RepoState { return &self.view.state; } /// The repo-state §6.1 default-branch name (config `main-branch` when set, /// else `"trunk"`) — the fallback `branchOf` threads. pub fn mainBranch(self: *const Repository) []const u8 { return self.view.main_branch; } /// The lowercase-hex artifact name of a derived rid — what a verb prints and /// compares against the current checkout. pub fn hashOf(self: *Repository, rid: Rid) []const u8 { return self.view.hashOf(rid); } /// The derived rid for a lowercase-hex artifact name, or null when the name /// was never interned in this view. pub fn lookup(self: *Repository, hash: []const u8) ?Rid { return self.view.lookup(hash); } /// Resolve a symbolic name / id / prefix to a rid via the repo-state §8.3 /// procedure (recency-based, §8.2 — closed-ness and leaf-ness never filter /// the candidate). Errors forwarded unchanged so the CLI switches once. pub fn resolve(self: *const Repository, name: []const u8, want: repostate.RepoState.NameType) ResolveError!Rid { return self.view.state.resolve(name, want); } /// The active-tag branch of `rid` (repo-state §6.1), config `main-branch` as /// the fallback. Folds the fallback so callers never pass it — precondition: /// `rid` bears an event (`state.mtimeOf(rid) != null`). pub fn branchOf(self: *const Repository, rid: Rid) []const u8 { return self.view.state.branchOf(rid, self.view.main_branch); } /// The 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, name: []const u8, fallback: []const u8) []const u8 { return self.view.state.activeTagValue(rid, name) 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.root_dir, &ck_buf) catch return null) orelse return null; if (ck.version.len == 0) return null; const rid = self.view.lookup(ck.version) orelse return null; if (self.view.state.mtimeOf(rid) == null) return null; return rid; } /// The lazily-opened read-only log for content reads. Reopens `.zit` (closed /// after the view derive) on first use and memoizes it for the repository's /// lifetime; subsequent calls return the same store. pub fn logStore(self: *Repository, gpa: std.mem.Allocator) OpenError!*logstore.LogStore { if (self.content) |*c| return &c.log; var file = self.root_dir.openFile(self.io, repo_filename, .{}) catch |e| return openFileError(e); errdefer file.close(self.io); const st = file.stat(self.io) catch return error.CannotRead; if (st.kind != .file) return error.CannotRead; self.content = .{ .file = file, .fs = storage.FileStorage.init(self.io, file, st.size), .log = undefined, }; self.content.?.log = logstore.LogStore.openReadOnly(gpa, self.content.?.fs.storageFile()) catch |e| { self.content = null; // discard the half-built channel; errdefer closes `file` return openReadOnlyError(e); }; return &self.content.?.log; } /// The uncompressed content of the artifact named `name` (a lowercase-hex /// id), or null when no artifact of that name is stored. The bytes are /// verified to hash to `name` (artifact §7.2, hash-over-uncompressed) — a /// content/name mismatch or an undecodable frame is `Corrupt`. Caller frees /// the returned slice. pub fn blob(self: *Repository, gpa: std.mem.Allocator, name: []const u8) OpenError!?[]u8 { var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeName(name, &digest_buf) orelse return null; // undecodable ⇒ never stored const log = try self.logStore(gpa); const rid = log.lookup(digest) orelse return null; const bytes = (log.content(gpa, rid) catch |e| return contentError(e)) orelse return null; errdefer gpa.free(bytes); if (!hashMatches(digest, bytes)) return error.Corrupt; // §7.2 return bytes; } /// 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 { const bytes = (try self.blob(gpa, self.view.hashOf(rid))) 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, 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`. pub fn files(self: *Repository, gpa: std.mem.Allocator, rid: Rid) FilesError!?FileList { var target = (try self.manifest(gpa, rid)) 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; if (target.manifest.b) |b| { 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); 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)); 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 { var list = (try self.files(gpa, rid)) 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); } }; /// 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. 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, }; } /// submanifests §5.2 resolver over the repository's artifact store: locate the /// artifact an S card names, parse it, and hand back its view only when it /// really is a SUBMANIFEST. Absent, unparseable, and wrong-type all collapse to /// null — the one hard failure §5.2 defines, modelled on §8.3's B-card collapse. /// /// Every sub-manifest parsed here is retained until `deinit`, because the walk /// borrows its cards; `flattenTree` copies each emitted card into the manifest's /// own arena, so this whole scratch set is freed the moment the flatten returns. /// A genuine store fault (OOM, unreadable, corrupt bytes) is latched in `fault` /// instead of degrading into "unresolvable", so the caller re-raises the real /// error rather than reporting a missing sub-manifest that is in fact present. const TreeStore = struct { repo: *Repository, gpa: std.mem.Allocator, scratch: std.ArrayList(Repository.OwnedManifest) = .empty, fault: ?Repository.OpenError = null, fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView { const self: *TreeStore = @ptrCast(@alignCast(ctx)); if (self.fault != null) return null; // already faulted: read no further const bytes = (self.repo.blob(self.gpa, hash) catch |e| { self.fault = e; return null; }) orelse return null; // §5.2: absent var im = artifact.parseForImport(self.gpa, bytes) catch |e| { self.gpa.free(bytes); if (e == error.OutOfMemory) self.fault = error.OutOfMemory; return null; // §5.2: not a parseable artifact ⇒ unresolvable }; if (im.manifest.kind != .submanifest) { im.deinit(self.gpa); // frees the manifest and any `normalized` self.gpa.free(bytes); return null; // §3.6: a reference to any other type fails the walk } 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 deinit(self: *TreeStore) void { for (self.scratch.items) |*m| m.deinit(self.gpa); self.scratch.deinit(self.gpa); self.* = undefined; } }; /// submanifests §5.1/§5.4: derive `m`'s complete file set when it is a tree /// manifest, leaving a flat or delta manifest untouched. Runs before the §8 /// enumeration, on the manifest the façade already holds, so `enumerate` and the /// merge walk stay byte-identical — §5.4: "the merge-walk itself is unchanged". /// /// Every §5.1 fault becomes `BaselineUnresolvable`. §5.2 models S resolution on /// §8.3's B resolution "under the same two disciplines" and mandates hard /// failure without mandating a distinct code; the five-way `TreeError` /// distinction the conformance vectors test lives at the artifact layer, where /// it is observable. Widening this façade's error set would force every caller's /// exhaustive switch to grow for a distinction no consumer of `files` acts on. fn flattenIfTree(repo: *Repository, gpa: std.mem.Allocator, m: *artifact.Manifest) Repository.FilesError!void { if (m.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one var store = TreeStore{ .repo = repo, .gpa = gpa }; defer store.deinit(); const scratch_seam: artifact.SubResolver = .{ .ctx = &store, .resolveFn = TreeStore.resolve }; // submanifests §7.4 — the walk runs through the repository-lifetime memo, // so a second check-in enumerated from this same open repository reuses the // subtrees it shares with the first instead of re-locating, re-reading and // re-parsing each one. Opened on the first tree walk, so a repository that // holds no tree manifest never allocates it. The scratch resolver above is // rebound each call because it owns the parsed sub-manifests the walk // borrows and dies with this function; a memo HIT never reaches it. if (repo.sub_memo == null) repo.sub_memo = repostate.SubManifestMemo.init(gpa, scratch_seam); const memo = &repo.sub_memo.?; memo.inner = scratch_seam; m.flattenTree(memo.resolver()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => { if (store.fault) |f| return f; // a real store fault, not a §5.2 gap return error.BaselineUnresolvable; // §5.2 hard failure }, }; std.debug.assert(store.fault == null); // a latched fault always fails the walk std.debug.assert(m.flattened != null); // §5.2: never a partial file set } /// 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; } 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); } } 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 }), ); }