Skip to content
zit Artifact [5c3abac2b9]
Login

Artifact [5c3abac2b9]

Artifact 5c3abac2b93cc78de682acf1e18f975607a23355db376fa6d4b2ef4e1b8f45cc:


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