Skip to content
zit Artifact [22c6949b2c]
Login

Artifact [22c6949b2c]

Artifact 22c6949b2c30745d3dc65454223c5786a51afe5427476bc3893cea9127472f9e:


//! zit working-tree tracking verbs (add, rm, mv, status, extras, clean,
//! addremove, diff) and the helpers and tests private to them.
//!
//! Extracted verbatim from src/main.zig (release step S3). Every declaration
//! below is byte-identical to its main.zig source; only this import prologue is
//! new. Helpers still shared with other verbs stay in main.zig and are aliased
//! back here so the moved bodies call them unqualified. main.zig's verb-dispatch
//! switch calls into this file (`cmd_tracking.runAdd` ...); its test block imports
//! it so these inline tests keep running under `zig build test`.

const std = @import("std");
const zit = @import("zit");
const cli = @import("../cli.zig");
const extras = @import("../extras.zig");
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const checkout = @import("zit").checkout;
const Repository = @import("zit").Repository;

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

// Shared CLI helpers and test fixtures that stay in main.zig (other verbs use
// them too); aliased back so the moved code and tests reference them by their
// original unqualified names.
const main = @import("../main.zig");
const symlink_unversioned = main.symlink_unversioned;
const repo_filename = main.repo_filename;
const reasonOf = main.reasonOf;
const checkoutFail = main.checkoutFail;
const managedFail = main.managedFail;
const stateOom = main.stateOom;
const max_tracked_file_bytes = main.max_tracked_file_bytes;
const trackedFileSizeAllowed = main.trackedFileSizeAllowed;
const executable = main.executable;
const not_a_repo_msg = main.not_a_repo_msg;
const diff_help = main.diff_help;
const add_help = main.add_help;
const rm_help = main.rm_help;
const mv_help = main.mv_help;
const status_help = main.status_help;
const extras_help = main.extras_help;
const clean_help = main.clean_help;
const addremove_help = main.addremove_help;

/// 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
    /// A directory inside the checkout: `null` is the checkout root itself
    /// (`zit add .`), otherwise the resolved root-relative directory key. The
    /// verb layer expands it over one bounded `extras.sweep` (P3-F4).
    directory: ?[]const u8, // B-add-5
    not_regular, // B-add-5: a fifo/socket/device — never a symlink
    /// A symlink: refused in 0.1.0, with its own wording (F-B3).
    symlink, // 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.)
pub 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). A
    //    directory argument is expanded from ONE `extras.sweep` of the whole
    //    checkout root, swept lazily on the first directory argument and reused
    //    for every later one (P3-F4): the same walk, the same exclusions, the
    //    same symlink posture `addremove` uses — never a second walker.
    var failed = false;
    var changed = false;
    var swept: ?extras.Extras = null;
    defer if (swept) |*s| s.deinit();
    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 {};
            },
            .directory => |dir_key| {
                // Every untracked file beneath `dir_key` (the whole tree when
                // it is null) becomes an add, in the sweep's ascending order.
                // The sweep already subtracts the tracked set and the depth-0
                // control names, so neither can be re-inserted or re-reported
                // here; `insert`'s false branch only fires when an earlier
                // argument in this same run already took the path, which is
                // silent by design (it was never named).
                const items = sweepRootOnce(gpa, io, root.abs, &set, &swept) catch |e| {
                    failed = true;
                    _ = sweepFail(streams, "add", e);
                    continue;
                };
                for (items) |entry| {
                    const path = entry.path;
                    if (!pathUnderDir(path, dir_key)) continue;
                    // 0.1.0 does not version symlinks, so the recursive path
                    // must skip exactly what explicit `add` and `commit` both
                    // refuse (F-B3): tracking one here is what wedged `commit`.
                    // The kind is the sweep's own lstat verdict — never
                    // re-derived here, or the two paths could drift again.
                    if (entry.is_symlink) {
                        if (!parsed.quiet) streams.err.print(
                            "zit add: skipped '{s}': " ++ symlink_unversioned ++ "\n",
                            .{path},
                        ) catch {};
                        continue;
                    }
                    const inserted = set.insert(path) catch |e| {
                        failed = true;
                        _ = addFail(streams, path, e);
                        continue;
                    };
                    if (!inserted) continue;
                    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, path }) catch {};
                    }
                }
            },
            .not_regular => {
                failed = true;
                streams.err.print("zit add: '{s}' is not a regular file\n", .{arg}) catch {};
            },
            .symlink => {
                failed = true;
                streams.err.print(
                    "zit add: '{s}': " ++ symlink_unversioned ++ "\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;
}

/// Whether the root-relative `key` lies strictly beneath the directory `dir`
/// (`null` = the checkout root, under which everything lies). The ONE
/// directory-containment predicate both verbs use — `add`'s sweep filter and
/// `rm`'s tracked-key expansion — so the boundary case cannot diverge between
/// them: `src2/c` is NOT under `src`, because containment requires the `/`
/// separator immediately after the directory's bytes. A directory never
/// contains itself (a key equal to `dir` is the file/entry case, decided
/// before this is reached). Pure; no I/O, no allocation.
fn pathUnderDir(key: []const u8, dir: ?[]const u8) bool {
    const d = dir orelse return true;
    std.debug.assert(d.len != 0); // the root is spelled `null`, never ""
    return key.len > d.len + 1 and key[d.len] == '/' and std.mem.startsWith(u8, key, d);
}

/// The untracked working-tree files under the checkout root, swept at most ONCE
/// per `add` invocation and memoized in `cache` (P3-F4). The first directory
/// argument pays for the walk; every later one filters the same result, so `zit
/// add a b c` over three directories still performs a single enumeration. The
/// root is re-opened ITERABLE with no-follow, exactly as `addremove`/`clean` do,
/// so a root symlink is never entered. Borrows `cache`; the slice is valid until
/// the caller deinits it.
fn sweepRootOnce(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_abs: []const u8,
    set: *const managed.ManagedSet,
    cache: *?extras.Extras,
) extras.SweepError![]const extras.Entry {
    if (cache.*) |*done| return done.items();
    var iter_root = Io.Dir.cwd().openDir(io, root_abs, .{
        .access_sub_paths = true,
        .iterate = true,
        .follow_symlinks = false,
    }) catch return error.Storage;
    defer iter_root.close(io);
    cache.* = try extras.sweep(gpa, io, iter_root, set);
    return cache.*.?.items();
}

/// 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 .{ .directory = null },
        .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 a symlink, 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 .{ .directory = key },
        .file => {},
        // A symlink gets its own refusal (F-B3) so the message matches the skip
        // notice `add <dir>` prints for the same entry; a fifo/socket/device
        // keeps the generic non-regular wording.
        .sym_link => return .symlink,
        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 a repo-internal control entry that is never tracked
/// (B-add-7, B-undo-13). Delegates to `extras.isControlName`, the single
/// never-tracked predicate shared with `extras` (and later `clean`), so the
/// tracked/never-tracked boundary cannot drift. That set is a STRICT SUPERSET
/// of add/rm's original four names (`.zit`, `.zit-checkout`, `.zit-managed`,
/// `.zit-undo`), additionally refusing the transient artifacts `.zit-cache` and
/// `.zit-pack-tmp` — names `zit` creates, never user content (B-extras-5).
fn isControlFile(key: []const u8) bool {
    return extras.isControlName(key);
}

/// 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,
    /// A directory argument with at least one tracked key beneath it: `null` is
    /// the checkout root (`zit rm .`), otherwise the resolved root-relative
    /// directory key. Expanded over the tracked set by the verb layer (P3-F4).
    directory: ?[]const u8, // B-rm-5
    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.)
pub 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 {};
            },
            .directory => |dir_key| {
                // Expand the directory over the TRACKED keys (P3-F4). The keys
                // are copied into the arena BEFORE any removal, because
                // `iterate()`'s slice and each key's backing bytes are
                // invalidated by the first `remove` — the same discipline
                // `addremove` uses for its remove set. Ascending order is
                // preserved (iterate is sorted), and every key still passes the
                // per-key control-file guard (B-rm-7) before it is forgotten.
                var under: std.ArrayList([]const u8) = .empty;
                for (set.iterate()) |tracked| {
                    if (!pathUnderDir(tracked, dir_key)) continue;
                    const owned = arena.dupe(u8, tracked) catch
                        return rmFail(streams, arg, error.OutOfMemory);
                    under.append(arena, owned) catch
                        return rmFail(streams, arg, error.OutOfMemory);
                }
                for (under.items) |k| {
                    if (isControlFile(k)) {
                        failed = true;
                        streams.err.print(
                            "zit rm: '{s}' is a zit control file and cannot be tracked\n",
                            .{k},
                        ) catch {};
                        continue;
                    }
                    if (!set.remove(k)) continue; // unreachable in practice; never claim a forget
                    changed = true;
                    forgotten.append(arena, k) catch
                        return rmFail(streams, arg, error.OutOfMemory);
                }
            },
            .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), which
    //    means every tracked key — unless nothing is tracked at all, in which
    //    case there is nothing to forget and the existing not-tracked
    //    diagnostic tells the truth (no new diagnostic class, P3-F4).
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .outside_root,
        .root => return if (set.iterate().len == 0)
            .{ .not_tracked = arg }
        else
            .{ .directory = null },
        .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 };
    // 4. Not an entry itself: it may still be a directory holding tracked keys
    //    (P3-F4). The verdict is a PURE prefix scan of the tracked set — never a
    //    directory walk — so `zit rm <dir>` still forgets a directory the user
    //    already deleted from disk, which is rm's whole model (see the doc
    //    comment above). An argument matching neither an entry nor any tracked
    //    prefix stays not_tracked: no new diagnostic class.
    for (set.iterate()) |tracked| {
        if (pathUnderDir(tracked, key)) return .{ .directory = 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;
}

// ==========================================================================
// `zit mv OLD NEW` — rename a tracked file. Moves the working file on disk,
// swaps the tracked-name entry in the managed set, and RECORDS the rename so
// the next `zit commit` emits the §4.7.6 four-token old-name F card. A thin
// consumer of the existing managed-set + repo-relative machinery; the one new
// mechanism (the recorded rename) is hidden inside managed.zig.
// (contracts/mv.md B-mv-1..18.)
// ==========================================================================

/// The outcome of resolving one `mv` operand to a repo-relative key, or the
/// refusal phrase to render against the raw operand (rendered as
/// `zit mv: '<arg>' <phrase>`). A directory (the root) and an escape are both
/// refusals: mv renames a single tracked file, never a directory or an outside
/// path (contracts/mv.md Non-goals).
const MvResolved = union(enum) {
    key: []const u8,
    refuse: []const u8,
};

/// Resolves one raw `mv` operand to a normalized repo-relative key, or a refusal
/// phrase. Filesystem-free: only string resolution + the control-file guard.
fn mvResolveKey(
    gpa: std.mem.Allocator,
    root_abs: []const u8,
    cwd_abs: []const u8,
    arg: []const u8,
) error{OutOfMemory}!MvResolved {
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .{ .refuse = "is outside the repository" },
        .root => return .{ .refuse = "is a directory; mv renames a single tracked file" },
        .key => |k| k,
    };
    if (isControlFile(key)) return .{ .refuse = "is a zit control file and cannot be tracked" };
    return .{ .key = key };
}

/// Whether `key` names an existing entry directly under `dir` (symlinks are not
/// followed, so a dangling symlink still counts as present — mv must not clobber
/// it). Any stat failure reads as absent.
fn mvOnDisk(io: std.Io, dir: std.Io.Dir, key: []const u8) bool {
    _ = dir.statFile(io, key, .{ .follow_symlinks = false }) catch return false;
    return true;
}

/// The directory component of `key`, or null when `key` sits at the checkout
/// root (which always exists, so there is nothing to check).
fn mvParentDir(key: []const u8) ?[]const u8 {
    return std.fs.path.dirnamePosix(key);
}

/// Whether `key`'s parent directory exists and is a directory. A path at the
/// checkout root is always satisfied. Symlinks ARE followed here: a symlink to
/// a real directory is a usable rename destination, unlike `mvOnDisk`'s
/// collision check where a dangling link must still count as occupied.
fn mvParentOnDisk(io: std.Io, dir: std.Io.Dir, key: []const u8) bool {
    const parent = mvParentDir(key) orelse return true;
    const st = dir.statFile(io, parent, .{}) catch return false;
    return st.kind == .directory;
}

/// `zit mv OLD NEW` — the thin wiring: parse, discover the checkout root, load
/// the managed set, resolve+validate both operands, refuse a collision, then
/// (all-or-nothing) move the working file and then swap the managed entry +
/// record the rename and persist it durably, rolling the move back if the
/// persist fails (B-mv-2 / B-commit-15 order).
/// Touches working-copy state only (`.zit-managed` and the renamed file); no log
/// append, no network. (contracts/mv.md B-mv-1..18.)
pub fn runMv(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-mv-12/-13/-14). --help wins over everything (B-mv-12).
    const parsed = switch (cli.parseMvArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "mv", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "mv", .extra_argument, tok),
    };
    if (parsed.help) {
        streams.out.writeAll(mv_help) catch {};
        return .ok;
    }
    // mv takes EXACTLY two operands: a missing one is the exit-2 usage class,
    // stderr-only with the try-help line (B-mv-14).
    const old_arg = parsed.old orelse return mvMissingOperand(streams);
    const new_arg = parsed.new orelse return mvMissingOperand(streams);

    // 2. Discover the checkout root (B-mv-15) — before any tracked/disk check.
    const root = (cli.discoverRoot(arena, io) catch
        return mvFail(streams, "", error.OutOfMemory)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    const cwd_abs = (cli.currentPathAlloc(arena, io) catch
        return mvFail(streams, "", error.OutOfMemory)) orelse
        return mvFail(streams, "", error.Storage);

    // 3. Load the managed set (absent ⇒ empty; B-mv-16 on a bad read, before any
    //    move).
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return mvFail(streams, "", e);
    defer set.deinit();

    // 4. Resolve both operands to normalized repo-relative keys.
    const old_key = switch (mvResolveKey(arena, root.abs, cwd_abs, old_arg) catch
        return mvFail(streams, "", error.OutOfMemory)) {
        .key => |k| k,
        .refuse => |ph| return mvRefuse(streams, old_arg, ph),
    };
    const new_key = switch (mvResolveKey(arena, root.abs, cwd_abs, new_arg) catch
        return mvFail(streams, "", error.OutOfMemory)) {
        .key => |k| k,
        .refuse => |ph| return mvRefuse(streams, new_arg, ph),
    };

    // 5. Reject any illegal rename BEFORE mutating (B-mv-3/-4/-5 plus the
    //    source-exists and dest-parent-exists guards). The gauntlet leaves
    //    stderr set and returns the exit for the first violation; null means
    //    the move cannot fail on an absent source or a missing dest directory.
    if (mvCheckLegal(io, root_dir, &set, old_key, new_key, streams)) |exit| return exit;

    // 6. Commit: move the file, THEN record + persist durably, rolling the move
    //    back if the persist fails (B-mv-2).
    return mvCommit(io, root_dir, &set, old_key, new_key, new_arg, streams);
}

/// The pre-mutation legality gauntlet for a rename. Returns the `.failure` exit
/// (diagnostic already on stderr) for the first violated condition, or null if
/// the rename is legal. Order fixes which message wins when several hold:
/// OLD must be tracked (B-mv-3); NEW must not be tracked (B-mv-4) — also catches
/// NEW==OLD, which is tracked; NEW must not exist on disk (B-mv-5), also catching
/// NEW==OLD on disk; OLD must exist on disk, so the caller's move cannot fail on
/// an absent source (defines the disk-rename failure out of the mutation path).
fn mvCheckLegal(
    io: std.Io,
    root_dir: std.Io.Dir,
    set: *const managed.ManagedSet,
    old_key: []const u8,
    new_key: []const u8,
    streams: cli.Streams,
) ?cli.Exit {
    if (!set.contains(old_key)) {
        streams.err.print("zit mv: '{s}' is not tracked\n", .{old_key}) catch {};
        return .failure;
    }
    if (set.contains(new_key)) {
        streams.err.print("zit mv: '{s}' is already tracked\n", .{new_key}) catch {};
        return .failure;
    }
    if (mvOnDisk(io, root_dir, new_key)) {
        streams.err.print("zit mv: '{s}' already exists on disk\n", .{new_key}) catch {};
        return .failure;
    }
    if (!mvOnDisk(io, root_dir, old_key)) {
        streams.err.print("zit mv: '{s}' is tracked but missing from the working tree\n", .{old_key}) catch {};
        return .failure;
    }
    // The most common way a legal-looking rename fails at the syscall: NEW's
    // parent directory does not exist. `mvOnDisk(new_key)` reads false in that
    // case (stat of a path under a missing directory fails), so without this
    // check the gauntlet passes and the failure only surfaces mid-move. Catch
    // it here and the refusal is a clean zero-state-change one that names the
    // directory the user has to create.
    if (!mvParentOnDisk(io, root_dir, new_key)) {
        streams.err.print(
            "zit mv: cannot move into '{s}': no such directory\n",
            .{mvParentDir(new_key).?},
        ) catch {};
        return .failure;
    }
    return null;
}

/// Performs the rename once `mvCheckLegal` has passed. MOVES THE WORKING FILE
/// FIRST, then swaps the managed entry and persists the set durably (B-mv-2 /
/// B-commit-15: write-temp + fsync + rename-replace). If the persist fails the
/// working file is renamed back, so a failed mv is a zero state change and the
/// gauntlet's refusals are the only way a caller sees "nothing happened".
///
/// Move-first-with-rollback rather than record-first-with-record-rollback: undoing
/// a durable record write needs a second durable write that can itself fail,
/// nesting the failure; renaming a file back cannot realistically fail, since the
/// source directory that existed a moment ago still exists and the source name is
/// still free (nothing else can claim it — mv holds no lock, but the window is a
/// single syscall pair).
///
/// Residual window, stated honestly: a crash BETWEEN the move and the record
/// leaves the file at NEW with the set still naming OLD — `status` then shows
/// OLD missing and NEW as an extra, and `zit add NEW` + `zit rm OLD` recovers.
/// Full crash-atomicity across filesystem + record is working-tree
/// crash-consistency work (0.2 / DST), not this handler's job.
///
/// Emits the one confirmation row to stdout on success; diagnostics stay on
/// stderr (B-mv-17).
fn mvCommit(
    io: std.Io,
    root_dir: std.Io.Dir,
    set: *managed.ManagedSet,
    old_key: []const u8,
    new_key: []const u8,
    new_arg: []const u8,
    streams: cli.Streams,
) cli.Exit {
    root_dir.rename(old_key, root_dir, new_key, io) catch |e|
        return mvRenameFail(streams, old_key, new_key, e);

    if (mvRecord(io, root_dir, set, old_key, new_key, new_arg, streams)) |exit| {
        root_dir.rename(new_key, root_dir, old_key, io) catch |e|
            return mvRollbackFail(streams, old_key, new_key, e);
        return exit;
    }

    streams.out.print("renamed {s} -> {s}\n", .{ old_key, new_key }) catch {};
    return .ok;
}

/// Swaps the entry in memory (`remove` is true — OLD tracked; `insert` is true —
/// NEW untracked; so `recordRename`'s precondition holds) and persists the set
/// durably. Returns null on success, or the exit for the first failure with its
/// diagnostic already on stderr — in which case NOTHING was persisted (the
/// in-memory set is abandoned by the caller) and the caller must undo the move.
fn mvRecord(
    io: std.Io,
    root_dir: std.Io.Dir,
    set: *managed.ManagedSet,
    old_key: []const u8,
    new_key: []const u8,
    new_arg: []const u8,
    streams: cli.Streams,
) ?cli.Exit {
    std.debug.assert(set.remove(old_key));
    const inserted = set.insert(new_key) catch |e| return mvFail(streams, new_arg, e);
    std.debug.assert(inserted);
    // A rename that exactly inverts the pending one DROPS it rather than
    // replacing it. Replacing would leave OLD→NEW→OLD queueing a §4.7.6
    // old-name F card asserting that OLD was formerly NEW — a name no manifest
    // has ever carried — writing a false claim about the past into a permanent
    // artifact. `mv --help` advertises exactly this sequence as the way to undo
    // a rename, so it is the path a user is most likely to take.
    if (mvInvertsPending(set.pendingRename(), old_key, new_key)) {
        set.clearRename();
    } else {
        set.recordRename(old_key, new_key) catch |e| return mvFail(streams, new_arg, e);
    }

    set.write(io, root_dir) catch |e| return mvFail(streams, "", e);
    return null;
}

/// Whether renaming OLD→NEW exactly undoes `pending`, making the pair a no-op.
fn mvInvertsPending(pending: ?managed.Rename, old_key: []const u8, new_key: []const u8) bool {
    const cur = pending orelse return false;
    return std.mem.eql(u8, cur.old, new_key) and std.mem.eql(u8, cur.new, old_key);
}

/// mv takes exactly two operands; fewer is the exit-2 usage class (B-mv-14).
fn mvMissingOperand(s: cli.Streams) cli.Exit {
    s.err.writeAll("zit mv: two operands required: OLD NEW\ntry 'zit mv --help'\n") catch {};
    return .usage;
}

/// Renders an operand refusal (B-mv-3..5 shape): `zit mv: '<arg>' <phrase>`,
/// exit 1 (data-layer failure, distinct from the exit-2 usage class).
fn mvRefuse(s: cli.Streams, arg: []const u8, phrase: []const u8) cli.Exit {
    s.err.print("zit mv: '{s}' {s}\n", .{ arg, phrase }) catch {};
    return .failure;
}

/// The working file could not be moved. Nothing has been recorded yet (the move
/// runs first), so this is a plain refusal and the tree is untouched. SF3 scoped
/// to mv: report the ACTUAL failure rather than the old blanket "input/output
/// error", which rendered ENOENT and EACCES identically and sent users looking
/// for a disk fault. The phrase comes from `reasonOf`, the table `init` and
/// `clean` already render through — no new mapping, so the global errno sweep
/// stays 0.2 while mv stops printing a raw Zig error name at a user.
fn mvRenameFail(
    s: cli.Streams,
    old_key: []const u8,
    new_key: []const u8,
    err: std.Io.Dir.RenameError,
) cli.Exit {
    s.err.print(
        "zit mv: cannot move '{s}' to '{s}': {s}\n",
        .{ old_key, new_key, reasonOf(err) },
    ) catch {};
    return .failure;
}

/// The move succeeded, the record did not, and putting the file back ALSO failed
/// — the one case where mv leaves the working tree changed. Nothing is recorded
/// either way, so the set still names OLD; say exactly where the file is so the
/// user can move it back by hand. Exit 1.
fn mvRollbackFail(
    s: cli.Streams,
    old_key: []const u8,
    new_key: []const u8,
    err: std.Io.Dir.RenameError,
) cli.Exit {
    s.err.print(
        "zit mv: recorded nothing, but '{s}' is now at '{s}' and could not be moved back: {s}\n" ++
            "move it back by hand: mv '{s}' '{s}'\n",
        .{ old_key, new_key, reasonOf(err), new_key, old_key },
    ) catch {};
    return .failure;
}

/// Renders a B-mv-16 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. The read-context wording matches
/// the add/rm parity (`cannot read tracked files: .zit-managed is corrupt`).
fn mvFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.writeAll(
            "zit mv: cannot read tracked files: .zit-managed is corrupt\n",
        ) catch {},
        error.Storage => s.err.writeAll(
            "zit mv: cannot update tracked files: input/output error\n",
        ) catch {},
        error.TooLong => s.err.print("zit mv: '{s}': path is too long to track\n", .{arg}) catch {},
        error.TooMany => s.err.writeAll("zit mv: too many tracked files\n") catch {},
        error.OutOfMemory => s.err.writeAll("zit mv: out of memory\n") catch {},
    }
    return .failure;
}

// ==========================================================================
// `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, resolves the
// current check-in through the SAME `CheckinBaseline` machinery `diff` uses,
// and renders. No logic enters any library module. The repository is opened
// read-only (`logstore.LogStore.openReadOnly`) — no write frame is ever
// appended, and `repostate`/`delta`/`std.http` stay untouched. Two things
// remain honestly deferred (B-status-16, stated in `--help`): files deleted
// from the check-in, and untracked ("extra") files — `status` iterates the
// managed set, so neither is reachable from here.
// ==========================================================================

/// `zit status [-h|--help]` — the thin wiring: parse, discover the checkout
/// root, read the checkout record and the managed set, resolve the current
/// check-in, then one pass comparing the sorted managed set against it.
/// Read-only: a dirty working copy is never a failure (B-status-15) — every
/// `added`/`edited`/`missing` row exits 0. A *comparison* failure is not
/// dirtiness and still exits 1 (an unreadable or oversized tracked file, a
/// corrupt or unresolvable check-in); the whole report is buffered so such a
/// failure leaves stdout empty rather than a header above a truncated report,
/// exactly as `diff` does. Mirrors `runAdd`'s order (parse → discover → load
/// → render).
pub 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. Resolve the current check-in's effective file set — BEFORE any output,
    //    so an unreadable check-in fails cleanly instead of leaving a header on
    //    stdout above a report that never arrives. Unborn means there is no
    //    check-in to compare against, so nothing is opened at all.
    const unborn = ck.version.len == 0;
    var current: CheckinBaseline = undefined;
    if (!unborn) {
        current.open(gpa, io, root_dir, ck.version) catch
            return compareOpenFail(streams, "status", current.failure);
    }
    defer if (!unborn) current.deinit(io);

    // 6. Header (B-status-3/-4/-17), buffered like every row below. An empty
    //    branch field defaults to trunk; an empty version is the unborn state
    //    (no check-ins yet).
    var sink: StatusSink = .{ .gpa = gpa };
    defer sink.report.deinit(gpa);
    const branch = if (ck.branch.len == 0) "trunk" else ck.branch;
    (if (unborn)
        sink.report.print(gpa, "On branch {s} (no check-ins yet)\n", .{branch})
    else
        sink.report.print(gpa, "On branch {s} (check-in {s})\n", .{ branch, ck.version })) catch
        return stateOom(streams, "status");

    // 6b. A pending merge / cherrypick / backout is state the next `commit` will
    //     consume (the P or Q card IS the change), so status must name it: a
    //     merge whose file effects are already materialized otherwise looks
    //     identical to a clean tree, and the user has no way to see that a
    //     second parent is queued (blocker B5). `merge_parent` carries the other
    //     side's hash; `special_op` is the q-sign byte glued onto REV's hash.
    if (ck.merge_parent.len != 0) {
        sink.report.print(gpa, "pending merge with {s}\n", .{ck.merge_parent}) catch
            return stateOom(streams, "status");
    } else if (ck.special_op.len > 1) {
        const label = switch (ck.special_op[0]) {
            '-' => "backout",
            else => "cherrypick", // `merge` only ever writes '+' or '-'
        };
        sink.report.print(gpa, "pending {s} of {s}\n", .{ label, ck.special_op[1..] }) catch
            return stateOom(streams, "status");
    }

    // 7. One pass over the sorted set (B-status-5/-6/-8/-16), compared against
    //    that file set. 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`/`edited`/`missing`
    //    interleave in path order (not grouped by kind). In the unborn state
    //    every present tracked file is `added`.
    const paths = set.iterate();
    walkWorkingTree(
        gpa,
        io,
        root_dir,
        paths,
        if (unborn) null else &current.walk,
        &sink,
    ) catch |err| return compareFail(streams, "status", err);

    // 8. Clean-state line when no change line was emitted (B-status-7).
    if (sink.changes == 0) {
        sink.report.appendSlice(gpa, if (unborn and paths.len == 0)
            "nothing tracked yet\n"
        else
            "nothing to report\n") catch return stateOom(streams, "status");
    }

    // 9. One write, only now that the whole report exists: every row above is
    //    a dirty-working-copy observation, never a failure (B-status-15).
    streams.out.writeAll(sink.report.items) catch {
        streams.err.writeAll("zit status: cannot write output\n") catch {};
        return .failure;
    };
    return .ok;
}

/// `status`'s rendering of a working-tree comparison: one human-readable row per
/// changed tracked file, counted so B-status-7's clean line knows whether any
/// row was printed. Deliberately narrower than `diff`'s report — a file that
/// exists only in the check-in (deleted-from-check-in) is dropped, because
/// `status` reports the tracked set and `status --help` still defers those.
/// Buffered for the same reason `diff` buffers (see the `zit diff` banner): a
/// failure part-way through the walk must not leave a partial report behind.
const StatusSink = struct {
    gpa: std.mem.Allocator,
    report: std.ArrayList(u8) = .empty,
    changes: usize = 0,

    fn emit(self: *StatusSink, entry: WorkingTreeEntry) WalkError!void {
        if (!entry.tracked) return; // deleted-from-check-in: still deferred
        const label = switch (entry.inspection) {
            .same => return, // clean: no row
            .added => "added",
            .modified => "edited",
            // Absent on disk, whether or not the check-in carried it.
            .none, .deleted => "missing",
            .io_error => return error.Storage,
            .oom => return error.OutOfMemory,
            .corrupt => return error.Corrupt,
        };
        const before = self.report.items.len;
        try self.report.print(self.gpa, "{s} {s}\n", .{ label, entry.path });
        std.debug.assert(self.report.items.len > before); // a row is never empty
        self.changes += 1;
    }
};

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

// ==========================================================================
// `zit extras` — list the working-tree files zit is not tracking. Read-only:
// discover the root, read the managed set, sweep the checkout once through the
// `extras.zig` deep module, then render the sorted list. The verb only renders;
// the sweep (and its bounds/ordering) lives in the module (contracts/extras.md).
// ==========================================================================

pub fn runExtras(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-extras-11/-12/-13). --help wins over any trailing token.
    const help = switch (cli.parseNoPositionalArgs(verb_args)) {
        .ok => |a| a.help,
        .unknown_option => |tok| return cli.usageError(streams, "extras", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "extras", .extra_argument, tok),
    };
    if (help) {
        streams.out.writeAll(extras_help) catch {};
        return .ok;
    }

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

    // 3. Load the managed set (B-extras-4/-15); an absent file reads as the
    //    empty set, so a repo with no `zit add` sweeps every on-disk file.
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "extras", e);
    defer set.deinit();

    // 4. Re-open the root ITERABLE (B-extras-18): `discoverRoot`'s handle is
    //    non-iterable, and the sweep needs to enumerate. No-follow so a symlink
    //    at the root is never entered.
    var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{
        .access_sub_paths = true,
        .iterate = true,
        .follow_symlinks = false,
    }) catch return sweepFail(streams, "extras", error.Storage);
    defer iter_root.close(io);

    // 5. Sweep once (B-extras-1..9). The module owns ordering and bounds.
    var result = extras.sweep(gpa, io, iter_root, &set) catch |e|
        return sweepFail(streams, "extras", e);
    defer result.deinit();

    // 6. Render: one root-relative path per line, ascending byte order
    //    (B-extras-2/-3). An empty set prints nothing and still exits 0
    //    (B-extras-10). Streaming writes honor an inherited O_APPEND offset
    //    under `>>` (B-extras-17).
    //    A symlink is an extra like any other: `extras` enumerates, it does not
    //    judge, so the entry's `is_symlink` tag is deliberately ignored here.
    for (result.items()) |e| streams.out.print("{s}\n", .{e.path}) catch {};
    return .ok;
}

/// Renders a shared-sweep failure to stderr and returns `.failure` (exit 1, the
/// data-layer class, distinct from the exit-2 usage class). `verb` names the
/// caller so `extras` and `clean` each prefix their own diagnostics. Exhaustive
/// over the closed `extras.SweepError` set — a new variant fails to compile.
fn sweepFail(s: cli.Streams, verb: []const u8, err: extras.SweepError) cli.Exit {
    switch (err) {
        error.Storage => s.err.print("zit {s}: cannot read the checkout: input/output error\n", .{verb}) catch {},
        error.TooMany => s.err.print("zit {s}: too many files to enumerate\n", .{verb}) catch {},
        error.TooDeep => s.err.print("zit {s}: checkout tree is nested too deeply\n", .{verb}) catch {},
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit clean` — delete the working-tree files zit is not tracking. It consumes
// the SAME `extras.zig` sweep unchanged (B-clean-18) and adds exactly one new
// mechanism: deletion. Tracked and control paths are untouchable BY
// CONSTRUCTION — clean acts only on the set the sweep hands it and performs no
// membership re-check (B-clean-7). clean is the destructive companion to
// `extras` and is NOT sheltered by undo; deletion is final (contracts/clean.md).
// ==========================================================================

/// `zit clean [--dry-run] [--force]` — the thin wiring: parse, discover the
/// checkout root, read the managed set, sweep once through the shared module,
/// then branch. Un-forced is a gate that refuses and deletes nothing
/// (B-clean-1/-2); `--dry-run` previews byte-identically to `zit extras`
/// (B-clean-3/-4); `--force` deletes exactly the swept set (B-clean-5..10).
/// Mirrors `runExtras` through the sweep, then diverges only in step 6.
pub fn runClean(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-clean-12/-13/-14). --help wins over any trailing token and over
    //    --force — help never sweeps and never deletes.
    const args = switch (cli.parseCleanArgs(verb_args)) {
        .ok => |a| a,
        .unknown_option => |tok| return cli.usageError(streams, "clean", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "clean", .extra_argument, tok),
    };
    if (args.help) {
        streams.out.writeAll(clean_help) catch {};
        return .ok;
    }

    // 2. Discover the checkout root (B-clean-15): the repository check precedes
    //    any sweep, regardless of --force/--dry-run. OOM → exit 1.
    const root = (cli.discoverRoot(arena, io) catch
        return stateOom(streams, "clean")) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // 3. Load the managed set (B-clean-16): a tree whose tracked set cannot be
    //    read is a tree clean must not touch — fail cleanly BEFORE any deletion.
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "clean", e);
    defer set.deinit();

    // 4. Re-open the root ITERABLE (B-clean-18), exactly as extras: the sweep
    //    enumerates and the deletion pass unlinks through this same handle.
    var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{
        .access_sub_paths = true,
        .iterate = true,
        .follow_symlinks = false,
    }) catch return sweepFail(streams, "clean", error.Storage);
    defer iter_root.close(io);

    // 5. Sweep once through the SHARED module (B-clean-18). The extras set is
    //    clean's SOLE path source — no re-walk, no re-derivation.
    var result = extras.sweep(gpa, io, iter_root, &set) catch |e|
        return sweepFail(streams, "clean", e);
    defer result.deinit();

    // 6. Branch. Order encodes the B-clean-11 precedence: --dry-run wins, so it is
    //    checked before --force and can never be upgraded into a real deletion.
    if (args.dry_run) {
        // Preview byte-identically to `zit extras` (B-clean-3/-4): one
        // root-relative path per line, ascending, deleting nothing. Like
        // `extras`, clean is kind-blind: a symlink is a deletable extra, so the
        // entry's `is_symlink` tag is ignored (only add/addremove filter on it).
        for (result.items()) |e| streams.out.print("{s}\n", .{e.path}) catch {};
        return .ok;
    }
    if (!args.force) {
        // The gate (B-clean-1/-2/-8): an empty set is a silent success no-op with
        // nothing to guard; otherwise refuse without deleting, naming the count.
        if (result.items().len == 0) return .ok;
        return cleanRefuse(streams, result.items().len);
    }
    // --force: delete exactly the swept set (B-clean-5..10).
    return cleanForce(streams, io, iter_root, &result);
}

/// Renders the B-clean-1 two-line refusal to stderr and returns `.failure`
/// (exit 1). The count is `> 0` by construction — the caller handles the empty
/// set as a silent success before reaching here (B-clean-8). Non-interactive:
/// no prompt is ever issued, stdin is never read (B-clean-2).
fn cleanRefuse(s: cli.Streams, count: usize) cli.Exit {
    std.debug.assert(count > 0);
    const noun: []const u8 = if (count == 1) "file" else "files";
    s.err.print(
        "zit clean: {d} untracked {s} would be deleted; refusing without --force\n",
        .{ count, noun },
    ) catch {};
    s.err.writeAll(
        "run 'zit clean --dry-run' to preview, or 'zit clean --force' to delete\n",
    ) catch {};
    return .failure;
}

/// Deletes exactly the swept extras set (B-clean-5) through `root` — the same
/// ITERABLE handle the sweep enumerated, so no path outside that set is ever
/// reachable and tracked/control files stay untouched BY CONSTRUCTION
/// (B-clean-7: no membership re-check). A failed unlink is reported to stderr
/// and the sweep CONTINUES its siblings (B-clean-9); the run then exits 1
/// (B-clean-10). An already-absent path satisfies the postcondition and counts
/// as success (idempotent, B-clean-6). Full success is silent on stdout and
/// exits 0 (cli-stream-separation).
fn cleanForce(s: cli.Streams, io: std.Io, root: std.Io.Dir, result: *const extras.Extras) cli.Exit {
    var failed = false;
    // Bound = the finite swept count (B-clean-18): no re-walk, no recursion.
    for (result.items()) |entry| {
        const path = entry.path;
        std.debug.assert(path.len > 0); // shape invariant, NOT a membership re-check
        root.deleteFile(io, path) catch |e| switch (e) {
            error.FileNotFound => {}, // already gone ⇒ postcondition holds (B-clean-6)
            else => {
                failed = true;
                s.err.print(
                    "zit clean: '{s}': could not delete: {s}\n",
                    .{ path, reasonOf(e) },
                ) catch {};
            },
        };
    }
    return if (failed) .failure else .ok;
}

// ==========================================================================
// `zit addremove` — reconcile the tracked set with the working tree in ONE
// pass: every untracked file the extras sweep reports is ADDED, every tracked
// file missing from disk is REMOVED (fossil/hg parity; git spells it `add -A`).
// A composite of the shipped add/rm state transitions over the ONE shipped
// `extras.zig` sweep — it adds NO second tree walker and NO ad-hoc managed-set
// mutation. It mutates `.zit-managed` only (never file contents, never the
// repository); the next `zit commit` records the result. Not journaled — the
// inverse is re-running add/rm (the B-clean posture). (contracts/addremove.md
// B-addremove-1..13.)
// ==========================================================================

/// `zit addremove [--dry-run]` — the thin wiring: parse, discover the checkout
/// root, read the managed set, sweep once through the SHARED `extras.zig` module
/// (the ADD set), derive the REMOVE set as the tracked entries absent from disk,
/// then apply both to the managed set in ONE atomic write. Mirrors `runClean`
/// through the sweep, then diverges: instead of deleting the swept set it folds
/// it into the tracked set, plus the missing-managed removals. The report is
/// WITHHELD until the write is durable (B-addremove-7/-12), so a failed write
/// claims no transition. Per-path add failures report and continue, exiting 1
/// (B-addremove-6). `--dry-run` computes the same report and leaves `.zit-managed`
/// byte-unchanged (B-addremove-4).
pub fn runAddremove(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-addremove-9/-10). --help wins over any trailing token.
    const args = switch (cli.parseAddremoveArgs(verb_args)) {
        .ok => |a| a,
        .unknown_option => |tok| return cli.usageError(streams, "addremove", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "addremove", .extra_argument, tok),
    };
    if (args.help) {
        streams.out.writeAll(addremove_help) catch {};
        return .ok;
    }

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

    // 3. Load the current set; an absent file reads as the empty set, so a repo
    //    with no `zit add` tracks every on-disk file in one addremove.
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return managedFail(streams, "addremove", e);
    defer set.deinit();

    // 4. Re-open the root ITERABLE (B-addremove-2), exactly as extras/clean: the
    //    sweep enumerates through this handle. No-follow so a root symlink is
    //    never entered.
    var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{
        .access_sub_paths = true,
        .iterate = true,
        .follow_symlinks = false,
    }) catch return sweepFail(streams, "addremove", error.Storage);
    defer iter_root.close(io);

    // 5. Sweep once through the SHARED module (B-addremove-2). The extras set IS
    //    the add set — same sweep, same exclusions, same symlink posture. A
    //    second walker is a refutation.
    var extra_paths = extras.sweep(gpa, io, iter_root, &set) catch |e|
        return sweepFail(streams, "addremove", e);
    defer extra_paths.deinit();

    // 6. Derive the REMOVE set: the tracked entries whose working-tree file is
    //    absent (B-addremove-3). A no-follow stat that succeeds means present —
    //    a modified or replaced file is NEVER removed (content is irrelevant to
    //    membership). Keys are copied into the arena BEFORE any mutation, because
    //    `set.iterate()`'s slice (and each key's backing bytes) are invalidated
    //    by the removals below. Ascending order is preserved (iterate is sorted).
    var to_remove: std.ArrayList([]const u8) = .empty;
    for (set.iterate()) |key| {
        const present = blk: {
            _ = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch break :blk false;
            break :blk true;
        };
        if (!present) {
            const owned = arena.dupe(u8, key) catch return stateOom(streams, "addremove");
            to_remove.append(arena, owned) catch return stateOom(streams, "addremove");
        }
    }

    // 7. Apply both verdicts to the tracked set. Adds first (each swept path is
    //    untracked, so `insert` returns true unless it fails a bound — a failure
    //    reports and continues, B-addremove-6), then the missing-managed removals.
    //    The report lines are collected, not printed, so step 9 can withhold them
    //    until the write is durable. `added` borrows `extra_paths` (owned until
    //    this function returns); `to_remove` is arena-owned.
    var failed = false;
    var changed = false;
    var added: std.ArrayList([]const u8) = .empty;
    for (extra_paths.items()) |entry| {
        // 0.1.0 does not version symlinks (F-B3). addremove withholds them
        // SILENTLY: its report is a two-channel ADDED/REMOVED ledger of state
        // transitions, and a skipped symlink is a non-transition — `zit extras`
        // is the verb that shows it is still there. `add`, whose contract is
        // per-argument feedback, does print the skip.
        if (entry.is_symlink) continue;
        const path = entry.path;
        const inserted = set.insert(path) catch |e| {
            failed = true;
            _ = addremoveFail(streams, path, e);
            continue;
        };
        if (inserted) {
            changed = true;
            added.append(arena, path) catch return stateOom(streams, "addremove");
        }
    }
    for (to_remove.items) |key| {
        if (set.remove(key)) changed = true;
    }

    // 8. Persist once, atomically, only if the set changed and this is not a dry
    //    run (B-addremove-4/-7). A write failure leaves the prior file wholly
    //    intact (ManagedSet.write is write-temp → fsync → rename) and returns
    //    before any report line prints, so nothing below claims a transition.
    if (changed and !args.dry_run) {
        set.write(io, root_dir) catch |e| return addremoveFail(streams, "", e);
    }

    // 9. Report — now that the change is durable (or, in a dry run, truthfully
    //    previewed and never persisted): the ADDED lines then the REMOVED lines,
    //    each sorted ascending, root-relative (B-addremove-1). Dry-run output
    //    equals the real run's report on the same tree (B-addremove-4).
    for (added.items) |path| streams.out.print("ADDED {s}\n", .{path}) catch {};
    for (to_remove.items) |key| streams.out.print("REMOVED {s}\n", .{key}) catch {};

    // 10. Fold outcomes into one code (B-addremove-6): valid transitions persist
    //     even when a sibling path failed to track (partial success).
    return if (failed) .failure else .ok;
}

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

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,
    /// Non-null when the read-path shim normalized a legacy trailing-separator
    /// F card; the manifest then borrows this copy instead of `bytes`.
    normalized: ?[]u8 = null,
    failure: Failure = .unresolved,

    fn deinit(self: *DiffBaselineResolver) void {
        if (self.manifest) |*m| m.deinit();
        if (self.normalized) |b| self.gpa.free(b);
        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;
        }

        // Read path: tolerate the legacy F-card trailing separator in
        // fossil-origin history when resolving a diff baseline.
        const im = artifact.parseForImport(self.gpa, bytes) catch |err| {
            self.failure = if (err == error.OutOfMemory) .oom else .corrupt;
            return null;
        };
        self.manifest = im.manifest;
        self.normalized = im.normalized;
        if (self.manifest.?.kind != .manifest) return null;
        // Same §5.1/§5.2 resolution the current check-in gets in
        // `CheckinBaseline.open`. A delta manifest's BASELINE can itself be
        // S-bearing, and a baseline whose file set was read as a bare `.f` run
        // would under-report exactly the paths the tree carries.
        if (self.manifest.?.s.len != 0) {
            Repository.flattenTreeFromLog(self.gpa, self.log, &self.manifest.?) catch |err| {
                self.failure = if (err == error.OutOfMemory) .oom else .corrupt;
                return null;
            };
        }
        return .{
            .baseline_hash = if (self.manifest.?.b) |b| b.hash else null,
            .files = self.manifest.?.flattened orelse 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 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;
    std.debug.assert(st.size <= max_tracked_file_bytes); // the @intCast below is sound
    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;
}

/// The failure modes of resolving the current check-in into a comparable file
/// set. Shared by every verb that compares the working tree against the
/// check-in (`diff`, `status`), so one mapping serves all of them; the verb is
/// a parameter exactly as in `checkoutFail`/`managedFail`.
fn compareLogFail(s: cli.Streams, verb: []const u8, err: logstore.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => return stateOom(s, verb),
        error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {},
        error.Storage => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {},
    }
    return .failure;
}

fn compareCurrentCorrupt(s: cli.Streams, verb: []const u8) cli.Exit {
    s.err.print("zit {s}: current check-in is not a valid manifest\n", .{verb}) catch {};
    return .failure;
}

fn compareBaselineFail(s: cli.Streams, verb: []const u8, failure: DiffBaselineResolver.Failure) cli.Exit {
    switch (failure) {
        .oom => return stateOom(s, verb),
        .storage => return compareLogFail(s, verb, error.Storage),
        .corrupt => return compareLogFail(s, verb, error.Corrupt),
        .unresolved => s.err.print("zit {s}: cannot resolve baseline\n", .{verb}) catch {},
    }
    return .failure;
}

fn compareFail(s: cli.Streams, verb: []const u8, err: error{ OutOfMemory, Storage, Corrupt }) cli.Exit {
    switch (err) {
        error.OutOfMemory => return stateOom(s, verb),
        error.Corrupt => s.err.print("zit {s}: cannot compare: repository is corrupt\n", .{verb}) catch {},
        error.Storage => s.err.print("zit {s}: cannot compare working tree: input/output error\n", .{verb}) catch {},
    }
    return .failure;
}

/// Maps every `CheckinBaseline.open` failure onto the verb's diagnostic. The
/// open path is shared, so its rendering is too.
fn compareOpenFail(s: cli.Streams, verb: []const u8, failure: CheckinBaseline.OpenFailure) cli.Exit {
    return switch (failure) {
        .log => |err| compareLogFail(s, verb, err),
        .current_corrupt => compareCurrentCorrupt(s, verb),
        .baseline => |b| compareBaselineFail(s, verb, b),
    };
}

/// The current check-in resolved into a merge walk over its *effective* file
/// set — the one thing every working-tree comparison needs and the one place
/// the delta-manifest walk, the legacy trailing-separator F-card tolerance and
/// the corrupt/unresolvable mapping are written down.
///
/// Self-referential by construction: `log` reads through `fs`, the resolver
/// borrows `log`, and the walk borrows the resolver. It is therefore
/// initialized in place through `open` into caller-owned storage and must not
/// be copied or moved afterwards.
const CheckinBaseline = struct {
    const OpenFailure = union(enum) {
        log: logstore.OpenError,
        current_corrupt,
        baseline: DiffBaselineResolver.Failure,
    };
    /// `open`'s single error; the discriminated reason lands in `failure`. Zig
    /// error sets carry no payload, so the detail travels beside the error —
    /// the same shape `DiffBaselineResolver` already uses for its resolve hook.
    const OpenError = error{OpenFailed};

    gpa: std.mem.Allocator,
    repo_file: File,
    fs: storage.FileStorage,
    log: logstore.LogStore,
    current_bytes: []u8,
    current: artifact.ImportedManifest,
    resolver: DiffBaselineResolver,
    walk: artifact.MergeWalk,
    /// Valid exactly when `open` returned `error.OpenFailed`.
    failure: OpenFailure,
    /// True exactly between a successful `open` and `deinit`. The open/deinit
    /// contract below used to rest entirely on each call site placing its
    /// `defer deinit` after the `open ... catch return`; hoisting that defer,
    /// or slipping an early return between the two, was silent UB. This makes
    /// the invariant checkable: `deinit` asserts it, and `fail` asserts the
    /// mirror (nothing is left open on the failure path).
    opened: bool,

    fn fail(self: *CheckinBaseline, reason: OpenFailure) OpenError {
        std.debug.assert(!self.opened);
        self.failure = reason;
        return error.OpenFailed;
    }

    /// Opens the repository read-only and resolves `version` (a born check-in's
    /// full artifact name) into a walk. On failure nothing is left open and
    /// `deinit` must NOT be called; read `failure` for the reason.
    fn open(
        self: *CheckinBaseline,
        gpa: std.mem.Allocator,
        io: std.Io,
        root_dir: std.Io.Dir,
        version: []const u8,
    ) OpenError!void {
        // First, before any fallible step: every `fail` below asserts it, and
        // it must read false if `open` returns an error.
        self.opened = false;
        std.debug.assert(version.len > 0); // callers screen the unborn state
        self.gpa = gpa;
        self.repo_file = root_dir.openFile(io, repo_filename, .{}) catch
            return self.fail(.{ .log = error.Storage });
        errdefer self.repo_file.close(io);
        const repo_stat = self.repo_file.stat(io) catch
            return self.fail(.{ .log = error.Storage });
        self.fs = storage.FileStorage.init(io, self.repo_file, repo_stat.size);
        self.log = logstore.LogStore.openReadOnly(gpa, self.fs.storageFile()) catch |err|
            return self.fail(.{ .log = err });
        errdefer self.log.deinit(gpa);

        var digest_buf: [logstore.max_hash_len]u8 = undefined;
        const digest = decodeDiffId(version, &digest_buf) orelse return self.fail(.current_corrupt);
        const rid = self.log.lookup(digest) orelse return self.fail(.current_corrupt);
        self.current_bytes = (self.log.content(gpa, rid) catch |err|
            return self.fail(.{ .log = err })) orelse return self.fail(.current_corrupt);
        errdefer gpa.free(self.current_bytes);
        if (!artifact.verify(version, self.current_bytes)) return self.fail(.current_corrupt);

        // Read path: tolerate the legacy F-card trailing separator when reading
        // the current check-in (fossil-origin history).
        self.current = artifact.parseForImport(gpa, self.current_bytes) catch |err| switch (err) {
            error.OutOfMemory => return self.fail(.{ .baseline = .oom }),
            else => return self.fail(.current_corrupt),
        };
        errdefer self.current.deinit(gpa);
        if (self.current.manifest.kind != .manifest) return self.fail(.current_corrupt);

        // draft-fossil-submanifests-00 §5.1/§5.2 — an S-bearing check-in carries
        // its file set in sub-manifests, not in its own F run, so `f` alone is
        // NOT the file set. Resolving it is the same step `commit` performs
        // before carry-forward; skipping it here made every tracked file look
        // absent from the current check-in, and `status`/`diff` reported all of
        // them as `added` on any tree-shaped repository -- identical output
        // whether or not anything had changed (issue #25).
        //
        // §5.1's "downstream MUST NOT distinguish" is exactly this: every reader
        // of a file set has to go through the derivation seam, and a raw `.f`
        // read is how a reader silently opts out of it.
        if (self.current.manifest.s.len != 0) {
            Repository.flattenTreeFromLog(gpa, &self.log, &self.current.manifest) catch |err| switch (err) {
                error.OutOfMemory => return self.fail(.{ .baseline = .oom }),
                else => return self.fail(.current_corrupt),
            };
        }

        self.resolver = .{ .gpa = gpa, .log = &self.log };
        errdefer self.resolver.deinit();
        self.walk = artifact.enumerate(.{
            .baseline_hash = if (self.current.manifest.b) |b| b.hash else null,
            .files = self.current.manifest.flattened orelse self.current.manifest.f,
        }, .{
            .ctx = &self.resolver,
            .resolveFn = DiffBaselineResolver.resolve,
        }) catch |err| switch (err) {
            error.BaselineUnresolvable => return self.fail(.{ .baseline = self.resolver.failure }),
            error.BaselineIsDelta => return self.fail(.{ .baseline = .unresolved }),
        };
        self.opened = true; // last: every fallible step above is behind us
    }

    fn deinit(self: *CheckinBaseline, io: std.Io) void {
        std.debug.assert(self.opened); // never call after a failed `open`
        self.resolver.deinit();
        self.current.deinit(self.gpa);
        self.gpa.free(self.current_bytes);
        self.log.deinit(self.gpa);
        self.repo_file.close(io);
        self.* = undefined;
    }
};

/// One entry of a working-tree comparison, as `walkWorkingTree` reports it.
const WorkingTreeEntry = struct {
    path: []const u8,
    inspection: DiffInspection,
    /// False when the path exists only in the check-in and is no longer tracked
    /// — a `diff` reports those as deleted; `status` iterates only the managed
    /// set, so it drops them (still deferred, and `status --help` says so).
    tracked: bool,
};

const WalkError = error{ OutOfMemory, Storage, Corrupt };

/// Walks the sorted managed set against the check-in's effective file set,
/// classifying every path exactly once and emitting it in path order. `walk`
/// is null in the unborn state, where every tracked path is compared against
/// no baseline. This is the whole comparison; a verb supplies only `sink`, a
/// pointer to a struct with `emit(entry) WalkError!void`, which decides how a
/// classification is rendered. `sink` is comptime-generic rather than an
/// erased `*anyopaque` + fn pointer: both verbs' sinks are known at compile
/// time, so the type stays checked and the call stays direct.
fn walkWorkingTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    paths: []const []const u8,
    walk: ?*artifact.MergeWalk,
    sink: anytype,
) WalkError!void {
    // The merge below is only correct on a sorted managed set (`ManagedSet`
    // keeps it sorted; §8.1-p1 gives the same for the F-card side). One pass,
    // debug-only, against per-file stat+read cost.
    if (std.debug.runtime_safety) {
        for (paths[0..paths.len -| 1], 1..) |p, i| {
            std.debug.assert(std.mem.order(u8, p, paths[i]) == .lt);
        }
    }

    const w = walk orelse {
        for (paths) |path| {
            try sink.emit(.{
                .path = path,
                .inspection = inspectDiffFile(gpa, io, root_dir, path, null),
                .tracked = true,
            });
        }
        return;
    };

    // Termination guard, the house pattern from `artifact.MergeWalk.next`
    // (src/artifact.zig): every arm of the switch below advances exactly one
    // cursor — `path_index` over the managed set, or `baseline` over the
    // effective file set — and the loop exits only once both are exhausted.
    // Nothing else enforces that, so a step counter against the exact bound
    // (one iteration per managed path, one per effective file, plus the
    // exit test) fails loudly instead of spinning if an arm ever stops
    // advancing. `w.baseline.len + w.delta.len` bounds the effective set:
    // the merge emits at most one card per input card.
    const bound = paths.len + w.baseline.len + w.delta.len + 1;
    var steps: usize = 0;

    var path_index: usize = 0;
    var baseline = nextEffectiveFile(w);
    while (path_index < paths.len or baseline != null) {
        std.debug.assert(steps <= bound);
        steps += 1;
        std.debug.assert(path_index < paths.len or baseline != null);

        const path: ?[]const u8 = if (path_index < paths.len) paths[path_index] else null;
        const order: std.math.Order = if (baseline == null)
            .lt // only tracked paths remain
        else if (path == null)
            .gt // only baseline files remain
        else
            std.mem.order(u8, path.?, baseline.?.filename);

        const before_index = path_index;
        switch (order) {
            .lt => {
                try sink.emit(.{
                    .path = path.?,
                    .inspection = inspectDiffFile(gpa, io, root_dir, path.?, null),
                    .tracked = true,
                });
                path_index += 1;
            },
            .eq => {
                try sink.emit(.{
                    .path = path.?,
                    .inspection = inspectDiffFile(gpa, io, root_dir, path.?, baseline.?),
                    .tracked = true,
                });
                path_index += 1;
                baseline = nextEffectiveFile(w);
            },
            .gt => {
                try sink.emit(.{
                    .path = baseline.?.filename,
                    .inspection = .deleted,
                    .tracked = false,
                });
                baseline = nextEffectiveFile(w);
            },
        }
        // Progress: the managed cursor moved, or the baseline one did.
        std.debug.assert(path_index > before_index or order == .gt);
        std.debug.assert(path_index <= paths.len);
    }
}

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

pub 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 sink: DiffSink = .{ .gpa = gpa };
    defer sink.report.deinit(gpa);
    const paths = set.iterate();

    if (ck.version.len == 0) {
        walkWorkingTree(gpa, io, root_dir, paths, null, &sink) catch |err|
            return compareFail(streams, "diff", err);
        return writeDiffOutput(streams, sink.report.items);
    }

    var baseline: CheckinBaseline = undefined;
    baseline.open(gpa, io, root_dir, ck.version) catch
        return compareOpenFail(streams, "diff", baseline.failure);
    defer baseline.deinit(io);

    walkWorkingTree(gpa, io, root_dir, paths, &baseline.walk, &sink) catch |err|
        return compareFail(streams, "diff", err);
    return writeDiffOutput(streams, sink.report.items);
}

/// `diff`'s rendering of a working-tree comparison: the compact A/M/D record
/// report, with §4.3 escapes on every filename.
const DiffSink = struct {
    gpa: std.mem.Allocator,
    report: std.ArrayList(u8) = .empty,

    fn emit(self: *DiffSink, entry: WorkingTreeEntry) WalkError!void {
        try appendDiffInspection(self.gpa, &self.report, entry.path, entry.inspection);
    }
};

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

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 "compare: compareLogFail maps the contract messages and exit 1, exhaustively over all 3 logstore.OpenError variants and both verbs" {
    const Case = struct { err: logstore.OpenError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "out of memory\n" },
        .{ .err = error.Corrupt, .want = "cannot read repository: repository is corrupt\n" },
        .{ .err = error.Storage, .want = "cannot read repository: input/output error\n" },
    };
    for ([_][]const u8{ "diff", "status" }) |verb| {
        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, compareLogFail(s, verb, c.err));
            var want_buf: [128]u8 = undefined;
            const want = try std.fmt.bufPrint(&want_buf, "zit {s}: {s}", .{ verb, c.want });
            try std.testing.expectEqualStrings(want, w.buffered());
        }
    }
}

test "compare: compareFail maps the contract messages and exit 1, exhaustively over all 3 comparison-error variants and both verbs" {
    const Case = struct { err: WalkError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "out of memory\n" },
        .{ .err = error.Corrupt, .want = "cannot compare: repository is corrupt\n" },
        .{ .err = error.Storage, .want = "cannot compare working tree: input/output error\n" },
    };
    for ([_][]const u8{ "diff", "status" }) |verb| {
        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, compareFail(s, verb, c.err));
            var want_buf: [128]u8 = undefined;
            const want = try std.fmt.bufPrint(&want_buf, "zit {s}: {s}", .{ verb, c.want });
            try std.testing.expectEqualStrings(want, w.buffered());
        }
    }
}

test "status: StatusSink maps every DiffInspection to its row, exhaustively over all 8 variants" {
    const Case = struct { inspection: DiffInspection, want: []const u8 };
    // Every variant, tracked. `.same` is clean and prints nothing; the three
    // failure variants escape as errors rather than as a row.
    const cases = [_]Case{
        .{ .inspection = .none, .want = "missing a\n" },
        .{ .inspection = .added, .want = "added a\n" },
        .{ .inspection = .same, .want = "" },
        .{ .inspection = .modified, .want = "edited a\n" },
        .{ .inspection = .deleted, .want = "missing a\n" },
    };
    const gpa = std.testing.allocator;
    for (cases) |c| {
        var sink: StatusSink = .{ .gpa = gpa };
        defer sink.report.deinit(gpa);
        try sink.emit(.{ .path = "a", .inspection = c.inspection, .tracked = true });
        try std.testing.expectEqualStrings(c.want, sink.report.items);
        try std.testing.expectEqual(@as(usize, if (c.want.len == 0) 0 else 1), sink.changes);
    }

    // The three failure variants escape as errors AND leave the buffer clean:
    // a partial report is exactly what buffering exists to prevent.
    const failures = [_]struct { inspection: DiffInspection, want: WalkError }{
        .{ .inspection = .io_error, .want = error.Storage },
        .{ .inspection = .oom, .want = error.OutOfMemory },
        .{ .inspection = .corrupt, .want = error.Corrupt },
    };
    for (failures) |f| {
        var sink: StatusSink = .{ .gpa = gpa };
        defer sink.report.deinit(gpa);
        try std.testing.expectError(
            f.want,
            sink.emit(.{ .path = "a", .inspection = f.inspection, .tracked = true }),
        );
        try std.testing.expectEqualStrings("", sink.report.items);
        try std.testing.expectEqual(@as(usize, 0), sink.changes);
    }

    // An untracked check-in-only path is dropped whatever its classification:
    // deleted-from-check-in reporting is still deferred (B-status-16).
    inline for (comptime std.meta.tags(DiffInspection)) |tag| {
        var sink: StatusSink = .{ .gpa = gpa };
        defer sink.report.deinit(gpa);
        try sink.emit(.{ .path = "a", .inspection = tag, .tracked = false });
        try std.testing.expectEqualStrings("", sink.report.items);
        try std.testing.expectEqual(@as(usize, 0), sink.changes);
    }
}

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