Skip to content
zit Artifact [c0fd5628bc]
Login

Artifact [c0fd5628bc]

Artifact c0fd5628bc18db37c36bffa58ae28a604aa86184d29a16cb9941a59fabae9b09:


//! zit — command-line entry point.
//!
//! The heavy lifting lives in the `zit` library module (src/root.zig and the
//! per-RFC / storage modules it re-exports); this file is only the CLI surface.
//! `main` wires I/O and flushes; `run` is a pure-ish dispatcher over an
//! injected `std.Io` that returns an exit code, so every verb is exercisable
//! without a subprocess (design-seams rule). No `std.http` appears here.

const std = @import("std");
const Io = std.Io;
const File = Io.File;
const zit = @import("zit");
const cli = @import("cli.zig");

// Storage and update are private modules. Build wiring injects the same
// instances into every consumer so their values never cross module identities.
const logstore = @import("zit_logstore");
const storage = @import("zit_storage");
const update = @import("zit_update");
const checkout = @import("zit_checkout");
const managed = zit.managed;
// The artifact-format vocabulary `commit` assembles a check-in from: the
// `Manifest`/card structs it populates, `create` (serializes + computes the Z
// self-check), `parse` (reads the parent F-set), and `artifactName` (names each
// blob and the manifest). Reached through the same `zit` module cli.zig uses, so
// the `FCard` values flow between them without a type mismatch.
const artifact = zit.artifact;

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const arena = init.arena.allocator();
    const io = init.io;

    // `toSlice` yields sentinel-terminated argv; widen to plain `[]const u8`
    // slices so the verb runner never depends on NUL termination (a slice of
    // `[:0]const u8` does not coerce to a slice of `[]const u8`).
    const argv_z = try init.minimal.args.toSlice(arena);
    const args = try arena.alloc([]const u8, argv_z.len);
    for (argv_z, args) |src, *dst| dst.* = src;

    // Streaming (not positional) writers: `Io.File.Writer.init` defaults to
    // `.positional`, which writes via `pwrite` from an internal `pos` that
    // starts at 0 — ignoring the OS file offset zit inherited on stdout/stderr.
    // That silently clobbers `zit … >> logfile` (O_APPEND) and grouped/shared-fd
    // redirections (`{ echo a; zit …; } > log`), overwriting bytes another
    // writer already put there. Streaming mode uses plain `write`/`writev`,
    // honoring the inherited offset and O_APPEND — the pipe-safe, composable
    // behavior a process's standard streams must have (contracts/rm.md B-rm-14).
    var out_buf: [4096]u8 = undefined;
    var out_writer: Io.File.Writer = .initStreaming(.stdout(), io, &out_buf);
    var err_buf: [4096]u8 = undefined;
    var err_writer: Io.File.Writer = .initStreaming(.stderr(), io, &err_buf);
    const streams: cli.Streams = .{ .out = &out_writer.interface, .err = &err_writer.interface };

    // The two ambient effects `commit` needs, read here — the single place time
    // and environment enter the CLI (design-seams rule) — and threaded as plain
    // values so every verb runner stays deterministic under test.
    const amb: cli.Ambient = .{
        .now_epoch = commitEpoch(io, init.environ_map),
        .user_env = init.environ_map.get("USER") orelse init.environ_map.get("LOGNAME"),
    };

    const code = run(gpa, io, arena, args, streams, amb);

    streams.out.flush() catch {};
    streams.err.flush() catch {};
    if (code != .ok) std.process.exit(@intFromEnum(code));
}

/// Each verb's `--help` block, keyed by `cli.Verb` — the one place a verb is
/// paired with its help text. `zit help <verb>` and `zit <verb> --help` both
/// read this, so they teach identical text (DX D4). Verbs with no dedicated
/// help (`version`, `help`) and the `unknown` catch-all map to `null`; the
/// caller then falls back to `top_usage`. Keyed by the enum, so a new verb
/// forces a matching entry here at compile time — no help topic can silently
/// drop.
const verb_help = std.EnumArray(cli.Verb, ?[]const u8).init(.{
    .init = init_help,
    .add = add_help,
    .rm = rm_help,
    .status = status_help,
    .diff = diff_help,
    .commit = commit_help,
    .log = log_help,
    .update = update_help,
    .version = null,
    .help = null,
    .unknown = null,
});

/// The `--help` block for a verb named on the command line, or `null` when the
/// verb carries no dedicated help (or the name is unknown) — the caller then
/// falls back to `top_usage`. Resolves the name through `cli.verbOf` so help
/// topics track the dispatched verb set exactly.
fn helpFor(name: []const u8) ?[]const u8 {
    return verb_help.get(cli.verbOf(name));
}

/// Dispatches on the verb (a total switch) and returns the process exit code.
/// Never errors: every failure is rendered as an educating diagnostic by the
/// verb and folded into the returned `Exit`.
fn run(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const verb = if (args.len >= 2) args[1] else "help";
    switch (cli.verbOf(verb)) {
        .init => return runInit(gpa, io, arena, args[2..], streams),
        .add => return runAdd(gpa, io, arena, args[2..], streams),
        .rm => return runRm(gpa, io, arena, args[2..], streams),
        .status => return runStatus(gpa, io, arena, args[2..], streams),
        .diff => return runDiff(gpa, io, arena, args[2..], streams),
        .commit => return runCommit(gpa, io, arena, args[2..], streams, amb),
        .log => return runLog(gpa, io, arena, args[2..], streams),
        .update => return runUpdate(gpa, io, arena, args[2..], streams),
        .version => {
            streams.out.writeAll("zit 0.0.0 (targets Fossil RFC rev " ++ zit.rfc_revision ++ ")\n") catch {};
            return .ok;
        },
        .help => {
            // `zit help <verb>` teaches that verb's own help — the same text as
            // `zit <verb> --help` (DX D4). Bare `zit`, `zit help`, and
            // `zit --help` list the commands. An unknown or help-less topic
            // (e.g. `version`) falls back to the command list, which now names
            // every real verb including `status`, so nothing reads as absent.
            if (args.len >= 3) {
                if (helpFor(args[2])) |topic_help| {
                    streams.out.writeAll(topic_help) catch {};
                    return .ok;
                }
            }
            streams.out.writeAll(top_usage) catch {};
            return .ok;
        },
        .unknown => {
            // A git muscle-memory verb (rebase, checkout, push, …) gets a
            // lesson that corrects the zit model instead of a bare error, so
            // the diagnostic teaches (DX D2). Still a usage error: exit 2,
            // stderr only, nothing on stdout.
            if (cli.reflexLesson(verb)) |lesson| {
                streams.err.print("zit: '{s}' is not a zit command. {s}\n", .{ verb, lesson }) catch {};
                return .usage;
            }
            // A planned-but-unbuilt zit command (e.g. `commit`, which init's
            // help points the user at) resolves to a promise, not a dead-end:
            // every verb the shipped help names owns a handler or this stub.
            if (cli.isPlanned(verb)) {
                streams.err.print("zit: '{s}' is planned but not implemented yet.\n", .{verb}) catch {};
                return .usage;
            }
            // Otherwise the generic path: name the unknown command, offer a
            // near-miss suggestion when one is close (DX D4), then the command
            // list so discoverability survives a growing verb set.
            streams.err.print("zit: unknown command '{s}'\n", .{verb}) catch {};
            if (cli.didYouMean(verb)) |suggestion| {
                streams.err.print("did you mean '{s}'?\n", .{suggestion}) catch {};
            }
            streams.err.writeAll("\n") catch {};
            streams.err.writeAll(top_usage) catch {};
            return .usage;
        },
    }
}

/// `zit init [-q|--quiet] [--] [directory]` — the thin wiring: parse, resolve
/// an absolute target, then drive the existing library sequence (create `.zit`,
/// `LogStore.open`/`commit`/`deinit`, `checkout.write`). Filesystem access is
/// confined to this function. All-or-nothing: any failure after `.zit` is
/// created rolls it back so a failed init never poisons the already-exists
/// guard (B-init-9). (contracts/init.md B-init-1..12.)
fn runInit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-init-1). Usage errors → exit 2 (B-init-10).
    const parsed = switch (cli.parseInitArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "init", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "init", .extra_argument, tok),
    };
    // --help wins over every effect (B-init-11).
    if (parsed.help) {
        streams.out.writeAll(init_help) catch {};
        return .ok;
    }

    // 2. Resolve an absolute target directory and repo path (B-init-2/5/12).
    //    `resolve` normalizes `.`/`..` and, when `dir_arg` is already
    //    absolute, ignores the cwd — yielding a stable, parseable success token.
    const dir_arg = parsed.directory orelse ".";
    // Uses the grow-buffer helper (not std's `currentPathAlloc`, which aborts on
    // a cwd deeper than the platform PATH_MAX). A cwd we cannot name is reported
    // honestly, never a crash.
    const cwd = (cli.currentPathAlloc(arena, io) catch |e|
        return renderStorage(streams, dir_arg, reasonOf(e))) orelse
        return renderStorage(streams, dir_arg, "cannot determine the current directory");
    const abs_dir = std.fs.path.resolve(arena, &.{ cwd, dir_arg }) catch |e|
        return renderStorage(streams, dir_arg, reasonOf(e));
    const abs_zit = std.fs.path.join(arena, &.{ abs_dir, repo_filename }) catch |e|
        return renderStorage(streams, abs_dir, reasonOf(e));

    // 3. Ensure the target directory exists (B-init-5); a file in the way is
    //    "not a directory" (B-init-8).
    const cwd_dir = std.Io.Dir.cwd();
    cwd_dir.createDirPath(io, abs_dir) catch |e| switch (e) {
        error.NotDir => return renderNotDir(streams, abs_dir),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };

    // 4. Open a handle to the target dir; all subsequent file ops go through it
    //    so `.zit`/`.zit-checkout` land in the resolved directory.
    var target = cwd_dir.openDir(io, abs_dir, .{}) catch |e| switch (e) {
        error.NotDir => return renderNotDir(streams, abs_dir),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };
    defer target.close(io);

    // 5. Create `.zit` exclusively — this defines the "already exists" error
    //    out of existence (no separate stat probe, no TOCTOU). Detection is
    //    precisely the presence of `.zit`; an orphan `.zit-checkout` is
    //    irrelevant (B-init-7).
    var file = target.createFile(io, repo_filename, .{ .read = true, .exclusive = true }) catch |e| switch (e) {
        error.PathAlreadyExists => return renderAlreadyExists(streams, abs_zit),
        error.IsDir, error.NotDir => return renderNotDir(streams, abs_zit),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };

    // 6. Default hash policy (B-init-3): a fresh repo with no SHA-named
    //    artifacts is SHA3-256 (`.sha3` = 2). Threaded opaquely into the header.
    const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false));

    // 7. Log lifecycle (B-init-2): `open` on the empty file writes the 64-byte
    //    header (zero frames); `commit` fsyncs it (durability before success).
    //    On any failure, close and roll back `.zit` (B-init-9).
    var fs = storage.FileStorage.init(io, file, 0);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), hash_kind) catch |e| {
        file.close(io);
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };
    log.commit() catch |e| {
        log.deinit(gpa);
        file.close(io);
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };
    log.deinit(gpa);
    file.close(io);

    // 8. Checkout record (B-init-2/4): trunk unborn (`version == ""`).
    //    `checkout.write` is itself atomic, so a partial record is impossible;
    //    on failure roll back `.zit` (B-init-9).
    checkout.write(io, target, .{ .version = "", .branch = "trunk" }) catch |e| {
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };

    // 9. Success feedback (B-init-2/6/12): a single plain-ASCII line whose
    //    trailing whitespace-delimited token is the absolute `.zit` path;
    //    `-q`/`--quiet` writes nothing.
    if (!parsed.quiet) {
        streams.out.print("Initialized empty zit repository in {s}\n", .{abs_zit}) catch {};
    }
    return .ok;
}

/// The per-path outcome of classifying one `add` argument. A tagged union so
/// the runner renders each case to the correct stream and folds it into the
/// exit code, with the managed-set key carried on the two cases that print it.
const Outcome = union(enum) {
    /// Newly inserted; `added <key>` to stdout (`would add` under -n).
    added: []const u8,
    /// Already in the set; informational notice to stderr (B-add-3).
    already_tracked: []const u8,
    no_such_file, // B-add-4
    is_directory, // B-add-5
    not_regular, // B-add-5
    outside_root, // B-add-6
    control_file, // B-add-7
    /// A bound was exceeded on insert (TooLong/TooMany) or allocation failed;
    /// rendered by `addFail` with the offending arg (B-add-17).
    insert_error: managed.ManagedError,
};

/// `zit add [-n] [-q] [--] <path>...` — the thin wiring: parse, discover the
/// checkout root, load the managed set, classify/insert each named path in
/// argument order (do-as-much-as-possible), persist once atomically, and fold
/// the per-path outcomes into one exit code. Touches working-copy state only;
/// no log append, no network. (contracts/add.md B-add-1..18.)
fn runAdd(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-add-1). --help wins over everything, including a missing path
    //    (B-add-13); an empty path list is the B-add-9 usage error, detected
    //    before any repo discovery or I/O.
    const parsed = switch (cli.parseAddArgs(gpa, verb_args) catch
        return addFail(streams, "", error.OutOfMemory)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "add", .unknown_option, tok),
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(add_help) catch {};
        return .ok;
    }
    if (parsed.paths.len == 0) return cli.usageError(streams, "add", .no_paths, "");

    // 2. Discover the checkout root (B-add-8). Absent → not a zit repository.
    const root = (cli.discoverRoot(arena, io) catch
        return addFail(streams, "", error.OutOfMemory)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    // Uses the grow-buffer helper (not std's panicking `currentPathAlloc`); a
    // cwd we cannot name after discovery succeeded is a storage failure.
    const cwd_abs = (cli.currentPathAlloc(arena, io) catch
        return addFail(streams, "", error.OutOfMemory)) orelse
        return addFail(streams, "", error.Storage);

    // 3. Load the current set (absent file ⇒ empty; B-add-17 on a bad read).
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return addFail(streams, "", e);
    defer set.deinit();

    // 4. Process each path in argument order (B-add-2..7/-11/-12/-15).
    var failed = false;
    var changed = false;
    for (parsed.paths) |arg| {
        const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, arg) catch {
            failed = true;
            _ = addFail(streams, arg, error.OutOfMemory);
            continue;
        };
        switch (outcome) {
            .added => |key| {
                changed = true;
                if (!parsed.quiet) {
                    const verb_word: []const u8 = if (parsed.dry_run) "would add" else "added";
                    streams.out.print("{s} {s}\n", .{ verb_word, key }) catch {};
                }
            },
            .already_tracked => |key| {
                if (!parsed.quiet)
                    streams.err.print("zit: '{s}' is already tracked\n", .{key}) catch {};
            },
            .no_such_file => {
                failed = true;
                streams.err.print("zit add: '{s}': no such file\n", .{arg}) catch {};
            },
            .is_directory => {
                failed = true;
                streams.err.print(
                    "zit add: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n",
                    .{arg},
                ) catch {};
            },
            .not_regular => {
                failed = true;
                streams.err.print("zit add: '{s}' is not a regular file\n", .{arg}) catch {};
            },
            .outside_root => {
                failed = true;
                streams.err.print("zit add: '{s}' is outside the repository\n", .{arg}) catch {};
            },
            .control_file => {
                failed = true;
                streams.err.print(
                    "zit add: '{s}' is a zit control file and cannot be tracked\n",
                    .{arg},
                ) catch {};
            },
            .insert_error => |e| {
                failed = true;
                _ = addFail(streams, arg, e);
            },
        }
    }

    // 5. Persist once, atomically, only if the set actually changed and this is
    //    not a dry run (B-add-11/-16). A write failure leaves the prior file
    //    wholly intact (ManagedSet.write is write-temp → fsync → rename).
    if (changed and !parsed.dry_run) {
        set.write(io, root_dir) catch |e| return addFail(streams, "", e);
    }

    // 6. Fold outcomes into one code (B-add-18): valid additions persist even
    //    when a sibling path failed (partial success).
    return if (failed) .failure else .ok;
}

/// Classifies one raw `add` argument into an `Outcome` — all of add's
/// filesystem access lives here (design-seams rule). Order is chosen so the most
/// specific message wins: resolve to a repo-relative key (escape → outside),
/// reject control files, stat for existence/kind, then membership/insert. Only
/// allocation failure escapes as an error; every other condition is an
/// `Outcome`.
fn classifyPath(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_abs: []const u8,
    root_dir: std.Io.Dir,
    cwd_abs: []const u8,
    set: *managed.ManagedSet,
    arg: []const u8,
) error{OutOfMemory}!Outcome {
    // 1. Normalize to a repo-relative key. An escape is B-add-6; the root dir
    //    itself is B-add-5's directory case (e.g. `zit add .` at the root), not
    //    an escape.
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .outside_root,
        .root => return .is_directory,
        .key => |k| k,
    };
    // 2. A key beyond the managed-set bound cannot be tracked (B-add-17
    //    error.TooLong). Checked here, not via stat: a name-too-long key makes
    //    the OS stat fail with NameTooLong, which would otherwise mask the honest
    //    "path is too long" message as "no such file". Same bound
    //    `ManagedSet.insert` enforces (`managed.max_path_len`).
    if (key.len > managed.max_path_len) return .{ .insert_error = error.TooLong };
    // 3. The three control files are never version-controlled (B-add-7). Checked
    //    before stat because they exist as real files and would otherwise add.
    if (isControlFile(key)) return .control_file;
    // 4. Existence and file-kind (B-add-4/-5). Symlinks are not followed so a
    //    symlink is rejected as non-regular, not silently resolved to its target.
    const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch
        return .no_such_file;
    switch (st.kind) {
        .directory => return .is_directory,
        .file => {},
        else => return .not_regular,
    }
    // 5. Membership then insert (B-add-2/-3). `contains` first keeps a re-add
    //    error-free; `insert`'s false branch is race-defensive idempotency.
    if (set.contains(key)) return .{ .already_tracked = key };
    const inserted = set.insert(key) catch |e| return .{ .insert_error = e };
    return if (inserted) .{ .added = key } else .{ .already_tracked = key };
}

/// Whether `key` names one of the three repo-internal control files, which are
/// never tracked (B-add-7): `.zit`, `.zit-checkout`, `.zit-managed`.
fn isControlFile(key: []const u8) bool {
    return std.mem.eql(u8, key, repo_filename) or
        std.mem.eql(u8, key, checkout.filename) or
        std.mem.eql(u8, key, managed.filename);
}

/// Renders a B-add-17 managed-state failure for `arg` to stderr and returns
/// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new
/// variant fails to compile, catching drift. `arg` is used only by `TooLong`
/// (the per-path bound); the read/write-context errors carry no path.
fn addFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.writeAll(
            "zit add: cannot read tracked files: .zit-managed is corrupt\n",
        ) catch {},
        error.Storage => s.err.writeAll(
            "zit add: cannot update tracked files: input/output error\n",
        ) catch {},
        error.TooLong => s.err.print("zit add: '{s}': path is too long to track\n", .{arg}) catch {},
        error.TooMany => s.err.writeAll("zit add: too many tracked files\n") catch {},
        error.OutOfMemory => s.err.writeAll("zit add: out of memory\n") catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit rm` — add's mirror over the per-checkout managed-path set. Forgets named
// paths (default: disk untouched); `--hard` also deletes the working file after
// the set is durably written. Thin consumer: no logic added to any library
// module, no `std.http`. (contracts/rm.md B-rm-1..19.)
// ==========================================================================

/// The per-path outcome of classifying one `rm` argument. A tagged union so the
/// runner renders each case to the correct stream and folds it into the exit
/// code. `forgotten`/`not_tracked` carry the normalized managed-set key (the
/// identity `contains`/`remove` operate on and, per B-rm-15, that `rm` reports);
/// the three resolution-time rejects carry no payload and are rendered against
/// the raw arg (exactly as `add` renders its rejects).
const RmOutcome = union(enum) {
    /// Was in the set and is now removed; `removed <key>` (`would remove` -n).
    forgotten: []const u8,
    /// A resolved key not in the set (B-rm-4 error notice to stderr).
    not_tracked: []const u8,
    is_directory, // B-rm-5 (the checkout root itself)
    outside_root, // B-rm-6
    control_file, // B-rm-7
};

/// `zit rm [-n] [--hard] [-q] [--] <path>...` — the thin wiring: parse, discover
/// the checkout root, load the managed set, classify/forget each named path in
/// argument order (do-as-much-as-possible), persist once atomically, then —
/// strictly after a successful write — delete the `--hard` working files. Folds
/// the per-path outcomes into one exit code. Touches working-copy state only
/// (`.zit-managed`, and under `--hard` the named files); no log append, no
/// network. (contracts/rm.md B-rm-1..19.)
fn runRm(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-rm-1). --help wins over everything, including a missing path
    //    (B-rm-13); an empty path list is the B-rm-9 usage error, detected
    //    before any repo discovery or I/O (B-rm-9). Unknown option → B-rm-10.
    const parsed = switch (cli.parseRmArgs(gpa, verb_args) catch
        return rmFail(streams, "", error.OutOfMemory)) {
        .ok => |p| p,
        .unknown_option => |tok| {
            // A git user reaches for `--force`/`-f`, but `rm` is safe by default
            // (files are kept on disk) so forcing is meaningless. Teach the model
            // in one line instead of a bare "unknown option" (DX). Still a usage
            // error: exit 2, stderr only.
            if (std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f")) {
                streams.err.writeAll(
                    "zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n",
                ) catch {};
                return .usage;
            }
            return cli.usageError(streams, "rm", .unknown_option, tok);
        },
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(rm_help) catch {};
        return .ok;
    }
    if (parsed.paths.len == 0) return cli.usageError(streams, "rm", .no_paths, "");

    // 2. Discover the checkout root (B-rm-8). Absent → not a zit repository.
    const root = (cli.discoverRoot(arena, io) catch
        return rmFail(streams, "", error.OutOfMemory)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io); // kept open for the --hard unlink pass
    const cwd_abs = (cli.currentPathAlloc(arena, io) catch
        return rmFail(streams, "", error.OutOfMemory)) orelse
        return rmFail(streams, "", error.Storage);

    // 3. Load the current set (absent file ⇒ empty; B-rm-17 on a bad read).
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return rmFail(streams, "", e);
    defer set.deinit();

    // 4. Process each path in argument order (B-rm-2/-4..7/-11/-15). Collect the
    //    forgotten keys (in argument order) rather than reporting them inline:
    //    the `removed`/`would remove` stdout lines are WITHHELD until the atomic
    //    write has succeeded (step 5b), so a persistence failure (B-rm-17) never
    //    claims a removal that did not happen — `zit rm <files> | wc -l` counts
    //    exactly the forgets that were durably written (B-rm-14). The same list
    //    drives the `--hard` unlink pass (B-rm-16: persist before destroy).
    //    Per-path error/notice diagnostics still print inline: they are
    //    independent of the write and belong on stderr regardless of its fate.
    var failed = false;
    var changed = false;
    var forgotten: std.ArrayList([]const u8) = .empty;
    for (parsed.paths) |arg| {
        const outcome = classifyRmPath(arena, root.abs, cwd_abs, &set, arg) catch {
            failed = true;
            _ = rmFail(streams, arg, error.OutOfMemory);
            continue;
        };
        switch (outcome) {
            .forgotten => |key| {
                changed = true;
                forgotten.append(arena, key) catch return rmFail(streams, arg, error.OutOfMemory);
            },
            .not_tracked => |key| {
                failed = true;
                streams.err.print("zit rm: '{s}' is not tracked\n", .{key}) catch {};
            },
            .is_directory => {
                failed = true;
                streams.err.print(
                    "zit rm: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n",
                    .{arg},
                ) catch {};
            },
            .outside_root => {
                failed = true;
                streams.err.print("zit rm: '{s}' is outside the repository\n", .{arg}) catch {};
            },
            .control_file => {
                failed = true;
                streams.err.print(
                    "zit rm: '{s}' is a zit control file and cannot be tracked\n",
                    .{arg},
                ) catch {};
            },
        }
    }

    // 5. Persist once, atomically, only if the set changed and this is not a dry
    //    run (B-rm-16). Returning on a write failure guarantees NOTHING below
    //    runs — neither the stdout report (5b) nor the --hard unlink (6): a write
    //    failure claims no removal on stdout and unlinks nothing, leaving every
    //    file both on disk and still tracked (B-rm-16/-17/-14).
    if (changed and !parsed.dry_run) {
        set.write(io, root_dir) catch |e| return rmFail(streams, "", e);
    }

    // 5b. Report the forgets on stdout — only now that they are durable (or, in a
    //     dry run, never persisted but truthfully previewed). Argument order is
    //     preserved (the collection order). Held until here so a failed write
    //     never prints a `removed` line for a forget that did not survive
    //     (B-rm-2/-11/-14). Under a real `--hard` run the disposition is NOT yet
    //     known — it depends on the per-file unlink outcome — so those lines are
    //     withheld and emitted in step 6, keyed on the actual deletion, so the
    //     `(deleted from disk)` suffix can never claim a deletion that failed
    //     (B-rm-3/-4/-14/-18). A dry run never unlinks, so `-n --hard` previews
    //     `(deleted from disk)` here truthfully (subjunctive; B-rm-11).
    const defer_report = parsed.hard and !parsed.dry_run;
    if (!parsed.quiet and !defer_report) {
        // D1/D6: state the resulting file disposition in the line so the user is
        // never left guessing whether their working copy survived, while keeping
        // the leading `removed <key>` / `would remove <key>` tokens stable so the
        // output stays pipe-parseable and `zit rm <files> | wc -l` still counts
        // one line per removal (B-rm-2/-11/-14). The verb word tracks `-n`; the
        // suffix tracks `--hard` (only reachable here as a dry-run preview).
        const verb_word: []const u8 = if (parsed.dry_run) "would remove" else "removed";
        const disposition: []const u8 =
            if (parsed.hard) "(deleted from disk)" else "(file kept on disk)";
        for (forgotten.items) |key|
            streams.out.print("{s} {s} {s}\n", .{ verb_word, key, disposition }) catch {};
    }

    // 6. --hard: delete each forgotten working file, strictly after the write
    //    succeeded (B-rm-3/-16/-18). An already-absent file is success (the "no
    //    working file" postcondition holds). The stdout `removed <key> (deleted
    //    from disk)` line is emitted HERE, keyed on the REAL outcome: only a
    //    deleted (or already-gone) file prints it (B-rm-3); a file that could not
    //    be unlinked is a failed path, so its diagnostic goes to stderr and
    //    NOTHING about it reaches stdout (B-rm-4/-14/-18) — the disposition suffix
    //    can never lie. The forget itself already stands and is persisted (honest
    //    partial). `-q` suppresses the stdout line but never the error.
    if (defer_report) {
        for (forgotten.items) |key| {
            const deleted = if (root_dir.deleteFile(io, key)) |_|
                true
            else |e| switch (e) {
                error.FileNotFound => true, // already gone ⇒ postcondition holds (B-rm-3)
                else => blk: {
                    failed = true;
                    streams.err.print(
                        "zit rm: '{s}': removed from tracking but could not delete from disk: {s}\n",
                        .{ key, reasonOf(e) },
                    ) catch {};
                    break :blk false;
                },
            };
            if (deleted and !parsed.quiet)
                streams.out.print("removed {s} (deleted from disk)\n", .{key}) catch {};
        }
    }

    // 7. Fold outcomes into one code (B-rm-19): successful forgets persist even
    //    when a sibling path failed (partial success).
    return if (failed) .failure else .ok;
}

/// Classifies one raw `rm` argument into an `RmOutcome`. Filesystem-free (unlike
/// `add`'s `classifyPath`): membership in the set decides, not the disk, so a
/// managed path whose working file the user already deleted is still forgettable
/// (contracts/rm.md Model). Order is chosen so the most specific message wins:
/// resolve to a repo-relative key (escape → outside, root → directory), reject
/// control files, then membership. Only allocation failure escapes as an error.
fn classifyRmPath(
    gpa: std.mem.Allocator,
    root_abs: []const u8,
    cwd_abs: []const u8,
    set: *managed.ManagedSet,
    arg: []const u8,
) error{OutOfMemory}!RmOutcome {
    // 1. Normalize to a repo-relative key. An escape is B-rm-6; the root dir
    //    itself is B-rm-5's directory case (e.g. `zit rm .` at the root).
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .outside_root,
        .root => return .is_directory,
        .key => |k| k,
    };
    // 2. The three control files are never version-controlled and must never be
    //    `--hard`-unlinked (B-rm-7). Checked BEFORE membership: a belt-and-
    //    suspenders guard so even a corrupt set holding a control-file key could
    //    not get it deleted. No max_path_len guard is needed (unlike add): an
    //    over-length key is simply absent from the set → falls to not_tracked.
    if (isControlFile(key)) return .control_file;
    // 3. Membership then forget (B-rm-2/-4). `remove` returns whether it was
    //    present (managed.zig:114): true ⇒ forgotten, false ⇒ never tracked. It
    //    is a no-op on an absent key, so the membership verdict and the forget
    //    fold into one call — no separate `contains` needed.
    if (set.remove(key)) return .{ .forgotten = key };
    return .{ .not_tracked = key };
}

/// Renders a B-rm-17 managed-state failure for `arg` to stderr and returns
/// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new
/// variant fails to compile, catching drift. Only `Corrupt`/`Storage`/
/// `OutOfMemory` are reachable from `rm`'s flow (`remove` never errors; `insert`
/// is never called), so `TooLong`/`TooMany` exist only for exhaustiveness.
fn rmFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.writeAll(
            "zit rm: cannot read tracked files: .zit-managed is corrupt\n",
        ) catch {},
        error.Storage => s.err.writeAll(
            "zit rm: cannot update tracked files: input/output error\n",
        ) catch {},
        error.TooLong => s.err.print("zit rm: '{s}': path is too long to track\n", .{arg}) catch {},
        error.TooMany => s.err.writeAll("zit rm: too many tracked files\n") catch {},
        error.OutOfMemory => s.err.writeAll("zit rm: out of memory\n") catch {},
    }
    return .failure;
}

/// The repository file's fixed name (logstore §E: a copied `.zit` is a
/// complete repository).
const repo_filename: []const u8 = ".zit";

// --- educating diagnostics (all to err, all exit 1) -----------------------

fn renderAlreadyExists(s: cli.Streams, abs_zit: []const u8) cli.Exit {
    s.err.print("zit: a zit repository already exists at {s}\n", .{abs_zit}) catch {};
    return .failure;
}

fn renderNotDir(s: cli.Streams, path: []const u8) cli.Exit {
    s.err.print("zit: cannot initialize: {s} is not a directory\n", .{path}) catch {};
    return .failure;
}

fn renderStorage(s: cli.Streams, abs_dir: []const u8, reason: []const u8) cli.Exit {
    s.err.print("zit: cannot initialize repository at {s}: {s}\n", .{ abs_dir, reason }) catch {};
    return .failure;
}

/// Maps a raw error to a short, honest, human phrase for the B-init-9 line.
/// Pure rendering, not logic — the switch is over the union of every error
/// the init path can surface (filesystem, `LogStore`, `checkout`).
fn reasonOf(err: anyerror) []const u8 {
    return switch (err) {
        error.OutOfMemory => "out of memory",
        error.AccessDenied, error.PermissionDenied => "permission denied",
        error.ReadOnlyFileSystem => "read-only file system",
        error.NoSpaceLeft, error.DiskQuota => "no space left on device",
        error.FileNotFound => "no such file or directory",
        error.Corrupt => "repository is corrupt",
        // `error.Storage` (LogStore/checkout device failure) and every other
        // device/OS error collapse to one honest phrase.
        else => "input/output error",
    };
}

// --- help / usage text ----------------------------------------------------

// ==========================================================================
// `zit status` — read-only report of the working copy (contracts/status.md
// B-status-1..17). A thin consumer: it parses, discovers the checkout root,
// reads `.zit-checkout` and `.zit-managed` through the library, stats each
// tracked path, and renders. No logic enters any library module; no log frame
// is opened; no `logstore`/`repostate`/`artifact`/`delta`/`std.http`. The
// committed-tree diff (edited/clean/deleted-from-check-in, untracked files) is
// honestly deferred to `commit`/`diff` (B-status-16), stated in `--help`.
// ==========================================================================

/// `zit status [-h|--help]` — the thin wiring: parse, discover the checkout
/// root, read the checkout record and the managed set, then one pass over the
/// sorted set stat'ing each path and rendering. Read-only: a dirty working copy
/// is never a failure (B-status-15). Mirrors `runAdd`'s order (parse → discover
/// → load → render).
fn runStatus(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-status-1). --help wins over everything (B-status-14).
    const help = switch (cli.parseNoPositionalArgs(verb_args)) {
        .ok => |a| a.help,
        .unknown_option => |tok| return cli.usageError(streams, "status", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "status", .extra_argument, tok),
    };
    if (help) {
        streams.out.writeAll(status_help) catch {};
        return .ok;
    }

    // 2. Discover the checkout root (B-status-2/-9). OOM → exit 1.
    const root = (cli.discoverRoot(arena, io) catch
        return stateOom(streams, "status")) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // 3. Read the checkout record (B-status-10). The buffer holds one byte past
    //    the record bound so `checkout.read`'s `out_buf.len > max_ck_payload`
    //    assertion holds and an oversized file is detected, not truncated.
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch |e|
        return checkoutFail(streams, "status", e)) orelse
        return checkoutMissing(streams, "status"); // null after a successful discovery

    // 4. Load the managed set (B-status-11); an absent file reads as the empty
    //    set (`ManagedSet.read`), so a repo with no `zit add` is not an error.
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "status", e);
    defer set.deinit();

    // 5. Header (B-status-3/-4/-17). An empty branch field defaults to trunk;
    //    an empty version is the unborn state (no check-ins yet).
    const branch = if (ck.branch.len == 0) "trunk" else ck.branch;
    const unborn = ck.version.len == 0;
    if (unborn)
        streams.out.print("On branch {s} (no check-ins yet)\n", .{branch}) catch {}
    else
        streams.out.print("On branch {s} (check-in {s})\n", .{ branch, ck.version }) catch {};

    // 6. One pass over the sorted set (B-status-5/-6/-8). A path is present iff a
    //    no-follow stat succeeds AND its kind is a regular file — a symlink, a
    //    directory, or any non-regular entry folds to `missing` (B-status-5).
    //    `added`/`missing` interleave in path order (not grouped by kind).
    var changes: usize = 0;
    for (set.iterate()) |path| {
        const present = blk: {
            const st = root_dir.statFile(io, path, .{ .follow_symlinks = false }) catch
                break :blk false;
            break :blk st.kind == .file;
        };
        if (!present) {
            streams.out.print("missing {s}\n", .{path}) catch {};
            changes += 1;
        } else if (unborn) {
            streams.out.print("added {s}\n", .{path}) catch {};
            changes += 1;
        }
        // born + present → not classified: distinguishing edited from clean
        // needs the committed-tree diff deferred to commit/diff (B-status-4/-16).
    }

    // 7. Clean-state line when no change line was emitted (B-status-7).
    if (changes == 0) {
        if (unborn and set.iterate().len == 0)
            streams.out.writeAll("nothing tracked yet\n") catch {}
        else
            streams.out.writeAll("nothing to report\n") catch {};
    }
    return .ok; // read-only; a dirty working copy is never a failure (B-status-15)
}

/// Renders a checkout-state read failure for any verb; the verb prefixes the
/// message so one mapper serves both the read-only working-tree verbs and commit.
fn checkoutFail(s: cli.Streams, verb: []const u8, err: checkout.CheckoutError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.print(
            "zit {s}: cannot read checkout state: .zit-checkout is corrupt\n",
            .{verb},
        ) catch {},
        error.Storage => s.err.print(
            "zit {s}: cannot read checkout state: input/output error\n",
            .{verb},
        ) catch {},
    }
    return .failure;
}

/// The checkout record vanished between discovery and read.
fn checkoutMissing(s: cli.Streams, verb: []const u8) cli.Exit {
    s.err.print("zit {s}: cannot read checkout state: .zit-checkout is missing\n", .{verb}) catch {};
    return .failure;
}

/// Renders a managed-set read failure for any verb; the verb prefixes the
/// message so one mapper serves both the read-only working-tree verbs and commit.
fn managedFail(s: cli.Streams, verb: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.print(
            "zit {s}: cannot read tracked files: .zit-managed is corrupt\n",
            .{verb},
        ) catch {},
        error.Storage => s.err.print(
            "zit {s}: cannot read tracked files: input/output error\n",
            .{verb},
        ) catch {},
        error.TooLong => s.err.print(
            "zit {s}: cannot read tracked files: a tracked path is too long\n",
            .{verb},
        ) catch {},
        error.TooMany => s.err.print("zit {s}: cannot read tracked files: too many tracked files\n", .{verb}) catch {},
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
    }
    return .failure;
}

fn stateOom(s: cli.Streams, verb: []const u8) cli.Exit {
    s.err.print("zit {s}: out of memory\n", .{verb}) catch {};
    return .failure;
}

// ==========================================================================
// `zit diff` — read-only metadata comparison of managed paths against the
// checkout's fully resolved F-set. It buffers every record before writing so
// an I/O/corruption/OOM failure never leaves a partial report on stdout.
// ==========================================================================

/// Upper bound on one managed working-tree file read by `commit` or `diff`.
/// The boundary is inclusive, so a file exactly this size is valid; larger
/// files are refused before allocation.
const max_tracked_file_bytes: usize = 1 << 30;

fn trackedFileSizeAllowed(size: u64) bool {
    return size <= @as(u64, max_tracked_file_bytes);
}

const DiffInspection = enum {
    none,
    added,
    same,
    modified,
    deleted,
    io_error,
    oom,
    corrupt,
};

const DiffBaselineResolver = struct {
    const Failure = enum { unresolved, storage, corrupt, oom };

    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    bytes: ?[]u8 = null,
    manifest: ?artifact.Manifest = null,
    failure: Failure = .unresolved,

    fn deinit(self: *DiffBaselineResolver) void {
        if (self.manifest) |*m| m.deinit();
        if (self.bytes) |bytes| self.gpa.free(bytes);
        self.* = undefined;
    }

    fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
        const self: *DiffBaselineResolver = @ptrCast(@alignCast(ctx));
        var digest_buf: [logstore.max_hash_len]u8 = undefined;
        const digest = decodeDiffId(hash, &digest_buf) orelse return null;
        const rid = self.log.lookup(digest) orelse return null;
        const bytes = self.log.content(self.gpa, rid) catch |err| {
            self.failure = switch (err) {
                error.OutOfMemory => .oom,
                error.Corrupt => .corrupt,
                error.Storage => .storage,
            };
            return null;
        } orelse return null;
        self.bytes = bytes;
        if (!artifact.verify(hash, bytes)) {
            self.failure = .corrupt;
            return null;
        }

        const manifest = artifact.parse(self.gpa, bytes) catch |err| {
            self.failure = if (err == error.OutOfMemory) .oom else .corrupt;
            return null;
        };
        self.manifest = manifest;
        if (self.manifest.?.kind != .manifest) return null;
        return .{
            .baseline_hash = if (self.manifest.?.b) |b| b.hash else null,
            .files = self.manifest.?.f,
        };
    }
};

/// Decodes a canonical 40- or 64-hex artifact name into the raw log key.
fn decodeDiffId(name: []const u8, out: *[logstore.max_hash_len]u8) ?[]const u8 {
    if (!artifact.validateName(name)) return null;
    const algo = artifact.nameAlgo(name.len) orelse return null;
    const len: usize = switch (algo) {
        .sha1 => 20,
        .sha3_256 => 32,
    };
    _ = std.fmt.hexToBytes(out[0..len], name) catch return null;
    return out[0..len];
}

fn executable(permissions: File.Permissions) bool {
    if (comptime File.Permissions.has_executable_bit) {
        return permissions.toMode() & 0o111 != 0;
    }
    return false;
}

fn absentDiffOutcome(baseline: ?artifact.FCard) DiffInspection {
    return if (baseline == null) .none else .deleted;
}

/// Inspects one managed pathname without following it. The path is no-follow
/// stated before opening; its opened handle is then stated and read, so neither
/// a symlink nor a replacement after the initial stat can be followed.
fn inspectDiffFile(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    path: []const u8,
    baseline: ?artifact.FCard,
) DiffInspection {
    const path_stat = root_dir.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) {
        error.FileNotFound => return absentDiffOutcome(baseline),
        else => return .io_error,
    };
    if (path_stat.kind != .file) return absentDiffOutcome(baseline);

    var file = root_dir.openFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) {
        error.FileNotFound, error.SymLinkLoop => return absentDiffOutcome(baseline),
        else => return .io_error,
    };
    defer file.close(io);

    const st = file.stat(io) catch return .io_error;
    if (st.kind != .file) return absentDiffOutcome(baseline);
    const f = baseline orelse return .added;
    const working_mode: artifact.FileMode = if (executable(st.permissions)) .executable else .regular;
    if (working_mode != f.mode) return .modified;

    if (!trackedFileSizeAllowed(st.size)) return .io_error;
    const content = gpa.alloc(u8, @intCast(st.size)) catch return .oom;
    defer gpa.free(content);
    const n = file.readPositionalAll(io, content, 0) catch return .io_error;
    if (n != content.len) return .io_error;

    const algo = artifact.nameAlgo(f.hash.?.len) orelse return .corrupt;
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(algo, content, &name_buf);
    return if (std.mem.eql(u8, name, f.hash.?)) .same else .modified;
}

fn appendDiffRecord(
    gpa: std.mem.Allocator,
    report: *std.ArrayList(u8),
    kind: u8,
    path: []const u8,
) error{OutOfMemory}!void {
    if (path.len > std.math.maxInt(usize) / 2) return error.OutOfMemory;
    try report.append(gpa, kind);
    try report.append(gpa, ' ');
    const encoded = try report.addManyAt(gpa, report.items.len, 2 * path.len);
    const escaped = artifact.encodeEscapes(path, encoded);
    report.items.len -= encoded.len - escaped.len;
    try report.append(gpa, '\n');
}

fn appendDiffInspection(
    gpa: std.mem.Allocator,
    report: *std.ArrayList(u8),
    path: []const u8,
    inspection: DiffInspection,
) error{ OutOfMemory, Storage, Corrupt }!void {
    switch (inspection) {
        .none, .same => {},
        .added => try appendDiffRecord(gpa, report, 'A', path),
        .modified => try appendDiffRecord(gpa, report, 'M', path),
        .deleted => try appendDiffRecord(gpa, report, 'D', path),
        .io_error => return error.Storage,
        .oom => return error.OutOfMemory,
        .corrupt => return error.Corrupt,
    }
}

fn nextEffectiveFile(walk: *artifact.MergeWalk) ?artifact.FCard {
    while (walk.next()) |f| {
        if (f.hash != null) return f;
    }
    return null;
}

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

fn diffLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => return diffOom(s),
        error.Corrupt => s.err.writeAll("zit diff: cannot read repository: repository is corrupt\n") catch {},
        error.Storage => s.err.writeAll("zit diff: cannot read repository: input/output error\n") catch {},
    }
    return .failure;
}

fn diffCurrentCorrupt(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit diff: current check-in is not a valid manifest\n") catch {};
    return .failure;
}

fn diffBaselineFail(s: cli.Streams, failure: DiffBaselineResolver.Failure) cli.Exit {
    switch (failure) {
        .oom => return diffOom(s),
        .storage => return diffLogFail(s, error.Storage),
        .corrupt => return diffLogFail(s, error.Corrupt),
        .unresolved => s.err.writeAll("zit diff: cannot resolve baseline\n") catch {},
    }
    return .failure;
}

fn diffComparisonFail(s: cli.Streams, err: error{ OutOfMemory, Storage, Corrupt }) cli.Exit {
    switch (err) {
        error.OutOfMemory => return diffOom(s),
        error.Corrupt => s.err.writeAll("zit diff: cannot compare: repository is corrupt\n") catch {},
        error.Storage => s.err.writeAll("zit diff: cannot compare working tree: input/output error\n") catch {},
    }
    return .failure;
}

fn writeDiffOutput(s: cli.Streams, bytes: []const u8) cli.Exit {
    s.out.writeAll(bytes) catch {
        s.err.writeAll("zit diff: cannot write output\n") catch {};
        return .failure;
    };
    return .ok;
}

fn runDiff(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const help = switch (cli.parseNoPositionalArgs(verb_args)) {
        .ok => |a| a.help,
        .unknown_option => |tok| return cli.usageError(streams, "diff", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "diff", .extra_argument, tok),
    };
    if (help) {
        return writeDiffOutput(streams, diff_help);
    }

    const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "diff")) 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 |err|
        return checkoutFail(streams, "diff", err)) orelse
        return checkoutMissing(streams, "diff");
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |err|
        return managedFail(streams, "diff", err);
    defer set.deinit();

    var report: std.ArrayList(u8) = .empty;
    defer report.deinit(gpa);
    const paths = set.iterate();

    if (ck.version.len == 0) {
        for (paths) |path| {
            appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err|
                return diffComparisonFail(streams, err);
        }
        return writeDiffOutput(streams, report.items);
    }

    var repo_file = root_dir.openFile(io, repo_filename, .{}) catch
        return diffLogFail(streams, error.Storage);
    defer repo_file.close(io);
    const repo_stat = repo_file.stat(io) catch return diffLogFail(streams, error.Storage);
    var fs = storage.FileStorage.init(io, repo_file, repo_stat.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |err|
        return diffLogFail(streams, err);
    defer log.deinit(gpa);

    var digest_buf: [logstore.max_hash_len]u8 = undefined;
    const digest = decodeDiffId(ck.version, &digest_buf) orelse return diffCurrentCorrupt(streams);
    const rid = log.lookup(digest) orelse return diffCurrentCorrupt(streams);
    const current_bytes = (log.content(gpa, rid) catch |err| return diffLogFail(streams, err)) orelse
        return diffCurrentCorrupt(streams);
    defer gpa.free(current_bytes);
    if (!artifact.verify(ck.version, current_bytes)) return diffCurrentCorrupt(streams);
    var current = artifact.parse(gpa, current_bytes) catch |err| switch (err) {
        error.OutOfMemory => return diffOom(streams),
        else => return diffCurrentCorrupt(streams),
    };
    defer current.deinit();
    if (current.kind != .manifest) return diffCurrentCorrupt(streams);

    var resolver_ctx: DiffBaselineResolver = .{ .gpa = gpa, .log = &log };
    defer resolver_ctx.deinit();
    var walk = artifact.enumerate(.{
        .baseline_hash = if (current.b) |b| b.hash else null,
        .files = current.f,
    }, .{
        .ctx = &resolver_ctx,
        .resolveFn = DiffBaselineResolver.resolve,
    }) catch |err| switch (err) {
        error.BaselineUnresolvable => return diffBaselineFail(streams, resolver_ctx.failure),
        error.BaselineIsDelta => return diffBaselineFail(streams, .unresolved),
    };

    var path_index: usize = 0;
    var baseline = nextEffectiveFile(&walk);
    while (path_index < paths.len or baseline != null) {
        if (baseline) |f| {
            if (path_index == paths.len) {
                appendDiffRecord(gpa, &report, 'D', f.filename) catch return diffOom(streams);
                baseline = nextEffectiveFile(&walk);
                continue;
            }
            const path = paths[path_index];
            switch (std.mem.order(u8, path, f.filename)) {
                .lt => {
                    appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err|
                        return diffComparisonFail(streams, err);
                    path_index += 1;
                },
                .eq => {
                    appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, f)) catch |err|
                        return diffComparisonFail(streams, err);
                    path_index += 1;
                    baseline = nextEffectiveFile(&walk);
                },
                .gt => {
                    appendDiffRecord(gpa, &report, 'D', f.filename) catch return diffOom(streams);
                    baseline = nextEffectiveFile(&walk);
                },
            }
        } else {
            const path = paths[path_index];
            appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err|
                return diffComparisonFail(streams, err);
            path_index += 1;
        }
    }

    return writeDiffOutput(streams, report.items);
}

/// The diagnostic every working-tree verb (add/rm/status) prints when discovery
/// finds no repository. Shared so all three fail identically (B-add-8 / B-rm-8 /
/// B-status-9 "identical shape") — a single, byte-identical line, exactly as each
/// contract quotes it.
const not_a_repo_msg =
    "zit: not a zit repository (no .zit found in this directory or any parent)\n";

// ==========================================================================
// `zit log CHECKIN` — one explicit check-in and its reachable parent graph.
// ==========================================================================

const LogWalkError = error{
    OutOfMemory,
    Storage,
    Corrupt,
    MissingRoot,
    InvalidRoot,
    InvalidTarget,
};

fn decodeFullId(id: []const u8, out: *[digest_len]u8) error{InvalidId}![]const u8 {
    if (!artifact.validateName(id)) return error.InvalidId;
    const decoded = out[0 .. id.len / 2];
    _ = std.fmt.hexToBytes(decoded, id) catch return error.InvalidId;
    return decoded;
}

fn walkLog(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    root_id: []const u8,
    out: *Io.Writer,
) LogWalkError!void {
    var pending: std.ArrayList([]u8) = .empty;
    defer {
        for (pending.items) |id| gpa.free(id);
        pending.deinit(gpa);
    }
    var visited: std.StringHashMapUnmanaged(void) = .empty;
    defer visited.deinit(gpa);
    var visited_ids: std.ArrayList([]u8) = .empty;
    defer {
        for (visited_ids.items) |id| gpa.free(id);
        visited_ids.deinit(gpa);
    }

    const root_copy = try gpa.dupe(u8, root_id);
    pending.append(gpa, root_copy) catch {
        gpa.free(root_copy);
        return error.OutOfMemory;
    };
    while (pending.pop()) |id| {
        if (visited.contains(id)) {
            gpa.free(id);
            continue;
        }
        visited_ids.append(gpa, id) catch {
            gpa.free(id);
            return error.OutOfMemory;
        };
        visited.put(gpa, id, {}) catch {
            _ = visited_ids.pop();
            gpa.free(id);
            return error.OutOfMemory;
        };
        const is_root = visited_ids.items.len == 1;

        var hash_buf: [digest_len]u8 = undefined;
        const decoded = decodeFullId(id, &hash_buf) catch
            return if (is_root) error.InvalidRoot else error.InvalidTarget;
        const rid = log.lookup(decoded) orelse {
            if (is_root) return error.MissingRoot;
            out.print("phantom {s}\n", .{id}) catch {};
            continue;
        };
        const bytes = (try log.content(gpa, rid)) orelse {
            if (is_root) return error.MissingRoot;
            out.print("phantom {s}\n", .{id}) catch {};
            continue;
        };
        defer gpa.free(bytes);
        if (!artifact.verify(id, bytes)) return error.Corrupt;

        var manifest = artifact.parse(gpa, bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return if (is_root) error.InvalidRoot else error.InvalidTarget,
        };
        defer manifest.deinit();
        if (manifest.kind != .manifest)
            return if (is_root) error.InvalidRoot else error.InvalidTarget;

        out.print("checkin {s}\n", .{id}) catch {};

        // Push in reverse wire order so the LIFO stack visits the first P hash
        // first. Reversing only this node's appended segment preserves DFS when
        // older sibling work is already below it.
        const parent_start = pending.items.len;
        for (manifest.p) |pc| {
            var hashes = pc.hashes();
            while (hashes.next()) |parent| {
                const owned = try gpa.dupe(u8, parent);
                pending.append(gpa, owned) catch {
                    gpa.free(owned);
                    return error.OutOfMemory;
                };
            }
        }
        std.mem.reverse([]u8, pending.items[parent_start..]);
    }
}

fn runLog(
    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.parseLogArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "log", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "log", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(log_help) catch {};
        return .ok;
    }
    const root_id = parsed.checkin orelse
        return cli.usageError(streams, "log", .no_checkin, "");
    if (!artifact.validateName(root_id)) {
        streams.err.print(
            "zit log: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
            .{root_id},
        ) catch {};
        return .failure;
    }

    const root = (cli.discoverRoot(arena, io) catch {
        streams.err.writeAll("zit log: 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 log: 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 log: 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 |e| {
        switch (e) {
            error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
            else => streams.err.print("zit log: cannot read repository: {s}\n", .{reasonOf(e)}) catch {},
        }
        return .failure;
    };
    defer log.deinit(gpa);

    walkLog(gpa, &log, root_id, streams.out) catch |e| {
        switch (e) {
            error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {},
            error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {},
            error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {},
            error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {},
            error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {},
            error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {},
        }
        return .failure;
    };
    return .ok;
}

/// `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.
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.parseLogArgs(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 target_id = parsed.checkin orelse
        return cli.usageError(streams, "update", .no_checkin, "");
    if (!artifact.validateName(target_id)) {
        streams.err.print(
            "zit update: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
            .{target_id},
        ) catch {};
        return .failure;
    }

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

    update.applyCheckin(gpa, io, root_dir, &log, target_id) catch |err|
        return updateApplyFail(streams, target_id, err);
    return .ok;
}

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

fn updateApplyFail(s: cli.Streams, target_id: []const u8, err: update.ApplyError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {},
        error.InvalidCheckinId => s.err.print(
            "zit update: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
            .{target_id},
        ) catch {},
        error.CheckinNotFound => s.err.print("zit update: check-in '{s}' not found\n", .{target_id}) catch {},
        error.CheckinContentMissing => s.err.print(
            "zit update: cannot read repository: check-in '{s}' content is missing\n",
            .{target_id},
        ) catch {},
        error.CheckinNameMismatch => s.err.print(
            "zit update: cannot read repository: check-in '{s}' content does not match its name\n",
            .{target_id},
        ) catch {},
        error.TargetNotManifest => s.err.print("zit update: check-in '{s}' is not a valid manifest\n", .{target_id}) catch {},
        error.BaselineNotFound => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline is not found\n",
            .{target_id},
        ) catch {},
        error.BaselineContentMissing => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline content is missing\n",
            .{target_id},
        ) catch {},
        error.BaselineNameMismatch => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline content does not match its name\n",
            .{target_id},
        ) catch {},
        error.BaselineNotManifest => s.err.print(
            "zit update: check-in '{s}' baseline is not a valid manifest\n",
            .{target_id},
        ) catch {},
        error.BaselineIsDelta => s.err.print(
            "zit update: check-in '{s}' baseline is not fully resolvable\n",
            .{target_id},
        ) catch {},
        error.StateRecordInvalid, error.Corrupt => s.err.writeAll("zit update: cannot read repository: repository is corrupt\n") catch {},
        error.InvalidPath => s.err.print("zit update: check-in '{s}' names an invalid file path\n", .{target_id}) catch {},
        error.ReservedPath => s.err.print("zit update: check-in '{s}' names a reserved file path\n", .{target_id}) catch {},
        error.SymlinkUnsupported => s.err.print(
            "zit update: check-in '{s}' contains an unsupported symlink entry\n",
            .{target_id},
        ) catch {},
        error.CurrentStateInvalid => s.err.writeAll("zit update: current working-tree state is invalid\n") catch {},
        error.WorktreeNotClean => s.err.writeAll("zit update: working tree is not clean\n") catch {},
        error.UnmanagedCollision => s.err.writeAll("zit update: an untracked file or directory is in the way\n") catch {},
        error.Storage => s.err.writeAll("zit update: cannot update working tree: input/output error\n") catch {},
        error.TransactionUnavailable => s.err.writeAll("zit update: cannot create a transaction directory\n") catch {},
        error.RollbackFailed => s.err.writeAll(
            "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
        ) catch {},
        error.CleanupFailed => s.err.writeAll(
            "zit update: update committed, but the transaction directory could not be removed\n",
        ) catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit commit` — mints a check-in (contracts/commit.md B-commit-1..20). The
// first verb that appends to the log AND advances the working state. A thin
// consumer: it decides which files at which content, hands the library RFC card
// structs, and the library owns all format work (`artifact.create` computes the
// Z self-check, orders/escapes cards, frames the artifact). No logic enters any
// library module; no `std.http`, no network; history is append-only. The one
// deep, RFC-normative unit — the F-set composition — is `cli.composeFCards`,
// pure and separately unit-tested; `runCommit` is thin wiring around it.
// ==========================================================================

/// SHA3-256 digest length: the on-disk log frame is keyed by these 32 raw bytes
/// (logstore.max_hash_len), while the artifact NAME is their 64-hex form. `init`
/// wrote the SHA3-256 policy (B-commit-16), so the algorithm is this constant.
const digest_len = 32;

/// Decodes a 64-hex artifact name into its 32-byte binary digest — the log-frame
/// key. Returns null for a non-64-hex string (a corrupt checkout `version`), so
/// the caller maps it to the parent-corrupt diagnostic rather than trapping.
fn hexToDigest(hex: []const u8) ?[digest_len]u8 {
    if (hex.len != digest_len * 2) return null;
    var d: [digest_len]u8 = undefined;
    _ = std.fmt.hexToBytes(&d, hex) catch return null;
    return d;
}

/// Reads the whole-second wall clock for the D card (B-commit-7), preferring the
/// `SOURCE_DATE_EPOCH` reproducible-build convention when set (a well-known,
/// tell-free way to pin the clock across a process boundary — how the e2e suite
/// fixes the timestamp of the spawned binary). Falls back to the real
/// (wall-clock) `Io.Clock`. The single ambient-time read in the CLI.
fn commitEpoch(io: std.Io, env: *const std.process.Environ.Map) i64 {
    if (env.get("SOURCE_DATE_EPOCH")) |raw| {
        const trimmed = std.mem.trim(u8, raw, " \t\r\n");
        if (std.fmt.parseInt(i64, trimmed, 10)) |v| return v else |_| {}
    }
    return Io.Timestamp.now(io, .real).toSeconds();
}

/// 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)
    not_regular, // present but not a regular file (B-commit-10)
    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,
) Capture {
    const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .missing;
    if (st.kind != .file) return .not_regular;
    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);
    _ = log.put(&digest, content) 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,
    } };
}

/// `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.
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;

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

    // 7. 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)) {
                .card => |c| captured.append(ma, c) catch return commitOom(streams),
                .missing => return commitMissing(streams, path),
                .not_regular => return commitNotRegular(streams, path),
                .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)) {
                .card => |c| captured.append(ma, c) catch return commitOom(streams),
                .missing => return commitMissing(streams, key),
                .not_regular => return commitNotRegular(streams, key),
                .io_error => return commitCaptureIo(streams, key),
                .oom => return commitOom(streams),
            }
        }
    }

    // 8. Parent F-set (child only): read + parse the parent manifest for
    //    carry-forward and the no-op verdict (B-commit-9/11). Slice-1 parents are
    //    always full manifests, so no delta resolution is reached.
    var pm: ?artifact.Manifest = null;
    defer if (pm) |*m| m.deinit();
    var parent_bytes: ?[]u8 = null;
    defer if (parent_bytes) |b| gpa.free(b);
    var parent_f: []const artifact.FCard = &.{};
    if (!is_root) {
        const pdigest = hexToDigest(ck.version) orelse return commitParentCorrupt(streams);
        const rid = log.lookup(&pdigest) orelse return commitParentCorrupt(streams);
        parent_bytes = (log.content(gpa, rid) catch |e| return commitContentFail(streams, e)) orelse
            return commitParentCorrupt(streams);
        pm = artifact.parse(gpa, parent_bytes.?) catch return commitParentCorrupt(streams);
        parent_f = pm.?.f;
    }

    // 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.
    const composed = cli.composeFCards(ma, .{
        .parent = parent_f,
        .captured = captured.items,
        .policy = policy,
    }) catch return commitOom(streams);
    if (composed.unchanged) {
        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;
    }

    // 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);
    const root_tags = [_]artifact.TCard{
        .{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" },
        .{ .sign = .propagate, .name = "sym-trunk", .target = null, .value = null },
    };
    const child_parents = [_]artifact.PCard{.{ .parents = ck.version, .count = 1 }};
    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 = &.{},
        .t = if (is_root) &root_tags else &.{},
    };
    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);
    _ = log.put(&mdigest, bytes) catch |e| return commitLogFail(streams, e);
    log.commit() catch |e| return commitLogFail(streams, 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).
    checkout.write(io, root_dir, .{ .version = manifest_name, .branch = branch }) 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. `-q` suppresses it.
    if (!parsed.quiet) {
        streams.out.print("check-in {s} on {s}\n", .{ manifest_name, 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).
fn commitNotRegular(s: cli.Streams, path: []const u8) cli.Exit {
    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;
}

const top_usage =
    \\zit — a Fossil version-control system in Zig
    \\
    \\usage: zit <command> [args]
    \\
    \\commands:
    \\  init       create an empty zit repository
    \\  add        mark files to include in your next check-in
    \\  rm         stop tracking files (undo add)
    \\  status     show the working checkout state
    \\  diff       compare managed files with the current check-in
    \\  commit     record a check-in from your tracked files
    \\  log        show a check-in and its reachable parents
    \\  update     materialize a check-in in the working tree
    \\  version    print the version
    \\  help       show this help
    \\
;

const log_help =
    \\usage: zit log CHECKIN
    \\
    \\Show CHECKIN followed by every reachable parent check-in in depth-first
    \\P-card order. CHECKIN must be an exact 40- or 64-character lowercase
    \\hexadecimal artifact ID. Each check-in is shown once; a referenced parent
    \\missing from this repository is shown once as a phantom.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;
const update_help =
    \\usage: zit update CHECKIN
    \\
    \\Materialize CHECKIN in the working tree. CHECKIN must be an exact 40- or
    \\64-character lowercase hexadecimal artifact ID.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

const diff_help =
    \\usage: zit diff [-h|--help]
    \\
    \\Compare the managed working-tree files with the current check-in. Output is
    \\metadata only, sorted by pathname: A for additions, M for content or
    \\executable-mode changes, and D for files removed from management or the
    \\working tree.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

/// Verbatim B-init-11 help text. Model-honest wording ("check-in", "trunk",
/// "unborn", "root check-in", "SHA3-256 by default"). Ends in exactly one
/// newline. The e2e suite verifies stdout equals an independent transcription
/// of the contract, so drift here is caught.
const init_help =
    \\usage: zit init [-q|--quiet] [--] [directory]
    \\
    \\Create an empty zit repository in <directory> (default: the current
    \\directory). Writes two files there:
    \\
    \\  .zit            the repository — a single, self-contained content log
    \\  .zit-checkout   the working-state record (branch trunk, no check-in yet)
    \\
    \\The new repository has no check-ins: the default branch 'trunk' is unborn
    \\until your first `zit commit`, which records the root check-in. Artifacts
    \\are named with SHA3-256 by default.
    \\
    \\If <directory> does not exist it is created.
    \\
    \\options:
    \\  -q, --quiet   do not print the confirmation line
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  repository created
    \\  1  a repository already exists, or the path could not be initialized
    \\  2  usage error (unknown option or extra argument)
    \\
;

/// Verbatim B-add-13 help text (contracts/add.md §Help). Model-honest wording
/// ("managed", "no staging area", "check-in", "trunk", "unborn", "zit rm").
/// Ends in exactly one newline. The e2e suite compares stdout against an
/// independent transcription of this block, so drift is caught.
const add_help =
    \\usage: zit add [-n|--dry-run] [-q|--quiet] [--] <path>...
    \\
    \\Mark one or more working-tree files as managed, so they are included in
    \\your next check-in. zit has no staging area: `add` records only *which*
    \\files to track — their contents are read from the working tree when you
    \\run `zit commit`, not now. Re-adding an already-tracked file does nothing.
    \\
    \\Files stay tracked until `zit rm` forgets them. You can add files before
    \\the first check-in (the default branch 'trunk' is unborn until then).
    \\
    \\Only individual files are accepted; naming a directory is an error.
    \\
    \\options:
    \\  -n, --dry-run   show what would be added; change nothing
    \\  -q, --quiet     do not print the added files
    \\  -h, --help      show this help and exit
    \\
    \\exit status:
    \\  0  all named files are now tracked (or already were)
    \\  1  a file could not be added (missing, a directory, outside the repo),
    \\     or this is not a zit repository
    \\  2  usage error (no files given, or an unknown option)
    \\
;

/// Verbatim B-rm-13 help text (contracts/rm.md §Help). Model-honest wording
/// ("tracking", "managed set", "no staging area", "check-in", and the explicit
/// contrast with git's delete-by-default). Ends in exactly one newline. The e2e
/// suite compares stdout against an independent transcription of this block.
const rm_help =
    \\usage: zit rm [-n|--dry-run] [--hard] [-q|--quiet] [--] <path>...
    \\
    \\Stop tracking one or more files: remove their paths from the managed set,
    \\so your next check-in records their deletion. zit has no staging area, so
    \\this takes effect when you run `zit commit`, not now.
    \\
    \\By default your working copy is left on disk untouched -- `rm` only forgets
    \\the file. Pass --hard to also delete the file from disk. (git deletes by
    \\default; zit keeps your file unless you ask for --hard.)
    \\
    \\Removing a path that is not tracked is an error. Only individual files are
    \\accepted; naming a directory is an error.
    \\
    \\options:
    \\  -n, --dry-run   show what would be removed; change nothing
    \\      --hard      also delete the working-tree file from disk
    \\  -q, --quiet     do not print the removed files
    \\  -h, --help      show this help and exit
    \\
    \\exit status:
    \\  0  all named files are no longer tracked (and, with --hard, deleted)
    \\  1  a file could not be removed (not tracked, a directory, outside the
    \\     repo, or --hard could not delete it), or this is not a zit repository
    \\  2  usage error (no files given, or an unknown option)
    \\
;

/// Verbatim B-status-16 help text (contracts/status.md §Help). Model-honest
/// wording ("check-in", "trunk", "unborn", "tracked", "artifact hash" — never
/// git's "index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary
/// (DX D2) rather than hiding it. Ends in exactly one newline. The e2e suite
/// compares stdout against an independent transcription of this block, so drift
/// is caught.
const status_help =
    \\usage: zit status [-h|--help]
    \\
    \\Show the state of the working copy: the current branch, the current
    \\check-in (if any), and the state of your tracked files.
    \\
    \\zit has no staging area. `status` reports the files you marked with
    \\`zit add` and how each one stands in the working tree:
    \\
    \\  added <path>     tracked and new — it will enter your next check-in
    \\  missing <path>   tracked, but not present in the working tree
    \\
    \\Until your first `zit commit` the branch 'trunk' is unborn: there is no
    \\check-in yet, so every tracked file is reported as 'added' (it will form
    \\the root check-in). The current check-in, once one exists, is shown as its
    \\full artifact hash.
    \\
    \\Not yet reported (these arrive with `zit commit` / `zit diff`): edits to
    \\already-committed files, files deleted from a check-in, and untracked
    \\("extra") files in the working tree.
    \\
    \\options:
    \\  -h, --help   show this help and exit
    \\
    \\exit status:
    \\  0  the working-copy state was reported (with or without changes)
    \\  1  this is not a zit repository, or its state could not be read
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// Verbatim B-commit-20 help text (contracts/commit.md §Help). Model-honest
/// wording ("check-in", "root check-in", "trunk", "artifact hash", "tracked",
/// "no staging area", "immutable", "no amend/--force" — never git's
/// "commit"/"index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary
/// (DX D2). Ends in exactly one newline. The e2e suite compares stdout against
/// an independent transcription of this block, so drift is caught.
const commit_help =
    \\usage: zit commit -m <message> [--user <login>] [-q|--quiet] [--] [<path>...]
    \\
    \\Record a check-in: save the current contents of your tracked files as a
    \\new, permanent point in history on the current branch.
    \\
    \\zit has no staging area. `commit` reads each tracked file straight from
    \\the working tree right now — there is nothing to stage first. With no
    \\paths, every tracked file is captured; name one or more paths to commit
    \\just those (the rest of the tree is carried forward unchanged).
    \\
    \\The first commit in a new repository creates the root check-in and starts
    \\the default branch 'trunk'. Each check-in is named by its full artifact
    \\hash (SHA3-256).
    \\
    \\History is immutable: a check-in is never rewritten. There is no amend and
    \\no --force; to undo a file, change it and commit again. To stop tracking a
    \\file (and record its deletion), use `zit rm` then commit.
    \\
    \\A check-in message is required (-m). Only regular files are captured.
    \\
    \\options:
    \\  -m, --message <message>   the check-in comment (required)
    \\      --user <login>        author to record (default: $USER)
    \\  -q, --quiet               do not print the check-in line
    \\  -h, --help                show this help and exit
    \\
    \\exit status:
    \\  0  the check-in was created
    \\  1  nothing to commit, a tracked file is missing, an empty message, or
    \\     this is not a zit repository
    \\  2  usage error (no message, or an unknown option)
    \\
;

test {
    // Pull the CLI harness's inline unit tests into `zig build test`.
    _ = @import("cli.zig");
}

/// A fixed `Ambient` for the in-process dispatch tests: a pinned epoch (no wall
/// clock) and no environment author. Verbs other than `commit` ignore it; the
/// commit dispatch tests that reach assembly pin their own values via the e2e
/// harness (the real binary), so this constant is only the argument shape.
const test_amb: cli.Ambient = .{ .now_epoch = 0, .user_env = null };

// --- add error-mapper unit test (B-add-17; pure switch) --------------------

test "add B-add-17: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .arg = "", .want = "zit add: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .arg = "", .want = "zit add: cannot update tracked files: input/output error\n" },
        .{ .err = error.TooLong, .arg = "deep/file", .want = "zit add: 'deep/file': path is too long to track\n" },
        .{ .err = error.TooMany, .arg = "x", .want = "zit add: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .arg = "x", .want = "zit add: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        const code = addFail(s, c.arg, c.err);
        try std.testing.expectEqual(cli.Exit.failure, code);
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

// --- rm error-mapper unit test (B-rm-17; pure switch) ----------------------

test "rm B-rm-17: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .arg = "", .want = "zit rm: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .arg = "", .want = "zit rm: cannot update tracked files: input/output error\n" },
        .{ .err = error.TooLong, .arg = "deep/file", .want = "zit rm: 'deep/file': path is too long to track\n" },
        .{ .err = error.TooMany, .arg = "x", .want = "zit rm: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .arg = "x", .want = "zit rm: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        const code = rmFail(s, c.arg, c.err);
        try std.testing.expectEqual(cli.Exit.failure, code);
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

// --- status error-mapper unit tests (B-status-10/-11; pure switches) -------

test "status B-status-10: CheckoutError maps to the contract messages and exit 1" {
    const Case = struct { err: checkout.CheckoutError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit status: cannot read checkout state: .zit-checkout is corrupt\n" },
        .{ .err = error.Storage, .want = "zit status: cannot read checkout state: input/output error\n" },
    };
    for (cases) |c| {
        var buf: [256]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        try std.testing.expectEqual(cli.Exit.failure, checkoutFail(s, "status", c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "status B-status-10: a vanished checkout record renders the missing message and exit 1" {
    var buf: [128]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: cli.Streams = .{ .out = &wnull, .err = &w };
    try std.testing.expectEqual(cli.Exit.failure, checkoutMissing(s, "status"));
    try std.testing.expectEqualStrings(
        "zit status: cannot read checkout state: .zit-checkout is missing\n",
        w.buffered(),
    );
}

test "status B-status-11: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit status: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .want = "zit status: cannot read tracked files: input/output error\n" },
        .{ .err = error.TooLong, .want = "zit status: cannot read tracked files: a tracked path is too long\n" },
        .{ .err = error.TooMany, .want = "zit status: cannot read tracked files: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .want = "zit status: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [256]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        try std.testing.expectEqual(cli.Exit.failure, managedFail(s, "status", c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "status B-status-9: stateOom renders the out-of-memory message and exit 1" {
    var buf: [64]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: cli.Streams = .{ .out = &wnull, .err = &w };
    try std.testing.expectEqual(cli.Exit.failure, stateOom(s, "status"));
    try std.testing.expectEqualStrings("zit status: out of memory\n", w.buffered());
}

// --- status dispatch: the io/alloc-free paths, driven in-process -----------
//
// `--help` and the two usage errors touch neither the filesystem nor the
// allocators, so they run through `run()` over fixed-buffer writers — the same
// technique the unknown-command dispatch tests use — with the exact argv.

/// Drives `run()` for `zit status <tail...>` over fixed-buffer writers,
/// returning the exit code and the captured stdout/stderr (valid until the next
/// call). Sized to hold the whole `status_help` block on stdout.
fn runStatusCapture(tail: []const []const u8) struct { code: cli.Exit, out: []const u8, err: []const u8 } {
    const S = struct {
        var out_buf: [2048]u8 = undefined;
        var err_buf: [512]u8 = undefined;
    };
    var out = Io.Writer.fixed(&S.out_buf);
    var err = Io.Writer.fixed(&S.err_buf);
    const streams: cli.Streams = .{ .out = &out, .err = &err };
    var argv_buf: [8][]const u8 = undefined;
    argv_buf[0] = "zit";
    argv_buf[1] = "status";
    for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
    const code = run(
        std.testing.allocator,
        std.testing.io,
        std.testing.allocator,
        argv_buf[0 .. 2 + tail.len],
        streams,
        test_amb,
    );
    return .{ .code = code, .out = out.buffered(), .err = err.buffered() };
}

test "status B-status-12: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
    const r = runStatusCapture(&.{"-x"});
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit status: unknown option '-x'\ntry 'zit status --help'\n",
        r.err,
    );
}

test "status B-status-13: an unexpected argument is a usage error (exit 2, stderr, empty stdout)" {
    const r = runStatusCapture(&.{"foo"});
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit status: unexpected argument 'foo'\ntry 'zit status --help'\n",
        r.err,
    );
}

test "status B-status-14: --help prints the help to stdout (exit 0, empty stderr)" {
    const r = runStatusCapture(&.{"--help"});
    try std.testing.expectEqual(cli.Exit.ok, r.code);
    try std.testing.expectEqualStrings(status_help, r.out);
    try std.testing.expectEqualStrings("", r.err);
}

test "status B-status-16 DX-D4: 'zit help status' routes to the verb's own help" {
    // `run()`'s help arm touches neither io nor the allocators, so the topic
    // routing is driven in-process; the stdout must be byte-identical to
    // `status --help` (the DX-D4 fix: `zit help status` no longer prints the
    // general command list that implied the verb was absent).
    var out_buf: [2048]u8 = undefined;
    var err_buf: [256]u8 = undefined;
    var out = Io.Writer.fixed(&out_buf);
    var err = Io.Writer.fixed(&err_buf);
    const streams: cli.Streams = .{ .out = &out, .err = &err };
    const code = run(
        std.testing.allocator,
        std.testing.io,
        std.testing.allocator,
        &.{ "zit", "help", "status" },
        streams,
        test_amb,
    );
    try std.testing.expectEqual(cli.Exit.ok, code);
    try std.testing.expectEqualStrings(status_help, out.buffered());
    try std.testing.expectEqualStrings("", err.buffered());
}

test "init B-init-3: default hash_kind is SHA3-256 (= 2)" {
    // Guards the byte the header stores for a fresh repo (artifact §7.2-m2).
    try std.testing.expectEqual(@as(u8, 2), @intFromEnum(zit.artifact.defaultPolicy(false, false)));
}

// --- unknown-command dispatch (DX D2/D4), verified through `run()` ----------
//
// `run()` on an unknown verb touches neither io nor the allocators, so these
// drive it in-process over fixed-buffer writers — no subprocess needed — and
// assert the same exit-2 / stderr-only contract the e2e suite holds for init.

/// Runs `zit <verb>` through `run()`, returning the exit code plus the captured
/// stdout/stderr slices (valid until the next call — backed by static buffers,
/// so a test uses one invocation at a time).
const RunCapture = struct {
    code: cli.Exit,
    out: []const u8,
    err: []const u8,

    var out_buf: [256]u8 = undefined;
    var err_buf: [512]u8 = undefined;

    fn run_verb(verb: []const u8) RunCapture {
        var out = Io.Writer.fixed(&out_buf);
        var err = Io.Writer.fixed(&err_buf);
        const streams: cli.Streams = .{ .out = &out, .err = &err };
        const code = run(std.testing.allocator, std.testing.io, std.testing.allocator, &.{ "zit", verb }, streams, test_amb);
        return .{ .code = code, .out = out.buffered(), .err = err.buffered() };
    }
};

/// Asserts a git-reflex `verb` renders its teaching line: exit 2, stdout empty,
/// stderr naming the verb, stating it is not a zit command, and containing the
/// lesson keyword `teaches`.
fn expectReflex(verb: []const u8, teaches: []const u8) !void {
    const r = RunCapture.run_verb(verb);
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, verb) != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "is not a zit command") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, teaches) != null);
}

/// Asserts a planned-but-unimplemented `verb` renders the exact promise line:
/// exit 2, stdout empty, stderr `zit: '<verb>' is planned but not implemented
/// yet.\n` and nothing else. A planned verb is a real zit command that simply
/// isn't built yet (e.g. `commit`, which `init --help` points the user at), so
/// unlike a git-reflex verb it is never told it "is not a zit command".
fn expectPlanned(verb: []const u8) !void {
    const r = RunCapture.run_verb(verb);
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    var buf: [128]u8 = undefined;
    const expected = try std.fmt.bufPrint(&buf, "zit: '{s}' is planned but not implemented yet.\n", .{verb});
    try std.testing.expectEqualStrings(expected, r.err);
}

test "cli D2 B-init-12: 'rebase' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("rebase", "history is immutable");
}
test "cli D2 B-init-12: 'amend' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("amend", "history is immutable");
}
test "cli D2 B-init-12: 'reset' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("reset", "history is immutable");
}
test "cli D2 B-init-12: 'filter-branch' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("filter-branch", "history is immutable");
}
test "cli D2 B-init-12: 'checkout' points at 'zit update', exit 2 stderr-only" {
    try expectReflex("checkout", "zit update");
}
test "cli D2 B-init-12: 'push' teaches bidirectional sync / no force-push, exit 2 stderr-only" {
    try expectReflex("push", "force-push does not exist");
}
test "cli D2 B-init-12: 'pull' teaches bidirectional sync, exit 2 stderr-only" {
    try expectReflex("pull", "zit sync");
}
test "cli D2 B-init-12: 'fetch' teaches bidirectional sync, exit 2 stderr-only" {
    try expectReflex("fetch", "zit sync");
}
test "cli D2 B-init-12: 'stash' states it is not implemented, exit 2 stderr-only" {
    try expectReflex("stash", "not implemented");
}
test "cli commit B-commit-5: 'zit commit' with no -m is a usage error (real verb, dispatched)" {
    // Regression: `commit` is now a real handler, not a planned stub. `run()`'s
    // commit arm raises the missing-message usage error before any I/O, so this
    // is driven in-process — exit 2, stderr the B-commit-5 message, empty stdout.
    const r = RunCapture.run_verb("commit");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
        r.err,
    );
}
test "cli D2 B-init-12: 'clone' is a planned verb, exit 2 stderr-only" {
    try expectPlanned("clone");
}

test "cli D4 B-init-12: a near-miss verb suggests the closest command, exit 2 stderr-only" {
    const r = RunCapture.run_verb("ini");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'ini'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'init'?") != null);
}

test "cli D4 B-init-12: a typo of a planned verb suggests the canonical command" {
    // `comit` is neither reflex nor exactly planned; the generic path should
    // suggest the planned canonical `commit` (planned verbs join the near-miss
    // set), so the typo does not dead-end.
    const r = RunCapture.run_verb("comit");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'comit'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'commit'?") != null);
}

test "cli D4 B-init-12: a typo of 'update' suggests it (every dispatched verb is suggestable)" {
    // `update` is a real dispatched verb, so a near-miss must land on it. Guards
    // against the suggester's candidate list drifting from the dispatched set.
    const r = RunCapture.run_verb("updat");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'updat'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'update'?") != null);
}

test "cli D4 B-init-12: a distant unknown lists commands with no misleading guess" {
    const r = RunCapture.run_verb("florble");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'florble'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean") == null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "commands:") != null);
}

// --- commit error-mapper unit tests (B-commit-15; pure switches) -----------

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

test "diff: diffLogFail maps the contract messages and exit 1, exhaustively over all 3 logstore.OpenError variants" {
    const Case = struct { err: logstore.OpenError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit diff: out of memory\n" },
        .{ .err = error.Corrupt, .want = "zit diff: cannot read repository: repository is corrupt\n" },
        .{ .err = error.Storage, .want = "zit diff: cannot read repository: input/output error\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        try std.testing.expectEqual(cli.Exit.failure, diffLogFail(s, c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "diff: diffComparisonFail maps the contract messages and exit 1, exhaustively over all 3 comparison-error variants" {
    const Case = struct { err: error{ OutOfMemory, Storage, Corrupt }, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit diff: out of memory\n" },
        .{ .err = error.Corrupt, .want = "zit diff: cannot compare: repository is corrupt\n" },
        .{ .err = error.Storage, .want = "zit diff: cannot compare working tree: input/output error\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        try std.testing.expectEqual(cli.Exit.failure, diffComparisonFail(s, c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "update: ApplyError maps to the contract messages and exit 1, exhaustively over all 23 variants" {
    const target_id = "1" ** 40;
    const Case = struct { err: update.ApplyError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit update: out of memory\n" },
        .{
            .err = error.InvalidCheckinId,
            .want = "zit update: invalid check-in ID '" ++ target_id ++ "' (expected 40 or 64 lowercase hexadecimal digits)\n",
        },
        .{ .err = error.CheckinNotFound, .want = "zit update: check-in '" ++ target_id ++ "' not found\n" },
        .{
            .err = error.CheckinContentMissing,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content is missing\n",
        },
        .{
            .err = error.CheckinNameMismatch,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content does not match its name\n",
        },
        .{ .err = error.TargetNotManifest, .want = "zit update: check-in '" ++ target_id ++ "' is not a valid manifest\n" },
        .{
            .err = error.BaselineNotFound,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline is not found\n",
        },
        .{
            .err = error.BaselineContentMissing,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content is missing\n",
        },
        .{
            .err = error.BaselineNameMismatch,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content does not match its name\n",
        },
        .{
            .err = error.BaselineNotManifest,
            .want = "zit update: check-in '" ++ target_id ++ "' baseline is not a valid manifest\n",
        },
        .{
            .err = error.BaselineIsDelta,
            .want = "zit update: check-in '" ++ target_id ++ "' baseline is not fully resolvable\n",
        },
        .{ .err = error.StateRecordInvalid, .want = "zit update: cannot read repository: repository is corrupt\n" },
        .{ .err = error.InvalidPath, .want = "zit update: check-in '" ++ target_id ++ "' names an invalid file path\n" },
        .{ .err = error.ReservedPath, .want = "zit update: check-in '" ++ target_id ++ "' names a reserved file path\n" },
        .{
            .err = error.SymlinkUnsupported,
            .want = "zit update: check-in '" ++ target_id ++ "' contains an unsupported symlink entry\n",
        },
        .{ .err = error.CurrentStateInvalid, .want = "zit update: current working-tree state is invalid\n" },
        .{ .err = error.WorktreeNotClean, .want = "zit update: working tree is not clean\n" },
        .{ .err = error.Corrupt, .want = "zit update: cannot read repository: repository is corrupt\n" },
        .{ .err = error.UnmanagedCollision, .want = "zit update: an untracked file or directory is in the way\n" },
        .{ .err = error.Storage, .want = "zit update: cannot update working tree: input/output error\n" },
        .{ .err = error.TransactionUnavailable, .want = "zit update: cannot create a transaction directory\n" },
        .{
            .err = error.RollbackFailed,
            .want = "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
        },
        .{
            .err = error.CleanupFailed,
            .want = "zit update: update committed, but the transaction directory could not be removed\n",
        },
    };
    for (cases) |c| {
        var buf: [256]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        try std.testing.expectEqual(cli.Exit.failure, updateApplyFail(s, target_id, c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "diff: output write failure is exit 1" {
    var out_buf: [0]u8 = .{};
    var out = Io.Writer.fixed(&out_buf);
    var err_buf: [64]u8 = undefined;
    var err = Io.Writer.fixed(&err_buf);
    const streams: cli.Streams = .{ .out = &out, .err = &err };

    try std.testing.expectEqual(cli.Exit.failure, writeDiffOutput(streams, "M a\n"));
    try std.testing.expectEqualStrings("zit diff: cannot write output\n", err.buffered());
}

test "tracked file size limit admits its boundary" {
    const limit: u64 = max_tracked_file_bytes;
    try std.testing.expect(trackedFileSizeAllowed(limit - 1));
    try std.testing.expect(trackedFileSizeAllowed(limit));
    try std.testing.expect(!trackedFileSizeAllowed(limit + 1));
}