Skip to content
zit Artifact [9aac67b929]
Login

Artifact [9aac67b929]

Artifact 9aac67b92963dbb1e6472d037aeb767402bdfaf180852850bd0126f6efa160d9:


//! zit checkout / mutating verbs (update, bisect, merge, revert, undo, redo,
//! commit, amend) and the helpers and tests private to them.
//!
//! Extracted verbatim from src/main.zig (release step S5 -- the final family
//! extraction, which completes the split of the CLI surface). Every declaration
//! below is byte-identical to its main.zig source; only this import prologue is
//! new, plus the `pub` the verb-dispatch switch forces on the eight dispatched
//! handlers. Helpers still shared with other verbs stay in main.zig and are
//! aliased back here so the moved bodies call them unqualified. main.zig's
//! verb-dispatch switch calls into this file (`cmd_checkout.runUpdate` ...); its
//! test block imports it so these inline tests keep running under
//! `zig build test`.

const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const merge3 = @import("../merge3.zig");
const cmd_sync = @import("sync.zig");
const cmd_precedent = @import("precedent.zig");
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const update = @import("zit").update;
const checkout = @import("zit").checkout;
const bisect = @import("zit").bisect;
const Repository = @import("zit").Repository;

const Io = std.Io;
const File = Io.File;
const repostate = zit.repostate;
const managed = zit.managed;
const artifact = zit.artifact;

// Shared CLI helpers, constants, and verb help text that stay in main.zig
// (other verbs use them too, or the dispatch help table references them);
// aliased back so the moved code and tests reference them by their original
// unqualified names.
const main = @import("../main.zig");
const reasonOf = main.reasonOf;
const stateOom = main.stateOom;
const checkoutFail = main.checkoutFail;
const managedFail = main.managedFail;
const repoOpenFail = main.repoOpenFail;
const openRepo = main.openRepo;
const firstCommentLine = main.firstCommentLine;
const lessByHash = main.lessByHash;
const mintControlArtifact = main.mintControlArtifact;
const hexToDigest = main.hexToDigest;
const updateApplyFail = main.updateApplyFail;
const trackedFileSizeAllowed = main.trackedFileSizeAllowed;
const executable = main.executable;
const digest_len = main.digest_len;
const sym_prefix = main.sym_prefix;
const timeline_hash_display = main.timeline_hash_display;
const not_a_repo_msg = main.not_a_repo_msg;
const repo_filename = main.repo_filename;
const max_tracked_file_bytes = main.max_tracked_file_bytes;
const symlink_unversioned = main.symlink_unversioned;
const update_help = main.update_help;
const merge_help = main.merge_help;
const bisect_help = main.bisect_help;
const revert_help = main.revert_help;
const undo_help = main.undo_help;
const redo_help = main.redo_help;
const amend_help = main.amend_help;
const commit_help = main.commit_help;

/// The parent's symbolic tags a branch-root check-in must cancel (repo-state
/// §6.2). §6.2: "Branch creation MUST also cancel every pre-existing active
/// sym-* tag on the branch-root check-in ... because symbolic tags are
/// otherwise INHERITED from a check-in's parent." Inheritance is propagation,
/// so the cancel set is exactly the parent's PROPAGATING (`*`) syms: a singleton
/// (`+`) sym never propagates, so the child does not inherit it and cancelling
/// it would emit a spurious non-canonical `-sym-X` for a tag the child never
/// held. `zit tag add` mints exactly such a singleton sym (REGRESSION B6), so
/// this exclusion is reachable locally now, not only via a synced
/// `T +sym-X *`. The branch's own re-established sym (`want_sym`) is excluded,
/// since `composeCommitTags` re-adds it as a propagating tag. Pure; each
/// surfaced name is duped onto `arena` so it outlives the read view. `entries`
/// come from `RepoState.tagsOf`, which surfaces active rows only (no cancels).
fn selectInheritedSyms(
    arena: std.mem.Allocator,
    entries: []const repostate.RepoState.TagEntry,
    want_sym: []const u8,
) error{OutOfMemory}![]const []const u8 {
    std.debug.assert(std.mem.startsWith(u8, want_sym, sym_prefix));
    var syms: std.ArrayList([]const u8) = .empty;
    errdefer syms.deinit(arena);
    for (entries) |e| {
        std.debug.assert(e.tagtype != .cancel); // tagsOf surfaces active rows only
        if (e.tagtype != .propagate) continue; // §6.2: only inherited (propagating) syms
        if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
        if (std.mem.eql(u8, e.name, want_sym)) continue; // re-established as a propagating tag
        try syms.append(arena, try arena.dupe(u8, e.name));
    }
    return syms.toOwnedSlice(arena);
}

test "commit repo-state §6.2: only inherited (propagating) parent syms are cancelled" {
    const gpa = std.testing.allocator;
    const TagEntry = repostate.RepoState.TagEntry;
    // A parent (as `tagsOf` surfaces it: active rows only) carrying every case:
    // an inherited propagating sym (MUST cancel), the branch's own propagating
    // sym (excluded — re-established), a SINGLETON sym (MUST NOT cancel; §6.2
    // cancels only inherited syms, and a `+` sym never propagates), and a
    // non-sym propagating tag (`branch`, not a symbolic name).
    const entries = [_]TagEntry{
        .{ .name = "sym-trunk", .tagtype = .propagate, .value = null },
        .{ .name = "sym-feature", .tagtype = .propagate, .value = null },
        .{ .name = "sym-adhoc", .tagtype = .singleton, .value = null },
        .{ .name = "branch", .tagtype = .propagate, .value = "trunk" },
    };
    const got = try selectInheritedSyms(gpa, &entries, "sym-feature");
    defer {
        for (got) |s| gpa.free(s);
        gpa.free(got);
    }
    // Exactly the inherited propagating sym, minus the re-established one: the
    // singleton `sym-adhoc` and the non-sym `branch` are both excluded.
    try std.testing.expectEqual(@as(usize, 1), got.len);
    try std.testing.expectEqualStrings("sym-trunk", got[0]);
}

/// `zit update CHECKIN` — parse one canonical artifact ID, discover the
/// worktree, open the repository read-only, and delegate the transaction to
/// the private update module.
pub fn runUpdate(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var parsed = switch (cli.parseUpdateArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "update", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "update", .extra_argument, tok),
        .empty_value => |tok| return cli.usageError(streams, "update", .empty_value, tok),
        .too_many_cones => |tok| return cli.usageError(streams, "update", .too_many_cones, tok),
        .cone_and_dense => return cli.usageError(streams, "update", .cone_and_dense, ""),
    };
    if (parsed.help) {
        streams.out.writeAll(update_help) catch {};
        return .ok;
    }
    if (parsed.latest and parsed.checkin != null)
        return cli.usageError(streams, "update", .extra_argument, parsed.checkin.?);
    // Fossil's bare `update` moves to the newest check-in on the current
    // branch; a missing CHECKIN means --latest, not a usage error.
    if (parsed.checkin == null) parsed.latest = true;
    // Autosync pull, BEFORE resolution: the freshly pulled DAG may be the
    // very thing the named check-in resolves against.
    if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "update");
    // SF1 (BLOCKER-FIX-PLAN.md §3): `update` used to accept ONLY a canonical
    // 40/64-hex ID, so `zit update trunk` — the single most obvious thing to
    // type — failed as an "invalid check-in ID". Its argument is a check-in
    // name like every other verb's, so it goes through the same §8.3 procedure.
    const root = (cli.discoverRoot(arena, io) catch {
        streams.err.writeAll("zit update: out of memory\n") catch {};
        return .failure;
    }) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    // Where the checkout is now: the receipt's "updated-from" side, and the
    // branch `--latest` names. An unborn checkout has neither.
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const current_ck = checkout.read(io, root_dir, &ck_buf) catch null;
    const cur_version: []const u8 = if (current_ck) |c| c.version else "";
    const requested: []const u8 = if (parsed.latest) blk: {
        const c = current_ck orelse {
            streams.err.writeAll("zit update: --latest needs a checkout on a branch\n") catch {};
            return .failure;
        };
        if (c.branch.len == 0) {
            streams.err.writeAll("zit update: --latest needs a checkout on a branch\n") catch {};
            return .failure;
        }
        break :blk c.branch;
    } else parsed.checkin.?;

    // One repository for the whole verb: name resolution, the branch facts
    // the record carries, and the content log the materialization reads all
    // come from it. Each used to open the store on its own -- three
    // repo-state derives and a full frame-header scan per update.
    // `.full` derive, not `.fast_read`: plain-name resolution reads the tag
    // tables, which the read-image does not materialize.
    var repo: Repository = undefined;
    var repo_root: std.Io.Dir = undefined;
    const open_rc = openRepo(gpa, io, arena, streams, "update", null, false, false, &repo, &repo_root);
    if (open_rc != .ok) return open_rc;
    defer repo_root.close(io);
    defer repo.deinit(gpa);
    const target_id = switch (updateResolveTarget(arena, &repo, requested, streams)) {
        .ok => |h| h,
        .fail => |exit| return exit,
    };
    // The receipt: what this move changes, computed from the two
    // check-ins' file sets before anything is written, so a dry run and the
    // real thing say the same lines.
    var receipt = updateReceipt(gpa, arena, &repo, cur_version, target_id, parsed.verbose) catch return stateOom(streams, "update");
    if (parsed.dry_run) {
        receipt.print(streams, &repo, &.{}, &.{}, true);
        return .ok;
    }
    const log = repo.logStore(gpa) catch |err| switch (err) {
        error.OutOfMemory => return stateOom(streams, "update"),
        else => {
            streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {};
            return .failure;
        },
    };

    // Sparse-cone RFC section 4.1: --cone replaces the stored cone, --dense
    // drops it, absent inherits. Cone.init validates members (worktree-path
    // rules, coverage minimality); the parser only collected tokens.
    var cone_members = parsed.cone_members;
    var cone_len = parsed.cone_len;
    var cone_not_members = parsed.cone_not_members;
    var cone_not_len = parsed.cone_not_len;
    if (parsed.cone_file) |cf| {
        const lens = cli.appendConeFile(arena, io, cf, &cone_members, cone_len, &cone_not_members, cone_not_len) orelse {
            streams.err.print("zit update: cannot read --cone-file '{s}' (or it overflows the {d}-member cap)\n", .{ cf, zit.cone.max_members }) catch {};
            return .failure;
        };
        cone_len = lens.members;
        cone_not_len = lens.not_members;
    }
    var new_cone: ?zit.cone.Cone = null;
    defer if (new_cone) |*c| c.deinit(gpa);
    const sparse: update.SparseOpt = if (parsed.dense)
        .{ .set = null }
    else if (cone_len + cone_not_len != 0) blk: {
        new_cone = zit.cone.Cone.init(gpa, cone_members[0..cone_len], cone_not_members[0..cone_not_len]) catch |e|
            return coneInitFail(streams, "update", e);
        break :blk .{ .set = &new_cone.? };
    } else .inherit;

    // The mutation entry: `target_id` is a full §3.6 artifact id — either the
    // canonical argument or the resolved-and-duped hash (both validated above).
    std.debug.assert(artifact.validateName(target_id));
    // The project's `manifest` setting supplies the stamp selection when
    // this checkout has none of its own, so a repository that adopts the
    // setting starts stamping at the next update rather than only after a
    // fresh `open`. A checkout that made its own choice keeps it.
    // One repository, one open. The config this verb already holds
    // answers the project setting, so nothing here re-opens the store.
    var update_config: ?logstore.OwnedConfig = log.readConfig(gpa) catch null;
    defer if (update_config) |*c| c.deinit(gpa);
    const project: ProjectPolicy = projectPolicy(io, arena, root_dir, amb, if (update_config) |*c| &c.config else null);

    // A dirty tree is merged INTO, not refused. `update` used to reject
    // it, which left an operator whose branch tip had moved under uncommitted
    // work with no way forward at all — `merge` refused for the same reason and
    // `stash` cannot replay onto a different check-in, so the only exit was
    // `commit --allow-fork`. The guard meant to keep forks deliberate was
    // manufacturing them. Carrying the edits across the move is fossil's own
    // answer (`www/branching.wiki`: update "merges them into his own changes"),
    // and it leaves the next commit an ordinary child of the tip.
    //
    // The policy is handed DOWN rather than computed first: the divergences it
    // needs come from the same tree scan the materialization already runs, and
    // asking for them separately would scan every clean update twice.
    var policy = CarryPolicy{
        .gpa = gpa,
        .io = io,
        .arena = arena,
        .repo = &repo,
        .root_dir = root_dir,
        .target_id = target_id,
        .streams = streams,
    };
    update.applyCheckinWith(gpa, io, root_dir, log, target_id, .{ .inherit_or = project.manifest }, sparse, &repo, policy.resolver()) catch |err| {
        // The policy already named the paths it could not merge; the generic
        // "not clean" line below would only bury them.
        if (policy.refused) return .failure;
        // Sparse-cone RFC section 6.4, policy form, update's half: content
        // missing on a SPARSE store while the operator widens (or densifies)
        // is elision meeting new coverage intent -- write the requested cone
        // as store policy, pull through the saved remote, and retry once.
        // On a dense store the same error stays the corruption it always was.
        if (err == error.FileContentMissing) {
            var probe = zit.repocache.readStoreCone(gpa, io, root_dir);
            const store_is_sparse = probe != null;
            if (probe) |*c| c.deinit(gpa);
            if (store_is_sparse and updateDemandFetch(gpa, io, arena, root_dir, streams, &sparse)) {
                // The log handle predates the fetch; the retry reopens
                // everything through a fresh verb pass with fetching spent.
                var file2 = cli.openStoreRead(io, root_dir) catch {
                    streams.err.writeAll("zit update: cannot reopen the repository after fetching\n") catch {};
                    return .failure;
                };
                defer file2.close(io);
                const st2 = file2.stat(io) catch {
                    streams.err.writeAll("zit update: cannot reopen the repository after fetching\n") catch {};
                    return .failure;
                };
                var fs2 = storage.FileStorage.init(io, file2, st2.size);
                var log2 = logstore.LogStore.openReadOnly(gpa, fs2.storageFile()) catch |e2|
                    return updateLogFail(streams, e2);
                defer log2.deinit(gpa);
                // The retry carries the same working-tree content:
                // re-resolving against the reopened store is what keeps the
                // post-fetch path from silently overwriting local edits that
                // the pre-fetch path preserves.
                update.applyCheckinWith(gpa, io, root_dir, &log2, target_id, .{ .inherit_or = project.manifest }, sparse, null, policy.resolver()) catch |err2| {
                    if (policy.refused) return .failure;
                    return updateApplyFail(streams, target_id, err2);
                };
                cli.stampWorktreeRegistry(gpa, io, root_dir, root.abs);
                // The tree exists now, so the empty-dirs shape
                // can be laid over it (fossil's update.c does the same after
                // its own materialization).
                update.ensureEmptyDirs(io, root_dir, project.empty_dirs, streams.err);
                receipt.print(streams, &repo, policy.merged.items, policy.conflicts.items, false);
                return policy.report(streams);
            }
        }
        return updateApplyFail(streams, target_id, err);
    };
    // A successful update stamps this tree into the store's worktree
    // registry (best-effort; discovery data never gates the update).
    cli.stampWorktreeRegistry(gpa, io, root_dir, root.abs);
    // After a SUCCESSFUL materialization only, and never on
    // --dry-run, which returned above.
    update.ensureEmptyDirs(io, root_dir, project.empty_dirs, streams.err);
    receipt.print(streams, &repo, policy.merged.items, policy.conflicts.items, false);
    return policy.report(streams);
}

/// One line of the update receipt: Fossil's vocabulary, so the
/// report reads the same to someone coming from `fossil update`.
const ReceiptOp = enum { update, add, remove, unchanged };

const ReceiptLine = struct { op: ReceiptOp, path: []const u8 };

const Receipt = struct {
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    from: []const u8,
    to: []const u8,
    lines: []const ReceiptLine,
    modified: usize,
    added: usize,
    removed: usize,

    /// Print to stderr: per-file lines (carried paths shown as MERGE,
    /// conflicted as CONFLICT, overriding the store's view), the from/to
    /// block, and the undo hint. stdout stays script-clean — the split
    /// `merge`'s conflict lines already use.
    fn print(self: *const Receipt, streams: cli.Streams, repo: *Repository, merged: []const []const u8, conflicts: []const []const u8, dry_run: bool) void {
        const e = streams.err;
        for (self.lines) |l| {
            const word: []const u8 = if (containsPath(conflicts, l.path)) "CONFLICT" else if (containsPath(merged, l.path)) "MERGE" else switch (l.op) {
                .update => "UPDATE",
                .add => "ADD",
                .remove => "REMOVE",
                .unchanged => "UNCHANGED",
            };
            e.print("{s} {s}\n", .{ word, l.path }) catch {};
        }
        // A carried path the store's diff did not list (a local edit to a
        // file the target leaves alone) still merged; say so.
        for (merged) |p| {
            if (!self.listed(p)) e.print("{s} {s}\n", .{ if (containsPath(conflicts, p)) "CONFLICT" else "MERGE", p }) catch {};
        }
        e.writeAll("-------------------------------------------------------------------------------\n") catch {};
        if (std.mem.eql(u8, self.from, self.to)) {
            // Fossil's no-op receipt: where you already are, not a from/to pair.
            e.print("checkout:     {s} {s}\n", .{ cli.shortHash(self.to), checkinDate(self.gpa, self.arena, repo, self.to) }) catch {};
        } else {
            if (self.from.len != 0) e.print("updated-from: {s} {s}\n", .{ cli.shortHash(self.from), checkinDate(self.gpa, self.arena, repo, self.from) }) catch {};
            e.print("updated-to:   {s} {s}\n", .{ cli.shortHash(self.to), checkinDate(self.gpa, self.arena, repo, self.to) }) catch {};
        }
        if (repo.lookup(self.to)) |rid| {
            e.print("tags:         {s}\n", .{repo.branchOf(rid)}) catch {};
            if (repo.manifest(self.gpa, rid) catch null) |om_const| {
                var om = om_const;
                defer om.deinit(self.gpa);
                const comment: []const u8 = if (om.manifest.c) |c| blk: {
                    const buf = self.arena.alloc(u8, c.comment.len) catch break :blk c.comment;
                    break :blk artifact.decodeEscapes(c.comment, buf);
                } else "";
                const user = if (om.manifest.u) |u| u.login else "";
                e.print("comment:      {s} (user: {s})\n", .{ comment, user }) catch {};
            }
        }
        if (std.mem.eql(u8, self.from, self.to)) {
            e.writeAll("changes:      none. Already up-to-date.\n") catch {};
        } else {
            // Fossil's sentence — `1 file modified.` — extended only when
            // something was added or removed.
            e.print("changes:      {d} file{s} modified", .{ self.modified, if (self.modified == 1) "" else "s" }) catch {};
            if (self.added != 0) e.print(", {d} added", .{self.added}) catch {};
            if (self.removed != 0) e.print(", {d} removed", .{self.removed}) catch {};
            e.writeAll(".\n") catch {};
        }
        if (dry_run) {
            e.writeAll("(dry run: nothing was changed)\n") catch {};
        } else if (!std.mem.eql(u8, self.from, self.to) or merged.len != 0) {
            e.writeAll(" \"zit undo\" is available to undo changes to the working checkout.\n") catch {};
        }
    }

    fn listed(self: *const Receipt, path: []const u8) bool {
        for (self.lines) |l| if (std.mem.eql(u8, l.path, path)) return true;
        return false;
    }
};

fn containsPath(list: []const []const u8, path: []const u8) bool {
    for (list) |p| if (std.mem.eql(u8, p, path)) return true;
    return false;
}

/// The check-in's D card as written, or "" when it cannot be read.
/// The check-in's time as the receipt says it: weekday first, like the
/// timeline and status block.
fn checkinDate(gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, hash: []const u8) []const u8 {
    _ = gpa;
    const rid = repo.lookup(hash) orelse return "";
    const mtime = repo.state().mtimeOf(rid) orelse return "";
    var buf: [32]u8 = undefined;
    return arena.dupe(u8, cli.humanTime(mtime, &buf)) catch "";
}

/// The store's view of the move: the target's file set against the current
/// check-in's, in canonical order. An unborn checkout (no `from`) lists every
/// target file as ADD.
fn updateReceipt(gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, from: []const u8, to: []const u8, verbose: bool) error{OutOfMemory}!Receipt {
    var lines: std.ArrayList(ReceiptLine) = .empty;
    var modified: usize = 0;
    var added: usize = 0;
    var removed: usize = 0;
    var old_cards: std.ArrayList(artifact.FCard) = .empty;
    var new_cards: std.ArrayList(artifact.FCard) = .empty;
    const same_checkin = std.mem.eql(u8, from, to);
    if (from.len != 0 and !same_checkin) {
        if (repo.lookup(from)) |rid| {
            if (repo.files(gpa, rid) catch null) |fl_const| {
                var fl = fl_const;
                defer fl.deinit(gpa);
                var it = fl.iterator();
                while (it.next()) |f| try old_cards.append(arena, try dupeCardInto(arena, f));
            }
        }
    }
    if (!same_checkin or verbose) {
        if (repo.lookup(to)) |rid| {
            if (repo.files(gpa, rid) catch null) |fl_const| {
                var fl = fl_const;
                defer fl.deinit(gpa);
                var it = fl.iterator();
                while (it.next()) |f| try new_cards.append(arena, try dupeCardInto(arena, f));
            }
        }
    }
    if (!same_checkin) {
        var i: usize = 0;
        var j: usize = 0;
        const o = old_cards.items;
        const n = new_cards.items;
        while (i < o.len or j < n.len) {
            if (j >= n.len or (i < o.len and std.mem.order(u8, o[i].filename, n[j].filename) == .lt)) {
                try lines.append(arena, .{ .op = .remove, .path = o[i].filename });
                removed += 1;
                i += 1;
            } else if (i >= o.len or std.mem.order(u8, o[i].filename, n[j].filename) == .gt) {
                try lines.append(arena, .{ .op = .add, .path = n[j].filename });
                added += 1;
                j += 1;
            } else {
                const same = std.mem.eql(u8, o[i].hash orelse "", n[j].hash orelse "") and std.mem.eql(u8, o[i].perm orelse "", n[j].perm orelse "");
                if (!same) {
                    try lines.append(arena, .{ .op = .update, .path = n[j].filename });
                    modified += 1;
                } else if (verbose) {
                    try lines.append(arena, .{ .op = .unchanged, .path = n[j].filename });
                }
                i += 1;
                j += 1;
            }
        }
    } else if (verbose) {
        for (new_cards.items) |c| try lines.append(arena, .{ .op = .unchanged, .path = c.filename });
    }
    return .{ .gpa = gpa, .arena = arena, .from = from, .to = to, .lines = lines.items, .modified = modified, .added = added, .removed = removed };
}

fn dupeCardInto(arena: std.mem.Allocator, f: artifact.FCard) error{OutOfMemory}!artifact.FCard {
    return .{
        .filename = try arena.dupe(u8, f.filename),
        .hash = if (f.hash) |h| try arena.dupe(u8, h) else null,
        .perm = if (f.perm) |p| try arena.dupe(u8, p) else null,
        .old_name = null,
        .mode = f.mode,
    };
}

/// The section 6.4 policy-form fetch for `update`: persist the REQUESTED
/// coverage as the store cone (a widened cone, or dense for --dense /
/// inherit-dense requests), then pull -- suppression and receive filtering
/// read the new policy. True on a completed pull; false (with the remedy
/// diagnostic) when no remote is saved.
/// The saved remote's URL, or null when none is stored. Mirrors the sync
/// verbs' resolver without importing their error mapping.
fn updateResolveRemote(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, buf: *[logstore.max_config_str]u8) ?[]const u8 {
    var file = cli.openStoreRead(io, dir) catch return null;
    defer file.close(io);
    const st = file.stat(io) catch return null;
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return null;
    defer log.deinit(gpa);
    var oc = (log.readConfig(gpa) catch return null) orelse return null;
    defer oc.deinit(gpa);
    const saved = oc.config.remote_url;
    if (saved.len == 0 or saved.len > buf.len) return null;
    @memcpy(buf[0..saved.len], saved);
    return buf[0..saved.len];
}

fn updateDemandFetch(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
    sparse: *const update.SparseOpt,
) bool {
    const requested: ?*const zit.cone.Cone = switch (sparse.*) {
        .set => |c| c,
        .inherit => blk: {
            // Inheriting on a sparse store and still missing content means
            // the WORKTREE cone exceeds the STORE cone (or the store lost
            // members); densify-to-worktree is not derivable here, so the
            // honest policy write is the worktree's own stored cone -- which
            // `applyCheckin` resolves itself. Densify fully instead: absent
            // a --cone, the operator asked for whatever the checkout wants,
            // and the store must at least hold that.
            break :blk null;
        },
    };
    var url_buf: [logstore.max_config_str]u8 = undefined;
    _ = updateResolveRemote(gpa, io, root_dir, &url_buf) orelse {
        streams.err.writeAll(
            "zit update: content is elided by the store's sparse cone and no remote is saved\n" ++
                "zit update: set one with 'zit remote URL', or re-clone with a wider --cone\n",
        ) catch {};
        return false;
    };
    streams.err.writeAll("zit update: content is elided by the store's sparse cone; fetching from the saved remote\n") catch {};
    zit.repocache.writeStoreCone(gpa, io, root_dir, requested);
    // A demand fetch has no ambient environment of its own; the stored login
    // still authenticates, and a prompt is never raised from here.
    const pull_exit = cmd_sync.runPull(gpa, io, arena, &.{}, streams, .{ .now_epoch = 0, .user_env = null });
    return pull_exit == .ok;
}

/// `zit cone [add PATH... | remove PATH...]` — show or edit the checkout's
/// sparse cone (sparse-cone RFC section 3). Pure sugar: the edit composes
/// the stored member set with the delta and drives `runUpdate` with
/// `--cone=` flags at the current check-in, so demand fetch (section 6.4),
/// dematerialization, and the store-policy-follows rule all come free and
/// stay in one code path.
pub fn runCone(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    if (verb_args.len >= 1 and (std.mem.eql(u8, verb_args[0], "-h") or std.mem.eql(u8, verb_args[0], "--help"))) {
        streams.out.writeAll(main.cone_help) catch {};
        return .ok;
    }

    const root = (cli.discoverRoot(arena, io) catch {
        streams.err.writeAll("zit cone: out of memory\n") catch {};
        return .failure;
    }) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // The worktree cone: the checkout container's section, absent = dense.
    var stored = update.readStoredCone(gpa, io, root_dir) catch {
        streams.err.writeAll("zit cone: cannot read the checkout state\n") catch {};
        return .failure;
    };
    defer if (stored) |*c| c.deinit(gpa);

    if (verb_args.len == 0) {
        if (stored) |*c| {
            for (c.members) |m| streams.out.print("{s}\n", .{m}) catch {};
            // The cone-file grammar's marker: `!member` is an exclusion.
            for (c.not_members) |m| streams.out.print("!{s}\n", .{m}) catch {};
        } else {
            streams.out.writeAll("dense\n") catch {};
        }
        return .ok;
    }

    const action = verb_args[0];
    const paths = verb_args[1..];
    if (std.mem.eql(u8, action, "test")) {
        return coneTest(gpa, io, arena, paths, streams, root_dir, &stored);
    }
    const adding = std.mem.eql(u8, action, "add");
    const removing = std.mem.eql(u8, action, "remove");
    if (!adding and !removing) return cli.usageError(streams, "cone", .bad_subcommand, action);
    if (paths.len == 0) return cli.usageError(streams, "cone", .no_paths, "");
    if (removing and stored == null) {
        streams.err.writeAll("zit cone: this checkout is dense; there is no cone to remove from\n") catch {};
        return .failure;
    }

    // Compose the new member set: stored ∪ paths, or stored ∖ paths. Bounded
    // by the cone cap; Cone.init re-validates and re-normalizes the result.
    var members: std.ArrayListUnmanaged([]const u8) = .empty;
    defer members.deinit(gpa);
    var not_members: std.ArrayListUnmanaged([]const u8) = .empty;
    defer not_members.deinit(gpa);
    if (stored) |*c| {
        for (c.members) |m| {
            if (removing) {
                var drop = false;
                for (paths) |p2| {
                    if (std.mem.eql(u8, m, p2)) drop = true;
                }
                if (drop) continue;
            }
            members.append(gpa, m) catch return coneOom(streams);
        }
        for (c.not_members) |m| {
            if (removing) {
                var drop = false;
                for (paths) |p2| {
                    if (std.mem.eql(u8, m, p2)) drop = true;
                }
                if (drop) continue;
            }
            not_members.append(gpa, m) catch return coneOom(streams);
        }
    }
    if (adding) {
        for (paths) |p2| members.append(gpa, p2) catch return coneOom(streams);
    }
    if (removing and members.items.len == 0) {
        streams.err.writeAll("zit cone: removing every member would leave an empty cone; use 'zit update --dense' to densify\n") catch {};
        return .failure;
    }
    if (members.items.len + not_members.items.len > zit.cone.max_members) {
        streams.err.print("zit cone: the cone holds at most {d} members\n", .{zit.cone.max_members}) catch {};
        return .failure;
    }

    // Resolve the current check-in and drive the one materialization path.
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const current = (checkout.read(io, root_dir, &ck_buf) catch {
        streams.err.writeAll("zit cone: cannot read the checkout state\n") catch {};
        return .failure;
    }) orelse {
        streams.err.writeAll("zit cone: no check-in is open\n") catch {};
        return .failure;
    };
    if (current.version.len == 0) {
        streams.err.writeAll("zit cone: no check-in is open\n") catch {};
        return .failure;
    }

    var update_args: std.ArrayListUnmanaged([]const u8) = .empty;
    defer update_args.deinit(gpa);
    for (members.items) |m| {
        const flag = std.fmt.allocPrint(arena, "--cone={s}", .{m}) catch return coneOom(streams);
        update_args.append(gpa, flag) catch return coneOom(streams);
    }
    for (not_members.items) |m| {
        const flag = std.fmt.allocPrint(arena, "--cone-not={s}", .{m}) catch return coneOom(streams);
        update_args.append(gpa, flag) catch return coneOom(streams);
    }
    const version_owned = arena.dupe(u8, current.version) catch return coneOom(streams);
    update_args.append(gpa, version_owned) catch return coneOom(streams);
    return runUpdate(gpa, io, arena, update_args.items, streams, amb);
}

fn coneOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit cone: out of memory\n") catch {};
    return .failure;
}

/// Largest `-` (stdin) path list `zit cone test` reads. Paths are not
/// members, so the cone's 255-member cap does not bound this list; the bound
/// exists so the read is finite by inspection rather than by trusting the
/// writer at the other end of the pipe. Generous enough for the full file
/// list of a very large tree.
const max_cone_test_stdin: usize = 8 * 1024 * 1024;

/// `zit cone test PATH...` — classify paths against a stored cone and
/// name the member that decided each, materializing nothing.
///
/// The verdict alone is all git's `sparse-checkout check-rules` can answer,
/// because every match there is a hash lookup against a directory set. Here
/// coverage is decided by the LONGEST member across both polarities, so the
/// deciding member is the real answer to "why isn't this file here" — and it
/// is exactly the token to hand `zit cone remove`, the usual next step. The
/// member prints bare for that reason: the verdict column already carries the
/// polarity (a named member under `elided` is necessarily an exclusion), so a
/// marker would only make the column unpasteable.
fn coneTest(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    args: []const []const u8,
    streams: cli.Streams,
    root_dir: std.Io.Dir,
    worktree_cone: *?zit.cone.Cone,
) cli.Exit {
    var use_store = false;
    var quiet = false;
    var read_stdin = false;
    var paths: std.ArrayListUnmanaged([]const u8) = .empty;
    defer paths.deinit(gpa);

    for (args) |a| {
        if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
            streams.out.writeAll(main.cone_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, a, "--store")) {
            use_store = true;
        } else if (std.mem.eql(u8, a, "--quiet")) {
            quiet = true;
        } else if (std.mem.eql(u8, a, "-")) {
            read_stdin = true;
        } else if (a.len >= 2 and a[0] == '-') {
            return cli.usageError(streams, "cone test", .unknown_option, a);
        } else {
            paths.append(gpa, a) catch return coneOom(streams);
        }
    }

    if (read_stdin) {
        const stdin = std.Io.File.stdin();
        var read_buf: [64 * 1024]u8 = undefined;
        var reader = stdin.readerStreaming(io, &read_buf);
        const blob = reader.interface.allocRemaining(arena, .limited(max_cone_test_stdin)) catch {
            streams.err.print(
                "zit cone test: cannot read paths from stdin, or more than {d} bytes were given\n",
                .{max_cone_test_stdin},
            ) catch {};
            return .failure;
        };
        var lines = std.mem.splitScalar(u8, blob, '\n');
        while (lines.next()) |raw| {
            // Only the line ending is stripped: a leading or trailing space
            // is part of the path, and validation is what refuses it.
            const line = std.mem.trimEnd(u8, raw, "\r");
            if (line.len == 0) continue; // A trailing newline is not a path.
            paths.append(gpa, line) catch return coneOom(streams);
        }
    }

    if (paths.items.len == 0) return cli.usageError(streams, "cone test", .no_paths, "");

    // Validated before any classification, so a path that could never be
    // tracked is a usage error rather than a silent `elided` (§3.2 defers
    // member validation to the tracked-path rules; the same rules apply to
    // what we classify).
    for (paths.items) |p| {
        zit.control_paths.validateWorktreePath(p) catch {
            streams.err.print(
                "zit cone test: not a repository-relative path: {s}\n",
                .{p},
            ) catch {};
            return .usage;
        };
    }

    // `--store` asks about the STORE cone, which is a different question:
    // a path present in the checkout but absent from the store is a real
    // condition, and the two cones need not agree.
    var store_cone = if (use_store) zit.repocache.readStoreCone(gpa, io, root_dir) else null;
    defer if (store_cone) |*c| c.deinit(gpa);
    const cone: ?*const zit.cone.Cone = if (use_store)
        (if (store_cone) |*c| c else null)
    else
        (if (worktree_cone.*) |*c| c else null);

    var width: usize = 0;
    for (paths.items) |p| width = @max(width, p.len);

    var any_elided = false;
    for (paths.items) |p| {
        // A dense cone elides nothing, so every path is covered and no member
        // decided it. Reporting that truthfully keeps the exit code usable as
        // a predicate: in a dense checkout the file really is there.
        const d: zit.cone.Decision = if (cone) |c| c.decide(p) else .{ .covered = true, .member = null };
        if (!d.covered) any_elided = true;
        if (quiet) continue;
        const verdict = if (d.covered) "covered" else "elided";
        const reason = if (d.member) |m| m else if (cone == null) "(dense)" else "(no member covers)";
        streams.out.print("{s}", .{verdict}) catch {};
        conePad(streams.out, verdict.len, 8);
        streams.out.print("{s}", .{p}) catch {};
        conePad(streams.out, p.len, width + 2);
        streams.out.print("{s}\n", .{reason}) catch {};
    }

    return if (any_elided) .failure else .ok;
}

/// Column padding for `cone test`, so the deciding member lines up under a
/// list of unequal paths. Widths are computed from the input, never fixed.
fn conePad(w: *std.Io.Writer, have: usize, want: usize) void {
    var i = have;
    while (i < want) : (i += 1) w.writeByte(' ') catch return;
}

/// Map a Cone.init refusal. The message states the rule, not a guess at the
/// operator's intent, and never says "missing" or "corrupt" (sparse-cone RFC
/// section 4.1 diagnostic discipline).
fn coneInitFail(s: cli.Streams, verb: []const u8, err: zit.cone.Error) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
        error.InvalidMember => s.err.print(
            "zit {s}: invalid --cone member: members are repository-relative paths using '/', with no '.', '..', or empty components\n",
            .{verb},
        ) catch {},
        error.ReservedMember => s.err.print("zit {s}: a --cone member names a reserved control path\n", .{verb}) catch {},
        error.Bounds => s.err.print("zit {s}: --cone needs between 1 and 255 members (exclusions included), at least one inclusion\n", .{verb}) catch {},
        error.DeadExclusion => s.err.print("zit {s}: a --cone-not member no --cone member covers is a dead rule\n", .{verb}) catch {},
        error.Contradiction => s.err.print("zit {s}: one path given as both --cone and --cone-not\n", .{verb}) catch {},
    }
    return .failure;
}

/// Resolve `update`'s check-in argument to a canonical artifact ID (SF1).
///
/// A canonical 40/64-hex ID short-circuits: it needs no repository state, so
/// update's cold path (open the log read-only, hand the ID to the update
/// engine) is byte-for-byte what it was. Anything else — `trunk`, `tip`, a hash
/// prefix, a date form — opens the repository read-only and runs the shared
/// §8.3 procedure, then hands back the arena-owned canonical ID. The
/// repository view is closed again before the update transaction opens the log,
/// so the two never hold it at once.
fn updateResolveTarget(
    arena: std.mem.Allocator,
    repo: *Repository,
    requested: []const u8,
    streams: cli.Streams,
) union(enum) { ok: []const u8, fail: cli.Exit } {
    if (artifact.validateName(requested)) return .{ .ok = requested };

    const rid = repo.resolve(requested, .checkin) catch |e| switch (e) {
        error.NotFound => {
            streams.err.print("zit update: check-in '{s}' not found\n", .{requested}) catch {};
            return .{ .fail = .failure };
        },
        error.Ambiguous => {
            streams.err.print("zit update: name '{s}' is ambiguous\n", .{requested}) catch {};
            return .{ .fail = .failure };
        },
        error.OutOfMemory => return .{ .fail = stateOom(streams, "update") },
    };
    // The hash borrows `repo`'s view; copy it out so it outlives the caller's
    // repository.
    const owned = arena.dupe(u8, repo.hashOf(rid)) catch
        return .{ .fail = stateOom(streams, "update") };
    std.debug.assert(artifact.validateName(owned));
    return .{ .ok = owned };
}

fn updateLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {},
        else => s.err.print("zit update: cannot read repository: {s}\n", .{reasonOf(err)}) catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit bisect SUBCOMMAND [NAME]` — binary-search the check-in DAG for the
// defect-introducing check-in (contracts/bisect.md B-bisect-1..20). The span
// math is ONE general-purpose repostate query (`Repository.bisectSpan`,
// ambiguities.md bisect-midpoint); moves ride the shipped update engine
// (`update.applyCheckin`, journal-armed); the session lives in `.zit-bisect`
// (the `undo.zig` state-file tier). This layer only orchestrates: parse, resolve,
// refuse, persist-then-move, announce.
// ==========================================================================

/// The 12-character display prefix of an artifact hash (timeline parity).
fn bisectShort(hash: []const u8) []const u8 {
    return hash[0..@min(hash.len, timeline_hash_display)];
}

fn bisectDiag(s: cli.Streams, msg: []const u8) cli.Exit {
    s.err.print("zit bisect: {s}\n", .{msg}) catch {};
    return .failure;
}

fn bisectOom(s: cli.Streams) cli.Exit {
    return bisectDiag(s, "out of memory");
}

fn bisectNoSession(s: cli.Streams) cli.Exit {
    return bisectDiag(s, "no bisect in progress");
}

fn bisectInconsistent(s: cli.Streams) cli.Exit {
    return bisectDiag(s, "bisect state names a check-in not in this repository; run 'zit bisect reset'");
}

/// Map a `.zit-bisect` read/write fault to its diagnostic. A corrupt file names
/// `reset` as the recovery (B-bisect-6), matching the `.zit-managed` parity.
fn bisectStateFail(s: cli.Streams, err: bisect.StateError) cli.Exit {
    switch (err) {
        error.OutOfMemory => return bisectOom(s),
        error.Storage => return bisectDiag(s, "cannot read bisect state: input/output error"),
        error.Corrupt => return bisectDiag(s, "cannot read bisect state: .zit-checkout is corrupt; run 'zit bisect reset' to recover"),
    }
}

/// Map an update-engine move fault to its diagnostic. The check-in id and
/// baseline arms are unreachable for a check-in this view already resolved, so
/// they fold into the generic corrupt-repository message.
///
/// Exhaustive over `update.ApplyError` with NO `else`: bisect drives the same
/// engine as update, open and merge, so it reaches the same errors they do, and
/// a blanket `else` here silently answered `StateRecordInvalid` — reachable on
/// an intact clone — with a corruption claim. A new variant must fail this
/// compile and force a decision, as it does at the other three mappers.
fn bisectMoveFail(s: cli.Streams, err: update.ApplyError) cli.Exit {
    switch (err) {
        // applyCheckin never emits this: only applyMerge's cross-cone guard
        // does, and bisect moves are checkin materializations. The unreachable
        // IS the assertion; a future emitter trips it in tests immediately.
        error.SparseMergeUnsupported => unreachable,
        error.OutOfMemory => return bisectOom(s),
        error.FileContentMissing => {
            var b: [512]u8 = undefined;
            return bisectDiag(s, update.stageFaultReason(&b));
        },
        error.WorktreeNotClean => return bisectDiag(s, "working tree is not clean"),
        error.UnmanagedCollision => return bisectDiag(s, "an untracked file or directory is in the way"),
        error.Storage => return bisectDiag(s, "cannot update working tree: input/output error"),
        error.TransactionUnavailable => return bisectDiag(s, "cannot create a transaction directory"),
        error.RollbackFailed => return bisectDiag(s, "move failed and could not be rolled back; the previous working-tree state is retained for recovery"),
        error.CleanupFailed => return bisectDiag(s, "move committed, but the transaction directory could not be removed"),
        error.StateRecordInvalid => return bisectDiag(s, "cannot read repository: " ++ update.state_record_invalid_reason),
        error.CurrentStateInvalid => return bisectDiag(s, "current working-tree state is invalid"),
        error.InvalidPath => return bisectDiag(s, "the check-in names an invalid file path"),
        error.ReservedPath => return bisectDiag(s, "the check-in names a reserved file path"),
        error.SymlinkUnsupported => return bisectDiag(s, "the check-in contains an unsupported symlink entry"),
        error.Corrupt,
        error.InvalidCheckinId,
        error.CheckinNotFound,
        error.CheckinContentMissing,
        error.CheckinNameMismatch,
        error.AggregateChecksumMismatch,
        error.TargetNotManifest,
        error.BaselineNotFound,
        error.BaselineContentMissing,
        error.BaselineNameMismatch,
        error.BaselineNotManifest,
        error.BaselineIsDelta,
        => return bisectDiag(s, "cannot read repository: repository is corrupt"),
    }
}

/// Resolve every stored mark hash to its rid. `Missing` == the session names a
/// hash this view never interned (an inconsistent state).
fn bisectRids(gpa: std.mem.Allocator, repo: *Repository, hashes: []const []const u8) error{ OutOfMemory, Missing }![]Repository.Rid {
    const out = gpa.alloc(Repository.Rid, hashes.len) catch return error.OutOfMemory;
    errdefer gpa.free(out);
    for (hashes, out) |h, *r| r.* = repo.lookup(h) orelse return error.Missing;
    return out;
}

/// Drop every occurrence of `hash` from `list` (content compare), so re-marking a
/// check-in moves it between the good/bad/skip sets rather than duplicating it.
fn bisectRemove(list: *std.ArrayList([]const u8), hash: []const u8) void {
    var i: usize = 0;
    while (i < list.items.len) {
        if (std.mem.eql(u8, list.items[i], hash)) {
            _ = list.orderedRemove(i);
        } else i += 1;
    }
}

// ===========================================================================
// `zit annotate PATH` (contracts/annotate.md B-annotate-1..16). Per-line
// provenance of one tracked file at the current check-out: which check-in
// introduced each line, and when. The version chain is the file's FULL ancestry
// materialized through `Repository.fileAt` (the §8 effective set) + `blob`, with
// adjacency from the repostate §3.3 parent query; adjacent versions are diffed
// by the ONE house line-diff core (`merge3.matchLines`); the pure `annotate`
// engine pushes origins backward per ambiguities.md `annotate-attribution`. This
// layer is only I/O + rendering — no second history walker, resolver, or diff.
// ===========================================================================

pub fn runBisect(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const parsed = switch (cli.parseBisectArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "bisect", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "bisect", .extra_argument, tok),
        .bad_subcommand => |tok| return cli.usageError(streams, "bisect", .bad_subcommand, tok),
        .missing_name => return cli.usageError(streams, "bisect", .no_name, ""),
    };
    if (parsed.help) {
        streams.out.writeAll(bisect_help) catch {};
        return .ok;
    }

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

    return switch (parsed.sub) {
        .bad => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .bad),
        .good => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .good),
        .next => bisectNext(gpa, io, &repo, root_dir, streams),
        .status => bisectStatus(gpa, io, &repo, root_dir, streams),
        .reset => bisectReset(gpa, io, &repo, root_dir, streams),
    };
}

/// `zit bisect bad|good NAME`: record the mark, then — once both a bad and a good
/// endpoint exist — narrow the span and move the checkout to the next probe
/// (B-bisect-1/3). A lone endpoint is recorded and confirmed without moving.
fn bisectMark(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
    name: []const u8,
    kind: cli.BisectSub,
) cli.Exit {
    const rid = repo.resolve(name, .checkin) catch |e| switch (e) {
        error.NotFound => {
            streams.err.print("zit bisect: check-in '{s}' not found\n", .{name}) catch {};
            return .failure;
        },
        error.Ambiguous => {
            streams.err.print("zit bisect: name '{s}' is ambiguous\n", .{name}) catch {};
            return .failure;
        },
        error.OutOfMemory => return stateOom(streams, "bisect"),
    };
    const hash = repo.hashOf(rid);

    var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
    defer if (sess_opt) |*s| s.deinit();

    var work = std.heap.ArenaAllocator.init(gpa);
    defer work.deinit();
    const a = work.allocator();

    var bad_list: std.ArrayList([]const u8) = .empty;
    var good_list: std.ArrayList([]const u8) = .empty;
    var skip_list: std.ArrayList([]const u8) = .empty;
    var start: []const u8 = "";
    if (sess_opt) |s| {
        start = s.start;
        for (s.bad) |x| bad_list.append(a, x) catch return bisectOom(streams);
        for (s.good) |x| good_list.append(a, x) catch return bisectOom(streams);
        for (s.skipped) |x| skip_list.append(a, x) catch return bisectOom(streams);
    } else {
        // Fresh session: the current checkout is the restore point for `reset`.
        var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        if (checkout.read(io, root_dir, &ck_buf) catch null) |ck| {
            start = a.dupe(u8, ck.version) catch return bisectOom(streams);
        }
    }

    const dh = a.dupe(u8, hash) catch return bisectOom(streams);
    bisectRemove(&bad_list, dh);
    bisectRemove(&good_list, dh);
    bisectRemove(&skip_list, dh);
    switch (kind) {
        .bad => bad_list.append(a, dh) catch return bisectOom(streams),
        .good => good_list.append(a, dh) catch return bisectOom(streams),
        else => unreachable,
    }

    const has_both = bad_list.items.len != 0 and good_list.items.len != 0;
    if (!has_both) {
        // Record the lone endpoint; no move (B-bisect-1 first half).
        bisect.write(gpa, io, root_dir, start, bad_list.items, good_list.items, skip_list.items) catch |e|
            return bisectStateFail(streams, e);
        streams.out.print("bisect: marked {s} {s}\n", .{ bisectShort(hash), @tagName(kind) }) catch {};
        const other = if (kind == .bad) "good" else "bad";
        streams.out.print("bisect: mark a {s} check-in to begin bisecting\n", .{other}) catch {};
        return .ok;
    }
    return bisectAdvance(gpa, io, repo, root_dir, streams, start, bad_list.items, good_list.items, skip_list.items, true);
}

/// Derive the span, refuse over a dirty tree / inverted / unrelated marks
/// (mutating nothing), then — only on success — persist the state and move the
/// checkout to the probe (or the culprit when the span is exhausted). Shared by
/// marking (persist == true) and `next` (persist == true, idempotent rewrite).
fn bisectAdvance(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
    start: []const u8,
    bad_hashes: []const []const u8,
    good_hashes: []const []const u8,
    skip_hashes: []const []const u8,
    persist: bool,
) cli.Exit {
    // Precondition: both endpoints exist — a caller only advances once a bad AND a
    // good mark are present (`bisectMark`'s `has_both`, `bisectNext`'s guard), and
    // `bisectSpan` needs both to bound the span.
    std.debug.assert(bad_hashes.len != 0 and good_hashes.len != 0);
    // Dirty-tree refusal first — the shipped update discipline (B-bisect-9),
    // mutating nothing.
    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "bisect", e);
    update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
        error.WorktreeNotClean => return bisectDiag(streams, "working tree is not clean"),
        else => return bisectMoveFail(streams, e),
    };

    const bad_rids = bisectRids(gpa, repo, bad_hashes) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(bad_rids);
    const good_rids = bisectRids(gpa, repo, good_hashes) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(good_rids);
    const skip_rids = bisectRids(gpa, repo, skip_hashes) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(skip_rids);

    const out = repo.bisectSpan(gpa, bad_rids, good_rids, skip_rids) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.InvertedMarks => return bisectDiag(streams, "the bad check-in is an ancestor of a good check-in; the marks are inverted"),
        error.UnrelatedHistories => return bisectDiag(streams, "the good and bad marks share no common history"),
    };

    // Persist the validated state durably BEFORE the move (B-bisect-6): a crash
    // leaves the marks recorded and the probe re-derivable.
    if (persist) {
        bisect.write(gpa, io, root_dir, start, bad_hashes, good_hashes, skip_hashes) catch |e|
            return bisectStateFail(streams, e);
    }

    if (out.probe) |probe| {
        const target = repo.hashOf(probe);
        update.applyCheckin(gpa, io, root_dir, log, target, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);
        streams.out.print(
            "bisect: {d} untested candidate(s) remain; testing {s}\n",
            .{ out.untested, bisectShort(target) },
        ) catch {};
        return .ok;
    }
    return bisectAnnounceCulprit(gpa, io, repo, root_dir, log, streams, out.culprit);
}

/// Termination (B-bisect-4): move the checkout to the culprit and announce it
/// with its comment line. The session stays recorded until `reset`.
fn bisectAnnounceCulprit(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    log: *logstore.LogStore,
    streams: cli.Streams,
    culprit: Repository.Rid,
) cli.Exit {
    const target = repo.hashOf(culprit);
    update.applyCheckin(gpa, io, root_dir, log, target, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);

    // The comment borrows the manifest bytes, so print before it is freed; the
    // override value borrows tagxref bytes valid for the repo's lifetime.
    var maybe = repo.manifest(gpa, culprit) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        else => return bisectDiag(streams, "cannot read repository: repository is corrupt"),
    };
    defer if (maybe) |*m| m.deinit(gpa);
    const manifest_comment: []const u8 = if (maybe) |m|
        (if (m.manifest.c) |c| firstCommentLine(c.comment) else "")
    else
        "";
    const comment = repo.override(culprit, .comment, manifest_comment);
    streams.out.print("bisect: {s} is the first bad check-in\n", .{bisectShort(target)}) catch {};
    streams.out.print("    {s}\n", .{comment}) catch {};
    return .ok;
}

/// `zit bisect next`: re-derive and re-announce the current probe, moving the
/// checkout to it. Refuses with no session or with only one endpoint (B-bisect-13).
fn bisectNext(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) cli.Exit {
    var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
    defer if (sess_opt) |*s| s.deinit();
    const s = sess_opt orelse return bisectNoSession(streams);
    if (s.bad.len == 0 or s.good.len == 0) {
        const missing = if (s.bad.len == 0) "bad" else "good";
        streams.err.print("zit bisect: mark a {s} check-in before running 'zit bisect next'\n", .{missing}) catch {};
        return .failure;
    }
    return bisectAdvance(gpa, io, repo, root_dir, streams, s.start, s.bad, s.good, s.skipped, false);
}

/// `zit bisect status`: report the marks, the current probe, and the remaining
/// count without moving (B-bisect-7). Never refuses on a dirty tree; with no
/// session it is exit 1, nothing mutated.
fn bisectStatus(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) cli.Exit {
    var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
    defer if (sess_opt) |*s| s.deinit();
    const s = sess_opt orelse return bisectNoSession(streams);

    for (s.bad) |h| streams.out.print("bisect: bad  {s}\n", .{bisectShort(h)}) catch {};
    for (s.good) |h| streams.out.print("bisect: good {s}\n", .{bisectShort(h)}) catch {};
    for (s.skipped) |h| streams.out.print("bisect: skip {s}\n", .{bisectShort(h)}) catch {};

    if (s.bad.len == 0 or s.good.len == 0) {
        const other = if (s.bad.len == 0) "bad" else "good";
        streams.out.print("bisect: mark a {s} check-in to begin bisecting\n", .{other}) catch {};
        return .ok;
    }

    const bad_rids = bisectRids(gpa, repo, s.bad) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(bad_rids);
    const good_rids = bisectRids(gpa, repo, s.good) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(good_rids);
    const skip_rids = bisectRids(gpa, repo, s.skipped) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.Missing => return bisectInconsistent(streams),
    };
    defer gpa.free(skip_rids);

    const out = repo.bisectSpan(gpa, bad_rids, good_rids, skip_rids) catch |e| switch (e) {
        error.OutOfMemory => return bisectOom(streams),
        error.InvertedMarks => return bisectDiag(streams, "the bad check-in is an ancestor of a good check-in; the marks are inverted"),
        error.UnrelatedHistories => return bisectDiag(streams, "the good and bad marks share no common history"),
    };
    if (out.probe) |probe| {
        streams.out.print(
            "bisect: testing {s} ({d} untested candidate(s) remain)\n",
            .{ bisectShort(repo.hashOf(probe)), out.untested },
        ) catch {};
    } else {
        streams.out.print("bisect: {s} is the first bad check-in\n", .{bisectShort(repo.hashOf(out.culprit))}) catch {};
    }
    return .ok;
}

/// `zit bisect reset`: restore the starting check-in, remove `.zit-bisect`, exit 0
/// (B-bisect-8). No session is exit 1; a corrupt file is cleared (reset is the
/// advertised recovery, B-bisect-6). The restore move rides the update engine and
/// is refused over a dirty tree (B-bisect-9).
fn bisectReset(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) cli.Exit {
    var sess_opt = bisect.read(gpa, io, root_dir) catch |e| switch (e) {
        error.Corrupt => {
            // reset IS the recovery: clear the damaged file rather than deadlock.
            bisect.clear(io, root_dir);
            streams.out.writeAll("bisect: cleared corrupt bisect state (working tree not restored)\n") catch {};
            return .ok;
        },
        else => return bisectStateFail(streams, e),
    };
    defer if (sess_opt) |*s| s.deinit();
    const s = sess_opt orelse return bisectNoSession(streams);

    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "bisect", e);
    update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
        error.WorktreeNotClean => return bisectDiag(streams, "working tree is not clean"),
        else => return bisectMoveFail(streams, e),
    };
    if (s.start.len != 0) {
        update.applyCheckin(gpa, io, root_dir, log, s.start, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);
        bisect.clear(io, root_dir);
        streams.out.print("bisect: reset; working tree restored to {s}\n", .{bisectShort(s.start)}) catch {};
    } else {
        bisect.clear(io, root_dir);
        streams.out.writeAll("bisect: reset\n") catch {};
    }
    return .ok;
}

// ==========================================================================
// `zit merge NAME` — compute a line-based three-way merge of NAME (the "other"
// side) into the working tree and record it as a pending merge parent. Thin
// orchestration: resolve + refusal gate + pure `merge3` classification, then
// `update.applyMerge` materializes and records the parent. It does NOT mint a
// check-in — `zit commit` does that from the recorded state (contracts/merge.md
// B-merge-1..29). Conflicts are written in-tree with markers, listed on stderr,
// exit 1 — but the merge parent is still recorded (`zit undo` is the escape).
// ==========================================================================

pub fn runMerge(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    return runMergeIn(gpa, io, arena, verb_args, streams, amb, null);
}

/// `merge`, against a checkout this caller NAMES rather than the one the
/// process happens to be standing in.
///
/// `checkout_root` null is the verb: discover from the cwd. Non-null is the
/// in-process caller (`landing accept`) driving an ephemeral checkout it
/// created itself -- the merge engine below already takes its worktree as a
/// parameter, so the only thing that was ever implicit here is WHICH tree, and
/// this makes that explicit instead of mutating the process's cwd to say it.
///
/// The dir is BORROWED: the caller opened it and closes it.
pub const NamedCheckout = struct { dir: std.Io.Dir, abs: []const u8 };

pub fn runMergeIn(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
    checkout_root: ?NamedCheckout,
) cli.Exit {
    // 1. Parse the one-positional NAME plus `-h`/`--help` and the two mutually-
    //    exclusive baseline flags `--cherrypick`/`--backout` (B-merge-25/26/40).
    //    Help wins; an unknown option, a second positional, or both flags is a
    //    usage error (exit 2). A missing NAME is a usage error — NAME is
    //    mandatory (unlike `update`, which defaults to tip). The flagless grammar
    //    stays byte-identical to the shipped surface (B-merge-31).
    const parsed = switch (cli.parseMergeArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "merge", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "merge", .extra_argument, tok),
        .bad_strategy => |tok| {
            if (tok.len == 0) {
                streams.err.writeAll("zit merge: -X needs a strategy: ours or theirs\n") catch {};
            } else {
                streams.err.print(
                    "zit merge: '{s}' is not a merge strategy; use ours or theirs\n",
                    .{tok},
                ) catch {};
            }
            streams.err.writeAll("try 'zit merge --help'\n") catch {};
            return .usage;
        },
        .conflicting_flags => {
            streams.err.writeAll(
                "zit merge: --cherrypick and --backout cannot be combined\n",
            ) catch {};
            streams.err.writeAll("try 'zit merge --help'\n") catch {};
            return .usage;
        },
    };
    if (parsed.help) {
        streams.out.writeAll(merge_help) catch {};
        return .ok;
    }
    // With no NAME, a real merge takes the one other open leaf
    // (resolved once the repository is open); a probe still needs a name.
    var name: []const u8 = parsed.checkin orelse "";
    if (name.len == 0 and (parsed.probe or parsed.special != null)) return cli.usageError(streams, "merge", .no_name, "");
    // `-X ours|theirs`. Absent means the shipped behaviour: a
    // content conflict is written as markers and left for a human. The parser
    // has already refused anything that is neither, so this mapping is total.
    const strategy: merge3.Strategy = if (parsed.strategy) |sname|
        (if (std.mem.eql(u8, sname, "ours")) .ours else .theirs)
    else
        .conflict;
    // `--probe` answers "would this merge cleanly?" and writes NOTHING.
    // It is a READ, so it takes `-R`, needs no checkout, and never autosyncs:
    // a caller asking a question must not have its store mutated by the asking.
    // Everything below this line is the writing verb.
    if (parsed.probe)
        return runMergeProbe(gpa, io, arena, parsed, streams, amb, checkout_root);

    // `-R` is granted to this verb for `--probe` only. A real merge
    // writes a working tree, so it is refused BY NAME here rather than left to
    // fail as a discovery miss -- which would tell the operator to pass `-R`
    // when they just did.
    if (checkout_root == null and amb.repo_override != null) {
        streams.err.writeAll(
            "zit merge: -R names a repository with no working tree to merge into; " ++
                "run it in a checkout, or ask with --probe\n",
        ) catch {};
        return .failure;
    }

    // Autosync pull, before resolution — same reasoning as `update`'s.
    if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "merge");

    // 2. Open the repository through the read-only facade (B-merge-27): a missing
    //    repo / read fault is exit 1, distinct from the exit-2 usage class, and
    //    the repository check precedes any resolution or working-tree read.
    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    var owns_root = false;
    if (checkout_root) |given| {
        // A named checkout: open the repository THROUGH it, exactly as
        // discovery does, so a store-relocated worktree resolves its store the
        // same way (`checkout.storeDirOf`). No second resolution rule.
        repo.open(gpa, io, given.dir, .{ .bypass_cache = false, .mode = .full }) catch |e| {
            if (repo.view.fault) |f| return main.repoDeriveFault(streams, "merge", f);
            return repoOpenFail(streams, "merge", e);
        };
        root_dir = given.dir;
    } else {
        const rc = openRepo(gpa, io, arena, streams, "merge", null, false, false, &repo, &root_dir);
        if (rc != .ok) return rc;
        owns_root = true;
    }
    defer if (owns_root) root_dir.close(io);
    defer repo.deinit(gpa);

    // 3. Resolve NAME (repo-state §8.3) → the "other" side. Unresolvable or
    //    ambiguous is a runtime failure (exit 1, B-merge-18), nothing mutated.
    if (name.len == 0) {
        name = soleOtherOpenLeaf(gpa, arena, &repo, streams) orelse return .failure;
    }
    const other = repo.resolve(name, .checkin) catch |e| {
        switch (e) {
            error.NotFound => streams.err.print("zit merge: check-in '{s}' not found\n", .{name}) catch {},
            error.Ambiguous => streams.err.print("zit merge: name '{s}' is ambiguous\n", .{name}) catch {},
            error.OutOfMemory => return stateOom(streams, "merge"),
        }
        return .failure;
    };

    // 4. The current checkout is the merge's "current" side. An unborn checkout
    //    has nothing to merge into.
    const current = repo.currentCheckoutRid() orelse {
        streams.err.writeAll("zit merge: no current check-in to merge into\n") catch {};
        return .failure;
    };

    // 5. Refusal gate — nothing mutated on any refusal (all checked BEFORE
    //    `applyMerge`). Read the checkout for a pending-merge marker FIRST: an
    //    unresolved prior merge leaves conflict markers in the tree, so a bare
    //    dirty-tree check would misreport it — the pending check must precede it
    //    (B-merge-15).
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch |e|
        return checkoutFail(streams, "merge", e)) orelse {
        streams.err.writeAll("zit merge: no current check-in to merge into\n") catch {};
        return .failure;
    };
    if (ck.merge_parent.len != 0 or ck.special_op.len != 0) {
        streams.err.writeAll(
            "zit merge: a merge is already pending; resolve and commit it, or run 'zit undo'\n",
        ) catch {};
        return .failure;
    }

    // 6. The working tree's own side. B-merge-14 used to refuse a
    //    dirty tree here, because the merge computed over the checkout's
    //    EFFECTIVE content — what the check-in says is on disk — and a dirty
    //    tree contradicts that. The fix is not to relax the check but to stop
    //    needing it: the current side becomes the working tree, so there is
    //    nothing left to disagree with. A clean tree scans to no divergences and
    //    every side is read exactly as before.
    //
    //    Scanned before the ancestry gates, in the position the refusal held, so
    //    an unreadable tree still reports before an up-to-date/unrelated verdict
    //    can mask it (plan §3 order).
    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "merge", e);
    const local = switch (localSide(gpa, io, arena, root_dir, log, streams)) {
        .ok => |l| l,
        .fail => |exit| return exit,
    };

    // 6b. Special merges (merge-flags.md) choose an ALTERNATE baseline inside
    //     this same machinery — cherrypick applies REV's own change, backout
    //     reverses it — so they skip the plain-merge ancestry gates (up-to-date /
    //     unrelated-histories / merge-base) entirely and route straight through
    //     `classifyFile`/`applyMerge` with base/other picked per the flag. A root
    //     REV (no primary parent) uses the EMPTY file set for its parent side, so
    //     no refusal is needed (error defined out of existence).
    if (parsed.special) |sp| {
        const rev = other;
        const pp = repo.state().primaryParentOf(rev);
        const sel = specialBaseline(sp, rev, pp);
        const rev_hash = repo.hashOf(rev);
        const sign: u8 = if (sp == .cherrypick) '+' else '-';
        // The pending op recorded in checkout state: the q-sign byte glued onto
        // REV's hash (checkout plan E `special_op`). Arena-owned; outlives applyMerge.
        const special_op = std.fmt.allocPrint(arena, "{c}{s}", .{ sign, rev_hash }) catch
            return mergeOom(streams);
        return computeAndApplyMerge(gpa, io, arena, &repo, root_dir, sel.base, current, sel.other, ck, .{
            .merge_parent = "",
            .special_op = special_op,
            .report_verb = if (sp == .cherrypick) "cherry-picked" else "backed out",
            .report_hash = rev_hash,
            .refuse_empty = true,
            .use_precedent = !parsed.no_precedent,
        }, strategy, local, streams);
    }

    // 7. Up to date (B-merge-16): NAME is an ancestor of the checkout, itself
    //    included → the checkout already contains it, refuse. This is the pure
    //    §3.3 full-ancestry membership test (`isAncestorOf`), mtime-INDEPENDENT —
    //    NOT `base == other`, which the merge-base mtime tiebreak defeats when
    //    `other` ties another ancestor on mtime and loses the least-hash
    //    selection: the base is then a different node, the equality misses, and
    //    an ancestor would wrongly be merged as a parent. Self-membership
    //    subsumes the self-merge case (other == current).
    if (repo.isAncestorOf(gpa, other, current) catch return mergeOom(streams)) {
        streams.err.writeAll("zit merge: already up to date; nothing to merge\n") catch {};
        return .failure;
    }

    // 8. Merge base over FULL ancestry (repo-state `mergeBaseOf`; ambiguities.md
    //    merge-base). No common ancestor → unrelated histories, refuse
    //    (B-merge-17). The up-to-date case (NAME an ancestor of the checkout) was
    //    already refused in step 7 by the mtime-independent ancestry test, so
    //    `base` here is strictly the three-way merge base.
    const base = blk: {
        // --baseline: the operator names the common ancestor.
        if (parsed.baseline) |bn| break :blk repo.resolve(bn, .checkin) catch |e| {
            switch (e) {
                error.NotFound => streams.err.print("zit merge: baseline '{s}' not found\n", .{bn}) catch {},
                error.Ambiguous => streams.err.print("zit merge: baseline '{s}' is ambiguous\n", .{bn}) catch {},
                error.OutOfMemory => return mergeOom(streams),
            }
            return .failure;
        };
        break :blk (repo.mergeBaseOf(gpa, current, other) catch return mergeOom(streams)) orelse {
            streams.err.print(
                "zit merge: '{s}' shares no history with the current check-in\n",
                .{name},
            ) catch {};
            return .failure;
        };
    };

    // 9. Compute the outcome set purely and materialize it, recording other's
    //    hash as the pending merge parent. Reports last.
    return computeAndApplyMerge(gpa, io, arena, &repo, root_dir, base, current, other, ck, .{
        .merge_parent = repo.hashOf(other),
        .special_op = "",
        .integrate = if (parsed.integrate) repo.hashOf(other) else "",
        .report_verb = "merged",
        .report_hash = repo.hashOf(other),
        .refuse_empty = false,
        .use_precedent = !parsed.no_precedent,
    }, strategy, local, streams);
}

/// The hash of the one open leaf that is not the current check-in,
/// or null (with a diagnostic) when there are none or several.
fn soleOtherOpenLeaf(gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, streams: cli.Streams) ?[]const u8 {
    const current = repo.currentCheckoutRid() orelse {
        streams.err.writeAll("zit merge: no NAME given and no current check-in to measure against\n") catch {};
        return null;
    };
    var found: std.ArrayList(Repository.Rid) = .empty;
    defer found.deinit(gpa);
    var it = repo.view.state.leaves();
    while (it.next()) |rid| {
        if (rid == current) continue;
        if (!repo.view.state.isOpenLeaf(rid)) continue;
        found.append(gpa, rid) catch {
            streams.err.writeAll("zit merge: out of memory\n") catch {};
            return null;
        };
    }
    if (found.items.len == 1) return arena.dupe(u8, repo.hashOf(found.items[0])) catch null;
    if (found.items.len == 0) {
        streams.err.writeAll("zit merge: no NAME given and no other open leaf to merge\n") catch {};
        return null;
    }
    streams.err.print("zit merge: no NAME given and {d} other open leaves; name one:\n", .{found.items.len}) catch {};
    for (found.items) |rid| streams.err.print("  {s} ({s})\n", .{ repo.hashOf(rid)[0..12], repo.branchOf(rid) }) catch {};
    return null;
}

fn updateOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit update: out of memory\n") catch {};
    return .failure;
}

fn updateCorrupt(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit update: cannot read repository: repository is corrupt\n") catch {};
    return .failure;
}

/// The `update` half of `mergeFilesFail`: a resolved name whose manifests do
/// not enumerate is a data fault, not a usage one.
fn updateFilesFail(s: cli.Streams, err: Repository.FilesError) cli.Exit {
    switch (err) {
        error.OutOfMemory => return updateOom(s),
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.writeAll("zit update: cannot read repository: input/output error\n") catch {},
        error.Corrupt,
        error.NotManifest,
        error.BaselineUnresolvable,
        error.BaselineIsDelta,
        => return updateCorrupt(s),
    }
    return .failure;
}

/// The merge policy behind `update`'s `CarryResolver`: what uncommitted
/// work becomes when the checkout moves to another check-in.
///
/// This is the same three-way merge `zit merge` runs, with the sides picked for
/// a move rather than a join: base = the check-in the tree is on, ours = the
/// working file, theirs = the target. The result is NOT a merge in the recorded
/// sense — no merge parent is written, because nothing is being joined. The
/// operator's edits simply land on top of the new check-in, which is what makes
/// the subsequent commit an ordinary child of the tip instead of a fork.
///
/// Lives on the CLI side of the seam because it needs the repository and the
/// merge engine; `update.applyCheckinWith` owns the scan and the transaction
/// and needs neither.
const CarryPolicy = struct {
    gpa: std.mem.Allocator,
    io: std.Io,
    /// Owns every byte slice handed back as a carry, so all of them outlive the
    /// materialization that writes them.
    arena: std.mem.Allocator,
    repo: *Repository,
    root_dir: std.Io.Dir,
    target_id: []const u8,
    streams: cli.Streams,

    /// Paths whose merge produced conflict markers. Reported after the update
    /// commits, not refused: the markers are in the working files to settle.
    conflicts: std.ArrayList([]const u8) = .empty,
    /// Every path carried across the move (the receipt's MERGE lines).
    merged: std.ArrayList([]const u8) = .empty,
    /// How many paths were carried, for the success line.
    carried: usize = 0,
    /// Set when `resolve` returned `Refused`, so the caller knows the paths were
    /// already named and does not print a second, generic diagnostic over them.
    refused: bool = false,

    fn resolver(self: *CarryPolicy) update.CarryResolver {
        return .{ .ctx = self, .resolve = resolve };
    }

    fn resolve(
        ctx: *anyopaque,
        divergences: []const update.Divergence,
    ) error{ OutOfMemory, Refused }![]const update.Carry {
        const self: *CarryPolicy = @ptrCast(@alignCast(ctx));
        return self.build(divergences) catch |err| switch (err) {
            error.OutOfMemory => error.OutOfMemory,
            error.Refused => {
                self.refused = true;
                return error.Refused;
            },
        };
    }

    fn build(
        self: *CarryPolicy,
        divergences: []const update.Divergence,
    ) error{ OutOfMemory, Refused }![]const update.Carry {
        // The two check-ins bracketing the move. A checkout with no current
        // check-in cannot be dirty against one — the scan reports every managed
        // path as `added` — so the base side is simply the empty file set.
        const target_rid = self.repo.resolve(self.target_id, .checkin) catch return self.fault("repository is corrupt");
        var base_files: ?Repository.FileList = if (self.repo.currentCheckoutRid()) |rid|
            (self.repo.files(self.gpa, rid) catch return self.fault("repository is corrupt")) orelse
                return self.fault("repository is corrupt")
        else
            null;
        defer if (base_files) |*l| l.deinit(self.gpa);
        var target_files = (self.repo.files(self.gpa, target_rid) catch return self.fault("repository is corrupt")) orelse
            return self.fault("repository is corrupt");
        defer target_files.deinit(self.gpa);

        var base_by_path = std.StringHashMap(artifact.FCard).init(self.arena);
        if (base_files) |*l| {
            var bit = l.iterator();
            while (bit.next()) |f| try base_by_path.put(f.filename, f);
        }
        var target_by_path = std.StringHashMap(artifact.FCard).init(self.arena);
        {
            var tit = target_files.iterator();
            while (tit.next()) |f| try target_by_path.put(f.filename, f);
        }

        var carry: std.ArrayList(update.Carry) = .empty;
        var unmergeable: std.ArrayList([]const u8) = .empty;

        for (divergences) |d| {
            // Own the path up front. It arrives borrowing storage that dies
            // with the scan, and both the carry list (read during
            // materialization) and the conflict list (read after the whole call
            // returns) outlive that. One dupe per DIVERGENT path only, so a
            // clean-ish tree pays nothing.
            const path = try self.arena.dupe(u8, d.path);

            // Ours: what the operator has right now. A `removed`/`missing` path
            // is absent on our side by definition and is not re-read.
            const ours: update.WorktreeContent = switch (d.kind) {
                .missing, .removed => .absent,
                .modified, .added => update.readWorktreeContent(self.arena, self.io, self.root_dir, path) catch
                    return self.fault("cannot read the working tree"),
            };
            if (ours == .unsupported) {
                try unmergeable.append(self.arena, path);
                continue;
            }
            const ours_side: merge3.Side = switch (ours) {
                .content => |c| c.bytes,
                .absent => null,
                .unsupported => unreachable,
            };
            const before: ?update.Carry.Content = switch (ours) {
                .content => |c| c,
                .absent, .unsupported => null,
            };

            const base_side: merge3.Side = if (base_by_path.get(path)) |f|
                (self.repo.blob(self.arena, f.hash.?) catch return self.fault("repository is corrupt")) orelse
                    return self.fault("repository is corrupt")
            else
                null;
            const target_card = target_by_path.get(path);
            const theirs_side: merge3.Side = if (target_card) |f|
                (self.repo.blob(self.arena, f.hash.?) catch return self.fault("repository is corrupt")) orelse
                    return self.fault("repository is corrupt")
            else
                null;

            // B-merge-23: the working file keeps its own mode. When the path is
            // absent on our side the target's is the only one there.
            const mode: artifact.FileMode = if (before) |c|
                c.mode
            else if (target_card) |f|
                (if (f.perm != null and std.mem.eql(u8, f.perm.?, "x")) .executable else .regular)
            else
                .regular;

            const outcome = try merge3.classifyFile(self.arena, base_side, ours_side, theirs_side, .conflict);
            const after: ?update.Carry.Content = switch (outcome) {
                // Our side is already correct — which for a dirty tree means the
                // file on disk stays exactly as the operator left it.
                .unchanged => before,
                .take_other => if (theirs_side) |b| .{ .bytes = b, .mode = mode } else null,
                .delete => null,
                .clean_merge => |r| .{ .bytes = r.bytes, .mode = mode },
                .text_conflict => |r| blk: {
                    try self.conflicts.append(self.arena, path);
                    break :blk .{ .bytes = r.bytes, .mode = mode };
                },
                .whole_file_conflict => blk: {
                    // A type change, or edited-here/deleted-there. Either
                    // reading silently destroys work, so it is named and
                    // refused rather than guessed at — the line `merge` draws.
                    if (ours_side == null or theirs_side == null) {
                        try unmergeable.append(self.arena, path);
                        break :blk before;
                    }
                    try self.conflicts.append(self.arena, path);
                    break :blk .{ .bytes = try merge3.writeEnvelope(self.arena, ours_side, base_side, theirs_side), .mode = mode };
                },
            };
            try carry.append(self.arena, .{ .path = path, .after = after, .before = before });
            try self.merged.append(self.arena, path);
        }

        // Refuse before the update mutates anything, and name the paths. Already
        // ascending: the divergence list is sorted and this walks it in order.
        if (unmergeable.items.len != 0) {
            for (unmergeable.items) |p| {
                self.streams.err.print("zit update: cannot merge local changes to {s}\n", .{p}) catch {};
            }
            self.streams.err.print(
                "zit update: {d} path(s) changed here and changed type or were removed there;\n" ++
                    "commit or revert them, then update again\n",
                .{unmergeable.items.len},
            ) catch {};
            return error.Refused;
        }

        self.carried = carry.items.len;
        return carry.items;
    }

    /// A read fault mid-merge: report it, then refuse. `Refused` (not a raw
    /// error) so the update aborts down the one path that has already
    /// guaranteed nothing was mutated.
    fn fault(self: *CarryPolicy, reason: []const u8) error{Refused} {
        self.streams.err.print("zit update: cannot merge local changes: {s}\n", .{reason}) catch {};
        return error.Refused;
    }

    /// What a carrying update says when it is done.
    ///
    /// Silence on a clean tree: an update that carried nothing is the update
    /// that always was, and its success has never printed anything. Otherwise
    /// the carried count goes to stdout, and any conflict goes to stderr with
    /// exit 1 — the same split and status `merge` uses, because the operator is
    /// in the same position either way.
    fn report(self: *const CarryPolicy, streams: cli.Streams) cli.Exit {
        if (self.carried == 0) return .ok;
        if (self.conflicts.items.len != 0) {
            for (self.conflicts.items) |p| {
                streams.err.print("zit update: conflict in {s}\n", .{p}) catch {};
            }
            streams.err.print(
                "zit update: {d} conflict(s); resolve them and commit\n",
                .{self.conflicts.items.len},
            ) catch {};
            return .failure;
        }
        streams.out.print("carried {d} local change(s) onto the new check-in\n", .{self.carried}) catch {};
        return .ok;
    }
};

/// What the WORKING TREE holds for each path that diverges from the check-in
/// the checkout is on. Empty on a clean tree, which is why every
/// clean merge reads exactly the sides it always did.
///
/// `merge`'s "current" side is the checkout. Before this, that meant the
/// check-in's stored content, and a dirty tree was refused because the two
/// disagreed. Now a divergent path answers from disk instead, which is what the
/// operator actually has and what a three-way merge is supposed to compare.
const LocalSide = struct {
    /// Divergent path -> the bytes on disk, or `.absent` where the file is gone.
    /// A path absent from this map is clean: its content IS the check-in's.
    by_path: std.StringHashMap(update.WorktreeContent),

    fn currentOf(self: *const LocalSide, path: []const u8) ?update.WorktreeContent {
        return self.by_path.get(path);
    }
};

/// Scan the working tree and read back every divergent path. Refuses, naming
/// the paths, when one holds something a three-way merge has no bytes for.
fn localSide(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    log: *logstore.LogStore,
    streams: cli.Streams,
) union(enum) { ok: LocalSide, fail: cli.Exit } {
    var out = LocalSide{ .by_path = std.StringHashMap(update.WorktreeContent).init(arena) };
    const divergences = update.scanWorktree(gpa, arena, io, root_dir, log) catch |e|
        return .{ .fail = mergeApplyFail(streams, e) };
    if (divergences.len == 0) return .{ .ok = out };

    var unsupported: std.ArrayList([]const u8) = .empty;
    for (divergences) |d| {
        const content: update.WorktreeContent = switch (d.kind) {
            // Absent on our side by definition; not re-read.
            .missing, .removed => .absent,
            .modified, .added => update.readWorktreeContent(arena, io, root_dir, d.path) catch |e|
                return .{ .fail = mergeApplyFail(streams, e) },
        };
        if (content == .unsupported) {
            unsupported.append(arena, d.path) catch return .{ .fail = mergeOom(streams) };
            continue;
        }
        out.by_path.put(d.path, content) catch return .{ .fail = mergeOom(streams) };
    }

    // A symlink, a directory, or an oversized file where a regular file's bytes
    // are needed. Refused before any mutation and named, rather than guessed at.
    if (unsupported.items.len != 0) {
        for (unsupported.items) |p| {
            streams.err.print("zit merge: cannot merge local changes to {s}\n", .{p}) catch {};
        }
        streams.err.print(
            "zit merge: {d} local change(s) cannot be merged; commit or revert them first\n",
            .{unsupported.items.len},
        ) catch {};
        return .{ .fail = .failure };
    }
    return .{ .ok = out };
}

/// The alternate baseline a special merge computes over (merge-flags.md
/// semantics): with REV's primary parent PP —
///   `--cherrypick`: base = PP, other = REV — applies REV's own change.
///   `--backout`:    base = REV, other = PP — applies its reverse.
/// A root REV has `pp == null`, so the parent side is null → the EMPTY file set
/// (error defined out of existence). Pure: the whole baseline decision, unit-
/// testable on rids alone.
const SpecialBaseline = struct { base: ?repostate.Rid, other: ?repostate.Rid };
fn specialBaseline(sp: cli.SpecialMerge, rev: repostate.Rid, pp: ?repostate.Rid) SpecialBaseline {
    return switch (sp) {
        .cherrypick => .{ .base = pp, .other = rev },
        .backout => .{ .base = rev, .other = pp },
    };
}

/// What `computeAndApplyMerge` records and reports: the pending checkout fields
/// (exactly one of `merge_parent`/`special_op` is non-empty), the success-line
/// verb/hash, and whether a no-op application refuses (special merges) versus
/// falls through as a zero-change merge (plain merges, unchanged behavior).
const MergeApply = struct {
    merge_parent: []const u8,
    special_op: []const u8,
    report_verb: []const u8,
    report_hash: []const u8,
    refuse_empty: bool,
    /// False when `--no-precedent` declined replay for this invocation.
    use_precedent: bool = true,
    /// --integrate: the merged leaf's hash, closed at commit.
    integrate: []const u8 = "",
};

test "specialBaseline: cherrypick is PP→REV, backout is REV→PP (merge-flags.md)" {
    const rev: repostate.Rid = 7;
    const pp: repostate.Rid = 3;
    // cherrypick applies REV's own change: base = its primary parent, other = REV.
    const cp = specialBaseline(.cherrypick, rev, pp);
    try std.testing.expectEqual(@as(?repostate.Rid, pp), cp.base);
    try std.testing.expectEqual(@as(?repostate.Rid, rev), cp.other);
    // backout reverses it: base = REV, other = its primary parent.
    const bo = specialBaseline(.backout, rev, pp);
    try std.testing.expectEqual(@as(?repostate.Rid, rev), bo.base);
    try std.testing.expectEqual(@as(?repostate.Rid, pp), bo.other);
    // A root REV (no primary parent): the parent side is the EMPTY file set
    // (null) — cherrypick's base and backout's other. No refusal needed.
    const cp_root = specialBaseline(.cherrypick, rev, null);
    try std.testing.expectEqual(@as(?repostate.Rid, null), cp_root.base);
    try std.testing.expectEqual(@as(?repostate.Rid, rev), cp_root.other);
    const bo_root = specialBaseline(.backout, rev, null);
    try std.testing.expectEqual(@as(?repostate.Rid, rev), bo_root.base);
    try std.testing.expectEqual(@as(?repostate.Rid, null), bo_root.other);
}

/// One path's F card on each of the three merge sides (null = absent there).
const MergeSlot = struct {
    base: ?artifact.FCard = null,
    current: ?artifact.FCard = null,
    other: ?artifact.FCard = null,
};

/// The current-side attribute wins (B-merge-23); when the path is added only on
/// the other side, its attribute is the only one there.
/// Optional-hash equality for the record-level three-way: both-absent is
/// equal; one-sided absence is not; present compares byte-wise.
fn hashEql(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.?);
}

fn mergeSlotPerm(slot: MergeSlot) ?[]const u8 {
    if (slot.current) |f| return f.perm;
    if (slot.other) |f| return f.perm;
    return null;
}

/// Step 8 of `runMerge`, split out for legibility: read the three §8.2 effective
/// file sets, classify every path with the pure `merge3` engine into a
/// `MergeEntry` list, hand it to `update.applyMerge` (which journals, records the
/// merge parent, and materializes), then report. Conflicts are written in-tree
/// and listed on stderr with exit 1; the merge parent is recorded either way.
fn computeAndApplyMerge(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    repo: *Repository,
    root_dir: std.Io.Dir,
    base: ?repostate.Rid,
    current: repostate.Rid,
    other: ?repostate.Rid,
    ck: checkout.CheckoutState,
    spec: MergeApply,
    strategy: merge3.Strategy,
    /// The working tree's own content for divergent paths. Empty on a
    /// clean tree, which reproduces the original all-sides-from-the-store read.
    local: LocalSide,
    streams: cli.Streams,
) cli.Exit {
    // The three effective file sets (§8.2 merge-walk). Kept alive until after
    // `applyMerge` — every `MergeEntry.path`/`perm` borrows these manifests. A
    // null side is the EMPTY file set (a special merge whose REV is a root, so
    // its primary-parent side has no manifest).
    var base_list: ?Repository.FileList = if (base) |b|
        (repo.files(gpa, b) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams)
    else
        null;
    defer if (base_list) |*l| l.deinit(gpa);
    var cur_list = (repo.files(gpa, current) catch |e| return mergeFilesFail(streams, e)) orelse
        return mergeCorrupt(streams);
    defer cur_list.deinit(gpa);
    var oth_list: ?Repository.FileList = if (other) |o|
        (repo.files(gpa, o) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams)
    else
        null;
    defer if (oth_list) |*l| l.deinit(gpa);

    // The union of paths across the three sides; each slot names the side's card.
    // Iteration order is unspecified — the conflict list is sorted before it is
    // reported and `applyMerge` is order-independent, so nothing depends on it.
    var slots = std.StringHashMap(MergeSlot).init(arena);
    if (base_list) |*bl| {
        var bit = bl.iterator();
        while (bit.next()) |f| {
            const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
            if (!gop.found_existing) gop.value_ptr.* = .{};
            gop.value_ptr.base = f;
        }
    }
    var cit = cur_list.iterator();
    while (cit.next()) |f| {
        const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
        if (!gop.found_existing) gop.value_ptr.* = .{};
        gop.value_ptr.current = f;
    }
    // Deliberately NOT seeding slots for locally-added paths the manifests do
    // not name. An uncommitted add that the OTHER side also adds is already
    // reached through `oth_list`, and its current side resolves from `local`
    // below — so the add-vs-add collision conflicts rather than silently taking
    // their bytes. One the other side does NOT have is in no manifest, so the
    // walk has nothing to decide about it and it simply stays on disk.
    // Verified both ways: seeding those slots changes no outcome.
    if (oth_list) |*ol| {
        var oit = ol.iterator();
        while (oit.next()) |f| {
            const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
            if (!gop.found_existing) gop.value_ptr.* = .{};
            gop.value_ptr.other = f;
        }
    }

    // Classify every path (pure), building the outcome entries and the conflict
    // list. Bytes/results are arena-owned and outlive `applyMerge`.
    var entries: std.ArrayList(update.MergeEntry) = .empty;
    var conflicts: std.ArrayList([]const u8) = .empty;
    // Sparse-cone RFC section 4.4: out-of-cone paths resolve at HASH level
    // (record-level application) -- no blob read, no materialization. The
    // resolved outcomes accumulate into the merge_overlay the next commit
    // composes; a both-changed path is a genuine content conflict that
    // cannot resolve without bytes this checkout deliberately lacks.
    var merge_cone = update.readStoredCone(gpa, io, root_dir) catch {
        streams.err.writeAll("zit merge: cannot read the checkout state\n") catch {};
        return .failure;
    };
    defer if (merge_cone) |*c| c.deinit(gpa);
    var overlay: std.ArrayList(update.OverlayEntry) = .empty;
    var cross_conflicts: std.ArrayList([]const u8) = .empty;
    var it = slots.iterator();
    while (it.next()) |kv| {
        const path = kv.key_ptr.*;
        const slot = kv.value_ptr.*;
        if (merge_cone) |*c| {
            if (!c.covers(path)) {
                const base_h: ?[]const u8 = if (slot.base) |f| f.hash else null;
                const cur_h: ?[]const u8 = if (slot.current) |f| f.hash else null;
                const oth_h: ?[]const u8 = if (slot.other) |f| f.hash else null;
                if (hashEql(oth_h, cur_h)) {
                    // Same on both sides (including both absent): nothing.
                } else if (hashEql(cur_h, base_h)) {
                    // Theirs-only change: adopt theirs' card by hash.
                    overlay.append(arena, .{
                        .path = path,
                        .hash = oth_h,
                        .perm = if (slot.other) |f| f.perm else null,
                    }) catch return mergeOom(streams);
                } else if (hashEql(oth_h, base_h)) {
                    // Ours-only (ancestral) change: ours stands via carry.
                } else {
                    cross_conflicts.append(arena, path) catch return mergeOom(streams);
                }
                continue;
            }
        }
        // The bytes of each side (null = path absent there). The §8.2 walk
        // suppresses hash-less delete markers, so a present card always names a
        // stored blob; a missing blob is a corruption. A read fault returns an
        // already-rendered `cli.Exit` from this function verbatim.
        const base_side: merge3.Side = if (slot.base) |f|
            (repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
        else
            null;
        // The current side is the WORKING TREE. A divergent path
        // answers from disk; a clean one has no entry, and its working bytes
        // are by definition the check-in's, so it still reads from the store.
        const cur_side: merge3.Side = if (local.currentOf(path)) |content| switch (content) {
            .content => |c| c.bytes,
            .absent => null,
            // `localSide` refused these before anything mutated.
            .unsupported => unreachable,
        } else if (slot.current) |f|
            (repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
        else
            null;
        const oth_side: merge3.Side = if (slot.other) |f|
            (repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
        else
            null;

        const outcome = merge3.classifyFile(arena, base_side, cur_side, oth_side, strategy) catch return mergeOom(streams);
        // B-merge-23 keeps the working file's own mode, so a locally-changed
        // mode wins over the card's exactly as a locally-changed byte does.
        const perm: ?[]const u8 = if (local.currentOf(path)) |content| switch (content) {
            .content => |c| if (c.mode == .executable) "x" else "",
            .absent, .unsupported => mergeSlotPerm(slot),
        } else mergeSlotPerm(slot);
        switch (outcome) {
            .unchanged => {},
            .delete => entries.append(arena, .{ .path = path, .action = .delete }) catch return mergeOom(streams),
            .take_other => entries.append(arena, .{
                .path = path,
                .action = .{ .write = .{ .bytes = oth_side.?, .perm = perm } },
            }) catch return mergeOom(streams),
            .clean_merge => |r| entries.append(arena, .{
                .path = path,
                .action = .{ .write = .{ .bytes = r.bytes, .perm = perm } },
            }) catch return mergeOom(streams),
            .text_conflict => |r| {
                entries.append(arena, .{
                    .path = path,
                    .action = .{ .write = .{ .bytes = r.bytes, .perm = perm } },
                }) catch return mergeOom(streams);
                conflicts.append(arena, path) catch return mergeOom(streams);
            },
            .whole_file_conflict => {
                const bytes = merge3.writeEnvelope(arena, cur_side, base_side, oth_side) catch return mergeOom(streams);
                entries.append(arena, .{
                    .path = path,
                    .action = .{ .write = .{ .bytes = bytes, .perm = perm } },
                }) catch return mergeOom(streams);
                conflicts.append(arena, path) catch return mergeOom(streams);
            },
        }
    }

    // Precedent replay: a conflict whose bytes match a resolution the
    // operator already authored is resolved from it instead of being written as
    // markers, and the ones that miss are remembered so `commit` can learn how
    // they get settled. Deliberately a pass OVER the outcome set rather than a
    // branch inside the classifier switch above: this feature has no opinion
    // about how a conflict is produced, only about one that already exists.
    _ = cmd_precedent.replayAtMerge(
        gpa,
        io,
        arena,
        root_dir,
        entries.items,
        &conflicts,
        spec.use_precedent,
        streams,
    );

    // A special merge whose change is already contained in the current checkout
    // classifies to zero entries — refuse before mutating anything (B-merge-39),
    // parallel to the plain merge's up-to-date refusal. A plain merge never sets
    // `refuse_empty`, so its behavior (call `applyMerge` regardless) is unchanged.
    if (spec.refuse_empty and entries.items.len == 0) {
        streams.err.writeAll("zit merge: nothing to apply\n") catch {};
        return .failure;
    }

    // Materialize (journal-armed) and record the pending operation: a plain
    // merge's `merge_parent` (other's hash) or a special merge's `special_op`
    // (sign + REV). `applyMerge` writes the checkout FIRST, so a conflict still
    // records the pending state for `zit commit`/`zit undo` (B-merge-3/7/35).
    // Exactly one pending operation is recorded: a plain merge's `merge_parent`
    // (other's hash) XOR a special merge's `special_op` (sign + REV).
    if (cross_conflicts.items.len != 0) {
        streams.err.print(
            "zit merge: {d} out-of-cone file(s) changed on both sides, starting with '{s}'\n" ++
                "zit merge: resolving them needs content this checkout's sparse cone excludes;\n" ++
                "zit merge: widen the cone ('zit cone add ...') and re-run the merge\n",
            .{ cross_conflicts.items.len, cross_conflicts.items[0] },
        ) catch {};
        return .failure;
    }
    const overlay_bytes: ?[]u8 = if (overlay.items.len != 0)
        update.encodeOverlay(gpa, overlay.items) catch |e| switch (e) {
            error.OutOfMemory => return mergeOom(streams),
            error.Overflow => return mergeCorrupt(streams),
        }
    else
        null;
    defer if (overlay_bytes) |b| gpa.free(b);

    std.debug.assert((spec.merge_parent.len == 0) != (spec.special_op.len == 0));
    update.applyMerge(gpa, io, root_dir, entries.items, overlay_bytes, .{
        .version = ck.version,
        .branch = ck.branch,
        .merge_parent = spec.merge_parent,
        .special_op = spec.special_op,
        // A merge changes the check-in, never the checkout's manifest opt-in.
        .manifest_flags = ck.manifest_flags,
        .integrate = spec.integrate,
    }) catch |e| return mergeApplyFail(streams, e);

    if (overlay.items.len != 0) {
        streams.out.print("{d} out-of-cone change(s) applied to the record\n", .{overlay.items.len}) catch {};
    }
    // Report last. Conflicts → stderr only, exit 1; clean → the confirmation to
    // stdout only, exit 0 (B-merge-28 stream separation).
    if (conflicts.items.len != 0) {
        std.mem.sort([]const u8, conflicts.items, {}, lessByHash);
        for (conflicts.items) |p| streams.err.print("zit merge: conflict in {s}\n", .{p}) catch {};
        streams.err.print(
            "zit merge: {d} conflict(s); resolve and commit, or run 'zit undo'\n",
            .{conflicts.items.len},
        ) catch {};
        return .failure;
    }
    streams.out.print(
        "{s} {s} into the working tree ({d} file(s) changed)\n",
        .{ spec.report_verb, spec.report_hash, entries.items.len },
    ) catch {};
    return .ok;
}

fn mergeOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit merge: out of memory\n") catch {};
    return .failure;
}

fn mergeCorrupt(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {};
    return .failure;
}

/// Maps a `Repository.files` fault to the shipped diagnostic phrasing. The three
/// §8 enumeration faults and a corrupt manifest all read as "repository is
/// corrupt" — a resolved name whose manifests do not enumerate is a data fault.
fn mergeFilesFail(s: cli.Streams, err: Repository.FilesError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit merge: out of memory\n") catch {},
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.writeAll("zit merge: cannot read repository: input/output error\n") catch {},
        error.Corrupt,
        error.NotManifest,
        error.BaselineUnresolvable,
        error.BaselineIsDelta,
        => s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {},
    }
    return .failure;
}

/// Maps an `update.ApplyError` from the merge materialization (or the clean-tree
/// gate) to the shipped `zit merge:` phrasing, mirroring `updateApplyFail`. The
/// check-in/baseline id faults cannot arise here — `files` already validated the
/// resolved manifests — but `ApplyError` is one closed set, so they fold into
/// the corrupt-repository message rather than an invented one.
fn mergeApplyFail(s: cli.Streams, err: update.ApplyError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit merge: out of memory\n") catch {},
        error.FileContentMissing => {
            var b: [512]u8 = undefined;
            s.err.print("zit merge: {s}\n", .{update.stageFaultReason(&b)}) catch {};
        },
        error.SparseMergeUnsupported => s.err.writeAll(
            "zit merge: the merge changes files outside this checkout's sparse cone\n" ++
                "record-level application is not supported yet; widen the cone with\n" ++
                "'zit update --cone PATH' (or return to dense with --dense) and re-run the merge\n",
        ) catch {},
        error.WorktreeNotClean => s.err.writeAll("zit merge: working tree is not clean\n") catch {},
        error.CurrentStateInvalid => s.err.writeAll("zit merge: current working-tree state is invalid\n") catch {},
        error.UnmanagedCollision => s.err.writeAll("zit merge: an untracked file or directory is in the way\n") catch {},
        error.InvalidPath => s.err.writeAll("zit merge: the merge names an invalid file path\n") catch {},
        error.ReservedPath => s.err.writeAll("zit merge: the merge names a reserved file path\n") catch {},
        error.SymlinkUnsupported => s.err.writeAll("zit merge: the merge contains an unsupported symlink entry\n") catch {},
        error.Storage => s.err.writeAll("zit merge: cannot update working tree: input/output error\n") catch {},
        error.TransactionUnavailable => s.err.writeAll("zit merge: cannot create a transaction directory\n") catch {},
        error.RollbackFailed => s.err.writeAll(
            "zit merge: merge failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
        ) catch {},
        error.CleanupFailed => s.err.writeAll(
            "zit merge: merge applied, but the transaction directory could not be removed\n",
        ) catch {},
        error.StateRecordInvalid => s.err.writeAll(
            "zit merge: cannot read repository: " ++ update.state_record_invalid_reason ++ "\n",
        ) catch {},
        error.Corrupt,
        error.InvalidCheckinId,
        error.CheckinNotFound,
        error.CheckinContentMissing,
        error.CheckinNameMismatch,
        error.AggregateChecksumMismatch,
        error.TargetNotManifest,
        error.BaselineNotFound,
        error.BaselineContentMissing,
        error.BaselineNameMismatch,
        error.BaselineNotManifest,
        error.BaselineIsDelta,
        => s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit revert` — restore managed files to their content at the current
// check-in (artifact §8.4 effective file + §7.2 verified blob). A thin consumer
// of the Repository read path (`fileAt`/`blob`) plus `update.restoreFile`: no
// format work, no managed-set mutation. Content-restore only.
// ==========================================================================

/// `zit revert [PATH...]` — restore managed files to their checked-in content.
/// With no paths, every managed file; with paths, only the named managed files.
pub fn runRevert(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const parsed = switch (cli.parseRevertArgs(gpa, verb_args) catch return revertOom(streams)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "revert", .unknown_option, tok),
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(revert_help) catch {};
        return .ok;
    }

    // Discover the root (its absolute path drives path-scoped `repoRelative`),
    // then open the read-only derived view.
    const root = (cli.discoverRoot(arena, io) catch return revertOom(streams)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    var repo: Repository = undefined;
    repo.open(gpa, io, root_dir, .{ .mode = .full }) catch |e| return repoOpenFail(streams, "revert", e);
    defer repo.deinit(gpa);

    // §7.5: the current checkout names the check-in whose content is restored;
    // an unborn/absent checkout has nothing to revert to.
    const current = repo.currentCheckoutRid() orelse return revertNoCheckout(streams);
    repo.current_hint = current; // checkout-relative names (prev, current) resolve here too
    // -r restores another check-in's content into this tree without
    // moving the checkout (Fossil's `revert -r`).
    const rid = if (parsed.version) |v| repo.resolve(v, .checkin) catch |e| switch (e) {
        error.NotFound => {
            streams.err.print("zit revert: check-in '{s}' not found\n", .{v}) catch {};
            return .failure;
        },
        error.Ambiguous => {
            streams.err.print("zit revert: name '{s}' is ambiguous\n", .{v}) catch {};
            return .failure;
        },
        error.OutOfMemory => return revertOom(streams),
    } else current;
    const where: []const u8 = if (parsed.version) |v|
        std.fmt.allocPrint(arena, "check-in '{s}'", .{v}) catch return revertOom(streams)
    else
        "the current check-in";

    // The managed set — read, never written: revert restores content only.
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "revert", e);
    defer set.deinit();

    // Gather the resolved restore targets first, so the undo journal captures
    // every path's pre-revert bytes in ONE seam before the first file is
    // rewritten (B-undo-2/14). Untracked or unresolvable args are reported here
    // and excluded from both the journal image and the restore.
    var targets: std.ArrayList([]const u8) = .empty;
    defer targets.deinit(gpa);
    var had_error = false;
    if (parsed.paths.len == 0) {
        for (set.iterate()) |path| targets.append(gpa, path) catch return revertOom(streams);
    } else {
        const cwd_abs = (cli.currentPathAlloc(arena, io) catch return revertOom(streams)) orelse {
            streams.err.writeAll("zit revert: cannot determine the current directory\n") catch {};
            return .failure;
        };
        for (parsed.paths) |arg| {
            const key = switch (cli.repoRelative(arena, root.abs, cwd_abs, arg) catch return revertOom(streams)) {
                .key => |k| k,
                .root, .outside => {
                    revertNotTracked(streams, arg);
                    had_error = true;
                    continue;
                },
            };
            if (!set.contains(key)) {
                revertNotTracked(streams, key);
                had_error = true;
                continue;
            }
            targets.append(gpa, key) catch return revertOom(streams);
        }
    }

    // Every restore target is a managed path (gathered from the set, or gated on
    // `set.contains` for an explicit arg) — revert never rewrites an untracked file.
    if (std.debug.runtime_safety) {
        for (targets.items) |t| std.debug.assert(set.contains(t));
    }

    // Journal-before-mutate (B-undo-3): the undo image is written and fsynced
    // before the first restore. A journal fault aborts with nothing mutated —
    // the durability rule forbids restoring without a recoverable pre-image.
    if (targets.items.len != 0 and !parsed.dry_run) {
        update.journal.record(gpa, io, root_dir, targets.items) catch |e|
            return revertJournalFail(streams, e);
    }

    var reverted: usize = 0;
    for (targets.items) |name| {
        switch (revertOne(gpa, io, &repo, root_dir, rid, where, name, parsed.dry_run, streams)) {
            .failed => had_error = true,
            .reverted => reverted += 1,
            .skipped => {},
        }
    }
    if (reverted != 0 and !parsed.dry_run) streams.err.writeAll(" \"zit undo\" is available to undo changes to the working checkout.\n") catch {};
    return if (had_error) .failure else .ok;
}

/// A journal-write failure before revert touches any file — exit 1, nothing
/// mutated. `record` only surfaces `OutOfMemory`/`Storage`; the `else` folds the
/// device faults into the honest I/O message.
fn revertJournalFail(s: cli.Streams, e: update.journal.JournalError) cli.Exit {
    switch (e) {
        error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {},
        else => s.err.writeAll("zit revert: cannot write the undo journal: input/output error\n") catch {},
    }
    return .failure;
}

/// The per-file revert verdict: content restored, nothing to restore (absent
/// from the effective set — informational, not an error), or a genuine fault.
const RevertOutcome = enum { reverted, skipped, failed };

/// Restore one managed file `name` to its content at `rid`. §8.4 effective
/// lookup: a file absent from the effective set (never present, or a §4.7.6
/// delete marker) has no content to restore — reported and left unchanged,
/// never fabricated (`.skipped`). A read/write fault is `.failed`.
fn revertOne(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    root_dir: std.Io.Dir,
    rid: Repository.Rid,
    where: []const u8,
    name: []const u8,
    dry_run: bool,
    streams: cli.Streams,
) RevertOutcome {
    const found = repo.fileAt(gpa, rid, name) catch |e| {
        revertFileFail(streams, name, e);
        return .failed;
    };
    const card = found orelse {
        streams.err.print("zit revert: '{s}' is not in {s}; left unchanged\n", .{ name, where }) catch {};
        return .skipped;
    };
    defer Repository.freeFileCard(gpa, card);
    // A file already at its check-in bytes is not "reverted": Fossil names
    // only the files it changes, and so does zit (a missing or edited file
    // still is).
    if (revertAlreadyClean(gpa, io, root_dir, name, card.hash.?, repo)) return .skipped;
    if (dry_run) {
        streams.out.print("would revert {s}\n", .{name}) catch {};
        return .reverted;
    }
    std.debug.assert(card.hash != null); // fileAt yields only hash-bearing cards (§4.7.6)

    // §7.2: the blob is verified to hash to its F-card id before it lands.
    const bytes = (repo.blob(gpa, card.hash.?) catch |e| {
        revertFileFail(streams, name, e);
        return .failed;
    }) orelse {
        streams.err.print("zit revert: content for '{s}' is missing; left unchanged\n", .{name}) catch {};
        return .failed;
    };
    defer gpa.free(bytes);

    update.restoreFile(io, root_dir, name, bytes, card.mode) catch |e| {
        switch (e) {
            error.SymlinkUnsupported => streams.err.print(
                "zit revert: '{s}' is a symlink, which zit does not support; left unchanged\n",
                .{name},
            ) catch {},
            error.Storage => streams.err.print(
                "zit revert: cannot restore '{s}': input/output error\n",
                .{name},
            ) catch {},
        }
        return .failed;
    };
    streams.out.print("REVERT   {s}\n", .{name}) catch {};
    return .reverted;
}

/// True when the working file exists and already hashes to the check-in's
/// blob. Any read failure answers false, so the restore path runs and reports.
fn revertAlreadyClean(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, name: []const u8, hash_hex: []const u8, repo: *Repository) bool {
    _ = repo;
    const st = root_dir.statFile(io, name, .{ .follow_symlinks = false }) catch return false;
    if (st.kind != .file) return false;
    const bytes = root_dir.readFileAlloc(io, name, gpa, .limited(1 << 30)) catch return false;
    defer gpa.free(bytes);
    var name_buf: [64]u8 = undefined;
    const algo: artifact.HashAlgo = if (hash_hex.len == 40) .sha1 else .sha3_256;
    const hex = artifact.artifactName(algo, bytes, &name_buf);
    return std.mem.eql(u8, hex, hash_hex);
}

fn revertOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit revert: out of memory\n") catch {};
    return .failure;
}

fn revertNoCheckout(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit revert: nothing to revert to (the working checkout is unborn)\n") catch {};
    return .failure;
}

fn revertNotTracked(s: cli.Streams, path: []const u8) void {
    s.err.print("zit revert: '{s}' is not tracked (use 'zit add' to track it)\n", .{path}) catch {};
}

/// Maps a `Repository.fileAt`/`blob` failure to a per-file diagnostic (no exit
/// code — the caller aggregates). Switches the full `FilesError` set
/// exhaustively; `blob`'s narrower `OpenError` coerces in. Phrasings match
/// `ls`/`cat`.
fn revertFileFail(s: cli.Streams, name: []const u8, e: Repository.FilesError) void {
    switch (e) {
        error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {},
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.print("zit revert: cannot read '{s}': input/output error\n", .{name}) catch {},
        error.Corrupt => s.err.print("zit revert: cannot read '{s}': repository is corrupt\n", .{name}) catch {},
        error.NotManifest => s.err.writeAll("zit revert: not a check-in\n") catch {},
        error.BaselineUnresolvable => s.err.writeAll("zit revert: cannot resolve the baseline of a delta check-in\n") catch {},
        error.BaselineIsDelta => s.err.writeAll("zit revert: the baseline of a delta check-in is itself a delta\n") catch {},
    }
}

// ==========================================================================
// `zit undo` / `zit redo` — single-level undo/redo of the working-file mutators
// (contracts/undo.md B-undo-1..21). Thin wiring: parse (help-only, no
// positionals), discover the root BEFORE any journal read (B-undo-19), then
// drive the `update.journal` swap. All format/slot work lives in `undo.zig`;
// restoration reuses the update layer. No log append, no network.
// ==========================================================================

const UndoKind = enum { undo, redo };

/// `zit undo` — reverse the last sheltered working-file mutation.
pub fn runUndo(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    return runUndoRedo(gpa, io, arena, verb_args, streams, .undo);
}

/// `zit redo` — reapply the change a preceding `zit undo` reversed.
pub fn runRedo(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    return runUndoRedo(gpa, io, arena, verb_args, streams, .redo);
}

/// The shared body: the two verbs differ only in name, help text, which journal
/// slot they consume, and the "nothing to …" phrasing (Ousterhout: one
/// mechanism, not two near-identical copies).
fn runUndoRedo(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    kind: UndoKind,
) cli.Exit {
    const verb = switch (kind) {
        .undo => "undo",
        .redo => "redo",
    };
    const help_text = switch (kind) {
        .undo => undo_help,
        .redo => redo_help,
    };
    // Usage errors are exit 2, stderr-only, and read no journal (B-undo-17/18);
    // `-h`/`--help` win over any trailing token and print to stdout (B-undo-16).
    const parsed = switch (cli.parseUndoArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, verb, .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, verb, .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(help_text) catch {};
        return .ok;
    }

    // B-undo-19: the repository check precedes any journal read; absent → the
    // standard not-a-zit-repository diagnostic, exit 1 (distinct from exit 2).
    const root = (cli.discoverRoot(arena, io) catch {
        streams.err.print("zit {s}: out of memory\n", .{verb}) catch {};
        return .failure;
    }) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    const dir: update.journal.Direction = if (kind == .undo) .undo_dir else .redo_dir;
    if (parsed.dry_run) {
        // Name what the swap would touch, touching nothing.
        const pk = update.journal.peek(arena, io, root_dir, dir) catch |err| return undoFail(streams, verb, err);
        for (pk.paths) |path| streams.out.print("{s} would restore {s}\n", .{ verb, path }) catch {};
        if (pk.restores_checkout) streams.out.print("{s} would restore the checkout record\n", .{verb}) catch {};
        return .ok;
    }

    const pk = update.journal.peek(arena, io, root_dir, dir) catch |err| return undoFail(streams, verb, err);
    (switch (kind) {
        .undo => update.journal.undo(gpa, io, root_dir),
        .redo => update.journal.redo(gpa, io, root_dir),
    }) catch |err| return undoFail(streams, verb, err);

    // Fossil's per-file rows, then the confirmation row, all on stdout
    // (B-undo-20); diagnostics never share the stream.
    const word: []const u8 = if (kind == .undo) "UNDO  " else "REDO  ";
    for (pk.paths) |path| streams.out.print("{s} {s}\n", .{ word, path }) catch {};
    streams.out.print("{s}: working tree restored\n", .{verb}) catch {};
    return .ok;
}

/// Renders a `JournalError` to stderr and returns `.failure` (exit 1). `Empty`
/// is the nothing-to-undo/redo case (B-undo-9/10); `Corrupt` the damaged
/// journal (B-undo-11); the rest are honest device/allocation faults.
fn undoFail(s: cli.Streams, verb: []const u8, err: update.journal.JournalError) cli.Exit {
    switch (err) {
        error.Empty => s.err.print("zit {s}: nothing to {s}\n", .{ verb, verb }) catch {},
        error.Corrupt => s.err.print("zit {s}: the undo journal is damaged\n", .{verb}) catch {},
        error.Storage => s.err.print("zit {s}: cannot restore working tree: input/output error\n", .{verb}) catch {},
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
    }
    return .failure;
}

/// One managed file captured for the check-in, or the reason it cannot be
/// (rendered by the caller so the stderr/exit discipline stays in `runCommit`).
const Capture = union(enum) {
    /// The file's F card (hash duped onto the manifest arena).
    card: artifact.FCard,
    missing, // absent from the working tree (B-commit-10)
    /// Present but not a regular file (B-commit-10). The payload is `true` for
    /// a symlink, which gets the F-B3 recovery hint instead of the bare
    /// refusal — carried here rather than re-stat'd by the renderer.
    not_regular: bool,
    io_error, // a read/store device failure (B-commit-15)
    /// Larger than one artifact frame can hold (`managed.max_tracked_file_bytes`).
    /// Distinct from `io_error` because it is neither a device fault nor
    /// anything the operator can retry: it is a stated limit, and reporting it
    /// as "input/output error" named nothing they had done and suggested a
    /// failure that might not recur.
    too_large: u64,
    /// The file's CONTENT is a class this repository has not said it
    /// accepts: NUL bytes, or CR/LF line endings. Refused rather than warned,
    /// because a warning is a prompt and `commit` has to be answerable
    /// non-interactively; fossil reaches the same place, since its warning
    /// blocks a non-tty commit until `--no-warnings` is passed.
    content_class: ContentFault,
    oom,
};

/// The content classes a commit will not record silently.
const ContentFault = enum {
    binary,
    /// UTF-16 content, fossil's "Unicode" warning. One `encoding-glob`
    /// class with `invalid_utf8`, kept as two variants because the operator is
    /// told which of the two their file is.
    unicode,
    invalid_utf8,
    crlf,

    fn describe(self: ContentFault) []const u8 {
        return switch (self) {
            .binary => "contains NUL bytes (it looks binary)",
            .unicode => "is UTF-16 (Unicode)",
            .invalid_utf8 => "is not valid UTF-8",
            .crlf => "has CR/LF line endings",
        };
    }

    fn glob(self: ContentFault) []const u8 {
        return switch (self) {
            .binary => "binary-glob",
            .unicode, .invalid_utf8 => "encoding-glob",
            .crlf => "crlf-glob",
        };
    }
};

/// The resolved content policy for one commit: which files may carry
/// which content class, and whether the operator waived the check entirely.
/// The globs resolve through the ordinary tier chain, so a tracked
/// `.zit-settings/binary-glob` applies to a fresh clone with no consent step —
/// which is the whole reason these are DECLARATIVE rather than a
/// `before-commit` script, since that one runs for nobody until `zit trust`.
const ContentPolicy = struct {
    binary_glob: []const u8 = "",
    crlf_glob: []const u8 = "",
    /// Fossil's `encoding-glob`: the files whose encoding this project
    /// has accepted. Covers both encoding classes, as it does in fossil.
    encoding_glob: []const u8 = "",
    waived: bool = false,

    /// The fault this file trips, or null when nothing objects.
    ///
    /// A class is checked ONLY when its glob is set. An unset glob is "this
    /// project has no opinion", not "refuse everything": committing a PNG is
    /// ordinary, and a check that fired by default would make every repository
    /// that never asked for it pay a two-step dance. Declaring the glob is what
    /// opts a project in, and it reads naturally — `binary-glob = *.png` says
    /// PNGs are the binaries this project expects, so a stray `.o` is refused.
    ///
    /// This departs from fossil, which warns on binary content by default and
    /// uses its globs to SUPPRESS the warning. The departure is deliberate: a
    /// warning that fires on every ordinary commit is one nobody reads, and a
    /// refusal nobody asked for is worse. zit checks when told to, and then
    /// actually refuses — which is what makes it a policy rather than noise.
    ///
    /// The ORDER is fossil's branch order (checkin.c): binary, then Unicode
    /// (with or without CR), then invalid UTF-8, then CR/LF. A binary file
    /// usually also contains CR/LF, and naming it "binary" is the diagnostic
    /// the operator can act on; and a UTF-16 file carrying CR is exempt only
    /// when BOTH globs permit it, which falls out of checking encoding first.
    ///
    /// Binary is not merely first, it is TERMINAL — fossil's binary branch
    /// returns, so no later class is consulted for a blob with a NUL, whether
    /// or not `binary-glob` is declared. Without that, declaring `encoding-glob`
    /// alone would start refusing every PNG and zip in the tree under a glob
    /// that cannot exempt them, since binary formats are rarely valid UTF-8.
    /// Invalid UTF-8 is terminal the same way (fossil's `fHasInvalidUtf8`
    /// branch returns once `encoding-glob` permits the file), so a Latin-1
    /// file with CR/LF that `encoding-glob` names is never a CR/LF fault.
    /// UTF-16 is NOT terminal: fossil exempts Unicode-with-CR only when BOTH
    /// globs permit it, so the CR/LF row still runs for it.
    fn fault(self: ContentPolicy, path: []const u8, content: []const u8) ?ContentFault {
        if (self.waived) return null;
        const look = zit.lookslike.classify(content);
        if (look.nul) return if (objects(self.binary_glob, path)) .binary else null;
        if (look.utf16 and objects(self.encoding_glob, path)) return .unicode;
        if (look.invalid_utf8) return if (objects(self.encoding_glob, path)) .invalid_utf8 else null;
        if (look.cr and objects(self.crlf_glob, path)) return .crlf;
        return null;
    }

    /// This project declared the class (the glob is set) and did not name this
    /// file. An empty glob is silence, and `*` is how the check is turned off.
    fn objects(list: []const u8, path: []const u8) bool {
        return list.len != 0 and !zit.ignore.listMatches(list, path);
    }

    /// What Fossil would have prompted about and zit lets through when no
    /// glob is declared: said in the commit receipt, never a refusal.
    fn undeclared(self: ContentPolicy, content: []const u8) ?ContentFault {
        if (self.waived) return null;
        const look = zit.lookslike.classify(content);
        // Same terminal binary branch as `fault`: a blob with a NUL is
        // reported as binary or not at all, never under another class's words.
        if (look.nul) return if (self.binary_glob.len == 0) .binary else null;
        if (self.encoding_glob.len == 0 and look.utf16) return .unicode;
        if (look.invalid_utf8) return if (self.encoding_glob.len == 0) .invalid_utf8 else null;
        if (self.crlf_glob.len == 0 and look.cr) return .crlf;
        return null;
    }
};
const ContentNote = struct { path: []const u8, fault: ContentFault, size: u64 };

/// Stats, reads, hashes, and stores one managed file `key`, returning its F card
/// or the reason it could not be captured. All of `commit`'s working-tree read
/// I/O lives here (design-seams rule). Symlinks are not followed, so a symlink
/// is `not_regular`, never silently resolved. The blob is stored uncompressed
/// (the name is over uncompressed bytes; storage compression is deferred —
/// Scope). Idempotent re-intern: `logstore.put` appends nothing for a blob
/// already present.
fn captureFile(
    gpa: std.mem.Allocator,
    io: std.Io,
    ma: std.mem.Allocator,
    root_dir: std.Io.Dir,
    log: *logstore.LogStore,
    key: []const u8,
    base: ?logstore.Rid,
    policy: ContentPolicy,
    notes: *std.ArrayList(ContentNote),
) Capture {
    const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .missing;
    if (st.kind != .file) return .{ .not_regular = st.kind == .sym_link };
    if (!trackedFileSizeAllowed(st.size)) return .{ .too_large = st.size };
    const content = root_dir.readFileAlloc(io, key, gpa, .limited(max_tracked_file_bytes)) catch |e|
        return switch (e) {
            error.OutOfMemory => .oom,
            else => .io_error,
        };
    defer gpa.free(content);
    // The on-disk log keys frames by the binary digest (logstore.max_hash_len ==
    // 32); the F card carries the 64-hex artifact name (§4.7.1 wire form). Both
    // derive from the same content, so naming is unaffected by storage.
    var digest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, content, &name_buf);
    // Policy gates what this commit INTRODUCES, not the tree it inherits.
    // An all-files commit re-captures every managed path, so classifying
    // unchanged content would make declaring a glob block every later commit
    // until the whole tree already conformed — adoption would be all-or-
    // nothing in exactly the repositories that most need it. Comparing against
    // the parent's blob for this path makes it incremental: existing files are
    // grandfathered, a new or edited one is not. Checked before the blob is
    // stored, so a refused commit mints nothing.
    const unchanged = if (base) |b| std.mem.eql(u8, &digest, log.hashOf(b)) else false;
    if (!unchanged) {
        if (policy.fault(key, content)) |f| return .{ .content_class = f };
    }
    // §4.1/§7.2 — store the blob, delta-encoded against the same path's blob in
    // the parent check-in when that is materially smaller (else a full §4.1
    // container). `base` is a hint only; naming is always over the uncompressed
    // bytes, so storage compression/delta never changes HASH.
    _ = log.putArtifact(&digest, content, base) catch |e| return switch (e) {
        error.OutOfMemory => .oom,
        error.Storage => .io_error,
    };
    const hash = ma.dupe(u8, name) catch return .oom;
    const filename = ma.dupe(u8, key) catch return .oom;
    if (!unchanged) if (policy.undeclared(content)) |f| {
        notes.append(ma, .{ .path = filename, .fault = f, .size = st.size }) catch return .oom;
    };
    // Capture the working-tree executable bit so a chmod +x round-trips into the
    // check-in instead of silently reverting to regular (a permission drift that
    // otherwise shows as `M` on every diff and never commits). §4.7.6: a perm
    // token containing `x` marks the entry executable, and a producer SHOULD emit
    // only w/x/l — so an executable file carries the explicit `x` token (needed
    // for the mode to survive re-parse via `fileMode`), and a regular file omits
    // the token. Non-regular kinds already returned `.not_regular` above, so the
    // mode is only ever regular or executable here.
    const is_exec = executable(st.permissions);
    return .{ .card = .{
        .filename = filename,
        .hash = hash,
        .perm = if (is_exec) "x" else null,
        .old_name = null,
        .mode = if (is_exec) .executable else .regular,
    } };
}

/// `submanifestEmission`'s full result: the §8.1/§6.3 emission decision this
/// commit builds against, plus `submanifest-auto-retune` read from the exact
/// same config frame — one `readConfig`, two decisions, so §6.4's
/// auto-dispatch gate (`commitSubmanifestAdvice`, below) costs no second read.
const EmissionDecision = struct {
    emission: zit.submanifest.Emission,
    /// The last effective-set shape whose k-cost was settled. Equal shapes need
    /// no advisory scan; missing/different shapes must be analyzed again.
    settled_cost_shape: ?[32]u8,
    /// draft-fossil-submanifests-00 §6.4's `submanifest-auto-retune`: the
    /// durable project-operator act authorizing subsequent automatic re-tunes.
    /// Default false, matching the config field's own default — a repository
    /// that never opted in reads `false` here exactly as it always has.
    auto_retune: bool,
};

/// draft-fossil-submanifests-00 §8.1/§6.3 — the emission decision for THIS
/// commit, read from the repository's own config frame.
///
/// §8.1: "A writer MUST NOT emit an S card unless the repository's
/// enable-submanifests setting -- a project-scope boolean, default false -- is
/// true". §6.3: "A writer MUST take k from the pinned setting, MUST NOT derive a
/// value per commit ... on receiving a value outside 1..12 it MUST fall back to
/// emitting flat manifests rather than substitute a parameter of its own, and
/// SHOULD report the fallback to the operator."
///
/// The decision is folded here rather than inlined at the call site so a config
/// that cannot be read is one decision, not two: a repository with no config
/// frame, or one whose frame will not decode, arrives as a null `config` and is
/// `.off` — the default, the state of every repository predating this draft,
/// and the only safe direction (guessing "enabled" would mint artifacts §8
/// calls unreadable). `submanifestEmission` makes no output;
/// `reportSubmanifestOutOfRange` preserves the §6.3 diagnostic ordering after
/// pre-capture pinned-k validation.
fn submanifestEmission(config: ?*const logstore.Config) EmissionDecision {
    const c = config orelse return .{
        .emission = .off,
        .settled_cost_shape = null,
        .auto_retune = false,
    };
    return .{
        .emission = zit.submanifest.emission(.of(c.*)),
        .settled_cost_shape = c.submanifest_settled_cost_shape,
        .auto_retune = c.submanifest_auto_retune,
    };
}

/// The two LOCAL signing settings, read from the same config frame
/// everything else in the commit path reads. `clearsign` makes every commit
/// signed without `--sign`, matching fossil; `pgp_command` is the stored
/// command line, which `$ZIT_PGP_COMMAND` still overrides. Neither ever
/// crosses the wire: fossil classifies `pgp-command` SENSITIVE (a propagated
/// value would let an origin choose what a cloner executes) and puts
/// `clearsign` in no config group at all.
///
/// An unreadable config yields the defaults — off, and the built-in command —
/// which is the same direction `submanifestEmission` takes for the same
/// reason: guessing "sign" on a frame we could not read would fail the commit,
/// not protect it.
const SigningSettings = struct {
    clearsign: bool = false,
    /// Borrows the caller's arena; empty means the built-in default.
    pgp_command: []const u8 = "",
};

/// The two signing settings, resolved through the SAME precedence
/// `zit settings` reports: environment, then this repository, then the
/// operator's global file, then the built-in default. (Neither is versionable,
/// so no tracked file can reach them — that boundary is enforced inside
/// `settings.resolve`, not here.)
///
/// Routed through the shared resolver rather than reading the frame's fields
/// directly, because the two must never disagree: a `zit settings` that reports
/// `clearsign on` from the global tier while the commit quietly does not sign is
/// a worse failure than having no global tier at all. That bug existed for
/// exactly one commit. `cfg` supplies only the REPOSITORY tier of that chain --
/// the frame the commit already holds, so no second store open is paid here.
///
/// An unresolvable setting yields the default — off, and the built-in command —
/// the same direction `submanifestEmission` takes for the same reason: guessing
/// "sign" on state we could not read would fail the commit, not protect it.
fn signingSettings(
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    amb: cli.Ambient,
    cfg: ?*const logstore.Config,
) SigningSettings {
    const clear = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .clearsign) catch
        return .{};
    const cmd = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .pgp_command) catch
        return .{ .clearsign = zit.settings.parseBool(clear.value) orelse false };
    return .{
        .clearsign = zit.settings.parseBool(clear.value) orelse false,
        // The resolver always answers with the default when no tier spoke, and
        // `clearsignBytes` treats an EMPTY stored command as "use the built-in".
        // Passing the resolved default through is the same thing, one fewer
        // special case.
        .pgp_command = cmd.value,
    };
}

/// draft-fossil-submanifests-00 §6.3's SHOULD-report occurs at the historic
/// post-no-op commit phase, even though its pinned-k decision was read earlier.
fn reportSubmanifestOutOfRange(streams: cli.Streams, decision: zit.submanifest.Emission) void {
    if (decision != .out_of_range) return;
    streams.err.print(
        "zit commit: {s} ({d}; must be {d}..{d}) — emitting a flat manifest. " ++
            "Re-pin with 'zit submanifest retune --k N'.\n",
        .{
            zit.submanifest.out_of_range_report_prefix,
            decision.out_of_range,
            artifact.min_k,
            artifact.max_k,
        },
    ) catch {};
}

/// submanifests §8.1 — record the local seen-submanifest indicator, on the
/// false->true edge only. Appends a config frame carrying every existing field
/// plus the flag (plan E last-frame-wins); a repository already carrying it writes
/// nothing. The caller's `log.commit()` makes it durable with the artifacts it
/// describes, so the indicator and the trees it is about land in one fsync.
/// The serialized size an F card contributes to a manifest: letter, spaces,
/// tokens, LF. An estimate only in the sense that §4.3 escapes can lengthen
/// a filename — identically in both runs being compared, so the comparison
/// is exact where it matters.
fn fCardCost(c: artifact.FCard) usize {
    var n: usize = 2 + c.filename.len + 1; // "F " + name + LF
    if (c.hash) |h| n += 1 + h.len;
    if (c.perm) |p| n += 1 + p.len;
    if (c.old_name) |o| n += 1 + o.len;
    return n;
}

/// Dialect memory read: has this repository ever stored a delta manifest?
/// Config-absent or unreadable reads as false — the flat default.
fn seenDeltaManifest(gpa: std.mem.Allocator, log: *logstore.LogStore) bool {
    var oc = (log.readConfig(gpa) catch return false) orelse return false;
    defer oc.deinit(gpa);
    return oc.config.seen_delta_manifest;
}

/// Record the seen-delta indicator, on the false->true edge only — the same
/// plan-E last-frame-wins append `setSeenSubmanifest` uses, landing in the
/// same `log.commit()` as the delta manifest it is about.
fn setSeenDelta(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
    var oc = log.readConfig(gpa) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage,
    };
    var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
    defer if (oc) |*c| c.deinit(gpa);
    if (cfg.seen_delta_manifest) return;
    cfg.seen_delta_manifest = true;
    try log.putConfig(cfg);
}

fn setSeenSubmanifest(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
    var oc = log.readConfig(gpa) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        // A config frame that will not decode is not a reason to lose a commit's
        // artifacts; the §7.2 gate arms on the setting alone in that case, and
        // `zit submanifest` shows the operator the unreadable config.
        error.Storage, error.Corrupt => return,
    };
    defer if (oc) |*c| c.deinit(gpa);
    var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
    if (cfg.seen_submanifest) return; // already recorded — no frame per commit
    cfg.seen_submanifest = true;
    try log.putConfig(cfg);
}

/// True when `bytes` still carries an unresolved three-way conflict marker: the
/// `merge3.conflict_sentinel` at the start of a line (file start or right after
/// a LF), exactly as `merge3.writeEnvelope` emits it. The sole consumer of the
/// sentinel outside `merge3` — `zit commit` scans every captured file of a
/// recorded merge and refuses on any hit (B-merge-10).
fn hasConflictMarker(bytes: []const u8) bool {
    if (std.mem.startsWith(u8, bytes, merge3.conflict_sentinel)) return true;
    return std.mem.indexOf(u8, bytes, "\n" ++ merge3.conflict_sentinel) != null;
}

fn buildFullTree(
    gpa: std.mem.Allocator,
    entries: []const artifact.FCard,
    k: u4,
) error{OutOfMemory}!?artifact.TreeBuild {
    var built = try artifact.buildTree(gpa, entries, k, .sha3_256);
    if (built.isFlat()) {
        built.deinit();
        return null;
    }
    return built;
}

/// `zit commit -m <message> [--user <login>] [-q] [--] [<path>...]` — the thin
/// wiring: parse → discover → load checkout+managed set → capture working-tree
/// files → compose the F-set (`cli.composeFCards`) → assemble+serialize the
/// manifest (`artifact.create`) → append blobs+manifest and fsync the log →
/// advance `.zit-checkout` → render. Durability order (B-commit-15): the log is
/// committed before the checkout advances; a failure between is stated honestly.
pub fn runCommit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    return runCommitIn(gpa, io, arena, verb_args, streams, amb, null);
}

/// `commit`, against a checkout this caller NAMES. See `runMergeIn`: same
/// reasoning, same borrowing rule. This is the ONE manifest minter -- the R
/// card, the delta-dialect continuation and sub-manifest emission are decided
/// here and nowhere else -- so a caller that needs a check-in comes through
/// it rather than growing a second writer whose artifacts other conforming
/// implementations would hash differently.
/// What `runCommitInner` did with the comment file: the wrapper
/// keeps it for recovery when the commit failed after the editor ran.
const CommitSession = struct { comment_file_used: bool = false };

pub const comment_file_name = ".zit-ci-comment";

pub fn runCommitIn(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
    checkout_root: ?NamedCheckout,
) cli.Exit {
    var session: CommitSession = .{};
    const rc = runCommitInner(gpa, io, arena, verb_args, streams, amb, checkout_root, &session);
    if (session.comment_file_used) {
        if (checkout_root) |given| {
            settleCommentFile(io, given.dir, rc, streams);
        } else if (cli.discoverRoot(arena, io) catch null) |root| {
            var dir = root.dir;
            defer dir.close(io);
            settleCommentFile(io, dir, rc, streams);
        }
    }
    return rc;
}

/// After the editor path: a recorded check-in retires the comment file; a
/// failure keeps it and says how to reuse it.
fn settleCommentFile(io: std.Io, dir: std.Io.Dir, rc: cli.Exit, streams: cli.Streams) void {
    if (rc == .ok) {
        dir.deleteFile(io, comment_file_name) catch {};
    } else {
        streams.err.print("zit commit: your comment is kept in {s}; pass -M {s} to reuse it\n", .{ comment_file_name, comment_file_name }) catch {};
    }
}

fn runCommitInner(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
    checkout_root: ?NamedCheckout,
    session: *CommitSession,
) cli.Exit {
    // 1. Parse (B-commit-1). --help wins over everything (B-commit-18a); an
    //    unknown/dangling option is a usage error (B-commit-18b).
    const parsed = switch (cli.parseCommitArgs(gpa, verb_args) catch
        return commitOom(streams)) {
        .ok => |p| p,
        .unknown_option => |tok| {
            // A git user reaches for `--amend`/`--force` to rewrite the last
            // check-in, but zit history is immutable (B-commit-20): check-ins are
            // never rewritten. Teach the model at the point of error rather than
            // deferring the lesson to --help; a bare "unknown option" would read
            // as a typo and hide the immutable-history rule. Still a usage error:
            // exit 2, stderr only, and the try-help line is kept.
            if (std.mem.eql(u8, tok, "--amend") or
                std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f"))
            {
                streams.err.print(
                    "zit commit: '{s}' is not supported. zit history is immutable — " ++
                        "check-ins are never rewritten; record a new check-in that " ++
                        "supersedes the old one.\n",
                    .{tok},
                ) catch {};
                streams.err.writeAll("try 'zit commit --help'\n") catch {};
                return .usage;
            }
            return cli.usageError(streams, "commit", .unknown_option, tok);
        },
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(commit_help) catch {};
        return .ok;
    }

    // 2. A message is required (B-commit-5, a usage error) and must be
    //    meaningful (B-commit-4, an expected failure) — both before any I/O.
    // The comment comes from -m, from -M FILE, or — on a terminal,
    // or with --editor — from an editor over a template that lists what
    // this commit captures. The last needs the composed change set, so its
    // resolution waits until step 9; what is decided here is only whether
    // there is a way to get one at all. A dry run needs no comment.
    var early_message: ?[]const u8 = parsed.message;
    if (early_message == null) if (parsed.message_file) |mf| {
        early_message = readMessageFile(gpa, io, arena, mf) orelse {
            streams.err.print("zit commit: cannot read the message file '{s}'\n", .{mf}) catch {};
            return .failure;
        };
    };
    // A configured `editor` setting makes the editor path available
    // without a terminal, as --editor does.
    var ed_arena = std.heap.ArenaAllocator.init(gpa);
    defer ed_arena.deinit();
    var disc: ?std.Io.Dir = null;
    defer if (disc) |*d| d.close(io);
    const editor_configured = blk: {
        const ea = ed_arena.allocator();
        const dir = if (checkout_root) |c| c.dir else dblk: {
            const root = (cli.discoverRoot(ea, io) catch null) orelse break :blk false;
            disc = root.dir;
            break :dblk root.dir;
        };
        const r = cmd_sync.resolveSetting(gpa, io, ea, dir, amb, .editor) catch break :blk false;
        break :blk r.value.len != 0;
    };
    const editor_capable = parsed.editor != null or editor_configured or ((std.Io.File.stdin().isTty(io) catch false) and (std.Io.File.stderr().isTty(io) catch false));
    if (early_message == null and !parsed.dry_run and !editor_capable) {
        streams.err.writeAll(
            "zit commit: a check-in message is required\n" ++
                "  give one with -m <message>,\n" ++
                "  run from a terminal to compose it in $EDITOR,\n" ++
                "  or set an editor: zit settings editor <command>\n" ++
                "try 'zit commit --help'\n",
        ) catch {};
        return .usage;
    }
    if (early_message) |m| if (std.mem.trim(u8, m, " \t\r\n").len == 0) {
        streams.err.writeAll(
            "zit commit: aborting — empty check-in message\n",
        ) catch {};
        return .failure;
    };

    // 3. Resolve the author (B-commit-6): --user, else the environment default.
    const author = parsed.user orelse amb.user_env orelse {
        streams.err.writeAll(
            "zit commit: cannot determine the check-in author; pass --user <login>\n",
        ) catch {};
        return .failure;
    };

    // 3b. Autosync pull: see the freshest DAG before the fork guard asks
    //     whether the parent is still a leaf. Best-effort — offline commits
    //     must keep working, so a failure warns and this verb proceeds.
    if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "commit");

    // 4. The checkout root (B-commit-14) and the working state. Discovered from
    //    the cwd for the verb; NAMED by an in-process caller driving a checkout
    //    it created (`runCommitIn`). A named dir is borrowed, so it is not
    //    closed here.
    var root_dir: std.Io.Dir = undefined;
    var root_abs: []const u8 = undefined;
    var owns_root = false;
    if (checkout_root) |given| {
        root_dir = given.dir;
        root_abs = given.abs;
    } else {
        const root = (cli.discoverRoot(arena, io) catch
            return commitOom(streams)) orelse {
            streams.err.writeAll(not_a_repo_msg) catch {};
            return .failure;
        };
        root_dir = root.dir;
        root_abs = root.abs;
        owns_root = true;
    }
    defer if (owns_root) root_dir.close(io);

    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch |e|
        return checkoutFail(streams, "commit", e)) orelse
        return commitCheckoutMissing(streams);
    const branch = if (ck.branch.len == 0) "trunk" else ck.branch;
    const is_root = ck.version.len == 0;
    // A recorded merge parent (from `zit merge`) turns this into a merge commit:
    // it emits the two-token P card and clears the record on advance (B-merge-8/
    // 9). Empty == no pending merge → the ordinary single-parent path, untouched
    // (B-merge-9 negative space). A merge is never a root (it requires a prior
    // version), so `is_merge` implies `!is_root`.
    const is_merge = ck.merge_parent.len != 0;
    // A recorded special-merge op (cherrypick/backout, from `zit merge
    // --cherrypick/--backout`) is NOT a merge: the check-in stays SINGLE-parent
    // (REV is never a merge parent, B-merge-33) and carries one Q card recording
    // the provenance (§4.7.14). `special_op` is the q-sign byte glued onto REV's
    // hash; empty means no pending special op → today's path, untouched. A
    // special op requires a prior version, so it too implies `!is_root`. At most
    // one of `is_merge`/`has_special` is ever set (the merge verb refuses to
    // start a second pending operation).
    const has_special = ck.special_op.len != 0;
    const q_sign: ?artifact.QSign = if (!has_special) null else switch (ck.special_op[0]) {
        '+' => .cherrypick,
        '-' => .backout,
        else => unreachable, // `merge` only ever writes '+' or '-'
    };
    // Checkout-record consistency the merge verb maintains: a pending merge or
    // special op never coexists with an unborn (root) checkout, the two are
    // mutually exclusive (`merge` refuses to start a second pending operation),
    // and a Q card is minted exactly for a special op.
    std.debug.assert(!is_root or (!is_merge and !has_special));
    std.debug.assert(!(is_merge and has_special));
    std.debug.assert((q_sign != null) == has_special);

    // repostate §6.2: a branch-root manifest is a CHILD of the branch point, so
    // `--branch` needs a parent to fork from. The root check-in's default-branch
    // start is `init`/§6.1's job, not a fork — reject `--branch` on an unborn
    // checkout before any capture.
    if (is_root and parsed.branch != null) {
        streams.err.writeAll(
            "zit commit: --branch needs a parent check-in; the first commit starts branch 'trunk'\n",
        ) catch {};
        return .failure;
    }
    // An empty `--branch` value names no branch (§6.2). Reject it here, with the
    // other early arg checks, before any store is opened.
    if (parsed.branch) |bname| {
        if (bname.len == 0) {
            streams.err.writeAll("zit commit: --branch name must not be empty\n") catch {};
            return .failure;
        }
    }
    // The branch the new check-in lands on: `--branch` overrides, else the
    // parent's branch (trunk when unset). `compose_branch` is the name whose
    // §6.2 branch/sym tags this commit emits — always trunk for the root
    // (establishing it), the explicit `--branch` for a child that switches, and
    // null for a child that stays on its parent's branch (no tag, inherited).
    const target_branch = parsed.branch orelse branch;
    const compose_branch: ?[]const u8 = if (is_root) target_branch else parsed.branch;

    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "commit", e);
    defer set.deinit();

    // 5. Open the log read-write (append blobs + manifest, then fsync), under
    //    the store's write lock — released by the explicit close after the
    //    log commits, so autosync and the drift advisory below run unlocked.
    var file = cli.openStoreAppend(io, root_dir) catch
        return commitLogFail(streams, error.Storage);
    var file_open = true;
    defer if (file_open) file.close(io);
    const log_st = file.stat(io) catch return commitLogFail(streams, error.Storage);
    var fs = storage.FileStorage.init(io, file, log_st.size);
    // `hash_kind` 0 is a don't-care on a non-empty file: `open` reads the stored
    // kind from the header. The repo was named SHA3-256 by `init` (B-commit-16).
    var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e|
        return commitLogFail(streams, e);
    var log_open = true;
    defer if (log_open) log.deinit(gpa);

    // One frame read for the whole commit gate. Every setting this verb
    // resolves from here on answers its REPOSITORY tier out of `cfg`, so the
    // store is opened once (above, for the write lock) instead of once per
    // name — the shape `update` and `open` already have. Read after the
    // autosync pull, which is the only thing that can rewrite the frame under
    // us. An unreadable frame is null, which every reader below treats as "this
    // repository said nothing", leaving the other tiers reachable.
    var commit_config: ?logstore.OwnedConfig = log.readConfig(gpa) catch null;
    defer if (commit_config) |*c| c.deinit(gpa);
    const cfg: ?*const logstore.Config = if (commit_config) |*c| &c.config else null;

    // 6. One arena owns every card's storage (F slice, arena copies of each
    //    captured hash + filename); `create` reads card fields only, so the CLI
    //    supplies the arena. Content buffers are read on `gpa` and freed per file.
    var m_arena = std.heap.ArenaAllocator.init(gpa);
    defer m_arena.deinit();
    const ma = m_arena.allocator();

    // 6b. Branch prep (repostate §6.2), only when `--branch` starts/switches a
    //     branch. The derived read-only view answers two questions the append
    //     transaction cannot: does NAME already name an open branch (§6.2 forbids
    //     re-creating one), and which of the parent's active sym-* tags must this
    //     commit cancel so the check-in leaves the parent's branch(es). The view
    //     is opened read-only and freed before any append; the sym names are
    //     duped onto `ma` so they outlive it. `--tag`/`--close` need no view.
    var parent_syms: []const []const u8 = &.{};
    if (!is_root) {
        var repo: Repository = undefined;
        repo.open(gpa, io, root_dir, .{ .mode = .full }) catch |e| return repoOpenFail(streams, "commit", e);
        defer repo.deinit(gpa);

        // Fork guard (fossil's commit-time warning): a commit whose
        // parent already has a same-branch child would silently fork the
        // branch. The condition is fossil's — the parent must still be a
        // LEAF — so committing onto the older head of an already-forked
        // branch stays legal (that head is a leaf too). `--branch` is exempt
        // (the check-in leaves the branch, a fork by name), as is
        // `--allow-fork` (the deliberate case). No network: this also fires
        // for offline divergence that no autosync could have prevented.
        if (parsed.branch == null and !parsed.allow_fork) {
            if (repo.currentCheckoutRid()) |prid| {
                if (!repo.state().isLeaf(prid)) {
                    const tip = repo.resolve(branch, .checkin) catch null;
                    if (tip) |trid| {
                        streams.err.print(
                            "zit commit: not the tip of branch '{s}' — check-in {s} is newer.\n" ++
                                "run 'zit update' to join it, or commit with --allow-fork to fork deliberately\n",
                            .{ branch, repo.hashOf(trid) },
                        ) catch {};
                    } else {
                        streams.err.print(
                            "zit commit: the current check-in is no longer the tip of branch '{s}'.\n" ++
                                "run 'zit update' to join the tip, or commit with --allow-fork to fork deliberately\n",
                            .{branch},
                        ) catch {};
                    }
                    return .failure;
                }
            }
        }

        if (parsed.branch) |bname| {
            const open_leaves = repo.state().openLeavesOf(gpa, bname) catch return commitOom(streams);
            defer gpa.free(open_leaves);
            if (open_leaves.len != 0) {
                streams.err.print(
                    "zit commit: branch '{s}' already exists with an open leaf; commit onto it or choose a new name\n",
                    .{bname},
                ) catch {};
                return .failure;
            }
            // The parent's inherited sym-* tags to cancel, minus the one we
            // re-establish, duped onto `ma` (the view's interned names die at
            // `repo.deinit`).
            if (repo.currentCheckoutRid()) |prid| {
                const want_sym = std.mem.concat(ma, u8, &.{ sym_prefix, bname }) catch return commitOom(streams);
                const entries = repo.state().tagsOf(gpa, prid) catch return commitOom(streams);
                defer gpa.free(entries);
                parent_syms = selectInheritedSyms(ma, entries, want_sym) catch return commitOom(streams);
            }
        }
    }

    // 7. Parent manifest (child only): draft-fossil-submanifests-00 §§5.1–5.2
    //    require an S-bearing parent to be resolved to its complete F-set
    //    before carry-forward, no-op, or same-path delta-base decisions. Load it
    //    before capture so resolver corruption cannot leave a captured blob.
    var pm: ?artifact.Manifest = null;
    defer if (pm) |*m| m.deinit();
    // Read path: the parent is prior (possibly fossil-origin) history, so it is
    // parsed with the legacy F-card trailing-separator tolerance.
    var pm_normalized: ?[]u8 = null;
    defer if (pm_normalized) |b| gpa.free(b);
    var parent_bytes: ?[]u8 = null;
    defer if (parent_bytes) |b| gpa.free(b);
    var baseline_bytes: ?[]u8 = null;
    defer if (baseline_bytes) |b| gpa.free(b);
    var baseline_manifest: ?artifact.Manifest = null;
    defer if (baseline_manifest) |*bm| bm.deinit();
    var baseline_normalized: ?[]u8 = null;
    defer if (baseline_normalized) |b| gpa.free(b);
    var parent_f: []const artifact.FCard = &.{};
    var parent_manifest_rid: ?logstore.Rid = null;
    // path → parent blob rid: the delta base for the same path this commit.
    var parent_blob = std.StringHashMap(logstore.Rid).init(gpa);
    defer parent_blob.deinit();
    if (!is_root) {
        var pdigest_buf: [digest_len]u8 = undefined;
        const pdigest = hexToDigest(ck.version, &pdigest_buf) orelse return commitParentCorrupt(streams, "the checkout names it in a form this build cannot decode");
        const prid = log.lookup(pdigest) orelse return commitParentCorrupt(streams, "not present in this repository");
        parent_manifest_rid = prid;
        parent_bytes = (log.content(gpa, prid) catch |e| return commitContentFail(streams, e)) orelse
            return commitParentCorrupt(streams, "its content is unreadable");
        const parent_im = artifact.parseForImport(gpa, parent_bytes.?) catch |err| switch (err) {
            error.OutOfMemory => return commitOom(streams),
            else => |pe| return commitParentCorrupt(streams, @errorName(pe)),
        };
        pm = parent_im.manifest;
        pm_normalized = parent_im.normalized;
        if (pm.?.kind != .manifest) return commitParentCorrupt(streams, "it is not a check-in manifest");
        if (pm.?.s.len != 0) {
            Repository.flattenTreeFromLog(gpa, &log, &pm.?) catch |err| switch (err) {
                error.OutOfMemory => return commitOom(streams),
                // NOT collapsed: a §5.2 tree-walk fault, an unreadable node and
                // real damage are different repairs.
                else => |te| return commitParentCorrupt(streams, @errorName(te)),
            };
        }
        if (pm.?.b) |b| {
            // §8.2/§8.3 — a delta parent's own F run is a PATCH, not a file
            // set. Whole-tree capture masks that on the default path (every
            // managed file is captured regardless), but carry-forward and
            // the delta-frame base hints read `parent_f` as the parent's
            // EFFECTIVE set, and the dialect emission below diffs against
            // the true baseline — so resolve it, hard-failure discipline,
            // and retain the raw baseline beside the merged result.
            var bdigest_buf: [digest_len]u8 = undefined;
            const bdigest = hexToDigest(b.hash, &bdigest_buf) orelse return commitParentCorrupt(streams, "its baseline is named in a form this build cannot decode");
            const brid = log.lookup(bdigest) orelse return commitParentCorrupt(streams, "its baseline is not present in this repository");
            baseline_bytes = (log.content(gpa, brid) catch |e| return commitContentFail(streams, e)) orelse
                return commitParentCorrupt(streams, "its baseline content is unreadable");
            const bim = artifact.parseForImport(gpa, baseline_bytes.?) catch |err| switch (err) {
                error.OutOfMemory => return commitOom(streams),
                else => |pe| return commitParentCorrupt(streams, @errorName(pe)),
            };
            baseline_manifest = bim.manifest;
            baseline_normalized = bim.normalized;
            if (baseline_manifest.?.kind != .manifest or baseline_manifest.?.b != null)
                return commitParentCorrupt(streams, "its baseline is not a baseline manifest");
            const BaselineCtx = struct {
                bm: *const artifact.Manifest,
                fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
                    const self_ctx: *@This() = @ptrCast(@alignCast(ctx));
                    _ = hash; // one candidate, already matched to the B card above
                    return zit.manifestFilesOf(self_ctx.bm);
                }
            };
            var bctx = BaselineCtx{ .bm = &baseline_manifest.? };
            var walk = artifact.enumerate(zit.manifestFilesOf(&pm.?), .{
                .ctx = @ptrCast(&bctx),
                .resolveFn = BaselineCtx.resolve,
            }) catch return commitParentCorrupt(streams, "its baseline does not enumerate");
            var effective: std.ArrayList(artifact.FCard) = .empty;
            while (walk.next()) |fc| {
                if (fc.hash == null) continue; // merged delete markers carry no file
                effective.append(ma, fc) catch return commitOom(streams);
            }
            parent_f = effective.items;
        } else {
            parent_f = pm.?.flattened orelse pm.?.f;
        }
        // Sparse-cone RFC section 4.4: a pending cross-cone merge left its
        // resolved out-of-cone outcomes in the merge_overlay section. Apply
        // them onto the parent's effective set BEFORE anything reads it --
        // the compose's carry-forward then records theirs' hashes, and the
        // base-hint loop below sees the overlaid cards too. The section is
        // cleared by the post-commit record write, same one-shot rule as
        // merge_parent itself.
        {
            const overlay_payload = checkout.readSectionAlloc(gpa, io, root_dir, .merge_overlay) catch |e| switch (e) {
                error.OutOfMemory => return commitOom(streams),
                else => return commitLogFail(streams, error.Storage),
            };
            if (overlay_payload) |payload| {
                defer gpa.free(payload);
                // Decoded entries BORROW the payload; the merged cards below
                // borrow the entries. Re-home the bytes in the commit arena
                // so everything outlives this block together.
                const payload_owned = ma.dupe(u8, payload) catch return commitOom(streams);
                const overlay_entries = update.decodeOverlay(ma, payload_owned) catch
                    return commitParentCorrupt(streams, "the pending merge's overlay record is unreadable");
                var merged: std.ArrayList(artifact.FCard) = .empty;
                for (parent_f) |fc| {
                    var replaced = false;
                    var deleted = false;
                    for (overlay_entries) |entry| {
                        if (!std.mem.eql(u8, entry.path, fc.filename)) continue;
                        if (entry.hash) |h| {
                            var card = fc;
                            card.hash = h;
                            card.perm = entry.perm;
                            merged.append(ma, card) catch return commitOom(streams);
                            replaced = true;
                        } else {
                            deleted = true;
                        }
                        break;
                    }
                    if (!replaced and !deleted) merged.append(ma, fc) catch return commitOom(streams);
                }
                // Overlay entries for paths the parent lacks are additions.
                for (overlay_entries) |entry| {
                    const h = entry.hash orelse continue;
                    var known = false;
                    for (parent_f) |fc| {
                        if (std.mem.eql(u8, entry.path, fc.filename)) {
                            known = true;
                            break;
                        }
                    }
                    if (!known) merged.append(ma, .{
                        .filename = entry.path,
                        .hash = h,
                        .perm = entry.perm,
                        .old_name = null,
                        .mode = .regular,
                    }) catch return commitOom(streams);
                }
                std.mem.sort(artifact.FCard, merged.items, {}, struct {
                    fn less(_: void, a: artifact.FCard, b: artifact.FCard) bool {
                        return artifact.compareBytes(a.filename, b.filename) == .lt;
                    }
                }.less);
                parent_f = merged.items;
            }
        }

        // A parent F card whose blob resolves in the log maps its path to that
        // blob's rid; a card without a resolvable blob is simply absent (its
        // child blob then stores full — the base hint is optional).
        for (parent_f) |fc| {
            const h = fc.hash orelse continue;
            var bd_buf: [digest_len]u8 = undefined;
            const bd = hexToDigest(h, &bd_buf) orelse continue;
            const brid = log.lookup(bd) orelse continue;
            parent_blob.put(fc.filename, brid) catch return commitOom(streams);
        }
    }

    // 8. Capture the working-tree files and pick the merge policy (B-commit-8/
    //    10/11/12). This is the only place the all-files/partial split is
    //    decided; all merge/sort/no-op arithmetic is deferred to composeFCards.
    var captured: std.ArrayList(artifact.FCard) = .empty;
    // Resolved once for the whole commit, not per file: the tier chain
    // reads the working tree, and a policy that changed mid-commit would make
    // the refusal depend on capture order. `--no-checks` waives this the same
    // way it waives `before-commit` — one flag for "skip the commit-time
    // checks" rather than a second spelling for the same intent. Unreadable
    // settings resolve to empty, which REFUSES the odd content rather than
    // admitting it: the safe direction for a check, and the opposite of the
    // choice `signingSettings` makes, where guessing "sign" would fail a
    // commit that should have succeeded.
    var content_notes: std.ArrayList(ContentNote) = .empty;
    const content_policy: ContentPolicy = .{
        .binary_glob = projectSetting(io, arena, root_dir, amb, cfg, .binary_glob),
        // `crnl-glob` is the fallback spelling, consulted only when no
        // tier supplies `crlf-glob`.
        .crlf_glob = (cmd_sync.resolveCrlfGlob(io, arena, root_dir, amb, cfg) catch
            zit.settings.Resolved{ .value = "", .source = .default }).value,
        // Fossil's `encoding-glob`, under the same opt-in rule:
        // unset means this project has no opinion about encodings.
        .encoding_glob = projectSetting(io, arena, root_dir, amb, cfg, .encoding_glob),
        .waived = parsed.no_checks,
    };
    const policy: cli.ComposePolicy = if (parsed.paths.len == 0) .snapshot else .carry_forward;
    if (parsed.paths.len == 0) {
        // All-files: capture every managed path (a deletion is simply absent
        // from the set → not captured → dropped by composeFCards, B-commit-12).
        for (set.iterate()) |path| {
            switch (captureFile(gpa, io, ma, root_dir, &log, path, parent_blob.get(path), content_policy, &content_notes)) {
                .card => |c| captured.append(ma, c) catch return commitOom(streams),
                .missing => return commitMissing(streams, path),
                .not_regular => |is_symlink| return commitNotRegular(streams, path, is_symlink),
                .io_error => return commitCaptureIo(streams, path),
                .too_large => |size| return commitTooLarge(streams, path, size),
                .content_class => |f| return commitContentClass(streams, path, f),
                .oom => return commitOom(streams),
            }
        }
    } else {
        // Partial: capture only the named managed files (B-commit-11). The
        // unnamed parent files are carried forward by composeFCards, not here.
        const cwd_abs = (cli.currentPathAlloc(arena, io) catch
            return commitOom(streams)) orelse
            return commitLogFail(streams, error.Storage);
        for (parsed.paths) |arg| {
            const key = switch (cli.repoRelative(arena, root_abs, cwd_abs, arg) catch
                return commitOom(streams)) {
                .key => |k| k,
                .root, .outside => return commitNotTracked(streams, arg),
            };
            if (!set.contains(key)) return commitNotTracked(streams, key);
            switch (captureFile(gpa, io, ma, root_dir, &log, key, parent_blob.get(key), content_policy, &content_notes)) {
                .card => |c| captured.append(ma, c) catch return commitOom(streams),
                .missing => return commitMissing(streams, key),
                .not_regular => |is_symlink| return commitNotRegular(streams, key, is_symlink),
                .io_error => return commitCaptureIo(streams, key),
                .too_large => |size| return commitTooLarge(streams, key, size),
                .content_class => |f| return commitContentClass(streams, key, f),
                .oom => return commitOom(streams),
            }
        }
    }

    // 8b. Stamp the pending rename (§4.7.6): if `zit mv` recorded a rename and
    //     NEW was captured this commit, mark that card with OLD as the fourth
    //     (old-name) token plus an explicit permission token — the grammar keeps
    //     old-name in the fourth field only when a perm token precedes it, and
    //     §4.7.6 SHOULD-w for a regular file (an executable keeps its `x`). The
    //     card is captured under NEW, so composeFCards sorts it under NEW (§4.6)
    //     and the perm/old_name fields ride through the sort/no-op logic
    //     untouched. `carried` gates the one-shot clear (step 12b): a partial
    //     commit that does not name NEW leaves the record pending (B-mv-8). OLD
    //     is duped onto `ma` so the card never aliases the record freed at clear.
    var carried: std.ArrayList([]const u8) = .empty;
    for (set.pendingRenames()) |rn| {
        for (captured.items) |*c| {
            if (std.mem.eql(u8, c.filename, rn.new)) {
                c.old_name = ma.dupe(u8, rn.old) catch return commitOom(streams);
                c.perm = c.perm orelse "w";
                carried.append(ma, c.filename) catch return commitOom(streams);
                break;
            }
        }
    }

    // Managed-set agreement: every captured card names a path in the managed set
    // (all-files iterates it; partial gates each arg on `set.contains`), so the
    // manifest assembles only over tracked files.
    if (std.debug.runtime_safety) {
        for (captured.items) |c| std.debug.assert(set.contains(c.filename));
    }

    // 9. Compose the F-set and map the no-op verdict (B-commit-9). Guarded
    //    before any durable write — nothing is committed on a no-op. A rename
    //    always makes NEW differ from the parent's names, so it is never a no-op.
    // Sparse-cone RFC section 4.2: a commit from a sparse checkout unions the
    // in-cone worktree state with the baseline's out-of-cone entries carried
    // verbatim. The reader-indistinguishability rule is the point: the minted
    // check-in is ordinary, and no reader can tell it was committed sparsely.
    var commit_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) {
        error.OutOfMemory => return commitOom(streams),
        else => return commitLogFail(streams, error.Storage),
    };
    defer if (commit_cone) |*c| c.deinit(gpa);

    const composed = cli.composeFCards(ma, .{
        .parent = parent_f,
        .captured = captured.items,
        .policy = policy,
        .sparse = if (commit_cone) |*c| c else null,
    }) catch return commitOom(streams);
    // A pending merge / cherrypick / backout is itself the change: a convergent
    // merge (both sides made the same edit) composes to F cards identical to the
    // parent's, so `unchanged` goes true even though a second parent (P card) or
    // a provenance record (Q card) is queued. Refusing there wedges the tree with
    // `undo` as the only escape and silently drops the merge parent (blocker B5),
    // so the no-op verdict applies only to ordinary commits.
    if (composed.unchanged and !is_merge and !has_special and !parsed.allow_empty) {
        if (is_root)
            streams.err.writeAll(
                "zit commit: nothing to commit (no tracked files) — use 'zit add <file>'\n",
            ) catch {}
        else
            streams.err.writeAll("zit commit: nothing to commit (no changes)\n") catch {};
        return .failure;
    }

    // 9b. A recorded merge must not be committed while any file it captures still
    //     carries an unresolved conflict marker (B-merge-10). Re-read each
    //     captured working-tree file and scan for `merge3.conflict_sentinel` at a
    //     line start; any hit refuses (exit 1, offending path(s) to stderr) with
    //     no check-in minted and the merge parent still recorded — there is no
    //     override flag; in-tree resolution or `zit undo` is the only way forward.
    //     Gated on `is_merge`/`has_special`, so an ordinary commit reads nothing
    //     extra and its bytes/I/O are unperturbed. Placed before any durable log
    //     write, so a refusal commits nothing. A cherrypick/backout conflict is
    //     surfaced identically (B-merge-35, the conflict model verbatim).
    if (is_merge or has_special) {
        var unresolved: usize = 0;
        for (captured.items) |c| {
            const cbytes = root_dir.readFileAlloc(io, c.filename, gpa, .limited(max_tracked_file_bytes)) catch |e|
                return switch (e) {
                    error.OutOfMemory => commitOom(streams),
                    else => commitCaptureIo(streams, c.filename),
                };
            defer gpa.free(cbytes);
            if (hasConflictMarker(cbytes)) {
                streams.err.print("zit commit: unresolved conflict in {s}\n", .{c.filename}) catch {};
                unresolved += 1;
            }
        }
        if (unresolved != 0) {
            streams.err.print(
                "zit commit: {d} unresolved conflict(s); resolve and commit, or run 'zit undo'\n",
                .{unresolved},
            ) catch {};
            return .failure;
        }
        // Every conflict this merge produced is resolved: remember how.
        // Placed after the refusal so a precedent can only ever be learned from
        // bytes that passed the same gate the check-in did.
        _ = cmd_precedent.promoteAtCommit(gpa, io, root_dir, streams);
    }

    // The change set is composed and every refusal that needs no
    // comment has had its say: a dry run reports here, and a comment that is
    // still missing is composed in the editor over exactly this list.
    const change_lines = commitChangeLines(arena, parent_f, composed.f) catch return commitOom(streams);
    if (parsed.dry_run) {
        for (change_lines) |l| streams.err.print("{s} {s}\n", .{ l.word, l.path }) catch {};
        streams.err.print("(dry run: would record {d} file(s) on branch {s}; nothing was written)\n", .{ composed.f.len, branch }) catch {};
        return .ok;
    }
    const message: []const u8 = early_message orelse blk: {
        const got = composeMessageInEditor(gpa, io, arena, root_dir, root_abs, streams, amb, cfg, parsed.editor, branch, if (is_root) "" else ck.version, change_lines) orelse return .failure;
        session.comment_file_used = true;
        break :blk got;
    };

    // 9c. submanifests §6.2 canonical tree construction, behind the §8.1 gate.
    //     Default OFF: the setting is `false` on every repository that has not
    //     been opted in by an operator act (`zit submanifest enable`), so this
    //     branch does not run and every check-in this verb mints keeps today's
    //     bytes exactly. §8.2 forbids enabling it on any repository that is not a
    //     disposable test fixture. What the branch owns is the library's storage
    //     contract: `subs` arrives in the order §6.2 requires ("Sub-manifest
    //     artifacts are stored before the manifest that references them"), so
    //     storing it front to back is the whole obligation — no heights, groups,
    //     or keys cross the boundary.
    var tree: ?artifact.TreeBuild = null;
    defer if (tree) |*t| t.deinit();
    var r_hex: [32]u8 = undefined;
    var r_card: ?artifact.RCard = null;
    const emission_decision = submanifestEmission(cfg);
    const emission_k: ?u4 = emission_decision.emission.treeK();
    reportSubmanifestOutOfRange(streams, emission_decision.emission);
    if (emission_k) |k| {
        tree = buildFullTree(gpa, composed.f, k) catch return commitOom(streams);
        if (tree != null) {
            for (tree.?.subs) |sub| {
                var sdigest: [digest_len]u8 = undefined;
                std.crypto.hash.sha3.Sha3_256.hash(sub.bytes, &sdigest, .{});
                // No delta base: a sub-manifest is content-addressed and shared
                // across check-ins, so there is no "same path in the parent" to
                // delta against the way a blob or a manifest has.
                _ = log.putArtifact(&sdigest, sub.bytes, null) catch |e| return commitLogFail(streams, e);
            }
            // submanifests §8.1 — "When the first S-bearing artifact is stored,
            // an implementation SHOULD record a local seen-submanifest indicator
            // so operational surfaces can warn before the setting is enabled or
            // a pre-S client is granted access." The sync receive path already
            // does this at its own persist boundary (`persistStore`); this is the
            // other place S-bearing artifacts enter a log, and it is the one that
            // matters most — a repository whose own commits mint trees must arm
            // the §7.2 transfer gate even after the operator disables the setting
            // again, since §8.1's "Disabling ... cannot retire existing ones".
            // Written only on the false->true edge, so a steady-state enabled
            // repository appends no extra config frame per commit.
            setSeenSubmanifest(gpa, &log) catch |e| return commitLogFail(streams, e);
        }
    }

    // 9d. The R aggregate content checksum, EVERY shape (§6.3; Fossil emits it
    //     by default). Flat: over the manifest's own F run. Tree (§5.5): "the R
    //     card MUST be computed over the walk enumeration of Section 5.1 ...
    //     after which the computation of [FossilArtifact], Section 6.3.2 applies
    //     byte-for-byte unchanged" — and for a freshly constructed tree that
    //     enumeration is `composed.f` in order, the very list the construction
    //     partitioned. One computation serves both shapes.
    //
    //     The content comes from the LOG, keyed by the card's own hash, not
    //     from a second read of the working tree. R exists to detect an F run
    //     that disagrees with the content it names, so computing the two from
    //     different sources defeats it: a file edited between capture (step 8)
    //     and here would mint a check-in whose R card contradicts its own F
    //     hashes. Every entry is present — captured this commit or carried
    //     forward from the parent — so a miss is a corrupt repository, and it
    //     costs no extra working-tree I/O.
    {
        var agg: artifact.ChecksumAggregator = .{};
        var r_computable = true;
        for (composed.f) |f| {
            const fhash = f.hash orelse return commitParentCorrupt(streams, "a parent F card carries no hash");
            var fdigest_buf: [digest_len]u8 = undefined;
            const fdigest = hexToDigest(fhash, &fdigest_buf) orelse return commitParentCorrupt(streams, "a parent F card names a blob in an undecodable form");
            const frid = log.lookup(fdigest) orelse {
                // Sparse-cone RFC section 4.3: over a SPARSE store an elided
                // carried-forward blob makes the aggregate uncomputable, and
                // the commit MUST omit R rather than fabricate or fail. The
                // absence must be the STORE cone's doing -- a dense store
                // missing a parent blob is still the corruption it always was.
                var probe = zit.repocache.readStoreCone(gpa, io, root_dir);
                if (probe) |*c| {
                    const covered = c.covers(f.filename);
                    c.deinit(gpa);
                    if (!covered) {
                        r_computable = false;
                        break;
                    }
                }
                return commitParentCorrupt(streams, "a parent F card names a blob this repository lacks");
            };
            const fbytes = (log.content(gpa, frid) catch |e| return commitContentFail(streams, e)) orelse
                return commitParentCorrupt(streams, "a parent F card's blob is unreadable");
            defer gpa.free(fbytes);
            agg.update(f.filename, fbytes);
        }
        if (r_computable) {
            r_card = .{ .checksum = agg.final(&r_hex) };
        }
    }

    // 9e. Dialect continuation: once this repository carries delta manifests
    //     (a fossil-imported history, or an earlier delta commit here), keep
    //     emitting them — a multi-megabyte flat manifest in a delta-dialect
    //     store changes the repository's shape and bloats it. The baseline is
    //     the parent when the parent is flat, or the parent's OWN baseline
    //     when it is a delta (§4.7.3: never a delta of a delta). Skipped for
    //     tree emission (§4.4: B and S never mix), for roots, and whenever
    //     the patch would not be smaller than the flat F run.
    var delta_b: ?artifact.BCard = null;
    var delta_f: []const artifact.FCard = &.{};
    if (tree == null and !is_root and pm.?.s.len == 0 and
        (pm.?.b != null or seenDeltaManifest(gpa, &log)))
    dialect: {
        const base_raw: []const artifact.FCard = if (pm.?.b != null) baseline_manifest.?.f else pm.?.f;
        const base_hash: []const u8 = if (pm.?.b) |b| b.hash else ck.version;
        var cards: std.ArrayList(artifact.FCard) = .empty;
        var ci: usize = 0;
        var bi: usize = 0;
        while (ci < composed.f.len or bi < base_raw.len) {
            const order: std.math.Order = if (ci >= composed.f.len)
                .gt
            else if (bi >= base_raw.len)
                .lt
            else
                artifact.compareBytes(composed.f[ci].filename, base_raw[bi].filename);
            switch (order) {
                .lt => {
                    cards.append(ma, composed.f[ci]) catch return commitOom(streams);
                    ci += 1;
                },
                .gt => {
                    // Present in the baseline, absent now: a delete marker
                    // (an F card with no hash, §4.7.6).
                    cards.append(ma, .{
                        .filename = base_raw[bi].filename,
                        .hash = null,
                        .perm = null,
                        .old_name = null,
                        .mode = .regular,
                    }) catch return commitOom(streams);
                    bi += 1;
                },
                .eq => {
                    const c = composed.f[ci];
                    const bl = base_raw[bi];
                    const same = c.old_name == null and c.mode == bl.mode and
                        std.mem.eql(u8, c.hash orelse "", bl.hash orelse "") and
                        std.mem.eql(u8, c.perm orelse "", bl.perm orelse "");
                    if (!same) cards.append(ma, c) catch return commitOom(streams);
                    ci += 1;
                    bi += 1;
                },
            }
        }
        // The size guard, in BYTES rather than cards (a delete marker is a
        // dozen bytes, a full card ~80 — counting them equal suppresses the
        // dialect on exactly the small patches deltas exist for): a patch
        // whose F run is not smaller than the flat one carries no benefit,
        // so emit flat and leave the dialect to the next commit.
        var delta_bytes: usize = 0;
        for (cards.items) |c| delta_bytes += fCardCost(c);
        var flat_bytes: usize = 0;
        for (composed.f) |c| flat_bytes += fCardCost(c);
        if (delta_bytes >= flat_bytes) break :dialect;
        delta_b = .{ .hash = base_hash };
        delta_f = cards.items;
        // First delta authored HERE (parent-dialect fallback on a store whose
        // config predates the indicator): make the memory durable alongside.
        setSeenDelta(gpa, &log) catch |e| return commitLogFail(streams, e);
    }

    // 10. Assemble the manifest and serialize it (B-commit-17). The library owns
    //     the Z self-check, card order, escaping, and framing. Root establishes
    //     `trunk` via two propagating T cards (B-commit-13); a child carries one
    //     P card and no T card (inherits trunk by propagation).
    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    // Control tags (repostate §6.2 branch creation + artifact §4.7 close).
    // The root establishes trunk (two propagating tags — `--branch` is rejected
    // on the root); a child emits branch/sym/cancel tags only when `--branch`
    // switches it, plus `+closed` on `--close`. In the common no-flag child case
    // this is an empty slice, preserving today's bytes exactly. (`--tag` is
    // deferred behind the propagating-vs-singleton ambiguity, so it is absent.)
    const tags = cli.composeCommitTags(ma, .{
        .branch = compose_branch,
        .close = parsed.close,
        .parent_syms = parent_syms,
    }) catch return commitOom(streams);
    // A recorded merge extends the single primary parent to a two-token P card
    // (B-merge-8): `P <primary> <merge-parent>`, primary ancestor first (§4.7.13
    // / repostate §3.2 first-listed-is-primary), SP-joined so `appendPCard`
    // renders both tokens verbatim. The non-merge case keeps `count == 1` and
    // `parents == ck.version`, so today's manifest bytes are byte-identical.
    const parents_field: []const u8 = if (is_merge)
        std.mem.concat(ma, u8, &.{ ck.version, " ", ck.merge_parent }) catch return commitOom(streams)
    else
        ck.version;
    const child_parents = [_]artifact.PCard{.{
        .parents = parents_field,
        .count = if (is_merge) 2 else 1,
    }};
    // A recorded special-merge op mints exactly one Q card (§4.7.14, B-merge-33):
    // `Q +<rev>` (cherrypick) or `Q -<rev>` (backout), the sign glued onto the
    // target hash, baseline token OMITTED — the omitted-baseline default IS the
    // primary parent the merge used, so emitting it would be redundant. The
    // target borrows `ck.special_op` (which outlives `create`, exactly as the P
    // card borrows `ck.version`). The non-special case keeps an empty `q` slice,
    // so today's manifest bytes are byte-identical (B-merge-31).
    var q_cards: [1]artifact.QCard = undefined;
    const q_slice: []const artifact.QCard = if (q_sign) |s| blk: {
        q_cards[0] = .{ .sign = s, .target = ck.special_op[1..], .baseline = null };
        break :blk &q_cards;
    } else &.{};
    // Checkout-record agreement: a child's parent version resolved to a stored
    // manifest (its rid feeds the delta base and the P card); only a root has none.
    std.debug.assert(is_root or parent_manifest_rid != null);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = delta_b,
        .c = .{ .comment = message },
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = r_card,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        // submanifests §4.4: the file set is conveyed by the F run or the S run,
        // "never a mixture". With the gate off `tree` is null, so this is
        // `composed.f` and an empty S run — not one byte of difference.
        .f = if (tree != null) &.{} else if (delta_b != null) delta_f else composed.f,
        .j = &.{},
        .m = &.{},
        .p = if (is_root) &.{} else &child_parents,
        .q = q_slice,
        .s = if (tree) |t| t.s else &.{},
        .t = tags,
    };
    defer m.deinit(); // frees the empty arena_state; card storage is m_arena's
    const plain = artifact.create(&m, gpa) catch return commitOom(streams);
    defer gpa.free(plain);
    // `--sign`: clearsign the manifest through the operator's PGP
    // command and store the WRAPPED text as the artifact, fossil's own
    // behavior: the name hashes the wrapper too, readers strip it (§4.1.1)
    // to reach the cards, and the signature therefore survives every
    // byte-preserving path for free. The wrapped payload MUST strip back to
    // the exact input — a signer that rewrites the manifest (CRLF
    // normalization, dash-escaping a line, a charset filter) would sign
    // different bytes than the cards the reader interprets, so that is a
    // refusal, never a store.
    // The project's own pre-commit check, run BEFORE anything is minted
    // so a refusal costs nothing and leaves no artifact behind.
    switch (runBeforeCommit(gpa, io, arena, root_dir, streams, amb, cfg, parsed.no_checks)) {
        .ok => {},
        else => |e| return e,
    }

    const signing = signingSettings(io, arena, root_dir, amb, cfg);
    // fossil's `clearsign` setting: sign every commit without the caller
    // asking. `--sign` on top of it is a no-op, not a doubling.
    const sign = parsed.sign or signing.clearsign;
    const bytes: []const u8 = if (!sign) plain else blk: {
        const wrapped = clearsignBytes(gpa, io, amb, root_dir, signing.pgp_command, plain) orelse
            return commitSignFail(streams);
        if (!std.mem.eql(u8, artifact.stripSignatureWrapper(wrapped), plain)) {
            gpa.free(wrapped);
            return commitSignFail(streams);
        }
        break :blk wrapped;
    };
    defer if (sign) gpa.free(@constCast(bytes));
    // Correctness self-check (safety builds only): the emitted bytes round-trip
    // to a manifest. A failure means the CLI mis-populated a card — a bug caught
    // here, never shipped.
    if (std.debug.runtime_safety) {
        var check = artifact.parse(gpa, bytes) catch |e|
            std.debug.panic("commit produced an unparseable manifest: {s}", .{@errorName(e)});
        defer check.deinit();
        std.debug.assert(check.kind == .manifest);
    }

    // 11. Store the manifest and durably commit the log (B-commit-15 order). The
    //     frame is keyed by the binary digest; the reported name is the 64-hex.
    var mdigest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{});
    var mname_buf: [64]u8 = undefined;
    const manifest_name = artifact.artifactName(.sha3_256, bytes, &mname_buf);
    // §4.1/§7.2 — store the manifest, delta-encoded against the primary parent
    // manifest when materially smaller (else a full §4.1 container). The
    // reported name is the 64-hex of the UNCOMPRESSED manifest, so
    // delta/compression is address-transparent.
    _ = log.putArtifact(&mdigest, bytes, parent_manifest_rid) catch |e| return commitLogFail(streams, e);
    log.commit() catch |e| return commitLogFail(streams, e);
    // The append is durable; release the store lock before the steps that
    // reopen the store themselves (autosync's push, the §6.4 advisory's
    // retune write) — the OS lock is per open file description, so holding
    // this handle across those would wedge the process against itself.
    log.deinit(gpa);
    log_open = false;
    file.close(io);
    file_open = false;

    // 12a. Clear the pending rename now that the check-in carrying it is durable
    //      (B-mv-8, one-shot: consumed only by the commit that named NEW). Order
    //      is log-durable → managed-clear (fsync) → checkout-advance, so a crash
    //      between stages leaves a recoverable tree (B-commit-15 discipline). The
    //      no-rename common case skips this entirely, so today's bytes and I/O
    //      are unperturbed (B-mv-9).
    if (carried.items.len != 0) {
        for (carried.items) |new| set.clearRename(new);
        set.write(io, root_dir) catch |e| return managedFail(streams, "commit", e);
    }

    // 12. Advance the working state — only after the log is durable. The single
    //     non-atomic seam (B-commit-15): a failure here leaves the check-in in
    //     history but the working version un-advanced; a re-run re-parents from
    //     the old version (the durable log is never corrupted).
    // The pending merge OR special-merge op (if any) is consumed by this
    // check-in, so the advanced checkout clears BOTH (`merge_parent = ""`,
    // `special_op = ""`, B-merge-9/34 one-shot clear).
    // --integrate: the check-in is durable, so close the merged-in
    // leaf with the same control artifact `branch close` mints. A failure
    // here leaves the check-in standing and says what to run by hand.
    if (ck.integrate.len != 0) {
        const close_tags = cli.composeTagCards(ma, ck.integrate, .{ .sign = .add, .name = "closed", .raw = true, .value = null }) catch return commitOom(streams);
        var cname_buf: [64]u8 = undefined;
        if (main.mintControlArtifact(gpa, io, root_dir, amb, close_tags, &cname_buf)) |_| {
            streams.out.print("closed {s} (integrated)\n", .{ck.integrate[0..@min(ck.integrate.len, 12)]}) catch {};
        } else |_| {
            streams.err.print("zit commit: the merged branch was not closed; run 'zit branch close {s}'\n", .{ck.integrate[0..@min(ck.integrate.len, 12)]}) catch {};
        }
    }
    {
        var post_ck_buf: [checkout.max_ck_payload]u8 = undefined;
        const post_ck = checkout.encodeCk(&post_ck_buf, .{ .version = manifest_name, .branch = target_branch, .merge_parent = "", .special_op = "", .manifest_flags = "" });
        checkout.updateSections(io, root_dir, &.{
            .{ .tag = .checkout, .payload = post_ck },
            // The overlay is consumed by this check-in exactly as the pending
            // merge is: one-shot, cleared in the same splice.
            .{ .tag = .merge_overlay, .payload = null },
        }) catch return commitCheckoutWriteFail(streams);
    }

    // 13. Success (B-commit-2/3/19): the one check-in line to stdout, the full
    //     artifact hash as a fixed-position token, on the branch it landed on.
    //     `-q` suppresses it.
    if (!parsed.quiet) {
        streams.out.print("check-in {s} on {s}\n", .{ manifest_name, target_branch }) catch {};
        commitReceipt(streams, change_lines, content_notes.items);
    }

    // 13b. Autosync push: the new check-in reaches the saved remote
    //      immediately, fossil's collaboration default. Strictly after the
    //      success line — the local commit is already true — and void like the
    //      advisory below: no autosync outcome can change this verb's exit.
    if (!parsed.no_sync) cmd_sync.autosyncPush(gpa, io, arena, streams, "commit", amb);

    // 14. submanifests §6.4 post-commit drift advisory — strictly after the
    //     check-in line above, never before it and never interleaved with the
    //     commit itself: `commitSubmanifestAdvice` returns void, so it is
    //     structurally incapable of changing this verb's exit code.
    commitSubmanifestAdvice(
        gpa,
        io,
        root_dir,
        streams,
        composed.f,
        emission_k,
        emission_decision.settled_cost_shape,
        emission_decision.auto_retune,
        parsed.no_submanifest_advice,
    );
    return .ok;
}

/// Renders `zit commit: out of memory` and returns `.failure` (B-commit-15).
fn commitOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit commit: out of memory\n") catch {};
    return .failure;
}

/// draft-fossil-submanifests-00 §6.4's post-commit drift advisory: a re-tune
/// "MUST be manual and operator-initiated unless automatic re-tuning is
/// authorized below", and "A writer MAY re-tune automatically only while the
/// repository's submanifest-auto-retune setting -- a project-scope boolean,
/// default false -- is true ... setting it true is the durable
/// project-operator act authorizing subsequent automatic re-tunes." With
/// `auto_retune` false — the config field's own default, so every repository
/// predating this slice reads it as such — the two arms below are
/// byte-identical to the manual-only path: the pin moves only after an
/// explicit 'y' typed at the prompt, never automatically.
///
/// Called from `runCommit` exactly once, strictly after the check-in is
/// durably recorded (never before, never interleaved with it) — `void` return
/// makes it structurally impossible for anything here to change the commit's
/// own exit code. Any failure along the way (allocator failure, a failed prompt
/// read, a failed write) simply stops and prints nothing further: the commit
/// already succeeded, and a broken advisory is not worth confusing that success.
///
/// `pinned_k` is `runCommit`'s own `emission_k` — the same `.treeK()`-derived
/// "enabled AND pinned-in-range" value step 9c already computed for the tree
/// build, so a repository that never turned submanifests on (`pinned_k ==
/// null`) reaches here and returns immediately, at zero added cost: no config
/// read, no `driftCheck` scan, no output (requirement 1). `no_advice` is
/// `--no-submanifest-advice`; when set, step 3 onward (the `driftCheck` scan
/// itself included) never runs (requirement 2), regardless of `auto_retune` —
/// an explicit per-commit "say nothing" wins over the durable opt-in.
/// `auto_retune` is `runCommit`'s own `emission_decision.auto_retune`, read
/// from the same config frame `pinned_k` was derived from.
fn commitSubmanifestAdvice(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
    entries: []const artifact.FCard,
    pinned_k: ?u4,
    settled_cost_shape: ?[32]u8,
    auto_retune: bool,
    no_advice: bool,
) void {
    if (no_advice) return;
    const k = pinned_k orelse return;

    // `entries` is `runCommit`'s exact canonical final F-set: §6.4 advice runs
    // after durability, but it must not reopen/flatten the checkout merely to
    // rediscover the same §6.2 tree input.
    const shape = submanifestCostShapeCheck(entries, settled_cost_shape);
    if (shape.settled) return;
    const drift = (zit.submanifest.driftCheck(gpa, entries, k, .sha3_256) catch return) orelse {
        _ = submanifestWrite(
            gpa,
            io,
            root_dir,
            null,
            null,
            replaceSettledCostShape(shape.digest),
            streams,
        );
        return;
    };

    // §6.4's advisory line — exact wording, one line, stdout. Printed
    // regardless of `auto_retune`: §6.4's automatic path still advises, it
    // just skips the prompt that would otherwise gate the write.
    streams.out.print(
        "submanifest-k has drifted from {d} to {d} ({d:.1}% read-cost improvement)\n",
        .{ k, drift.recommended.k, drift.margin_pct },
    ) catch return;

    if (auto_retune) {
        if (!submanifestWrite(
            gpa,
            io,
            root_dir,
            null,
            drift.recommended.k,
            replaceSettledCostShape(shape.digest),
            streams,
        )) return;
        streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {};
        streams.out.writeAll(
            "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
                "leaf, once. No history is rewritten and no reader behaves differently.\n",
        ) catch {};
        return;
    }

    if (!promptRetune(streams)) return;
    if (!submanifestWrite(
        gpa,
        io,
        root_dir,
        null,
        drift.recommended.k,
        replaceSettledCostShape(shape.digest),
        streams,
    )) return;
    streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {};
    streams.out.writeAll(
        "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
            "leaf, once. No history is rewritten and no reader behaves differently.\n",
    ) catch {};
}

const SubmanifestCostShapeCheck = struct {
    digest: [32]u8,
    settled: bool,
};

/// Internal cache decision only: §6.4 still governs advisory timing and output.
/// It needs only the already-composed F-set, never a repository read.
fn submanifestCostShapeCheck(
    entries: []const artifact.FCard,
    settled_cost_shape: ?[32]u8,
) SubmanifestCostShapeCheck {
    var digest: [32]u8 = undefined;
    const current = zit.submanifest.costShapeDigest(entries, &digest);
    return .{
        .digest = digest,
        .settled = if (settled_cost_shape) |cached| std.mem.eql(u8, cached[0..], current) else false,
    };
}

test "commit submanifest advice §6.4: settled equal shape needs no repository read" {
    const entries = [_]artifact.FCard{
        .{ .filename = "a", .hash = null, .perm = null, .old_name = null, .mode = .regular },
        .{ .filename = "b", .hash = null, .perm = null, .old_name = null, .mode = .regular },
    };
    const initial = submanifestCostShapeCheck(&entries, null);
    const cached = submanifestCostShapeCheck(&entries, initial.digest);
    try std.testing.expect(cached.settled);
}

/// The §6.4 drift-advisory confirm prompt: `retune now? [y/N] ` on stderr
/// (the same stream `readPasswordNoEcho` prompts on, `cmd/sync.zig`), one line
/// read from stdin, true only when its first byte is `y`/`Y`.
///
/// `tcgetattr(STDIN_FILENO)` success/failure is the sole interactivity signal,
/// checked BEFORE printing anything — matching `readPasswordNoEcho`'s own
/// precedent for "is this interactive" rather than inventing a second one for
/// this single call site. On a non-terminal stdin (every automated gate) this
/// returns false without printing the prompt or attempting a read, which is
/// what makes the non-interactive/advisory-only path exercised by ordinary
/// commit tests for free.
///
/// Not exercised by the automated gates (they never run against a real tty)
/// — this is the interactive path, same honesty `readPasswordNoEcho`'s own
/// doc comment states about itself.
fn promptRetune(streams: cli.Streams) bool {
    const fd = std.posix.STDIN_FILENO;
    _ = std.posix.tcgetattr(fd) catch return false;
    streams.err.writeAll("retune now? [y/N] ") catch return false;
    streams.err.flush() catch return false;
    var buf: [8]u8 = undefined;
    const n = std.posix.read(fd, &buf) catch return false;
    if (n == 0) return false;
    return buf[0] == 'y' or buf[0] == 'Y';
}

/// Bound on what a `before-commit` check may print. Generous -- a failing test
/// run is exactly when an operator wants the whole output -- but finite,
/// because an unbounded child could otherwise hold the commit open forever.
const max_check_output: usize = 8 * 1024 * 1024;

/// Run the project's `before-commit` check, if there is one this
/// checkout has agreed to run.
///
/// Ordering matters: this runs before the manifest is composed, so a refusal
/// mints nothing and the failed commit leaves the repository exactly as it was.
///
/// The three outcomes an operator can meet:
///   * no declaration, or one they wrote here -> run it (or nothing);
///   * a versioned declaration they have not accepted -> REFUSE, with the
///     command printed and the one command that accepts it;
///   * a versioned declaration that CHANGED since they accepted it -> refuse
///     and say so, because that is the case where a pull may have edited a
///     script they already agreed to run.
///
/// Refusing rather than warning is the deliberate choice. A warning lets an
/// operator believe they are gated when they are not, and "I thought the tests
/// ran" is a worse failure than "I had to type one command once".
fn runBeforeCommit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
    amb: cli.Ambient,
    cfg: ?*const logstore.Config,
    skip: bool,
) cli.Exit {
    const resolved = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .before_commit) catch
        return .ok; // unreadable settings must not invent a check
    if (resolved.value.len == 0) return .ok;

    if (skip) {
        // Loud on purpose, and it names the command that did not run: a silent
        // bypass is how `--no-verify` became a habit rather than a decision.
        streams.err.print(
            "zit commit: --no-checks: SKIPPING before-commit: {s}\n",
            .{resolved.value},
        ) catch {};
        return .ok;
    }

    // The trust record is a field of the frame the caller is holding,
    // so it is read from there rather than re-opening the store (`trustRecord`
    // stays for `zit trust`, which has no frame in hand). A null frame is a
    // repository whose config could not be read at all: no stored acceptance.
    const stored: []const u8 = if (cfg) |c| c.before_commit_trust else "";
    switch (zit.settings.trustVerdict(resolved.value, resolved.source, stored)) {
        .none => return .ok,
        .self_authored, .trusted => {},
        .untrusted => {
            streams.err.print(
                "zit commit: this repository ships a before-commit check that has not been accepted here:\n  {s}\n",
                .{resolved.value},
            ) catch {};
            streams.err.writeAll("read it, then run 'zit trust --yes' to accept it (or 'zit commit --no-checks' to skip once)\n") catch {};
            return .failure;
        },
        .changed => {
            streams.err.print(
                "zit commit: the before-commit check CHANGED since you accepted it:\n  {s}\n",
                .{resolved.value},
            ) catch {};
            streams.err.writeAll("re-read it, then run 'zit trust --yes' to accept the new text\n") catch {};
            return .failure;
        },
    }

    // Run it against THIS worktree, through a shell so a pipeline works --
    // which is the whole use case (`zig fmt --check src && zig build test`).
    // The command was explicitly trusted, so the shell is not a new exposure.
    var env_map = amb.environ.createMap(gpa) catch {
        streams.err.writeAll("zit commit: out of memory preparing before-commit\n") catch {};
        return .failure;
    };
    defer env_map.deinit();
    const result = std.process.run(gpa, io, .{
        .argv = &.{ "/bin/sh", "-c", resolved.value },
        .cwd = .{ .dir = root_dir },
        .environ_map = &env_map,
        .stdout_limit = .limited(max_check_output),
    }) catch {
        streams.err.print("zit commit: before-commit could not be run: {s}\n", .{resolved.value}) catch {};
        return .failure;
    };
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);
    const failed = switch (result.term) {
        .exited => |code| code != 0,
        else => true, // signalled or stopped is not success
    };
    // The check's OWN diagnostics are what an operator needs to fix the commit,
    // so they are relayed verbatim rather than summarised -- and on success too,
    // because a check that prints a warning has something to say either way.
    if (result.stdout.len != 0) streams.out.writeAll(result.stdout) catch {};
    if (result.stderr.len != 0) streams.err.writeAll(result.stderr) catch {};
    if (failed) {
        streams.err.writeAll("zit commit: before-commit failed; nothing was committed\n") catch {};
        return .failure;
    }
    return .ok;
}

/// The project-level settings one `update` needs, read from the ONE config
/// frame the verb already holds. Both degrade to empty when the setting is
/// absent or unreadable: empty `manifest` is the pre-setting behaviour (no build
/// stamps) and empty `empty_dirs` creates nothing, so a settings source that
/// cannot be read costs the operator neither the materialization nor any data.
const ProjectPolicy = struct {
    /// The stamp selection a checkout with no choice of its own inherits.
    manifest: []const u8,
    /// The directories to create after the tree is written.
    empty_dirs: []const u8,
};

/// Reads both of `ProjectPolicy`'s values in one place, before update forks
/// into its apply and retry arms, so the retry gets them for free and neither
/// arm can resolve a different value than the other. Fossil reads `empty-dirs`
/// at the same point (update.c calls `ensure_empty_dirs_created` before the
/// update begins, and its cached value is what the post-update call sees), so
/// a `.fossil-settings/empty-dirs` arriving in THIS update takes effect at the
/// next one — as in fossil.
fn projectPolicy(
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    amb: cli.Ambient,
    cfg: ?*const logstore.Config,
) ProjectPolicy {
    return .{
        .manifest = projectSetting(io, arena, root_dir, amb, cfg, .manifest),
        .empty_dirs = projectSetting(io, arena, root_dir, amb, cfg, .empty_dirs),
    };
}

/// One setting's value from the frame the caller holds, or empty when no tier
/// answers and no default applies. The `catch` is the whole reason it exists:
/// its callers want a string, not an error union, for a setting whose absence
/// is an ordinary state.
fn projectSetting(
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    amb: cli.Ambient,
    cfg: ?*const logstore.Config,
    setting: zit.settings.Setting,
) []const u8 {
    const r = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, setting) catch return "";
    return r.value;
}

/// The checkout record vanished between discovery and read (B-commit-15).
fn commitCheckoutMissing(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit commit: cannot read checkout state: .zit-checkout is missing\n") catch {};
    return .failure;
}

/// Renders a B-commit-15 log open/put/commit failure. Exhaustive over the widest
/// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError`
/// coerce into it, so one mapper serves all three.
/// Clearsign `plain` through the operator's signing command, fossil's
/// `clearsign()` contract byte for byte (fossil src/clearsign.c), so a
/// `pgp-command` value that works there works here verbatim:
///
///   * the command is `$ZIT_PGP_COMMAND`, else the repository's stored
///     `pgp-command` setting, else `gpg --clearsign -o ` (fossil's own
///     default, trailing space included);
///   * it is invoked as `COMMAND OUTFILE INFILE` — the `-o ` in the default
///     binds OUTFILE, and the manifest arrives as the positional INFILE;
///   * a command whose basename starts with `ssh` is the DETACHED case
///     (2025: ssh cannot make a non-detached signature): it is invoked as
///     `COMMAND INFILE`, writes `INFILE.sig`, and the armor is assembled
///     here — `-----BEGIN SSH SIGNED MESSAGE-----` + blank line + manifest
///     + the detached signature, exactly fossil's assembly;
///   * a false-ish command (`off`/`no`/`0`/`false`) means "do not sign".
///
/// Returns the wrapped bytes (caller frees), or null on any failure. `null`
/// is the SIGNING FAILURE signal; whether that aborts the commit is the
/// caller's policy, not this function's.
fn clearsignBytes(
    gpa: std.mem.Allocator,
    io: std.Io,
    amb: cli.Ambient,
    root_dir: std.Io.Dir,
    stored_command: []const u8,
    plain: []const u8,
) ?[]u8 {
    // Precedence, widest scope last: the environment overrides the repository's
    // stored `pgp-command`, which overrides fossil's built-in default. That is
    // fossil's own order — an operator must be able to redirect one invocation
    // without editing the repository.
    const command: []const u8 = amb.environ.getPosix("ZIT_PGP_COMMAND") orelse
        if (stored_command.len != 0) stored_command else "gpg --clearsign -o ";
    if (isFalseSetting(command)) return null;
    const ssh_style = std.mem.startsWith(u8, commandBasename(command), "ssh");

    const in_name = ".zit-tmp-sign-in";
    const out_name = ".zit-tmp-sign-out";
    const sig_name = ".zit-tmp-sign-in.sig"; // ssh writes INFILE.sig
    {
        var f = root_dir.createFile(io, in_name, .{ .truncate = true }) catch return null;
        defer f.close(io);
        f.writePositionalAll(io, plain, 0) catch {
            root_dir.deleteFile(io, in_name) catch {};
            return null;
        };
    }
    defer {
        root_dir.deleteFile(io, in_name) catch {};
        root_dir.deleteFile(io, out_name) catch {};
        root_dir.deleteFile(io, sig_name) catch {};
    }

    var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
    const abs_len = root_dir.realPath(io, &abs_buf) catch return null;
    const root_abs = abs_buf[0..abs_len];

    // Fossil interpolates the file names straight into the shell command, and
    // so does this — but the names are OURS (fixed literals under a resolved
    // root), never operator input, so there is nothing here for a quoted
    // path to protect against that single-quoting does not already cover.
    const script = if (ssh_style)
        std.fmt.allocPrint(gpa, "{s} '{s}/{s}'", .{ command, root_abs, in_name }) catch return null
    else
        std.fmt.allocPrint(gpa, "{s} '{s}/{s}' '{s}/{s}'", .{ command, root_abs, out_name, root_abs, in_name }) catch return null;
    defer gpa.free(script);

    var env_map = amb.environ.createMap(gpa) catch return null;
    defer env_map.deinit();
    const result = std.process.run(gpa, io, .{
        .argv = &.{ "/bin/sh", "-c", script },
        .environ_map = &env_map,
        .stdout_limit = .limited(max_signature_bytes),
    }) catch return null;
    gpa.free(result.stdout);
    gpa.free(result.stderr);
    switch (result.term) {
        .exited => |c| if (c != 0) return null,
        else => return null,
    }

    if (!ssh_style) {
        return root_dir.readFileAlloc(io, out_name, gpa, .limited(max_signature_bytes)) catch null;
    }

    // SSH: assemble what ssh-keygen will not. Fossil's exact layout — armor
    // line, ONE blank line (no `Hash:` header), the manifest verbatim, then
    // the already-armored detached signature.
    const sig = root_dir.readFileAlloc(io, sig_name, gpa, .limited(max_signature_bytes)) catch return null;
    defer gpa.free(sig);
    var out: std.ArrayListUnmanaged(u8) = .empty;
    errdefer out.deinit(gpa);
    out.appendSlice(gpa, "-----BEGIN SSH SIGNED MESSAGE-----\n\n") catch return null;
    out.appendSlice(gpa, plain) catch return null;
    out.appendSlice(gpa, sig) catch return null;
    return out.toOwnedSlice(gpa) catch null;
}

/// One signature's ceiling: a manifest plus armor, never a payload channel.
const max_signature_bytes: usize = 8 * 1024 * 1024;

/// Fossil's `is_false()` over a setting value: the spellings that mean
/// "disabled". Anything else is a command to run.
fn isFalseSetting(v: []const u8) bool {
    const off = [_][]const u8{ "off", "no", "false", "0" };
    for (off) |o| if (std.ascii.eqlIgnoreCase(v, o)) return true;
    return false;
}

/// The command word's basename — fossil's `command_basename()`, used only to
/// spot the `ssh*` family. Reads up to the first space, then after the last
/// path separator.
fn commandBasename(command: []const u8) []const u8 {
    const word_end = std.mem.indexOfScalar(u8, command, ' ') orelse command.len;
    const word = command[0..word_end];
    const slash = std.mem.lastIndexOfScalar(u8, word, '/') orelse return word;
    return word[slash + 1 ..];
}

/// Renders the signing refusal: the signer failed, or its output does
/// not strip back to the exact manifest it was given.
fn commitSignFail(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit commit: --sign failed: the clearsign command errored or altered the manifest (set ZIT_PGP_COMMAND to override 'gpg --clearsign')\n") catch {};
    return .failure;
}

fn commitLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {},
        error.Storage => s.err.writeAll(
            "zit commit: cannot write the check-in: input/output error\n",
        ) catch {},
        error.Corrupt => s.err.writeAll("zit commit: the repository is corrupt\n") catch {},
    }
    return .failure;
}

/// Renders a B-commit-15 parent-manifest content failure. A `Corrupt` frame is
/// the parent-corrupt case; exhaustive over `logstore.ContentError`.
fn commitContentFail(s: cli.Streams, err: logstore.ContentError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {},
        error.Storage => s.err.writeAll(
            "zit commit: cannot write the check-in: input/output error\n",
        ) catch {},
        error.Corrupt => s.err.writeAll("zit commit: the parent check-in is corrupt\n") catch {},
    }
    return .failure;
}

/// The parent check-in named by the checkout could not be found or parsed
/// (B-commit-15). A missing parent frame or a malformed manifest is corruption.
///
/// `why` names WHICH step failed. Every one of these used to print the same
/// sentence, so a name this build could not decode, a parent absent from the
/// log, and genuine damage were indistinguishable at the CLI -- which is how
/// `zit verify` could report a store clean while `commit` called its parent
/// corrupt, with no way to tell from the outside which one was wrong.
fn commitParentCorrupt(s: cli.Streams, why: []const u8) cli.Exit {
    s.err.print("zit commit: the parent check-in is corrupt ({s})\n", .{why}) catch {};
    return .failure;
}

/// The log committed but the working state could not advance (B-commit-15) — the
/// single non-atomic seam, stated honestly.
fn commitCheckoutWriteFail(s: cli.Streams) cli.Exit {
    s.err.writeAll(
        "zit commit: the check-in was recorded but the working state could not be updated: input/output error\n",
    ) catch {};
    return .failure;
}

/// A managed/selected file is absent from the working tree (B-commit-10). Nothing
/// is written — the failure is raised before `logstore.commit`.
fn commitMissing(s: cli.Streams, path: []const u8) cli.Exit {
    s.err.print(
        "zit commit: cannot commit '{s}': missing from the working tree (use 'zit rm' to stop tracking it)\n",
        .{path},
    ) catch {};
    return .failure;
}

/// A managed/selected file is present but not a regular file (B-commit-10).
/// A tracked SYMLINK is the wedge blocker B3 left in the field (a pre-fix `add
/// .` registered it, and every later commit refuses): 0.1.0 does not version
/// symlinks, so the only way forward is to stop tracking it — the message names
/// that recovery instead of leaving it to be guessed. A fifo/socket keeps the
/// bare refusal: no recovery is being recommended for content zit never claimed.
fn commitNotRegular(s: cli.Streams, path: []const u8, is_symlink: bool) cli.Exit {
    if (is_symlink) {
        s.err.print(
            "zit commit: cannot commit '{s}': " ++ symlink_unversioned ++
                "; run 'zit rm {s}' to stop tracking it\n",
            .{ path, path },
        ) catch {};
        return .failure;
    }
    s.err.print("zit commit: cannot commit '{s}': not a regular file\n", .{path}) catch {};
    return .failure;
}

/// A managed/selected file could not be read or stored (B-commit-15). Raised
/// before `logstore.commit`, so nothing durable is written.
fn commitCaptureIo(s: cli.Streams, path: []const u8) cli.Exit {
    s.err.print("zit commit: cannot commit '{s}': input/output error\n", .{path}) catch {};
    return .failure;
}

/// A tracked file too large for one artifact frame. States the actual size and
/// the limit, because the operator's next question is "by how much" — and says
/// the limit is the repository format's, so it does not read as a setting they
/// should go looking for.
fn commitTooLarge(s: cli.Streams, path: []const u8, size: u64) cli.Exit {
    s.err.print(
        "zit commit: cannot commit '{s}': {d} bytes exceeds the {d}-byte limit on one file\n",
        .{ path, size, main.max_tracked_file_bytes },
    ) catch {};
    s.err.print("zit commit: that limit is the repository format's artifact size, not a setting\n", .{}) catch {};
    return .failure;
}

/// The file's content is a class this repository has not accepted. Names
/// the file, what it is, and BOTH ways forward: the durable one (record the
/// glob, which then applies to every clone) and the one-off waiver. Refusing
/// with only "use --no-warnings" would train the reflex that discards the check.
fn commitContentClass(s: cli.Streams, path: []const u8, fault: ContentFault) cli.Exit {
    s.err.print("zit commit: refusing '{s}': it {s}\n", .{ path, fault.describe() }) catch {};
    s.err.print(
        "zit commit: accept it for good with 'zit settings {s} \"{s}\"' (a tracked .zit-settings/{s} applies to every clone), or once with --no-checks\n",
        .{ fault.glob(), path, fault.glob() },
    ) catch {};
    return .failure;
}

/// A named path in a partial commit is not tracked (B-commit-11). Educating: a
/// file must be `zit add`ed before it can be committed.
fn commitNotTracked(s: cli.Streams, path: []const u8) cli.Exit {
    s.err.print("zit commit: '{s}' is not tracked (use 'zit add' first)\n", .{path}) catch {};
    return .failure;
}

pub fn runAmend(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    // 1. Parse (B-amend-1). Usage errors → exit 2; `--help` wins (B-amend-13).
    const parsed = switch (cli.parseAmendArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "amend", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "amend", .extra_argument, tok),
        .repeated_option => |tok| return cli.usageError(streams, "amend", .repeated_option, tok),
        .empty_value => |tok| return cli.usageError(streams, "amend", .empty_value, tok),
        .bad_date => |tok| return cli.usageError(streams, "amend", .bad_date, tok),
        .no_override => return cli.usageError(streams, "amend", .no_override, ""),
    };
    if (parsed.help) {
        streams.out.writeAll(amend_help) catch {};
        return .ok;
    }
    // NAME is required (B-amend-14): a missing positional is a usage error, before
    // any repository access.
    const name = parsed.name orelse return cli.usageError(streams, "amend", .no_name, "");

    // 2. Open the repository read-only for resolution (B-amend-15: the repository
    //    check precedes resolution — `openRepo` renders not-a-zit-repository and
    //    exits 1). Full derive: `resolve`/`tagsOf` read the tag tables.
    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = openRepo(gpa, io, arena, streams, "amend", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);

    // 3. Resolve NAME to a check-in and capture everything the mint needs — the
    //    target's 64-hex hash and, for `--branch`, the parent syms to cancel
    //    (repo-state §6.2) — all duped onto `arena` so they outlive the read view,
    //    which is closed before the rw append (commit's durability pattern).
    var target_hash: []const u8 = undefined;
    var parent_syms: []const []const u8 = &.{};
    {
        defer repo.deinit(gpa);
        const rid = repo.resolve(name, .checkin) catch |e| switch (e) {
            // B-amend-9: an ambiguous hash prefix (repo-state §8.3).
            error.Ambiguous => {
                streams.err.print("zit amend: name '{s}' is ambiguous\n", .{name}) catch {};
                return .failure;
            },
            error.OutOfMemory => return stateOom(streams, "amend"),
            error.NotFound => {
                // B-amend-10: a NAME that names a stored non-check-in artifact (a
                // control artifact, say) is refused BY its actual type — T cards
                // target check-ins. Anything else is the B-amend-9 not-found.
                if (amendArtifactType(gpa, &repo, name)) |type_name| {
                    streams.err.print("zit amend: '{s}' is a {s}, not a check-in\n", .{ name, type_name }) catch {};
                } else {
                    streams.err.print("zit amend: check-in '{s}' not found\n", .{name}) catch {};
                }
                return .failure;
            },
        };
        target_hash = arena.dupe(u8, repo.hashOf(rid)) catch return amendOom(streams);
        // The amendment targets a resolved check-in by its full §3.6 artifact id.
        std.debug.assert(artifact.validateName(target_hash));

        // repo-state §6.2: a `--branch` move cancels the target's inherited
        // (propagating) sym-* tags, minus the branch's own re-established sym. The
        // surfaced names are duped onto `arena` (the view's interned names die at
        // `repo.deinit`).
        if (parsed.branch) |bname| {
            const want_sym = std.mem.concat(arena, u8, &.{ sym_prefix, bname }) catch return amendOom(streams);
            const entries = repo.state().tagsOf(gpa, rid) catch return amendOom(streams);
            defer gpa.free(entries);
            parent_syms = selectInheritedSyms(arena, entries, want_sym) catch return amendOom(streams);
        }
    }

    // 4. Compose the one control artifact's T run (B-amend-1/4), each card
    //    retargeted at the check-in (artifact §5.4). At least one override is
    //    present (the parser's `no_override` guard), so the run is non-empty.
    const tags = cli.composeAmendTags(arena, target_hash, .{
        .comment = parsed.comment,
        .user = parsed.user,
        .date = parsed.date,
        .branch = parsed.branch,
        .parent_syms = parent_syms,
    }) catch return amendOom(streams);

    // 5-6. Assemble, serialize, and durably store the one control artifact.
    var cname_buf: [64]u8 = undefined;
    const control_name = mintControlArtifact(gpa, io, root_dir, amb, tags, &cname_buf) catch |e|
        return amendLogFail(streams, e);

    // 7. Success (B-amend-1/16): the one confirmation line to stdout, the full
    //    control-artifact hash as a fixed-position token (mirrors commit's
    //    verb-prefixed line). Every diagnostic already went to stderr.
    streams.out.print("amend {s}\n", .{control_name}) catch {};
    return .ok;
}

/// Renders `zit amend: out of memory` and returns `.failure`.
fn amendOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit amend: out of memory\n") catch {};
    return .failure;
}

/// Renders a B-amend log open/put/commit failure. Exhaustive over the widest
/// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError`
/// coerce into it, so one mapper serves all three.
fn amendLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit amend: out of memory\n") catch {},
        error.Storage => s.err.writeAll("zit amend: cannot write the amendment: input/output error\n") catch {},
        error.Corrupt => s.err.writeAll("zit amend: the repository is corrupt\n") catch {},
    }
    return .failure;
}

/// The external type name of the stored artifact named `name` when it is NOT a
/// check-in (B-amend-10), or null when `name` names no stored artifact, is
/// unparseable, or IS a check-in manifest (in which case the not-found diagnostic
/// stands — `resolve` already ruled it out as a crosslinked check-in). Reads the
/// raw artifact through the repo's content channel and classifies it; the
/// returned name is a static string, valid after the parse is freed.
fn amendArtifactType(gpa: std.mem.Allocator, repo: *Repository, name: []const u8) ?[]const u8 {
    const bytes = (repo.blob(gpa, name) catch return null) orelse return null;
    defer gpa.free(bytes);
    var m = artifact.parse(gpa, bytes) catch return null;
    defer m.deinit();
    if (m.kind == .manifest) return null; // a manifest IS a check-in; let not-found stand
    return m.kind.externalName();
}

/// Captures a mapper's stderr into a fixed buffer and returns (exit, bytes).
fn commitMapCapture(comptime run_map: anytype, args: anytype) struct { code: cli.Exit, err: []const u8 } {
    const S = struct {
        var err_buf: [256]u8 = undefined;
    };
    var w = Io.Writer.fixed(&S.err_buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: cli.Streams = .{ .out = &wnull, .err = &w };
    const code = @call(.auto, run_map, .{s} ++ args);
    return .{ .code = code, .err = w.buffered() };
}

// Same guard as the `zit update` mapper: `Corrupt` alleges damaged storage,
// `StateRecordInvalid` does not. `zit merge` maps most read-path errors onto
// one corruption line, so these two have to be pinned apart explicitly or they
// drift back into that group.
test "merge: the two read-path failures do not share a diagnostic" {
    // `commitMapCapture` renders into one static buffer, so each message is
    // copied out before the next render overwrites it.
    var a: [256]u8 = undefined;
    var b: [256]u8 = undefined;
    const corrupt = mergeFailText(&a, error.Corrupt);
    const state = mergeFailText(&b, error.StateRecordInvalid);

    try std.testing.expect(!std.mem.eql(u8, corrupt, state));
    try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null);
    try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null);
}

/// Render one `ApplyError` through `mergeApplyFail` into `buf`.
fn mergeFailText(buf: []u8, err: update.ApplyError) []const u8 {
    const rendered = commitMapCapture(mergeApplyFail, .{err}).err;
    // `buf` is the caller's, `rendered` borrows `commitMapCapture`'s static
    // buffer: the fit is a contract of this helper, not a coincidence of the
    // two sizes agreeing today.
    std.debug.assert(rendered.len <= buf.len);
    @memcpy(buf[0..rendered.len], rendered);
    return buf[0..rendered.len];
}

/// Render one `ApplyError` through `bisectMoveFail` into `buf`.
fn bisectFailText(buf: []u8, err: update.ApplyError) []const u8 {
    const rendered = commitMapCapture(bisectMoveFail, .{err}).err;
    std.debug.assert(rendered.len <= buf.len);
    @memcpy(buf[0..rendered.len], rendered);
    return buf[0..rendered.len];
}

// `zit bisect` moves the working tree through the SAME `update.applyCheckin`
// engine as update, open and merge, so it reaches `StateRecordInvalid` on the
// same intact-clone artifacts they do. It is the fourth mapper of that one
// error set and must make the same distinction they make.
test "bisect: the two read-path failures do not share a diagnostic" {
    var a: [256]u8 = undefined;
    var b: [256]u8 = undefined;
    const corrupt = bisectFailText(&a, error.Corrupt);
    const state = bisectFailText(&b, error.StateRecordInvalid);

    try std.testing.expect(!std.mem.eql(u8, corrupt, state));
    try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null);
    try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null);
}

test "commit B-commit-15: checkout-read errors map to the contract messages, exit 1" {
    const Case = struct { err: checkout.CheckoutError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit commit: cannot read checkout state: .zit-checkout is corrupt\n" },
        .{ .err = error.Storage, .want = "zit commit: cannot read checkout state: input/output error\n" },
    };
    for (cases) |c| {
        const r = commitMapCapture(checkoutFail, .{ "commit", c.err });
        try std.testing.expectEqual(cli.Exit.failure, r.code);
        try std.testing.expectEqualStrings(c.want, r.err);
    }
}

test "commit B-commit-15: managed-read errors map to the contract messages, exit 1" {
    const Case = struct { err: managed.ManagedError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit commit: cannot read tracked files: .zit-checkout is corrupt\n" },
        .{ .err = error.Storage, .want = "zit commit: cannot read tracked files: input/output error\n" },
        .{ .err = error.TooLong, .want = "zit commit: cannot read tracked files: a tracked path is too long\n" },
        .{ .err = error.TooMany, .want = "zit commit: cannot read tracked files: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
    };
    for (cases) |c| {
        const r = commitMapCapture(managedFail, .{ "commit", c.err });
        try std.testing.expectEqual(cli.Exit.failure, r.code);
        try std.testing.expectEqualStrings(c.want, r.err);
    }
}

test "commit B-commit-15: log open/put/commit errors map to the contract messages, exit 1" {
    const Case = struct { err: logstore.OpenError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
        .{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" },
        .{ .err = error.Corrupt, .want = "zit commit: the repository is corrupt\n" },
    };
    for (cases) |c| {
        const r = commitMapCapture(commitLogFail, .{c.err});
        try std.testing.expectEqual(cli.Exit.failure, r.code);
        try std.testing.expectEqualStrings(c.want, r.err);
    }
}

test "commit B-commit-15: parent-content errors map to the contract messages, exit 1" {
    const Case = struct { err: logstore.ContentError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
        .{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" },
        .{ .err = error.Corrupt, .want = "zit commit: the parent check-in is corrupt\n" },
    };
    for (cases) |c| {
        const r = commitMapCapture(commitContentFail, .{c.err});
        try std.testing.expectEqual(cli.Exit.failure, r.code);
        try std.testing.expectEqualStrings(c.want, r.err);
    }
}

test "commit B-commit-10/-11/-15: the single-message diagnostics render exactly, exit 1" {
    try std.testing.expectEqualStrings(
        "zit commit: cannot read checkout state: .zit-checkout is missing\n",
        commitMapCapture(commitCheckoutMissing, .{}).err,
    );
    try std.testing.expectEqualStrings(
        "zit commit: the parent check-in is corrupt (not present in this repository)\n",
        commitMapCapture(commitParentCorrupt, .{"not present in this repository"}).err,
    );
    try std.testing.expectEqualStrings(
        "zit commit: the check-in was recorded but the working state could not be updated: input/output error\n",
        commitMapCapture(commitCheckoutWriteFail, .{}).err,
    );
    try std.testing.expectEqualStrings("zit commit: out of memory\n", commitMapCapture(commitOom, .{}).err);
    try std.testing.expectEqualStrings(
        "zit commit: cannot commit 'a.txt': missing from the working tree (use 'zit rm' to stop tracking it)\n",
        commitMapCapture(commitMissing, .{"a.txt"}).err,
    );
    try std.testing.expectEqualStrings(
        "zit commit: cannot commit 'link': not a regular file\n",
        commitMapCapture(commitNotRegular, .{ "link", false }).err,
    );
    // F-B3: a tracked symlink is the one non-regular kind with a stated
    // recovery, because a pre-fix `add .` is how it got tracked at all.
    try std.testing.expectEqualStrings(
        "zit commit: cannot commit 'link': symlink (not versioned in 0.1.0); run 'zit rm link' to stop tracking it\n",
        commitMapCapture(commitNotRegular, .{ "link", true }).err,
    );
    try std.testing.expectEqualStrings(
        "zit commit: cannot commit 'x': input/output error\n",
        commitMapCapture(commitCaptureIo, .{"x"}).err,
    );
    try std.testing.expectEqualStrings(
        "zit commit: 'new.txt' is not tracked (use 'zit add' first)\n",
        commitMapCapture(commitNotTracked, .{"new.txt"}).err,
    );
}

// ---------------------------------------------------------------------------
// `zit submanifest [show|enable|disable|retune|analyze]` — the sub-manifest
// operator surface (draft-fossil-submanifests-00 §6.3, §6.4, §8.1).
//
// Four actions over two settings. `show` reports them; `enable`/`disable` move
// §8.1's emission gate, `enable` pinning §6.3's partition parameter the first
// time and printing the consequence warning; `retune` performs §6.4's manual,
// forward-only re-pin; `analyze` runs §6.3's selection pass and writes nothing.
//
// §8.2's provisional-phase rules make enabling an act with real consequences,
// so it is opt-in with the warning printed, not a silent toggle and not an
// indefinite refusal: the maintainer's compatibility contract says zit 0.2.0
// stays backwards compatible with fossil, and it does — the DEFAULT is off, and
// §7.2's transfer gate refuses a pre-2.29 peer every S-bearing artifact rather
// than hand it something it cannot read.
// ---------------------------------------------------------------------------

pub fn runSubmanifest(
    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 parsed = switch (cli.parseSubmanifestArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "submanifest", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "submanifest", .extra_argument, tok),
        .bad_k => |tok| {
            streams.err.print(
                "zit submanifest: --k wants an integer {d}..{d} (got '{s}')\n",
                .{ artifact.min_k, artifact.max_k, tok },
            ) catch {};
            streams.err.writeAll("try 'zit submanifest --help'\n") catch {};
            return .usage;
        },
        .missing_value => |tok| {
            streams.err.print("zit submanifest: {s} wants a path\n", .{tok}) catch {};
            streams.err.writeAll("try 'zit submanifest --help'\n") catch {};
            return .usage;
        },
    };
    if (parsed.help) {
        streams.out.writeAll(main.submanifest_help) catch {};
        return .ok;
    }

    // Settings-writing actions operate on the repository the checkout names;
    // only `analyze` (a pure measurement) honors -R -- the conversion
    // gate's pre-flight runs against a bare cloned store.
    if (amb.repo_override != null and parsed.action != .analyze) {
        streams.err.writeAll("zit submanifest: -R/--repo works with 'analyze' only; settings live in the repository the checkout names\n") catch {};
        return .failure;
    }
    if (parsed.replay_all and parsed.action != .analyze) {
        streams.err.writeAll("zit submanifest: --replay-all is an 'analyze' option\n") catch {};
        return .usage;
    }

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return submanifestOom(streams)) orelse {
        main.reportNoRoot(streams, io, "submanifest", amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    return switch (parsed.action) {
        .show => submanifestShow(gpa, io, root_dir, streams),
        .enable => submanifestEnable(gpa, io, root_dir, parsed.k, streams),
        .disable => submanifestDisable(gpa, io, root_dir, streams),
        .retune => submanifestRetune(gpa, io, root_dir, parsed.k, streams),
        .analyze => submanifestAnalyze(gpa, io, root_dir, parsed.names_path, parsed.replay_all, streams),
        // Routed by main.zig before a checkout is resolved.
        .convert => .usage,
    };
}

/// Read the repository's config frame. A missing or unreadable frame is a
/// repository-level failure here (unlike on the commit path, where the safe
/// default is simply "off"): every action below either reports the settings or
/// rewrites them, and both would be lies over a config that did not load.
fn submanifestConfig(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) ?logstore.OwnedConfig {
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
        return null;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
        return null;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
        streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
        return null;
    };
    defer log.deinit(gpa);
    const oc = log.readConfig(gpa) catch {
        streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
        return null;
    };
    if (oc == null) {
        streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {};
        return null;
    }
    return oc;
}

/// An outer null preserves the cached value; an outer non-null writes its inner
/// value, including null to conservatively clear an unreadable checkout's cache.
/// This is implementation-only state, not an RFC setting.
fn replaceSettledCostShape(shape: ?[32]u8) ??[32]u8 {
    return shape;
}

/// Read-modify-write of the sub-manifest settings, preserving every unrelated
/// config field (plan E last-frame-wins). `settled_cost_shape` is tri-state so only
/// disable may preserve its cache; enable and retune replace it even with null.
fn submanifestWrite(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    enabled: ?bool,
    k: ?u8,
    settled_cost_shape: ??[32]u8,
    streams: cli.Streams,
) bool {
    var file = cli.openStoreAppend(io, root_dir) catch {
        streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
        return false;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
        return false;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
        streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
        return false;
    };
    defer log.deinit(gpa);
    var oc = (log.readConfig(gpa) catch null) orelse {
        streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {};
        return false;
    };
    defer oc.deinit(gpa);
    var next = oc.config;
    if (enabled) |value| next.enable_submanifests = value;
    if (k) |value| next.submanifest_k = value;
    if (settled_cost_shape) |shape| next.submanifest_settled_cost_shape = shape;
    log.putConfig(next) catch {
        streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
        return false;
    };
    log.commit() catch {
        streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
        return false;
    };
    return true;
}

fn submanifestShow(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) cli.Exit {
    var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
    defer oc.deinit(gpa);
    const s: zit.submanifest.Settings = .of(oc.config);

    streams.out.print("enable-submanifests: {s}\n", .{if (s.enabled) "on" else "off"}) catch {};
    if (s.pinned()) {
        streams.out.print("submanifest-k:       {d}\n", .{s.k}) catch {};
    } else {
        streams.out.writeAll("submanifest-k:       (not pinned)\n") catch {};
    }
    // §6.4's `submanifest-auto-retune` — shown because it is now a PROPAGATED
    // setting: a clone adopts it from its origin over sync §9.3, so an operator
    // can arrive at a repository that re-tunes on commit without ever having
    // set it here. A setting that can reach a repository without the operator's
    // act has to be visible to the operator.
    streams.out.print(
        "submanifest-auto-retune: {s}\n",
        .{if (oc.config.submanifest_auto_retune) "on" else "off"},
    ) catch {};
    // §8.1's local indicator, the thing that arms the §7.2 transfer gate even
    // after the setting is turned back off. Worth showing: it is the durable
    // half of "this repository is a disposable test fixture".
    streams.out.print(
        "seen-submanifest:    {s}\n",
        .{if (oc.config.seen_submanifest) "yes (this repository holds S-bearing artifacts)" else "no"},
    ) catch {};

    switch (zit.submanifest.emission(s)) {
        .off => streams.out.writeAll("commits emit: flat manifests\n") catch {},
        .out_of_range => |v| streams.out.print(
            "commits emit: flat manifests — {s} ({d}; must be {d}..{d})\n",
            .{ zit.submanifest.out_of_range_report_prefix, v, artifact.min_k, artifact.max_k },
        ) catch {},
        .tree => |v| streams.out.print("commits emit: tree manifests, partitioned at k={d}\n", .{v}) catch {},
    }
    // §6.3's project-scope requirement is not met; say so where an operator will
    // read it rather than only in a source comment.
    streams.out.writeAll(submanifest_scope_gap) catch {};
    return .ok;
}

/// §6.3 — "Project scope (configuration propagating to clones) is REQUIRED".
/// A clone now adopts these settings over [FossilSync] §9.3's scalar `/config`
/// channel, so the shortfall is narrower than it was: what remains is that a
/// repository already in existence never re-reads them from a peer. Printed by
/// `show` and by `enable`, and kept honest about which half works.
const submanifest_scope_gap =
    "\nscope: a CLONE adopts these settings from its origin (§6.3 project scope, over\n" ++
    "the sync §9.3 configuration exchange), so a fresh clone partitions the way this\n" ++
    "repository does. An EXISTING repository is not refreshed by a later pull or\n" ++
    "sync — change it here and on each peer. Repositories pinned differently still\n" ++
    "exchange artifacts correctly (§6.4) — they just stop sharing sub-manifests.\n";

/// The cache key is an effective F-set shape, never a checkout filename list.
/// Failure to read the current check-in leaves the setting change valid but
/// uncached; the following commit then conservatively scans.
fn submanifestSettledShape(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
) ?[32]u8 {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    var repo: Repository = undefined;
    repo.open(gpa, io, root_dir, .{ .mode = .full }) catch return null;
    defer repo.deinit(gpa);
    const rid = repo.currentCheckoutRid() orelse return null;
    var files = (repo.files(gpa, rid) catch return null) orelse return null;
    defer files.deinit(gpa);

    var entries: std.ArrayList(artifact.FCard) = .empty;
    defer entries.deinit(arena);
    var it = files.iterator();
    while (it.next()) |entry| entries.append(arena, entry) catch return null;
    var digest: [32]u8 = undefined;
    _ = zit.submanifest.costShapeDigest(entries.items, &digest);
    return digest;
}

fn submanifestEnable(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    k_arg: ?u8,
    streams: cli.Streams,
) cli.Exit {
    var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
    defer oc.deinit(gpa);
    const s: zit.submanifest.Settings = .of(oc.config);

    // §6.3: "The partition parameter k is selected once, when the
    // enable-submanifests setting is first turned on for a repository, and
    // pinned". A repository that already has one keeps it — re-pinning is a
    // re-tune (§6.4), which is a different verb with a different consequence.
    if (s.pinned() and k_arg != null and k_arg.? != s.k) {
        streams.err.print(
            "zit submanifest: k is already pinned to {d}; changing it is a re-tune — " ++
                "use 'zit submanifest retune --k {d}'\n",
            .{ s.k, k_arg.? },
        ) catch {};
        return .failure;
    }
    // §6.3: "The partition parameter k is selected once, when the
    // enable-submanifests setting ... is first turned on" — and the draft names
    // the objective it is selected BY, so selecting it is the writer's job. An
    // explicit --k overrides; an already-pinned repository keeps its value
    // (re-pinning is a re-tune); otherwise the analysis pass answers, and
    // §8.1's "MUST NOT enable emission ... with no pinned parameter" is
    // satisfied by pinning rather than by refusing.
    var analyzed_over: ?usize = null;
    const k: u8 = if (k_arg) |v| v else if (s.pinned()) s.k else blk: {
        const sel = submanifestSelectK(gpa, io, root_dir, streams) orelse return .failure;
        analyzed_over = sel.names;
        // History outranks the snapshot when both can speak. The
        // snapshot objective is theory over ONE file set; the replay is the
        // same choice measured against this repository's own recent commits
        // (argmin of realized steady-state materialization). No verdict, no
        // gate — the one decision that stays with the operator is the
        // compatibility spend, stated below as it always was.
        break :blk submanifestReplayK(gpa, io, root_dir, sel.k, streams) orelse sel.k;
    };
    // §6.3: "MUST support every value from 1 to 12 inclusive (the pinned value
    // MUST lie in that range)". Refuse to PIN an out-of-range value; the
    // fallback-and-report rule exists for a value that reaches a writer some
    // other way, not as a licence to store one here.
    if (!zit.submanifest.inRange(k)) {
        streams.err.print(
            "zit submanifest: k must be {d}..{d} (got {d})\n",
            .{ artifact.min_k, artifact.max_k, k },
        ) catch {};
        return .failure;
    }

    const settled_shape = submanifestSettledShape(gpa, io, root_dir);
    if (!submanifestWrite(
        gpa,
        io,
        root_dir,
        true,
        k,
        replaceSettledCostShape(settled_shape),
        streams,
    )) return .failure;

    // §8.1/§8.2 — the consequence, stated before the operator's next commit,
    // not buried in documentation.
    streams.out.print("enable-submanifests: on (submanifest-k pinned to {d})\n", .{k}) catch {};
    // What this does NOT do, said here rather than discovered
    // later — existing history is untouched, and every fossil peer stops
    // receiving new check-ins.
    streams.out.writeAll(
        "existing check-ins stay flat; from the next commit, peers below Fossil 2.29 — every\n" ++
            "released fossil — receive nothing new from this repository. To re-mint the whole\n" ++
            "history as trees in a new repository: 'zit submanifest convert SRC DEST'.\n",
    ) catch {};
    if (analyzed_over) |names| {
        // §6.3: "The selection is advisory rather than canonical -- any pinned
        // k yields conforming artifacts". Say what it was measured over, so the
        // operator can tell a selection from a default.
        streams.out.print(
            "submanifest-k was selected by the §6.3 analysis over this check-in's {d} names;\n" ++
                "'zit submanifest analyze' shows the full table, 'retune --k N' moves the pin.\n",
            .{names},
        ) catch {};
    }
    streams.out.print("\nWARNING: {s}\n", .{zit.sync.submanifest_enable_warning}) catch {};
    streams.out.writeAll(submanifest_scope_gap) catch {};
    return .ok;
}

fn submanifestDisable(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) cli.Exit {
    // §8.1: "Disabling the setting stops the emission of new tree manifests; it
    // cannot retire existing ones (artifact names are immutable)." The pin is
    // deliberately left in place — it is the repository's identity for content
    // sharing, and clearing it would silently make a later re-enable a re-tune.
    if (!submanifestWrite(gpa, io, root_dir, false, null, null, streams)) return .failure;
    streams.out.writeAll("enable-submanifests: off\n") catch {};
    var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .ok;
    defer oc.deinit(gpa);
    if (oc.config.seen_submanifest) {
        streams.out.writeAll(
            "note: this repository already holds S-bearing artifacts. Disabling stops new\n" ++
                "tree manifests; it cannot retire the existing ones, and peers below Fossil\n" ++
                "2.29 are still refused every one of them.\n",
        ) catch {};
    }
    return .ok;
}

fn submanifestRetune(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    k_arg: ?u8,
    streams: cli.Streams,
) cli.Exit {
    // §6.4: "A re-tune MUST be manual and operator-initiated". The value is
    // never inferred here — no argument is a usage failure, not a call into the
    // analysis pass.
    const k = k_arg orelse {
        streams.err.writeAll(
            "zit submanifest: retune needs an explicit value: 'zit submanifest retune --k N'.\n" ++
                "A re-tune is always manual; run 'zit submanifest analyze' first if you want the " ++
                "analyzed optimum.\n",
        ) catch {};
        return .failure;
    };
    if (!zit.submanifest.inRange(k)) {
        streams.err.print(
            "zit submanifest: k must be {d}..{d} (got {d})\n",
            .{ artifact.min_k, artifact.max_k, k },
        ) catch {};
        return .failure;
    }
    var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
    defer oc.deinit(gpa);
    const s: zit.submanifest.Settings = .of(oc.config);
    if (s.k == k) {
        streams.out.print("submanifest-k: already {d} — nothing to do\n", .{k}) catch {};
        return .ok;
    }

    const settled_shape = submanifestSettledShape(gpa, io, root_dir);
    if (!submanifestWrite(
        gpa,
        io,
        root_dir,
        null,
        k,
        replaceSettledCostShape(settled_shape),
        streams,
    )) return .failure;
    streams.out.print("submanifest-k: {d} -> {d}\n", .{ s.k, k }) catch {};
    // §6.4: "The first commit after it shares no sub-manifest with its parent's
    // tree and rewrites every leaf, once ... the every-leaf rewrite lands as a
    // surprisingly large commit at an arbitrary moment." Said now, so the
    // moment is not arbitrary.
    if (s.enabled) {
        streams.out.writeAll(
            "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
                "leaf, once. No history is rewritten and no reader behaves differently.\n",
        ) catch {};
    }
    return .ok;
}

/// §6.3's objective at every k in `min_k..max_k`, in order, into `out`; returns
/// how many rows were filled. The shared core of `analyze` (which prints the
/// whole table) and `enable` (which pins only the winner), so the value the
/// operator is shown is by construction the value that gets pinned.
fn submanifestCosts(
    gpa: std.mem.Allocator,
    entries: []const artifact.FCard,
    out: *[artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost,
) std.mem.Allocator.Error!usize {
    var n: usize = 0;
    var k: u4 = artifact.min_k;
    while (true) : (k += 1) {
        out[n] = try zit.submanifest.cost(gpa, entries, k, .sha3_256);
        n += 1;
        // Explicit terminator: `k` is a u4, so raising `max_k` to 15 would make
        // `k += 1` wrap to 0 and spin forever on a `k <= max_k` condition.
        if (k == artifact.max_k) break;
    }
    return n;
}

/// §6.3's selection: the k minimizing the objective over the repository's
/// current name set. This is the writer's choice, not the operator's — the
/// draft has the parameter "selected once, when the enable-submanifests setting
/// is first turned on", and names the objective it is selected by. Returns null
/// after reporting why no value could be selected; the caller then refuses to
/// enable rather than substituting one.
fn submanifestSelectK(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    streams: cli.Streams,
) ?struct { k: u8, names: usize } {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();

    const entries = submanifestNamesFromCheckout(gpa, arena_state.allocator(), io, root_dir, false, streams) orelse {
        streams.err.writeAll(
            "zit submanifest: no file set to select a partition parameter from — " ++
                "pin one explicitly with 'zit submanifest enable --k N'.\n",
        ) catch {};
        return null;
    };
    if (entries.len == 0) {
        streams.err.writeAll(
            "zit submanifest: the current check-in holds no files, so there is nothing to\n" ++
                "select a partition parameter from — pin one with 'zit submanifest enable --k N'.\n",
        ) catch {};
        return null;
    }

    var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined;
    const n = submanifestCosts(gpa, entries, &costs) catch {
        _ = submanifestOom(streams);
        return null;
    };
    const winner = zit.submanifest.best(costs[0..n]) orelse {
        // Every k leaves this name set flat (§6.2 step 2), so the objective has
        // no candidate to minimize and there is no analyzed value to pin. Not a
        // reason to invent one: the set will not stay this small, and §6.3
        // leaves the choice to the operator when the pass cannot make it.
        streams.err.print(
            "zit submanifest: no k partitions this repository's {d} names into a tree " ++
                "(§6.2 step 2),\nso no parameter can be selected — pin one with " ++
                "'zit submanifest enable --k N' if you want one anyway.\n",
            .{entries.len},
        ) catch {};
        return null;
    };
    return .{ .k = winner.k, .names = entries.len };
}

/// The §6.3 analysis pass over a name set: k = 1..12, the objective for each,
/// and the §6.5 prediction beside the measured optimum.
///
/// Writes nothing. §6.4: "an implementation MAY advise of drift from the
/// analyzed optimum but MUST NOT re-tune automatically" — so this reports and
/// stops, and moving the pin is the operator's separate `retune` act.
fn submanifestAnalyze(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    names_path: ?[]const u8,
    replay_all: bool,
    streams: cli.Streams,
) cli.Exit {
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const aa = arena_state.allocator();

    const entries = (if (names_path) |p|
        submanifestNamesFromFile(aa, io, p, streams)
    else
        submanifestNamesFromCheckout(gpa, aa, io, root_dir, true, streams)) orelse {
        if (names_path == null) {
            streams.err.writeAll("zit submanifest: analyze a name list instead with '--names PATH'\n") catch {};
        }
        return .failure;
    };

    if (entries.len == 0) {
        streams.err.writeAll("zit submanifest: the name set is empty — nothing to analyze\n") catch {};
        return .failure;
    }

    streams.out.print("names: {d}\n", .{entries.len}) catch {};
    streams.out.writeAll("\n  k   leaves  depth   S-bytes  wmean-leaf     total   flat-F-bytes\n") catch {};

    var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined;
    const n = submanifestCosts(gpa, entries, &costs) catch return submanifestOom(streams);
    for (costs[0..n]) |c| {
        if (c.flat) {
            streams.out.print(
                "{d:>3}   {s:>44}   {d:>12}\n",
                .{ c.k, "(flat: fewer than two groups, §6.2 step 2)", c.flat_bytes },
            ) catch {};
        } else {
            streams.out.print(
                "{d:>3}   {d:>6}  {d:>5}  {d:>8}  {d:>10.1}  {d:>8.1}   {d:>12}\n",
                .{ c.k, c.leaves, c.heights, c.s_bytes, c.weighted_mean_leaf_bytes, c.total, c.flat_bytes },
            ) catch {};
        }
    }

    // Stated before any early return: a name list carries no history,
    // and history is the replay's whole subject.
    if (names_path != null) {
        streams.out.writeAll(
            "\nreplay: unavailable with --names — a name list carries no commit history,\n" ++
                "and history is what decides whether enabling pays (see 'zit submanifest --help').\n",
        ) catch {};
    }

    const winner = zit.submanifest.best(costs[0..n]) orelse {
        streams.out.writeAll(
            "\nno k partitions this name set into a tree (§6.2 step 2) — commits stay flat at every k.\n",
        ) catch {};
        return .ok;
    };
    streams.out.print(
        "\nanalyzed optimum: k={d} (objective {d:.1})\n",
        .{ winner.k, winner.total },
    ) catch {};
    // §6.5, informative: "the analyzed optimum tracks roughly half the base-2
    // logarithm of the file count". Printed beside the measurement so drift
    // between the two is visible rather than assumed away.
    streams.out.print(
        "§6.5 prediction:  ~{d:.1} (half the base-2 log of {d} names)\n",
        .{ zit.submanifest.predictedK(entries.len), entries.len },
    ) catch {};

    // The replay — the static table above is theory over ONE snapshot;
    // whether enabling would have PAID on this repository is a property of
    // its history (churn/reuse), so it is measured by replaying that history
    // through the real builder and the real delta encoder.
    if (names_path == null) {
        submanifestReplaySection(gpa, io, root_dir, winner.k, if (replay_all) std.math.maxInt(usize) else replay_window, streams);
    }

    streams.out.writeAll(
        "\nAdvisory only — any pinned k yields conforming artifacts (§6.3). Nothing was\n" ++
            "changed; move the pin with 'zit submanifest retune --k N' (§6.4, manual only).\n",
    ) catch {};
    return .ok;
}

/// The replay window: enough steady-state commits for churn structure to
/// show, small enough that analyze stays an offline-audit-scale wait.
const replay_window: usize = 200;

/// Render the replay for the static winner and its in-range neighbours. Any
/// failure is reported and skipped — the replay is evidence for a decision,
/// never a gate on printing the static analysis above it.
fn submanifestReplaySection(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    winner_k: u4,
    window: usize,
    streams: cli.Streams,
) void {
    var ks_buf: [3]u4 = undefined;
    var n_ks: usize = 0;
    if (winner_k > artifact.min_k) {
        ks_buf[n_ks] = winner_k - 1;
        n_ks += 1;
    }
    ks_buf[n_ks] = winner_k;
    n_ks += 1;
    if (winner_k < artifact.max_k) {
        ks_buf[n_ks] = winner_k + 1;
        n_ks += 1;
    }

    var repo: Repository = undefined;
    Repository.open(&repo, gpa, io, root_dir, .{ .mode = .full }) catch {
        streams.out.writeAll("\nreplay: cannot open the repository — skipped.\n") catch {};
        return;
    };
    defer repo.deinit(gpa);

    var rep = zit.submanifest.replay(gpa, &repo, window, ks_buf[0..n_ks]) catch {
        streams.out.writeAll("\nreplay: failed reading history — skipped.\n") catch {};
        return;
    };
    defer rep.deinit(gpa);
    renderReplay(&rep, streams);
}

/// The replay table: flat actuals first, then each arm's steady-state deltas
/// on BOTH axes with bootstrap and breakeven separate — the verdict gates on
/// full-form/materialization (where the F-card wall actually breaks people),
/// with store inflation reported as the price.
fn renderReplay(rep: *const zit.submanifest.Replay, streams: cli.Streams) void {
    if (rep.commits < 2) {
        streams.out.print(
            "\nreplay: only {d} replayable commit(s) — too little history to measure; not evidence either way.\n",
            .{rep.commits},
        ) catch {};
        return;
    }
    streams.out.print(
        "\nreplayed over the last {d} commits (this repository's own history):\n" ++
            "flat (actual):   store {d} bytes, full-form {d} bytes\n",
        .{ rep.commits, rep.flat_store, rep.flat_full },
    ) catch {};
    const steady_commits: u64 = @intCast(rep.commits - 1);
    const flat_first_store = rep.flat_store - rep.flat_store_steady;
    for (rep.arms) |arm| {
        if (arm.all_flat) {
            streams.out.print("k={d:<2}  every replayed commit built flat (§6.2 step 2) — no evidence at this k\n", .{arm.k}) catch {};
            continue;
        }
        const full_pct = signedPct(arm.steady_full, rep.flat_full_steady);
        const store_pct = signedPct(arm.steady_store, rep.flat_store_steady);
        streams.out.print(
            "k={d:<2}  steady state: full-form {d:>6.1}%, store {d:>6.1}%; bootstrap {d} bytes",
            .{ arm.k, full_pct, store_pct, arm.bootstrap_store },
        ) catch {};
        // Breakeven on the STORE axis: commits until the bootstrap's extra
        // stored bytes are repaid by the steady-state per-commit saving.
        if (arm.bootstrap_store > flat_first_store and rep.flat_store_steady > arm.steady_store) {
            const extra = arm.bootstrap_store - flat_first_store;
            const saving_per_commit = (rep.flat_store_steady - arm.steady_store) / steady_commits;
            if (saving_per_commit > 0) {
                streams.out.print(", breakeven ~{d} commits", .{extra / saving_per_commit + 1}) catch {};
            }
        } else if (arm.steady_store >= rep.flat_store_steady) {
            streams.out.writeAll(", breakeven: never on store bytes") catch {};
        }
        streams.out.writeAll("\n") catch {};
    }
    if (rep.bestArm()) |w| {
        if (w.steady_full < rep.flat_full_steady) {
            const full_pct = signedPct(w.steady_full, rep.flat_full_steady);
            streams.out.print(
                "verdict: k={d} — steady-state full-form {d:.1}% vs flat over this window.\n",
                .{ w.k, full_pct },
            ) catch {};
            if (rep.insideDesignEnvelope()) {
                streams.out.print(
                    "note: flat materialization averages {d} KB/commit — inside the format's design\n" ++
                        "envelope, so the absolute saving is small; the real decision is the permanent\n" ++
                        "compatibility spend, and that one is yours.\n",
                    .{rep.flatFullSteadyPerCommit() / 1024},
                ) catch {};
            }
        } else {
            streams.out.writeAll(
                "verdict: no replayed k beat the flat arm's steady-state full-form cost over\n" ++
                    "this window — this churn pattern favors flat manifests.\n",
            ) catch {};
        }
    }
}

/// The k-selector for `enable`: replay the snapshot winner and its
/// in-range neighbours over recent history and return the argmin of realized
/// steady-state materialization — the k this repository's own churn actually
/// rewards — or null when the replay cannot speak (no history, every arm
/// flat, unopenable repository), leaving the snapshot winner standing. Prints
/// which evidence chose the pin. Never refuses anything: choosing k is this
/// function's whole authority.
fn submanifestReplayK(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    snapshot_k: u8,
    streams: cli.Streams,
) ?u8 {
    if (!zit.submanifest.inRange(snapshot_k)) return null;
    const winner_k: u4 = @intCast(snapshot_k);
    var ks_buf: [3]u4 = undefined;
    var n_ks: usize = 0;
    if (winner_k > artifact.min_k) {
        ks_buf[n_ks] = winner_k - 1;
        n_ks += 1;
    }
    ks_buf[n_ks] = winner_k;
    n_ks += 1;
    if (winner_k < artifact.max_k) {
        ks_buf[n_ks] = winner_k + 1;
        n_ks += 1;
    }

    var repo: Repository = undefined;
    Repository.open(&repo, gpa, io, root_dir, .{ .mode = .full }) catch return null;
    defer repo.deinit(gpa);
    var rep = zit.submanifest.replay(gpa, &repo, replay_window, ks_buf[0..n_ks]) catch return null;
    defer rep.deinit(gpa);
    if (rep.commits < 2) return null;
    const w = rep.bestArm() orelse return null;
    streams.out.print(
        "k={d} by replaying {d} commits (steady-state materialization {d:.1}% vs flat; " ++
            "snapshot optimum k={d}).\n",
        .{ w.k, rep.commits, signedPct(w.steady_full, rep.flat_full_steady), snapshot_k },
    ) catch {};
    return w.k;
}

/// Percentage delta of `got` against `base`, signed: -40.0 means 40% below.
fn signedPct(got: u64, base: u64) f64 {
    std.debug.assert(base > 0);
    const g: f64 = @floatFromInt(got);
    const b: f64 = @floatFromInt(base);
    return (g - b) * 100.0 / b;
}

/// The current checkout's effective file set as F cards, on `aa`.
fn submanifestNamesFromCheckout(
    gpa: std.mem.Allocator,
    aa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    fallback_tip: bool,
    streams: cli.Streams,
) ?[]const artifact.FCard {
    var repo: Repository = undefined;
    repo.open(gpa, io, root_dir, .{ .mode = .full }) catch {
        streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
        return null;
    };
    defer repo.deinit(gpa);
    // The fact only — each caller states its own remedy, since `analyze` can be
    // pointed at a name file and `enable` cannot.
    const rid = repo.currentCheckoutRid() orelse blk: {
        // analyze over a bare store (-R, no checkout anywhere): the main
        // branch's tip file set is the natural measurement subject.
        if (fallback_tip) {
            if (repo.resolve("tip", .checkin)) |tip| {
                streams.out.writeAll("no checkout open — analyzing the tip check-in's file set\n") catch {};
                break :blk tip;
            } else |_| {}
        }
        streams.err.writeAll(
            "zit submanifest: no check-in is open, so there is no file set to work from\n",
        ) catch {};
        return null;
    };
    var fl = (repo.files(gpa, rid) catch {
        streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {};
        return null;
    }) orelse {
        streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {};
        return null;
    };
    defer fl.deinit(gpa);

    var list: std.ArrayList(artifact.FCard) = .empty;
    var it = fl.iterator();
    while (it.next()) |f| {
        list.append(aa, .{
            .filename = aa.dupe(u8, f.filename) catch return null,
            .hash = if (f.hash) |h| aa.dupe(u8, h) catch return null else null,
            .perm = if (f.perm) |p| aa.dupe(u8, p) catch return null else null,
            .old_name = null, // §6.2 partitions on the filename only
            .mode = f.mode,
        }) catch return null;
    }
    return list.items;
}

/// A newline-separated name list from `path`, as F cards on `aa`.
///
/// §6.1: "The predicate reads the filename only: content hash, permission, and
/// old-name never influence the partition." So a name list is a complete input
/// to the partition; only the BYTE counts need a hash, and those depend on its
/// length, not its value — every entry gets a distinct 64-hex placeholder, the
/// width the SHA3-256 policy this pass measures under emits. Blank lines are
/// skipped; the list must already be in canonical order (§4.5.3), which is what
/// a manifest's F run and `fossil ls` both produce.
fn submanifestNamesFromFile(
    aa: std.mem.Allocator,
    io: std.Io,
    path: []const u8,
    streams: cli.Streams,
) ?[]const artifact.FCard {
    const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, aa, .limited(max_tracked_file_bytes)) catch {
        streams.err.print("zit submanifest: cannot read '{s}'\n", .{path}) catch {};
        return null;
    };
    var list: std.ArrayList(artifact.FCard) = .empty;
    var it = std.mem.splitScalar(u8, bytes, '\n');
    var seq: u64 = 0;
    while (it.next()) |raw| {
        const name = std.mem.trimEnd(u8, raw, "\r");
        if (name.len == 0) continue;
        seq += 1;
        var digest: [32]u8 = undefined;
        std.mem.writeInt(u64, digest[0..8], seq, .little);
        @memset(digest[8..], 0);
        const hex = aa.alloc(u8, 64) catch return null;
        _ = std.fmt.bufPrint(hex, "{x}", .{&digest}) catch return null;
        list.append(aa, .{
            .filename = name,
            .hash = hex,
            .perm = null,
            .old_name = null,
            .mode = .regular,
        }) catch return null;
    }
    const items = list.items;
    // §6.2's precondition: strictly ascending canonical order. Reported rather
    // than sorted — a caller whose list is out of order is measuring a different
    // name set than it thinks it is.
    for (items, 0..) |e, i| {
        if (i != 0 and !std.mem.lessThan(u8, items[i - 1].filename, e.filename)) {
            streams.err.print(
                "zit submanifest: '{s}' is not in canonical order after '{s}' " ++
                    "(the name list must be sorted, with no duplicates)\n",
                .{ e.filename, items[i - 1].filename },
            ) catch {};
            return null;
        }
    }
    return items;
}

fn submanifestOom(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit submanifest: out of memory\n") catch {};
    return .failure;
}

// ---------------------------------------------------------------------------
// zit checkpoint — the durable rebuild checkpoint's operator surface. Thin
// wiring only: parse, discover, open, call one zit.checkpoint entry point,
// print. No container parsing, tree building, or resume logic here.
// ---------------------------------------------------------------------------

pub fn runCheckpoint(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const parsed = switch (cli.parseCheckpointArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "checkpoint", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "checkpoint", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(main.checkpoint_help) catch {};
        return .ok;
    }

    const root = (cli.discoverRoot(arena, io) catch return checkpointOom(streams)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    return switch (parsed.action) {
        .show => checkpointShow(gpa, io, root_dir, streams),
        .build => checkpointBuild(gpa, io, root_dir, parsed.checkin, streams),
        .verify => checkpointVerify(gpa, io, root_dir, streams),
        .drop => blk: {
            zit.checkpoint.drop(io, root_dir);
            streams.out.writeAll("checkpoint: dropped\n") catch {};
            break :blk .ok;
        },
    };
}

fn checkpointOom(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit checkpoint: out of memory\n") catch {};
    return .failure;
}

fn checkpointShow(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams) cli.Exit {
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit checkpoint: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit checkpoint: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var loaded = zit.checkpoint.tryLoad(gpa, io, root_dir, fs.storageFile()) orelse {
        streams.out.writeAll("checkpoint: none (or it no longer validates against this log)\n") catch {};
        return .ok;
    };
    defer loaded.deinit(gpa);
    streams.out.print(
        "checkpoint: witness\n  check-in: {s}\n  prefix:   {d} frames, {d} bytes\n",
        .{ loaded.claim.checkin_hash, loaded.claim.prefix_key.frame_count, loaded.claim.prefix_key.end },
    ) catch {};
    streams.out.writeAll("  validates against the live log\n") catch {};
    return .ok;
}

fn checkpointBuild(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, checkin: ?[]const u8, streams: cli.Streams) cli.Exit {
    var repo: zit.Repository = undefined;
    // The ordinary open, caches and all: the derive cache's HIT ≡ recompute
    // invariant makes a cache-served view equal to a paranoid derive, and
    // `zit checkpoint verify` is the deliberately paranoid surface. Bypassing
    // here made every build pay a full derive for nothing.
    zit.Repository.open(&repo, gpa, io, root_dir, .{ .mode = .full }) catch |e| {
        streams.err.print("zit checkpoint: cannot read repository: {s}\n", .{openWhy(e)}) catch {};
        return .failure;
    };
    defer repo.deinit(gpa);

    const report = zit.checkpoint.build(gpa, io, &repo, root_dir, checkin) catch |e| {
        streams.err.print("zit checkpoint: build failed: {s}\n", .{buildWhy(e)}) catch {};
        return .failure;
    };
    streams.out.print(
        "checkpoint: built at {s}\n  files: {d}  prefix frames: {d}\n",
        .{ report.checkinHash(), report.files, report.prefix_frames },
    ) catch {};
    return .ok;
}

fn checkpointVerify(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams) cli.Exit {
    const verdict = zit.checkpoint.verify(gpa, io, root_dir) catch |e| {
        streams.err.print("zit checkpoint: verify failed: {s}\n", .{buildWhy(e)}) catch {};
        return .failure;
    };
    switch (verdict) {
        .ok => |report| {
            streams.out.print("checkpoint: verified ok ({d} files, {d} prefix frames)\n", .{ report.files, report.prefix_frames }) catch {};
            return .ok;
        },
        .no_checkpoint => {
            streams.out.writeAll("checkpoint: none to verify\n") catch {};
            return .ok;
        },
        .stale => {
            streams.err.writeAll("zit checkpoint: STALE — the sidecar no longer validates against this log; run 'zit checkpoint build' to replace it\n") catch {};
            return .failure;
        },
        .derivation_mismatch => {
            streams.err.writeAll("zit checkpoint: DERIVATION MISMATCH — the stored prefix record differs from an independent re-derivation; drop and rebuild\n") catch {};
            return .failure;
        },
        .fileset_mismatch => {
            streams.err.writeAll("zit checkpoint: FILE-SET MISMATCH — the fingerprint disagrees with the independently derived file set at the anchor check-in\n") catch {};
            return .failure;
        },
    }
}

fn openWhy(e: anyerror) []const u8 {
    return switch (e) {
        error.NotARepo => "not a zit repository",
        error.Corrupt => "repository is corrupt",
        error.OutOfMemory => "out of memory",
        else => "input/output error",
    };
}

fn buildWhy(e: zit.checkpoint.BuildError) []const u8 {
    return switch (e) {
        error.OutOfMemory => "out of memory",
        error.Storage => "input/output error",
        error.Corrupt => "repository is corrupt",
        error.CheckinNotFound => "check-in not found in this repository",
        error.NotACheckin => "the named artifact is not a check-in",
    };
}

/// `zit worktrees` — list the store's registered working trees, lazily
/// pruning entries whose path died or whose checkout re-pointed at some
/// other store. The identity test is the store file's inode: an entry
/// counts iff its own resolved store names the same `.zit` this invocation
/// resolved. Pruning rewrites only the disposable cache tier.
pub fn runWorktrees(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    for (verb_args) |tok| {
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(main.worktrees_help) catch {};
            return .ok;
        }
        return cli.usageError(streams, "worktrees", .extra_argument, tok);
    }

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "worktrees")) orelse {
        main.reportNoRoot(streams, io, "worktrees", amb.repo_override);
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // The store root: the resolved pointer for a detached tree, the root
    // itself otherwise (including the bare -R form, where root IS the store).
    var store_dir = blk: {
        if (checkout.storeDirOf(io, root_dir) catch null) |resolved| break :blk resolved;
        break :blk root_dir.openDir(io, ".", .{}) catch {
            streams.err.writeAll("zit worktrees: cannot open the repository\n") catch {};
            return .failure;
        };
    };
    defer store_dir.close(io);
    const store_inode = (store_dir.statFile(io, zit.repo_filename, .{}) catch {
        streams.err.writeAll("zit worktrees: cannot read the repository\n") catch {};
        return .failure;
    }).inode;

    const registered = zit.repocache.readWorktrees(gpa, io, store_dir);
    defer zit.repocache.freeWorktrees(gpa, registered);

    var live: std.ArrayListUnmanaged([]const u8) = .empty;
    defer live.deinit(gpa);
    for (registered) |path| {
        if (worktreeServesStore(io, path, store_inode)) {
            live.append(gpa, path) catch return stateOom(streams, "worktrees");
        }
    }
    if (live.items.len != registered.len) {
        zit.repocache.writeWorktrees(gpa, io, store_dir, live.items); // lazy prune
    }

    if (live.items.len == 0) {
        streams.out.writeAll("no working trees are registered against this store\n") catch {};
        return .ok;
    }
    for (live.items) |path| {
        // The invoking-tree marker only makes sense when the invocation RAN
        // in a tree; under -R the resolved root is the store, not us.
        const marker: []const u8 = if (amb.repo_override == null and std.mem.eql(u8, path, root.abs)) "* " else "  ";
        var tree = std.Io.Dir.cwd().openDir(io, path, .{}) catch continue;
        defer tree.close(io);
        var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        const state = checkout.read(io, tree, &ck_buf) catch null;
        if (state) |st| {
            if (st.version.len != 0) {
                streams.out.print("{s}{s}  ({s} at {s})\n", .{ marker, path, st.branch, st.version[0..@min(st.version.len, 10)] }) catch {};
                continue;
            }
            streams.out.print("{s}{s}  ({s}, unborn)\n", .{ marker, path, st.branch }) catch {};
            continue;
        }
        streams.out.print("{s}{s}\n", .{ marker, path }) catch {};
    }
    return .ok;
}

/// Does the checkout at `path` still serve the store identified by
/// `store_inode`? Total: any failure reads as "no" and the entry prunes —
/// the registry's self-healing contract.
fn worktreeServesStore(io: std.Io, path: []const u8, store_inode: std.Io.File.INode) bool {
    var tree = std.Io.Dir.cwd().openDir(io, path, .{}) catch return false;
    defer tree.close(io);
    _ = tree.statFile(io, checkout.filename, .{}) catch return false;
    if (checkout.storeDirOf(io, tree) catch null) |resolved| {
        var sd = resolved;
        defer sd.close(io);
        const st = sd.statFile(io, zit.repo_filename, .{}) catch return false;
        return st.inode == store_inode;
    }
    const st = tree.statFile(io, zit.repo_filename, .{}) catch return false;
    return st.inode == store_inode;
}

// ---------------------------------------------------------------------------
// `zit trust`: consent to execute a versioned before-commit declaration
// ---------------------------------------------------------------------------

/// `zit trust [--revoke]` — show, grant, or withdraw this checkout's consent to
/// run the project's `before-commit` check.
///
/// Bare, it SHOWS the declaration and whether it is trusted, and does not grant
/// anything. Granting is a second, explicit invocation. That split is
/// deliberate: consent given without seeing what is being consented to is not
/// consent, and a single command that printed and trusted in one step would
/// make the safe reading of `zit trust` the dangerous one.
pub fn runTrust(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var revoke = false;
    var grant = false;
    for (verb_args) |tok| {
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(main.trust_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--revoke")) {
            revoke = true;
        } else if (std.mem.eql(u8, tok, "--yes")) {
            grant = true;
        } else {
            return cli.usageError(streams, "trust", .unknown_option, tok);
        }
    }
    if (revoke and grant) {
        streams.err.writeAll("zit trust: --revoke and --yes are contradictory\n") catch {};
        return .usage;
    }

    const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "trust")) orelse {
        streams.err.writeAll(cmd_sync.not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    const resolved = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .before_commit) catch {
        streams.err.writeAll("zit trust: cannot read the before-commit setting\n") catch {};
        return .failure;
    };

    if (revoke) {
        cmd_sync.updateConfig(gpa, io, root_dir, .{ .before_commit_trust = "" }) catch {
            streams.err.writeAll("zit trust: cannot write the trust record\n") catch {};
            return .failure;
        };
        streams.out.writeAll("before-commit: trust revoked\n") catch {};
        return .ok;
    }

    if (resolved.value.len == 0) {
        streams.out.writeAll("before-commit: not set; nothing to trust\n") catch {};
        return .ok;
    }

    const stored = trustRecord(gpa, io, arena, root_dir) catch "";
    const verdict = zit.settings.trustVerdict(resolved.value, resolved.source, stored);

    if (!grant) {
        // SHOW. The declaration is printed in full because that is the thing
        // being consented to; a summary would defeat the point.
        streams.out.print("before-commit ({s}): {s}\n", .{ resolved.source.label(), resolved.value }) catch {};
        switch (verdict) {
            .none => unreachable, // handled above
            .self_authored => streams.out.writeAll("status: runs (you set it here; no trust record needed)\n") catch {},
            .trusted => streams.out.writeAll("status: trusted — runs before each commit\n") catch {},
            .untrusted => {
                streams.out.writeAll("status: NOT trusted — commits are refused until you accept it\n") catch {};
                streams.err.writeAll("read the command above, then run 'zit trust --yes' to accept it\n") catch {};
            },
            .changed => {
                streams.out.writeAll("status: CHANGED since you trusted it — commits are refused\n") catch {};
                streams.err.writeAll("the declaration was edited (possibly by a pull); read it above, then 'zit trust --yes'\n") catch {};
            },
        }
        return .ok;
    }

    if (verdict == .self_authored) {
        streams.out.writeAll("before-commit: set locally; it already runs and needs no trust record\n") catch {};
        return .ok;
    }
    var hash_buf: [64]u8 = undefined;
    const hash = zit.settings.declarationHash(resolved.value, &hash_buf);
    cmd_sync.updateConfig(gpa, io, root_dir, .{ .before_commit_trust = hash }) catch {
        streams.err.writeAll("zit trust: cannot write the trust record\n") catch {};
        return .failure;
    };
    streams.out.print("before-commit: trusted ({s})\n", .{hash[0..12]}) catch {};
    return .ok;
}

/// This checkout's stored trust hash, or empty. Local-only by construction:
/// it is read from the config frame, which never crosses a sync.
fn trustRecord(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
) ![]const u8 {
    var file = cli.openStoreRead(io, root_dir) catch return "";
    defer file.close(io);
    const st = file.stat(io) catch return "";
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return "";
    defer log.deinit(gpa);
    var oc = (log.readConfig(gpa) catch return "") orelse return "";
    defer oc.deinit(gpa);
    return arena.dupe(u8, oc.config.before_commit_trust) catch "";
}

test "commit: ContentPolicy classifies binary before crlf, and a glob permits each" {
    // Ordering matters: a binary file usually also contains CR/LF, and naming
    // it "binary" is the diagnostic the operator can act on.
    const both = "a\x00b\r\n";
    // Undeclared is no opinion: a repository that never asked keeps committing
    // binary content exactly as before. This is the whole opt-in rule.
    const silent: ContentPolicy = .{};
    try std.testing.expectEqual(@as(?ContentFault, null), silent.fault("x.dat", both));

    // Declared: binary first, since a binary file usually also has CR/LF.
    const declared: ContentPolicy = .{ .binary_glob = "*.ok", .crlf_glob = "*.ok" };
    try std.testing.expectEqual(ContentFault.binary, declared.fault("x.dat", both).?);
    try std.testing.expectEqual(ContentFault.crlf, declared.fault("x.txt", "a\r\nb").?);
    try std.testing.expectEqual(@as(?ContentFault, null), declared.fault("x.txt", "plain\n"));

    // Each glob permits only its own class, and only its own paths.
    const bin_ok: ContentPolicy = .{ .binary_glob = "*.dat" };
    try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.fault("x.dat", "a\x00b"));
    try std.testing.expectEqual(ContentFault.binary, bin_ok.fault("x.bin", "a\x00b").?);
    // Declaring only binary-glob leaves CR/LF undeclared, hence unchecked.
    try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.fault("x.dat", "a\r\nb"));

    const crlf_ok: ContentPolicy = .{ .crlf_glob = "*.txt" };
    try std.testing.expectEqual(@as(?ContentFault, null), crlf_ok.fault("x.txt", "a\r\nb"));
    // crlf-glob alone leaves the binary class undeclared, so it stays silent.
    try std.testing.expectEqual(@as(?ContentFault, null), crlf_ok.fault("x.bin", "a\x00b"));

    // --no-checks waives everything, which is the only way past a class with
    // no glob — and is deliberately one flag, not one per class.
    const waived: ContentPolicy = .{ .binary_glob = "*.ok", .crlf_glob = "*.ok", .waived = true };
    try std.testing.expectEqual(@as(?ContentFault, null), waived.fault("x.bin", both));
}

test "encoding-glob gates UTF-16 and invalid UTF-8, and binary-glob stops claiming UTF-16" {
    const utf16 = "\xFF\xFEa\x00b\x00";
    const utf16_crlf = "\xFF\xFE\x0D\x00\x0A\x00";
    const bad_utf8 = "hello \xC3\x28 there";

    // Opt-in, as for every other class: with no encoding-glob declared the
    // repository has no opinion and nothing is refused.
    const silent: ContentPolicy = .{};
    try std.testing.expectEqual(@as(?ContentFault, null), silent.fault("x.txt", utf16));
    try std.testing.expectEqual(@as(?ContentFault, null), silent.fault("x.txt", bad_utf8));

    // Declared: only the named paths may carry either encoding class.
    const enc: ContentPolicy = .{ .encoding_glob = "*.bin" };
    try std.testing.expectEqual(ContentFault.unicode, enc.fault("x.txt", utf16).?);
    try std.testing.expectEqual(ContentFault.invalid_utf8, enc.fault("x.txt", bad_utf8).?);
    try std.testing.expectEqual(@as(?ContentFault, null), enc.fault("x.bin", utf16));
    try std.testing.expectEqual(@as(?ContentFault, null), enc.fault("x.bin", bad_utf8));
    // `*` is how a project turns the whole check back off.
    const off: ContentPolicy = .{ .encoding_glob = "*" };
    try std.testing.expectEqual(@as(?ContentFault, null), off.fault("x.txt", utf16));

    // fossil: Unicode WITH CR is exempt only when both globs permit the file
    // (checkin.c's "crlf-glob and encoding-glob settings" branch).
    const both: ContentPolicy = .{ .encoding_glob = "*.ok", .crlf_glob = "*.ok" };
    try std.testing.expectEqual(@as(?ContentFault, null), both.fault("x.ok", utf16_crlf));
    try std.testing.expectEqual(ContentFault.unicode, both.fault("x.txt", utf16_crlf).?);
    const crlf_only: ContentPolicy = .{ .encoding_glob = "*.txt", .crlf_glob = "*.ok" };
    try std.testing.expectEqual(ContentFault.crlf, crlf_only.fault("x.txt", utf16_crlf).?);
    // ...but invalid UTF-8 with CR/LF is exempt on encoding-glob ALONE: fossil's
    // `fHasInvalidUtf8` branch returns before crlf-glob is consulted, so
    // `fossil commit` accepts this Latin-1 file where zit once refused it.
    const latin1_crlf = "caf\xE9 latin-1\r\nline two\r\n";
    try std.testing.expectEqual(@as(?ContentFault, null), crlf_only.fault("x.txt", latin1_crlf));
    try std.testing.expectEqual(ContentFault.invalid_utf8, crlf_only.fault("x.ok", latin1_crlf).?);
    try std.testing.expectEqual(@as(?ContentFault, null), crlf_only.undeclared(latin1_crlf));

    // A UTF-16 file is NOT binary. Before this it tripped
    // binary-glob on the zero bytes inside its units, which is a class fossil
    // never puts it in.
    const bin: ContentPolicy = .{ .binary_glob = "*.dat" };
    try std.testing.expectEqual(@as(?ContentFault, null), bin.fault("x.txt", utf16));
    // NUL still wins over encoding when the units really are zero.
    const bin_enc: ContentPolicy = .{ .binary_glob = "*.dat", .encoding_glob = "*.dat" };
    try std.testing.expectEqual(ContentFault.binary, bin_enc.fault("x.txt", "\xFF\xFEa\x00\x00\x00").?);

    // One waiver for every class, as before.
    const waived: ContentPolicy = .{ .encoding_glob = "*.ok", .waived = true };
    try std.testing.expectEqual(@as(?ContentFault, null), waived.fault("x.txt", utf16));

    // The receipt half: an undeclared class is said, never refused.
    try std.testing.expectEqual(ContentFault.unicode, silent.undeclared(utf16).?);
    try std.testing.expectEqual(ContentFault.invalid_utf8, silent.undeclared(bad_utf8).?);
    try std.testing.expectEqual(@as(?ContentFault, null), enc.undeclared(utf16));
}

test "binary wins over encoding whether or not binary-glob is declared" {
    // fossil decides the binary class FIRST and returns from that branch
    // (checkin.c `commit_warning`): a blob with a NUL is never reported as an
    // encoding or a CR/LF fault, and only `binary-glob` can exempt it. The
    // opt-in model keeps that ordering: with the binary class undeclared a
    // binary blob passes with a receipt note, it does not fall through to the
    // encoding rows and get refused under the wrong glob.
    const png = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xFF\xFE\x01";
    const wide_nul = "\xFF\xFE\x0D\x00\x00\x00";

    // encoding-glob alone: a PNG is not this project's business, so it goes in.
    const enc_only: ContentPolicy = .{ .encoding_glob = "*.txt" };
    try std.testing.expectEqual(@as(?ContentFault, null), enc_only.fault("pic.png", png));
    try std.testing.expectEqual(@as(?ContentFault, null), enc_only.fault("wide.png", wide_nul));
    // ... and the receipt says "binary", the class it is actually in.
    try std.testing.expectEqual(ContentFault.binary, enc_only.undeclared(png).?);
    try std.testing.expectEqual(ContentFault.binary, enc_only.undeclared(wide_nul).?);

    // crlf-glob alone is the same story: a binary blob full of CR/LF pairs is
    // binary, not a line-ending problem.
    const crlf_only: ContentPolicy = .{ .crlf_glob = "*.txt" };
    try std.testing.expectEqual(@as(?ContentFault, null), crlf_only.fault("pic.png", png));

    // Declared and matched: fossil returns 0 from the binary branch, so no
    // later class is consulted for the file at all.
    const bin_ok: ContentPolicy = .{ .binary_glob = "*.png", .crlf_glob = "*.txt", .encoding_glob = "*.txt" };
    try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.fault("pic.png", png));
    try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.undeclared(png));
    // Declared and unmatched: refused as binary, never as an encoding fault.
    try std.testing.expectEqual(ContentFault.binary, bin_ok.fault("pic.dat", png).?);
}

// --- mergeability -------------------------------------------------------------------------------
//
// "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 landing workflow.
// Content-level or the answer is useless.

/// What one branch would do against another.
///
/// Lives beside the merge engine rather than in the verb that first needed it:
/// `landing show` was the first caller, `merge --probe` is the general one, and
/// a caller with its own record index must be able to ask about a pair
/// of check-ins without paying a scan to find records it already knows about.
/// One implementation, three callers.
pub 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.
pub const Probe = struct {
    verdict: Mergeability,
    both_modified: []const []const u8,
};

/// Would `branch` land on `target` without a human? Store-only; writes nothing.
/// `zit merge --probe NAME [--into TARGET]`.
///
/// Reports whether NAME would merge into TARGET without a human, computed from
/// the store and writing nothing. The output is the contract a consumer reads:
///
///     merges   clean | up-to-date | unrelated | conflict (N)
///     conflict PATH                       (one per unresolvable path)
///
/// Exit 0 in every one of those cases -- "has conflicts" is an ANSWER, not a
/// failure. A caller distinguishes them by reading the line, not the status.
///
/// This exists because a consumer with its own record index should not have to
/// scan a store to ask about a pair of check-ins it already knows. The
/// same question through `landing show` costs a full-store walk to FIND the
/// record first; here the caller names both sides.
fn runMergeProbe(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    parsed: cli.MergeArgs,
    streams: cli.Streams,
    amb: cli.Ambient,
    checkout_root: ?NamedCheckout,
) cli.Exit {
    const name = parsed.checkin orelse return cli.usageError(streams, "merge", .no_name, "");

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    var owns_root = false;
    if (checkout_root) |given| {
        repo.open(gpa, io, given.dir, .{ .bypass_cache = false, .mode = .full }) catch |e| {
            if (repo.view.fault) |f| return main.repoDeriveFault(streams, "merge", f);
            return repoOpenFail(streams, "merge", e);
        };
        root_dir = given.dir;
    } else {
        const rc = openRepo(gpa, io, arena, streams, "merge", amb.repo_override, false, false, &repo, &root_dir);
        if (rc != .ok) return rc;
        owns_root = true;
    }
    defer if (owns_root) root_dir.close(io);
    defer repo.deinit(gpa);

    const other = repo.resolve(name, .checkin) catch {
        streams.err.print("zit merge: check-in '{s}' not found\n", .{name}) catch {};
        return .failure;
    };

    // The target: named, or the checkout's own check-in. With `-R` there is no
    // checkout to fall back to, so `--into` is required rather than guessed.
    const current = if (parsed.into) |target|
        repo.resolve(target, .checkin) catch {
            streams.err.print("zit merge: check-in '{s}' not found\n", .{target}) catch {};
            return .failure;
        }
    else
        repo.currentCheckoutRid() orelse {
            streams.err.writeAll(
                "zit merge: --probe against a repository needs --into TARGET (there is no checkout to merge into)\n",
            ) catch {};
            return .failure;
        };

    if (parsed.chain_len > 1) {
        const names = parsed.chain[0..parsed.chain_len];
        const rungs = arena.alloc(u64, names.len) catch return mergeOom(streams);
        rungs[0] = other;
        for (names[1..], rungs[1..]) |n, *r| {
            r.* = repo.resolve(n, .checkin) catch {
                streams.err.print("zit merge: check-in '{s}' not found\n", .{n}) catch {};
                return .failure;
            };
        }
        const verdicts = probeChain(gpa, arena, &repo, current, rungs) catch return mergeOom(streams);
        for (names, verdicts) |n, v| switch (v) {
            .up_to_date => streams.out.print("merges   {s} up-to-date\n", .{n}) catch {},
            .unrelated => streams.out.print("merges   {s} unrelated\n", .{n}) catch {},
            .clean => streams.out.print("merges   {s} clean\n", .{n}) catch {},
            .not_reached => streams.out.print("merges   {s} not-reached\n", .{n}) catch {},
            .conflicts => |paths| {
                streams.out.print("merges   {s} conflict ({d})\n", .{ n, paths.len }) catch {};
                for (paths) |path| streams.out.print("conflict {s}\n", .{path}) catch {};
            },
        };
        return .ok;
    }

    const probe = probeMergeability(gpa, arena, &repo, current, other) catch
        return mergeOom(streams);
    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 {};
        },
    }
    return .ok;
}

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

/// One rung's answer in a chained probe. `not_reached` follows the rung that
/// stopped: a merge that did not complete leaves no tree to continue from.
pub const ChainVerdict = union(enum) {
    up_to_date,
    unrelated,
    clean,
    conflicts: []const []const u8,
    not_reached,
};

/// A path's content on the landed side of a chained probe: a blob the store
/// holds, or bytes an earlier rung's merge produced in memory. The second is
/// why the chain cannot run on hashes alone — a cleanly merged file has no
/// name in the store until someone commits it.
const ChainContent = union(enum) { hash: []const u8, bytes: []const u8 };
const ChainMap = std.StringHashMapUnmanaged(ChainContent);

/// Would each of `rungs` land, in order, on `target` with the ones before it
/// already landed? One verdict per rung, arena-owned, in rung order. `zit merge --probe --into TARGET NAME NAME...`. Store-only;
/// writes nothing.
///
/// Each clean rung's merged tree becomes the next rung's "ours", in memory.
/// The base is widened the same way: once rung 1 has landed, TARGET's tip has
/// it as an ancestor, so rung 2's base is the nearest common ancestor with ANY
/// landed check-in, not with TARGET alone. Merging the landed tree against
/// TARGET's old fork point instead would see rung 1's edits on the landed side
/// and rung 2's rewrite of them on the other, and report a stacked rung as
/// conflicting with the very rung beneath it.
///
/// The first rung is exactly `probeMergeability`'s question and answers it
/// the same way.
pub fn probeChain(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    repo: *Repository,
    target_rid: u64,
    rungs: []const u64,
) ![]ChainVerdict {
    const out = try arena.alloc(ChainVerdict, rungs.len);
    var landed: std.ArrayList(u64) = .empty;
    try landed.append(arena, target_rid);

    var ours: ChainMap = .empty;
    {
        var tmp: SideMap = .empty;
        try sideMapOf(gpa, arena, repo, target_rid, &tmp);
        var it = tmp.iterator();
        while (it.next()) |e| try ours.put(arena, e.key_ptr.*, .{ .hash = e.value_ptr.* });
    }

    var stopped = false;
    for (rungs, out) |rung, *verdict| {
        if (stopped) {
            verdict.* = .not_reached;
            continue;
        }
        if (try chainHolds(gpa, repo, landed.items, rung)) {
            verdict.* = .up_to_date;
            continue;
        }
        const base_rid = (try chainBase(gpa, repo, landed.items, rung)) orelse {
            verdict.* = .unrelated;
            stopped = true;
            continue;
        };
        var base: SideMap = .empty;
        var theirs: SideMap = .empty;
        try sideMapOf(gpa, arena, repo, base_rid, &base);
        try sideMapOf(gpa, arena, repo, rung, &theirs);

        var next: ChainMap = .empty;
        var conflicts: std.ArrayList([]const u8) = .empty;
        var seen: std.StringHashMapUnmanaged(void) = .empty;
        var ours_it = ours.iterator();
        while (ours_it.next()) |e| try chainMergePath(arena, repo, e.key_ptr.*, &base, &ours, &theirs, &next, &conflicts, &seen);
        var theirs_it = theirs.iterator();
        while (theirs_it.next()) |e| try chainMergePath(arena, repo, e.key_ptr.*, &base, &ours, &theirs, &next, &conflicts, &seen);

        if (conflicts.items.len != 0) {
            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);
            verdict.* = .{ .conflicts = conflicts.items };
            stopped = true;
            continue;
        }
        verdict.* = .clean;
        ours = next;
        try landed.append(arena, rung);
    }
    return out;
}

/// Whether `rung` is already in the landed tree: equal to, or an ancestor of,
/// something landed.
fn chainHolds(gpa: std.mem.Allocator, repo: *Repository, landed: []const u64, rung: u64) !bool {
    for (landed) |l| {
        if (l == rung) return true;
        if (try repo.isAncestorOf(gpa, rung, l)) return true;
    }
    return false;
}

/// The merge base of `rung` against the landed tree: the most recent of its
/// merge bases with each landed check-in. A candidate replaces the held one
/// only when it descends from it, so incomparable candidates (a criss-cross)
/// keep the first found — deterministic, and the same order the stack names.
fn chainBase(gpa: std.mem.Allocator, repo: *Repository, landed: []const u64, rung: u64) !?u64 {
    var best: ?u64 = null;
    for (landed) |l| {
        const c = (try repo.mergeBaseOf(gpa, l, rung)) orelse continue;
        if (best) |b| {
            if (b != c and try repo.isAncestorOf(gpa, b, c)) best = c;
        } else best = c;
    }
    return best;
}

/// Merge one path of a chained probe into `next`, or record it in `conflicts`.
/// The same short circuits `probeMergeability` takes, except that the landed
/// side may be bytes rather than a stored blob, so equality is by content
/// whenever either side is in memory.
fn chainMergePath(
    arena: std.mem.Allocator,
    repo: *Repository,
    path: []const u8,
    base: *const SideMap,
    ours: *const ChainMap,
    theirs: *const SideMap,
    next: *ChainMap,
    conflicts: *std.ArrayList([]const u8),
    seen: *std.StringHashMapUnmanaged(void),
) !void {
    if (seen.contains(path)) return;
    try seen.put(arena, path, {});
    const b: ?ChainContent = if (base.get(path)) |h| .{ .hash = h } else null;
    const o: ?ChainContent = ours.get(path);
    const t: ?ChainContent = if (theirs.get(path)) |h| .{ .hash = h } else null;

    if (try chainEql(arena, repo, o, t)) return keep(arena, next, path, o);
    if (try chainEql(arena, repo, b, o)) return keep(arena, next, path, t);
    if (try chainEql(arena, repo, b, t)) return keep(arena, next, path, o);

    // Both sides moved. An unreadable blob (a sparse store's elided content)
    // is a conflict, as in `conflictsOnContent`: claiming clean is the
    // dangerous direction.
    const bb = chainBytes(arena, repo, b) catch |e| return chainUnreadable(arena, conflicts, path, e);
    const ob = chainBytes(arena, repo, o) catch |e| return chainUnreadable(arena, conflicts, path, e);
    const tb = chainBytes(arena, repo, t) catch |e| return chainUnreadable(arena, conflicts, path, e);
    switch (try merge3.classifyFile(arena, bb, ob, tb, .conflict)) {
        .unchanged => try keep(arena, next, path, o),
        .take_other => try keep(arena, next, path, t),
        .delete => {},
        .clean_merge => |r| try next.put(arena, path, .{ .bytes = r.bytes }),
        .text_conflict, .whole_file_conflict => try conflicts.append(arena, path),
    }
}

fn keep(arena: std.mem.Allocator, next: *ChainMap, path: []const u8, c: ?ChainContent) !void {
    if (c) |v| try next.put(arena, path, v);
}

fn chainUnreadable(arena: std.mem.Allocator, conflicts: *std.ArrayList([]const u8), path: []const u8, e: anyerror) !void {
    if (e == error.OutOfMemory) return error.OutOfMemory;
    try conflicts.append(arena, path);
}

/// A side's bytes, or null for an absent path. `error.Unreadable` when the
/// store names a blob it cannot produce.
fn chainBytes(arena: std.mem.Allocator, repo: *Repository, c: ?ChainContent) !?[]const u8 {
    const v = c orelse return null;
    return switch (v) {
        .bytes => |b| b,
        .hash => |h| (try repo.blob(arena, h)) orelse error.Unreadable,
    };
}

fn chainEql(arena: std.mem.Allocator, repo: *Repository, a: ?ChainContent, b: ?ChainContent) !bool {
    if (a == null and b == null) return true;
    if (a == null or b == null) return false;
    if (a.? == .hash and b.? == .hash) return std.mem.eql(u8, a.?.hash, b.?.hash);
    // A side held in memory has no name to compare, so compare bytes. A blob
    // that cannot be read equals nothing, which sends the path on to the
    // both-moved case and its unreadable-is-a-conflict rule.
    const ab = chainBytes(arena, repo, a) catch |e| {
        if (e == error.OutOfMemory) return error.OutOfMemory;
        return false;
    };
    const bb = chainBytes(arena, repo, b) catch |e| {
        if (e == error.OutOfMemory) return error.OutOfMemory;
        return false;
    };
    return std.mem.eql(u8, ab.?, bb.?);
}

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.?);
}

/// One line of the commit template / dry run: the composed file set
/// against the parent's, in Fossil's words.
const ChangeLine = struct { word: []const u8, path: []const u8 };

/// The stderr receipt under the check-in line: what went in, then every
/// binary or CRLF file no glob declared (what Fossil prompts about).
fn commitReceipt(streams: cli.Streams, lines: []const ChangeLine, notes: []const ContentNote) void {
    const e = streams.err;
    if (lines.len == 1) {
        e.print("{s} {s}\n", .{ lines[0].path, changeWordLower(lines[0].word) }) catch {};
    } else if (lines.len != 0) {
        var counts = [3]usize{ 0, 0, 0 };
        for (lines) |l| counts[changeWordIndex(l.word)] += 1;
        e.print("{d} files:", .{lines.len}) catch {};
        var sep: []const u8 = " ";
        for (counts, [3][]const u8{ "added", "edited", "deleted" }) |c, w| {
            if (c == 0) continue;
            e.print("{s}{d} {s}", .{ sep, c, w }) catch {};
            sep = ", ";
        }
        e.writeAll("\n") catch {};
    }
    for (notes) |note| switch (note.fault) {
        .binary => e.print("note: {s} is binary ({d} bytes); declare binaries with 'zit settings binary-glob'\n", .{ note.path, note.size }) catch {},
        .unicode => e.print("note: {s} is UTF-16 (Unicode); declare its encoding with 'zit settings encoding-glob'\n", .{note.path}) catch {},
        .invalid_utf8 => e.print("note: {s} is not valid UTF-8; declare its encoding with 'zit settings encoding-glob'\n", .{note.path}) catch {},
        .crlf => e.print("note: {s} has CRLF line endings; declare them with 'zit settings crlf-glob'\n", .{note.path}) catch {},
    };
}
fn changeWordIndex(word: []const u8) usize {
    if (std.mem.eql(u8, word, "ADDED")) return 0;
    if (std.mem.eql(u8, word, "EDITED")) return 1;
    return 2;
}
fn changeWordLower(word: []const u8) []const u8 {
    return ([3][]const u8{ "added", "edited", "deleted" })[changeWordIndex(word)];
}
fn commitChangeLines(arena: std.mem.Allocator, parent: []const artifact.FCard, child: []const artifact.FCard) error{OutOfMemory}![]const ChangeLine {
    var lines: std.ArrayList(ChangeLine) = .empty;
    var i: usize = 0;
    var j: usize = 0;
    while (i < parent.len or j < child.len) {
        if (j >= child.len or (i < parent.len and std.mem.order(u8, parent[i].filename, child[j].filename) == .lt)) {
            try lines.append(arena, .{ .word = "DELETED", .path = parent[i].filename });
            i += 1;
        } else if (i >= parent.len or std.mem.order(u8, parent[i].filename, child[j].filename) == .gt) {
            try lines.append(arena, .{ .word = "ADDED", .path = child[j].filename });
            j += 1;
        } else {
            const same = std.mem.eql(u8, parent[i].hash orelse "", child[j].hash orelse "") and parent[i].mode == child[j].mode;
            if (!same) try lines.append(arena, .{ .word = "EDITED", .path = child[j].filename });
            i += 1;
            j += 1;
        }
    }
    return lines.items;
}

/// `-M FILE`: the file's bytes, or null when it cannot be read.
fn readMessageFile(gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, path: []const u8) ?[]const u8 {
    const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1 << 20)) catch return null;
    defer gpa.free(bytes);
    return arena.dupe(u8, bytes) catch null;
}

/// Compose the comment in an editor. The template is written to
/// `.zit-ci-comment` in the checkout root: a blank line for the comment,
/// then `#` lines naming the branch, the parent and every file this commit
/// captures — stripped on the way back, so a comment can never carry them.
/// The editor is `--editor`, else $VISUAL, else $EDITOR; it runs with the
/// terminal inherited, as `sh -c 'EDITOR "$1"'`. An empty result aborts, and
/// the file stays for `-M`.
fn composeMessageInEditor(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    root_dir: std.Io.Dir,
    root_abs: []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
    cfg: ?*const logstore.Config,
    editor_override: ?[]const u8,
    branch: []const u8,
    parent: []const u8,
    lines: []const ChangeLine,
) ?[]const u8 {
    var env_map = amb.environ.createMap(gpa) catch {
        streams.err.writeAll("zit commit: out of memory\n") catch {};
        return null;
    };
    defer env_map.deinit();
    // The `editor` setting sits between --editor and the environment.
    const configured: ?[]const u8 = blk: {
        const r = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .editor) catch break :blk null;
        break :blk if (r.value.len == 0) null else r.value;
    };
    const editor = editor_override orelse configured orelse env_map.get("VISUAL") orelse env_map.get("EDITOR") orelse {
        streams.err.writeAll("zit commit: no editor: set $VISUAL or $EDITOR, 'zit settings editor PROG', pass --editor PROG, or give the comment with -m\n") catch {};
        return null;
    };
    var tpl: std.ArrayList(u8) = .empty;
    defer tpl.deinit(gpa);
    tpl.appendSlice(gpa, "\n# Enter a check-in comment. Lines starting with '#' are ignored;\n# an empty comment aborts the commit.\n#\n") catch return null;
    tpl.print(gpa, "# branch: {s}\n", .{branch}) catch return null;
    if (parent.len != 0) tpl.print(gpa, "# parent: {s}\n", .{parent[0..@min(parent.len, 12)]}) catch return null;
    tpl.appendSlice(gpa, "#\n") catch return null;
    for (lines) |l| tpl.print(gpa, "# {s: <8}{s}\n", .{ l.word, l.path }) catch return null;
    root_dir.writeFile(io, .{ .sub_path = comment_file_name, .data = tpl.items }) catch {
        streams.err.print("zit commit: cannot write {s}\n", .{comment_file_name}) catch {};
        return null;
    };
    const path = std.fs.path.join(arena, &.{ root_abs, comment_file_name }) catch return null;
    const script = std.fmt.allocPrint(arena, "{s} \"$1\"", .{editor}) catch return null;
    var child = std.process.spawn(io, .{
        .argv = &.{ "/bin/sh", "-c", script, "zit-editor", path },
        .cwd = .{ .dir = root_dir },
        .environ_map = &env_map,
    }) catch {
        streams.err.print("zit commit: cannot start the editor '{s}'\n", .{editor}) catch {};
        return null;
    };
    const term = child.wait(io) catch {
        streams.err.print("zit commit: the editor '{s}' did not finish\n", .{editor}) catch {};
        return null;
    };
    switch (term) {
        .exited => |code| if (code != 0) {
            streams.err.print("zit commit: the editor exited with status {d}; comment kept in {s}\n", .{ code, comment_file_name }) catch {};
            return null;
        },
        else => {
            streams.err.print("zit commit: the editor did not exit normally; comment kept in {s}\n", .{comment_file_name}) catch {};
            return null;
        },
    }
    const raw = root_dir.readFileAlloc(io, comment_file_name, gpa, .limited(1 << 20)) catch {
        streams.err.print("zit commit: cannot read {s} back\n", .{comment_file_name}) catch {};
        return null;
    };
    defer gpa.free(raw);
    var out: std.ArrayList(u8) = .empty;
    var it = std.mem.splitScalar(u8, raw, '\n');
    while (it.next()) |line| {
        if (std.mem.startsWith(u8, line, "#")) continue;
        out.appendSlice(arena, line) catch return null;
        out.append(arena, '\n') catch return null;
    }
    const trimmed = std.mem.trim(u8, out.items, " \t\r\n");
    if (trimmed.len == 0) {
        streams.err.print("zit commit: aborting — empty check-in message (template kept in {s})\n", .{comment_file_name}) catch {};
        return null;
    }
    return arena.dupe(u8, trimmed) catch null;
}