Artifact 14cba4a41c6eb2670de698b8ba81bfa5b428449c2424bdde78d62ff96b9eb1cb:
- File
src/cmd/landing.zig
— part of check-in
[c9ff7a71ea]
at
2026-08-23 13:30:51
on branch trunk
— landing: propose a branch for trunk as a reviewable record
A landing is a forum thread plus a raw tag on the check-in that lands, so a stock fossil clone reads all of it and no new artifact kind is minted. open/ls/show/comment/accept/reject; accept merges and commits first, then records the outcome, so a partial failure leaves an open landing over merged work rather than an accepted record over an unmerged branch. (user: dmestas, size: 33642) [annotate] [blame] [check-ins using]
//! `zit landing` — propose a branch for trunk, as a reviewable record (#127). //! //! A landing is a CEREMONY, not a new artifact kind. Everything it records is //! an ordinary fossil artifact: the record is a forum thread, every comment and //! state transition is a reply in that thread, and the terminal outcome is a //! raw tag on the check-in that actually landed. A stock fossil clone of a //! repository carrying landings sees forum threads and tagged check-ins — fully //! readable, merely not actionable. No card type and no artifact kind is minted //! here that `forum` and `tag` do not already mint. //! //! That constraint is the feature's whole compatibility story, so it is worth //! stating as a rule rather than a habit: IF AN IMPLEMENTATION HERE REACHES FOR //! A NEW CARD, THE DESIGN IS WRONG. //! //! The encoding, therefore, lives in the body of the thread root: //! //! zit-landing: 1 //! branch: <name> //! target: <name> //! //! <the author's message> //! //! and a transition is a reply whose body opens with `zit-landing-state: X`. //! Both are plain text a human reads without tooling, which is the point: the //! fallback for a reader that does not know what a landing is should be a //! slightly odd forum post, never an unparseable one. //! //! THE INTERFACE HIDES THAT ENCODING (Ousterhout, and the reason this verb //! family exists at all instead of a documentation page). A caller opens, //! reviews, accepts and rejects a landing knowing only branch names and landing //! ids. If a thread hash leaks into the interface, or `accept` starts asking //! callers to name a tag, this has become a pass-through wrapper and the //! complexity it was built to absorb has been pushed back up to quarry and to //! every human — seven consumers at last count. //! //! Reads are FULL-STORE SCANS, deliberately, on the precedent `ticket set` //! already set (`records.zig`): the store carries no forum index, and a wrong //! answer from a stale one is worse than a slow correct one. Quarry owns //! indexing; zit owns being right. Quarry-scale stores read in milliseconds. const std = @import("std"); const cli = @import("zit").cli; const artifact = @import("zit").artifact; const logstore = @import("zit").logstore; const storage = @import("zit").storage; const Repository = @import("zit").Repository; const main = @import("../main.zig"); const cmd_checkout = @import("checkout.zig"); const cmd_history = @import("history.zig"); const stateOom = main.stateOom; const landing_help = main.landing_help; /// The body-header line that makes a forum thread a landing. Versioned from /// the first commit: a later dialect must be able to say so without guessing, /// and a reader that meets a version it does not know must refuse rather than /// misread a field it only thinks it recognises. const marker = "zit-landing: 1"; /// The reply-body prefix carrying a state transition. const state_marker = "zit-landing-state:"; /// The raw tag `accept` writes on the check-in that landed, and `reject` /// writes on the branch tip it declined. Raw because a landing outcome is a /// PROPERTY many check-ins may carry, not a name that resolves — which is /// exactly the distinction `tag --raw` was built for. const outcome_tag = "landing"; /// One landing body beyond this is not a proposal, it is a data dump. const max_body_bytes: usize = 1024 * 1024; /// TigerStyle: every scan is finite by inspection. A store holding more /// landings than this has a different problem than rendering them. const max_landings: usize = 4096; /// A landing's derived state. `open` is the absence of a terminal transition, /// never a recorded value — so a thread whose replies are all comments is open /// by construction and cannot drift. const State = enum { open, accepted, rejected, fn label(s: State) []const u8 { return switch (s) { .open => "open", .accepted => "accepted", .rejected => "rejected", }; } fn parse(s: []const u8) ?State { if (std.mem.eql(u8, s, "open")) return .open; if (std.mem.eql(u8, s, "accepted")) return .accepted; if (std.mem.eql(u8, s, "rejected")) return .rejected; return null; } }; /// One landing, folded from its thread. Every slice borrows the arena the /// fold was given; nothing here outlives it. const Landing = struct { id: []const u8, branch: []const u8, target: []const u8, title: []const u8, author: []const u8, opened: []const u8, state: State, /// The transition that settled `state`, for `show`. Empty while open. settled_by: []const u8 = "", }; /// The header a landing's thread root carries, parsed back out of the body. /// Null when the body is not a landing at all, which is the common case: most /// forum posts in a real store are conversation. fn parseHeader(body: []const u8) ?struct { branch: []const u8, target: []const u8 } { if (!std.mem.startsWith(u8, body, marker)) return null; var branch: ?[]const u8 = null; var target: ?[]const u8 = null; var it = std.mem.splitScalar(u8, body, '\n'); _ = it.next(); // the marker line, already matched while (it.next()) |line| { if (line.len == 0) break; // the blank line ends the header if (std.mem.startsWith(u8, line, "branch: ")) { branch = std.mem.trim(u8, line["branch: ".len..], " \r"); } else if (std.mem.startsWith(u8, line, "target: ")) { target = std.mem.trim(u8, line["target: ".len..], " \r"); } else { // An unknown header line under a version we DO claim to know is a // dialect mismatch, not a field to skip. Refuse the whole record. return null; } } const b = branch orelse return null; const t = target orelse return null; if (b.len == 0 or t.len == 0) return null; return .{ .branch = b, .target = t }; } /// The state a reply carries, or null when it is an ordinary comment. fn parseTransition(body: []const u8) ?State { if (!std.mem.startsWith(u8, body, state_marker)) return null; const rest = body[state_marker.len..]; const end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; return State.parse(std.mem.trim(u8, rest[0..end], " \t\r")); } pub fn runLanding( 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 Sub = enum { open, ls, show, comment, accept, reject }; var sub: ?Sub = null; var positional: ?[]const u8 = null; var branch: ?[]const u8 = null; var target: ?[]const u8 = null; var message: ?[]const u8 = null; var user: ?[]const u8 = null; var show_all = false; var i: usize = 0; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { streams.out.writeAll(landing_help) catch {}; return .ok; } else if (std.mem.eql(u8, tok, "--branch")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--branch"); branch = verb_args[i]; } else if (std.mem.eql(u8, tok, "--to")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--to"); target = verb_args[i]; } else if (std.mem.eql(u8, tok, "-m") or std.mem.eql(u8, tok, "--message")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, tok); message = verb_args[i]; } else if (std.mem.eql(u8, tok, "--user")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--user"); user = verb_args[i]; } else if (std.mem.eql(u8, tok, "--all")) { show_all = true; } else if (tok.len > 1 and tok[0] == '-') { return cli.usageError(streams, "landing", .unknown_option, tok); } else if (sub == null) { if (std.mem.eql(u8, tok, "open")) { sub = .open; } else if (std.mem.eql(u8, tok, "ls") or std.mem.eql(u8, tok, "list")) { sub = .ls; } else if (std.mem.eql(u8, tok, "show")) { sub = .show; } else if (std.mem.eql(u8, tok, "comment")) { sub = .comment; } else if (std.mem.eql(u8, tok, "accept")) { sub = .accept; } else if (std.mem.eql(u8, tok, "reject")) { sub = .reject; } else { return cli.usageError(streams, "landing", .unknown_option, tok); } } else if (positional == null) { positional = tok; } else { return cli.usageError(streams, "landing", .extra_argument, tok); } } const chosen = sub orelse { streams.err.writeAll("zit landing: usage: zit landing open|ls|show|comment|accept|reject\n") catch {}; return .usage; }; return switch (chosen) { .open => openLanding(gpa, io, arena, streams, amb, branch, target, message, user), .ls => listLandings(gpa, io, arena, streams, amb, show_all), .show => showLanding(gpa, io, arena, streams, amb, positional), .comment => transition(gpa, io, arena, streams, amb, positional, message, user, null), .accept => acceptLanding(gpa, io, arena, streams, amb, positional, message, user), .reject => transition(gpa, io, arena, streams, amb, positional, message, user, .rejected), }; } /// `open` — mint the thread root that IS the landing. fn openLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, branch_opt: ?[]const u8, target_opt: ?[]const u8, message_opt: ?[]const u8, user: ?[]const u8, ) cli.Exit { const branch = branch_opt orelse { streams.err.writeAll("zit landing: open needs --branch NAME\n") catch {}; return .usage; }; const message = message_opt orelse { streams.err.writeAll("zit landing: open needs -m MESSAGE\n") catch {}; return .usage; }; if (message.len == 0) { streams.err.writeAll("zit landing: the message must not be empty\n") catch {}; return .failure; } const author = resolveAuthor(user, amb) orelse { streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {}; return .failure; }; var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); // `mainBranch()` borrows the repository's own memory, which the block below // frees. Duped into the arena BEFORE that, because the header this ends up // in is a permanent artifact: a dangling read here mints a corrupt record. const target = target_opt orelse (arena.dupe(u8, repo.mainBranch()) catch { repo.deinit(gpa); return stateOom(streams, "landing"); }); // A landing against a branch that does not resolve is a typo, and a typo // that mints a permanent artifact is a footprint nobody can retract. Both // ends are checked BEFORE anything is written. { defer repo.deinit(gpa); _ = repo.resolve(branch, .checkin) catch { streams.err.print("zit landing: branch '{s}' not found\n", .{branch}) catch {}; return .failure; }; _ = repo.resolve(target, .checkin) catch { streams.err.print("zit landing: target '{s}' not found\n", .{target}) catch {}; return .failure; }; if (std.mem.eql(u8, branch, target)) { streams.err.writeAll("zit landing: a branch cannot land on itself\n") catch {}; return .failure; } } // One open landing per branch. Refused by name, so the caller learns which // record already claims it rather than discovering two later. var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found) != .ok) return .failure; for (found.items) |l| { if (l.state == .open and std.mem.eql(u8, l.branch, branch)) { streams.err.print( "zit landing: '{s}' already has an open landing ({s})\n", .{ branch, abbrev(l.id) }, ) catch {}; return .failure; } } const body = std.fmt.allocPrint( arena, "{s}\nbranch: {s}\ntarget: {s}\n\n{s}\n", .{ marker, branch, target, message }, ) catch return stateOom(streams, "landing"); const hash = postThread(gpa, io, arena, streams, amb, author, message, body) orelse return .failure; streams.out.print("{s}\n", .{hash}) catch {}; return .ok; } /// `ls` — the queue. Open landings by default; `--all` includes settled ones. fn listLandings( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, show_all: bool, ) cli.Exit { var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found) != .ok) return .failure; for (found.items) |l| { if (!show_all and l.state != .open) continue; streams.out.print("{s} {s} {s} -> {s} {s}\n", .{ abbrev(l.id), l.state.label(), l.branch, l.target, l.title, }) catch {}; } return .ok; } /// `show` — the review surface for one landing. /// /// The check-in list is computed AT VIEW TIME against the target, never cached /// against a frozen range: the workflow this serves merges the target into the /// branch while work continues, so a snapshot would be wrong by the second /// commit. #127's acceptance criteria pin exactly that. fn showLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, ) cli.Exit { const id = id_opt orelse { streams.err.writeAll("zit landing: show needs a landing id\n") catch {}; return .usage; }; var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found) != .ok) return .failure; const l = pick(found.items, id) orelse { streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {}; return .failure; }; streams.out.print("landing {s}\n", .{l.id}) catch {}; streams.out.print("branch {s} -> {s}\n", .{ l.branch, l.target }) catch {}; streams.out.print("state {s}\n", .{l.state.label()}) catch {}; streams.out.print("author {s}\n", .{l.author}) catch {}; streams.out.print("opened {s}\n", .{l.opened}) catch {}; streams.out.print("\n{s}\n", .{l.title}) catch {}; return .ok; } /// `comment`, `accept` and `reject` — one path, because they differ only in /// what the reply says. `to` is null for a comment. /// /// ACCEPT'S ORDERING IS THE LOAD-BEARING PART. The merge is performed first /// and the transition recorded second: a merge that lands with no transition /// leaves a visibly-open landing over merged work, which a human resolves in /// one command. The reverse order would leave a landing marked accepted over /// an unmerged branch — a lie in the record, and the failure mode the module /// doc says this verb exists to absorb. fn transition( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, message_opt: ?[]const u8, user: ?[]const u8, to: ?State, ) cli.Exit { const id = id_opt orelse { streams.err.writeAll("zit landing: needs a landing id\n") catch {}; return .usage; }; const author = resolveAuthor(user, amb) orelse { streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {}; return .failure; }; var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found) != .ok) return .failure; const l = pick(found.items, id) orelse { streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {}; return .failure; }; // A settled landing takes comments but not a second verdict: re-deciding // would make the fold's latest-wins rule silently rewrite history that a // reader already acted on. if (to != null and l.state != .open) { streams.err.print( "zit landing: {s} is already {s}\n", .{ abbrev(l.id), l.state.label() }, ) catch {}; return .failure; } const message = message_opt orelse ""; const body = if (to) |s| std.fmt.allocPrint(arena, "{s} {s}\n\n{s}\n", .{ state_marker, s.label(), message }) catch return stateOom(streams, "landing") else b: { if (message.len == 0) { streams.err.writeAll("zit landing: comment needs -m MESSAGE\n") catch {}; return .failure; } break :b std.fmt.allocPrint(arena, "{s}\n", .{message}) catch return stateOom(streams, "landing"); }; const hash = replyThread(gpa, io, arena, streams, amb, author, l.id, body) orelse return .failure; streams.out.print("{s}\n", .{hash}) catch {}; return .ok; } /// `accept` — land the branch, then record that it landed. /// /// THE ORDER IS THE CONTRACT. Merge and commit run first; the outcome tag and /// the transition reply follow. A failure after the merge leaves merged work /// under a still-open landing, which one command settles. The reverse order /// would leave an accepted record over an unmerged branch — a lie in the /// permanent record, and the exact complexity this verb exists to keep away /// from its callers. /// /// Unlike its siblings this needs a WORKING TREE: `merge` writes one. So it /// refuses `-R` in-verb, the way `diff` refuses it in worktree mode, rather /// than making the whole verb checkout-dependent for quarry's sake. fn acceptLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, message_opt: ?[]const u8, user: ?[]const u8, ) cli.Exit { if (amb.repo_override != null) { streams.err.writeAll("zit landing: accept works in a checkout; -R names a repository with no working tree to merge into\n") catch {}; return .failure; } const id = id_opt orelse { streams.err.writeAll("zit landing: accept needs a landing id\n") catch {}; return .usage; }; const author = resolveAuthor(user, amb) orelse { streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {}; return .failure; }; var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found) != .ok) return .failure; const l = pick(found.items, id) orelse { streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {}; return .failure; }; if (l.state != .open) { streams.err.print("zit landing: {s} is already {s}\n", .{ abbrev(l.id), l.state.label() }) catch {}; return .failure; } // The checkout must already be on the target. Moving it here would be this // verb quietly rewriting the operator's working tree to make its own job // easier -- a surprise with a dirty tree in it. { var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = main.openRepo(gpa, io, arena, streams, "landing", null, false, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); const rid = repo.currentCheckoutRid() orelse { streams.err.writeAll("zit landing: accept works in a checkout\n") catch {}; return .failure; }; const on = repo.branchOf(rid); if (!std.mem.eql(u8, on, l.target)) { streams.err.print( "zit landing: this checkout is on '{s}'; {s} lands on '{s}' — update first\n", .{ on, abbrev(l.id), l.target }, ) catch {}; return .failure; } } // The sub-verbs' stdout is the ENCODING talking: a merge line, a check-in // line, a tag line. The module doc's rule is that callers never learn a // landing is a forum thread with a raw tag, so their stdout is discarded // and one landing-shaped line is printed instead. stderr is passed through // untouched — a real failure must still reach the operator in full. var sink_buf: [4096]u8 = undefined; var sink: std.Io.Writer.Discarding = .init(&sink_buf); const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err }; if (cmd_checkout.runMerge(gpa, io, arena, &.{l.branch}, quiet) != .ok) { streams.err.print("zit landing: {s} did not merge cleanly; resolve and commit, then accept again\n", .{abbrev(l.id)}) catch {}; return .failure; } const msg = if (message_opt) |m| m else std.fmt.allocPrint( arena, "land {s}", .{l.branch}, ) catch return stateOom(streams, "landing"); if (cmd_checkout.runCommit(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb) != .ok) { streams.err.writeAll("zit landing: the merge is in the working tree but the commit failed; commit it yourself, then accept again\n") catch {}; return .failure; } // The durable, check-in-oriented fact: this check-in is where a landing // came in. Raw, because it is a property many check-ins carry, not a name. if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) { streams.err.writeAll("zit landing: merged and committed, but the outcome tag failed\n") catch {}; return .failure; } const body = std.fmt.allocPrint( arena, "{s} accepted\n\n{s}\n", .{ state_marker, msg }, ) catch return stateOom(streams, "landing"); _ = replyThread(gpa, io, arena, streams, amb, author, l.id, body) orelse return .failure; streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {}; return .ok; } /// The shortest unambiguous rendering of a landing id for human output. Twelve /// hex characters is the codebase's existing abbreviation width. fn abbrev(id: []const u8) []const u8 { return if (id.len > 12) id[0..12] else id; } /// The landing an id names, matching on any unambiguous prefix. Null when no /// landing matches; a prefix matching two is also null, because answering /// arbitrarily is worse than refusing. fn pick(items: []const Landing, id: []const u8) ?Landing { var hit: ?Landing = null; for (items) |l| { if (!std.mem.startsWith(u8, l.id, id)) continue; if (hit != null) return null; hit = l; } return hit; } /// The author: `--user` wins, else the ambient OS user, exactly as `forum` /// resolves it. Empty is refused either way, for the reason `records.zig` /// gives: this writer will not mint more empty U tokens. fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 { const chosen = user orelse (amb.user_env orelse return null); if (chosen.len == 0) return null; return chosen; } /// One forum artifact as the fold needs it. Borrowed from the fold's arena. const Post = struct { hash: []const u8, root: ?[]const u8, title: []const u8, author: []const u8, stamp: []const u8, body: []const u8, }; /// Scan the store, collect every forum artifact, and fold the landing threads /// out of them. /// /// A FULL-STORE SCAN, on the precedent `ticket set` set: no forum index /// exists, and a stale index answering "no such landing" would silently mint a /// second one. See the module doc. /// /// The fold is deterministic and latest-wins on (timestamp, hash) — the same /// rule quarry already applies to ticket fields, chosen so two readers of the /// same store never disagree about a landing's state. fn fold( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, out: *std.ArrayList(Landing), ) cli.Exit { const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "landing")) orelse { main.reportNoRoot(streams, io, "landing", amb.repo_override); return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var file = cli.openStoreRead(io, root_dir) catch { streams.err.writeAll("zit landing: cannot read the repository\n") catch {}; return .failure; }; defer file.close(io); const st = file.stat(io) catch { streams.err.writeAll("zit landing: cannot read the repository\n") catch {}; return .failure; }; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch { streams.err.writeAll("zit landing: cannot read the repository\n") catch {}; return .failure; }; defer log.deinit(gpa); var posts: std.ArrayList(Post) = .empty; defer posts.deinit(arena); var rid: logstore.Rid = 1; const count: logstore.Rid = @intCast(log.entries.items.len); while (rid <= count) : (rid += 1) { const bytes = (log.content(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "landing"), // Damage is `verify`'s report to make, not this reader's: a // corrupt entry cannot be a landing, so skipping it is correct // and failing the whole listing would not be. else => continue, }) orelse continue; defer gpa.free(bytes); var parsed = artifact.parse(gpa, bytes) catch continue; defer parsed.deinit(); if (parsed.kind != .forum) continue; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, bytes, &name_buf); posts.append(arena, .{ .hash = arena.dupe(u8, name) catch return stateOom(streams, "landing"), .root = if (parsed.g) |g| arena.dupe(u8, g.hash) catch return stateOom(streams, "landing") else null, .title = if (parsed.h) |h| arena.dupe(u8, h.title) catch return stateOom(streams, "landing") else "", .author = if (parsed.u) |u| arena.dupe(u8, u.login) catch return stateOom(streams, "landing") else "", .stamp = if (parsed.d) |d| arena.dupe(u8, d.timestamp) catch return stateOom(streams, "landing") else "", .body = arena.dupe(u8, parsed.w orelse "") catch return stateOom(streams, "landing"), }) catch return stateOom(streams, "landing"); if (posts.items.len > max_landings * 8) break; // TigerStyle: bounded } // Pass 1 — the roots that are landings. for (posts.items) |p| { if (p.root != null) continue; // a reply is never a record const hdr = parseHeader(p.body) orelse continue; out.append(arena, .{ .id = p.hash, .branch = hdr.branch, .target = hdr.target, .title = p.title, .author = p.author, .opened = p.stamp, .state = .open, }) catch return stateOom(streams, "landing"); if (out.items.len >= max_landings) break; } // Pass 2 — settle each landing from its thread's transitions. Latest wins // on (timestamp, hash); the hash tiebreak is what makes two readers of the // same store agree when a second resolution shares a timestamp. for (out.items) |*l| { var best_stamp: []const u8 = ""; var best_hash: []const u8 = ""; for (posts.items) |p| { const r = p.root orelse continue; if (!std.mem.eql(u8, r, l.id)) continue; const s = parseTransition(p.body) orelse continue; const newer = std.mem.order(u8, p.stamp, best_stamp) == .gt or (std.mem.eql(u8, p.stamp, best_stamp) and std.mem.order(u8, p.hash, best_hash) == .gt); if (!newer) continue; best_stamp = p.stamp; best_hash = p.hash; l.state = s; l.settled_by = p.hash; } } return .ok; } /// Mint the thread root. `title` becomes the H card; `body` the W card. fn postThread( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, author: []const u8, title: []const u8, body: []const u8, ) ?[]const u8 { return mintForum(gpa, io, arena, streams, amb, author, title, null, body); } /// Mint a reply into `root`'s thread. G names the thread root and I the post /// replied to; here both are the root, because a landing's conversation is /// flat by design — a threaded argument about a state transition is a forum /// feature nobody asked this verb for. fn replyThread( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, author: []const u8, root: []const u8, body: []const u8, ) ?[]const u8 { return mintForum(gpa, io, arena, streams, amb, author, null, root, body); } /// The one place a landing writes an artifact. Exactly `forum`'s manifest /// shape — same kind, same cards — because a landing artifact IS a forum /// artifact and any divergence here would be the compatibility break the /// module doc forbids. fn mintForum( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, author: []const u8, title: ?[]const u8, root: ?[]const u8, body: []const u8, ) ?[]const u8 { std.debug.assert(body.len > 0); std.debug.assert(body.len <= max_body_bytes); std.debug.assert(title == null or root == null); // §5.4: H XOR I var ts_buf: [23]u8 = undefined; const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf); var m: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .forum, .a = null, .b = null, .c = null, .d = .{ .timestamp = ts }, .e = null, .g = if (root) |r| .{ .hash = r } else null, .h = if (title) |t| .{ .title = t } else null, .i = if (root) |r| .{ .hash = r } else null, .k = null, .l = null, .n = .{ .mimetype = "text/plain" }, .r = null, .u = .{ .login = author }, .z = null, .w = body, .f = &.{}, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .s = &.{}, .t = &.{}, }; defer m.deinit(); const bytes = artifact.create(&m, gpa) catch { _ = stateOom(streams, "landing"); return null; }; defer gpa.free(bytes); // Round-trip in every build mode, for the reason `records.zig` gives: these // bytes are read back by another process, and a malformed artifact minted // here would be quarry's data-loss bug to discover. var check = artifact.parse(gpa, bytes) catch { streams.err.writeAll("zit landing: refusing to store a malformed artifact (a zit bug — please report the invocation)\n") catch {}; return null; }; const kind_ok = check.kind == m.kind; check.deinit(); if (!kind_ok) { streams.err.writeAll("zit landing: refusing to store a malformed artifact (a zit bug — please report the invocation)\n") catch {}; return null; } const rroot = (cli.resolveRoot(arena, io, amb.repo_override) catch return null) orelse { main.reportNoRoot(streams, io, "landing", amb.repo_override); return null; }; var root_dir = rroot.dir; defer root_dir.close(io); var file = cli.openStoreAppend(io, root_dir) catch { streams.err.writeAll("zit landing: cannot write the repository\n") catch {}; return null; }; defer file.close(io); const st = file.stat(io) catch { streams.err.writeAll("zit landing: cannot write the repository\n") catch {}; return null; }; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch { streams.err.writeAll("zit landing: cannot write the repository\n") catch {}; return null; }; defer log.deinit(gpa); var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{}); var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, bytes, &name_buf); _ = log.putArtifact(&digest, bytes, null) catch { streams.err.writeAll("zit landing: cannot write the repository\n") catch {}; return null; }; log.commit() catch { streams.err.writeAll("zit landing: cannot write the repository\n") catch {}; return null; }; return arena.dupe(u8, name) catch null; }