Skip to content
zit Artifact [0db49ba9d6]
Login

Artifact [0db49ba9d6]

Artifact 0db49ba9d6541d635e9cb71304073d66e39b2fddb8f460bb44b5b0c2acde2a5e:


//! `zit landing` — propose a branch for trunk, as a reviewable record (#127).
//!
//! A landing is a CEREMONY, not a new artifact kind. Everything it records is
//! an ordinary fossil artifact: the record is a forum thread, every comment and
//! state transition is a reply in that thread, and the terminal outcome is a
//! raw tag on the check-in that actually landed. A stock fossil clone of a
//! repository carrying landings sees forum threads and tagged check-ins — fully
//! readable, merely not actionable. No card type and no artifact kind is minted
//! here that `forum` and `tag` do not already mint.
//!
//! That constraint is the feature's whole compatibility story, so it is worth
//! stating as a rule rather than a habit: IF AN IMPLEMENTATION HERE REACHES FOR
//! A NEW CARD, THE DESIGN IS WRONG.
//!
//! The encoding, therefore, lives in the body of the thread root:
//!
//!     zit-landing: 1
//!     branch: <name>
//!     target: <name>
//!
//!     <the author's message>
//!
//! and a transition is a reply whose body opens with `zit-landing-state: X`.
//! Both are plain text a human reads without tooling, which is the point: the
//! fallback for a reader that does not know what a landing is should be a
//! slightly odd forum post, never an unparseable one.
//!
//! THE INTERFACE HIDES THAT ENCODING (Ousterhout, and the reason this verb
//! family exists at all instead of a documentation page). A caller opens,
//! reviews, accepts and rejects a landing knowing only branch names and landing
//! ids. If a thread hash leaks into the interface, or `accept` starts asking
//! callers to name a tag, this has become a pass-through wrapper and the
//! complexity it was built to absorb has been pushed back up to every caller --
//! the machine ones and the human at a terminal alike.
//!
//! Reads are FULL-STORE SCANS, deliberately, on the precedent `ticket set`
//! already set (`records.zig`): the store carries no forum index, and a wrong
//! answer from a stale one is worse than a slow correct one. Indexing belongs to
//! whatever serves the store; zit owns being right, and owns no opinion about
//! what is built on top of it.
//!
//! That trade stands. The sentence that used to close this paragraph -- that
//! stores of the size this serves "read in milliseconds" -- did not, and is
//! removed rather than softened (#134). It was carried over from `ticketKnown`,
//! where it is true because that is a WRITE path taken once per ticket change;
//! this is a READ path taken per view, and the scale did not carry either. On a
//! 117MB mirror the scan cost 62 seconds.
//!
//! The waste in it is gone (#134: the fold no longer parses every content blob
//! to discover it is not a forum post), but the SHAPE has not changed: this is
//! still linear in store bytes, and a store ten times larger costs ten times
//! more. A caller that already knows which records exist, from an index of its
//! own, should not be paying this at all -- the open half of #134, and not
//! something the constant-factor fix closed.

const std = @import("std");
const cli = @import("zit").cli;
const artifact = @import("zit").artifact;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const Repository = @import("zit").Repository;
const merge3 = @import("../merge3.zig");
const cone_mod = @import("zit").cone;
const checkout = @import("zit").checkout;
const update = @import("zit").update;

const main = @import("../main.zig");
const cmd_checkout = @import("checkout.zig");
const cmd_history = @import("history.zig");
const stateOom = main.stateOom;
const landing_help = main.landing_help;

/// The body-header line that makes a forum thread a landing. Versioned from
/// the first commit: a later dialect must be able to say so without guessing,
/// and a reader that meets a version it does not know must refuse rather than
/// misread a field it only thinks it recognises.
const marker = "zit-landing: 1";

/// The reply-body prefix carrying a state transition.
const state_marker = "zit-landing-state:";

/// The raw tag `accept` writes on the check-in that landed, and `reject`
/// writes on the branch tip it declined. Raw because a landing outcome is a
/// PROPERTY many check-ins may carry, not a name that resolves — which is
/// exactly the distinction `tag --raw` was built for.
const outcome_tag = "landing";

/// One landing body beyond this is not a proposal, it is a data dump.
const max_body_bytes: usize = 1024 * 1024;

/// TigerStyle: every scan is finite by inspection. A store holding more
/// landings than this has a different problem than rendering them.
const max_landings: usize = 4096;

/// A landing's derived state. `open` is the absence of a terminal transition,
/// never a recorded value — so a thread whose replies are all comments is open
/// by construction and cannot drift.
const State = enum {
    open,
    accepted,
    rejected,

    fn label(s: State) []const u8 {
        return switch (s) {
            .open => "open",
            .accepted => "accepted",
            .rejected => "rejected",
        };
    }

    fn parse(s: []const u8) ?State {
        if (std.mem.eql(u8, s, "open")) return .open;
        if (std.mem.eql(u8, s, "accepted")) return .accepted;
        if (std.mem.eql(u8, s, "rejected")) return .rejected;
        return null;
    }
};

/// One landing, folded from its thread. Every slice borrows the arena the
/// fold was given; nothing here outlives it.
const Landing = struct {
    id: []const u8,
    branch: []const u8,
    target: []const u8,
    title: []const u8,
    author: []const u8,
    opened: []const u8,
    state: State,
    /// The transition that settled `state`, for `show`. Empty while open.
    settled_by: []const u8 = "",
};

/// The header a landing's thread root carries, parsed back out of the body.
/// Null when the body is not a landing at all, which is the common case: most
/// forum posts in a real store are conversation.
fn parseHeader(body: []const u8) ?struct { branch: []const u8, target: []const u8 } {
    if (!std.mem.startsWith(u8, body, marker)) return null;
    var branch: ?[]const u8 = null;
    var target: ?[]const u8 = null;
    var it = std.mem.splitScalar(u8, body, '\n');
    _ = it.next(); // the marker line, already matched
    while (it.next()) |line| {
        if (line.len == 0) break; // the blank line ends the header
        if (std.mem.startsWith(u8, line, "branch: ")) {
            branch = std.mem.trim(u8, line["branch: ".len..], " \r");
        } else if (std.mem.startsWith(u8, line, "target: ")) {
            target = std.mem.trim(u8, line["target: ".len..], " \r");
        } else {
            // An unknown header line under a version we DO claim to know is a
            // dialect mismatch, not a field to skip. Refuse the whole record.
            return null;
        }
    }
    const b = branch orelse return null;
    const t = target orelse return null;
    if (b.len == 0 or t.len == 0) return null;
    return .{ .branch = b, .target = t };
}

/// The state a reply carries, or null when it is an ordinary comment.
fn parseTransition(body: []const u8) ?State {
    if (!std.mem.startsWith(u8, body, state_marker)) return null;
    const rest = body[state_marker.len..];
    const end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
    return State.parse(std.mem.trim(u8, rest[0..end], " \t\r"));
}

pub fn runLanding(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const Sub = enum { open, ls, show, comment, accept, reject };
    var sub: ?Sub = null;
    var positional: ?[]const u8 = null;
    var branch: ?[]const u8 = null;
    var target: ?[]const u8 = null;
    var message: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var show_all = false;

    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(landing_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--branch")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--branch");
            branch = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--to")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--to");
            target = verb_args[i];
        } else if (std.mem.eql(u8, tok, "-m") or std.mem.eql(u8, tok, "--message")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, tok);
            message = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--all")) {
            show_all = true;
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "landing", .unknown_option, tok);
        } else if (sub == null) {
            if (std.mem.eql(u8, tok, "open")) {
                sub = .open;
            } else if (std.mem.eql(u8, tok, "ls") or std.mem.eql(u8, tok, "list")) {
                sub = .ls;
            } else if (std.mem.eql(u8, tok, "show")) {
                sub = .show;
            } else if (std.mem.eql(u8, tok, "comment")) {
                sub = .comment;
            } else if (std.mem.eql(u8, tok, "accept")) {
                sub = .accept;
            } else if (std.mem.eql(u8, tok, "reject")) {
                sub = .reject;
            } else {
                return cli.usageError(streams, "landing", .unknown_option, tok);
            }
        } else if (positional == null) {
            positional = tok;
        } else {
            return cli.usageError(streams, "landing", .extra_argument, tok);
        }
    }

    const chosen = sub orelse {
        streams.err.writeAll("zit landing: usage: zit landing open|ls|show|comment|accept|reject\n") catch {};
        return .usage;
    };

    return switch (chosen) {
        .open => openLanding(gpa, io, arena, streams, amb, branch, target, message, user),
        .ls => listLandings(gpa, io, arena, streams, amb, show_all),
        .show => showLanding(gpa, io, arena, streams, amb, positional),
        .comment => transition(gpa, io, arena, streams, amb, positional, message, user, null),
        .accept => acceptLanding(gpa, io, arena, streams, amb, positional, message, user),
        .reject => transition(gpa, io, arena, streams, amb, positional, message, user, .rejected),
    };
}

/// `open` — mint the thread root that IS the landing.
fn openLanding(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    branch_opt: ?[]const u8,
    target_opt: ?[]const u8,
    message_opt: ?[]const u8,
    user: ?[]const u8,
) cli.Exit {
    const branch = branch_opt orelse {
        streams.err.writeAll("zit landing: open needs --branch NAME\n") catch {};
        return .usage;
    };
    const message = message_opt orelse {
        streams.err.writeAll("zit landing: open needs -m MESSAGE\n") catch {};
        return .usage;
    };
    if (message.len == 0) {
        streams.err.writeAll("zit landing: the message must not be empty\n") catch {};
        return .failure;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);

    // `mainBranch()` borrows the repository's own memory, which the block below
    // frees. Duped into the arena BEFORE that, because the header this ends up
    // in is a permanent artifact: a dangling read here mints a corrupt record.
    const target = target_opt orelse (arena.dupe(u8, repo.mainBranch()) catch {
        repo.deinit(gpa);
        return stateOom(streams, "landing");
    });

    // A landing against a branch that does not resolve is a typo, and a typo
    // that mints a permanent artifact is a footprint nobody can retract. Both
    // ends are checked BEFORE anything is written.
    {
        defer repo.deinit(gpa);
        _ = repo.resolve(branch, .checkin) catch {
            streams.err.print("zit landing: branch '{s}' not found\n", .{branch}) catch {};
            return .failure;
        };
        _ = repo.resolve(target, .checkin) catch {
            streams.err.print("zit landing: target '{s}' not found\n", .{target}) catch {};
            return .failure;
        };
        if (std.mem.eql(u8, branch, target)) {
            streams.err.writeAll("zit landing: a branch cannot land on itself\n") catch {};
            return .failure;
        }
    }

    // One open landing per branch. Refused by name, so the caller learns which
    // record already claims it rather than discovering two later.
    var found: std.ArrayList(Landing) = .empty;
    defer found.deinit(arena);
    if (fold(gpa, io, arena, streams, amb, &found, 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, message },
    ) catch return stateOom(streams, "landing");

    const hash = postThread(gpa, io, arena, streams, amb, author, message, body) orelse return .failure;
    streams.out.print("{s}\n", .{hash}) catch {};
    return .ok;
}

/// `ls` — the queue. Open landings by default; `--all` includes settled ones.
fn listLandings(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    show_all: bool,
) cli.Exit {
    var found: std.ArrayList(Landing) = .empty;
    defer found.deinit(arena);
    if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;

    for (found.items) |l| {
        if (!show_all and l.state != .open) continue;
        streams.out.print("{s} {s} {s} -> {s} {s}\n", .{
            abbrev(l.id),
            l.state.label(),
            l.branch,
            l.target,
            l.title,
        }) catch {};
    }
    return .ok;
}

/// `show` — the review surface for one landing.
///
/// The check-in list is computed AT VIEW TIME against the target, never cached
/// against a frozen range: the workflow this serves merges the target into the
/// branch while work continues, so a snapshot would be wrong by the second
/// commit. #127's acceptance criteria pin exactly that.
fn showLanding(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    id_opt: ?[]const u8,
) cli.Exit {
    const id = id_opt orelse {
        streams.err.writeAll("zit landing: show needs a landing id\n") catch {};
        return .usage;
    };
    var found: std.ArrayList(Landing) = .empty;
    defer found.deinit(arena);
    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 {};
    reportMergeability(gpa, io, arena, streams, amb, l);
    streams.out.print("\n{s}\n", .{l.title}) 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 = 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,
) cli.Exit {
    const id = id_opt orelse {
        streams.err.writeAll("zit landing: needs a landing id\n") catch {};
        return .usage;
    };
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    var found: std.ArrayList(Landing) = .empty;
    defer found.deinit(arena);
    if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;
    const l = pick(found.items, id) orelse {
        streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {};
        return .failure;
    };

    // A settled landing takes comments but not a second verdict: re-deciding
    // would make the fold's latest-wins rule silently rewrite history that a
    // reader already acted on.
    if (to != null and l.state != .open) {
        streams.err.print(
            "zit landing: {s} is already {s}\n",
            .{ abbrev(l.id), l.state.label() },
        ) catch {};
        return .failure;
    }

    const message = message_opt orelse "";
    const body = if (to) |s|
        std.fmt.allocPrint(arena, "{s} {s}\n\n{s}\n", .{ state_marker, s.label(), message }) catch
            return stateOom(streams, "landing")
    else b: {
        if (message.len == 0) {
            streams.err.writeAll("zit landing: comment needs -m MESSAGE\n") catch {};
            return .failure;
        }
        break :b std.fmt.allocPrint(arena, "{s}\n", .{message}) catch return stateOom(streams, "landing");
    };

    const hash = replyThread(gpa, io, arena, streams, amb, author, l.id, body) 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,
) 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;
    }

    // #131: named a repository rather than standing in a checkout -- the forge
    // shape. Same ceremony, same ordered writes; the merge runs against a
    // checkout this command creates and destroys.
    if (amb.repo_override != null)
        return acceptBare(gpa, io, arena, streams, amb, l, author, message_opt);

    // The checkout must already be on the target. Moving it here would be this
    // verb quietly rewriting the operator's working tree to make its own job
    // easier -- a surprise with a dirty tree in it.
    {
        var repo: Repository = undefined;
        var root_dir: std.Io.Dir = undefined;
        const rc = main.openRepo(gpa, io, arena, streams, "landing", null, false, true, &repo, &root_dir);
        if (rc != .ok) return rc;
        defer root_dir.close(io);
        defer repo.deinit(gpa);
        const rid = repo.currentCheckoutRid() orelse {
            streams.err.writeAll("zit landing: accept works in a checkout\n") catch {};
            return .failure;
        };
        const on = repo.branchOf(rid);
        if (!std.mem.eql(u8, on, l.target)) {
            streams.err.print(
                "zit landing: this checkout is on '{s}'; {s} lands on '{s}' — update first\n",
                .{ on, abbrev(l.id), l.target },
            ) catch {};
            return .failure;
        }
    }

    // The sub-verbs' stdout is the ENCODING talking: a merge line, a check-in
    // line, a tag line. The module doc's rule is that callers never learn a
    // landing is a forum thread with a raw tag, so their stdout is discarded
    // and one landing-shaped line is printed instead. stderr is passed through
    // untouched — a real failure must still reach the operator in full.
    var sink_buf: [4096]u8 = undefined;
    var sink: std.Io.Writer.Discarding = .init(&sink_buf);
    const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err };

    if (cmd_checkout.runMerge(gpa, io, arena, &.{l.branch}, quiet) != .ok) {
        streams.err.print("zit landing: {s} did not merge cleanly; resolve and commit, then accept again\n", .{abbrev(l.id)}) catch {};
        return .failure;
    }

    const msg = if (message_opt) |m| m else std.fmt.allocPrint(
        arena,
        "land {s}",
        .{l.branch},
    ) catch return stateOom(streams, "landing");
    if (cmd_checkout.runCommit(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb) != .ok) {
        streams.err.writeAll("zit landing: the merge is in the working tree but the commit failed; commit it yourself, then accept again\n") catch {};
        return .failure;
    }

    // The durable, check-in-oriented fact: this check-in is where a landing
    // came in. Raw, because it is a property many check-ins carry, not a name.
    if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) {
        streams.err.writeAll("zit landing: merged and committed, but the outcome tag failed\n") catch {};
        return .failure;
    }

    const body = std.fmt.allocPrint(
        arena,
        "{s} accepted\n\n{s}\n",
        .{ state_marker, msg },
    ) catch return stateOom(streams, "landing");
    _ = replyThread(gpa, io, arena, streams, amb, author, l.id, body) orelse return .failure;
    streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {};
    return .ok;
}

/// The shortest unambiguous rendering of a landing id for human output. Twelve
/// hex characters is the codebase's existing abbreviation width.
fn abbrev(id: []const u8) []const u8 {
    return if (id.len > 12) id[0..12] else id;
}

/// The landing an id names, matching on any unambiguous prefix. Null when no
/// landing matches; a prefix matching two is also null, because answering
/// arbitrarily is worse than refusing.
fn pick(items: []const Landing, id: []const u8) ?Landing {
    var hit: ?Landing = null;
    for (items) |l| {
        if (!std.mem.startsWith(u8, l.id, id)) continue;
        if (hit != null) return null;
        hit = l;
    }
    return hit;
}

/// The author: `--user` wins, else the ambient OS user, exactly as `forum`
/// resolves it. Empty is refused either way, for the reason `records.zig`
/// gives: this writer will not mint more empty U tokens.
fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 {
    const chosen = user orelse (amb.user_env orelse return null);
    if (chosen.len == 0) return null;
    return chosen;
}

/// One forum artifact as the fold needs it. Borrowed from the fold's arena.
const Post = struct {
    hash: []const u8,
    root: ?[]const u8,
    title: []const u8,
    author: []const u8,
    stamp: []const u8,
    body: []const u8,
};

/// Scan the store, collect every forum artifact, and fold the landing threads
/// out of them.
///
/// A FULL-STORE SCAN, on the precedent `ticket set` set: no forum index
/// exists, and a stale index answering "no such landing" would silently mint a
/// second one. See the module doc -- including what this costs, which is
/// linear in store bytes and not something the #134 fix changed.
///
/// The fold is deterministic and latest-wins on (timestamp, hash) — the same
/// rule a ticket-field fold applies, chosen so two readers of the
/// same store never disagree about a landing's state.
fn fold(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    out: *std.ArrayList(Landing),
    /// The forum artifacts the scan read, for a caller that wants the thread
    /// as well as the state. Null discards them. Handed back rather than
    /// re-scanned: this is a full-store read, and `show` needing the same
    /// bytes twice would double the one genuinely expensive thing here.
    posts_out: ?*std.ArrayList(Post),
) cli.Exit {
    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "landing")) orelse {
        main.reportNoRoot(streams, io, "landing", amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
        return .failure;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
        streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
        return .failure;
    };
    defer log.deinit(gpa);

    var posts: std.ArrayList(Post) = .empty;
    defer if (posts_out == null) posts.deinit(arena);

    // One reusable plaintext buffer for the whole walk, as `frames` does:
    // a per-artifact allocation across a whole store is measurable, and a
    // frame's reconstruction is memoised across the entries that delta
    // against it.
    var plaintext: logstore.PlaintextCache = .{};
    defer plaintext.deinit(gpa);

    var rid: logstore.Rid = 1;
    const count: logstore.Rid = @intCast(log.entries.items.len);
    while (rid <= count) : (rid += 1) {
        const bytes = (log.contentCached(gpa, rid, &plaintext) catch |e| switch (e) {
            error.OutOfMemory => return stateOom(streams, "landing"),
            // Damage is `verify`'s report to make, not this reader's: a
            // corrupt entry cannot be a landing, so skipping it is correct
            // and failing the whole listing would not be.
            else => continue,
        }) orelse continue;
        defer gpa.free(bytes);

        // Reject-fast pre-screen (#108's, and for its reason): most of a real
        // store's bytes are CONTENT BLOBS, and a full parse pays a whole-bytes
        // Z checksum before rejecting each one. A structural artifact's first
        // bytes are a card line -- `<LETTER><SP>`, artifact-format Section 4.2
        // -- or a signature wrapper, `-----BEGIN `, Section 4.1.1.
        //
        // The screen only ever REJECTS. Every accept still takes the full
        // parse below, so nothing this fold would refuse can be admitted by
        // being shaped like a card.
        const card_shaped = bytes.len >= 2 and bytes[0] >= 'A' and bytes[0] <= 'Z' and bytes[1] == ' ';
        const wrapper_shaped = std.mem.startsWith(u8, bytes, "-----BEGIN ");
        if (!card_shaped and !wrapper_shaped) continue;

        var parsed = artifact.parse(gpa, bytes) catch continue;
        defer parsed.deinit();
        if (parsed.kind != .forum) continue;

        var name_buf: [64]u8 = undefined;
        const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
        posts.append(arena, .{
            .hash = arena.dupe(u8, name) catch return stateOom(streams, "landing"),
            .root = if (parsed.g) |g| arena.dupe(u8, g.hash) catch return stateOom(streams, "landing") else null,
            .title = if (parsed.h) |h| arena.dupe(u8, h.title) catch return stateOom(streams, "landing") else "",
            .author = if (parsed.u) |u| arena.dupe(u8, u.login) catch return stateOom(streams, "landing") else "",
            .stamp = if (parsed.d) |d| arena.dupe(u8, d.timestamp) catch return stateOom(streams, "landing") else "",
            .body = arena.dupe(u8, parsed.w orelse "") catch return stateOom(streams, "landing"),
        }) catch return stateOom(streams, "landing");
        if (posts.items.len > max_landings * 8) break; // TigerStyle: bounded
    }

    // Pass 1 — the roots that are landings.
    for (posts.items) |p| {
        if (p.root != null) continue; // a reply is never a record
        const hdr = parseHeader(p.body) orelse continue;
        out.append(arena, .{
            .id = p.hash,
            .branch = hdr.branch,
            .target = hdr.target,
            .title = p.title,
            .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;
        }
    }
    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,
) ?[]const u8 {
    return mintForum(gpa, io, arena, streams, amb, author, title, null, body);
}

/// Mint a reply into `root`'s thread. G names the thread root and I the post
/// replied to; here both are the root, because a landing's conversation is
/// flat by design — a threaded argument about a state transition is a forum
/// feature nobody asked this verb for.
fn replyThread(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    author: []const u8,
    root: []const u8,
    body: []const u8,
) ?[]const u8 {
    return mintForum(gpa, io, arena, streams, amb, author, null, root, body);
}

/// The one place a landing writes an artifact. Exactly `forum`'s manifest
/// shape — same kind, same cards — because a landing artifact IS a forum
/// artifact and any divergence here would be the compatibility break the
/// module doc forbids.
fn mintForum(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    author: []const u8,
    title: ?[]const u8,
    root: ?[]const u8,
    body: []const u8,
) ?[]const u8 {
    std.debug.assert(body.len > 0);
    std.debug.assert(body.len <= max_body_bytes);
    std.debug.assert(title == null or root == null); // §5.4: H XOR I

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .forum,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = if (root) |r| .{ .hash = r } else null,
        .h = if (title) |t| .{ .title = t } else null,
        .i = if (root) |r| .{ .hash = r } else null,
        .k = null,
        .l = null,
        .n = .{ .mimetype = "text/plain" },
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();

    const bytes = artifact.create(&m, gpa) catch {
        _ = stateOom(streams, "landing");
        return null;
    };
    defer gpa.free(bytes);

    // Round-trip in every build mode, for the reason `records.zig` gives: these
    // bytes are read back by another process, and a malformed artifact minted
    // here would be 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;
}

// --- mergeability (#131) ---------------------------------------------------
//
// "Would this land cleanly?", answered from the store alone — no working tree,
// no cone, nothing written. A forge has to be able to ask this before anyone
// clicks accept, because the one thing a server must never do is commit a
// conflict.
//
// It reuses `merge3.classifyFile`, the SAME pure classifier the working-tree
// merge runs, rather than deciding conflicts a second way. Two conflict
// detectors that can disagree would be worse than none: the forge would show
// "clean" on a branch that then refuses, or block a branch that would have
// merged. One engine, two callers.
//
// NOT the hash-level resolution the sparse out-of-cone path uses. That refuses
// any file touched on both sides, which would report conflicts on exactly the
// long-lived branches that merged their target back in — the #127 workflow.
// Content-level or the answer is useless.

/// What a landing's branch would do against its target.
const Mergeability = union(enum) {
    /// Already an ancestor of the target: nothing to land.
    up_to_date,
    /// No common ancestor. Not a conflict — a different repository's history.
    unrelated,
    clean,
    /// Paths that cannot be resolved without a human. Arena-owned, sorted.
    conflicts: []const []const u8,
};

/// A path's blob name on one side, or null when the path is absent there.
const SideMap = std.StringHashMapUnmanaged([]const u8);

/// Index a check-in's file set by path. Names are arena-duped: the `FileList`
/// they borrow from dies with this call.
fn sideMapOf(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    rid: u64,
    out: *SideMap,
) !void {
    var list = (try repo.files(gpa, rid)) orelse return;
    defer list.deinit(gpa);
    var it = list.iterator();
    while (it.next()) |f| {
        const hash = f.hash orelse continue; // a delete marker is absence
        try out.put(arena, try arena.dupe(u8, f.filename), try arena.dupe(u8, hash));
    }
}

/// Classify one both-modified path by CONTENT, which is the only case that
/// needs blob reads. Every other shape is decided by F-card hash alone.
fn conflictsOnContent(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    base_hash: ?[]const u8,
    ours_hash: ?[]const u8,
    theirs_hash: ?[]const u8,
) !bool {
    var base_bytes: ?[]u8 = null;
    var ours_bytes: ?[]u8 = null;
    var theirs_bytes: ?[]u8 = null;
    defer if (base_bytes) |b| gpa.free(b);
    defer if (ours_bytes) |b| gpa.free(b);
    defer if (theirs_bytes) |b| gpa.free(b);

    if (base_hash) |h| base_bytes = try repo.blob(gpa, h);
    if (ours_hash) |h| ours_bytes = try repo.blob(gpa, h);
    if (theirs_hash) |h| theirs_bytes = try repo.blob(gpa, h);

    // A blob the store does not hold (a sparse store elides out-of-cone
    // content) cannot be merged here. Reported as a conflict rather than
    // guessed: "I could not read it" and "it does not merge" are different
    // facts, but both mean a human has to do this one, and claiming clean
    // would be the dangerous direction.
    if (base_hash != null and base_bytes == null) return true;
    if (ours_hash != null and ours_bytes == null) return true;
    if (theirs_hash != null and theirs_bytes == null) return true;

    // Arena-scoped, matching the working-tree merge's own call
    // (`checkout.zig:1468`): `classifyFile`'s Result bytes belong to the
    // allocator it was given, and this one is the per-invocation arena.
    const outcome = try merge3.classifyFile(
        arena,
        if (base_bytes) |b| b else null,
        if (ours_bytes) |b| b else null,
        if (theirs_bytes) |b| b else null,
        .conflict,
    );
    return switch (outcome) {
        .text_conflict, .whole_file_conflict => true,
        .unchanged, .take_other, .delete, .clean_merge => false,
    };
}

/// A probe's full answer: the verdict, plus the paths BOTH sides changed.
///
/// The second half is not a diagnostic -- it is the cone a bare accept needs.
/// sparse-cone Section 4.4 resolves out-of-cone paths at hash level, which
/// cannot combine two edits to one file, so exactly these paths must be
/// in-cone for the merge to reach the content engine. Every other path is
/// correctly resolved at the record without materializing anything.
const Probe = struct {
    verdict: Mergeability,
    both_modified: []const []const u8,
};

/// Would `branch` land on `target` without a human? Store-only; writes nothing.
fn probeMergeability(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    target_rid: u64,
    branch_rid: u64,
) !Probe {
    if (try repo.isAncestorOf(gpa, branch_rid, target_rid))
        return .{ .verdict = .up_to_date, .both_modified = &.{} };
    const base_rid = (try repo.mergeBaseOf(gpa, target_rid, branch_rid)) orelse
        return .{ .verdict = .unrelated, .both_modified = &.{} };

    var base: SideMap = .empty;
    var ours: SideMap = .empty;
    var theirs: SideMap = .empty;
    try sideMapOf(gpa, arena, repo, base_rid, &base);
    try sideMapOf(gpa, arena, repo, target_rid, &ours);
    try sideMapOf(gpa, arena, repo, branch_rid, &theirs);

    var conflicts: std.ArrayList([]const u8) = .empty;
    var both: std.ArrayList([]const u8) = .empty;

    // Every path either side knows. Walking ours then theirs covers the union
    // without a third set: a path in `base` alone is deleted on both sides,
    // which is agreement, not a conflict.
    var seen: std.StringHashMapUnmanaged(void) = .empty;
    for ([_]*SideMap{ &ours, &theirs }) |m| {
        var it = m.iterator();
        while (it.next()) |e| {
            const path = e.key_ptr.*;
            if (seen.contains(path)) continue;
            try seen.put(arena, path, {});

            const b = base.get(path);
            const o = ours.get(path);
            const t = theirs.get(path);

            // Hash-level short circuits, in the order that makes the common
            // cases free. Only a genuinely both-modified path reads blobs.
            if (sameHash(o, t)) continue; // both sides agree already
            if (sameHash(b, o)) continue; // only theirs moved -> take theirs
            if (sameHash(b, t)) continue; // only ours moved  -> keep ours

            // Reaching here IS the both-modified case: neither side matches
            // the base and they differ from each other.
            try both.append(arena, path);
            if (try conflictsOnContent(gpa, arena, repo, b, o, t)) {
                try conflicts.append(arena, path);
            }
        }
    }
    std.mem.sort([]const u8, both.items, {}, struct {
        fn lt(_: void, a: []const u8, b2: []const u8) bool {
            return std.mem.order(u8, a, b2) == .lt;
        }
    }.lt);
    if (conflicts.items.len == 0)
        return .{ .verdict = .clean, .both_modified = both.items };
    // Sorted so two readers of the same store see the same list in the same
    // order — a forge rendering them must not shuffle between refreshes.
    std.mem.sort([]const u8, conflicts.items, {}, struct {
        fn lt(_: void, a: []const u8, b: []const u8) bool {
            return std.mem.order(u8, a, b) == .lt;
        }
    }.lt);
    return .{ .verdict = .{ .conflicts = conflicts.items }, .both_modified = both.items };
}

fn sameHash(a: ?[]const u8, b: ?[]const u8) bool {
    if (a == null and b == null) return true;
    if (a == null or b == null) return false;
    return std.mem.eql(u8, a.?, b.?);
}

/// `accept` against a NAMED repository, with no checkout of the operator's to
/// stand in (#131). The forge path.
///
/// The shape, and why it is this shape:
///
///   * The merge needs a working tree, so this command MAKES one -- ephemeral,
///     created and destroyed inside this call, and materializing ONLY the paths
///     both sides changed. sparse-cone Section 4.4 resolves out-of-cone paths at
///     the record "without materializing the files", so every other path costs
///     nothing; the cone exists solely because hash-level resolution cannot
///     combine two edits to one file.
///
///   * NOTHING is written until the merge is known to be clean. A conflicting
///     landing exits `.conflicts` with the store byte-unchanged. A forge must
///     never hold a half-finished merge, and must never commit conflict
///     markers -- the operator resolves it from their own checkout, as they
///     would on any forge.
///
///   * The check-in is minted by `runCommitIn`, the SAME minter the verb uses.
///     The R card, the delta-dialect continuation and sub-manifest emission are
///     decided there; a second writer here would produce artifacts other
///     conforming implementations hash differently.
fn acceptBare(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    l: Landing,
    author: []const u8,
    message_opt: ?[]const u8,
) cli.Exit {
    // The store's ABSOLUTE path: the ephemeral checkout points at it from
    // another directory, so a relative pointer would go stale the moment
    // anything resolved it from elsewhere.
    const store_abs = blk: {
        const r = (cli.resolveRoot(arena, io, amb.repo_override) catch
            return stateOom(streams, "landing")) orelse {
            main.reportNoRoot(streams, io, "landing", amb.repo_override);
            return .failure;
        };
        var d = r.dir;
        d.close(io);
        break :blk r.abs;
    };

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok)
        return .failure;
    defer root_dir.close(io);

    var plan_conflicts: []const []const u8 = &.{};
    var cone_members: []const []const u8 = &.{};
    var target_hash: []const u8 = "";
    {
        defer repo.deinit(gpa);
        const target_rid = repo.resolve(l.target, .checkin) catch {
            streams.err.print("zit landing: target '{s}' not found\n", .{l.target}) catch {};
            return .failure;
        };
        const branch_rid = repo.resolve(l.branch, .checkin) catch {
            streams.err.print("zit landing: branch '{s}' not found\n", .{l.branch}) catch {};
            return .failure;
        };
        const probe = probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch
            return stateOom(streams, "landing");
        switch (probe.verdict) {
            .up_to_date => {
                streams.err.print(
                    "zit landing: '{s}' is already contained in '{s}'; nothing to land\n",
                    .{ l.branch, l.target },
                ) catch {};
                return .failure;
            },
            .unrelated => {
                streams.err.print(
                    "zit landing: '{s}' and '{s}' share no common ancestor\n",
                    .{ l.branch, l.target },
                ) catch {};
                return .failure;
            },
            .conflicts => |paths| plan_conflicts = paths,
            .clean => {},
        }
        cone_members = probe.both_modified;
        target_hash = arena.dupe(u8, repo.hashOf(target_rid)) catch
            return stateOom(streams, "landing");
    }

    // Refuse BEFORE anything is created, let alone written. The paths go to
    // stderr and the status is `.conflicts`, so a caller can render "blocked"
    // and name the files without parsing prose.
    if (plan_conflicts.len != 0) {
        streams.err.print(
            "zit landing: {s} does not merge cleanly into '{s}'; nothing was written\n",
            .{ abbrev(l.id), l.target },
        ) catch {};
        for (plan_conflicts) |path|
            streams.err.print("zit landing: conflict {s}\n", .{path}) catch {};
        streams.err.writeAll(
            "zit landing: merge the target into the branch from a checkout, resolve, and push\n",
        ) catch {};
        return .conflicts;
    }

    // cone.Cone refuses an empty member list, and a landing that touches no
    // file on both sides legitimately has one. A member no path can equal
    // covers nothing, which is exactly the intent: materialize nothing.
    const members: []const []const u8 = if (cone_members.len == 0)
        &[_][]const u8{no_overlap_member}
    else
        cone_members;
    if (members.len > cone_mod.max_members) {
        streams.err.print(
            "zit landing: {d} files changed on both sides, past the {d}-member cone bound; accept this from a checkout\n",
            .{ members.len, cone_mod.max_members },
        ) catch {};
        return .failure;
    }

    var work = ephemeralCheckout(gpa, io, arena, streams, root_dir, store_abs, target_hash, members, l) orelse
        return .failure;
    defer work.destroy(io, root_dir);

    const named: cmd_checkout.NamedCheckout = .{ .dir = work.dir, .abs = work.abs };
    var sink_buf: [4096]u8 = undefined;
    var sink: std.Io.Writer.Discarding = .init(&sink_buf);
    const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err };

    if (cmd_checkout.runMergeIn(gpa, io, arena, &.{ l.branch, "--no-sync" }, quiet, named) != .ok) {
        streams.err.print("zit landing: {s} failed to merge; nothing was written\n", .{abbrev(l.id)}) catch {};
        return .failure;
    }

    const msg = message_opt orelse std.fmt.allocPrint(arena, "land {s}", .{l.branch}) catch
        return stateOom(streams, "landing");
    if (cmd_checkout.runCommitIn(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb, named) != .ok) {
        streams.err.writeAll("zit landing: the merge did not commit; nothing was landed\n") catch {};
        return .failure;
    }

    // Ordered writes, unchanged from the checkout path (#127): the branch has
    // landed, so now the record says so.
    if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) {
        streams.err.writeAll("zit landing: landed, but the outcome tag failed\n") catch {};
        return .failure;
    }
    const body = std.fmt.allocPrint(arena, "{s} accepted\n\n{s}\n", .{ state_marker, msg }) catch
        return stateOom(streams, "landing");
    _ = replyThread(gpa, io, arena, streams, amb, author, l.id, body) orelse return .failure;
    streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {};
    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) 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 };
}