Artifact 5c12450b101808acd21afe768b3c8a6c9aedb219c51a9e3f2a2928db3dfd4759:
- File
src/cmd/landing.zig
— part of check-in
[d97b39b272]
at
2026-08-24 18:52:22
on branch strata
— landing: close the landed leaf without propagating
The propagating form marked every segment ABOVE a landed one closed too: in a stack those check-ins descend from the landed tip, so propagation followed the DAG straight into work that was still open. The landing records stayed open and correct; only 'branch' and 'leaves' lied, which is exactly where you look for what still needs doing.
Fossil's own propagating close is safe because it closes a check-in with no descendants. A landed segment is a leaf by the same-branch rule while still having children on the branch above it. (user: dmestas, size: 75306) [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 prose> //! //! where the message's FIRST LINE is the thread title (the H card) and the //! prose is what follows it, so the record never carries the same text twice. //! 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 every caller -- //! the machine ones and the human at a terminal alike. //! //! 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. Indexing belongs to //! whatever serves the store; zit owns being right, and owns no opinion about //! what is built on top of it. //! //! That trade stands. The sentence that used to close this paragraph -- that //! stores of the size this serves "read in milliseconds" -- did not, and is //! removed rather than softened (#134). It was carried over from `ticketKnown`, //! where it is true because that is a WRITE path taken once per ticket change; //! this is a READ path taken per view, and the scale did not carry either. On a //! 117MB mirror the scan cost 62 seconds. //! //! The waste in it is gone (#134: the fold no longer parses every content blob //! to discover it is not a forum post), but the SHAPE has not changed: this is //! still linear in store bytes, and a store ten times larger costs ten times //! more. A caller that already knows which records exist, from an index of its //! own, should not be paying this at all -- the open half of #134, and not //! something the constant-factor fix closed. 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 merge3 = @import("../merge3.zig"); const cone_mod = @import("zit").cone; const checkout = @import("zit").checkout; const update = @import("zit").update; const main = @import("../main.zig"); const cmd_checkout = @import("checkout.zig"); const cmd_history = @import("history.zig"); const cmd_records = @import("records.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 reply-body prefix carrying a RETARGET — the branch this landing should /// land on, replacing the one its root header named (zit#144, `strata`). /// /// Deliberately the same shape as `state_marker`, settled by the same /// latest-wins rule, for the same reason: a landing's target changes when the /// branch below it in a stack lands, and a record that could not say so would /// have to be edited. Fossil artifacts are immutable, so "edited" would mean a /// new artifact kind — and the rule this feature is built under is that /// reaching for a new card means the design is wrong. const target_marker = "zit-landing-target:"; /// 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"; /// Fossil's own "this leaf is finished" property. `accept` writes it on the /// tip of the branch it just landed. /// /// Written WITHOUT `--propagate`, which is the whole subtlety. Propagation /// follows the DAG, and in a stack the next segment's check-ins are descendants /// of this one's tip — so a propagating close would mark every segment above /// this one closed too, hiding landings that are still open. Fossil's own use /// of a propagating `closed` is safe only because it closes a check-in nothing /// descends from; a landed stack segment is a leaf by fossil's same-branch rule /// while still having children on the branch above. /// /// Not written by `reject`: a rejected branch is one the author may revise and /// propose again, and closing it would be the tooling deciding that for them. const closed_tag = "closed"; /// 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, /// The author's message below the title, header block already stripped. /// Empty for a one-line proposal, which is what a one-line proposal is. prose: []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 }; } /// Split one authored message into the title and the prose beneath it, on the /// convention every commit message already follows: the first line is the /// subject, the rest is the body. /// /// The split is at the first NEWLINE, not the first blank line, because an H /// card is a single token (§4.7.7) and a title carrying an embedded newline is /// the defect this exists to prevent (#138). Splitting at a blank line would /// still let `"line one\nline two"` mint a two-line heading; splitting here /// cannot. A message with no newline is a title with no prose, which is what a /// one-line proposal is. fn splitMessage(message: []const u8) struct { title: []const u8, prose: []const u8 } { const nl = std.mem.indexOfScalar(u8, message, '\n') orelse return .{ .title = std.mem.trim(u8, message, " \t\r"), .prose = "" }; return .{ .title = std.mem.trim(u8, message[0..nl], " \t\r"), .prose = std.mem.trim(u8, message[nl + 1 ..], "\n\r \t"), }; } /// The author's message, with the header block the encoding prepends removed. /// /// The header is ENCODING, and the module doc is explicit that the interface /// hides it: a reader shown `zit-landing: 1` is reading through the interface /// built to hide it. Stripping it belongs beside `parseHeader`, which defines /// where the header ends, rather than in each caller that prints a body. fn prose(body: []const u8) []const u8 { if (!std.mem.startsWith(u8, body, marker)) return body; const blank = std.mem.indexOf(u8, body, "\n\n") orelse return ""; return std.mem.trim(u8, body[blank + 2 ..], "\n\r \t"); } /// The one line a heading can be. Landings minted before #138 carry the whole /// message in the H card, and an artifact is immutable — so every reader that /// prints a title on one line has to cope with those forever, not merely stop /// making new ones. fn titleLine(title: []const u8) []const u8 { const nl = std.mem.indexOfScalar(u8, title, '\n') orelse return title; return std.mem.trim(u8, title[0..nl], " \t\r"); } /// 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")); } /// The new target a reply carries, or null when it is not a retarget. An empty /// name is not a retarget: a reply that names no branch says nothing, and /// letting it through would blank a landing's target on a typo. fn parseRetarget(body: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, body, target_marker)) return null; const rest = body[target_marker.len..]; const end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; const name = std.mem.trim(u8, rest[0..end], " \t\r"); return if (name.len == 0) null else name; } 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, retarget }; 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 mimetype: ?[]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, "--mimetype")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--mimetype"); mimetype = 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 if (std.mem.eql(u8, tok, "retarget")) { sub = .retarget; } 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|retarget\n") catch {}; return .usage; }; return switch (chosen) { .open => openLanding(gpa, io, arena, streams, amb, branch, target, message, user, mimetype), .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, mimetype), .accept => acceptLanding(gpa, io, arena, streams, amb, positional, message, user, mimetype), .reject => transition(gpa, io, arena, streams, amb, positional, message, user, .rejected, mimetype), .retarget => retargetLanding(gpa, io, arena, streams, amb, positional, target, user, mimetype), }; } /// `retarget` — say that a landing should land somewhere else (zit#144). /// /// The stack case: when the branch below this one lands, this landing's target /// is a branch that is already merged, and accepting it there would put the /// work on a landed branch instead of on trunk. `accept` does this /// automatically for the stack it just unblocked; this is the manual form, and /// the one an operator reaches for when a stack is reordered by hand. /// /// Writes a reply, exactly as a state transition does. Nothing is edited, /// because nothing here can be: the record is immutable artifacts, and the fold /// resolves the target the same latest-wins way it resolves state. fn retargetLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, to_opt: ?[]const u8, user: ?[]const u8, mimetype: ?[]const u8, ) cli.Exit { const id = id_opt orelse { streams.err.writeAll("zit landing: retarget needs a landing id\n") catch {}; return .usage; }; const to = to_opt orelse { streams.err.writeAll("zit landing: retarget needs --to TARGET\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, null) != .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's target is history. Retargeting it would change what // the record says happened, which the fold would then serve as though it // had always said so. if (l.state != .open) { streams.err.print( "zit landing: {s} is already {s}\n", .{ abbrev(l.id), l.state.label() }, ) catch {}; return .failure; } // Refuse the no-op rather than writing a reply that changes nothing: a // thread of retargets to the same branch is noise in a permanent record. if (std.mem.eql(u8, l.target, to)) { streams.err.print( "zit landing: {s} already lands on '{s}'\n", .{ abbrev(l.id), to }, ) catch {}; return .failure; } // A landing cannot land on itself; the fold would report a chain that // never bottoms out. if (std.mem.eql(u8, l.branch, to)) { streams.err.print( "zit landing: '{s}' cannot land on itself\n", .{to}, ) catch {}; return .failure; } const body = std.fmt.allocPrint(arena, "{s} {s}\n", .{ target_marker, to }) catch return stateOom(streams, "landing"); const hash = replyThread(gpa, io, arena, streams, amb, author, l.id, body, mimetype) orelse return .failure; streams.out.print("{s} now lands on '{s}'\n", .{ abbrev(l.id), to }) catch {}; streams.out.print("{s}\n", .{hash}) catch {}; return .ok; } /// `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, mimetype: ?[]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; } // The title is the first line and the prose is what follows (#138): an H // card is one token, and a body carrying the title again would mint the // same text twice — once as a heading it cannot be, once as prose. // Checked HERE, with the other argument validation, so a message with no // title line is refused before the branch lookups and the store scan. const split = splitMessage(message); if (split.title.len == 0) { streams.err.writeAll("zit landing: the message must open with a title line\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, null) != .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, split.prose }, ) catch return stateOom(streams, "landing"); const hash = postThread(gpa, io, arena, streams, amb, author, split.title, body, mimetype) 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, null) != .ok) return .failure; for (found.items) |l| { if (!show_all and l.state != .open) continue; // zit#144: a segment's depth is how many open landings sit below it. // Rendered as an indent, so a stack reads as a stack without the list // growing a column that is blank for every landing that is not in one. const depth = stackDepth(found.items, l); var pad: [2 * max_stack_depth]u8 = undefined; const indent = pad[0 .. @min(depth, max_stack_depth) * 2]; @memset(indent, ' '); streams.out.print("{s}{s} {s} {s} -> {s} {s}\n", .{ indent, abbrev(l.id), l.state.label(), l.branch, l.target, titleLine(l.title), }) catch {}; } return .ok; } /// How deep in a stack a landing sits: 0 for the bottom, 1 for the one on it, /// and so on. Bounded by `max_stack_depth` so a cycle — expressible, since a /// retarget takes any branch name — terminates instead of hanging the verb. fn stackDepth(items: []const Landing, l: Landing) usize { var depth: usize = 0; var cur = l; while (depth < max_stack_depth) { const base = baseOf(items, cur) orelse return depth; depth += 1; cur = base; } return depth; } /// The deepest stack that renders as a stack. Past this the indent stops /// growing; the landings still list, they just stop being drawn as layers. /// TigerStyle: the walk above is finite by inspection because of this. const max_stack_depth: usize = 32; /// `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); var posts: std.ArrayList(Post) = .empty; defer posts.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found, &posts) != .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 {}; reportStack(streams, found.items, l); reportMergeability(gpa, io, arena, streams, amb, l); streams.out.print("\n{s}\n", .{titleLine(l.title)}) catch {}; if (l.prose.len != 0) streams.out.print("\n{s}\n", .{l.prose}) catch {}; reportCheckins(gpa, io, arena, streams, amb, l); reportThread(streams, posts.items, l); return .ok; } /// Upper bounds on what one `show` prints. A landing thread is a conversation /// and a branch is a branch -- neither is unbounded in principle, but paging a /// thousand rows into a terminal serves nobody, and a SILENT cap reads as /// "that was all of it". Both are stated when they bite. const max_shown_checkins: usize = 50; const max_shown_posts: usize = 100; /// The check-ins being proposed: on the branch, not on the target, newest /// first. What a reviewer decides from before they look at a diff -- and the /// reason `git request-pull`, which this ceremony descends from, puts a /// shortlog in its message. fn reportCheckins( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, ) void { var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; // Full derive, not the fast-read image: the comment is read from the // crosslinked state below, and the image cannot always answer for it. if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok) return; defer root_dir.close(io); defer repo.deinit(gpa); const branch_rid = repo.resolve(l.branch, .checkin) catch return; const target_rid = repo.resolve(l.target, .checkin) catch return; // Topological order, oldest first: every parent precedes its child, so // two check-ins made in the same second never invert. `repository.zig` // states the rule this follows -- "the verb owns no ancestry ordering of // its own" -- and an mtime sort here would have been exactly that mistake. const branch_line = repo.ancestryLinearized(gpa, branch_rid) catch return; defer gpa.free(branch_line); const target_anc = repo.ancestorsOf(gpa, target_rid) catch return; defer gpa.free(target_anc); var on_target: std.AutoHashMapUnmanaged(u64, void) = .empty; for (target_anc) |rid| on_target.put(arena, rid, {}) catch return; // What is being proposed: on the branch, not already on the target. var mine: std.ArrayList(u64) = .empty; for (branch_line) |rid| { if (on_target.contains(rid)) continue; mine.append(arena, rid) catch return; } if (mine.items.len == 0) return; std.mem.reverse(u64, mine.items); // newest first, for a reader const shown = @min(mine.items.len, max_shown_checkins); streams.out.writeAll("\n") catch {}; for (mine.items[0..shown]) |rid| { const mtime = repo.state().mtimeOf(rid) orelse 0; var iso_buf: [19]u8 = undefined; const iso = cli.formatWriterTimestamp(@intCast(@divTrunc(mtime, 1000)), &iso_buf); const hash = repo.hashOf(rid); // The comment rides the crosslinked state, so no manifest is parsed per // row -- the same reason `timeline` reads it there. The manifest // fallback covers a state that cannot answer; a bodyless check-in // degenerates to an empty comment, never a failure. var maybe: ?Repository.OwnedManifest = null; defer if (maybe) |*m| m.deinit(gpa); const from_state: []const u8 = if (repo.state().commentOf(rid)) |c| c else blk: { maybe = repo.manifest(gpa, rid) catch break :blk ""; break :blk if (maybe) |m| (if (m.manifest.c) |c| c.comment else "") else ""; }; const comment = repo.override(rid, .comment, from_state); streams.out.print("checkin {s} {s} {s}\n", .{ hash[0..@min(hash.len, 12)], iso, firstLine(comment), }) catch {}; } if (mine.items.len > shown) streams.out.print("... {d} more check-in(s) not shown\n", .{mine.items.len - shown}) catch {}; } /// The conversation, in the SAME order the state fold uses (date then hash), so /// what a reader sees can never disagree with the state they were shown. /// /// A transition renders as an EVENT rather than a remark -- "dan accepted this" /// is what happened, and showing it as a comment whose body starts with a /// marker line would leak the encoding the interface exists to hide. fn reportThread(streams: cli.Streams, posts: []const Post, l: Landing) void { var replies: [max_shown_posts]Post = undefined; var n: usize = 0; var total: usize = 0; for (posts) |p| { const r = p.root orelse continue; if (!std.mem.eql(u8, r, l.id)) continue; total += 1; if (n < replies.len) { replies[n] = p; n += 1; } } if (total == 0) return; // no thread section at all, not an empty heading std.mem.sort(Post, replies[0..n], {}, struct { fn lt(_: void, a: Post, b: Post) bool { const o = std.mem.order(u8, a.stamp, b.stamp); if (o != .eq) return o == .lt; return std.mem.order(u8, a.hash, b.hash) == .lt; } }.lt); for (replies[0..n]) |p| { if (parseTransition(p.body)) |st| { streams.out.print("\n{s} {s} {s} this\n", .{ p.stamp, p.author, st.label() }) catch {}; const note = std.mem.trim(u8, bodyAfterHeader(p.body), " \n\r\t"); if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {}; } else { streams.out.print("\n{s} {s}\n", .{ p.stamp, p.author }) catch {}; const note = std.mem.trim(u8, p.body, " \n\r\t"); if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {}; } } if (total > n) streams.out.print("\n... {d} more remark(s) not shown\n", .{total - n}) catch {}; } /// Everything after a body's header block -- the note a transition carried. fn bodyAfterHeader(body: []const u8) []const u8 { const sep = std.mem.indexOf(u8, body, "\n\n") orelse return ""; return body[sep + 2 ..]; } /// A comment's first line, for a one-row rendering. fn firstLine(text: []const u8) []const u8 { const end = std.mem.indexOfScalar(u8, text, '\n') orelse text.len; return text[0..end]; } /// The `merges` line, and one `conflict <path>` line per unresolvable path. /// /// Written as data a caller can parse without a second command: a forge has to /// render "this branch has conflicts" and NAME them, and the alternative -- /// making the forge attempt the merge to find out -- is the one thing a server /// must never do. /// /// A landing whose branch or target no longer resolves reports `merges /// unknown` rather than failing the whole `show`. The record outliving its /// branch is an ordinary state (the branch was renamed, or the landing is old), /// and refusing to display the record over it would be the wrong trade. fn reportMergeability( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, ) void { var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir) != .ok) { streams.out.writeAll("merges unknown\n") catch {}; return; } defer root_dir.close(io); defer repo.deinit(gpa); const target_rid = repo.resolve(l.target, .checkin) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; const branch_rid = repo.resolve(l.branch, .checkin) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; const probe = cmd_checkout.probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; switch (probe.verdict) { .up_to_date => streams.out.writeAll("merges up-to-date\n") catch {}, .unrelated => streams.out.writeAll("merges unrelated\n") catch {}, .clean => streams.out.writeAll("merges clean\n") catch {}, .conflicts => |paths| { streams.out.print("merges conflict ({d})\n", .{paths.len}) catch {}; for (paths) |path| streams.out.print("conflict {s}\n", .{path}) catch {}; }, } } /// `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, mimetype: ?[]const u8, ) 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, null) != .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, mimetype) 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 a bare-store forge'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, mimetype: ?[]const u8, ) cli.Exit { 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, null) != .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; } // zit#144: refuse out-of-order, before either accept path mutates anything. if (refuseIfStacked(streams, found.items, l)) |exit| return exit; // #131: named a repository rather than standing in a checkout -- the forge // shape. Same ceremony, same ordered writes; the merge runs against a // checkout this command creates and destroys. if (amb.repo_override != null) return acceptBare(gpa, io, arena, streams, amb, l, author, message_opt, mimetype, found.items); // 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, amb) != .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, mimetype) orelse return .failure; streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {}; // The branch is landed, so it is no longer work in progress. closeLandedBranch(gpa, io, arena, streams, amb, quiet, l); // zit#144: whatever was stacked on this branch now aims where it did. retargetChildren(gpa, io, arena, streams, amb, found.items, l, author, mimetype); return .ok; } /// Close the branch a landing just merged, so a landed segment stops showing up /// as an open leaf. /// /// Every landed branch left one behind, which was survivable when landings were /// one at a time and became untenable with stacks: a six-deep stack left six /// dangling leaves, and `leaves` is how an operator finds work that still needs /// doing. A branch whose landing was accepted is, by definition, not that. /// /// Best-effort and last, like the retargets: the landing has already succeeded /// and been recorded. A leaf that stayed open is untidy, never incorrect, and /// saying so beats unwinding a merge over it. fn closeLandedBranch( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, quiet: cli.Streams, l: Landing, ) void { if (cmd_history.runTag( gpa, io, arena, &.{ "add", "--raw", closed_tag, l.branch }, quiet, amb, ) != .ok) { streams.err.print( "zit landing: '{s}' landed but stayed an open leaf; close it with " ++ "'zit tag add --raw closed {s}'\n", .{ l.branch, l.branch }, ) catch {}; } } /// The stack edge, in both directions (zit#144, `strata`). /// /// There is no stored dependency and there must not be one: a landing whose /// `target` equals another OPEN landing's `branch` IS a stack edge, so the /// chain is already derivable from fields that exist. A second, writable source /// of truth for the same relation could only ever disagree with the first. /// /// `baseOf` — the landing this one sits on, or null when it is the bottom. fn baseOf(items: []const Landing, l: Landing) ?Landing { for (items) |c| { if (c.state != .open) continue; if (std.mem.eql(u8, c.id, l.id)) continue; if (std.mem.eql(u8, c.branch, l.target)) return c; } return null; } /// Every open landing sitting directly on `branch`. Written into `out`, which /// the caller sizes; the return is the slice actually filled. fn stackedOn(items: []const Landing, branch: []const u8, out: []Landing) []Landing { var n: usize = 0; for (items) |c| { if (n == out.len) break; if (c.state != .open) continue; if (!std.mem.eql(u8, c.target, branch)) continue; out[n] = c; n += 1; } return out[0..n]; } /// The ordering guard: a landing whose base has not landed cannot land either. /// /// Accepting out of order is not corruption — the merge still works, because /// the DAG does not care. What it produces is a landing whose diff silently /// widens to include the segment below it, which is a review that nobody /// agreed to. So this refuses and names the base, in the same posture the /// conflict refusal already uses: read the state, decline, explain. fn refuseIfStacked(streams: cli.Streams, items: []const Landing, l: Landing) ?cli.Exit { const base = baseOf(items, l) orelse return null; streams.err.print( "zit landing: {s} lands on '{s}', which has its own open landing {s}\n", .{ abbrev(l.id), l.target, abbrev(base.id) }, ) catch {}; streams.err.print( "zit landing: land {s} first, or retarget this one with 'zit landing retarget {s} --to BRANCH'\n", .{ abbrev(base.id), abbrev(l.id) }, ) catch {}; return .failure; } /// The most a single accept will retarget. A stack wider than this at one level /// is not a stack; TigerStyle wants the scan finite by inspection either way. const max_stacked_children: usize = 64; /// Auto-retarget the children of a landing that just landed (zit#144). /// /// This is the whole of what other tools call a server-side restack, and it is /// a metadata write per child rather than a history rewrite: the children's /// branches already have the landed tip as an ancestor, so their content needs /// no adjustment at all — only the record's idea of where they are going. /// /// AFTER the merge, the outcome tag and the accepted reply, for the same reason /// those are ordered as they are: a failure here leaves children pointing at a /// branch that really did land, which one `retarget` settles. Retargeting first /// would point them at a landing that might not happen. /// /// Best-effort by design. The accept has already succeeded and is recorded; a /// child that could not be retargeted is a stale target, which is exactly the /// state every child was in before this existed, and is reported rather than /// unwound. fn retargetChildren( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, items: []const Landing, landed: Landing, author: []const u8, mimetype: ?[]const u8, ) void { var buf: [max_stacked_children]Landing = undefined; const children = stackedOn(items, landed.branch, &buf); if (children.len == 0) return; const body = std.fmt.allocPrint(arena, "{s} {s}\n", .{ target_marker, landed.target }) catch return; var moved: usize = 0; for (children) |c| { // A child already aimed where its base was going needs nothing. if (std.mem.eql(u8, c.target, landed.target)) continue; if (replyThread(gpa, io, arena, streams, amb, author, c.id, body, mimetype)) |_| { moved += 1; } else { streams.err.print( "zit landing: {s} still lands on '{s}'; retarget it with 'zit landing retarget {s} --to {s}'\n", .{ abbrev(c.id), c.target, abbrev(c.id), landed.target }, ) catch {}; } } if (moved != 0) { streams.out.print( "retargeted {d} stacked landing(s) onto '{s}'\n", .{ moved, landed.target }, ) catch {}; } } /// The stack lines of `show` (zit#144): what this segment sits on, and what /// sits on it. Printed only when there is a stack — a landing that is not in /// one says nothing, because "stack: none" is noise on the overwhelming /// majority of records. fn reportStack(streams: cli.Streams, items: []const Landing, l: Landing) void { if (baseOf(items, l)) |base| { streams.out.print("on {s} ({s}, not yet landed)\n", .{ base.branch, abbrev(base.id), }) catch {}; } var buf: [max_stacked_children]Landing = undefined; const children = stackedOn(items, l.branch, &buf); for (children) |c| { streams.out.print("under {s} ({s})\n", .{ c.branch, abbrev(c.id) }) catch {}; } // The base was rejected, so this segment is stacked on abandoned work. Its // mergeability line will still say `clean`, which is true and useless: it // merges cleanly into a branch nobody is going to land. for (items) |c| { if (c.state != .rejected) continue; if (!std.mem.eql(u8, c.branch, l.target)) continue; streams.out.print("WARNING '{s}' was rejected ({s}); retarget before landing\n", .{ c.branch, abbrev(c.id), }) catch {}; } } /// 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 -- including what this costs, which is /// linear in store bytes and not something the #134 fix changed. /// /// The fold is deterministic and latest-wins on (timestamp, hash) — the same /// rule a ticket-field fold applies, 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), /// The forum artifacts the scan read, for a caller that wants the thread /// as well as the state. Null discards them. Handed back rather than /// re-scanned: this is a full-store read, and `show` needing the same /// bytes twice would double the one genuinely expensive thing here. posts_out: ?*std.ArrayList(Post), ) 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 if (posts_out == null) posts.deinit(arena); // One reusable plaintext buffer for the whole walk, as `frames` does: // a per-artifact allocation across a whole store is measurable, and a // frame's reconstruction is memoised across the entries that delta // against it. var plaintext: logstore.PlaintextCache = .{}; defer plaintext.deinit(gpa); var rid: logstore.Rid = 1; const count: logstore.Rid = @intCast(log.entries.items.len); while (rid <= count) : (rid += 1) { const bytes = (log.contentCached(gpa, rid, &plaintext) 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); // Reject-fast pre-screen (#108's, and for its reason): most of a real // store's bytes are CONTENT BLOBS, and a full parse pays a whole-bytes // Z checksum before rejecting each one. A structural artifact's first // bytes are a card line -- `<LETTER><SP>`, artifact-format Section 4.2 // -- or a signature wrapper, `-----BEGIN `, Section 4.1.1. // // The screen only ever REJECTS. Every accept still takes the full // parse below, so nothing this fold would refuse can be admitted by // being shaped like a card. const card_shaped = bytes.len >= 2 and bytes[0] >= 'A' and bytes[0] <= 'Z' and bytes[1] == ' '; const wrapper_shaped = std.mem.startsWith(u8, bytes, "-----BEGIN "); if (!card_shaped and !wrapper_shaped) continue; 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, .prose = prose(p.body), .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; } } // Pass 3 — retargets (zit#144). Same latest-wins rule as pass 2 and // deliberately a separate pass: a retarget and a settle are independent // facts about the same thread, so one must not be able to consume the // other's reply. Applied to open and settled landings alike — a settled // one simply has no reader that acts on its target. 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 t = parseRetarget(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.target = t; } } if (posts_out) |po| po.* = posts; 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, mimetype: ?[]const u8, ) ?[]const u8 { return mintForum(gpa, io, arena, streams, amb, author, title, null, body, mimetype); } /// 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, mimetype: ?[]const u8, ) ?[]const u8 { return mintForum(gpa, io, arena, streams, amb, author, null, root, body, mimetype); } /// 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, mimetype: ?[]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, // A landing record is a FORUM artifact, so it takes forum's N-card // rule verbatim -- shared, not restated: `text/x-fossil-wiki` is // suppressed because fossil's forum writer suppresses it, and minting // the card redundantly would give a different artifact id than fossil // produces from the same input. .n = cmd_records.nCard(mimetype, true), .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 a consumer'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; } /// `accept` against a NAMED repository, with no checkout of the operator's to /// stand in (#131). The forge path. /// /// The shape, and why it is this shape: /// /// * The merge needs a working tree, so this command MAKES one -- ephemeral, /// created and destroyed inside this call, and materializing ONLY the paths /// both sides changed. sparse-cone Section 4.4 resolves out-of-cone paths at /// the record "without materializing the files", so every other path costs /// nothing; the cone exists solely because hash-level resolution cannot /// combine two edits to one file. /// /// * NOTHING is written until the merge is known to be clean. A conflicting /// landing exits `.conflicts` with the store byte-unchanged. A forge must /// never hold a half-finished merge, and must never commit conflict /// markers -- the operator resolves it from their own checkout, as they /// would on any forge. /// /// * The check-in is minted by `runCommitIn`, the SAME minter the verb uses. /// The R card, the delta-dialect continuation and sub-manifest emission are /// decided there; a second writer here would produce artifacts other /// conforming implementations hash differently. fn acceptBare( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, author: []const u8, message_opt: ?[]const u8, mimetype: ?[]const u8, /// Every landing the same fold produced, so the stack this one unblocks can /// be retargeted without a second scan (zit#144). all: []const Landing, ) cli.Exit { // The store's ABSOLUTE path: the ephemeral checkout points at it from // another directory, so a relative pointer would go stale the moment // anything resolved it from elsewhere. const store_abs = blk: { const r = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "landing")) orelse { main.reportNoRoot(streams, io, "landing", amb.repo_override); return .failure; }; var d = r.dir; d.close(io); break :blk r.abs; }; var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok) return .failure; defer root_dir.close(io); var plan_conflicts: []const []const u8 = &.{}; var cone_members: []const []const u8 = &.{}; var target_hash: []const u8 = ""; { defer repo.deinit(gpa); const target_rid = repo.resolve(l.target, .checkin) catch { streams.err.print("zit landing: target '{s}' not found\n", .{l.target}) catch {}; return .failure; }; const branch_rid = repo.resolve(l.branch, .checkin) catch { streams.err.print("zit landing: branch '{s}' not found\n", .{l.branch}) catch {}; return .failure; }; const probe = cmd_checkout.probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch return stateOom(streams, "landing"); switch (probe.verdict) { .up_to_date => { streams.err.print( "zit landing: '{s}' is already contained in '{s}'; nothing to land\n", .{ l.branch, l.target }, ) catch {}; return .failure; }, .unrelated => { streams.err.print( "zit landing: '{s}' and '{s}' share no common ancestor\n", .{ l.branch, l.target }, ) catch {}; return .failure; }, .conflicts => |paths| plan_conflicts = paths, .clean => {}, } cone_members = probe.both_modified; target_hash = arena.dupe(u8, repo.hashOf(target_rid)) catch return stateOom(streams, "landing"); } // Refuse BEFORE anything is created, let alone written. The paths go to // stderr and the status is `.conflicts`, so a caller can render "blocked" // and name the files without parsing prose. if (plan_conflicts.len != 0) { streams.err.print( "zit landing: {s} does not merge cleanly into '{s}'; nothing was written\n", .{ abbrev(l.id), l.target }, ) catch {}; for (plan_conflicts) |path| streams.err.print("zit landing: conflict {s}\n", .{path}) catch {}; streams.err.writeAll( "zit landing: merge the target into the branch from a checkout, resolve, and push\n", ) catch {}; return .conflicts; } // cone.Cone refuses an empty member list, and a landing that touches no // file on both sides legitimately has one. A member no path can equal // covers nothing, which is exactly the intent: materialize nothing. const members: []const []const u8 = if (cone_members.len == 0) &[_][]const u8{no_overlap_member} else cone_members; if (members.len > cone_mod.max_members) { streams.err.print( "zit landing: {d} files changed on both sides, past the {d}-member cone bound; accept this from a checkout\n", .{ members.len, cone_mod.max_members }, ) catch {}; return .failure; } var work = ephemeralCheckout(gpa, io, arena, streams, root_dir, store_abs, target_hash, members, l) orelse return .failure; defer work.destroy(io, root_dir); const named: cmd_checkout.NamedCheckout = .{ .dir = work.dir, .abs = work.abs }; 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.runMergeIn(gpa, io, arena, &.{ l.branch, "--no-sync" }, quiet, amb, named) != .ok) { streams.err.print("zit landing: {s} failed to merge; nothing was written\n", .{abbrev(l.id)}) catch {}; return .failure; } const msg = message_opt orelse std.fmt.allocPrint(arena, "land {s}", .{l.branch}) catch return stateOom(streams, "landing"); if (cmd_checkout.runCommitIn(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb, named) != .ok) { streams.err.writeAll("zit landing: the merge did not commit; nothing was landed\n") catch {}; return .failure; } // Ordered writes, unchanged from the checkout path (#127): the branch has // landed, so now the record says so. if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) { streams.err.writeAll("zit landing: landed, 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, mimetype) orelse return .failure; streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {}; closeLandedBranch(gpa, io, arena, streams, amb, quiet, l); // zit#144: a forge lands the bottom of a stack the same way a checkout // does, so the children move here too. retargetChildren(gpa, io, arena, streams, amb, all, l, author, mimetype); return .ok; } /// A cone member no worktree path can equal, for a landing whose sides touch /// no file in common. Reserved-looking on purpose: `validateWorktreePath` /// accepts it as a name, and nothing will ever be called it. const no_overlap_member = "__zit_landing_no_overlap__"; /// The ephemeral worktree an `accept -R` merges in. Its whole life is one /// command; `destroy` removes it whether the merge succeeded or not. const Work = struct { dir: std.Io.Dir, abs: []const u8, name: []const u8, fn destroy(self: *Work, io: std.Io, parent: std.Io.Dir) void { self.dir.close(io); // Best effort: a leftover directory is untidy, never incorrect -- it // holds no artifacts, only materialized copies of blobs the store // already has. parent.deleteTree(io, self.name) catch {}; } }; /// Create the ephemeral checkout: a container pointing at this store, then a /// cone-scoped materialization of `target_hash`. fn ephemeralCheckout( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, parent: std.Io.Dir, abs: []const u8, target_hash: []const u8, members: []const []const u8, l: Landing, ) ?Work { const name = std.fmt.allocPrint(arena, ".zit-landing-{s}", .{abbrev(l.id)}) catch { _ = stateOom(streams, "landing"); return null; }; parent.deleteTree(io, name) catch {}; // a prior run's wreckage, if any parent.createDir(io, name, .default_dir) catch { streams.err.writeAll("zit landing: cannot create a working area beside the repository\n") catch {}; return null; }; var dir = parent.openDir(io, name, .{}) catch { streams.err.writeAll("zit landing: cannot create a working area beside the repository\n") catch {}; parent.deleteTree(io, name) catch {}; return null; }; var ok = false; defer if (!ok) { dir.close(io); parent.deleteTree(io, name) catch {}; }; const work_abs = std.fs.path.join(arena, &.{ abs, name }) catch { _ = stateOom(streams, "landing"); return null; }; checkout.write(io, dir, .{ .version = "", .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "", }) catch { streams.err.writeAll("zit landing: cannot write the working area\n") catch {}; return null; }; // The store lives elsewhere: an absolute pointer, as `open --store` writes. checkout.updateSections(io, dir, &.{.{ .tag = .store_location, .payload = abs }}) catch { streams.err.writeAll("zit landing: cannot write the working area\n") catch {}; return null; }; var cone = cone_mod.Cone.init(gpa, members, &.{}) catch { streams.err.writeAll("zit landing: cannot scope the working area\n") catch {}; return null; }; defer cone.deinit(gpa); var repo2: Repository = undefined; repo2.open(gpa, io, dir, .{ .bypass_cache = false, .mode = .full }) catch { streams.err.writeAll("zit landing: cannot read the repository through the working area\n") catch {}; return null; }; defer repo2.deinit(gpa); const log = repo2.logStore(gpa) catch { streams.err.writeAll("zit landing: cannot read the repository\n") catch {}; return null; }; update.applyCheckinWith(gpa, io, dir, log, target_hash, .inherit, .{ .set = &cone }, &repo2, null) catch { streams.err.writeAll("zit landing: cannot materialize the target for the merge\n") catch {}; return null; }; ok = true; return .{ .dir = dir, .abs = work_abs, .name = name }; }