Skip to content
zit Artifact [901c934545]
Login

Artifact [901c934545]

Artifact 901c9345456481c185859168fc69422bc2c86e3470ca040b09881369e2af445e:


//! 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("../cli.zig");
const merge3 = @import("../merge3.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,
) cli.Exit {
    const 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),
    };
    if (parsed.help) {
        streams.out.writeAll(update_help) catch {};
        return .ok;
    }
    const requested = parsed.checkin orelse
        return cli.usageError(streams, "update", .no_checkin, "");
    // 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 target_id = switch (updateResolveTarget(gpa, io, arena, requested, streams)) {
        .ok => |h| h,
        .fail => |exit| return exit,
    };

    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);

    var file = root_dir.openFile(io, repo_filename, .{}) catch {
        streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    defer file.close(io);
    const log_st = file.stat(io) catch {
        streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, log_st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |err|
        return updateLogFail(streams, err);
    defer log.deinit(gpa);

    // 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));
    update.applyCheckin(gpa, io, root_dir, &log, target_id, .inherit) catch |err|
        return updateApplyFail(streams, target_id, err);
    return .ok;
}

/// 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(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    requested: []const u8,
    streams: cli.Streams,
) union(enum) { ok: []const u8, fail: cli.Exit } {
    if (artifact.validateName(requested)) return .{ .ok = requested };

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    // `.full` derive, not `.fast_read`: plain-name resolution reads the tag
    // tables, which the read-image does not materialize.
    const rc = openRepo(gpa, io, arena, streams, "update", null, false, false, &repo, &root_dir);
    if (rc != .ok) return .{ .fail = rc };
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    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, which dies with the `defer` above.
    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-bisect is corrupt; run 'zit bisect reset' to recover"),
    }
}

/// Map an update-engine move fault to its diagnostic. The path/baseline arms are
/// unreachable for a check-in this view already resolved, so they fold into the
/// generic corrupt-repository message.
fn bisectMoveFail(s: cli.Streams, err: update.ApplyError) cli.Exit {
    switch (err) {
        error.OutOfMemory => return bisectOom(s),
        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"),
        else => 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, false, &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) 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) 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) 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,
) 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),
        .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;
    }
    const name = parsed.checkin orelse return cli.usageError(streams, "merge", .no_name, "");

    // 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;
    const rc = openRepo(gpa, io, arena, streams, "merge", null, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer 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.
    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. Dirty-tree refusal (B-merge-14): the merge computes over the checkout's
    //    effective content (never a disk re-read), so a divergent working tree is
    //    rejected up front — the same `requireClean` gate `update` runs. Checked
    //    before the ancestry gates so a dirty tree reads as "not clean" rather
    //    than being masked by an up-to-date/unrelated verdict (plan §3 order).
    const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "merge", e);
    update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
        error.WorktreeNotClean => {
            streams.err.writeAll("zit merge: working tree is not clean\n") catch {};
            return .failure;
        },
        else => return mergeApplyFail(streams, e),
    };

    // 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 §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,
        }, 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 = (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 = "",
        .report_verb = "merged",
        .report_hash = repo.hashOf(other),
        .refuse_empty = false,
    }, streams);
}

/// 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,
};

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.
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,
    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;
    }
    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;
    var it = slots.iterator();
    while (it.next()) |kv| {
        const path = kv.key_ptr.*;
        const slot = kv.value_ptr.*;
        // 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;
        const cur_side: merge3.Side = 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) catch return mergeOom(streams);
        const perm = 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);
            },
        }
    }

    // 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).
    std.debug.assert((spec.merge_parent.len == 0) != (spec.special_op.len == 0));
    update.applyMerge(gpa, io, root_dir, entries.items, .{
        .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,
    }) catch |e| return mergeApplyFail(streams, e);

    // 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.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,
        error.Corrupt,
        error.InvalidCheckinId,
        error.CheckinNotFound,
        error.CheckinContentMissing,
        error.CheckinNameMismatch,
        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, .{}) 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 rid = repo.currentCheckoutRid() orelse return revertNoCheckout(streams);

    // 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) {
        update.journal.record(gpa, io, root_dir, targets.items) catch |e|
            return revertJournalFail(streams, e);
    }

    for (targets.items) |name| {
        if (revertOne(gpa, io, &repo, root_dir, rid, name, streams) == .failed) had_error = true;
    }
    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,
    name: []const u8,
    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 the current check-in; left unchanged\n", .{name}) catch {};
        return .skipped;
    };
    defer Repository.freeFileCard(gpa, card);
    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("reverted {s}\n", .{name}) catch {};
    return .reverted;
}

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.parseNoPositionalArgs(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);

    (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);

    // The single confirmation row goes to stdout (B-undo-20); diagnostics never
    // share the stream.
    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)
    oom,
};

/// 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,
) 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 .io_error;
    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);
    // §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;
    // 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,
    } };
}

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

/// `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 {
    // 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.
    const message = parsed.message orelse {
        streams.err.writeAll(
            "zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
        ) catch {};
        return .usage;
    };
    if (std.mem.trim(u8, message, " \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;
    };

    // 4. Discover the checkout root (B-commit-14) and load the working state.
    const root = (cli.discoverRoot(arena, io) catch
        return commitOom(streams)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer 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).
    var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch
        return commitLogFail(streams, error.Storage);
    defer 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);
    defer log.deinit(gpa);

    // 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 (parsed.branch) |bname| {
        var repo: Repository = undefined;
        repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "commit", e);
        defer repo.deinit(gpa);
        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): read + parse it for carry-forward, the
    //    no-op verdict (B-commit-9/11), AND the per-path delta bases. Loaded
    //    BEFORE capture — it has no dependency on capture — so each captured
    //    blob can be delta-encoded against the same path's parent blob (§7.2
    //    storage compaction). Slice-1 parents are always full manifests, so no
    //    delta resolution is reached here.
    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 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) {
        const pdigest = hexToDigest(ck.version) orelse return commitParentCorrupt(streams);
        const prid = log.lookup(&pdigest) orelse return commitParentCorrupt(streams);
        parent_manifest_rid = prid;
        parent_bytes = (log.content(gpa, prid) catch |e| return commitContentFail(streams, e)) orelse
            return commitParentCorrupt(streams);
        const parent_im = artifact.parseForImport(gpa, parent_bytes.?) catch return commitParentCorrupt(streams);
        pm = parent_im.manifest;
        pm_normalized = parent_im.normalized;
        parent_f = pm.?.f;
        // 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;
            const bd = hexToDigest(h) 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;
    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))) {
                .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),
                .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))) {
                .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),
                .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 = false;
    if (set.pendingRename()) |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 = true;
                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.
    const composed = cli.composeFCards(ma, .{
        .parent = parent_f,
        .captured = captured.items,
        .policy = policy,
    }) 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) {
        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;
        }
    }

    // 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: [19]u8 = undefined;
    const ts = cli.formatWriterTimestamp(amb.now_epoch, &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 = null,
        .c = .{ .comment = message },
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        .f = composed.f,
        .j = &.{},
        .m = &.{},
        .p = if (is_root) &.{} else &child_parents,
        .q = q_slice,
        .t = tags,
    };
    defer m.deinit(); // frees the empty arena_state; card storage is m_arena's
    const bytes = artifact.create(&m, gpa) catch return commitOom(streams);
    defer gpa.free(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);

    // 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) {
        set.clearRename();
        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).
    checkout.write(io, root_dir, .{ .version = manifest_name, .branch = target_branch, .merge_parent = "", .special_op = "", .manifest_flags = "" }) 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 {};
    }
    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;
}

/// 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.
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.
fn commitParentCorrupt(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit commit: the parent check-in is corrupt\n") 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 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() };
}

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-managed 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\n",
        commitMapCapture(commitParentCorrupt, .{}).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,
    );
}