Artifact a45f09e18e2d5648d52638c12714853ac76bb634e296546b0ffbd65a67538bcc:
- File
src/cmd/history.zig
— part of check-in
[3fa532af3a]
at
2026-08-16 13:26:39
on branch tag-cards
— tag: raw-namespace tags, values, and cancel (#80)
tag add could mint only T +sym-NAME, so a verdict like ci-pass became a symbolic check-in name and the second check-in carrying it made that name ambiguous. add and cancel now take fossil's --raw namespace choice, an optional VALUE, and --propagate; list --raw reads raw tags back, since they are deliberately outside the name space. Names under wiki-, tkt-, or event- are refused, as fossil refuses them.
The composer's arguments moved into a TagCardSpec, which hides its two precondition asserts from the release-assert scanner (a struct field is not slice-shaped), so its baseline entry goes; the asserts stay. (user: dmestas, size: 93227) [annotate] [blame] [check-ins using]
//! zit history / read-only inspection verbs (stat, log, leaves, timeline, //! branch, tag, info, ls, cat, artifact, annotate) and the helpers and tests //! private to them. //! //! Extracted verbatim from src/main.zig (release step S4). Every declaration //! below is byte-identical to its main.zig source; only this import prologue is //! new. Helpers still shared with other verbs stay in main.zig and are aliased //! back here so the moved bodies call them unqualified. main.zig's verb-dispatch //! switch calls into this file (`cmd_history.runLog` ...); its test block imports //! it so these inline tests keep running under `zig build test`. const std = @import("std"); const zit = @import("zit"); const cli = @import("zit").cli; const merge3 = @import("../merge3.zig"); const annotate_mod = @import("../annotate.zig"); const logstore = @import("zit").logstore; const storage = @import("zit").storage; const storestat = @import("zit").storestat; const Repository = @import("zit").Repository; const Io = std.Io; const File = Io.File; const repostate = zit.repostate; const managed = zit.managed; const artifact = zit.artifact; // Shared CLI helpers and test fixtures that stay in main.zig (other verbs use // them too); aliased back so the moved code and tests reference them by their // original unqualified names. const main = @import("../main.zig"); const repo_filename = main.repo_filename; const reasonOf = main.reasonOf; const stateOom = main.stateOom; const not_a_repo_msg = main.not_a_repo_msg; const repoReadFail = main.repoReadFail; const repoOpenFail = main.repoOpenFail; const openRepo = main.openRepo; const digest_len = main.digest_len; const timeline_hash_display = main.timeline_hash_display; const firstCommentLine = main.firstCommentLine; const renderBranchList = main.renderBranchList; const sym_prefix = main.sym_prefix; const lessByHash = main.lessByHash; const mintControlArtifact = main.mintControlArtifact; const packSeedBlob = main.packSeedBlob; const packSeedCheckin = main.packSeedCheckin; const packEvolvingBlob = main.packEvolvingBlob; const packOpenWritable = main.packOpenWritable; const packOpenReadOnly = main.packOpenReadOnly; const stat_help = main.stat_help; const log_help = main.log_help; const leaves_help = main.leaves_help; const timeline_help = main.timeline_help; const branch_help = main.branch_help; const tag_help = main.tag_help; const info_help = main.info_help; const ls_help = main.ls_help; const cat_help = main.cat_help; const artifact_help = main.artifact_help; const annotate_help = main.annotate_help; /// Which artifacts `zit stat --artifacts` lists. `all` is the unfiltered roll; /// `full` and `delta` partition it by storage form, which is the one property /// the frame walk already knows and so costs nothing extra to filter on. const StatType = enum { all, full, delta }; /// `zit stat` — answer questions about the store itself: how large it is, how /// much of that is artifact content, and which artifacts it holds. /// /// The honesty constraint that shapes this verb: `content-bytes` is MEASURED, /// by summing each artifact frame's payload length during the walk. It is /// never the `.zit` file size, which additionally carries the store header, a /// header per frame, and the padding that aligns each frame to 8 bytes. /// Reporting the file size as content would look like a result and be a /// fabrication, so the two travel as separate fields and the overhead that /// separates them is reported alongside, making the accounting checkable: /// `header-bytes + content-bytes + frame-overhead-bytes + config-bytes + /// shun-bytes` is the file, exactly — the shun table's frames are part of the /// store, so leaving them out of the report would leave an unlabelled gap on /// every repository an operator has shunned anything in. /// /// Two traversals, both bounded by the store rather than by the artifact /// count. `openReadOnly` is the first: it whole-frame-CRC-verifies every frame /// and builds the in-memory index, which is what `--artifacts` enumerates and /// what lets `storestat.walk` trust the prefix bytes it reads. The summary path /// then runs the walk, a prefix-only second pass. The open is not skippable on /// the summary path: unverified frame prefixes would make the totals a report /// about bytes nobody checked. What is avoided is the per-artifact shape — no /// invocation, and no payload decode, per stored artifact. Read-only; the /// store is not modified. pub fn runStat( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { var list_artifacts = false; var want: StatType = .all; var typed = false; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { streams.out.writeAll(stat_help) catch {}; return .ok; } else if (std.mem.eql(u8, tok, "--artifacts")) { list_artifacts = true; } else if (std.mem.startsWith(u8, tok, "--type=")) { // A second --type is a mistake, not a refinement: silently keeping // the last one would answer a question the user did not ask. if (typed) return cli.usageError(streams, "stat", .repeated_option, "--type"); const val = tok["--type=".len..]; want = if (std.mem.eql(u8, val, "full")) .full else if (std.mem.eql(u8, val, "delta")) .delta else return cli.usageError(streams, "stat", .unknown_option, tok); typed = true; } else if (tok.len > 1 and tok[0] == '-') { return cli.usageError(streams, "stat", .unknown_option, tok); } else { return cli.usageError(streams, "stat", .extra_argument, tok); } } // `--type` selects among artifacts; without a listing there is nothing for // it to select, and silently ignoring it would hide a mistaken invocation. if (typed and !list_artifacts) { streams.err.writeAll("zit stat: --type needs --artifacts\n") catch {}; return .usage; } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "stat")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var file = cli.openStoreRead(io, root_dir) catch return repoReadFail(streams, "stat"); defer file.close(io); const st = file.stat(io) catch return repoReadFail(streams, "stat"); var fs = storage.FileStorage.init(io, file, st.size); var store = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "stat"), else => { streams.err.print("zit stat: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}; return .failure; }, }; defer store.deinit(gpa); if (list_artifacts) return statArtifacts(gpa, &store, want, streams); var fs2 = storage.FileStorage.init(io, file, st.size); const bd = storestat.walk(fs2.storageFile(), st.size) catch |e| { streams.err.print("zit stat: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}; return .failure; }; streams.out.print( \\store-bytes {d} \\header-bytes {d} \\content-bytes {d} \\frame-overhead-bytes {d} \\config-bytes {d} \\shun-bytes {d} \\shun-rows {d} \\artifacts {d} \\full-form {d} \\full-form-bytes {d} \\deltas {d} \\delta-bytes {d} \\ , .{ st.size, logstore.header_len, bd.contentBytes(), bd.contentOverhead(), bd.configBytes(), bd.shunBytes(), bd.shunRows(), bd.artifacts(), bd.fullForm(), bd.fullFormBytes(), bd.deltas(), bd.deltaBytes(), }) catch {}; return .ok; } /// Emits one lowercase-hex artifact hash per line, in ascending hash order, so /// two stores' rolls compare with `comm`. Reads the already-open store's index /// — no further I/O, and no per-artifact process. fn statArtifacts( gpa: std.mem.Allocator, store: *logstore.LogStore, want: StatType, streams: cli.Streams, ) cli.Exit { const rids = store.ridsInPersistedOrder(gpa) catch return stateOom(streams, "stat"); defer gpa.free(rids); var hashes: std.ArrayListUnmanaged([]const u8) = .empty; defer hashes.deinit(gpa); hashes.ensureTotalCapacity(gpa, rids.len) catch return stateOom(streams, "stat"); for (rids) |rid| { const kind = store.kindOf(rid); // The config record is repository metadata, not a stored artifact, so // it is absent from every listing and from the `artifacts` count. if (kind == .config) continue; const keep = switch (want) { .all => true, .delta => kind == .delta, .full => kind == .raw or kind == .zlib, }; if (keep) hashes.appendAssumeCapacity(store.hashOf(rid)); } // The listing is a filtered projection of the persisted rid set — never more // hashes than records walked. std.debug.assert(hashes.items.len <= rids.len); std.mem.sort([]const u8, hashes.items, {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lt); for (hashes.items) |h| streams.out.print("{x}\n", .{h}) catch {}; return .ok; } const LogWalkError = error{ OutOfMemory, Storage, Corrupt, MissingRoot, InvalidRoot, InvalidTarget, }; fn decodeFullId(id: []const u8, out: *[digest_len]u8) error{InvalidId}![]const u8 { if (!artifact.validateName(id)) return error.InvalidId; const decoded = out[0 .. id.len / 2]; _ = std.fmt.hexToBytes(decoded, id) catch return error.InvalidId; // §3.6: `validateName` accepted a 40/64-hex token, so the decode is a whole // 20/32-byte digest that fits the fixed buffer — never a truncated prefix. std.debug.assert(decoded.len <= digest_len); return decoded; } /// The optional PATH restriction for a `zit log CHECKIN PATH` walk (B-log-2). /// `repo` answers the one general-purpose §8 effective-set query (`fileAt`) the /// change predicate consumes (B-log-5); `path` is the exact repo-relative name. const LogPathFilter = struct { repo: *Repository, path: []const u8, }; fn walkLog( gpa: std.mem.Allocator, log: *logstore.LogStore, root_id: []const u8, out: *Io.Writer, filter: ?LogPathFilter, limit: ?usize, ) LogWalkError!void { var pending: std.ArrayList([]u8) = .empty; defer { for (pending.items) |id| gpa.free(id); pending.deinit(gpa); } var visited: std.StringHashMapUnmanaged(void) = .empty; defer visited.deinit(gpa); var visited_ids: std.ArrayList([]u8) = .empty; defer { for (visited_ids.items) |id| gpa.free(id); visited_ids.deinit(gpa); } // `-n N` caps the ENTRIES NAMED, not the nodes traversed: with a PATH filter // most visited check-ins print nothing, and capping traversal would make the // answer depend on how much unrelated history sat in between. Counting the // lines the user actually sees is the only reading that stays stable. var emitted: usize = 0; const root_copy = try gpa.dupe(u8, root_id); pending.append(gpa, root_copy) catch { gpa.free(root_copy); return error.OutOfMemory; }; while (pending.pop()) |id| { // `-n N` (N ≥ 1; `parseLimitValue` maps 0 → null) returns the moment the // Nth line is named, so at every loop entry fewer than N lines are out. if (limit) |n| std.debug.assert(emitted < n); if (visited.contains(id)) { gpa.free(id); continue; } visited_ids.append(gpa, id) catch { gpa.free(id); return error.OutOfMemory; }; visited.put(gpa, id, {}) catch { _ = visited_ids.pop(); gpa.free(id); return error.OutOfMemory; }; const is_root = visited_ids.items.len == 1; var hash_buf: [digest_len]u8 = undefined; const decoded = decodeFullId(id, &hash_buf) catch return if (is_root) error.InvalidRoot else error.InvalidTarget; // A decoded id is a full §3.6 artifact name (40/64 hex), never a prefix — // it is exactly what every `checkin`/`phantom` line below renders. std.debug.assert(artifact.validateName(id)); const rid = log.lookup(decoded) orelse { if (is_root) return error.MissingRoot; // A phantom's effective set is unknowable, so the PATH filter cannot // assert it changes PATH — it is silently skipped (the unfiltered // walk still names it once). if (filter == null) { out.print("phantom {s}\n", .{id}) catch {}; emitted += 1; if (limit) |n| if (emitted >= n) return; } continue; }; const bytes = (try log.content(gpa, rid)) orelse { if (is_root) return error.MissingRoot; if (filter == null) { out.print("phantom {s}\n", .{id}) catch {}; emitted += 1; if (limit) |n| if (emitted >= n) return; } continue; }; defer gpa.free(bytes); if (!artifact.verify(id, bytes)) return error.Corrupt; // Read path: the log-walk reads fossil-origin history, so it tolerates // the legacy F-card trailing separator. var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return if (is_root) error.InvalidRoot else error.InvalidTarget, }; defer loaded.deinit(gpa); const manifest = &loaded.manifest; if (manifest.kind != .manifest) return if (is_root) error.InvalidRoot else error.InvalidTarget; // With no PATH the walk names every reachable check-in (B-log-1); with // PATH it names only the check-ins whose effective content for PATH // differs from their primary parent's (B-log-2). if (filter) |f| { if (try logChangesPath(gpa, f.repo, id, manifest, f.path)) { out.print("checkin {s}\n", .{id}) catch {}; emitted += 1; } } else { out.print("checkin {s}\n", .{id}) catch {}; emitted += 1; } if (limit) |n| if (emitted >= n) return; // Push in reverse wire order so the LIFO stack visits the first P hash // first. Reversing only this node's appended segment preserves DFS when // older sibling work is already below it. const parent_start = pending.items.len; for (manifest.p) |pc| { var hashes = pc.hashes(); while (hashes.next()) |parent| { const owned = try gpa.dupe(u8, parent); pending.append(gpa, owned) catch { gpa.free(owned); return error.OutOfMemory; }; } } std.mem.reverse([]u8, pending.items[parent_start..]); } } /// PATH's presence and effective content hash inside one check-in's file set. /// The hash is a 40/64-char lowercase-hex F-card token (§4.7.6), copied into a /// fixed buffer so the comparison outlives `fileAt`'s duped card. const PathContent = struct { present: bool, hash: [2 * digest_len]u8 = undefined, hash_len: usize = 0, }; /// The effective content of PATH in the check-in named `id` (artifact §8 /// reconstruction via `Repository.fileAt` — the one general-purpose query, so /// no CLI-side re-derivation off raw F cards, B-log-5). `present` is false when /// `id` is not interned (a phantom the derived view never crosslinked) or PATH /// is not in its effective set (never added, or a delta delete marker §4.7.6). fn effectivePathContent( gpa: std.mem.Allocator, repo: *Repository, id: []const u8, path: []const u8, ) LogWalkError!PathContent { const rid = repo.lookup(id) orelse return .{ .present = false }; const card = (repo.fileAt(gpa, rid, path) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, // NotManifest/BaselineUnresolvable/BaselineIsDelta/Corrupt/NotARepo all // mean the reachable check-in cannot be reconstructed — the same class // the unfiltered walk reports as a corrupt/invalid target. else => error.Corrupt, }) orelse return .{ .present = false }; defer Repository.freeFileCard(gpa, card); // The iterator behind `fileAt` suppresses delete markers, so a returned // card always carries a hash (§4.7.6). const h = card.hash.?; std.debug.assert(h.len <= 2 * digest_len); var out: PathContent = .{ .present = true, .hash_len = h.len }; @memcpy(out.hash[0..h.len], h); return out; } /// Whether the check-in `id` changes PATH per the B-log change definition: its /// effective content (or presence) for PATH differs from its PRIMARY parent's /// (§3.2 first-listed P-card parent). A root has no parent, so presence alone is /// a change; a rename surfaces at both names as a presence flip (no old-name /// following, the B-merge-22 delete+add posture). `manifest` is the already- /// parsed check-in the walk holds. fn logChangesPath( gpa: std.mem.Allocator, repo: *Repository, id: []const u8, manifest: *const artifact.Manifest, path: []const u8, ) LogWalkError!bool { const here = try effectivePathContent(gpa, repo, id, path); // §3.2: the primary parent is the first hash of the first P card. var primary: ?[]const u8 = null; for (manifest.p) |pc| { var hashes = pc.hashes(); if (hashes.next()) |first| { primary = first; break; } } const parent = if (primary) |pid| try effectivePathContent(gpa, repo, pid, path) else PathContent{ .present = false }; if (here.present != parent.present) return true; if (!here.present) return false; return !std.mem.eql(u8, here.hash[0..here.hash_len], parent.hash[0..parent.hash_len]); } /// `zit log CHECKIN PATH` — the PATH-restricted ancestry walk (B-log-2). Opens /// the `Repository` facade for the §8 effective-set query, then reuses the exact /// unfiltered DFS (`walkLog`) so order and line layout match B-log-1, gating each /// `checkin` line on the change predicate. `root_dir` is borrowed (the caller /// closes it after this returns). fn runLogPath( gpa: std.mem.Allocator, io: std.Io, streams: cli.Streams, root_dir: std.Io.Dir, root_id: []const u8, path: []const u8, limit: ?usize, ) cli.Exit { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "log", e); defer repo.deinit(gpa); const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "log", e); walkLog(gpa, log, root_id, streams.out, .{ .repo = &repo, .path = path }, limit) catch |e| { switch (e) { error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {}, error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {}, error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {}, error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {}, error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}, error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, } return .failure; }; return .ok; } pub fn runLog( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseLogArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "log", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "log", .extra_argument, tok), .bad_limit => |tok| return cli.usageError(streams, "log", .bad_limit, tok), }; if (parsed.help) { streams.out.writeAll(log_help) catch {}; return .ok; } const name = parsed.checkin orelse return cli.usageError(streams, "log", .no_checkin, ""); const root = (cli.resolveRoot(arena, io, amb.repo_override) catch { streams.err.writeAll("zit log: out of memory\n") catch {}; return .failure; }) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // A full lowercase-hex ID walks exactly as it always has, without deriving // repo state. Anything else — a prefix, a branch, a tag, a reserved form — // goes through the §8.3 resolver `zit info` uses, then walks by the // resolved full ID. const root_id: []const u8 = if (artifact.validateName(name)) name else blk: { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "log", e); defer repo.deinit(gpa); const rid = repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit log: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit log: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, } return .failure; }; break :blk arena.dupe(u8, repo.hashOf(rid)) catch { streams.err.writeAll("zit log: out of memory\n") catch {}; return .failure; }; }; // With a PATH the walk is restricted to the check-ins that change it // (B-log-2), a route that needs the `Repository` facade for §8 effective-set // queries; without one the walk is byte-identical to the shipped behavior // (B-log-1), so the no-PATH path below is left exactly as it was. if (parsed.path) |path| return runLogPath(gpa, io, streams, root_dir, root_id, path, parsed.limit); var file = cli.openStoreRead(io, root_dir) catch { streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}; return .failure; }; defer file.close(io); const log_st = file.stat(io) catch { streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}; return .failure; }; var fs = storage.FileStorage.init(io, file, log_st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| { switch (e) { error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, else => streams.err.print("zit log: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}, } return .failure; }; defer log.deinit(gpa); walkLog(gpa, &log, root_id, streams.out, null, parsed.limit) catch |e| { switch (e) { error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {}, error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {}, error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {}, error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {}, error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}, error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, } return .failure; }; return .ok; } /// `zit leaves [--all]` — list the open leaves of the check-in DAG (repo-state /// §7.1/§7.4/§7.5). Opens the repository through `Repository` and renders each /// leaf's full artifact ID and branch, mtime-descending (§7.5 SHOULD order), /// marking the leaf that matches the current checkout with "*". `--all` /// additionally lists closed leaves — §7.4: closed status is derived, so a /// closed leaf stays a leaf-table member. Enumeration is distinct from name /// resolution (§7.7): the listing is exactly the leaf table filtered by the /// §7.4 open predicate. An unborn repository has no leaves: empty stdout, exit 0. pub fn runLeaves( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseLeavesArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "leaves", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "leaves", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(leaves_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "leaves", amb.repo_override, parsed.no_cache, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Resolve the current checkout's version to a rid so its leaf is marked // (§7.5). Absence/unknown/eventless leaves nothing marked — the mark is a // checkout convenience, never the source of the leaf set. const cur = repo.currentCheckoutRid(); return renderLeaves(arena, &repo, parsed.all, cur, streams); } /// One row of the leaf listing, gathered before the §7.5 mtime-descending sort. /// `hash` borrows the view's identity space (stable for the view's lifetime). const LeafRow = struct { rid: repostate.Rid, mtime: repostate.Mtime, hash: []const u8, open: bool, }; /// §7.5 order: modification time descending. Ties break by artifact ID ascending /// so the listing is a deterministic total order (IDs are unique per check-in). fn leafRowLess(_: void, a: LeafRow, b: LeafRow) bool { if (a.mtime != b.mtime) return a.mtime > b.mtime; return std.mem.order(u8, a.hash, b.hash) == .lt; } /// Gathers, sorts (§7.5), and prints the leaf listing to stdout. A row is /// included when `all` is set or the leaf is open (§7.4 open predicate). Each /// line is `<mark><id> <branch>`, `<mark>` = "* " for the current-checkout leaf /// else " "; under `all` a closed leaf appends " (closed)". Bounded by the /// leaf-table size. fn renderLeaves( arena: std.mem.Allocator, repo: *Repository, all: bool, cur: ?repostate.Rid, streams: cli.Streams, ) cli.Exit { var rows: std.ArrayList(LeafRow) = .empty; defer rows.deinit(arena); const bound = repo.state().leaves().rids.len; var scanned: usize = 0; var it = repo.state().leaves(); while (it.next()) |rid| { scanned += 1; std.debug.assert(scanned <= bound); const open = repo.state().isOpenLeaf(rid); if (!all and !open) continue; // A leaf-table rid is always a crosslinked check-in, so it has an event. const mtime = repo.state().mtimeOf(rid) orelse unreachable; rows.append(arena, .{ .rid = rid, .mtime = mtime, .hash = repo.hashOf(rid), .open = open, }) catch { streams.err.writeAll("zit leaves: out of memory\n") catch {}; return .failure; }; } std.debug.assert(rows.items.len <= bound); std.mem.sort(LeafRow, rows.items, {}, leafRowLess); for (rows.items) |row| { std.debug.assert(repo.state().isLeaf(row.rid)); std.debug.assert(all or row.open); const mark = if (cur != null and cur.? == row.rid) "* " else " "; const branch = repo.branchOf(row.rid); streams.out.print("{s}{s} {s}", .{ mark, row.hash, branch }) catch {}; if (all and !row.open) streams.out.writeAll(" (closed)") catch {}; streams.out.writeAll("\n") catch {}; } return .ok; } /// `zit timeline` — the global chronological history of every check-in /// (repo-state §2/§8.3): one line per check-in, event.mtime descending, ties by /// full artifact hash so the listing is byte-reproducible. Read-only: opens the /// repository, enumerates through the single `checkinsByMtime` query, renders, /// and mutates nothing. No positional, `-h`/`--help` only (the house /// `parseNoPositionalArgs` grammar). (contracts/timeline.md B-timeline-1..13.) pub fn runTimeline( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseTimelineArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "timeline", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "timeline", .extra_argument, tok), .bad_limit => |tok| return cli.usageError(streams, "timeline", .bad_limit, tok), }; // --help wins over every effect (B-timeline-8). if (parsed.help) { streams.out.writeAll(timeline_help) catch {}; return .ok; } // A cold `.full` verb: it reads adjacency (the §3.2 merge mark) and content // (the C-card comment), so it never takes the zero-parse image path. var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "timeline", amb.repo_override, false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); return renderTimeline(gpa, &repo, streams, parsed.limit); } /// §3.2: a check-in that joins two or more lines of development — one carrying /// at least one merge parent (a second-or-later P-card hash), i.e. more than one /// distinct parent. The mainline (primary-only) check-in has exactly one. fn timelineIsMerge(state: *const repostate.RepoState, rid: repostate.Rid) bool { return state.parentCount(rid) >= 2; } /// Renders the timeline to stdout: for each check-in in `checkinsByMtime` order, /// one line `<mark><hash12> <ISO> <branch> <first-comment-line>`. `<mark>` is /// "M " for a merge check-in (§3.2) else " ". Bounded by the enumeration size; /// each row reads and frees exactly one manifest (no unbounded retention). An /// empty repository prints nothing (B-timeline-7). fn renderTimeline(gpa: std.mem.Allocator, repo: *Repository, streams: cli.Streams, limit: ?usize) cli.Exit { const rids = repo.state().checkinsByMtime(gpa) catch return stateOom(streams, "timeline"); defer gpa.free(rids); // `checkinsByMtime` is newest-first and totally ordered (ties are broken // deterministically, not by arrival), so "the most recent N" is the first N // of it — including when N check-ins share a one-second timestamp. A cap // past the end simply shows everything. const shown_count = if (limit) |n| @min(n, rids.len) else rids.len; // The window is a prefix of the walked set (`rids`), capped by `-n N` when set. std.debug.assert(shown_count <= rids.len); if (limit) |n| std.debug.assert(shown_count <= n); var i: usize = 0; while (i < shown_count) : (i += 1) { const rid = rids[i]; // `checkinsByMtime` postcondition: every returned rid bears an event. const mtime = repo.state().mtimeOf(rid) orelse unreachable; // Mtime is the repository's millisecond timeline (repostate); the §4.6.4 // writer form is whole-second, so drop the sub-second remainder. var iso_buf: [19]u8 = undefined; const iso = cli.formatWriterTimestamp(@intCast(mtime / 1000), &iso_buf); const branch = repo.branchOf(rid); // §6.1, config main-branch fallback const mark = if (timelineIsMerge(repo.state(), rid)) "M " else " "; const hash = repo.hashOf(rid); const shown = hash[0..@min(hash.len, timeline_hash_display)]; // The comment rides the crosslinked state (the record body carries the // C card), so no manifest is read or parsed per row — the cost that // dominated an unlimited timeline on a large store. The manifest-read // fallback covers a state that cannot answer (image backing); either // way a bodyless check-in degenerates to an empty comment, never a // failure (errors defined out of existence here). var maybe: ?Repository.OwnedManifest = null; defer if (maybe) |*m| m.deinit(gpa); const manifest_comment: []const u8 = if (repo.state().commentOf(rid)) |c| firstCommentLine(c) else blk: { maybe = repo.manifest(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "timeline"), error.NotARepo, error.CannotRead, error.Corrupt => return repoReadFail(streams, "timeline"), }; break :blk if (maybe) |m| (if (m.manifest.c) |c| firstCommentLine(c.comment) else "") else ""; }; // A `zit amend --comment` override (repo-state §5.2) supersedes the // manifest's own comment in the display; absent one, the manifest comment // stands. The override value borrows the view's tagxref bytes (valid for // the whole render), so it is safe to print after the manifest is freed. const comment = repo.override(rid, "comment", manifest_comment); streams.out.print("{s}{s} {s} {s} {s}\n", .{ mark, shown, iso, branch, comment }) catch {}; } // Every intended row was rendered — the printed count matches the window. std.debug.assert(i == shown_count); return .ok; } /// `zit branch` — list the repository's branches (repo-state §6.3 derived /// grouping) one per line, name-ascending, marking the branch of the current /// checkout (§6.1/§6.4) with a leading "*". Pure presentation over two derived /// queries (`branches`, `branchOf`); no branch-creation surface (§6.2 is a /// later write-path slice). The default branch name comes from the view's /// config-derived `main_branch`, never a literal. pub fn runBranch( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseBranchArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "branch", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "branch", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(branch_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "branch", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // §6.3: the branch set is derived by grouping active branch tags — never // stored, never re-hardcoded here. const entries = repo.state().branches(gpa) catch return stateOom(streams, "branch"); defer gpa.free(entries); // §6.1/§6.4: mark the checked-out check-in's branch, resolved through the // active-tag filter and the config main-branch. `currentCheckoutRid` // guarantees `cur` bears an event, so `branchOf`'s precondition holds. const cur = repo.currentCheckoutRid(); const current_name: ?[]const u8 = if (cur) |r| repo.branchOf(r) else null; renderBranchList(streams.out, entries, current_name); return .ok; } /// `zit tag list [--raw] [CHECKIN]` (alias `ls`), `zit tag add [--raw] /// [--propagate] NAME CHECKIN [VALUE]`, and `zit tag cancel [--raw] NAME /// CHECKIN`. /// /// `list` is read-only enumeration over the crosslinked repo-view (repo-state /// §5.1/§5.2): with no CHECKIN, the distinct active tag names in use, symbolic /// ('sym-') names surfaced with the prefix stripped (§2) and every other tag /// omitted unless `--raw`; with a CHECKIN, resolve it (§8.3) and list the /// active tags visible on it and their values — pure presentation over the /// `tagNames`/`tagsOf` derivations. /// /// `add` and `cancel` are the write paths (REGRESSION B6): each mints one /// control artifact carrying a single T card. pub fn runTag( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseTagArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "tag", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "tag", .extra_argument, tok), .bad_subcommand => |tok| return cli.usageError(streams, "tag", .bad_subcommand, tok), .no_name => return cli.usageError(streams, "tag", .no_name, ""), .no_checkin => return cli.usageError(streams, "tag", .no_checkin, ""), .option_not_for_subcommand => |tok| return cli.usageError(streams, "tag", .option_not_for_subcommand, tok), }; if (parsed.help) { streams.out.writeAll(tag_help) catch {}; return .ok; } // The mutating arms own their own repository lifetime: the read view must // be CLOSED before the read-write append, so they cannot share the listing // path's `defer repo.deinit`. if (parsed.sub != .list) return runTagWrite(gpa, io, arena, parsed, streams, amb); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "tag", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); if (parsed.checkin) |name| return runTagCheckin(gpa, &repo, name, streams); // §5.1/§2: the distinct active tag names in use — derived, never stored. const names = repo.state().tagNames(gpa) catch return stateOom(streams, "tag"); defer gpa.free(names); renderTagNames(streams.out, names, parsed.raw); return .ok; } /// The `zit tag list CHECKIN` arm: resolve CHECKIN through the §8.3 procedure /// and list the active tags visible on it. An unresolvable or ambiguous name /// is a failure (exit 1, diagnostic to stderr); a resolved check-in with no /// active tags lists nothing (empty stdout, exit 0). fn runTagCheckin( gpa: std.mem.Allocator, repo: *Repository, name: []const u8, streams: cli.Streams, ) cli.Exit { const rid = repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit tag: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit tag: check-in '{s}' is ambiguous\n", .{name}) catch {}, // Settling an equal-mtime tie by ancestry allocates (`MtimeMaxPick`). error.OutOfMemory => return stateOom(streams, "tag"), } return .failure; }; const tags = repo.state().tagsOf(gpa, rid) catch return stateOom(streams, "tag"); defer gpa.free(tags); renderTagList(streams.out, tags); return .ok; } /// Tag-name prefixes fossil reserves for its own crosslinking of wiki pages, /// tickets, and technotes. A T card in one of these namespaces asserts that /// its target IS such an artifact, so minting one from the tag verb would /// forge that association — fossil refuses them here for the same reason, and /// matching the refusal keeps a zit-written repository readable by fossil /// without surprises. const reserved_tag_prefixes = [_][]const u8{ "wiki-", "tkt-", "event-" }; /// The `zit tag add` / `zit tag cancel` arm (REGRESSION B6): mint one control /// artifact carrying a single `T <sign><name> <hash> [value]` card. /// /// Mirrors `amend`'s shape, which is the point — both resolve a check-in, then /// compose a T run, then mint. Resolution first, over a read view that is closed /// before the read-write append (commit's durability pattern); the resolved hash /// is duped onto `arena` so it outlives that view. A failure to resolve mints /// NOTHING: every diagnostic below happens before the single fsync. /// /// Default sign is SINGLETON (`+`), so a symbolic name pins to exactly this /// check-in and is not inherited by its descendants — see `composeTagCards` /// for why that sign is the whole correctness argument, and why `--propagate` /// belongs to the raw namespace. fn runTagWrite( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, parsed: cli.TagArgs, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const name = parsed.name.?; // the parser's `no_name` arm guarantees it const checkin = parsed.checkin.?; // likewise `no_checkin` // An empty NAME names nothing (repo-state §2), rejected before any store is // opened — the same early check `commit --branch` makes. if (name.len == 0) { streams.err.writeAll("zit tag: NAME must not be empty\n") catch {}; return .failure; } // An empty VALUE cannot be written: §4.7.16's value is `[ SP value ]`, so // "present but empty" has no spelling on the wire. Omitting VALUE is how // you say valueless. if (parsed.value) |v| { if (v.len == 0) { streams.err.writeAll("zit tag: VALUE must not be empty; omit it for a valueless tag\n") catch {}; return .failure; } } for (reserved_tag_prefixes) |prefix| { if (std.mem.startsWith(u8, name, prefix)) { streams.err.print( "zit tag: '{s}' is reserved for fossil's own crosslinking; pick a name outside it\n", .{prefix}, ) catch {}; return .failure; } } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "tag", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); var target_hash: []const u8 = undefined; { defer repo.deinit(gpa); const rid = repo.resolve(checkin, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit tag: check-in '{s}' not found\n", .{checkin}) catch {}, error.Ambiguous => streams.err.print("zit tag: check-in '{s}' is ambiguous\n", .{checkin}) catch {}, // Settling an equal-mtime tie by ancestry allocates. error.OutOfMemory => return stateOom(streams, "tag"), } return .failure; }; target_hash = arena.dupe(u8, repo.hashOf(rid)) catch return stateOom(streams, "tag"); } // The mint targets a resolved check-in by its full §3.6 artifact id. std.debug.assert(artifact.validateName(target_hash)); const sign: artifact.TSign = switch (parsed.sub) { .cancel => .cancel, .add => if (parsed.propagate) .propagate else .add, .list => unreachable, // the caller routes `.list` elsewhere }; const tags = cli.composeTagCards(arena, target_hash, .{ .sign = sign, .name = name, .raw = parsed.raw, .value = parsed.value, }) catch return stateOom(streams, "tag"); var cname_buf: [64]u8 = undefined; const control_name = mintControlArtifact(gpa, io, root_dir, amb, tags, &cname_buf) catch |e| return tagLogFail(streams, e); // One confirmation line naming the control artifact, as `amend` reports its // mint. Every diagnostic already went to stderr. streams.out.print("tag {s}\n", .{control_name}) catch {}; return .ok; } /// Renders a `tag add` mint failure. Exhaustive over the widest closed set /// (`logstore.OpenError`); the narrower put/commit sets coerce into it. fn tagLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit { switch (err) { error.OutOfMemory => s.err.writeAll("zit tag: out of memory\n") catch {}, error.Storage => s.err.writeAll("zit tag: cannot write the tag: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit tag: the repository is corrupt\n") catch {}, } return .failure; } /// §5.1/§2 no-arg listing: one tag name per line, ascending. By default only /// symbolic ('sym-') tags are listed — they are the user-facing names, and are /// surfaced with the prefix stripped. The machinery tags (`branch`, `closed`) /// and other reserved prefixes are not listed as bare names: branch identity /// is already present via its stripped 'sym-' tag. /// /// `raw` lists every tag name verbatim, prefixes intact. It is the read half /// of `tag add --raw`: a property tag is deliberately outside the name space, /// so without this the tag you just wrote would be invisible here. Pure; /// bounded by `names.len`. fn renderTagNames(out: *std.Io.Writer, names: []const []const u8, raw: bool) void { for (names) |name| { if (raw) { out.writeAll(name) catch {}; } else { if (!std.mem.startsWith(u8, name, sym_prefix)) continue; out.writeAll(name[sym_prefix.len..]) catch {}; } out.writeAll("\n") catch {}; } } /// §5.1/§5.2 CHECKIN listing: one active tag per line, ascending by raw name /// (as `tagsOf` returns them). A 'sym-' tag prints with the prefix stripped; /// every other tag (including the §6.4 `branch` tag and the `closed` marker) /// prints its raw name. A tag carrying a value payload (§5.2) prints /// `<name> <value>`; a valueless tag prints `<name>` alone. Pure; bounded by /// `tags.len`. fn renderTagList(out: *std.Io.Writer, tags: []const repostate.RepoState.TagEntry) void { for (tags) |tag| { const shown = if (std.mem.startsWith(u8, tag.name, sym_prefix)) tag.name[sym_prefix.len..] else tag.name; out.writeAll(shown) catch {}; if (tag.value) |v| { out.writeAll(" ") catch {}; out.writeAll(v) catch {}; } out.writeAll("\n") catch {}; } } /// `zit info NAME` — resolve NAME through the repo-state §8 procedure and /// describe the check-in it names: full artifact ID, branch (§6.1), leaf /// status (§7), and parents (§3.2/§3.3). A thin renderer over the shared /// resolver: it calls `resolve(name, .checkin)` exactly as `tag` does, never a /// second resolver. §8.5: resolution is recency-based (§8.2 max-mtime), so a /// name can name a closed or non-leaf check-in that `leaves` would not list — /// name resolution is not open-leaf enumeration. Read-only; joins the /// leaves/branch/tag family through `openRepo`. pub fn runInfo( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseInfoArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "info", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "info", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(info_help) catch {}; return .ok; } // NAME is required; the arg is a symbolic name, not a check-in ID. const name = parsed.name orelse return cli.usageError(streams, "info", .no_name, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "info", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // §8.3 full dispatch. §8.2's two MUST-NOTs: closed-ness and leaf-ness of a // candidate never filter the selection — that lives entirely in `resolve`. const rid = repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit info: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit info: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => return stateOom(streams, "info"), } return .failure; }; return renderInfo(gpa, arena, &repo, rid, streams); } /// Renders the resolved check-in's facts to stdout only: its full artifact ID, /// branch (§6.1, config-threaded main-branch as fallback), leaf status /// (§7.1 membership + §7.4 open/closed), the primary parent (§3.2), and each /// merge parent (§3.3). Values left-align at a fixed column. Data only — every /// diagnostic already went to stderr. Bounded by the check-in's parent count. fn renderInfo( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, streams: cli.Streams, ) cli.Exit { // `resolve`'s postcondition: a resolved rid always bears an event, so every // derived query below has a defined answer — no failure path to invent. std.debug.assert(repo.state().mtimeOf(rid) != null); // Line 1: the canonical full artifact ID (echoes the resolved id even when // NAME was a prefix or a symbolic name). streams.out.print("{s}\n", .{repo.hashOf(rid)}) catch {}; // §6.1: the active-tag branch value, config main-branch as the fallback — // never the literal "trunk". streams.out.print("branch: {s}\n", .{repo.branchOf(rid)}) catch {}; // §7.1/§7.4: open (a leaf with no active closed tag), closed (a leaf that // carries one), or no (not a leaf at all). open/closed is defined only over // leaves, so a non-leaf's closed tag — if any — is not surfaced here. The // three arms are total over {isLeaf, hasActiveClosed}. const leaf_word: []const u8 = if (repo.state().isOpenLeaf(rid)) "open" else if (repo.state().isLeaf(rid)) "closed" else "no"; streams.out.print("leaf: {s}\n", .{leaf_word}) catch {}; // §3.2: the primary parent line (omitted for a root). const primary = repo.state().primaryParentOf(rid); if (primary) |p| streams.out.print("parent: {s}\n", .{repo.hashOf(p)}) catch {}; const mp = renderMergeParents(arena, repo, rid, primary, streams); if (mp != .ok) return mp; // §4.7.14 cherrypick/backout provenance (B-merge-41): a Q card on this // check-in surfaces as a `cherrypick:`/`backout:` line naming the picked // REV. Derived through the shipped artifact parse path (repo.manifest → // artifact.parse → parseQCard), never a bespoke re-parse; repostate keeps // no Q edge (REV is not a parent), so the manifest is the only source. A // check-in with no Q card prints nothing here — byte-identical to its // pre-B-merge-41 baseline (negative space). A parse fault skips the line // rather than failing the already-rendered facts. Bounded by the (v1: // single) Q-card count; the value column widens past the fixed lines // above because `cherrypick:` overflows their 8-space field. if (repo.manifest(gpa, rid) catch null) |owned| { var om = owned; defer om.deinit(gpa); for (om.manifest.q) |q| { const label: []const u8 = switch (q.sign) { .cherrypick => "cherrypick: ", .backout => "backout: ", }; streams.out.print("{s}{s}\n", .{ label, q.target }) catch {}; } } // Display overrides (repo-state §5.2, minted by `zit amend`): emit a // `comment:`/`user:`/`date:` line for each override ONLY when it is active, so // an un-amended check-in's info output stays byte-identical to its B-info // baseline (negative space). This is the one render path that surfaces a // check-in's overridden user and date. Values align at the same column as the // fixed lines above (8-space field, so `comment:`+1, `user:`/`date:`+4). const overrides = [_][]const u8{ "comment", "user", "date" }; for (overrides) |name| { std.debug.assert(name.len >= 1 and name.len <= 7); if (repo.state().activeTagValue(rid, name)) |v| { const pad = " "; // 8 spaces; the value column lands at index 9 streams.out.print("{s}:{s}{s}\n", .{ name, pad[0 .. 8 - name.len], v }) catch {}; } } return .ok; } /// Renders the §3.3 merge parents of `rid` — every parent but the primary — /// one `merge:` line each, sorted by artifact ID ascending. Gathered into an /// arena vector bounded by the parent count; OOM → exit 1. fn renderMergeParents( arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, primary: ?repostate.Rid, streams: cli.Streams, ) cli.Exit { var merges: std.ArrayList([]const u8) = .empty; defer merges.deinit(arena); const bound = repo.state().parentCount(rid); var scanned: usize = 0; var it = repo.state().parentsOf(rid); while (it.next()) |parent| { scanned += 1; std.debug.assert(scanned <= bound); if (primary != null and parent == primary.?) continue; merges.append(arena, repo.hashOf(parent)) catch { streams.err.writeAll("zit info: out of memory\n") catch {}; return .failure; }; } // Negative space: exactly the parents minus the single §3.2 primary — no // parent dropped or doubled. std.debug.assert(merges.items.len == bound - @as(usize, if (primary == null) 0 else 1)); std.mem.sort([]const u8, merges.items, {}, lessByHash); for (merges.items) |hash| streams.out.print("merge: {s}\n", .{hash}) catch {}; return .ok; } /// `zit ls [VERSION]` — list the effective file set of a resolved check-in, one /// `<blob-id> <filename>` line per file in canonical §4.5.3 order. Thin over the /// `Repository` facade: resolve VERSION (or fall back to the current checkout), /// then stream `repo.files`' iterator. All the §8.2 merge and §8.3 baseline /// policy lives in the library; this only selects the check-in and renders. pub fn runLs( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseLsArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "ls", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "ls", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(ls_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "ls", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // An explicit VERSION resolves through the §8.3 name procedure; an omitted // VERSION lists the current checkout (§7.5). An unborn or absent checkout has // no check-in to list — a failure, not empty output. const rid = if (parsed.version) |name| repo.resolve(name, .checkin) catch |e| return lsResolveFail(streams, name, e) else repo.currentCheckoutRid() orelse return lsNoCheckout(streams); var list = (repo.files(gpa, rid) catch |e| return lsFilesFail(streams, e)) orelse return lsMissingContent(streams); defer list.deinit(gpa); // Data only: one line per effective file. Iteration is over in-memory cards // (no I/O), so nothing but the write itself can fail mid-stream. var it = list.iterator(); while (it.next()) |f| { std.debug.assert(f.hash != null); // the iterator suppresses delete markers streams.out.print("{s} {s}\n", .{ f.hash.?, f.filename }) catch {}; } return .ok; } /// Maps a `Repository.resolve` failure for `ls` to its diagnostic + exit 1. fn lsResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit { switch (e) { error.NotFound => s.err.print("zit ls: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => s.err.print("zit ls: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => s.err.writeAll("zit ls: out of memory\n") catch {}, } return .failure; } /// Maps a `Repository.files` failure to its diagnostic + exit 1. Closed over /// `FilesError` so the switch stays exhaustive; the open/content phrasings match /// the shared `repoOpenFail` text. fn lsFilesFail(s: cli.Streams, e: Repository.FilesError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit ls: out of memory\n") catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.writeAll("zit ls: cannot read repository: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit ls: cannot read repository: repository is corrupt\n") catch {}, error.NotManifest => s.err.writeAll("zit ls: not a check-in\n") catch {}, error.BaselineUnresolvable => s.err.writeAll("zit ls: cannot resolve the baseline of a delta check-in\n") catch {}, error.BaselineIsDelta => s.err.writeAll("zit ls: the baseline of a delta check-in is itself a delta\n") catch {}, } return .failure; } fn lsNoCheckout(s: cli.Streams) cli.Exit { s.err.writeAll("zit ls: no check-in to list (the working checkout is unborn); name a version\n") catch {}; return .failure; } fn lsMissingContent(s: cli.Streams) cli.Exit { s.err.writeAll("zit ls: check-in content is missing\n") catch {}; return .failure; } /// `zit cat FILE [VERSION]` — emit one file's uncompressed content at a resolved /// check-in. `ls` narrowed to a single file (§8.4 single-name lookup): resolve /// the version the same way `ls` does, look the file up in the effective set, /// reconstruct its blob (delta chain → uncompressed, hash-verified §7.2), and /// write the bytes verbatim — no framing, no trailing newline (content is /// `[]const u8`, so embedded NUL and arbitrary binary bytes pass through). pub fn runCat( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseCatArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "cat", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "cat", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(cat_help) catch {}; return .ok; } const file = parsed.file orelse return cli.usageError(streams, "cat", .no_file, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "cat", amb.repo_override, parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Version resolution mirrors `ls`: an explicit VERSION through the §8.3 name // procedure, an omitted one the current checkout (§7.5); an unborn/absent // checkout has no check-in to read. const rid = if (parsed.version) |name| repo.resolve(name, .checkin) catch |e| return catResolveFail(streams, name, e) else repo.currentCheckoutRid() orelse return catNoCheckout(streams); // §8.4 single-name lookup: null ⇒ the file is not in this check-in's // effective set (absent, or a delete marker suppresses it). const found = (repo.fileAt(gpa, rid, file) catch |e| return catFail(streams, e)) orelse return catNotFound(streams, file); defer Repository.freeFileCard(gpa, found); std.debug.assert(found.hash != null); // fileAt yields only hash-bearing cards (§4.7.6) // Reconstruct the blob and verify it hashes to its F-card id (§7.2). A null // blob is a stored-content gap for a named-but-absent file. const bytes = (repo.blob(gpa, found.hash.?) catch |e| return catFail(streams, e)) orelse return catMissingContent(streams, file); defer gpa.free(bytes); streams.out.writeAll(bytes) catch {}; return .ok; } /// Maps a `Repository.resolve` failure for `cat` to its diagnostic + exit 1. fn catResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit { switch (e) { error.NotFound => s.err.print("zit cat: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => s.err.print("zit cat: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => s.err.writeAll("zit cat: out of memory\n") catch {}, } return .failure; } /// Maps a `Repository.fileAt` / `blob` failure to its diagnostic + exit 1. /// Switches the full `FilesError` set exhaustively; `blob`'s narrower `OpenError` /// coerces into it, so one renderer covers both surfaces. The open/content /// phrasings match the shared `repoOpenFail` text. fn catFail(s: cli.Streams, e: Repository.FilesError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit cat: out of memory\n") catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.writeAll("zit cat: cannot read repository: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit cat: cannot read repository: repository is corrupt\n") catch {}, error.NotManifest => s.err.writeAll("zit cat: not a check-in\n") catch {}, error.BaselineUnresolvable => s.err.writeAll("zit cat: cannot resolve the baseline of a delta check-in\n") catch {}, error.BaselineIsDelta => s.err.writeAll("zit cat: the baseline of a delta check-in is itself a delta\n") catch {}, } return .failure; } fn catNoCheckout(s: cli.Streams) cli.Exit { s.err.writeAll("zit cat: no check-in to read (the working checkout is unborn); name a version\n") catch {}; return .failure; } fn catNotFound(s: cli.Streams, file: []const u8) cli.Exit { s.err.print("zit cat: file '{s}' is not in the check-in\n", .{file}) catch {}; return .failure; } fn catMissingContent(s: cli.Streams, file: []const u8) cli.Exit { s.err.print("zit cat: content for '{s}' is missing\n", .{file}) catch {}; return .failure; } /// `zit artifact HASH [--raw]` — zit's plumbing window (contracts/artifact.md). /// A strictly read-only describe/dump of ONE stored artifact addressed by its /// hash. HASH covers every artifact kind (file blobs included), so it resolves /// through the artifact store's prefix lookup, not the symbolic-name procedure /// `info`/`ls`/`cat` use. No flag → the whatis summary; `--raw` → the exact /// stored bytes. All card interpretation flows through the one `artifact` /// parser — never a CLI-side re-parse. pub fn runArtifact( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseArtifactArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "artifact", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "artifact", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(artifact_help) catch {}; return .ok; } const hash_arg = parsed.hash orelse return cli.usageError(streams, "artifact", .no_hash, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "artifact", amb.repo_override, false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Canonicalize HASH: lowercase, hex-only, length band 4..64 (§3.2/§3.6). A // syntactically invalid HASH is the SAME not-found family as an unknown one // (B-artifact-8) — no distinct error surface. var canon_buf: [2 * logstore.max_hash_len]u8 = undefined; const canon = canonicalizeHexPrefix(hash_arg, &canon_buf) orelse return artifactNotFound(streams, hash_arg); var full_buf: [2 * logstore.max_hash_len]u8 = undefined; const full = (repo.resolveArtifactPrefix(gpa, canon, &full_buf) catch |e| switch (e) { error.Ambiguous => return artifactAmbiguous(streams, hash_arg), // B-artifact-7 error.OutOfMemory => return artifactOom(streams), error.NotARepo, error.CannotRead, error.Corrupt => |oe| return repoOpenFail(streams, "artifact", oe), }) orelse return artifactNotFound(streams, hash_arg); // B-artifact-8 // §7.2-verified stored bytes. `resolveArtifactPrefix` named a real stored // artifact, so a null here would be a store race, not user input. const bytes = (repo.blob(gpa, full) catch |e| return repoOpenFail(streams, "artifact", e)) orelse return artifactNotFound(streams, hash_arg); defer gpa.free(bytes); // B-artifact-4 round-trip invariant: the emitted bytes hash back to HASH. std.debug.assert(artifact.verify(full, bytes)); if (parsed.raw) { // B-artifact-4: verbatim, no trailing newline, binary-safe (mirrors cat). // Never parses or verifies Z. streams.out.writeAll(bytes) catch {}; return .ok; } return renderArtifactSummary(streams, gpa, full, bytes); } /// Canonicalize a HASH argument into `out`: lowercase every byte, accept only /// hex digits and only a 4..64-length token (§3.2 length band, §3.6 case- /// insensitive prefix). Returns null for anything outside that — the caller /// folds null into the not-found refusal, so a bad HASH never has its own /// error surface (B-artifact-8). No allocation. fn canonicalizeHexPrefix(arg: []const u8, out: *[2 * logstore.max_hash_len]u8) ?[]const u8 { if (arg.len < 4 or arg.len > out.len) return null; for (arg, 0..) |c, i| { const lc = std.ascii.toLower(c); if (!((lc >= '0' and lc <= '9') or (lc >= 'a' and lc <= 'f'))) return null; out[i] = lc; } return out[0..arg.len]; } /// Render the whatis summary of a resolved artifact to stdout (data-only). Tries /// the Z-tolerant `parseWindow`: on success the artifact is structural (its type, /// size, one line per card, and the §6.2 Z verdict); on any framing/grammar/ /// classification fault it is an opaque blob (B-artifact-3) — type file, size, /// and the NUL binary verdict, never an error. Exit 0 either way. fn renderArtifactSummary(streams: cli.Streams, gpa: std.mem.Allocator, full: []const u8, bytes: []const u8) cli.Exit { streams.out.print("{s}\n", .{full}) catch {}; var m = artifact.parseWindow(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return artifactOom(streams), else => { // A blob is a first-class answer (B-artifact-3), not a failure. streams.out.writeAll("type: file\n") catch {}; streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {}; streams.out.print("binary: {s}\n", .{if (merge3.isBinary(bytes)) "yes" else "no"}) catch {}; return .ok; }, }; defer m.deinit(); streams.out.print("type: {s}\n", .{m.kind.diagnosticName()}) catch {}; streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {}; renderCardLines(streams, m); // B-artifact-5: the Z verdict is REPORTED, not enforced — a mismatch stays // exit 0 (this is a window, not a gate). Emitted only when a Z card is // present; `parseWindow` succeeded so the framed content is recoverable. if (m.z != null) { const content = artifact.frameNoZ(bytes).?; // non-null: parseWindow framed it const verdict = artifact.verifyZCard(content); std.debug.assert(verdict != .absent); // a present Z card is structurally last streams.out.print("Z: {s}\n", .{if (verdict == .match) "verified" else "checksum mismatch"}) catch {}; } return .ok; } /// One line per card of a structural artifact, in ascending card-letter order /// (A..W; Z is the separate verdict line the caller prints). §4.4 makes wire /// order non-decreasing, so lane order == stored order — this reads decoded /// struct fields, never re-walking the bytes. Repeatable lanes (F/J/M/P/Q/T) /// render in retained wire order, one line per card. Best-effort writes. fn renderCardLines(s: cli.Streams, m: artifact.Manifest) void { if (m.a) |a| { s.out.print("A {s} {s}", .{ a.name, a.target }) catch {}; if (a.source) |src| s.out.print(" {s}", .{src}) catch {}; s.out.writeAll("\n") catch {}; } if (m.b) |b| s.out.print("B {s}\n", .{b.hash}) catch {}; if (m.c) |c| s.out.print("C {s}\n", .{c.comment}) catch {}; if (m.d) |d| s.out.print("D {s}\n", .{d.timestamp}) catch {}; if (m.e) |e| s.out.print("E {s} {s}\n", .{ e.timestamp, e.technote_id }) catch {}; for (m.f) |f| { s.out.print("F {s}", .{f.filename}) catch {}; if (f.hash) |h| s.out.print(" {s}", .{h}) catch {}; if (f.perm) |p| s.out.print(" {s}", .{p}) catch {}; if (f.old_name) |o| s.out.print(" {s}", .{o}) catch {}; s.out.writeAll("\n") catch {}; } if (m.g) |g| s.out.print("G {s}\n", .{g.hash}) catch {}; if (m.h) |h| s.out.print("H {s}\n", .{h.title}) catch {}; if (m.i) |i| s.out.print("I {s}\n", .{i.hash}) catch {}; for (m.j) |j| s.out.print("J {s} {s}\n", .{ j.field, j.value }) catch {}; if (m.k) |k| s.out.print("K {s}\n", .{k.ticket_id}) catch {}; if (m.l) |l| s.out.print("L {s}\n", .{l.title}) catch {}; for (m.m) |mc| s.out.print("M {s}\n", .{mc.hash}) catch {}; if (m.n) |n| s.out.print("N {s}\n", .{n.mimetype}) catch {}; for (m.p) |p| { s.out.writeAll("P") catch {}; var it = p.hashes(); while (it.next()) |hh| s.out.print(" {s}", .{hh}) catch {}; s.out.writeAll("\n") catch {}; } for (m.q) |q| { const sign_byte: u8 = if (q.sign == .cherrypick) '+' else '-'; s.out.print("Q {c}{s}", .{ sign_byte, q.target }) catch {}; if (q.baseline) |base| s.out.print(" {s}", .{base}) catch {}; s.out.writeAll("\n") catch {}; } if (m.r) |r| s.out.print("R {s}\n", .{r.checksum}) catch {}; for (m.t) |t| { const tgt = t.target orelse "*"; // §4.7.16 self-referential tag s.out.print("T {c}{s} {s}", .{ t.sign.byte(), t.name, tgt }) catch {}; if (t.value) |v| s.out.print(" {s}", .{v}) catch {}; s.out.writeAll("\n") catch {}; } if (m.u) |u| s.out.print("U {s}\n", .{u.login}) catch {}; if (m.w) |w| s.out.print("W {d} bytes\n", .{w.len}) catch {}; } fn artifactNotFound(s: cli.Streams, arg: []const u8) cli.Exit { s.err.print("zit artifact: no artifact matches '{s}'\n", .{arg}) catch {}; return .failure; } fn artifactAmbiguous(s: cli.Streams, arg: []const u8) cli.Exit { s.err.print("zit artifact: hash '{s}' is ambiguous\n", .{arg}) catch {}; return .failure; } fn artifactOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit artifact: out of memory\n") catch {}; return .failure; } /// The narrow read-failure set the annotate content path collapses the /// `Repository` file/blob errors into, so the renderer switches once. const AnnotateReadError = error{ OutOfMemory, Storage, Corrupt }; /// PATH's effective content in the check-in `rid` (artifact §8 via /// `Repository.fileAt` + `blob`), duplicated into `arena`, or null when PATH is /// absent there (never added, or a delta delete). Transient `gpa` allocations /// (the F card, the verified blob) are freed before returning; only the arena /// copy survives, owned by the caller's arena. fn annotateContentAt( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, path: []const u8, ) AnnotateReadError!?[]const u8 { const maybe_card = repo.fileAt(gpa, rid, path) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, // NotARepo/Corrupt/NotManifest/BaselineUnresolvable/BaselineIsDelta all // mean this reachable check-in cannot be reconstructed — corrupt. else => error.Corrupt, }; const card = maybe_card orelse return null; defer Repository.freeFileCard(gpa, card); // The effective-set iterator suppresses delete markers, so a returned card // always carries a hash (§4.7.6). const bytes = (repo.blob(gpa, card.hash.?) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, error.NotARepo, error.Corrupt => error.Corrupt, }) orelse return null; defer gpa.free(bytes); return try arena.dupe(u8, bytes); } /// One check-in in PATH's ancestry, in the processing order the repostate /// topological linearization fixes (parent before child; B-annotate-5). `hash` /// borrows the view's identity space (stable for the view's lifetime). const AnnotateEntry = struct { rid: repostate.Rid, mtime: repostate.Mtime, hash: []const u8, }; fn annotateReadFail(streams: cli.Streams, e: AnnotateReadError) cli.Exit { switch (e) { error.OutOfMemory => streams.err.writeAll("zit annotate: out of memory\n") catch {}, error.Storage => streams.err.writeAll("zit annotate: cannot read repository: input/output error\n") catch {}, error.Corrupt => streams.err.writeAll("zit annotate: cannot read repository: repository is corrupt\n") catch {}, } return .failure; } /// The B-annotate-10 refusal: PATH is not in the current check-out's managed set. /// Distinguishes an untracked-but-present working file from a nonexistent one so /// the diagnostic tells the user which mistake they made. Exit 1, stdout empty. fn annotateNotTracked(io: std.Io, root_dir: std.Io.Dir, path: []const u8, streams: cli.Streams) cli.Exit { const present = blk: { var f = root_dir.openFile(io, path, .{}) catch break :blk false; f.close(io); break :blk true; }; if (present) streams.err.print("zit annotate: '{s}' is not tracked at the current check-out\n", .{path}) catch {} else streams.err.print("zit annotate: '{s}': no such tracked file\n", .{path}) catch {}; return .failure; } /// `zit annotate PATH` — parse (one repo-relative positional via the shared /// one-positional grammar), open the repository read-only, and render the /// annotation. `-h`/`--help` wins; a missing PATH / unknown option / second /// positional is a usage error (exit 2). (B-annotate-12/-13/-16.) pub fn runAnnotate( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseAnnotateArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "annotate", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "annotate", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(annotate_help) catch {}; return .ok; } const path = parsed.path orelse return cli.usageError(streams, "annotate", .no_file, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; // A cold read: it walks ancestry (§3.3) and reconstructs effective sets, so // it takes the full derive, never the zero-parse image path. const rc = openRepo(gpa, io, arena, streams, "annotate", amb.repo_override, false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // VERSION resolves exactly as `cat`'s (the §8.3 name procedure); omitted // means the current checkout, so a bare repository (-R with no working // tree) names its target explicitly (issue #67). const at: ?repostate.Rid = if (parsed.version) |name| repo.resolve(name, .checkin) catch |e| return annotateResolveFail(streams, name, e) else null; return annotateFile(gpa, io, &repo, root_dir, path, at, parsed.full, streams); } /// Maps a `Repository.resolve` failure for `annotate` to its diagnostic + exit 1. fn annotateResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit { switch (e) { error.NotFound => s.err.print("zit annotate: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => s.err.print("zit annotate: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => s.err.writeAll("zit annotate: out of memory\n") catch {}, } return .failure; } /// Gather PATH's version chain over the current check-out's full ancestry, /// attribute each target line through the pure engine, and render. A scratch /// arena owns every derived array and every materialized version body; the /// repository content channel stays on `gpa` so it is freed by `repo.deinit`. fn annotateFile( gpa: std.mem.Allocator, io: std.Io, repo: *Repository, root_dir: std.Io.Dir, path: []const u8, at: ?repostate.Rid, full_ids: bool, streams: cli.Streams, ) cli.Exit { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // The annotated version is PATH at `at` (an explicit VERSION), else at the // current check-out. An unborn check-out tracks nothing (B-annotate-10 / // -14 boundary). const cur = at orelse repo.currentCheckoutRid() orelse return annotateNotTracked(io, root_dir, path, streams); // The full ancestry of `cur` (repo-state §3.3, merge parents included per // §3.2), already linearized into processing order by the ONE general-purpose // query — never a CLI-side walk or sort (the merge-base/bisect posture). The // order is a topological linearization of the ancestry DAG: every parent // precedes its child, so same-mtime and non-monotone parent/child pairs never // invert the attribution (ambiguities.md `annotate-attribution`). const ordered_rids = repo.ancestryLinearized(gpa, cur) catch return stateOom(streams, "annotate"); defer gpa.free(ordered_rids); var entries: std.ArrayList(AnnotateEntry) = .empty; for (ordered_rids) |rid| { entries.append(arena, .{ .rid = rid, .mtime = repo.state().mtimeOf(rid) orelse 0, .hash = repo.hashOf(rid), }) catch return stateOom(streams, "annotate"); } // rid → node index in processing order, so parent links resolve to indices. var index_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty; for (entries.items, 0..) |e, i| index_of.put(arena, e.rid, @intCast(i)) catch return stateOom(streams, "annotate"); // Build the pure engine's node view: content + §3.2 primary/merge parents. const nodes = arena.alloc(annotate_mod.Node, entries.items.len) catch return stateOom(streams, "annotate"); for (entries.items, 0..) |e, i| { const content = annotateContentAt(gpa, arena, repo, e.rid, path) catch |err| return annotateReadFail(streams, err); const primary_rid = repo.state().primaryParentOf(e.rid); const primary: ?u32 = if (primary_rid) |pr| index_of.get(pr) else null; var merges: std.ArrayList(u32) = .empty; var it = repo.state().parentsOf(e.rid); while (it.next()) |p| { if (primary_rid != null and p == primary_rid.?) continue; // primary handled separately if (index_of.get(p)) |idx| merges.append(arena, idx) catch return stateOom(streams, "annotate"); } nodes[i] = .{ .content = content, .primary = primary, .merges = merges.items }; } const cur_index = index_of.get(cur).?; // cur is self-inclusive in its ancestry const target = nodes[cur_index].content orelse return annotateNotTracked(io, root_dir, path, streams); // Provenance is undefined over binary content (B-annotate-9, the merge3 NUL // predicate) — refuse before emitting anything. if (merge3.isBinary(target)) { streams.err.print("zit annotate: '{s}': cannot annotate binary content\n", .{path}) catch {}; return .failure; } const origins = annotate_mod.attribute(arena, nodes, cur_index) catch return stateOom(streams, "annotate"); // Render one output line per file line: the origin's 10-char artifact-hash // prefix, a space, the ISO date (UTC, from the origin's event mtime), a space, // then the file line verbatim (its own trailing newline carries the break). const lines = merge3.splitLines(arena, target) catch return stateOom(streams, "annotate"); std.debug.assert(lines.len == origins.len); for (lines, origins) |line, origin| { const oe = entries.items[origin]; const full = repo.hashOf(oe.rid); // --full: the whole artifact ID, safe as an index key; the default // 10-character prefix stays for eyeballs (issue #67). const shown = if (full_ids) full else full[0..@min(full.len, 10)]; var iso_buf: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(@intCast(oe.mtime / 1000), &iso_buf); const iso_date = ts[0..10]; // YYYY-MM-DD streams.out.print("{s} {s} {s}", .{ shown, iso_date, merge3.lineBytes(target, line) }) catch {}; } return .ok; } /// Seed a `depth`-deep linear check-in chain into `log` (index 0 = the deepest /// ancestor, index depth-1 = the tip), filling `names_out` with each check-in's /// 64-hex name. The deepest check-in's sole P parent is a check-in NOT stored /// here — a phantom. Used to witness `walkLog`'s windowed early exit: the /// phantom line is emitted only if the walk descends past the deepest ancestor. fn logSeedPhantomChain( gpa: std.mem.Allocator, log: *logstore.LogStore, comptime depth: usize, names_out: *[depth][64]u8, ) !void { try log.putConfig(.{ .hash_policy = 2, .default_branch = "trunk", .project_id = "pid", .project_code = "code-abc" }); // A valid 64-hex name for content deliberately absent from the store. var phantom: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, "unstored-parent", &phantom); var i: usize = 0; while (i < depth) : (i += 1) { const v = try packEvolvingBlob(gpa, @intCast(i + 1)); defer gpa.free(v); const b = try packSeedBlob(log, v); const parent: []const u8 = if (i == 0) &phantom else &names_out[i - 1]; var ts_buf: [32]u8 = undefined; const ts = try std.fmt.bufPrint(&ts_buf, "2020-01-{d:0>2}T00:00:00", .{i + 1}); try packSeedCheckin(gpa, log, ts, parent, "f.txt", &b, &names_out[i]); } try log.commit(); } /// Count the lines of `text` that begin with `prefix` (test-only line tally). fn logCountLinePrefix(text: []const u8, prefix: []const u8) usize { var n: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |line| { if (std.mem.startsWith(u8, line, prefix)) n += 1; } return n; } test "log walk: `zit log CHECKIN` prints every reachable ancestor in P-card DFS order then the missing parent as a phantom" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const depth = 6; var names: [depth][64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try logSeedPhantomChain(gpa, &wlog, depth, &names); } var sfs: storage.FileStorage = undefined; var log = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer log.deinit(gpa); const tip: []const u8 = &names[depth - 1]; // The unbounded walk prints all `depth` check-ins tip->root in P-card DFS // order, then the deepest ancestor's phantom parent — exactly once each. var buf: [4096]u8 = undefined; var w = Io.Writer.fixed(&buf); try walkLog(gpa, &log, tip, &w, null, null); const out = w.buffered(); try std.testing.expectEqual(@as(usize, depth), logCountLinePrefix(out, "checkin ")); try std.testing.expectEqual(@as(usize, 1), logCountLinePrefix(out, "phantom ")); var ebuf: [4096]u8 = undefined; var stream = Io.Writer.fixed(&ebuf); var d: usize = depth; while (d > 0) : (d -= 1) stream.print("checkin {s}\n", .{names[d - 1][0..]}) catch unreachable; const want_checkins = stream.buffered(); try std.testing.expectEqualStrings(want_checkins, out[0..want_checkins.len]); } // --------------------------------------------------------------------------- // frames (#66): the incremental-consumer read seam. Emits every artifact // appended after a cursor as whole raw bytes, self-framed, with a trailer // carrying the new cursor and a prefix-validation token. The token is the // store's own LogKey — the O(1) freshness proof the derived-cache tier // already trusts — so appends keep a cursor alive and a pack/shun rewrite // (a new generation, new layout) kills it detectably. // --------------------------------------------------------------------------- const frames_help = main.frames_help; /// Decode a `--since` cursor: the decimal rid count a previous trailer /// emitted. Null on any malformation — the caller folds it into the /// cursor-invalid refusal (a forged cursor is indistinguishable from a dead /// one, and both have the same remedy: re-read from zero). fn framesParseCursor(text: []const u8) ?u64 { return std.fmt.parseInt(u64, text, 10) catch null; } /// Decode a `--verify` token: the four LogKey fields a previous trailer /// emitted, dash-joined decimal. Null on any malformation. fn framesParseToken(text: []const u8) ?logstore.LogStore.LogKey { var it = std.mem.splitScalar(u8, text, '-'); const end = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null; const frame_count = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null; const last_frame_offset = std.fmt.parseInt(u64, it.next() orelse return null, 10) catch return null; const last_header_crc = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; if (it.next() != null) return null; // no residue return .{ .end = end, .frame_count = frame_count, .last_frame_offset = last_frame_offset, .last_header_crc = last_header_crc, }; } pub fn runFrames( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseFramesArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "frames", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "frames", .extra_argument, tok), .empty_value => |tok| return cli.usageError(streams, "frames", .unknown_option, tok), }; if (parsed.help) { streams.out.writeAll(frames_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "frames", amb.repo_override, false, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "frames", e); // PERSISTED (frame) order, not dense rid order: `intern` mints a rid for // a phantom before the frame that fills it lands, so a rid-ordered cursor // would pass a hole and never revisit it. Frame order is append order -- // the same clock the LogKey prefix token describes. const persisted = log.ridsInPersistedOrder(gpa) catch return stateOom(streams, "frames"); defer gpa.free(persisted); const count: u64 = persisted.len; // The consumed prefix: 0 (bootstrap) or the previous trailer's cursor. var since: u64 = 0; if (parsed.since) |text| { since = framesParseCursor(text) orelse return framesCursorInvalid(streams, "the cursor is not one this store issued"); // A cursor past the store's end can only come from a rewritten // (shrunk) store or a foreign one -- dead either way. if (since > count) return framesCursorInvalid(streams, "the cursor names artifacts this store does not hold"); } // Prefix validation: the token is the LogKey of the consumed prefix. A // rewrite (pack / shun reclaim) relocates or re-CRCs frames and fails // this check; plain appends never touch it. if (parsed.verify) |text| { const key = framesParseToken(text) orelse return framesCursorInvalid(streams, "the token is not one this store issued"); if (!logstore.LogStore.validatePrefix(log.file, key)) { return framesCursorInvalid(streams, "the store was rewritten since the cursor was issued"); } } // Emit rids since+1..count: whole reconstructed artifacts (the delta // chain and container layout stay private), each verified against its // name before a byte leaves — byte-identical to `artifact HASH --raw`. // Phantoms hold no bytes and shun-masked names read as absent; both are // skipped, and the cursor still advances over them (rid order is the // cursor's clock, presence is per-artifact). var emitted: u64 = 0; for (persisted[@intCast(since)..]) |r| { const bytes = (log.content(gpa, r) catch |e| return framesReadFail(streams, e)) orelse continue; defer gpa.free(bytes); var hex_buf: [2 * logstore.max_hash_len]u8 = undefined; const hex = std.fmt.bufPrint(&hex_buf, "{x}", .{log.hashOf(r)}) catch unreachable; if (!zit.artifact.verify(hex, bytes)) { streams.err.print("zit frames: stored artifact {s} does not hash to its name\n", .{hex}) catch {}; return .failure; } streams.out.print("artifact {s} {d}\n", .{ hex, bytes.len }) catch {}; streams.out.writeAll(bytes) catch {}; streams.out.writeAll("\n") catch {}; emitted += 1; } // The trailer: the new cursor and its token, emitted even when nothing // new exists so a bootstrap poll always learns its position. const key = if (count == 0) (log.logKey() catch |e| return framesKeyFail(streams, e)) else (log.logKeyAt(persisted[persisted.len - 1]) catch |e| return framesKeyFail(streams, e)); streams.out.print("cursor {d} {d}-{d}-{d}-{d}\n", .{ count, key.end, key.frame_count, key.last_frame_offset, key.last_header_crc, }) catch {}; if (emitted == 0) return .no_new; return .ok; } /// The #66 cursor-invalid refusal: a distinct exit code (4) so a consumer /// distinguishes "re-read from zero" from every operational failure. The /// message states the mechanism, never "corrupt". fn framesCursorInvalid(s: cli.Streams, reason: []const u8) cli.Exit { s.err.print("zit frames: cursor invalid: {s}; re-read from --since=0\n", .{reason}) catch {}; return .cursor_invalid; } fn framesReadFail(s: cli.Streams, e: logstore.ContentError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit frames: out of memory\n") catch {}, error.Storage => s.err.writeAll("zit frames: cannot read the repository\n") catch {}, error.Corrupt => s.err.writeAll("zit frames: the repository is unreadable\n") catch {}, } return .failure; } fn framesKeyFail(s: cli.Streams, e: logstore.LogStore.KeyError) cli.Exit { switch (e) { error.Storage => s.err.writeAll("zit frames: cannot read the repository\n") catch {}, error.Corrupt => s.err.writeAll("zit frames: the repository is unreadable\n") catch {}, } return .failure; }