Artifact 9e68b97029b3ba86085c170375546907279a1fefcf3215655bde8a2381906841:
- File
src/cmd/landing.zig
— part of check-in
[baa15c3362]
at
2026-08-24 00:31:18
on branch ncard-rule
— N card: follow fossil's per-type write rule (#136)
landing hardcoded N text/plain with no way to say otherwise, overriding the text/x-fossil-wiki default of artifact-format 4.7.12 on every record it wrote. It now takes --mimetype and, minting a forum artifact, follows forum's rule.
The wider bug was in what landing copied. Verified against fossil's source: wiki (wiki.c:2084) and forum (forum.c:1784) write N only when the mimetype is present, non-empty and NOT the default; technote (event.c:284) writes it whenever non-empty, with no suppression. zit wrote it unconditionally for all three, so an explicit --mimetype text/x-fossil-wiki minted different bytes, and a different artifact id, than fossil produces from the same input.
The draft states the READ default, not when the card is written. Only the source shows the suppression, and only the source shows that the three types disagree -- so tests pin the divergence rather than tidying it away. (user: dmestas, size: 64107) [annotate] [blame] [check-ins using] [more...]
//! `zit landing` — propose a branch for trunk, as a reviewable record (#127). //! //! A landing is a CEREMONY, not a new artifact kind. Everything it records is //! an ordinary fossil artifact: the record is a forum thread, every comment and //! state transition is a reply in that thread, and the terminal outcome is a //! raw tag on the check-in that actually landed. A stock fossil clone of a //! repository carrying landings sees forum threads and tagged check-ins — fully //! readable, merely not actionable. No card type and no artifact kind is minted //! here that `forum` and `tag` do not already mint. //! //! That constraint is the feature's whole compatibility story, so it is worth //! stating as a rule rather than a habit: IF AN IMPLEMENTATION HERE REACHES FOR //! A NEW CARD, THE DESIGN IS WRONG. //! //! The encoding, therefore, lives in the body of the thread root: //! //! zit-landing: 1 //! branch: <name> //! target: <name> //! //! <the author's message> //! //! and a transition is a reply whose body opens with `zit-landing-state: X`. //! Both are plain text a human reads without tooling, which is the point: the //! fallback for a reader that does not know what a landing is should be a //! slightly odd forum post, never an unparseable one. //! //! THE INTERFACE HIDES THAT ENCODING (Ousterhout, and the reason this verb //! family exists at all instead of a documentation page). A caller opens, //! reviews, accepts and rejects a landing knowing only branch names and landing //! ids. If a thread hash leaks into the interface, or `accept` starts asking //! callers to name a tag, this has become a pass-through wrapper and the //! complexity it was built to absorb has been pushed back up to every caller -- //! the machine ones and the human at a terminal alike. //! //! Reads are FULL-STORE SCANS, deliberately, on the precedent `ticket set` //! already set (`records.zig`): the store carries no forum index, and a wrong //! answer from a stale one is worse than a slow correct one. Indexing belongs to //! whatever serves the store; zit owns being right, and owns no opinion about //! what is built on top of it. //! //! That trade stands. The sentence that used to close this paragraph -- that //! stores of the size this serves "read in milliseconds" -- did not, and is //! removed rather than softened (#134). It was carried over from `ticketKnown`, //! where it is true because that is a WRITE path taken once per ticket change; //! this is a READ path taken per view, and the scale did not carry either. On a //! 117MB mirror the scan cost 62 seconds. //! //! The waste in it is gone (#134: the fold no longer parses every content blob //! to discover it is not a forum post), but the SHAPE has not changed: this is //! still linear in store bytes, and a store ten times larger costs ten times //! more. A caller that already knows which records exist, from an index of its //! own, should not be paying this at all -- the open half of #134, and not //! something the constant-factor fix closed. const std = @import("std"); const cli = @import("zit").cli; const artifact = @import("zit").artifact; const logstore = @import("zit").logstore; const storage = @import("zit").storage; const Repository = @import("zit").Repository; const merge3 = @import("../merge3.zig"); const cone_mod = @import("zit").cone; const checkout = @import("zit").checkout; const update = @import("zit").update; const main = @import("../main.zig"); const cmd_checkout = @import("checkout.zig"); const cmd_history = @import("history.zig"); const 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, author: []const u8, opened: []const u8, state: State, /// The transition that settled `state`, for `show`. Empty while open. settled_by: []const u8 = "", }; /// The header a landing's thread root carries, parsed back out of the body. /// Null when the body is not a landing at all, which is the common case: most /// forum posts in a real store are conversation. fn parseHeader(body: []const u8) ?struct { branch: []const u8, target: []const u8 } { if (!std.mem.startsWith(u8, body, marker)) return null; var branch: ?[]const u8 = null; var target: ?[]const u8 = null; var it = std.mem.splitScalar(u8, body, '\n'); _ = it.next(); // the marker line, already matched while (it.next()) |line| { if (line.len == 0) break; // the blank line ends the header if (std.mem.startsWith(u8, line, "branch: ")) { branch = std.mem.trim(u8, line["branch: ".len..], " \r"); } else if (std.mem.startsWith(u8, line, "target: ")) { target = std.mem.trim(u8, line["target: ".len..], " \r"); } else { // An unknown header line under a version we DO claim to know is a // dialect mismatch, not a field to skip. Refuse the whole record. return null; } } const b = branch orelse return null; const t = target orelse return null; if (b.len == 0 or t.len == 0) return null; return .{ .branch = b, .target = t }; } /// The state a reply carries, or null when it is an ordinary comment. fn parseTransition(body: []const u8) ?State { if (!std.mem.startsWith(u8, body, state_marker)) return null; const rest = body[state_marker.len..]; const end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; return State.parse(std.mem.trim(u8, rest[0..end], " \t\r")); } pub fn runLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const Sub = enum { open, ls, show, comment, accept, reject }; var sub: ?Sub = null; var positional: ?[]const u8 = null; var branch: ?[]const u8 = null; var target: ?[]const u8 = null; var message: ?[]const u8 = null; var user: ?[]const u8 = null; var 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; } const author = resolveAuthor(user, amb) orelse { streams.err.writeAll("zit landing: --user is required (no checkout default here)\n") catch {}; return .failure; }; var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); // `mainBranch()` borrows the repository's own memory, which the block below // frees. Duped into the arena BEFORE that, because the header this ends up // in is a permanent artifact: a dangling read here mints a corrupt record. const target = target_opt orelse (arena.dupe(u8, repo.mainBranch()) catch { repo.deinit(gpa); return stateOom(streams, "landing"); }); // A landing against a branch that does not resolve is a typo, and a typo // that mints a permanent artifact is a footprint nobody can retract. Both // ends are checked BEFORE anything is written. { defer repo.deinit(gpa); _ = repo.resolve(branch, .checkin) catch { streams.err.print("zit landing: branch '{s}' not found\n", .{branch}) catch {}; return .failure; }; _ = repo.resolve(target, .checkin) catch { streams.err.print("zit landing: target '{s}' not found\n", .{target}) catch {}; return .failure; }; if (std.mem.eql(u8, branch, target)) { streams.err.writeAll("zit landing: a branch cannot land on itself\n") catch {}; return .failure; } } // One open landing per branch. Refused by name, so the caller learns which // record already claims it rather than discovering two later. var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found, null) != .ok) return .failure; for (found.items) |l| { if (l.state == .open and std.mem.eql(u8, l.branch, branch)) { streams.err.print( "zit landing: '{s}' already has an open landing ({s})\n", .{ branch, abbrev(l.id) }, ) catch {}; return .failure; } } const body = std.fmt.allocPrint( arena, "{s}\nbranch: {s}\ntarget: {s}\n\n{s}\n", .{ marker, branch, target, message }, ) catch return stateOom(streams, "landing"); const hash = postThread(gpa, io, arena, streams, amb, author, message, body, 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, l.title, }) catch {}; } return .ok; } /// `show` — the review surface for one landing. /// /// The check-in list is computed AT VIEW TIME against the target, never cached /// against a frozen range: the workflow this serves merges the target into the /// branch while work continues, so a snapshot would be wrong by the second /// commit. #127's acceptance criteria pin exactly that. fn showLanding( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, ) cli.Exit { const id = id_opt orelse { streams.err.writeAll("zit landing: show needs a landing id\n") catch {}; return .usage; }; var found: std.ArrayList(Landing) = .empty; defer found.deinit(arena); var posts: std.ArrayList(Post) = .empty; defer posts.deinit(arena); if (fold(gpa, io, arena, streams, amb, &found, &posts) != .ok) return .failure; const l = pick(found.items, id) orelse { streams.err.print("zit landing: unknown landing '{s}'\n", .{id}) catch {}; return .failure; }; streams.out.print("landing {s}\n", .{l.id}) catch {}; streams.out.print("branch {s} -> {s}\n", .{ l.branch, l.target }) catch {}; streams.out.print("state {s}\n", .{l.state.label()}) catch {}; streams.out.print("author {s}\n", .{l.author}) catch {}; streams.out.print("opened {s}\n", .{l.opened}) catch {}; reportMergeability(gpa, io, arena, streams, amb, l); streams.out.print("\n{s}\n", .{l.title}) catch {}; reportCheckins(gpa, io, arena, streams, amb, l); reportThread(streams, posts.items, l); return .ok; } /// Upper bounds on what one `show` prints. A landing thread is a conversation /// and a branch is a branch -- neither is unbounded in principle, but paging a /// thousand rows into a terminal serves nobody, and a SILENT cap reads as /// "that was all of it". Both are stated when they bite. const max_shown_checkins: usize = 50; const max_shown_posts: usize = 100; /// The check-ins being proposed: on the branch, not on the target, newest /// first. What a reviewer decides from before they look at a diff -- and the /// reason `git request-pull`, which this ceremony descends from, puts a /// shortlog in its message. fn reportCheckins( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, ) void { var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; // Full derive, not the fast-read image: the comment is read from the // crosslinked state below, and the image cannot always answer for it. if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, false, &repo, &root_dir) != .ok) return; defer root_dir.close(io); defer repo.deinit(gpa); const branch_rid = repo.resolve(l.branch, .checkin) catch return; const target_rid = repo.resolve(l.target, .checkin) catch return; // Topological order, oldest first: every parent precedes its child, so // two check-ins made in the same second never invert. `repository.zig` // states the rule this follows -- "the verb owns no ancestry ordering of // its own" -- and an mtime sort here would have been exactly that mistake. const branch_line = repo.ancestryLinearized(gpa, branch_rid) catch return; defer gpa.free(branch_line); const target_anc = repo.ancestorsOf(gpa, target_rid) catch return; defer gpa.free(target_anc); var on_target: std.AutoHashMapUnmanaged(u64, void) = .empty; for (target_anc) |rid| on_target.put(arena, rid, {}) catch return; // What is being proposed: on the branch, not already on the target. var mine: std.ArrayList(u64) = .empty; for (branch_line) |rid| { if (on_target.contains(rid)) continue; mine.append(arena, rid) catch return; } if (mine.items.len == 0) return; std.mem.reverse(u64, mine.items); // newest first, for a reader const shown = @min(mine.items.len, max_shown_checkins); streams.out.writeAll("\n") catch {}; for (mine.items[0..shown]) |rid| { const mtime = repo.state().mtimeOf(rid) orelse 0; var iso_buf: [19]u8 = undefined; const iso = cli.formatWriterTimestamp(@intCast(@divTrunc(mtime, 1000)), &iso_buf); const hash = repo.hashOf(rid); // The comment rides the crosslinked state, so no manifest is parsed per // row -- the same reason `timeline` reads it there. The manifest // fallback covers a state that cannot answer; a bodyless check-in // degenerates to an empty comment, never a failure. var maybe: ?Repository.OwnedManifest = null; defer if (maybe) |*m| m.deinit(gpa); const from_state: []const u8 = if (repo.state().commentOf(rid)) |c| c else blk: { maybe = repo.manifest(gpa, rid) catch break :blk ""; break :blk if (maybe) |m| (if (m.manifest.c) |c| c.comment else "") else ""; }; const comment = repo.override(rid, .comment, from_state); streams.out.print("checkin {s} {s} {s}\n", .{ hash[0..@min(hash.len, 12)], iso, firstLine(comment), }) catch {}; } if (mine.items.len > shown) streams.out.print("... {d} more check-in(s) not shown\n", .{mine.items.len - shown}) catch {}; } /// The conversation, in the SAME order the state fold uses (date then hash), so /// what a reader sees can never disagree with the state they were shown. /// /// A transition renders as an EVENT rather than a remark -- "dan accepted this" /// is what happened, and showing it as a comment whose body starts with a /// marker line would leak the encoding the interface exists to hide. fn reportThread(streams: cli.Streams, posts: []const Post, l: Landing) void { var replies: [max_shown_posts]Post = undefined; var n: usize = 0; var total: usize = 0; for (posts) |p| { const r = p.root orelse continue; if (!std.mem.eql(u8, r, l.id)) continue; total += 1; if (n < replies.len) { replies[n] = p; n += 1; } } if (total == 0) return; // no thread section at all, not an empty heading std.mem.sort(Post, replies[0..n], {}, struct { fn lt(_: void, a: Post, b: Post) bool { const o = std.mem.order(u8, a.stamp, b.stamp); if (o != .eq) return o == .lt; return std.mem.order(u8, a.hash, b.hash) == .lt; } }.lt); for (replies[0..n]) |p| { if (parseTransition(p.body)) |st| { streams.out.print("\n{s} {s} {s} this\n", .{ p.stamp, p.author, st.label() }) catch {}; const note = std.mem.trim(u8, bodyAfterHeader(p.body), " \n\r\t"); if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {}; } else { streams.out.print("\n{s} {s}\n", .{ p.stamp, p.author }) catch {}; const note = std.mem.trim(u8, p.body, " \n\r\t"); if (note.len != 0) streams.out.print(" {s}\n", .{note}) catch {}; } } if (total > n) streams.out.print("\n... {d} more remark(s) not shown\n", .{total - n}) catch {}; } /// Everything after a body's header block -- the note a transition carried. fn bodyAfterHeader(body: []const u8) []const u8 { const sep = std.mem.indexOf(u8, body, "\n\n") orelse return ""; return body[sep + 2 ..]; } /// A comment's first line, for a one-row rendering. fn firstLine(text: []const u8) []const u8 { const end = std.mem.indexOfScalar(u8, text, '\n') orelse text.len; return text[0..end]; } /// The `merges` line, and one `conflict <path>` line per unresolvable path. /// /// Written as data a caller can parse without a second command: a forge has to /// render "this branch has conflicts" and NAME them, and the alternative -- /// making the forge attempt the merge to find out -- is the one thing a server /// must never do. /// /// A landing whose branch or target no longer resolves reports `merges /// unknown` rather than failing the whole `show`. The record outliving its /// branch is an ordinary state (the branch was renamed, or the landing is old), /// and refusing to display the record over it would be the wrong trade. fn reportMergeability( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, ) void { var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; if (main.openRepo(gpa, io, arena, streams, "landing", amb.repo_override, false, true, &repo, &root_dir) != .ok) { streams.out.writeAll("merges unknown\n") catch {}; return; } defer root_dir.close(io); defer repo.deinit(gpa); const target_rid = repo.resolve(l.target, .checkin) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; const branch_rid = repo.resolve(l.branch, .checkin) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; const probe = probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch { streams.out.writeAll("merges unknown\n") catch {}; return; }; switch (probe.verdict) { .up_to_date => streams.out.writeAll("merges up-to-date\n") catch {}, .unrelated => streams.out.writeAll("merges unrelated\n") catch {}, .clean => streams.out.writeAll("merges clean\n") catch {}, .conflicts => |paths| { streams.out.print("merges conflict ({d})\n", .{paths.len}) catch {}; for (paths) |path| streams.out.print("conflict {s}\n", .{path}) catch {}; }, } } /// `comment`, `accept` and `reject` — one path, because they differ only in /// what the reply says. `to` is null for a comment. /// /// ACCEPT'S ORDERING IS THE LOAD-BEARING PART. The merge is performed first /// and the transition recorded second: a merge that lands with no transition /// leaves a visibly-open landing over merged work, which a human resolves in /// one command. The reverse order would leave a landing marked accepted over /// an unmerged branch — a lie in the record, and the failure mode the module /// doc says this verb exists to absorb. fn transition( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, id_opt: ?[]const u8, message_opt: ?[]const u8, user: ?[]const u8, to: ?State, 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) != .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, .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; } // --- mergeability (#131) --------------------------------------------------- // // "Would this land cleanly?", answered from the store alone — no working tree, // no cone, nothing written. A forge has to be able to ask this before anyone // clicks accept, because the one thing a server must never do is commit a // conflict. // // It reuses `merge3.classifyFile`, the SAME pure classifier the working-tree // merge runs, rather than deciding conflicts a second way. Two conflict // detectors that can disagree would be worse than none: the forge would show // "clean" on a branch that then refuses, or block a branch that would have // merged. One engine, two callers. // // NOT the hash-level resolution the sparse out-of-cone path uses. That refuses // any file touched on both sides, which would report conflicts on exactly the // long-lived branches that merged their target back in — the #127 workflow. // Content-level or the answer is useless. /// What a landing's branch would do against its target. const Mergeability = union(enum) { /// Already an ancestor of the target: nothing to land. up_to_date, /// No common ancestor. Not a conflict — a different repository's history. unrelated, clean, /// Paths that cannot be resolved without a human. Arena-owned, sorted. conflicts: []const []const u8, }; /// A path's blob name on one side, or null when the path is absent there. const SideMap = std.StringHashMapUnmanaged([]const u8); /// Index a check-in's file set by path. Names are arena-duped: the `FileList` /// they borrow from dies with this call. fn sideMapOf( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: u64, out: *SideMap, ) !void { var list = (try repo.files(gpa, rid)) orelse return; defer list.deinit(gpa); var it = list.iterator(); while (it.next()) |f| { const hash = f.hash orelse continue; // a delete marker is absence try out.put(arena, try arena.dupe(u8, f.filename), try arena.dupe(u8, hash)); } } /// Classify one both-modified path by CONTENT, which is the only case that /// needs blob reads. Every other shape is decided by F-card hash alone. fn conflictsOnContent( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, base_hash: ?[]const u8, ours_hash: ?[]const u8, theirs_hash: ?[]const u8, ) !bool { var base_bytes: ?[]u8 = null; var ours_bytes: ?[]u8 = null; var theirs_bytes: ?[]u8 = null; defer if (base_bytes) |b| gpa.free(b); defer if (ours_bytes) |b| gpa.free(b); defer if (theirs_bytes) |b| gpa.free(b); if (base_hash) |h| base_bytes = try repo.blob(gpa, h); if (ours_hash) |h| ours_bytes = try repo.blob(gpa, h); if (theirs_hash) |h| theirs_bytes = try repo.blob(gpa, h); // A blob the store does not hold (a sparse store elides out-of-cone // content) cannot be merged here. Reported as a conflict rather than // guessed: "I could not read it" and "it does not merge" are different // facts, but both mean a human has to do this one, and claiming clean // would be the dangerous direction. if (base_hash != null and base_bytes == null) return true; if (ours_hash != null and ours_bytes == null) return true; if (theirs_hash != null and theirs_bytes == null) return true; // Arena-scoped, matching the working-tree merge's own call // (`checkout.zig:1468`): `classifyFile`'s Result bytes belong to the // allocator it was given, and this one is the per-invocation arena. const outcome = try merge3.classifyFile( arena, if (base_bytes) |b| b else null, if (ours_bytes) |b| b else null, if (theirs_bytes) |b| b else null, .conflict, ); return switch (outcome) { .text_conflict, .whole_file_conflict => true, .unchanged, .take_other, .delete, .clean_merge => false, }; } /// A probe's full answer: the verdict, plus the paths BOTH sides changed. /// /// The second half is not a diagnostic -- it is the cone a bare accept needs. /// sparse-cone Section 4.4 resolves out-of-cone paths at hash level, which /// cannot combine two edits to one file, so exactly these paths must be /// in-cone for the merge to reach the content engine. Every other path is /// correctly resolved at the record without materializing anything. const Probe = struct { verdict: Mergeability, both_modified: []const []const u8, }; /// Would `branch` land on `target` without a human? Store-only; writes nothing. fn probeMergeability( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, target_rid: u64, branch_rid: u64, ) !Probe { if (try repo.isAncestorOf(gpa, branch_rid, target_rid)) return .{ .verdict = .up_to_date, .both_modified = &.{} }; const base_rid = (try repo.mergeBaseOf(gpa, target_rid, branch_rid)) orelse return .{ .verdict = .unrelated, .both_modified = &.{} }; var base: SideMap = .empty; var ours: SideMap = .empty; var theirs: SideMap = .empty; try sideMapOf(gpa, arena, repo, base_rid, &base); try sideMapOf(gpa, arena, repo, target_rid, &ours); try sideMapOf(gpa, arena, repo, branch_rid, &theirs); var conflicts: std.ArrayList([]const u8) = .empty; var both: std.ArrayList([]const u8) = .empty; // Every path either side knows. Walking ours then theirs covers the union // without a third set: a path in `base` alone is deleted on both sides, // which is agreement, not a conflict. var seen: std.StringHashMapUnmanaged(void) = .empty; for ([_]*SideMap{ &ours, &theirs }) |m| { var it = m.iterator(); while (it.next()) |e| { const path = e.key_ptr.*; if (seen.contains(path)) continue; try seen.put(arena, path, {}); const b = base.get(path); const o = ours.get(path); const t = theirs.get(path); // Hash-level short circuits, in the order that makes the common // cases free. Only a genuinely both-modified path reads blobs. if (sameHash(o, t)) continue; // both sides agree already if (sameHash(b, o)) continue; // only theirs moved -> take theirs if (sameHash(b, t)) continue; // only ours moved -> keep ours // Reaching here IS the both-modified case: neither side matches // the base and they differ from each other. try both.append(arena, path); if (try conflictsOnContent(gpa, arena, repo, b, o, t)) { try conflicts.append(arena, path); } } } std.mem.sort([]const u8, both.items, {}, struct { fn lt(_: void, a: []const u8, b2: []const u8) bool { return std.mem.order(u8, a, b2) == .lt; } }.lt); if (conflicts.items.len == 0) return .{ .verdict = .clean, .both_modified = both.items }; // Sorted so two readers of the same store see the same list in the same // order — a forge rendering them must not shuffle between refreshes. std.mem.sort([]const u8, conflicts.items, {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lt); return .{ .verdict = .{ .conflicts = conflicts.items }, .both_modified = both.items }; } fn sameHash(a: ?[]const u8, b: ?[]const u8) bool { if (a == null and b == null) return true; if (a == null or b == null) return false; return std.mem.eql(u8, a.?, b.?); } /// `accept` against a NAMED repository, with no checkout of the operator's to /// stand in (#131). The forge path. /// /// The shape, and why it is this shape: /// /// * The merge needs a working tree, so this command MAKES one -- ephemeral, /// created and destroyed inside this call, and materializing ONLY the paths /// both sides changed. sparse-cone Section 4.4 resolves out-of-cone paths at /// the record "without materializing the files", so every other path costs /// nothing; the cone exists solely because hash-level resolution cannot /// combine two edits to one file. /// /// * NOTHING is written until the merge is known to be clean. A conflicting /// landing exits `.conflicts` with the store byte-unchanged. A forge must /// never hold a half-finished merge, and must never commit conflict /// markers -- the operator resolves it from their own checkout, as they /// would on any forge. /// /// * The check-in is minted by `runCommitIn`, the SAME minter the verb uses. /// The R card, the delta-dialect continuation and sub-manifest emission are /// decided there; a second writer here would produce artifacts other /// conforming implementations hash differently. fn acceptBare( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, l: Landing, author: []const u8, message_opt: ?[]const u8, 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 = probeMergeability(gpa, arena, &repo, target_rid, branch_rid) catch return stateOom(streams, "landing"); switch (probe.verdict) { .up_to_date => { streams.err.print( "zit landing: '{s}' is already contained in '{s}'; nothing to land\n", .{ l.branch, l.target }, ) catch {}; return .failure; }, .unrelated => { streams.err.print( "zit landing: '{s}' and '{s}' share no common ancestor\n", .{ l.branch, l.target }, ) catch {}; return .failure; }, .conflicts => |paths| plan_conflicts = paths, .clean => {}, } cone_members = probe.both_modified; target_hash = arena.dupe(u8, repo.hashOf(target_rid)) catch return stateOom(streams, "landing"); } // Refuse BEFORE anything is created, let alone written. The paths go to // stderr and the status is `.conflicts`, so a caller can render "blocked" // and name the files without parsing prose. if (plan_conflicts.len != 0) { streams.err.print( "zit landing: {s} does not merge cleanly into '{s}'; nothing was written\n", .{ abbrev(l.id), l.target }, ) catch {}; for (plan_conflicts) |path| streams.err.print("zit landing: conflict {s}\n", .{path}) catch {}; streams.err.writeAll( "zit landing: merge the target into the branch from a checkout, resolve, and push\n", ) catch {}; return .conflicts; } // cone.Cone refuses an empty member list, and a landing that touches no // file on both sides legitimately has one. A member no path can equal // covers nothing, which is exactly the intent: materialize nothing. const members: []const []const u8 = if (cone_members.len == 0) &[_][]const u8{no_overlap_member} else cone_members; if (members.len > cone_mod.max_members) { streams.err.print( "zit landing: {d} files changed on both sides, past the {d}-member cone bound; accept this from a checkout\n", .{ members.len, cone_mod.max_members }, ) catch {}; return .failure; } var work = ephemeralCheckout(gpa, io, arena, streams, root_dir, store_abs, target_hash, members, l) orelse return .failure; defer work.destroy(io, root_dir); const named: cmd_checkout.NamedCheckout = .{ .dir = work.dir, .abs = work.abs }; var sink_buf: [4096]u8 = undefined; var sink: std.Io.Writer.Discarding = .init(&sink_buf); const quiet = cli.Streams{ .out = &sink.writer, .err = streams.err }; if (cmd_checkout.runMergeIn(gpa, io, arena, &.{ l.branch, "--no-sync" }, quiet, named) != .ok) { streams.err.print("zit landing: {s} failed to merge; nothing was written\n", .{abbrev(l.id)}) catch {}; return .failure; } const msg = message_opt orelse std.fmt.allocPrint(arena, "land {s}", .{l.branch}) catch return stateOom(streams, "landing"); if (cmd_checkout.runCommitIn(gpa, io, arena, &.{ "-m", msg, "--user", author }, quiet, amb, named) != .ok) { streams.err.writeAll("zit landing: the merge did not commit; nothing was landed\n") catch {}; return .failure; } // Ordered writes, unchanged from the checkout path (#127): the branch has // landed, so now the record says so. if (cmd_history.runTag(gpa, io, arena, &.{ "add", "--raw", outcome_tag, l.target, l.id }, quiet, amb) != .ok) { streams.err.writeAll("zit landing: landed, but the outcome tag failed\n") catch {}; return .failure; } const body = std.fmt.allocPrint(arena, "{s} accepted\n\n{s}\n", .{ state_marker, msg }) catch return stateOom(streams, "landing"); _ = replyThread(gpa, io, arena, streams, amb, author, l.id, body, 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) 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 }; }