//! `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 find the forum artifacts through the FORUM INDEX (`nameindex`, in
//! `.zit-cache/forum`): the names of every forum artifact, keyed to the log
//! prefix they were found in. Every read extends the index over whatever was
//! appended since, before it answers, so the answer is exactly what a scan of
//! the whole store would give; a rewrite of the store invalidates the key and
//! the index is rebuilt. The worry that kept an index out -- a stale one
//! answering "no such landing" and a second landing being minted -- cannot
//! arise: the index is never read without being brought to the current end.
//!
//! What a read costs is the tail since the last read plus the forum posts
//! themselves, not the store. The full walk still happens once, when the
//! index is first built or after a rewrite.
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 nameindex = @import("zit").nameindex;
const repocache = @import("zit").repocache;
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 expect_target: ?[]const u8 = null;
var message: ?[]const u8 = null;
var user: ?[]const u8 = null;
var mimetype: ?[]const u8 = null;
var show_all = false;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(landing_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--branch")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--branch");
branch = verb_args[i];
} else if (std.mem.eql(u8, tok, "--to")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--to");
target = verb_args[i];
} else if (std.mem.eql(u8, tok, "--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, "--expect-target")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--expect-target");
expect_target = 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_target != null and chosen != .accept) {
streams.err.writeAll("zit landing: --expect-target is only valid with accept\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, expect_target),
.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,
expect_target: ?[]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 = "";
var target_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;
}
}
// The one read of the target, for the same reason: the merge lands on
// this exact check-in, so a landing accepted meanwhile cannot put a
// target under the merge that nothing judged. A caller that judged the
// pair names the target it saw, and a target that has moved is refused.
target_tip = arena.dupe(u8, repo.hashOf(target_rid)) catch return stateOom(streams, "landing");
if (expect_target) |want| {
const want_rid = repo.resolve(want, .checkin) catch {
streams.err.print("zit landing: --expect-target '{s}' names no check-in\n", .{want}) catch {};
return .failure;
};
if (want_rid != target_rid) {
streams.err.print(
"zit landing: target {s} is at {s}, not {s}; nothing was written\n",
.{ l.target, target_tip, want },
) catch {};
return .stale_target;
}
}
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, target_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 merge lands on the checkout's check-in. When the caller judged a
// particular target, that has to be it.
if (expect_target != null and !std.mem.eql(u8, repo.hashOf(rid), target_tip)) {
streams.err.print(
"zit landing: this checkout is behind '{s}', which is at {s}; update first\n",
.{ l.target, target_tip },
) 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;
}
/// The forum index: every forum artifact in the store, by name.
const forum_index: nameindex.Kind = .{
.magic = "ZITFORM\x00",
.member = repocache.forum_member,
.keep = isForum,
};
fn isForum(gpa: std.mem.Allocator, bytes: []const u8) bool {
// 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; everything it passes takes the full parse.
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) return false;
var parsed = artifact.parse(gpa, bytes) catch return false;
defer parsed.deinit();
return parsed.kind == .forum;
}
/// 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,
};
/// Collect every forum artifact, through the forum index (see the module
/// doc), and fold the landing threads out of them.
///
/// 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
/// read again, so `show` pays for the posts once.
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);
// Which entries are forum artifacts comes from the forum index, which
// walks only what was appended since it was last extended; each named
// post is then read through the store, shun mask and all. The index
// lives in the STORE's cache, shared by every worktree of it.
var store_dir: ?std.Io.Dir = cli.storeDirOf(io, root_dir) catch null;
defer if (store_dir) |*d| d.close(io);
const names = nameindex.refresh(gpa, arena, io, store_dir orelse root_dir, &log, forum_index) catch
return stateOom(streams, "landing");
for (names) |name| {
var digest_buf: [logstore.max_hash_len]u8 = undefined;
const digest = std.fmt.hexToBytes(&digest_buf, name) catch continue;
const rid = log.lookup(digest) orelse continue;
const bytes = (log.content(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return stateOom(streams, "landing"),
// Damage is `verify`'s report to make, not this reader's: a
// corrupt entry cannot be a landing, so skipping it is correct
// and failing the whole listing would not be.
else => continue,
}) orelse continue; // shunned since it was indexed
defer gpa.free(bytes);
var parsed = artifact.parse(gpa, bytes) catch continue;
defer parsed.deinit();
if (parsed.kind != .forum) continue;
posts.append(arena, .{
.hash = name,
.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 store_append = cli.openStoreAppend(io, root_dir) catch |e| {
if (e == error.StoreBusy) return null; // the lock notice already said it
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
defer store_append.close(io);
const file = store_append.file;
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,
/// How `into_name` is named in diagnostics: the branch, when `into_name`
/// is a hash the caller pinned.
into_label: []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_label },
) 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;
}
if (cone_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",
.{ cone_members.len, cone_mod.max_members, verb },
) catch {};
return .failure;
}
const work_name = std.fmt.allocPrint(arena, ".zit-landing-{s}", .{abbrev(l.id)}) catch
return oomOnStore(streams);
var work = ephemeralCheckout(gpa, io, arena, streams, "landing", root_dir, store_abs, into_hash, cone_members, work_name) 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_label, .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.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,
/// The target tip `acceptLanding` read once: merged onto by hash.
target_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, target_tip, 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 merge 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.
pub const Work = struct {
dir: std.Io.Dir,
abs: []const u8,
name: []const u8,
pub 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`. `members` are the files
/// changed on both sides, and may be empty.
pub fn ephemeralCheckout(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
verb: []const u8,
parent: std.Io.Dir,
abs: []const u8,
target_hash: []const u8,
members: []const []const u8,
/// The working area's directory name beside the repository.
name: []const u8,
) ?Work {
parent.deleteTree(io, name) catch {}; // a prior run's wreckage, if any
parent.createDir(io, name, .default_dir) catch {
streams.err.print("zit {s}: cannot create a working area beside the repository\n", .{verb}) catch {};
return null;
};
var dir = parent.openDir(io, name, .{}) catch {
streams.err.print("zit {s}: cannot create a working area beside the repository\n", .{verb}) 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, verb);
return null;
};
checkout.write(io, dir, .{
.version = "",
.branch = "trunk",
.merge_parent = "",
.special_op = "",
.manifest_flags = "",
}) catch {
streams.err.print("zit {s}: cannot write the working area\n", .{verb}) 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.print("zit {s}: cannot write the working area\n", .{verb}) catch {};
return null;
};
// cone.Cone refuses an empty member list, and a merge 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 scoped: []const []const u8 = if (members.len == 0) &.{no_overlap_member} else members;
var cone = cone_mod.Cone.init(gpa, scoped, &.{}) catch {
streams.err.print("zit {s}: cannot scope the working area\n", .{verb}) catch {};
return null;
};
defer cone.deinit(gpa);
var repo2: Repository = undefined;
repo2.open(gpa, io, dir, .{ .bypass_cache = false, .mode = .full }) catch {
streams.err.print("zit {s}: cannot read the repository through the working area\n", .{verb}) catch {};
return null;
};
defer repo2.deinit(gpa);
const log = repo2.logStore(gpa) catch {
streams.err.print("zit {s}: cannot read the repository\n", .{verb}) catch {};
return null;
};
update.applyCheckinWith(gpa, io, dir, log, target_hash, .inherit, .{ .set = &cone }, &repo2, null) catch {
streams.err.print("zit {s}: cannot materialize the target for the merge\n", .{verb}) 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);
}