Skip to content
zit Artifact [2e9f60a562]
Login

Artifact [2e9f60a562]

Artifact 2e9f60a562e8dc0c6f38da2a31d7aac5b02468f3d825c6af0c4908b78c01a427:


//! `zit landing` — propose a branch for trunk, as a reviewable record.
//!
//! 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. 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 (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 the problem, 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 (`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 reply-body prefix recording an UPDATE — the target merged into the
/// proposed branch on the store, followed by the merge check-in's hash.
/// Informational: the fold reads no state from it, so a reader that
/// does not know it sees an ordinary comment, and a forge that does renders
/// the refresh as an event rather than prose.
const update_marker = "zit-landing-updated:";

/// 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. 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 this rule 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, update };
    var sub: ?Sub = null;
    var positional: ?[]const u8 = null;
    var branch: ?[]const u8 = null;
    var target: ?[]const u8 = null;
    var reply_to: ?[]const u8 = null;
    var expect_tip: ?[]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, "--reply-to")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--reply-to");
            reply_to = 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, "--expect-tip")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--expect-tip");
            expect_tip = 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 if (std.mem.eql(u8, tok, "update")) {
                sub = .update;
            } 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|update\n") catch {};
        return .usage;
    };

    if (expect_tip != null and chosen != .accept) {
        streams.err.writeAll("zit landing: --expect-tip is only valid with accept\n") catch {};
        return .usage;
    }
    if (reply_to != null and chosen != .comment) {
        streams.err.writeAll("zit landing: --reply-to is only valid with comment\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, reply_to, mimetype),
        .accept => acceptLanding(gpa, io, arena, streams, amb, positional, message, user, mimetype, expect_tip),
        .reject => transition(gpa, io, arena, streams, amb, positional, message, user, .rejected, null, mimetype),
        .retarget => retargetLanding(gpa, io, arena, streams, amb, positional, target, user, mimetype),
        .update => updateLanding(gpa, io, arena, streams, amb, positional, message, user, mimetype),
    };
}

/// `retarget` — say that a landing should land somewhere else.
///
/// 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, 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: 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;
        // 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. The 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,
    reply_to_opt: ?[]const u8,
    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");
    };

    // A comment may aim its I-card at any post already in this landing's
    // thread; absent --reply-to it replies to the record root, as before.
    // The target's resolved thread root MUST be this record, so the flag
    // cannot post into an unrelated thread under the landing's identity.
    var rt_buf: [64]u8 = undefined;
    const reply_target: []const u8 = if (reply_to_opt) |rt| blk: {
        const rr = cmd_records.resolveThreadRoot(gpa, io, arena, streams, amb, "landing", rt, &rt_buf) orelse return .failure;
        if (!std.mem.eql(u8, rr, l.id)) {
            streams.err.print("zit landing: '{s}' is not a post in {s}'s thread\n", .{ rt, abbrev(l.id) }) catch {};
            return .failure;
        }
        break :blk rt;
    } else l.id;

    const hash = replyThread(gpa, io, arena, streams, amb, author, l.id, reply_target, 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,
    expect_tip: ?[]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;
    }
    // Refuse out-of-order, before either accept path mutates anything.
    if (refuseIfStacked(streams, found.items, l)) |exit| return exit;

    var tip: []const u8 = "";

    // 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;
        };
        // The one read of the branch tip. Everything after this merges this
        // exact check-in, never the branch by name, so a push arriving
        // mid-accept cannot slip a tip into the merge that nothing probed.
        tip = arena.dupe(u8, repo.hashOf(branch_rid)) catch return stateOom(streams, "landing");
        // A caller that checked a tip (CI, a preflight) names it, and a branch
        // that has moved since is refused before anything is written: the
        // caller checks the new tip and asks again.
        if (expect_tip) |want| {
            const want_rid = repo.resolve(want, .checkin) catch {
                streams.err.print("zit landing: --expect-tip '{s}' names no check-in\n", .{want}) catch {};
                return .failure;
            };
            if (want_rid != branch_rid) {
                streams.err.print(
                    "zit landing: branch {s} is at {s}, not {s}; nothing was written\n",
                    .{ l.branch, tip, want },
                ) catch {};
                return .stale_tip;
            }
        }
        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);
    }

    // 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, tip, 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, &.{tip}, 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, 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);
    // 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 (`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.
///
/// 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, 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`: 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, 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 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, 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. 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, 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,
    reply_to: []const u8,
    body: []const u8,
    mimetype: ?[]const u8,
) ?[]const u8 {
    return mintForum(gpa, io, arena, streams, amb, author, null, root, reply_to, 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,
    reply_to: ?[]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 and reply_to == null)); // §5.4: H (opener) XOR I (reply)

    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 (reply_to) |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;
}

/// The merge behind both `accept -R` and `update`: one side of a
/// landing merged into the other ON THE STORE, in an ephemeral working area
/// that materializes only the paths both sides changed, with nothing written
/// until the merge is known to be clean. `into_name` is checked out and
/// `from_name` merged in, so the check-in minted lands on `into_name`'s branch
/// -- `accept` passes (target, branch), `update` passes (branch, target), and
/// the machinery does not know which it is. `from_label`, `verb` and `hint`
/// only shape the diagnostics.
const MergeOnStore = union(enum) {
    /// The merge check-in's hash, on `into_name`.
    written: []const u8,
    up_to_date,
    unrelated,
    /// Already reported, path by path.
    conflicts,
    /// Already reported.
    failure,
};

fn oomOnStore(streams: cli.Streams) MergeOnStore {
    _ = stateOom(streams, "landing");
    return .failure;
}

fn mergeOnStore(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    l: Landing,
    author: []const u8,
    msg: []const u8,
    into_name: []const u8,
    from_name: []const u8,
    from_label: []const u8,
    verb: []const u8,
    hint: []const u8,
) MergeOnStore {
    // 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 oomOnStore(streams)) 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 into_hash: []const u8 = "";
    // What the probe judged, merged by hash below: resolving `from_name` again
    // for the merge would take whatever the name points at THEN.
    var from_hash: []const u8 = "";
    {
        defer repo.deinit(gpa);
        const into_rid = repo.resolve(into_name, .checkin) catch {
            streams.err.print("zit landing: '{s}' not found\n", .{into_name}) catch {};
            return .failure;
        };
        const from_rid = repo.resolve(from_name, .checkin) catch {
            streams.err.print("zit landing: '{s}' not found\n", .{from_name}) catch {};
            return .failure;
        };
        const probe = cmd_checkout.probeMergeability(gpa, arena, &repo, into_rid, from_rid) catch
            return oomOnStore(streams);
        switch (probe.verdict) {
            .up_to_date => return .up_to_date,
            .unrelated => return .unrelated,
            .conflicts => |paths| plan_conflicts = paths,
            .clean => {},
        }
        cone_members = probe.both_modified;
        into_hash = arena.dupe(u8, repo.hashOf(into_rid)) catch
            return oomOnStore(streams);
        from_hash = arena.dupe(u8, repo.hashOf(from_rid)) catch
            return oomOnStore(streams);
    }

    // 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",
            .{ from_label, into_name },
        ) catch {};
        for (plan_conflicts) |path|
            streams.err.print("zit landing: conflict {s}\n", .{path}) catch {};
        streams.err.print("zit landing: {s}\n", .{hint}) 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; {s} this from a checkout\n",
            .{ members.len, cone_mod.max_members, verb },
        ) catch {};
        return .failure;
    }

    var work = ephemeralCheckout(gpa, io, arena, streams, root_dir, store_abs, into_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, &.{ from_hash, "--no-sync" }, quiet, amb, named) != .ok) {
        streams.err.print("zit landing: {s} failed to merge; nothing was written\n", .{from_label}) catch {};
        return .failure;
    }

    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 written\n") catch {};
        return .failure;
    }

    // The check-in just minted: the new tip of the side merged INTO, read back
    // through the store the way every verb reads it.
    var after: Repository = undefined;
    var after_dir: std.Io.Dir = undefined;
    if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &after, &after_dir) != .ok)
        return .failure;
    defer after_dir.close(io);
    defer after.deinit(gpa);
    const tip = after.resolve(into_name, .checkin) catch return .failure;
    const hash = arena.dupe(u8, after.hashOf(tip)) catch return oomOnStore(streams);
    return .{ .written = hash };
}

/// `update`: bring the record's target INTO its proposed branch on
/// the store, as an ordinary merge check-in on that branch -- GitHub's
/// "Update branch", for a reviewer with no clone. Always merges in an
/// ephemeral working area, checkout or not, so it never leaves a half-merged
/// tree behind and one implementation serves both. A conflict is refused with
/// its paths and nothing written: resolving it stays a clone's job. Unlike
/// `accept`, a stacked landing is NOT refused -- refreshing a rung from the
/// rung below it is exactly what a stack wants before anything lands.
fn updateLanding(
    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: update 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;
    }

    const msg = message_opt orelse std.fmt.allocPrint(arena, "update {s} from {s}", .{ l.branch, l.target }) catch
        return stateOom(streams, "landing");
    const from_label = std.fmt.allocPrint(arena, "'{s}'", .{l.target}) catch return stateOom(streams, "landing");
    const hash = switch (mergeOnStore(gpa, io, arena, streams, amb, l, author, msg, l.branch, l.target, from_label, "update", "merge the target into the branch from a checkout, resolve, and push")) {
        .written => |h| h,
        .up_to_date => {
            // Not a failure: the branch already carries everything the target
            // has, which is the state update exists to reach.
            streams.out.print("'{s}' already contains '{s}'; nothing to do\n", .{ l.branch, l.target }) catch {};
            return .ok;
        },
        .unrelated => {
            streams.err.print(
                "zit landing: '{s}' and '{s}' share no common ancestor\n",
                .{ l.branch, l.target },
            ) catch {};
            return .failure;
        },
        .conflicts => return .conflicts,
        .failure => return .failure,
    };

    // The record says so, the way a retarget does: a reply a clone folds into
    // the same thread.
    const body = std.fmt.allocPrint(arena, "{s} {s}\n\n{s}\n", .{ update_marker, hash, msg }) catch
        return stateOom(streams, "landing");
    const reply = replyThread(gpa, io, arena, streams, amb, author, l.id, l.id, body, mimetype) orelse return .failure;
    streams.out.print("updated {s} {s} <- {s} {s}\n{s}\n", .{ abbrev(l.id), l.branch, l.target, hash[0..12], reply }) catch {};
    return .ok;
}

/// `accept` against a NAMED repository, with no checkout of the operator's to
/// stand in. 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,
    /// The branch tip `acceptLanding` read once: merged by hash, never by name.
    tip: []const u8,
    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.
    all: []const Landing,
) cli.Exit {
    const msg = message_opt orelse std.fmt.allocPrint(arena, "land {s}", .{l.branch}) catch
        return stateOom(streams, "landing");
    switch (mergeOnStore(gpa, io, arena, streams, amb, l, author, msg, l.target, tip, abbrev(l.id), "accept", "merge the target into the branch from a checkout, resolve, and push")) {
        .written => {},
        .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 => return .conflicts,
        .failure => return .failure,
    }

    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 };

    // Ordered writes, unchanged from the checkout path: 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, 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);
    // 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 };
}

test "stackDepth caps at max_stack_depth even on a cycle" {
    // A cycle is refused at retarget now, but a store written by an older
    // binary — or by hand — can still contain one, and the renderer must not
    // depend on that. This pins the bound the indent computation trusts.
    const t = std.testing;
    const a = Landing{
        .id = "aaaa",
        .branch = "segA",
        .target = "segB",
        .title = "",
        .prose = "",
        .author = "u",
        .opened = "",
        .state = .open,
    };
    const b = Landing{
        .id = "bbbb",
        .branch = "segB",
        .target = "segA",
        .title = "",
        .prose = "",
        .author = "u",
        .opened = "",
        .state = .open,
    };
    const items = [_]Landing{ a, b };
    try t.expectEqual(max_stack_depth, stackDepth(&items, a));
    try t.expectEqual(max_stack_depth, stackDepth(&items, b));

    // The bottom of an ordinary chain is depth 0, and the indent derived from
    // any depth fits the buffer the renderer sizes for it.
    const solo = Landing{
        .id = "cccc",
        .branch = "segC",
        .target = "trunk",
        .title = "",
        .prose = "",
        .author = "u",
        .opened = "",
        .state = .open,
    };
    try t.expectEqual(@as(usize, 0), stackDepth(&.{solo}, solo));
    try t.expect(max_stack_depth * 2 <= max_indent);
}