Artifact 51c7d5cb8d2c7e2625e77d0c347298d5cf6f520789495a8aa7bb3185104b8d65:
- File
src/cmd/landing.zig
— part of check-in
[b450374bce]
at
2026-08-24 20:10:51
on branch strata
— landing: cycles, unresolvable targets, and already-contained branches
Three holes found by walking the scenario space rather than the happy path.
A retarget could make a cycle. Every segment in one then refuses forever, each waiting on the next, and 'ls' panicked outright computing an indent for a chain with no bottom. Cycles are now refused, direct and indirect.
A retarget accepted a branch name that does not resolve, minting a permanent record aimed at nothing -- the check 'open' has always made before minting one.
A branch already contained in its target reported 'did not merge cleanly; resolve and commit'. There was nothing to resolve: 'merge' had said 'already up to date'. The record could then only be closed by rejecting it, recording the opposite of what happened. It now settles as accepted with no merge, which is what is true. Reachable from a stack by landing the top segment first -- it carries the ones below it in. (user: dmestas, size: 83654) [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; } // Nor onto a stack that leads back here. A cycle is not merely untidy: the // ordering guard then refuses every segment in it forever, because each one // is waiting on the next, and nothing in the record says why. if (cycleThrough(found.items, l, to)) { streams.err.print( "zit landing: landing '{s}' on '{s}' would make a cycle — '{s}' already lands on it, directly or through the stack\n", .{ l.branch, to, to }, ) catch {}; return .failure; } // A retarget mints a permanent artifact, exactly as `open` does, so it // checks the same thing `open` checks before minting one: that the branch // it names resolves. A typo here left a landing aimed at nothing, which // then failed at accept time with a message about the wrong thing. { 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) return .failure; defer root_dir.close(io); defer repo.deinit(gpa); _ = repo.resolve(to, .checkin) catch { streams.err.print("zit landing: target '{s}' not found\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); const shown_depth: usize = if (depth > max_stack_depth) max_stack_depth else depth; var pad: [max_indent]u8 = undefined; const indent = pad[0 .. shown_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; /// Two spaces per layer, so the widest indent `ls` can emit. const max_indent: usize = max_stack_depth * 2; /// `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; // The branch may already BE in the target -- merged by hand, or carried in // by a segment stacked above it that landed first. The landing's claim is // then already true, and there is nothing to merge: `merge` says "already // up to date" and the old code reported that as "did not merge cleanly; // resolve and commit", which named no real problem and left the record // permanently unsettleable. `reject` was the only way out, and it records // the opposite of what happened. { 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) return .failure; defer root_dir.close(io); 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"); if (probe.verdict == .up_to_date) return settleAlreadyIn(gpa, io, arena, streams, amb, found.items, l, author, mimetype); } // #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 OPEN landing this one sits on, or null when it is the bottom /// of the stack. Used for rendering the chain, where a settled base is no /// longer part of a stack anyone is going to land. 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; } /// Any landing for the branch this one targets, whatever its state. /// /// Separate from `baseOf` because the guard and the renderer want different /// questions answered. The renderer asks "what is this stacked on that is still /// live"; the guard asks "is this landing pointed at a branch that some landing /// has already decided about" — and a base that was REJECTED, or one that /// already landed, are both reasons to refuse even though neither is open. fn targetLanding(items: []const Landing, l: Landing) ?Landing { for (items) |c| { 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]; } /// Would landing `l` on branch `to` close a loop? True when following the stack /// upward from `to` arrives back at `l`'s own branch. /// /// Walking UP from the proposed target is the cheap direction: the chain above /// `to` is what would come to depend on `l`, and a cycle is exactly the case /// where `l` is already somewhere in it. Bounded by `max_stack_depth` so a /// cycle that already exists cannot hang the check meant to prevent one. fn cycleThrough(items: []const Landing, l: Landing, to: []const u8) bool { var hop: usize = 0; var branch = to; while (hop < max_stack_depth) : (hop += 1) { if (std.mem.eql(u8, branch, l.branch)) return true; const next = for (items) |c| { if (c.state != .open) continue; if (std.mem.eql(u8, c.branch, branch)) break c; } else return false; branch = next.target; } // Ran out of hops without bottoming out: whatever this is, it is not a // chain a landing should be added to. return true; } /// The ordering guard: a landing may not land onto a branch that some landing /// has already decided about. /// /// Three cases, one rule. The branch below is still OPEN — landing over it /// would silently widen this segment's diff to include work nobody reviewed /// under it. The branch below was REJECTED — landing onto it puts this work on /// a branch nobody is going to land, which is the quiet loss this guard exists /// to prevent, and the merge SUCCEEDS while doing it. The branch below already /// ACCEPTED — reachable only when the auto-retarget after that accept did not /// run, and landing onto an already-landed branch puts the work where trunk /// will not see it. /// /// All three end the same way: refuse, name the base, name the remedy. Nothing /// here is corruption — the DAG does not care — which is exactly why it has to /// be caught by a rule rather than by a merge failure. fn refuseIfStacked(streams: cli.Streams, items: []const Landing, l: Landing) ?cli.Exit { const base = targetLanding(items, l) orelse return null; switch (base.state) { .open => { 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 {}; }, .rejected => { streams.err.print( "zit landing: {s} lands on '{s}', whose landing {s} was rejected\n", .{ abbrev(l.id), l.target, abbrev(base.id) }, ) catch {}; streams.err.print( "zit landing: landing here would put this work on a branch nobody will land;\n" ++ "zit landing: retarget it with 'zit landing retarget {s} --to {s}'\n", .{ abbrev(l.id), base.target }, ) catch {}; }, .accepted => { streams.err.print( "zit landing: {s} lands on '{s}', which already landed ({s})\n", .{ abbrev(l.id), l.target, abbrev(base.id) }, ) catch {}; streams.err.print( "zit landing: retarget it with 'zit landing retarget {s} --to {s}'\n", .{ abbrev(l.id), base.target }, ) 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 {}; } } /// Settle a landing whose branch is ALREADY contained in its target. /// /// Nothing to merge, so nothing is merged: the ordered writes that follow a /// real accept still run — outcome tag, accepted reply, close the branch, /// retarget the stack — because every one of them is a statement about a fact /// that is already true. The alternative was a record that could only be closed /// by rejecting it, which would say the work was turned down when it landed. /// /// Reported with its own line rather than "accepted", because an operator who /// expected a merge should be told there was not one. fn settleAlreadyIn( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, items: []const Landing, l: Landing, author: []const u8, mimetype: ?[]const u8, ) cli.Exit { 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_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) { streams.err.writeAll("zit landing: the branch is already in the target, but the outcome tag failed\n") catch {}; return .failure; } const body = std.fmt.allocPrint( arena, "{s} accepted\n\n'{s}' was already contained in '{s}'; nothing to merge.\n", .{ state_marker, l.branch, l.target }, ) 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} (already contained; nothing to merge)\n", .{ abbrev(l.id), l.branch, l.target }, ) catch {}; closeLandedBranch(gpa, io, arena, streams, amb, quiet, l); retargetChildren(gpa, io, arena, streams, amb, items, l, author, mimetype); 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 -- 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 }; }