//! `zit landing` — propose a branch for trunk, as a reviewable record (#127).
//!
//! A landing is a CEREMONY, not a new artifact kind. Everything it records is
//! an ordinary fossil artifact: the record is a forum thread, every comment and
//! state transition is a reply in that thread, and the terminal outcome is a
//! raw tag on the check-in that actually landed. A stock fossil clone of a
//! repository carrying landings sees forum threads and tagged check-ins — fully
//! readable, merely not actionable. No card type and no artifact kind is minted
//! here that `forum` and `tag` do not already mint.
//!
//! That constraint is the feature's whole compatibility story, so it is worth
//! stating as a rule rather than a habit: IF AN IMPLEMENTATION HERE REACHES FOR
//! A NEW CARD, THE DESIGN IS WRONG.
//!
//! The encoding, therefore, lives in the body of the thread root:
//!
//! zit-landing: 1
//! branch: <name>
//! target: <name>
//!
//! <the author's prose>
//!
//! where the message's FIRST LINE is the thread title (the H card) and the
//! prose is what follows it, so the record never carries the same text twice.
//! A transition is a reply whose body opens with `zit-landing-state: X`.
//! Both are plain text a human reads without tooling, which is the point: the
//! fallback for a reader that does not know what a landing is should be a
//! slightly odd forum post, never an unparseable one.
//!
//! THE INTERFACE HIDES THAT ENCODING (Ousterhout, and the reason this verb
//! family exists at all instead of a documentation page). A caller opens,
//! reviews, accepts and rejects a landing knowing only branch names and landing
//! ids. If a thread hash leaks into the interface, or `accept` starts asking
//! callers to name a tag, this has become a pass-through wrapper and the
//! complexity it was built to absorb has been pushed back up to every caller --
//! the machine ones and the human at a terminal alike.
//!
//! Reads are FULL-STORE SCANS, deliberately, on the precedent `ticket set`
//! already set (`records.zig`): the store carries no forum index, and a wrong
//! answer from a stale one is worse than a slow correct one. Indexing belongs to
//! whatever serves the store; zit owns being right, and owns no opinion about
//! what is built on top of it.
//!
//! That trade stands. The sentence that used to close this paragraph -- that
//! stores of the size this serves "read in milliseconds" -- did not, and is
//! removed rather than softened (#134). It was carried over from `ticketKnown`,
//! where it is true because that is a WRITE path taken once per ticket change;
//! this is a READ path taken per view, and the scale did not carry either. On a
//! 117MB mirror the scan cost 62 seconds.
//!
//! The waste in it is gone (#134: the fold no longer parses every content blob
//! to discover it is not a forum post), but the SHAPE has not changed: this is
//! still linear in store bytes, and a store ten times larger costs ten times
//! more. A caller that already knows which records exist, from an index of its
//! own, should not be paying this at all -- the open half of #134, and not
//! something the constant-factor fix closed.
const std = @import("std");
const cli = @import("zit").cli;
const artifact = @import("zit").artifact;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const Repository = @import("zit").Repository;
const merge3 = @import("../merge3.zig");
const cone_mod = @import("zit").cone;
const checkout = @import("zit").checkout;
const update = @import("zit").update;
const main = @import("../main.zig");
const cmd_checkout = @import("checkout.zig");
const cmd_history = @import("history.zig");
const cmd_records = @import("records.zig");
const stateOom = main.stateOom;
const landing_help = main.landing_help;
/// The body-header line that makes a forum thread a landing. Versioned from
/// the first commit: a later dialect must be able to say so without guessing,
/// and a reader that meets a version it does not know must refuse rather than
/// misread a field it only thinks it recognises.
const marker = "zit-landing: 1";
/// The reply-body prefix carrying a state transition.
const state_marker = "zit-landing-state:";
/// The 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,
/// The author's message below the title, header block already stripped.
/// Empty for a one-line proposal, which is what a one-line proposal is.
prose: []const u8,
author: []const u8,
opened: []const u8,
state: State,
/// The transition that settled `state`, for `show`. Empty while open.
settled_by: []const u8 = "",
};
/// The header a landing's thread root carries, parsed back out of the body.
/// Null when the body is not a landing at all, which is the common case: most
/// forum posts in a real store are conversation.
fn parseHeader(body: []const u8) ?struct { branch: []const u8, target: []const u8 } {
if (!std.mem.startsWith(u8, body, marker)) return null;
var branch: ?[]const u8 = null;
var target: ?[]const u8 = null;
var it = std.mem.splitScalar(u8, body, '\n');
_ = it.next(); // the marker line, already matched
while (it.next()) |line| {
if (line.len == 0) break; // the blank line ends the header
if (std.mem.startsWith(u8, line, "branch: ")) {
branch = std.mem.trim(u8, line["branch: ".len..], " \r");
} else if (std.mem.startsWith(u8, line, "target: ")) {
target = std.mem.trim(u8, line["target: ".len..], " \r");
} else {
// An unknown header line under a version we DO claim to know is a
// dialect mismatch, not a field to skip. Refuse the whole record.
return null;
}
}
const b = branch orelse return null;
const t = target orelse return null;
if (b.len == 0 or t.len == 0) return null;
return .{ .branch = b, .target = t };
}
/// Split one authored message into the title and the prose beneath it, on the
/// convention every commit message already follows: the first line is the
/// subject, the rest is the body.
///
/// The split is at the first NEWLINE, not the first blank line, because an H
/// card is a single token (§4.7.7) and a title carrying an embedded newline is
/// the defect this exists to prevent (#138). Splitting at a blank line would
/// still let `"line one\nline two"` mint a two-line heading; splitting here
/// cannot. A message with no newline is a title with no prose, which is what a
/// one-line proposal is.
fn splitMessage(message: []const u8) struct { title: []const u8, prose: []const u8 } {
const nl = std.mem.indexOfScalar(u8, message, '\n') orelse
return .{ .title = std.mem.trim(u8, message, " \t\r"), .prose = "" };
return .{
.title = std.mem.trim(u8, message[0..nl], " \t\r"),
.prose = std.mem.trim(u8, message[nl + 1 ..], "\n\r \t"),
};
}
/// The author's message, with the header block the encoding prepends removed.
///
/// The header is ENCODING, and the module doc is explicit that the interface
/// hides it: a reader shown `zit-landing: 1` is reading through the interface
/// built to hide it. Stripping it belongs beside `parseHeader`, which defines
/// where the header ends, rather than in each caller that prints a body.
fn prose(body: []const u8) []const u8 {
if (!std.mem.startsWith(u8, body, marker)) return body;
const blank = std.mem.indexOf(u8, body, "\n\n") orelse return "";
return std.mem.trim(u8, body[blank + 2 ..], "\n\r \t");
}
/// The one line a heading can be. Landings minted before #138 carry the whole
/// message in the H card, and an artifact is immutable — so every reader that
/// prints a title on one line has to cope with those forever, not merely stop
/// making new ones.
fn titleLine(title: []const u8) []const u8 {
const nl = std.mem.indexOfScalar(u8, title, '\n') orelse return title;
return std.mem.trim(u8, title[0..nl], " \t\r");
}
/// The state a reply carries, or null when it is an ordinary comment.
fn parseTransition(body: []const u8) ?State {
if (!std.mem.startsWith(u8, body, state_marker)) return null;
const rest = body[state_marker.len..];
const end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
return State.parse(std.mem.trim(u8, rest[0..end], " \t\r"));
}
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 mimetype: ?[]const u8 = null;
var show_all = false;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(landing_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--branch")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--branch");
branch = verb_args[i];
} else if (std.mem.eql(u8, tok, "--to")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--to");
target = verb_args[i];
} else if (std.mem.eql(u8, tok, "-m") or std.mem.eql(u8, tok, "--message")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, tok);
message = verb_args[i];
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--user");
user = verb_args[i];
} else if (std.mem.eql(u8, tok, "--mimetype")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "landing", .unknown_option, "--mimetype");
mimetype = verb_args[i];
} else if (std.mem.eql(u8, tok, "--all")) {
show_all = true;
} else if (tok.len > 1 and tok[0] == '-') {
return cli.usageError(streams, "landing", .unknown_option, tok);
} else if (sub == null) {
if (std.mem.eql(u8, tok, "open")) {
sub = .open;
} else if (std.mem.eql(u8, tok, "ls") or std.mem.eql(u8, tok, "list")) {
sub = .ls;
} else if (std.mem.eql(u8, tok, "show")) {
sub = .show;
} else if (std.mem.eql(u8, tok, "comment")) {
sub = .comment;
} else if (std.mem.eql(u8, tok, "accept")) {
sub = .accept;
} else if (std.mem.eql(u8, tok, "reject")) {
sub = .reject;
} else {
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, mimetype),
.ls => listLandings(gpa, io, arena, streams, amb, show_all),
.show => showLanding(gpa, io, arena, streams, amb, positional),
.comment => transition(gpa, io, arena, streams, amb, positional, message, user, null, mimetype),
.accept => acceptLanding(gpa, io, arena, streams, amb, positional, message, user, mimetype),
.reject => transition(gpa, io, arena, streams, amb, positional, message, user, .rejected, mimetype),
};
}
/// `open` — mint the thread root that IS the landing.
fn openLanding(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
branch_opt: ?[]const u8,
target_opt: ?[]const u8,
message_opt: ?[]const u8,
user: ?[]const u8,
mimetype: ?[]const u8,
) cli.Exit {
const branch = branch_opt orelse {
streams.err.writeAll("zit landing: open needs --branch NAME\n") catch {};
return .usage;
};
const message = message_opt orelse {
streams.err.writeAll("zit landing: open needs -m MESSAGE\n") catch {};
return .usage;
};
if (message.len == 0) {
streams.err.writeAll("zit landing: the message must not be empty\n") catch {};
return .failure;
}
// The title is the first line and the prose is what follows (#138): an H
// card is one token, and a body carrying the title again would mint the
// same text twice — once as a heading it cannot be, once as prose.
// Checked HERE, with the other argument validation, so a message with no
// title line is refused before the branch lookups and the store scan.
const split = splitMessage(message);
if (split.title.len == 0) {
streams.err.writeAll("zit landing: the message must open with a title line\n") catch {};
return .failure;
}
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {};
return .failure;
};
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
const rc = main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir);
if (rc != .ok) return rc;
defer root_dir.close(io);
// `mainBranch()` borrows the repository's own memory, which the block below
// frees. Duped into the arena BEFORE that, because the header this ends up
// in is a permanent artifact: a dangling read here mints a corrupt record.
const target = target_opt orelse (arena.dupe(u8, repo.mainBranch()) catch {
repo.deinit(gpa);
return stateOom(streams, "landing");
});
// A landing against a branch that does not resolve is a typo, and a typo
// that mints a permanent artifact is a footprint nobody can retract. Both
// ends are checked BEFORE anything is written.
{
defer repo.deinit(gpa);
_ = repo.resolve(branch, .checkin) catch {
streams.err.print("zit landing: branch '{s}' not found\n", .{branch}) catch {};
return .failure;
};
_ = repo.resolve(target, .checkin) catch {
streams.err.print("zit landing: target '{s}' not found\n", .{target}) catch {};
return .failure;
};
if (std.mem.eql(u8, branch, target)) {
streams.err.writeAll("zit landing: a branch cannot land on itself\n") catch {};
return .failure;
}
}
// One open landing per branch. Refused by name, so the caller learns which
// record already claims it rather than discovering two later.
var found: std.ArrayList(Landing) = .empty;
defer found.deinit(arena);
if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;
for (found.items) |l| {
if (l.state == .open and std.mem.eql(u8, l.branch, branch)) {
streams.err.print(
"zit landing: '{s}' already has an open landing ({s})\n",
.{ branch, abbrev(l.id) },
) catch {};
return .failure;
}
}
const body = std.fmt.allocPrint(
arena,
"{s}\nbranch: {s}\ntarget: {s}\n\n{s}\n",
.{ marker, branch, target, split.prose },
) catch return stateOom(streams, "landing");
const hash = postThread(gpa, io, arena, streams, amb, author, split.title, body, mimetype) orelse return .failure;
streams.out.print("{s}\n", .{hash}) catch {};
return .ok;
}
/// `ls` — the queue. Open landings by default; `--all` includes settled ones.
fn listLandings(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
show_all: bool,
) cli.Exit {
var found: std.ArrayList(Landing) = .empty;
defer found.deinit(arena);
if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;
for (found.items) |l| {
if (!show_all and l.state != .open) continue;
streams.out.print("{s} {s} {s} -> {s} {s}\n", .{
abbrev(l.id),
l.state.label(),
l.branch,
l.target,
titleLine(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", .{titleLine(l.title)}) catch {};
if (l.prose.len != 0) streams.out.print("\n{s}\n", .{l.prose}) catch {};
reportCheckins(gpa, io, arena, streams, amb, l);
reportThread(streams, posts.items, l);
return .ok;
}
/// Upper bounds on what one `show` prints. A landing thread is a conversation
/// and a branch is a branch -- neither is unbounded in principle, but paging a
/// thousand rows into a terminal serves nobody, and a SILENT cap reads as
/// "that was all of it". Both are stated when they bite.
const max_shown_checkins: usize = 50;
const max_shown_posts: usize = 100;
/// The check-ins being proposed: on the branch, not on the target, newest
/// first. What a reviewer decides from before they look at a diff -- and the
/// reason `git request-pull`, which this ceremony descends from, puts a
/// shortlog in its message.
fn reportCheckins(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
l: Landing,
) void {
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
// Full derive, not the fast-read image: the comment is read from the
// crosslinked state below, and the image cannot always answer for it.
if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok)
return;
defer root_dir.close(io);
defer repo.deinit(gpa);
const branch_rid = repo.resolve(l.branch, .checkin) catch return;
const target_rid = repo.resolve(l.target, .checkin) catch return;
// Topological order, oldest first: every parent precedes its child, so
// two check-ins made in the same second never invert. `repository.zig`
// states the rule this follows -- "the verb owns no ancestry ordering of
// its own" -- and an mtime sort here would have been exactly that mistake.
const branch_line = repo.ancestryLinearized(gpa, branch_rid) catch return;
defer gpa.free(branch_line);
const target_anc = repo.ancestorsOf(gpa, target_rid) catch return;
defer gpa.free(target_anc);
var on_target: std.AutoHashMapUnmanaged(u64, void) = .empty;
for (target_anc) |rid| on_target.put(arena, rid, {}) catch return;
// What is being proposed: on the branch, not already on the target.
var mine: std.ArrayList(u64) = .empty;
for (branch_line) |rid| {
if (on_target.contains(rid)) continue;
mine.append(arena, rid) catch return;
}
if (mine.items.len == 0) return;
std.mem.reverse(u64, mine.items); // newest first, for a reader
const shown = @min(mine.items.len, max_shown_checkins);
streams.out.writeAll("\n") catch {};
for (mine.items[0..shown]) |rid| {
const mtime = repo.state().mtimeOf(rid) orelse 0;
var iso_buf: [19]u8 = undefined;
const iso = cli.formatWriterTimestamp(@intCast(@divTrunc(mtime, 1000)), &iso_buf);
const hash = repo.hashOf(rid);
// The comment rides the crosslinked state, so no manifest is parsed per
// row -- the same reason `timeline` reads it there. The manifest
// fallback covers a state that cannot answer; a bodyless check-in
// degenerates to an empty comment, never a failure.
var maybe: ?Repository.OwnedManifest = null;
defer if (maybe) |*m| m.deinit(gpa);
const from_state: []const u8 = if (repo.state().commentOf(rid)) |c| c else blk: {
maybe = repo.manifest(gpa, rid) catch break :blk "";
break :blk if (maybe) |m| (if (m.manifest.c) |c| c.comment else "") else "";
};
const comment = repo.override(rid, .comment, from_state);
streams.out.print("checkin {s} {s} {s}\n", .{
hash[0..@min(hash.len, 12)],
iso,
firstLine(comment),
}) catch {};
}
if (mine.items.len > shown)
streams.out.print("... {d} more check-in(s) not shown\n", .{mine.items.len - shown}) catch {};
}
/// The conversation, in the SAME order the state fold uses (date then hash), so
/// what a reader sees can never disagree with the state they were shown.
///
/// A transition renders as an EVENT rather than a remark -- "dan accepted this"
/// is what happened, and showing it as a comment whose body starts with a
/// marker line would leak the encoding the interface exists to hide.
fn reportThread(streams: cli.Streams, posts: []const Post, l: Landing) void {
var replies: [max_shown_posts]Post = undefined;
var n: usize = 0;
var total: usize = 0;
for (posts) |p| {
const r = p.root orelse continue;
if (!std.mem.eql(u8, r, l.id)) continue;
total += 1;
if (n < replies.len) {
replies[n] = p;
n += 1;
}
}
if (total == 0) return; // no thread section at all, not an empty heading
std.mem.sort(Post, replies[0..n], {}, struct {
fn lt(_: void, a: Post, b: Post) bool {
const o = std.mem.order(u8, a.stamp, b.stamp);
if (o != .eq) return o == .lt;
return std.mem.order(u8, a.hash, b.hash) == .lt;
}
}.lt);
for (replies[0..n]) |p| {
if (parseTransition(p.body)) |st| {
streams.out.print("\n{s} {s} {s} this\n", .{ p.stamp, p.author, st.label() }) catch {};
const note = std.mem.trim(u8, bodyAfterHeader(p.body), " \n\r\t");
if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {};
} else {
streams.out.print("\n{s} {s}\n", .{ p.stamp, p.author }) catch {};
const note = std.mem.trim(u8, p.body, " \n\r\t");
if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {};
}
}
if (total > n)
streams.out.print("\n... {d} more remark(s) not shown\n", .{total - n}) catch {};
}
/// Everything after a body's header block -- the note a transition carried.
fn bodyAfterHeader(body: []const u8) []const u8 {
const sep = std.mem.indexOf(u8, body, "\n\n") orelse return "";
return body[sep + 2 ..];
}
/// A comment's first line, for a one-row rendering.
fn firstLine(text: []const u8) []const u8 {
const end = std.mem.indexOfScalar(u8, text, '\n') orelse text.len;
return text[0..end];
}
/// The `merges` line, and one `conflict <path>` line per unresolvable path.
///
/// Written as data a caller can parse without a second command: a forge has to
/// render "this branch has conflicts" and NAME them, and the alternative --
/// making the forge attempt the merge to find out -- is the one thing a server
/// must never do.
///
/// A landing whose branch or target no longer resolves reports `merges
/// unknown` rather than failing the whole `show`. The record outliving its
/// branch is an ordinary state (the branch was renamed, or the landing is old),
/// and refusing to display the record over it would be the wrong trade.
fn reportMergeability(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
l: Landing,
) void {
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir) != .ok) {
streams.out.writeAll("merges unknown\n") catch {};
return;
}
defer root_dir.close(io);
defer repo.deinit(gpa);
const target_rid = repo.resolve(l.target, .checkin) catch {
streams.out.writeAll("merges unknown\n") catch {};
return;
};
const branch_rid = repo.resolve(l.branch, .checkin) catch {
streams.out.writeAll("merges unknown\n") catch {};
return;
};
const probe = cmd_checkout.probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch {
streams.out.writeAll("merges unknown\n") catch {};
return;
};
switch (probe.verdict) {
.up_to_date => streams.out.writeAll("merges up-to-date\n") catch {},
.unrelated => streams.out.writeAll("merges unrelated\n") catch {},
.clean => streams.out.writeAll("merges clean\n") catch {},
.conflicts => |paths| {
streams.out.print("merges conflict ({d})\n", .{paths.len}) catch {};
for (paths) |path| streams.out.print("conflict {s}\n", .{path}) catch {};
},
}
}
/// `comment`, `accept` and `reject` — one path, because they differ only in
/// what the reply says. `to` is null for a comment.
///
/// ACCEPT'S ORDERING IS THE LOAD-BEARING PART. The merge is performed first
/// and the transition recorded second: a merge that lands with no transition
/// leaves a visibly-open landing over merged work, which a human resolves in
/// one command. The reverse order would leave a landing marked accepted over
/// an unmerged branch — a lie in the record, and the failure mode the module
/// doc says this verb exists to absorb.
fn transition(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
id_opt: ?[]const u8,
message_opt: ?[]const u8,
user: ?[]const u8,
to: ?State,
mimetype: ?[]const u8,
) cli.Exit {
const id = id_opt orelse {
streams.err.writeAll("zit landing: needs a landing id\n") catch {};
return .usage;
};
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {};
return .failure;
};
var found: std.ArrayList(Landing) = .empty;
defer found.deinit(arena);
if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;
const l = pick(found.items, id) orelse {
streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {};
return .failure;
};
// A settled landing takes comments but not a second verdict: re-deciding
// would make the fold's latest-wins rule silently rewrite history that a
// reader already acted on.
if (to != null and l.state != .open) {
streams.err.print(
"zit landing: {s} is already {s}\n",
.{ abbrev(l.id), l.state.label() },
) catch {};
return .failure;
}
const message = message_opt orelse "";
const body = if (to) |s|
std.fmt.allocPrint(arena, "{s} {s}\n\n{s}\n", .{ state_marker, s.label(), message }) catch
return stateOom(streams, "landing")
else b: {
if (message.len == 0) {
streams.err.writeAll("zit landing: comment needs -m MESSAGE\n") catch {};
return .failure;
}
break :b std.fmt.allocPrint(arena, "{s}\n", .{message}) catch return stateOom(streams, "landing");
};
const hash = replyThread(gpa, io, arena, streams, amb, author, l.id, body, mimetype) orelse return .failure;
streams.out.print("{s}\n", .{hash}) catch {};
return .ok;
}
/// `accept` — land the branch, then record that it landed.
///
/// THE ORDER IS THE CONTRACT. Merge and commit run first; the outcome tag and
/// the transition reply follow. A failure after the merge leaves merged work
/// under a still-open landing, which one command settles. The reverse order
/// would leave an accepted record over an unmerged branch — a lie in the
/// permanent record, and the exact complexity this verb exists to keep away
/// from its callers.
///
/// Unlike its siblings this needs a WORKING TREE: `merge` writes one. So it
/// refuses `-R` in-verb, the way `diff` refuses it in worktree mode, rather
/// than making the whole verb checkout-dependent for a bare-store forge's sake.
fn acceptLanding(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
id_opt: ?[]const u8,
message_opt: ?[]const u8,
user: ?[]const u8,
mimetype: ?[]const u8,
) cli.Exit {
const id = id_opt orelse {
streams.err.writeAll("zit landing: accept needs a landing id\n") catch {};
return .usage;
};
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {};
return .failure;
};
var found: std.ArrayList(Landing) = .empty;
defer found.deinit(arena);
if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure;
const l = pick(found.items, id) orelse {
streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {};
return .failure;
};
if (l.state != .open) {
streams.err.print("zit landing: {s} is already {s}\n", .{ abbrev(l.id), l.state.label() }) catch {};
return .failure;
}
// #131: named a repository rather than standing in a checkout -- the forge
// shape. Same ceremony, same ordered writes; the merge runs against a
// checkout this command creates and destroys.
if (amb.repo_override != null)
return acceptBare(gpa, io, arena, streams, amb, l, author, message_opt, mimetype);
// The checkout must already be on the target. Moving it here would be this
// verb quietly rewriting the operator's working tree to make its own job
// easier -- a surprise with a dirty tree in it.
{
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
const rc = main.openRepo(gpa, io, arena, streams, "landing", null, false, true, &repo, &root_dir);
if (rc != .ok) return rc;
defer root_dir.close(io);
defer repo.deinit(gpa);
const rid = repo.currentCheckoutRid() orelse {
streams.err.writeAll("zit landing: accept works in a checkout\n") catch {};
return .failure;
};
const on = repo.branchOf(rid);
if (!std.mem.eql(u8, on, l.target)) {
streams.err.print(
"zit landing: this checkout is on '{s}'; {s} lands on '{s}' — update first\n",
.{ on, abbrev(l.id), l.target },
) catch {};
return .failure;
}
}
// The sub-verbs' stdout is the ENCODING talking: a merge line, a check-in
// line, a tag line. The module doc's rule is that callers never learn a
// landing is a forum thread with a raw tag, so their stdout is discarded
// and one landing-shaped line is printed instead. stderr is passed through
// untouched — a real failure must still reach the operator in full.
var sink_buf: [4096]u8 = undefined;
var sink: std.Io.Writer.Discarding = .init(&sink_buf);
const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err };
if (cmd_checkout.runMerge(gpa, io, arena, &.{l.branch}, quiet, amb) != .ok) {
streams.err.print("zit landing: {s} did not merge cleanly; resolve and commit, then accept again\n", .{abbrev(l.id)}) catch {};
return .failure;
}
const msg = if (message_opt) |m| m else std.fmt.allocPrint(
arena,
"land {s}",
.{l.branch},
) catch return stateOom(streams, "landing");
if (cmd_checkout.runCommit(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb) != .ok) {
streams.err.writeAll("zit landing: the merge is in the working tree but the commit failed; commit it yourself, then accept again\n") catch {};
return .failure;
}
// The durable, check-in-oriented fact: this check-in is where a landing
// came in. Raw, because it is a property many check-ins carry, not a name.
if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) {
streams.err.writeAll("zit landing: merged and committed, but the outcome tag failed\n") catch {};
return .failure;
}
const body = std.fmt.allocPrint(
arena,
"{s} accepted\n\n{s}\n",
.{ state_marker, msg },
) catch return stateOom(streams, "landing");
_ = replyThread(gpa, io, arena, streams, amb, author, l.id, body, mimetype) orelse return .failure;
streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {};
return .ok;
}
/// The shortest unambiguous rendering of a landing id for human output. Twelve
/// hex characters is the codebase's existing abbreviation width.
fn abbrev(id: []const u8) []const u8 {
return if (id.len > 12) id[0..12] else id;
}
/// The landing an id names, matching on any unambiguous prefix. Null when no
/// landing matches; a prefix matching two is also null, because answering
/// arbitrarily is worse than refusing.
fn pick(items: []const Landing, id: []const u8) ?Landing {
var hit: ?Landing = null;
for (items) |l| {
if (!std.mem.startsWith(u8, l.id, id)) continue;
if (hit != null) return null;
hit = l;
}
return hit;
}
/// The author: `--user` wins, else the ambient OS user, exactly as `forum`
/// resolves it. Empty is refused either way, for the reason `records.zig`
/// gives: this writer will not mint more empty U tokens.
fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 {
const chosen = user orelse (amb.user_env orelse return null);
if (chosen.len == 0) return null;
return chosen;
}
/// One forum artifact as the fold needs it. Borrowed from the fold's arena.
const Post = struct {
hash: []const u8,
root: ?[]const u8,
title: []const u8,
author: []const u8,
stamp: []const u8,
body: []const u8,
};
/// Scan the store, collect every forum artifact, and fold the landing threads
/// out of them.
///
/// A FULL-STORE SCAN, on the precedent `ticket set` set: no forum index
/// exists, and a stale index answering "no such landing" would silently mint a
/// second one. See the module doc -- including what this costs, which is
/// linear in store bytes and not something the #134 fix changed.
///
/// The fold is deterministic and latest-wins on (timestamp, hash) — the same
/// rule a ticket-field fold applies, chosen so two readers of the
/// same store never disagree about a landing's state.
fn fold(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
out: *std.ArrayList(Landing),
/// The forum artifacts the scan read, for a caller that wants the thread
/// as well as the state. Null discards them. Handed back rather than
/// re-scanned: this is a full-store read, and `show` needing the same
/// bytes twice would double the one genuinely expensive thing here.
posts_out: ?*std.ArrayList(Post),
) cli.Exit {
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "landing")) orelse {
main.reportNoRoot(streams, io, "landing", amb.repo_override);
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
var file = cli.openStoreRead(io, root_dir) catch {
streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
return .failure;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
return .failure;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
return .failure;
};
defer log.deinit(gpa);
var posts: std.ArrayList(Post) = .empty;
defer if (posts_out == null) posts.deinit(arena);
// One reusable plaintext buffer for the whole walk, as `frames` does:
// a per-artifact allocation across a whole store is measurable, and a
// frame's reconstruction is memoised across the entries that delta
// against it.
var plaintext: logstore.PlaintextCache = .{};
defer plaintext.deinit(gpa);
var rid: logstore.Rid = 1;
const count: logstore.Rid = @intCast(log.entries.items.len);
while (rid <= count) : (rid += 1) {
const bytes = (log.contentCached(gpa, rid, &plaintext) catch |e| switch (e) {
error.OutOfMemory => return stateOom(streams, "landing"),
// Damage is `verify`'s report to make, not this reader's: a
// corrupt entry cannot be a landing, so skipping it is correct
// and failing the whole listing would not be.
else => continue,
}) orelse continue;
defer gpa.free(bytes);
// Reject-fast pre-screen (#108's, and for its reason): most of a real
// store's bytes are CONTENT BLOBS, and a full parse pays a whole-bytes
// Z checksum before rejecting each one. A structural artifact's first
// bytes are a card line -- `<LETTER><SP>`, artifact-format Section 4.2
// -- or a signature wrapper, `-----BEGIN `, Section 4.1.1.
//
// The screen only ever REJECTS. Every accept still takes the full
// parse below, so nothing this fold would refuse can be admitted by
// being shaped like a card.
const card_shaped = bytes.len >= 2 and bytes[0] >= 'A' and bytes[0] <= 'Z' and bytes[1] == ' ';
const wrapper_shaped = std.mem.startsWith(u8, bytes, "-----BEGIN ");
if (!card_shaped and !wrapper_shaped) continue;
var parsed = artifact.parse(gpa, bytes) catch continue;
defer parsed.deinit();
if (parsed.kind != .forum) continue;
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
posts.append(arena, .{
.hash = arena.dupe(u8, name) catch return stateOom(streams, "landing"),
.root = if (parsed.g) |g| arena.dupe(u8, g.hash) catch return stateOom(streams, "landing") else null,
.title = if (parsed.h) |h| arena.dupe(u8, h.title) catch return stateOom(streams, "landing") else "",
.author = if (parsed.u) |u| arena.dupe(u8, u.login) catch return stateOom(streams, "landing") else "",
.stamp = if (parsed.d) |d| arena.dupe(u8, d.timestamp) catch return stateOom(streams, "landing") else "",
.body = arena.dupe(u8, parsed.w orelse "") catch return stateOom(streams, "landing"),
}) catch return stateOom(streams, "landing");
if (posts.items.len > max_landings * 8) break; // TigerStyle: bounded
}
// Pass 1 — the roots that are landings.
for (posts.items) |p| {
if (p.root != null) continue; // a reply is never a record
const hdr = parseHeader(p.body) orelse continue;
out.append(arena, .{
.id = p.hash,
.branch = hdr.branch,
.target = hdr.target,
.title = p.title,
.prose = prose(p.body),
.author = p.author,
.opened = p.stamp,
.state = .open,
}) catch return stateOom(streams, "landing");
if (out.items.len >= max_landings) break;
}
// Pass 2 — settle each landing from its thread's transitions. Latest wins
// on (timestamp, hash); the hash tiebreak is what makes two readers of the
// same store agree when a second resolution shares a timestamp.
for (out.items) |*l| {
var best_stamp: []const u8 = "";
var best_hash: []const u8 = "";
for (posts.items) |p| {
const r = p.root orelse continue;
if (!std.mem.eql(u8, r, l.id)) continue;
const s = parseTransition(p.body) orelse continue;
const newer = std.mem.order(u8, p.stamp, best_stamp) == .gt or
(std.mem.eql(u8, p.stamp, best_stamp) and std.mem.order(u8, p.hash, best_hash) == .gt);
if (!newer) continue;
best_stamp = p.stamp;
best_hash = p.hash;
l.state = s;
l.settled_by = p.hash;
}
}
if (posts_out) |po| po.* = posts;
return .ok;
}
/// Mint the thread root. `title` becomes the H card; `body` the W card.
fn postThread(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
author: []const u8,
title: []const u8,
body: []const u8,
mimetype: ?[]const u8,
) ?[]const u8 {
return mintForum(gpa, io, arena, streams, amb, author, title, null, body, mimetype);
}
/// Mint a reply into `root`'s thread. G names the thread root and I the post
/// replied to; here both are the root, because a landing's conversation is
/// flat by design — a threaded argument about a state transition is a forum
/// feature nobody asked this verb for.
fn replyThread(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
author: []const u8,
root: []const u8,
body: []const u8,
mimetype: ?[]const u8,
) ?[]const u8 {
return mintForum(gpa, io, arena, streams, amb, author, null, root, body, mimetype);
}
/// The one place a landing writes an artifact. Exactly `forum`'s manifest
/// shape — same kind, same cards — because a landing artifact IS a forum
/// artifact and any divergence here would be the compatibility break the
/// module doc forbids.
fn mintForum(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
author: []const u8,
title: ?[]const u8,
root: ?[]const u8,
body: []const u8,
mimetype: ?[]const u8,
) ?[]const u8 {
std.debug.assert(body.len > 0);
std.debug.assert(body.len <= max_body_bytes);
std.debug.assert(title == null or root == null); // §5.4: H XOR I
var ts_buf: [23]u8 = undefined;
const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .forum,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = ts },
.e = null,
.g = if (root) |r| .{ .hash = r } else null,
.h = if (title) |t| .{ .title = t } else null,
.i = if (root) |r| .{ .hash = r } else null,
.k = null,
.l = null,
// A landing record is a FORUM artifact, so it takes forum's N-card
// rule verbatim -- shared, not restated: `text/x-fossil-wiki` is
// suppressed because fossil's forum writer suppresses it, and minting
// the card redundantly would give a different artifact id than fossil
// produces from the same input.
.n = cmd_records.nCard(mimetype, true),
.r = null,
.u = .{ .login = author },
.z = null,
.w = body,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
const bytes = artifact.create(&m, gpa) catch {
_ = stateOom(streams, "landing");
return null;
};
defer gpa.free(bytes);
// Round-trip in every build mode, for the reason `records.zig` gives: these
// bytes are read back by another process, and a malformed artifact minted
// here would be a consumer's data-loss bug to discover.
var check = artifact.parse(gpa, bytes) catch {
streams.err.writeAll("zit landing: refusing to store a malformed artifact (a zit bug — please report the invocation)\n") catch {};
return null;
};
const kind_ok = check.kind == m.kind;
check.deinit();
if (!kind_ok) {
streams.err.writeAll("zit landing: refusing to store a malformed artifact (a zit bug — please report the invocation)\n") catch {};
return null;
}
const rroot = (cli.resolveRoot(arena, io, amb.repo_override) catch return null) orelse {
main.reportNoRoot(streams, io, "landing", amb.repo_override);
return null;
};
var root_dir = rroot.dir;
defer root_dir.close(io);
var file = cli.openStoreAppend(io, root_dir) catch {
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
defer log.deinit(gpa);
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
_ = log.putArtifact(&digest, bytes, null) catch {
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
log.commit() catch {
streams.err.writeAll("zit landing: cannot write the repository\n") catch {};
return null;
};
return arena.dupe(u8, name) catch null;
}
/// `accept` against a NAMED repository, with no checkout of the operator's to
/// stand in (#131). The forge path.
///
/// The shape, and why it is this shape:
///
/// * The merge needs a working tree, so this command MAKES one -- ephemeral,
/// created and destroyed inside this call, and materializing ONLY the paths
/// both sides changed. sparse-cone Section 4.4 resolves out-of-cone paths at
/// the record "without materializing the files", so every other path costs
/// nothing; the cone exists solely because hash-level resolution cannot
/// combine two edits to one file.
///
/// * NOTHING is written until the merge is known to be clean. A conflicting
/// landing exits `.conflicts` with the store byte-unchanged. A forge must
/// never hold a half-finished merge, and must never commit conflict
/// markers -- the operator resolves it from their own checkout, as they
/// would on any forge.
///
/// * The check-in is minted by `runCommitIn`, the SAME minter the verb uses.
/// The R card, the delta-dialect continuation and sub-manifest emission are
/// decided there; a second writer here would produce artifacts other
/// conforming implementations hash differently.
fn acceptBare(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
l: Landing,
author: []const u8,
message_opt: ?[]const u8,
mimetype: ?[]const u8,
) cli.Exit {
// The store's ABSOLUTE path: the ephemeral checkout points at it from
// another directory, so a relative pointer would go stale the moment
// anything resolved it from elsewhere.
const store_abs = blk: {
const r = (cli.resolveRoot(arena, io, amb.repo_override) catch
return stateOom(streams, "landing")) orelse {
main.reportNoRoot(streams, io, "landing", amb.repo_override);
return .failure;
};
var d = r.dir;
d.close(io);
break :blk r.abs;
};
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok)
return .failure;
defer root_dir.close(io);
var plan_conflicts: []const []const u8 = &.{};
var cone_members: []const []const u8 = &.{};
var target_hash: []const u8 = "";
{
defer repo.deinit(gpa);
const target_rid = repo.resolve(l.target, .checkin) catch {
streams.err.print("zit landing: target '{s}' not found\n", .{l.target}) catch {};
return .failure;
};
const branch_rid = repo.resolve(l.branch, .checkin) catch {
streams.err.print("zit landing: branch '{s}' not found\n", .{l.branch}) catch {};
return .failure;
};
const probe = cmd_checkout.probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch
return stateOom(streams, "landing");
switch (probe.verdict) {
.up_to_date => {
streams.err.print(
"zit landing: '{s}' is already contained in '{s}'; nothing to land\n",
.{ l.branch, l.target },
) catch {};
return .failure;
},
.unrelated => {
streams.err.print(
"zit landing: '{s}' and '{s}' share no common ancestor\n",
.{ l.branch, l.target },
) catch {};
return .failure;
},
.conflicts => |paths| plan_conflicts = paths,
.clean => {},
}
cone_members = probe.both_modified;
target_hash = arena.dupe(u8, repo.hashOf(target_rid)) catch
return stateOom(streams, "landing");
}
// Refuse BEFORE anything is created, let alone written. The paths go to
// stderr and the status is `.conflicts`, so a caller can render "blocked"
// and name the files without parsing prose.
if (plan_conflicts.len != 0) {
streams.err.print(
"zit landing: {s} does not merge cleanly into '{s}'; nothing was written\n",
.{ abbrev(l.id), l.target },
) catch {};
for (plan_conflicts) |path|
streams.err.print("zit landing: conflict {s}\n", .{path}) catch {};
streams.err.writeAll(
"zit landing: merge the target into the branch from a checkout, resolve, and push\n",
) catch {};
return .conflicts;
}
// cone.Cone refuses an empty member list, and a landing that touches no
// file on both sides legitimately has one. A member no path can equal
// covers nothing, which is exactly the intent: materialize nothing.
const members: []const []const u8 = if (cone_members.len == 0)
&[_][]const u8{no_overlap_member}
else
cone_members;
if (members.len > cone_mod.max_members) {
streams.err.print(
"zit landing: {d} files changed on both sides, past the {d}-member cone bound; accept this from a checkout\n",
.{ members.len, cone_mod.max_members },
) catch {};
return .failure;
}
var work = ephemeralCheckout(gpa, io, arena, streams, root_dir, store_abs, target_hash, members, l) orelse
return .failure;
defer work.destroy(io, root_dir);
const named: cmd_checkout.NamedCheckout = .{ .dir = work.dir, .abs = work.abs };
var sink_buf: [4096]u8 = undefined;
var sink: std.Io.Writer.Discarding = .init(&sink_buf);
const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err };
if (cmd_checkout.runMergeIn(gpa, io, arena, &.{ l.branch, "--no-sync" }, quiet, amb, named) != .ok) {
streams.err.print("zit landing: {s} failed to merge; nothing was written\n", .{abbrev(l.id)}) catch {};
return .failure;
}
const msg = message_opt orelse std.fmt.allocPrint(arena, "land {s}", .{l.branch}) catch
return stateOom(streams, "landing");
if (cmd_checkout.runCommitIn(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb, named) != .ok) {
streams.err.writeAll("zit landing: the merge did not commit; nothing was landed\n") catch {};
return .failure;
}
// Ordered writes, unchanged from the checkout path (#127): the branch has
// landed, so now the record says so.
if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) {
streams.err.writeAll("zit landing: landed, but the outcome tag failed\n") catch {};
return .failure;
}
const body = std.fmt.allocPrint(arena, "{s} accepted\n\n{s}\n", .{ state_marker, msg }) catch
return stateOom(streams, "landing");
_ = replyThread(gpa, io, arena, streams, amb, author, l.id, body, mimetype) orelse return .failure;
streams.out.print("accepted {s} {s} -> {s}\n", .{ abbrev(l.id), l.branch, l.target }) catch {};
return .ok;
}
/// A cone member no worktree path can equal, for a landing whose sides touch
/// no file in common. Reserved-looking on purpose: `validateWorktreePath`
/// accepts it as a name, and nothing will ever be called it.
const no_overlap_member = "__zit_landing_no_overlap__";
/// The ephemeral worktree an `accept -R` merges in. Its whole life is one
/// command; `destroy` removes it whether the merge succeeded or not.
const Work = struct {
dir: std.Io.Dir,
abs: []const u8,
name: []const u8,
fn destroy(self: *Work, io: std.Io, parent: std.Io.Dir) void {
self.dir.close(io);
// Best effort: a leftover directory is untidy, never incorrect -- it
// holds no artifacts, only materialized copies of blobs the store
// already has.
parent.deleteTree(io, self.name) catch {};
}
};
/// Create the ephemeral checkout: a container pointing at this store, then a
/// cone-scoped materialization of `target_hash`.
fn ephemeralCheckout(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
parent: std.Io.Dir,
abs: []const u8,
target_hash: []const u8,
members: []const []const u8,
l: Landing,
) ?Work {
const name = std.fmt.allocPrint(arena, ".zit-landing-{s}", .{abbrev(l.id)}) catch {
_ = stateOom(streams, "landing");
return null;
};
parent.deleteTree(io, name) catch {}; // a prior run's wreckage, if any
parent.createDir(io, name, .default_dir) catch {
streams.err.writeAll("zit landing: cannot create a working area beside the repository\n") catch {};
return null;
};
var dir = parent.openDir(io, name, .{}) catch {
streams.err.writeAll("zit landing: cannot create a working area beside the repository\n") catch {};
parent.deleteTree(io, name) catch {};
return null;
};
var ok = false;
defer if (!ok) {
dir.close(io);
parent.deleteTree(io, name) catch {};
};
const work_abs = std.fs.path.join(arena, &.{ abs, name }) catch {
_ = stateOom(streams, "landing");
return null;
};
checkout.write(io, dir, .{
.version = "",
.branch = "trunk",
.merge_parent = "",
.special_op = "",
.manifest_flags = "",
}) catch {
streams.err.writeAll("zit landing: cannot write the working area\n") catch {};
return null;
};
// The store lives elsewhere: an absolute pointer, as `open --store` writes.
checkout.updateSections(io, dir, &.{.{ .tag = .store_location, .payload = abs }}) catch {
streams.err.writeAll("zit landing: cannot write the working area\n") catch {};
return null;
};
var cone = cone_mod.Cone.init(gpa, members, &.{}) catch {
streams.err.writeAll("zit landing: cannot scope the working area\n") catch {};
return null;
};
defer cone.deinit(gpa);
var repo2: Repository = undefined;
repo2.open(gpa, io, dir, .{ .bypass_cache = false, .mode = .full }) catch {
streams.err.writeAll("zit landing: cannot read the repository through the working area\n") catch {};
return null;
};
defer repo2.deinit(gpa);
const log = repo2.logStore(gpa) catch {
streams.err.writeAll("zit landing: cannot read the repository\n") catch {};
return null;
};
update.applyCheckinWith(gpa, io, dir, log, target_hash, .inherit, .{ .set = &cone }, &repo2, null) catch {
streams.err.writeAll("zit landing: cannot materialize the target for the merge\n") catch {};
return null;
};
ok = true;
return .{ .dir = dir, .abs = work_abs, .name = name };
}