Skip to content
zit Artifact [5612c946a2]
Login

Artifact [5612c946a2]

Artifact 5612c946a289108760801dc6be7f7c8f46bc95f84553fedec64c55707bd893fd:


//! End-to-end tests for the `zit` CLI: they run the REAL compiled binary via
//! `std.process.Child`, with a pinned, isolated environment (TZ=UTC, an
//! isolated HOME, cwd bound to a fresh `std.testing.tmpDir`), assert the exit
//! code and the exact/structural stdout+stderr, then verify the resulting
//! `.zit`/`.zit-checkout` state READ BACK THROUGH THE LIBRARY. Teardown is
//! unconditional (`defer tmp.cleanup()`).
//!
//! Timestamps and hashes are never asserted against a wall clock: the success
//! line is checked structurally (its trailing token is an absolute `.zit`
//! path), and repository state is decoded with the library's own reader.
//!
//! Built by `zig build e2e`, which compiles `zit` first and passes its path in
//! via `build_options.zit_exe`. Behavior ids cite contracts/init.md.

const std = @import("std");
const build_options = @import("build_options");

// The storage-layer readers used to decode repository state. Reached by
// relative import (they depend on the `zit` module rather than belonging to
// it); the `zit` dependency the build declares for this module is what lets
// their internal `@import("zit")` resolve.
const logstore = @import("logstore.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
// The managed-path set is read back through the library to verify `add`'s
// on-disk effect (contracts/add.md); re-exported by the `zit` module.
const managed = @import("zit").managed;
// The artifact reader used to decode a minted check-in back through the library
// (the state oracle for the `commit` e2e — contracts/commit.md).
const artifact = @import("zit").artifact;
const File = std.Io.File;

/// Absolute path to the freshly built `zit` executable (a build-time
/// `LazyPath`, so the exe is always rebuilt before these tests run).
const zit_exe: []const u8 = build_options.zit_exe;

const success_prefix = "Initialized empty zit repository in ";

// --- helpers --------------------------------------------------------------

/// Spawns `zit init <tail...>` with a pinned environment and cwd bound to
/// `tmp_dir`. Caller owns `result.stdout` / `result.stderr`.
fn spawnInit(
    gpa: std.mem.Allocator,
    io: std.Io,
    tmp_dir: std.Io.Dir,
    tail: []const []const u8,
) !std.process.RunResult {
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");

    // `zit_exe` is relative to the build root (the test's cwd); the child
    // chdir's into `tmp_dir` before exec, so resolve it to an absolute path
    // first. `resolve` returns it unchanged if it is already absolute.
    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
    defer gpa.free(abs_exe);

    var argv_buf: [8][]const u8 = undefined;
    argv_buf[0] = abs_exe;
    argv_buf[1] = "init";
    std.debug.assert(tail.len <= argv_buf.len - 2);
    for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
    const argv = argv_buf[0 .. 2 + tail.len];

    return std.process.run(gpa, io, .{
        .argv = argv,
        .cwd = .{ .dir = tmp_dir },
        .environ_map = &env,
    });
}

fn freeResult(gpa: std.mem.Allocator, res: *std.process.RunResult) void {
    gpa.free(res.stdout);
    gpa.free(res.stderr);
}

fn expectExit(term: std.process.Child.Term, code: u8) !void {
    switch (term) {
        .exited => |c| try std.testing.expectEqual(code, c),
        else => {
            std.debug.print("expected exit {d}, got {any}\n", .{ code, term });
            return error.TestUnexpectedTerm;
        },
    }
}

/// Asserts, through the library, that `dir` holds a freshly-initialized repo:
/// a `.zit` of exactly the header length (zero frames) and a `.zit-checkout`
/// whose trunk is unborn (`version == ""`, `branch == "trunk"`). Returns the
/// `.zit` bytes (caller frees) so callers can compare byte-identity.
fn expectFreshRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    const bytes = try dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
    errdefer gpa.free(bytes);
    try std.testing.expectEqual(@as(usize, logstore.header_len), bytes.len);

    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, dir, &ckbuf)).?;
    try std.testing.expectEqualStrings("", ck.version);
    try std.testing.expectEqualStrings("trunk", ck.branch);
    return bytes;
}

fn fileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
    var f = dir.openFile(io, name, .{}) catch return false;
    f.close(io);
    return true;
}

// --- add helpers ----------------------------------------------------------

/// Spawns `zit <tail...>` with a pinned environment and cwd bound to `tmp_dir`.
/// The generic sibling of `spawnInit` — any verb. Caller owns `result.stdout` /
/// `result.stderr`.
fn runZit(
    gpa: std.mem.Allocator,
    io: std.Io,
    tmp_dir: std.Io.Dir,
    tail: []const []const u8,
) !std.process.RunResult {
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");

    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
    defer gpa.free(abs_exe);

    var argv_buf: [16][]const u8 = undefined;
    argv_buf[0] = abs_exe;
    std.debug.assert(tail.len <= argv_buf.len - 1);
    for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;
    const argv = argv_buf[0 .. 1 + tail.len];

    return std.process.run(gpa, io, .{
        .argv = argv,
        .cwd = .{ .dir = tmp_dir },
        .environ_map = &env,
    });
}

/// Materializes a fresh repository in `dir` by running the real `zit init`
/// (exit 0 asserted) — the same path a user takes before `add`.
fn initRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void {
    var res = try runZit(gpa, io, dir, &.{"init"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
}

/// Creates an empty regular file `name` under `dir`.
fn touch(io: std.Io, dir: std.Io.Dir, name: []const u8) !void {
    var f = try dir.createFile(io, name, .{});
    f.close(io);
}

/// Reads `.zit-managed` back through the library and asserts its membership is
/// exactly `expected` (order-independent — the set is sorted internally). This
/// readback is the on-disk oracle for every `add` e2e.
fn expectManaged(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    expected: []const []const u8,
) !void {
    var set = try managed.ManagedSet.read(gpa, io, dir);
    defer set.deinit();
    try std.testing.expectEqual(expected.len, set.iterate().len);
    for (expected) |key| try std.testing.expect(set.contains(key));
}

/// Marks `paths` managed by running the real `zit add` (exit 0 asserted) — the
/// same path a user takes to set up state before exercising `rm`.
fn addManaged(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    paths: []const []const u8,
) !void {
    var argv_buf: [16][]const u8 = undefined;
    argv_buf[0] = "add";
    std.debug.assert(paths.len <= argv_buf.len - 1);
    for (paths, argv_buf[1..][0..paths.len]) |src, *dst| dst.* = src;
    var res = try runZit(gpa, io, dir, argv_buf[0 .. 1 + paths.len]);
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
}

/// Runs `/bin/sh -c <script>` under the same pinned, isolated environment as
/// `runZit`, with cwd bound to `tmp_dir` and the absolute `zit` path exposed as
/// `$ZIT`. Driving the binary through a real shell is the only faithful way to
/// exercise the output-composition guarantees (B-rm-14): `>> logfile` (O_APPEND)
/// and grouped/shared-fd redirections (`{ …; } > log`) inherit an OS file offset
/// that a process's stdout must honor. `std.process.run`'s captured pipe is not
/// seekable and so cannot reproduce these; the shell can. Caller owns the
/// returned `stdout`/`stderr`.
fn runShell(
    gpa: std.mem.Allocator,
    io: std.Io,
    tmp_dir: std.Io.Dir,
    script: []const u8,
) !std.process.RunResult {
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");

    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
    defer gpa.free(abs_exe);
    try env.put("ZIT", abs_exe);

    return std.process.run(gpa, io, .{
        .argv = &.{ "/bin/sh", "-c", script },
        .cwd = .{ .dir = tmp_dir },
        .environ_map = &env,
    });
}

/// Reads `name` under `dir` fully and returns its bytes (caller frees). Used by
/// the composition tests to read back a redirected log file.
fn readFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8) ![]u8 {
    return dir.readFileAlloc(io, name, gpa, .limited(1 << 16));
}

// --- tests ----------------------------------------------------------------

test "cli init B-init-2/-3/-4: happy path writes the header, unborn trunk, prints the absolute path" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try spawnInit(gpa, io, tmp.dir, &.{});
    defer freeResult(gpa, &res);

    try expectExit(res.term, 0);
    // stdout: the success line; its last whitespace-delimited token is an
    // absolute `.zit` path. Structural — no wall-clock, no hash asserted.
    try std.testing.expect(std.mem.startsWith(u8, res.stdout, success_prefix));
    try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/.zit\n"));
    const path = res.stdout[success_prefix.len .. res.stdout.len - 1];
    try std.testing.expect(std.fs.path.isAbsolute(path));
    // discipline: nothing on stderr for a success (B-init-12).
    try std.testing.expectEqualStrings("", res.stderr);

    const bytes = try expectFreshRepo(gpa, io, tmp.dir);
    gpa.free(bytes);
}

test "cli init B-init-5: an explicit missing directory (and its parents) is created" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try spawnInit(gpa, io, tmp.dir, &.{"sub/deep"});
    defer freeResult(gpa, &res);

    try expectExit(res.term, 0);
    try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/sub/deep/.zit\n"));
    try std.testing.expectEqualStrings("", res.stderr);

    var sub = try tmp.dir.openDir(io, "sub/deep", .{});
    defer sub.close(io);
    const bytes = try expectFreshRepo(gpa, io, sub);
    gpa.free(bytes);
}

test "cli init B-init-5: 'init .' produces the same observable state as the bare form" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    var tmp_bare = std.testing.tmpDir(.{});
    defer tmp_bare.cleanup();
    var res_bare = try spawnInit(gpa, io, tmp_bare.dir, &.{});
    defer freeResult(gpa, &res_bare);
    try expectExit(res_bare.term, 0);
    const bytes_bare = try expectFreshRepo(gpa, io, tmp_bare.dir);
    defer gpa.free(bytes_bare);

    var tmp_dot = std.testing.tmpDir(.{});
    defer tmp_dot.cleanup();
    var res_dot = try spawnInit(gpa, io, tmp_dot.dir, &.{"."});
    defer freeResult(gpa, &res_dot);
    try expectExit(res_dot.term, 0);
    const bytes_dot = try expectFreshRepo(gpa, io, tmp_dot.dir);
    defer gpa.free(bytes_dot);

    // Same repository bytes: init is deterministic (no timestamp/hash in an
    // empty repo's header content that differs run-to-run).
    try std.testing.expectEqualSlices(u8, bytes_bare, bytes_dot);
}

test "cli init B-init-6: -q suppresses the success line; on-disk state is identical" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    var tmp_loud = std.testing.tmpDir(.{});
    defer tmp_loud.cleanup();
    var res_loud = try spawnInit(gpa, io, tmp_loud.dir, &.{});
    defer freeResult(gpa, &res_loud);
    try expectExit(res_loud.term, 0);
    const bytes_loud = try expectFreshRepo(gpa, io, tmp_loud.dir);
    defer gpa.free(bytes_loud);

    var tmp_quiet = std.testing.tmpDir(.{});
    defer tmp_quiet.cleanup();
    var res_quiet = try spawnInit(gpa, io, tmp_quiet.dir, &.{"-q"});
    defer freeResult(gpa, &res_quiet);
    try expectExit(res_quiet.term, 0);
    // quiet: nothing on stdout, nothing on stderr.
    try std.testing.expectEqualStrings("", res_quiet.stdout);
    try std.testing.expectEqualStrings("", res_quiet.stderr);
    const bytes_quiet = try expectFreshRepo(gpa, io, tmp_quiet.dir);
    defer gpa.free(bytes_quiet);

    try std.testing.expectEqualSlices(u8, bytes_loud, bytes_quiet);
}

test "cli init B-init-7: an existing repository refuses with exit 1 and no change" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var first = try spawnInit(gpa, io, tmp.dir, &.{});
    defer freeResult(gpa, &first);
    try expectExit(first.term, 0);
    const before = try expectFreshRepo(gpa, io, tmp.dir);
    defer gpa.free(before);

    var second = try spawnInit(gpa, io, tmp.dir, &.{});
    defer freeResult(gpa, &second);
    try expectExit(second.term, 1);
    try std.testing.expectEqualStrings("", second.stdout);
    try std.testing.expect(std.mem.startsWith(u8, second.stderr, "zit: a zit repository already exists at "));
    try std.testing.expect(std.mem.endsWith(u8, second.stderr, "/.zit\n"));

    // No change: the `.zit` bytes are byte-identical to after the first init.
    const after = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
    defer gpa.free(after);
    try std.testing.expectEqualSlices(u8, before, after);
}

test "cli init B-init-7: an orphan .zit-checkout does not trigger already-exists" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Pre-seed a stale checkout with NO `.zit` present.
    try checkout.write(io, tmp.dir, .{ .version = "deadbeef", .branch = "feature" });

    var res = try spawnInit(gpa, io, tmp.dir, &.{});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    // `.zit` created and the orphan checkout atomically overwritten to unborn.
    const bytes = try expectFreshRepo(gpa, io, tmp.dir);
    gpa.free(bytes);
}

test "cli init B-init-8: a target path that is a file reports not-a-directory, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    {
        var f = try tmp.dir.createFile(io, "notdir", .{});
        f.close(io);
    }

    var res = try spawnInit(gpa, io, tmp.dir, &.{"notdir"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize: "));
    try std.testing.expect(std.mem.endsWith(u8, res.stderr, " is not a directory\n"));
    // Nothing created: no `.zit` beside the offending file.
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}

test "cli init B-init-9: a failure after .zit is created rolls it back (all-or-nothing), exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Force the checkout-write step to fail deterministically: a DIRECTORY
    // named `.zit-checkout` cannot be atomically replaced by a file, so
    // `checkout.write` returns error.Storage AFTER `.zit` was created — the
    // exact rollback path.
    try tmp.dir.createDir(io, checkout.filename, .default_dir);

    var res = try spawnInit(gpa, io, tmp.dir, &.{});
    defer freeResult(gpa, &res);

    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize repository at "));
    try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
    // Rollback: `.zit` must be absent so a re-run is not poisoned into B-init-7.
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}

test "cli init B-init-10: an unknown option prints usage to stderr, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit init: unknown option '-x'\ntry 'zit init --help'\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}

test "cli init B-init-10: a second positional prints usage to stderr, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try spawnInit(gpa, io, tmp.dir, &.{ "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit init: unexpected argument 'b'\ntry 'zit init --help'\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}

/// Independent transcription of the B-init-11 help text (contracts/init.md).
/// Hand-copied from the contract — never generated from the binary — so the
/// binary's output is checked against the spec, not against itself.
const expected_init_help =
    \\usage: zit init [-q|--quiet] [--] [directory]
    \\
    \\Create an empty zit repository in <directory> (default: the current
    \\directory). Writes two files there:
    \\
    \\  .zit            the repository — a single, self-contained content log
    \\  .zit-checkout   the working-state record (branch trunk, no check-in yet)
    \\
    \\The new repository has no check-ins: the default branch 'trunk' is unborn
    \\until your first `zit commit`, which records the root check-in. Artifacts
    \\are named with SHA3-256 by default.
    \\
    \\If <directory> does not exist it is created.
    \\
    \\options:
    \\  -q, --quiet   do not print the confirmation line
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  repository created
    \\  1  a repository already exists, or the path could not be initialized
    \\  2  usage error (unknown option or extra argument)
    \\
;

test "cli init B-init-11: --help and -h print the verbatim help to stdout, exit 0, no changes" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    inline for (.{ "--help", "-h" }) |flag| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();

        var res = try spawnInit(gpa, io, tmp.dir, &.{flag});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected_init_help, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        // help makes no changes.
        try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
        try std.testing.expect(!fileExists(io, tmp.dir, ".zit-checkout"));
    }
}

test "cli init B-init-12: data goes to stdout and diagnostics to stderr, never mixed" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Success: stdout carries the line, stderr is empty.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try spawnInit(gpa, io, tmp.dir, &.{});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expect(res.stdout.len > 0);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    // Usage error: stderr carries the diagnostic, stdout is empty.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(res.stderr.len > 0);
    }
}

// ==========================================================================
// `zit add` end-to-end (contracts/add.md B-add-1..18). Each spawns the REAL
// binary in an isolated, pinned environment and reads `.zit-managed` back
// through the library.
// ==========================================================================

test "cli add B-add-2/-14/-16: happy path marks files managed, prints added, persists the set" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("added a\nadded b\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);

    try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}

test "cli add B-add-3: re-adding a managed path is idempotent (notice to stderr, set unchanged)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");

    var first = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
    defer freeResult(gpa, &first);
    try expectExit(first.term, 0);

    var second = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
    defer freeResult(gpa, &second);
    try expectExit(second.term, 0);
    try std.testing.expectEqualStrings("", second.stdout);
    try std.testing.expectEqualStrings("zit: 'a' is already tracked\n", second.stderr);

    try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}

test "cli add B-add-15: ./a, a, and sub/../a denote one managed entry" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try tmp.dir.createDir(io, "sub", .default_dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "./a", "a", "sub/../a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // First normalizes-and-adds; the later two are already tracked.
    try std.testing.expectEqualStrings("added a\n", res.stdout);
    try std.testing.expectEqualStrings(
        "zit: 'a' is already tracked\nzit: 'a' is already tracked\n",
        res.stderr,
    );
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
}

test "cli add B-add-4/-18: a missing path is partial — the valid sibling is still added, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "nope" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("added a\n", res.stdout);
    try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
}

test "cli add B-add-5: a directory and a symlink are rejected; the regular sibling is added" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try tmp.dir.createDir(io, "d", .default_dir);
    try tmp.dir.symLink(io, "a", "s", .{});

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "d", "s" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("added a\n", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: 'd' is a directory; name individual files (directory recursion is not yet supported)\n" ++
            "zit add: 's' is not a regular file\n",
        res.stderr,
    );
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
}

test "cli add B-add-6: a path outside the root is refused, nothing added" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "../x", "/etc/hosts" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: '../x' is outside the repository\n" ++
            "zit add: '/etc/hosts' is outside the repository\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-7: the three control files are refused" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", ".zit", ".zit-checkout", ".zit-managed" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: '.zit' is a zit control file and cannot be tracked\n" ++
            "zit add: '.zit-checkout' is a zit control file and cannot be tracked\n" ++
            "zit add: '.zit-managed' is a zit control file and cannot be tracked\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-8: not a zit repository" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try touch(io, tmp.dir, "a"); // a file, but no repo anywhere above

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-9: no paths is a usage error, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{"add"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit add: no paths given\ntry 'zit add --help'\n", res.stderr);
}

test "cli add B-add-10: an unknown option is a usage error, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: unknown option '-x'\ntry 'zit add --help'\n",
        res.stderr,
    );
}

test "cli add B-add-11: --dry-run previews and persists nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-n", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("would add a\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    // No write occurred: `.zit-managed` is absent.
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));

    // A dry run with a missing sibling still previews the valid one and reports
    // the error, exits 1, and writes nothing.
    var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "--dry-run", "a", "nope" });
    defer freeResult(gpa, &res2);
    try expectExit(res2.term, 1);
    try std.testing.expectEqualStrings("would add a\n", res2.stdout);
    try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res2.stderr);
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-12: --quiet suppresses chatter but keeps effect and errors" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a", "nope" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    // The `added a` line is suppressed; the error still prints.
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
    // Effect persisted despite quiet.
    try expectManaged(gpa, io, tmp.dir, &.{"a"});

    // Quiet also suppresses the already-tracked notice.
    var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a" });
    defer freeResult(gpa, &res2);
    try expectExit(res2.term, 0);
    try std.testing.expectEqualStrings("", res2.stdout);
    try std.testing.expectEqualStrings("", res2.stderr);
}

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

test "cli add B-add-13: --help/-h prints the verbatim help, exit 0, changes nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Help wins even with no repo, no path, and extra tokens present.
    inline for (.{
        &[_][]const u8{ "add", "--help" },
        &[_][]const u8{ "add", "-h" },
        &[_][]const u8{ "add", "--help", "a", "b" },
    }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected_add_help, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
    }
}

test "cli add B-add-16: an all-invalid invocation writes nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "nope", "also-nope" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    // No managed file created when nothing valid was added.
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-17: a corrupt .zit-managed fails cleanly, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    // Pre-write garbage: the leading u32 decodes to an impossible entry count.
    {
        var f = try tmp.dir.createFile(io, managed.filename, .{});
        defer f.close(io);
        try f.writePositionalAll(io, "this is not a valid managed file", 0);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
}

test "cli add B-add-16/-17: a write failure leaves the prior managed set intact, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");

    // Make the checkout root read-only so `ManagedSet.write` cannot create its
    // temp file → error.Storage, AFTER the (absent) set reads as empty. Restore
    // write permission before cleanup can run (defers are LIFO).
    try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
    defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings(
        "zit add: cannot update tracked files: input/output error\n",
        res.stderr,
    );
    // The prior state is intact: no managed file was left behind.
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

// ==========================================================================
// Regression coverage for the three refuted behaviors (deep-cwd crash;
// help-vs-unknown-option precedence; `zit add .` misclassification).
// ==========================================================================

/// Creates `levels` nested subdirectories each named `seg` under `base`, and
/// returns an open handle to the deepest one (caller closes it). Built one
/// component at a time through directory handles, so the total path may exceed
/// the platform PATH_MAX without any single syscall ever seeing an over-long
/// path — the only portable way to place a subprocess's cwd past that limit.
fn makeDeepDir(io: std.Io, base: std.Io.Dir, seg: []const u8, levels: usize) !std.Io.Dir {
    var cur = try base.openDir(io, ".", .{});
    errdefer cur.close(io);
    for (0..levels) |_| {
        cur.createDir(io, seg, .default_dir) catch |e| switch (e) {
            error.PathAlreadyExists => {},
            else => return e,
        };
        const child = try cur.openDir(io, seg, .{});
        cur.close(io);
        cur = child;
    }
    return cur;
}

test "cli add B-add-17/-18: a deep checkout diagnoses instead of crashing (no panic)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // A cwd whose absolute path exceeds the platform PATH_MAX. Running `zit add`
    // from there made std's `currentPathAlloc` abort the process (SIGABRT); the
    // fix must instead exit cleanly with a diagnostic. 24 × 200-char levels
    // ≈ 4800 bytes, past both the 1024 (macOS) and 4096 (Linux) limits.
    const seg = "d" ** 200;
    var deep = try makeDeepDir(io, tmp.dir, seg, 24);
    defer deep.close(io);
    try touch(io, deep, "deep.txt");

    var res = try runZit(gpa, io, deep, &.{ "add", "deep.txt" });
    defer freeResult(gpa, &res);
    // The regression guard: a clean exit 1, never a signal/abort term, and no
    // panic trace on stderr.
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "panic") == null);
    // Where getcwd names the deep path, the repo-relative key exceeds
    // managed.max_path_len → the honest B-add-17 message; where getcwd refuses
    // an over-long path, discovery reports no repository. Both are honest exit-1
    // diagnostics; neither is a crash.
    try std.testing.expect(
        std.mem.indexOf(u8, res.stderr, "path is too long to track") != null or
            std.mem.indexOf(u8, res.stderr, "not a zit repository") != null,
    );
}

test "cli add B-add-17: a path longer than managed.max_path_len is rejected as too long" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // A single arg whose normalized repo-relative key exceeds
    // managed.max_path_len (4096), built from short "aa/" components so every
    // path component is well under NAME_MAX. The file need not exist: the length
    // bound is enforced before any filesystem stat, so this is deterministic on
    // every platform (cwd stays short).
    var buf: [managed.max_path_len + 64]u8 = undefined;
    var w: usize = 0;
    while (w + 3 <= buf.len) : (w += 3) {
        buf[w] = 'a';
        buf[w + 1] = 'a';
        buf[w + 2] = '/';
    }
    buf[w] = 'f';
    w += 1;
    const long_arg = buf[0..w];
    try std.testing.expect(long_arg.len > managed.max_path_len);

    var res = try runZit(gpa, io, tmp.dir, &.{ "add", long_arg });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    var ebuf: [managed.max_path_len + 128]u8 = undefined;
    const want = try std.fmt.bufPrint(&ebuf, "zit add: '{s}': path is too long to track\n", .{long_arg});
    try std.testing.expectEqualStrings(want, res.stderr);
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli add B-add-13/-10: --help wins over an unknown option (exit 0, verbatim help)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // Both orders print the help and exit 0, never the unknown-option usage
    // error — B-add-13's explicit precedence ("help wins over any other token")
    // beats B-add-10.
    inline for (.{
        &[_][]const u8{ "add", "--help", "-x" },
        &[_][]const u8{ "add", "-x", "--help" },
        &[_][]const u8{ "add", "-x", "-h", "nope.txt" },
    }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected_add_help, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
    }
}

test "cli add B-add-5: 'zit add .' at the root gives the directory guidance, not 'outside'" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // `.` resolves to the checkout root itself — a directory inside the repo, so
    // it must produce the B-add-5 directory diagnostic, not B-add-6's "outside
    // the repository" (the most likely novice invocation).
    var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: '.' is a directory; name individual files (directory recursion is not yet supported)\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

// ==========================================================================
// `zit rm` end-to-end (contracts/rm.md B-rm-1..19). Each spawns the REAL binary
// in an isolated, pinned environment, sets up tracked state via the real `zit
// add`, and reads `.zit-managed` back through the library.
// ==========================================================================

test "cli rm B-rm-2/-14/-16: happy path forgets tracked files, prints removed, persists, keeps files on disk" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");
    try touch(io, tmp.dir, "c");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b", "c" });

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // stdout carries the removals in argument order, each stating the file was
    // kept on disk (D1); stderr is empty (B-rm-14).
    try std.testing.expectEqualStrings(
        "removed a (file kept on disk)\nremoved b (file kept on disk)\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
    // Only c remains tracked (readback is the oracle, B-rm-16).
    try expectManaged(gpa, io, tmp.dir, &.{"c"});
    // Default rm keeps the working files on disk untouched (B-rm-2).
    try std.testing.expect(fileExists(io, tmp.dir, "a"));
    try std.testing.expect(fileExists(io, tmp.dir, "b"));
}

test "cli rm B-rm-2: membership not existence — a tracked file already deleted from disk is still forgettable" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "gone");
    try addManaged(gpa, io, tmp.dir, &.{"gone"});
    // The user deletes the working file themselves — the primary reason to rm.
    try tmp.dir.deleteFile(io, "gone");

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "gone" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("removed gone (file kept on disk)\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-3: --hard also deletes the working file from disk" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // Under --hard the line states the file was deleted from disk (D1).
    try std.testing.expectEqualStrings("removed a (deleted from disk)\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{"b"});
    // --hard removed the working file; the untouched sibling remains.
    try std.testing.expect(!fileExists(io, tmp.dir, "a"));
    try std.testing.expect(fileExists(io, tmp.dir, "b"));
}

test "cli rm B-rm-3: --hard on a tracked file already gone from disk is success" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});
    try tmp.dir.deleteFile(io, "a"); // already gone before --hard

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
    defer freeResult(gpa, &res);
    // FileNotFound ⇒ postcondition ("no working file") already holds ⇒ success.
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("removed a (deleted from disk)\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-4/-19: a not-tracked path is partial — the tracked sibling is still forgotten, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a", "nope" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("removed a (file kept on disk)\n", res.stdout);
    try std.testing.expectEqualStrings("zit rm: 'nope' is not tracked\n", res.stderr);
    // The valid forget still persisted (do-as-much-as-possible).
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-5: 'zit rm .' at the root gives the directory guidance, not 'outside'" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "." });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: '.' is a directory; name individual files (directory recursion is not yet supported)\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli rm B-rm-6: a path outside the root is refused" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "../x", "/etc/hosts" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: '../x' is outside the repository\n" ++
            "zit rm: '/etc/hosts' is outside the repository\n",
        res.stderr,
    );
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli rm B-rm-7: the three control files are refused and never --hard-deleted" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    // Give the set a real entry so a successful write would occur if any control
    // file slipped through; it must not, and the control files must survive.
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", ".zit", ".zit-checkout", ".zit-managed" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: '.zit' is a zit control file and cannot be tracked\n" ++
            "zit rm: '.zit-checkout' is a zit control file and cannot be tracked\n" ++
            "zit rm: '.zit-managed' is a zit control file and cannot be tracked\n",
        res.stderr,
    );
    // The guard held: every control file still on disk, set unchanged.
    try std.testing.expect(fileExists(io, tmp.dir, ".zit"));
    try std.testing.expect(fileExists(io, tmp.dir, ".zit-checkout"));
    try std.testing.expect(fileExists(io, tmp.dir, managed.filename));
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
}

test "cli rm B-rm-8: not a zit repository" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try touch(io, tmp.dir, "a"); // a file, but no repo anywhere above

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        res.stderr,
    );
}

test "cli rm B-rm-9: no paths is a usage error, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{"rm"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit rm: no paths given\ntry 'zit rm --help'\n", res.stderr);
}

test "cli rm B-rm-10: an unknown option is a usage error, exit 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: unknown option '-x'\ntry 'zit rm --help'\n",
        res.stderr,
    );
}

test "cli rm B-rm-11: --dry-run previews and persists/deletes nothing (even with --hard)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-n", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("would remove a (file kept on disk)\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    // Nothing persisted: the set still tracks a, the file still exists.
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
    try std.testing.expect(fileExists(io, tmp.dir, "a"));

    // `-n --hard` previews without unlinking.
    var res2 = try runZit(gpa, io, tmp.dir, &.{ "rm", "--dry-run", "--hard", "a" });
    defer freeResult(gpa, &res2);
    try expectExit(res2.term, 0);
    // `-n --hard` previews the disk deletion without performing it (D1: the
    // whole line is subjunctive under `would remove`).
    try std.testing.expectEqualStrings("would remove a (deleted from disk)\n", res2.stdout);
    try std.testing.expectEqualStrings("", res2.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
    try std.testing.expect(fileExists(io, tmp.dir, "a"));
}

test "cli rm B-rm-12: --quiet suppresses removed lines but keeps effect and errors" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-q", "a", "nope" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    // The `removed a` line is suppressed; the error still prints (B-rm-12).
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit rm: 'nope' is not tracked\n", res.stderr);
    // Effect persisted despite quiet.
    try expectManaged(gpa, io, tmp.dir, &.{});
}

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

test "cli rm B-rm-13: --help/-h prints the verbatim help, exit 0, changes nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Help wins even with no repo, no path, and extra/unknown tokens present.
    inline for (.{
        &[_][]const u8{ "rm", "--help" },
        &[_][]const u8{ "rm", "-h" },
        &[_][]const u8{ "rm", "--help", "a", "b" },
        &[_][]const u8{ "rm", "-x", "--help" },
    }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected_rm_help, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
    }
}

test "cli rm B-rm-15: ./a, a, and sub/../a denote one managed entry (forget once, rest not tracked)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try tmp.dir.createDir(io, "sub", .default_dir);
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "./a", "a", "sub/../a" });
    defer freeResult(gpa, &res);
    // First forgets the normalized key `a`; the later two are now not tracked.
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("removed a (file kept on disk)\n", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: 'a' is not tracked\nzit rm: 'a' is not tracked\n",
        res.stderr,
    );
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-16/-17: a write failure leaves the prior set intact and deletes nothing, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    // Make the checkout root read-only so `ManagedSet.write` cannot create its
    // temp file → error.Storage, AFTER `a` is forgotten in memory. Restore write
    // permission before cleanup can run (defers are LIFO).
    try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
    defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings(
        "zit rm: cannot update tracked files: input/output error\n",
        res.stderr,
    );
    // The write failed, so NO `removed` line may reach stdout — the report is
    // withheld until after a durable write (B-rm-14/-17). stdout must be empty.
    try std.testing.expectEqualStrings("", res.stdout);
    // Persist-before-destroy (B-rm-16): the write failed, so `a` is still tracked
    // AND still on disk — nothing was unlinked.
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
    try std.testing.expect(fileExists(io, tmp.dir, "a"));
}

test "cli rm B-rm-17/-14: a write failure claims NO removal on stdout; every named path stays tracked" {
    // The exact verify refutation: two tracked files, the checkout root made
    // unwritable so `ManagedSet.write` cannot create its temp file. The forgets
    // happen in memory, the atomic write fails → exit 1 with only the storage
    // error on stderr. stdout must NOT print `removed f1`/`removed f2` (nothing
    // was persisted), and a readback must show BOTH paths still tracked so that
    // `zit rm <files> | wc -l` counts exactly the real (zero) removals.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "f1.txt");
    try touch(io, tmp.dir, "f2.txt");
    try addManaged(gpa, io, tmp.dir, &.{ "f1.txt", "f2.txt" });

    try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
    defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};

    // Default (non-hard) rm, two paths — mirrors the refutation repro exactly.
    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "f1.txt", "f2.txt" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings(
        "zit rm: cannot update tracked files: input/output error\n",
        res.stderr,
    );
    try std.testing.expectEqualStrings("", res.stdout);

    // Restore write access so the readback (and cleanup) can open the set, then
    // confirm the on-disk set is unchanged — both paths still tracked.
    try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777));
    try expectManaged(gpa, io, tmp.dir, &.{ "f1.txt", "f2.txt" });
}

test "cli rm B-rm-17: a corrupt .zit-managed fails cleanly, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    // Pre-write garbage: the leading u32 decodes to an impossible entry count.
    {
        var f = try tmp.dir.createFile(io, managed.filename, .{});
        defer f.close(io);
        try f.writePositionalAll(io, "this is not a valid managed file", 0);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit rm: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
}

test "cli rm B-rm-18: --hard forgets and persists but reports an unlink failure, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Seed the managed set (through the library) with a key that names a
    // DIRECTORY on disk. `rm --hard d` forgets `d` and writes the set, then the
    // unlink fails (deleteFile refuses a directory) — the honest partial: the
    // forget stands, only the disk delete failed. Directory recursion is out of
    // scope, so this is the deterministic non-FileNotFound unlink failure.
    try tmp.dir.createDir(io, "d", .default_dir);
    {
        var set = managed.ManagedSet.init(gpa);
        defer set.deinit();
        _ = try set.insert("d");
        try set.write(io, tmp.dir);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "d" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    // The unlink FAILED, so `d` is a failed path (B-rm-19): NOTHING about it may
    // reach stdout (B-rm-4/-14). The earlier defect printed
    // `removed d (deleted from disk)\n` here — a suffix claiming a deletion that
    // never happened — while the file remained on disk; stdout must be empty.
    try std.testing.expectEqualStrings("", res.stdout);
    // The unlink failure is an honest partial on stderr (the forget stands).
    try std.testing.expect(std.mem.startsWith(
        u8,
        res.stderr,
        "zit rm: 'd': removed from tracking but could not delete from disk: ",
    ));
    try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
    // The forget is persisted (it preceded the failed unlink); the dir remains.
    try expectManaged(gpa, io, tmp.dir, &.{});
    var d = try tmp.dir.openDir(io, "d", .{});
    d.close(io);
}

test "cli rm B-rm-3/-18/-14: --hard routes a deleted file to stdout and an unlink failure to stderr" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // `ok` is a regular file that unlinks cleanly (→ stdout removal line); `d` is
    // a directory whose deleteFile fails (directory recursion is out of scope) —
    // a deterministic non-FileNotFound unlink failure (→ stderr only). Seeding
    // both through the library keeps this independent of `zit add`'s file check.
    try touch(io, tmp.dir, "ok");
    try tmp.dir.createDir(io, "d", .default_dir);
    {
        var set = managed.ManagedSet.init(gpa);
        defer set.deinit();
        _ = try set.insert("ok");
        _ = try set.insert("d");
        try set.write(io, tmp.dir);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "ok", "d" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    // Only the successfully-deleted file prints a stdout removal line (D6): the
    // failed path is stderr-only, so `| wc -l` counts exactly the real deletions
    // and no disposition suffix ever lies (B-rm-3/-4/-14/-18).
    try std.testing.expectEqualStrings("removed ok (deleted from disk)\n", res.stdout);
    try std.testing.expect(std.mem.startsWith(
        u8,
        res.stderr,
        "zit rm: 'd': removed from tracking but could not delete from disk: ",
    ));
    try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
    // Both forgets persisted (they precede the unlink); `ok` gone, `d` remains.
    try expectManaged(gpa, io, tmp.dir, &.{});
    try std.testing.expect(!fileExists(io, tmp.dir, "ok"));
    var d = try tmp.dir.openDir(io, "d", .{});
    d.close(io);
}

test "cli rm B-rm-14: `zit rm >> log` appends, never clobbers prior file content (O_APPEND honored)" {
    // Repro 1 of the composition refutation: a positional stdout writer wrote
    // from offset 0, overwriting `PRE-EXISTING-LI` with `removed g1\n`. A
    // streaming writer honors the inherited O_APPEND offset, so the line is
    // appended after the pre-existing content.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "g1");
    try addManaged(gpa, io, tmp.dir, &.{"g1"});

    var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" rm g1 >> log");
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    const log = try readFile(gpa, io, tmp.dir, "log");
    defer gpa.free(log);
    try std.testing.expectEqualStrings("PRE-EXISTING-LINE\nremoved g1 (file kept on disk)\n", log);
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-14: a grouped redirect keeps the shell's own lines around zit's output (shared fd offset)" {
    // Repro 2: `{ echo …; zit rm …; echo …; } > log` — zit shares one open file
    // description with the shell's `echo`s. A positional write at offset 0
    // overwrote `SHELL-LINE-1`; a streaming write lands at the shared, advanced
    // offset, so all three lines survive in order.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "g2");
    try addManaged(gpa, io, tmp.dir, &.{"g2"});

    var res = try runShell(gpa, io, tmp.dir, "{ echo SHELL-LINE-1; \"$ZIT\" rm g2; echo SHELL-LINE-3; } > log");
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    const log = try readFile(gpa, io, tmp.dir, "log");
    defer gpa.free(log);
    try std.testing.expectEqualStrings("SHELL-LINE-1\nremoved g2 (file kept on disk)\nSHELL-LINE-3\n", log);
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-14: two zit invocations sharing one redirected fd do not overwrite each other" {
    // Repro 3: `{ zit rm a; zit rm b; } > log` — the second invocation inherits
    // the fd whose offset the first advanced. Positional writes made the second
    // clobber the first (`removed g3a\n` → `.txt\n`); streaming writes append.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "g3a");
    try touch(io, tmp.dir, "g3b");
    try addManaged(gpa, io, tmp.dir, &.{ "g3a", "g3b" });

    var res = try runShell(gpa, io, tmp.dir, "{ \"$ZIT\" rm g3a; \"$ZIT\" rm g3b; } > log");
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    const log = try readFile(gpa, io, tmp.dir, "log");
    defer gpa.free(log);
    try std.testing.expectEqualStrings(
        "removed g3a (file kept on disk)\nremoved g3b (file kept on disk)\n",
        log,
    );
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli rm B-rm-10 (DX): --force/-f teaches the safe-by-default model, exit 2, stderr only" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // Parse rejects the flag before repo discovery, so no repo is needed; the
    // message must educate (files kept by default, --hard to delete) rather than
    // print a bare "unknown option".
    inline for (.{
        &[_][]const u8{ "rm", "--force", "a" },
        &[_][]const u8{ "rm", "-f", "a" },
    }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings(
            "zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n",
            res.stderr,
        );
    }
}

test "cli rm D4: 'zit remove'/'zit delete' suggest 'rm' (git-synonym redirect)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // Unknown-command dispatch runs before any repo lookup; a git user's synonym
    // is redirected to the real verb instead of dead-ending (exit 2, stderr).
    inline for (.{ "remove", "delete" }) |cmd| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, &.{cmd});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unknown command '" ++ cmd ++ "'") != null);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "did you mean 'rm'?") != null);
    }
}

// ==========================================================================
// `zit status` end-to-end tests (contracts/status.md B-status-1..17). Each
// spawns the REAL binary in an isolated, pinned environment, asserts exit code
// + exact stdout/stderr (structural around the born hash, never a wall clock),
// and — status being read-only — reads `.zit-checkout`/`.zit-managed` back
// through the library to prove no mutation occurred. Teardown is unconditional.
// ==========================================================================

/// Overwrites `.zit-checkout` with a chosen state via the library's own writer,
/// so the born-state header (B-status-4) is reachable with no `commit` verb (the
/// checkout record is independent of the log). Used only by the status e2e.
fn seedCheckout(io: std.Io, dir: std.Io.Dir, version: []const u8, branch: []const u8) !void {
    try checkout.write(io, dir, .{ .version = version, .branch = branch });
}

/// Asserts, through the library, that `dir`'s checkout record still holds
/// exactly `version`/`branch` — the read-only oracle proving `status` did not
/// mutate `.zit-checkout`.
fn expectCheckout(io: std.Io, dir: std.Io.Dir, version: []const u8, branch: []const u8) !void {
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, dir, &ckbuf)).?;
    try std.testing.expectEqualStrings(version, ck.version);
    try std.testing.expectEqualStrings(branch, ck.branch);
}

/// An independent transcription of contracts/status.md §Help. Kept separate from
/// the binary's own `status_help` so the e2e compares two hand-made copies —
/// drift in either is caught (B-status-14/-16).
const status_help_expected =
    \\usage: zit status [-h|--help]
    \\
    \\Show the state of the working copy: the current branch, the current
    \\check-in (if any), and the state of your tracked files.
    \\
    \\zit has no staging area. `status` reports the files you marked with
    \\`zit add` and how each one stands in the working tree:
    \\
    \\  added <path>     tracked and new — it will enter your next check-in
    \\  missing <path>   tracked, but not present in the working tree
    \\
    \\Until your first `zit commit` the branch 'trunk' is unborn: there is no
    \\check-in yet, so every tracked file is reported as 'added' (it will form
    \\the root check-in). The current check-in, once one exists, is shown as its
    \\full artifact hash.
    \\
    \\Not yet reported (these arrive with `zit commit` / `zit diff`): edits to
    \\already-committed files, files deleted from a check-in, and untracked
    \\("extra") files in the working tree.
    \\
    \\options:
    \\  -h, --help   show this help and exit
    \\
    \\exit status:
    \\  0  the working-copy state was reported (with or without changes)
    \\  1  this is not a zit repository, or its state could not be read
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

test "cli status B-status-3/-7/-15: fresh repo, nothing tracked, header + nothing tracked yet" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nnothing tracked yet\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr); // report → stdout only

    // Read-only: the checkout record is unchanged and no managed set was written.
    try expectCheckout(io, tmp.dir, "", "trunk");
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}

test "cli status B-status-3/-6/-8: unborn, two present tracked files → added in sorted order" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nadded a\nadded b\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
    // Read-only: the tracked set still holds exactly {a, b}.
    try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}

test "cli status B-status-5: a tracked file removed from disk → missing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});
    try tmp.dir.deleteFile(io, "a"); // gone from the working tree, still tracked

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nmissing a\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
    try expectManaged(gpa, io, tmp.dir, &.{"a"}); // still tracked (read-only)
}

test "cli status B-status-5/-8: present/absent interleave in path order, not grouped by kind" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try touch(io, tmp.dir, "b");
    try touch(io, tmp.dir, "c");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b", "c" });
    try tmp.dir.deleteFile(io, "b"); // the middle path is the one gone

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nadded a\nmissing b\nadded c\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli status B-status-5: a tracked path replaced by a directory folds to missing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a");
    try addManaged(gpa, io, tmp.dir, &.{"a"});
    try tmp.dir.deleteFile(io, "a");
    try tmp.dir.createDir(io, "a", .default_dir); // non-regular now → missing

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nmissing a\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli status B-status-4/-5/-16: born state, present tracked file NOT classified, absent → missing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "p");
    try touch(io, tmp.dir, "q");
    try addManaged(gpa, io, tmp.dir, &.{ "p", "q" });
    try tmp.dir.deleteFile(io, "q");
    const born_hash = "a" ** 64;
    try seedCheckout(io, tmp.dir, born_hash, "trunk"); // reach the born state

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // The full hash is echoed verbatim (no abbreviation, B-status-4/-16); the
    // present `p` produces NO line (needs the deferred diff), only `q` is missing.
    try std.testing.expectEqualStrings(
        "On branch trunk (check-in " ++ born_hash ++ ")\nmissing q\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
    try expectCheckout(io, tmp.dir, born_hash, "trunk"); // read-only
}

test "cli status B-status-7: born state, no missing → nothing to report" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "p");
    try addManaged(gpa, io, tmp.dir, &.{"p"});
    const born_hash = "b" ** 40;
    try seedCheckout(io, tmp.dir, born_hash, "trunk");

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (check-in " ++ born_hash ++ ")\nnothing to report\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli status B-status-17: an empty branch field displays as trunk" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try seedCheckout(io, tmp.dir, "", ""); // empty branch → defensive trunk default

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(
        "On branch trunk (no check-ins yet)\nnothing tracked yet\n",
        res.stdout,
    );
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli status B-status-9/-15: not a zit repository → exit 1, stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup(); // a bare tmpDir: no repo here or in any parent

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        res.stderr,
    );
}

test "cli status B-status-11: a corrupt .zit-managed → exit 1, stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    {
        var f = try tmp.dir.createFile(io, managed.filename, .{});
        defer f.close(io);
        try f.writePositionalAll(io, "this is not a valid managed file", 0);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout); // header not yet printed
    try std.testing.expectEqualStrings(
        "zit status: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
}

test "cli status B-status-10: a corrupt .zit-checkout → exit 1, stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    // version_len 0xffff with no payload: a malformed TLV → error.Corrupt. The
    // file still exists, so discovery succeeds and the read is what fails.
    {
        var f = try tmp.dir.createFile(io, checkout.filename, .{});
        defer f.close(io);
        try f.writePositionalAll(io, &[_]u8{ 0xff, 0xff, 0xaa }, 0);
    }

    var res = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit status: cannot read checkout state: .zit-checkout is corrupt\n",
        res.stderr,
    );
}

test "cli status B-status-14/-16: --help and -h print the verbatim help to stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    inline for (.{ "--help", "-h" }) |flag| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup(); // no repo needed: help wins before discovery
        var res = try runZit(gpa, io, tmp.dir, &.{ "status", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(status_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
}

test "cli status B-status-12: an unknown option → exit 2, usage on stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "status", "-x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit status: unknown option '-x'\ntry 'zit status --help'\n",
        res.stderr,
    );
}

test "cli status B-status-13: an unexpected argument → exit 2, usage on stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runZit(gpa, io, tmp.dir, &.{ "status", "extra" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit status: unexpected argument 'extra'\ntry 'zit status --help'\n",
        res.stderr,
    );
}

// --- top-level discoverability & help routing (DX D4) ----------------------

test "cli status B-status-16 DX-D4: the command list names status" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // Bare `zit` and `zit help` both print the command list on stdout; a user
    // scanning it must SEE `status` — the DX-D4 blocker was that it was absent,
    // so `zit help status` implied the verb did not exist.
    inline for (.{ &[_][]const u8{}, &[_][]const u8{"help"} }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup(); // no repo needed: help is repo-independent
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expect(std.mem.indexOf(u8, res.stdout, "commands:") != null);
        try std.testing.expect(std.mem.indexOf(
            u8,
            res.stdout,
            "status     show the working checkout state\n",
        ) != null);
        try std.testing.expectEqualStrings("", res.stderr);
    }
}

test "cli status B-status-16 DX-D4: zit help status prints the status help, not the command list" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup(); // repo-independent: help wins before any discovery
    var res = try runZit(gpa, io, tmp.dir, &.{ "help", "status" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // Byte-identical to `zit status --help` — the same independent transcription
    // the --help e2e uses — proving the topic routed to the verb's own help.
    try std.testing.expectEqualStrings(status_help_expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli status B-status-16 DX-D4: zit help <verb> routes each verb to its own help, not the command list" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // A verb's help carries `usage: zit <verb>` and never the top-level
    // `commands:` block, so these two probes distinguish routed help from the
    // fallback command list for every routed verb.
    const Case = struct { verb: []const u8, usage: []const u8 };
    const cases = [_]Case{
        .{ .verb = "init", .usage = "usage: zit init" },
        .{ .verb = "add", .usage = "usage: zit add" },
        .{ .verb = "rm", .usage = "usage: zit rm" },
    };
    inline for (cases) |c| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, &.{ "help", c.verb });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expect(std.mem.indexOf(u8, res.stdout, c.usage) != null);
        try std.testing.expect(std.mem.indexOf(u8, res.stdout, "commands:") == null);
        try std.testing.expectEqualStrings("", res.stderr);
    }
}

// ==========================================================================
// `zit commit` end-to-end (contracts/commit.md B-commit-1..20). Each test
// spawns the REAL binary in an isolated tmp dir with a pinned environment
// (TZ=UTC, isolated HOME, and — for reproducibility — a fixed
// SOURCE_DATE_EPOCH and USER), asserts exit code + stdout/stderr
// (structurally around the hash/timestamp), and reads the resulting `.zit` /
// `.zit-checkout` state back THROUGH THE LIBRARY. Teardown is unconditional.
// ==========================================================================

/// A fixed clock for reproducible check-ins: 1234567890 == 2009-02-13T23:31:30Z.
const commit_epoch = "1234567890";
const commit_ts = "2009-02-13T23:31:30";

/// Optional environment overrides for a `commit` spawn. Defaults pin a
/// reproducible clock and a known author; a test sets a field to null to
/// exercise its absence (e.g. `.{ .user = null }` for the author-missing path).
const CommitEnv = struct {
    epoch: ?[]const u8 = commit_epoch,
    user: ?[]const u8 = "e2e-user",
};

/// Spawns `zit <tail...>` with the pinned base environment plus the commit
/// overrides. Caller owns `result.stdout`/`result.stderr`.
fn runCommitEnv(
    gpa: std.mem.Allocator,
    io: std.Io,
    tmp_dir: std.Io.Dir,
    env_opts: CommitEnv,
    tail: []const []const u8,
) !std.process.RunResult {
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");
    if (env_opts.epoch) |e| try env.put("SOURCE_DATE_EPOCH", e);
    if (env_opts.user) |u| try env.put("USER", u);

    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
    defer gpa.free(abs_exe);

    var argv_buf: [16][]const u8 = undefined;
    argv_buf[0] = abs_exe;
    std.debug.assert(tail.len <= argv_buf.len - 1);
    for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;

    return std.process.run(gpa, io, .{
        .argv = argv_buf[0 .. 1 + tail.len],
        .cwd = .{ .dir = tmp_dir },
        .environ_map = &env,
    });
}

/// Writes `content` to `name` under `dir` (truncating any existing file), so a
/// second commit sees the edited bytes.
fn writeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8) !void {
    var f = try dir.createFile(io, name, .{});
    defer f.close(io);
    try f.writePositionalAll(io, content, 0);
}

/// Reads `.zit-checkout` through the library and returns whether `version`
/// equals `expected` (empty string ⇒ unborn).
fn expectCheckoutVersion(io: std.Io, dir: std.Io.Dir, expected: []const u8) !void {
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, dir, &ckbuf)).?;
    try std.testing.expectEqualStrings(expected, ck.version);
}

/// Returns the checkout's current `version` copied onto `gpa` (caller frees).
fn checkoutVersionAlloc(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, dir, &ckbuf)).?;
    return gpa.dupe(u8, ck.version);
}

/// A parsed check-in plus the raw manifest bytes it borrows. `Manifest`'s `hash`
/// (F card) and `parents` (P card) fields are RAW slices into these bytes, so the
/// bytes must outlive the manifest — `deinit` frees both, in order.
const Checkin = struct {
    m: artifact.Manifest,
    bytes: []u8,
    gpa: std.mem.Allocator,
    fn deinit(self: *Checkin) void {
        self.m.deinit();
        self.gpa.free(self.bytes);
    }
};

/// Opens `.zit` read-only through the library and parses the artifact named
/// `hash` into a `Checkin` (caller `deinit`s). The e2e state oracle for a minted
/// check-in — never reads Fossil, only zit's own reader.
fn parseCheckin(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, hash: []const u8) !Checkin {
    var file = try dir.openFile(io, ".zit", .{ .mode = .read_only });
    defer file.close(io);
    const st = try file.stat(io);
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
    defer log.deinit(gpa);
    // The log keys frames by the 32-byte binary digest; the check-in `hash` is
    // its 64-hex name, so decode before lookup (the same bridge `commit` uses).
    var digest: [32]u8 = undefined;
    if (hash.len != 64) return error.CheckinNotFound;
    _ = try std.fmt.hexToBytes(&digest, hash);
    const rid = log.lookup(&digest) orelse return error.CheckinNotFound;
    const bytes = (try log.content(gpa, rid)) orelse return error.CheckinNotFound;
    errdefer gpa.free(bytes);
    const m = try artifact.parse(gpa, bytes);
    return .{ .m = m, .bytes = bytes, .gpa = gpa };
}

/// Finds the F card named `name`, or null.
fn findF(m: artifact.Manifest, name: []const u8) ?artifact.FCard {
    for (m.f) |f| if (std.mem.eql(u8, f.filename, name)) return f;
    return null;
}

/// Extracts the 64-hex artifact hash from a `check-in <hash> on <branch>\n`
/// success line, asserting the fixed structure (never a wall clock).
fn checkinHash(stdout: []const u8) ![]const u8 {
    const prefix = "check-in ";
    try std.testing.expect(std.mem.startsWith(u8, stdout, prefix));
    try std.testing.expect(std.mem.endsWith(u8, stdout, "\n"));
    const rest = stdout[prefix.len .. stdout.len - 1];
    const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.MalformedSuccessLine;
    const hash = rest[0..sp];
    try std.testing.expectEqual(@as(usize, 64), hash.len);
    for (hash) |ch| try std.testing.expect(std.ascii.isHex(ch) and !std.ascii.isUpper(ch));
    return hash;
}

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

test "cli commit B-commit-18a: --help and -h print the help to stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var r1 = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "--help" });
    defer freeResult(gpa, &r1);
    try expectExit(r1.term, 0);
    try std.testing.expectEqualStrings(commit_help_expected, r1.stdout);
    try std.testing.expectEqualStrings("", r1.stderr);

    // -h wins even mixed with other tokens (no repo needed).
    var r2 = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x", "-h", "-z" });
    defer freeResult(gpa, &r2);
    try expectExit(r2.term, 0);
    try std.testing.expectEqualStrings(commit_help_expected, r2.stdout);
}

test "cli commit B-commit-5: a missing message is a usage error (exit 2, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{"commit"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
        res.stderr,
    );
}

test "cli commit B-commit-18b: an unknown option is a usage error (exit 2)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x", "-z" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 2);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit commit: unknown option '-z'\ntry 'zit commit --help'\n",
        res.stderr,
    );
}

test "cli commit B-commit-20 (DX): --amend/--force teach the immutable-history model, exit 2, stderr only" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    // The reflex flag is rejected right after parse, before any repo discovery,
    // so no repo is needed; the message must teach that history is immutable
    // (check-ins are never rewritten) rather than print a bare "unknown option"
    // that reads as a typo. Still a usage error: exit 2, stderr only, try-help kept.
    inline for (.{
        .{ &[_][]const u8{ "commit", "-m", "x", "--amend" }, "--amend" },
        .{ &[_][]const u8{ "commit", "-m", "x", "--force" }, "--force" },
        .{ &[_][]const u8{ "commit", "-m", "x", "-f" }, "-f" },
    }) |case| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, case[0]);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings(
            "zit commit: '" ++ case[1] ++ "' is not supported. zit history is immutable — " ++
                "check-ins are never rewritten; record a new check-in that supersedes the old one.\n" ++
                "try 'zit commit --help'\n",
            res.stderr,
        );
    }
}

test "cli commit B-commit-14: outside a repository is an expected failure (exit 1)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{}); // no init
    defer tmp.cleanup();

    var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        res.stderr,
    );
}

test "cli commit B-commit-4: an empty or whitespace-only message is rejected (exit 1), nothing written" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a.txt");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    inline for (.{ "", "   " }) |msg| {
        var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", msg });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings(
            "zit commit: aborting — empty check-in message\n",
            res.stderr,
        );
    }
    // Unborn trunk untouched: nothing was committed.
    try expectCheckoutVersion(io, tmp.dir, "");
}

test "cli commit B-commit-9: a root with no tracked files has nothing to commit (exit 1)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit commit: nothing to commit (no tracked files) — use 'zit add <file>'\n",
        res.stderr,
    );
    try expectCheckoutVersion(io, tmp.dir, "");
}

test "cli commit B-commit-2/13/16/17: the root check-in mints trunk, decodes back through the library" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "hello\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "alice" }, &.{ "commit", "-m", "first" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    // Structural success line: `check-in <64-hex> on trunk` (B-commit-2/16/19).
    const hash = try checkinHash(res.stdout);
    try std.testing.expect(std.mem.endsWith(u8, res.stdout, " on trunk\n"));

    // The checkout advanced to the new hash (B-commit-15).
    try expectCheckoutVersion(io, tmp.dir, hash);

    // Decode the manifest back through the library (B-commit-17).
    var mc = try parseCheckin(gpa, io, tmp.dir, hash);
    defer mc.deinit();
    const m = mc.m;
    try std.testing.expectEqual(artifact.ArtifactType.manifest, m.kind);
    try std.testing.expect(m.c != null and std.mem.eql(u8, m.c.?.comment, "first"));
    try std.testing.expect(m.u != null and std.mem.eql(u8, m.u.?.login, "alice"));
    // Root: no P card, an F card for a.txt, and the two trunk tags (B-commit-13).
    try std.testing.expectEqual(@as(usize, 0), m.p.len);
    const fa = findF(m, "a.txt") orelse return error.MissingFCard;
    try std.testing.expectEqual(@as(usize, 64), fa.hash.?.len); // SHA3-256 blob name
    try std.testing.expect(fa.mode == .regular);
    try std.testing.expectEqual(@as(usize, 2), m.t.len);
    try std.testing.expect(m.t[0].sign == .propagate and std.mem.eql(u8, m.t[0].name, "branch"));
    try std.testing.expect(m.t[0].value != null and std.mem.eql(u8, m.t[0].value.?, "trunk"));
    try std.testing.expect(m.t[1].sign == .propagate and std.mem.eql(u8, m.t[1].name, "sym-trunk"));
    try std.testing.expect(m.t[1].value == null);
}

test "cli commit B-commit-7: the D card is the pinned UTC writer timestamp; commits are reproducible" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    var hashes: [2][]u8 = undefined;
    inline for (0..2) |i| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "same bytes\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

        var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "bob" }, &.{ "commit", "-m", "msg" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        const hash = try checkinHash(res.stdout);
        var mc = try parseCheckin(gpa, io, tmp.dir, hash);
        defer mc.deinit();
        const m = mc.m;
        try std.testing.expect(m.d != null);
        try std.testing.expectEqualStrings(commit_ts, m.d.?.timestamp);
        hashes[i] = try gpa.dupe(u8, hash);
    }
    defer gpa.free(hashes[0]);
    defer gpa.free(hashes[1]);
    // Identical inputs + pinned clock/author ⇒ identical manifest hash.
    try std.testing.expectEqualStrings(hashes[0], hashes[1]);
}

test "cli commit B-commit-6: author is --user, else $USER, else an expected failure" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) --user overrides the environment author.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "x\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "env-user" }, &.{ "commit", "-m", "m", "--user", "flag-user" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        const hash = try checkinHash(res.stdout);
        var mc = try parseCheckin(gpa, io, tmp.dir, hash);
        defer mc.deinit();
        const m = mc.m;
        try std.testing.expectEqualStrings("flag-user", m.u.?.login);
    }
    // (b) no --user ⇒ the $USER environment default.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "x\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "env-user" }, &.{ "commit", "-m", "m" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        const hash = try checkinHash(res.stdout);
        var mc = try parseCheckin(gpa, io, tmp.dir, hash);
        defer mc.deinit();
        const m = mc.m;
        try std.testing.expectEqualStrings("env-user", m.u.?.login);
    }
    // (c) neither ⇒ author-missing, exit 1, nothing written.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "x\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = null }, &.{ "commit", "-m", "m" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings(
            "zit commit: cannot determine the check-in author; pass --user <login>\n",
            res.stderr,
        );
        try expectCheckoutVersion(io, tmp.dir, "");
    }
}

test "cli commit B-commit-3/9: a child check-in carries a P card; an unchanged re-commit is a no-op" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "v1\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
    defer freeResult(gpa, &r1);
    try expectExit(r1.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(r1.stdout));
    defer gpa.free(root_hash);

    // No change ⇒ B-commit-9 child no-op, checkout unchanged.
    var rnoop = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "again" });
    defer freeResult(gpa, &rnoop);
    try expectExit(rnoop.term, 1);
    try std.testing.expectEqualStrings("", rnoop.stdout);
    try std.testing.expectEqualStrings("zit commit: nothing to commit (no changes)\n", rnoop.stderr);
    try expectCheckoutVersion(io, tmp.dir, root_hash);

    // Edit ⇒ a real child: new hash, one P card == root, no T card, checkout advanced.
    try writeFile(io, tmp.dir, "a.txt", "v2\n");
    var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "second" });
    defer freeResult(gpa, &r2);
    try expectExit(r2.term, 0);
    const child_hash = try checkinHash(r2.stdout);
    try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash));
    try expectCheckoutVersion(io, tmp.dir, child_hash);

    var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
    defer mc.deinit();
    const m = mc.m;
    try std.testing.expectEqual(@as(usize, 1), m.p.len);
    try std.testing.expect(m.p[0].parents != null and std.mem.eql(u8, m.p[0].parents.?, root_hash));
    try std.testing.expectEqual(@as(usize, 0), m.t.len); // child inherits trunk by propagation
}

test "cli commit B-commit-10: a managed file missing/non-regular is refused, nothing written" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try touch(io, tmp.dir, "a.txt");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    // Remove the working-tree file (but keep it managed): an all-files commit
    // must refuse, teaching `zit rm` for a genuine deletion.
    try tmp.dir.deleteFile(io, "a.txt");

    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "m" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit commit: cannot commit 'a.txt': missing from the working tree (use 'zit rm' to stop tracking it)\n",
        res.stderr,
    );
    try expectCheckoutVersion(io, tmp.dir, "");
}

test "cli commit B-commit-11: a partial commit re-hashes named files and carries the rest forward" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "a1\n");
    try writeFile(io, tmp.dir, "b.txt", "b1\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });

    var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
    defer freeResult(gpa, &r1);
    try expectExit(r1.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(r1.stdout));
    defer gpa.free(root_hash);
    var root_mc = try parseCheckin(gpa, io, tmp.dir, root_hash);
    defer root_mc.deinit();
    const root_m = root_mc.m;
    const b_orig_hash = try gpa.dupe(u8, (findF(root_m, "b.txt") orelse return error.MissingFCard).hash.?);
    defer gpa.free(b_orig_hash);

    // Edit both, but commit only a.txt: b.txt must be carried at its parent hash.
    try writeFile(io, tmp.dir, "a.txt", "a2\n");
    try writeFile(io, tmp.dir, "b.txt", "b2-uncommitted\n");
    var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "partial", "a.txt" });
    defer freeResult(gpa, &r2);
    try expectExit(r2.term, 0);
    const child_hash = try checkinHash(r2.stdout);

    var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
    defer mc.deinit();
    const m = mc.m;
    const fa = findF(m, "a.txt") orelse return error.MissingFCard;
    const fb = findF(m, "b.txt") orelse return error.MissingFCard;
    // a.txt re-hashed (differs from its root card); b.txt carried unchanged.
    const fa_root = findF(root_m, "a.txt").?;
    try std.testing.expect(!std.mem.eql(u8, fa.hash.?, fa_root.hash.?));
    try std.testing.expectEqualStrings(b_orig_hash, fb.hash.?);
}

test "cli commit B-commit-11: a named unmanaged path is not tracked (exit 1)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "a\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    try writeFile(io, tmp.dir, "b.txt", "b\n"); // exists but never `zit add`ed

    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "m", "b.txt" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit commit: 'b.txt' is not tracked (use 'zit add' first)\n",
        res.stderr,
    );
    try expectCheckoutVersion(io, tmp.dir, "");
}

test "cli commit B-commit-12: after `zit rm`, an all-files commit records the deletion" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "a\n");
    try writeFile(io, tmp.dir, "b.txt", "b\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
    var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
    defer freeResult(gpa, &r1);
    try expectExit(r1.term, 0);

    // Forget b.txt, then commit: the new manifest omits b's F card (deletion).
    var rrm = try runZit(gpa, io, tmp.dir, &.{ "rm", "b.txt" });
    defer freeResult(gpa, &rrm);
    try expectExit(rrm.term, 0);

    var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "drop b" });
    defer freeResult(gpa, &r2);
    try expectExit(r2.term, 0);
    const child_hash = try checkinHash(r2.stdout);
    var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
    defer mc.deinit();
    const m = mc.m;
    try std.testing.expect(findF(m, "a.txt") != null);
    try std.testing.expect(findF(m, "b.txt") == null); // deletion recorded
}

test "cli commit B-commit-19: -q suppresses the success line but still advances the checkout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "a.txt", "a\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-q", "-m", "quiet" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout); // no success line
    try std.testing.expectEqualStrings("", res.stderr);

    // The commit still happened: the checkout advanced off the unborn state.
    const version = try checkoutVersionAlloc(gpa, io, tmp.dir);
    defer gpa.free(version);
    try std.testing.expectEqual(@as(usize, 64), version.len);
}