Skip to content
zit Artifact [a04ba76c47]
Login

Artifact [a04ba76c47]

Artifact a04ba76c4725c21eb8caf552f43df1d9676dc1663d4f4830200cf84779432490:


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

// Private storage modules share the build graph's single `zit` module instance.
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const checkout = @import("zit").checkout;
// 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 `commit` e2e's state oracle is its own decoded manifest
// fields, never raw bytes compared to a hand-written expectation.
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` carrying the header plus a single config frame (its §9.1 project
/// code and defaults) and no content artifacts, and a `.zit-checkout` whose
/// trunk is unborn (`version == ""`, `branch == "trunk"`). The project code is
/// a real 40-hex value, never the all-zero placeholder. Returns the `.zit`
/// bytes (caller frees) so a same-directory caller can prove 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.expect(bytes.len > logstore.header_len); // header + config frame

    const digests = try collectArtifactDigests(gpa, io, dir);
    defer freeDigests(gpa, digests);
    try std.testing.expectEqual(@as(usize, 0), digests.len); // no content yet

    const code = try readProjectCode(gpa, io, dir);
    defer gpa.free(code);
    try std.testing.expectEqual(@as(usize, 40), code.len); // §9.1 artifact-hash width
    try std.testing.expect(!std.mem.eql(u8, code, "0" ** 40)); // a real, non-placeholder code

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

    // `zit init` and `zit init .` are equivalent: both parse to the cwd and
    // yield a valid fresh repo (asserted above). They are NOT byte-identical —
    // each repository gets its own §9.1 project code, so the two codes are
    // well-formed and, being per-repo, distinct.
    const code_bare = try readProjectCode(gpa, io, tmp_bare.dir);
    defer gpa.free(code_bare);
    const code_dot = try readProjectCode(gpa, io, tmp_dot.dir);
    defer gpa.free(code_dot);
    try std.testing.expect(!std.mem.eql(u8, code_bare, code_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);

    // `-q` changes only stdout: both runs create a full, valid fresh repo
    // (asserted above via expectFreshRepo). The on-disk repos are not
    // byte-identical because each carries its own §9.1 project code — a
    // per-repo identity, not a run-to-run nondeterminism.
    const code_loud = try readProjectCode(gpa, io, tmp_loud.dir);
    defer gpa.free(code_loud);
    const code_quiet = try readProjectCode(gpa, io, tmp_quiet.dir);
    defer gpa.free(code_quiet);
    try std.testing.expect(!std.mem.eql(u8, code_loud, code_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", .merge_parent = "" });

    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 mv OLD NEW` end-to-end tests (contracts/mv.md B-mv-1..18; derived
// manifest bytes from artifact §4.7.6/§4.6). Each spawns the REAL binary in an
// isolated, pinned environment, asserts exit code + exact stdout/stderr, and
// reads `.zit-managed`/the working tree/the minted manifest back through the
// library to prove the state the mv (and the following commit) produced.
// ==========================================================================

/// An independent transcription of contracts/mv.md §Help. Kept separate from the
/// binary's own `mv_help` so the e2e compares two hand-made copies — drift in
/// either is caught (B-mv-12).
const mv_help_expected =
    \\usage: zit mv OLD NEW
    \\
    \\Rename a tracked file: move the working file from OLD to NEW on disk, swap
    \\the tracked-name entry, and record the rename so your next `zit commit`
    \\records NEW with OLD as its former name.
    \\
    \\OLD must be tracked and present in the working tree; NEW must not already be
    \\tracked or exist on disk. Renaming never overwrites -- a collision is
    \\refused, and there is no --force. mv takes exactly two operands; it is never
    \\a glob or multi-file move, and it does not rename directories.
    \\
    \\mv is not undone by `zit undo`; to reverse a rename, run `zit mv NEW OLD`.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the file was renamed
    \\  1  OLD is not tracked or missing, NEW is already tracked or on disk, or
    \\     this is not a zit repository
    \\  2  usage error (wrong operand count, or an unknown option)
    \\
;

test "cli mv B-mv-1: happy path moves the file, swaps the managed entry, exit 0" {
    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, "old.txt", "contents\n");
    try addManaged(gpa, io, tmp.dir, &.{"old.txt"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "old.txt", "new.txt" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("renamed old.txt -> new.txt\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);

    // On disk: OLD gone, NEW present with the moved bytes.
    try std.testing.expect(!fileExists(io, tmp.dir, "old.txt"));
    try std.testing.expect(fileExists(io, tmp.dir, "new.txt"));
    const moved = try readFile(gpa, io, tmp.dir, "new.txt");
    defer gpa.free(moved);
    try std.testing.expectEqualStrings("contents\n", moved);
    // Managed set: OLD dropped, NEW gained.
    try expectManaged(gpa, io, tmp.dir, &.{"new.txt"});
}

test "cli mv B-mv-2: the managed set names NEW and the file is at NEW (end state consistent)" {
    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", "x\n");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // The persisted managed set names NEW and NOT OLD, and the file sits at NEW —
    // the write-then-move end state (the crash seam is covered by managed unit
    // tests); the two never disagree.
    var set = try managed.ManagedSet.read(gpa, io, tmp.dir);
    defer set.deinit();
    try std.testing.expect(set.contains("b"));
    try std.testing.expect(!set.contains("a"));
    try std.testing.expect(fileExists(io, tmp.dir, "b"));
    try std.testing.expect(!fileExists(io, tmp.dir, "a"));
}

test "cli mv B-mv-3: OLD not tracked refuses, exit 1, nothing moves" {
    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, "loose", "y\n"); // on disk but never added

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "loose", "dest" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit mv: 'loose' is not tracked\n", res.stderr);
    // Untouched: source still there, dest never created, set still empty.
    try std.testing.expect(fileExists(io, tmp.dir, "loose"));
    try std.testing.expect(!fileExists(io, tmp.dir, "dest"));
    try expectManaged(gpa, io, tmp.dir, &.{});
}

test "cli mv B-mv-4: NEW already tracked refuses, 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", "1\n");
    try writeFile(io, tmp.dir, "b", "2\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit mv: 'b' is already tracked\n", res.stderr);
    // No clobber: both still tracked, both files intact with original bytes.
    try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
    const bb = try readFile(gpa, io, tmp.dir, "b");
    defer gpa.free(bb);
    try std.testing.expectEqualStrings("2\n", bb);
}

test "cli mv B-mv-5: NEW exists on disk refuses, exit 1, OLD unmoved" {
    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", "src\n");
    try addManaged(gpa, io, tmp.dir, &.{"a"});
    try writeFile(io, tmp.dir, "b", "untracked-but-present\n"); // NEW exists on disk, not tracked

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit mv: 'b' already exists on disk\n", res.stderr);
    // OLD unmoved and still tracked; NEW file untouched (never overwritten).
    try expectManaged(gpa, io, tmp.dir, &.{"a"});
    const bb = try readFile(gpa, io, tmp.dir, "b");
    defer gpa.free(bb);
    try std.testing.expectEqualStrings("untracked-but-present\n", bb);
}

test "cli mv B-mv-6: status after mv reports NEW tracked, OLD absent" {
    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, "old", "z\n");
    try addManaged(gpa, io, tmp.dir, &.{"old"});
    var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "old", "new" });
    defer freeResult(gpa, &mvres);
    try expectExit(mvres.term, 0);

    var st = try runZit(gpa, io, tmp.dir, &.{"status"});
    defer freeResult(gpa, &st);
    try expectExit(st.term, 0);
    // status reads the same managed set mv wrote: NEW appears, OLD does not.
    try std.testing.expect(std.mem.indexOf(u8, st.stdout, "new") != null);
    try std.testing.expect(std.mem.indexOf(u8, st.stdout, "old") == null);
}

test "cli mv §4.7.6 B-mv-7: mv then commit emits the four-token old-name F card, sorted under NEW" {
    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 straddling file "mike" sits alphabetically between OLD ("alpha") and NEW
    // ("yankee"), so the sort order distinguishes current-name from old-name.
    try writeFile(io, tmp.dir, "alpha", "alpha bytes\n");
    try writeFile(io, tmp.dir, "mike", "mike bytes\n");
    try addManaged(gpa, io, tmp.dir, &.{ "alpha", "mike" });
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" });
    defer freeResult(gpa, &mvres);
    try expectExit(mvres.term, 0);

    var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" });
    defer freeResult(gpa, &rec);
    try expectExit(rec.term, 0);
    const child_hash = try checkinHash(rec.stdout);

    var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
    defer mc.deinit();
    const f = findF(mc.m, "yankee") orelse return error.MissingFCard;
    // §4.7.6: NEW filename, an explicit `w` perm token (regular-file SHOULD-w so
    // old-name stays in the fourth field), OLD as the fourth (old-name) token,
    // and a `w` token yields regular-file behavior (contains no x/l).
    try std.testing.expectEqualStrings("yankee", f.filename);
    try std.testing.expectEqualStrings("w", f.perm.?);
    try std.testing.expectEqualStrings("alpha", f.old_name.?);
    try std.testing.expectEqual(artifact.FileMode.regular, f.mode);
    // Hash is over the moved (unchanged) bytes now at NEW (§4.7.1 artifact name).
    var namebuf: [64]u8 = undefined;
    const expected_hash = artifact.artifactName(.sha3_256, "alpha bytes\n", &namebuf);
    try std.testing.expectEqualStrings(expected_hash, f.hash.?);
    // §4.6 strict-ascending-by-current-filename: mike (straddle) sorts BEFORE
    // yankee — proving the entry sorts under NEW, not OLD ("alpha").
    try std.testing.expectEqual(@as(usize, 2), mc.m.f.len);
    try std.testing.expectEqualStrings("mike", mc.m.f[0].filename);
    try std.testing.expectEqualStrings("yankee", mc.m.f[1].filename);
}

test "cli mv B-mv-8: the record is one-shot — a later commit emits NEW three-token" {
    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, "alpha", "a\n");
    try writeFile(io, tmp.dir, "mike", "m\n");
    try addManaged(gpa, io, tmp.dir, &.{ "alpha", "mike" });
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" });
    defer freeResult(gpa, &mvres);
    try expectExit(mvres.term, 0);
    var c1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" });
    defer freeResult(gpa, &c1);
    try expectExit(c1.term, 0);

    // A second commit with NO further mv (a change elsewhere forces a non-no-op).
    try writeFile(io, tmp.dir, "mike", "m edited\n");
    var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "edit mike" });
    defer freeResult(gpa, &c2);
    try expectExit(c2.term, 0);
    const h2 = try checkinHash(c2.stdout);

    var mc = try parseCheckin(gpa, io, tmp.dir, h2);
    defer mc.deinit();
    const f = findF(mc.m, "yankee") orelse return error.MissingFCard;
    // The record was consumed + cleared by the first commit: NEW is now a plain
    // current-name card — no perm, no old-name (§4.7.6 optional positions omitted).
    try std.testing.expectEqual(@as(?[]const u8, null), f.perm);
    try std.testing.expectEqual(@as(?[]const u8, null), f.old_name);
}

test "cli mv B-mv-9: with no rename pending, a commit is byte-identical to a fresh non-mv repo" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Repo A: a plain add+commit, no mv anywhere.
    var ta = std.testing.tmpDir(.{});
    defer ta.cleanup();
    try initRepo(gpa, io, ta.dir);
    try writeFile(io, ta.dir, "f", "same bytes\n");
    try addManaged(gpa, io, ta.dir, &.{"f"});
    var ca = try runCommitEnv(gpa, io, ta.dir, .{ .user = "u" }, &.{ "commit", "-m", "msg" });
    defer freeResult(gpa, &ca);
    try expectExit(ca.term, 0);
    const ha = try gpa.dupe(u8, try checkinHash(ca.stdout));
    defer gpa.free(ha);

    // Repo B: identical inputs. Same pinned epoch/user ⇒ identical manifest bytes
    // ⇒ identical check-in hash, proving the no-rename F card is unchanged (three
    // tokens: `F <name> <hash>`), since the mv mechanism never touched this path.
    var tb = std.testing.tmpDir(.{});
    defer tb.cleanup();
    try initRepo(gpa, io, tb.dir);
    try writeFile(io, tb.dir, "f", "same bytes\n");
    try addManaged(gpa, io, tb.dir, &.{"f"});
    var cb = try runCommitEnv(gpa, io, tb.dir, .{ .user = "u" }, &.{ "commit", "-m", "msg" });
    defer freeResult(gpa, &cb);
    try expectExit(cb.term, 0);
    const hb = try checkinHash(cb.stdout);
    try std.testing.expectEqualStrings(ha, hb);

    // And the F card really is three-token (no perm, no old-name).
    var mc = try parseCheckin(gpa, io, ta.dir, ha);
    defer mc.deinit();
    const f = findF(mc.m, "f") orelse return error.MissingFCard;
    try std.testing.expectEqual(@as(?[]const u8, null), f.perm);
    try std.testing.expectEqual(@as(?[]const u8, null), f.old_name);
}

test "cli mv B-mv-10: an uncommitted edit to OLD is preserved and hashed at NEW" {
    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, "alpha", "v1\n");
    try addManaged(gpa, io, tmp.dir, &.{"alpha"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    // Edit OLD after the check-in, THEN rename it.
    try writeFile(io, tmp.dir, "alpha", "v2 edited\n");
    var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" });
    defer freeResult(gpa, &mvres);
    try expectExit(mvres.term, 0);
    var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" });
    defer freeResult(gpa, &rec);
    try expectExit(rec.term, 0);
    const child_hash = try checkinHash(rec.stdout);

    var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
    defer mc.deinit();
    const f = findF(mc.m, "yankee") orelse return error.MissingFCard;
    // The commit captures the CURRENT bytes at NEW (the edit is preserved), with
    // OLD as the old-name token — mv moves, it never reverts or snapshots content.
    var namebuf: [64]u8 = undefined;
    const expected_hash = artifact.artifactName(.sha3_256, "v2 edited\n", &namebuf);
    try std.testing.expectEqualStrings(expected_hash, f.hash.?);
    try std.testing.expectEqualStrings("alpha", f.old_name.?);
    try std.testing.expectEqualStrings("w", f.perm.?);
}

test "cli mv B-mv-11: mv OLD OLD is a refusal, not a self-clobber" {
    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, "f", "same\n");
    try addManaged(gpa, io, tmp.dir, &.{"f"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "f", "f" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1); // caught by the already-tracked / on-disk checks
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit mv: 'f' is already tracked\n", res.stderr);
    // Nothing changed: still tracked, file intact.
    try expectManaged(gpa, io, tmp.dir, &.{"f"});
    const ff = try readFile(gpa, io, tmp.dir, "f");
    defer gpa.free(ff);
    try std.testing.expectEqualStrings("same\n", ff);
}

test "cli mv B-mv-12: -h and --help print the verbatim help, exit 0, change nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    inline for (.{ "-h", "--help" }) |flag| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a", "x\n");
        try addManaged(gpa, io, tmp.dir, &.{"a"});

        // -h/--help win even over otherwise-valid positionals (B-mv-12).
        var res = try runZit(gpa, io, tmp.dir, &.{ "mv", flag, "a", "b" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(mv_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        // No mutation: 'a' still tracked and on disk, 'b' never created.
        try expectManaged(gpa, io, tmp.dir, &.{"a"});
        try std.testing.expect(fileExists(io, tmp.dir, "a"));
        try std.testing.expect(!fileExists(io, tmp.dir, "b"));
    }
}

test "cli mv B-mv-13: an unknown option is a usage error, exit 2, stderr only" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    inline for (.{
        &[_][]const u8{ "mv", "--force", "a", "b" },
        &[_][]const u8{ "mv", "-f", "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, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unknown option '" ++ argv[1] ++ "'") != null);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "try 'zit mv --help'") != null);
    }
}

test "cli mv B-mv-14: the wrong operand count is a usage error, exit 2, no mutation" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    inline for (.{
        &[_][]const u8{"mv"}, // zero operands
        &[_][]const u8{ "mv", "a" }, // one operand
        &[_][]const u8{ "mv", "a", "b", "c" }, // three operands
    }) |argv| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a", "x\n");
        try addManaged(gpa, io, tmp.dir, &.{"a"});

        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.expect(std.mem.indexOf(u8, res.stderr, "try 'zit mv --help'") != null);
        // No mutation regardless of which count failed.
        try expectManaged(gpa, io, tmp.dir, &.{"a"});
    }
}

test "cli mv B-mv-15: outside a repository is an expected failure, exit 1" {
    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, &.{ "mv", "a", "b" });
    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 mv B-mv-16: a corrupt .zit-managed fails cleanly before any move, 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", "x\n");
    {
        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, &.{ "mv", "a", "b" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit mv: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
    // Nothing moved: source intact, dest never created.
    try std.testing.expect(fileExists(io, tmp.dir, "a"));
    try std.testing.expect(!fileExists(io, tmp.dir, "b"));
}

test "cli mv B-mv-17: streams stay separated under redirection (success vs refusal)" {
    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", "x\n");
    try addManaged(gpa, io, tmp.dir, &.{"a"});

    // Success: the confirmation goes to stdout only; stderr stays empty.
    var ok = try runShell(gpa, io, tmp.dir, "\"$ZIT\" mv a b > out.txt 2> err.txt");
    defer freeResult(gpa, &ok);
    try expectExit(ok.term, 0);
    const ok_out = try readFile(gpa, io, tmp.dir, "out.txt");
    defer gpa.free(ok_out);
    const ok_err = try readFile(gpa, io, tmp.dir, "err.txt");
    defer gpa.free(ok_err);
    try std.testing.expectEqualStrings("renamed a -> b\n", ok_out);
    try std.testing.expectEqualStrings("", ok_err);

    // Refusal: the diagnostic goes to stderr only; stdout stays empty.
    var bad = try runShell(gpa, io, tmp.dir, "\"$ZIT\" mv missing dest > out2.txt 2> err2.txt");
    defer freeResult(gpa, &bad);
    try expectExit(bad.term, 1);
    const bad_out = try readFile(gpa, io, tmp.dir, "out2.txt");
    defer gpa.free(bad_out);
    const bad_err = try readFile(gpa, io, tmp.dir, "err2.txt");
    defer gpa.free(bad_err);
    try std.testing.expectEqualStrings("", bad_out);
    try std.testing.expectEqualStrings("zit mv: 'missing' is not tracked\n", bad_err);
}

// ==========================================================================
// `zit merge` end-to-end tests (contracts/merge.md B-merge-1..29). Each spawns
// the REAL binary in an isolated, pinned environment and builds its DAG through
// real check-ins (commit → update → commit forks a diamond), then drives
// `zit merge` and asserts exit code + stdout/stderr + on-disk state read back
// through the library. Cases that need the commit P-card extension (the next,
// separate component) — B-merge-8/9/10/11/12 — are deferred to that slice.
// ==========================================================================

/// The verbatim `zit merge` help — must match `main.zig`'s `merge_help`
/// byte-for-byte (drift fails B-merge-25).
const merge_help_expected =
    \\usage: zit merge NAME
    \\
    \\Merge the check-in NAME into the working tree with a three-way merge and
    \\record it as a pending merge parent. NAME resolves like 'zit info' — a full
    \\or abbreviated artifact ID, a branch, or a tag. This does not create a
    \\check-in; run 'zit commit' to record the merge. Conflicts are written into
    \\the working files with conflict markers and listed on stderr — resolve them
    \\and commit, or run 'zit undo' to abandon the merge.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

/// Commits the current working tree (message `msg`, pinned author/epoch), asserts
/// exit 0, and returns the new check-in's 64-hex hash copied onto `gpa`.
fn commitHashDup(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, msg: []const u8) ![]const u8 {
    var res = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", msg });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    const h = try checkinHash(res.stdout);
    return gpa.dupe(u8, h);
}

const MergeDiamond = struct {
    base: []const u8,
    other: []const u8,
    current: []const u8,
    fn deinit(self: *MergeDiamond, gpa: std.mem.Allocator) void {
        gpa.free(self.base);
        gpa.free(self.other);
        gpa.free(self.current);
    }
};

/// Builds a diamond over one file `poem`: root(base_content) → other(other_content)
/// on trunk, then `update base` and commit current(current_content). The two
/// leaves share `base` as their §3.3 merge base. On return the checkout sits at
/// `current` on a clean trunk with `poem` == current_content — ready to merge.
fn buildMergeDiamond(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    base_content: []const u8,
    other_content: []const u8,
    current_content: []const u8,
) !MergeDiamond {
    try initRepo(gpa, io, dir);
    try writeFile(io, dir, "poem", base_content);
    try addManaged(gpa, io, dir, &.{"poem"});
    const base = try commitHashDup(gpa, io, dir, "root");
    errdefer gpa.free(base);

    try writeFile(io, dir, "poem", other_content);
    const other = try commitHashDup(gpa, io, dir, "other");
    errdefer gpa.free(other);

    var up = try runZit(gpa, io, dir, &.{ "update", base });
    defer freeResult(gpa, &up);
    try expectExit(up.term, 0);

    try writeFile(io, dir, "poem", current_content);
    const current = try commitHashDup(gpa, io, dir, "current");
    return .{ .base = base, .other = other, .current = current };
}

/// Reads `.zit-checkout` and asserts its version and merge-parent fields.
fn expectMergeState(io: std.Io, dir: std.Io.Dir, version: []const u8, merge_parent: []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(merge_parent, ck.merge_parent);
}

test "cli merge B-merge-1/-2/-3: clean disjoint merge lands both sides, records the parent, mints no check-in" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Disjoint edits: current changed line 1, other changed line 3.
    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);

    // B-merge-2 oracle: `.zit` is byte-identical across the merge — no blob and
    // no manifest is appended, so the merge is provably not a check-in.
    const zit_before = try readFile(gpa, io, tmp.dir, ".zit");
    defer gpa.free(zit_before);

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    // B-merge-1: the three-way result lands byte-for-byte, exit 0, one line to
    // stdout naming the other side and the changed-file count; stderr empty.
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expectEqualStrings("A1\nB\nC1\n", poem);
    var line_buf: [256]u8 = undefined;
    const want_line = try std.fmt.bufPrint(&line_buf, "merged {s} into the working tree (1 file(s) changed)\n", .{fx.other});
    try std.testing.expectEqualStrings(want_line, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);

    // B-merge-1: the merge parent is recorded; the version is unchanged (no
    // advance — merge does not mint).
    try expectMergeState(io, tmp.dir, fx.current, fx.other);

    // B-merge-2: `.zit` unchanged.
    const zit_after = try readFile(gpa, io, tmp.dir, ".zit");
    defer gpa.free(zit_after);
    try std.testing.expectEqualSlices(u8, zit_before, zit_after);

    // B-merge-3: the undo journal is armed (present after the merge).
    try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo"));
}

test "cli merge B-merge-5: a disjoint add/delete file set merges from the §8.2 walk" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Root with two files. Other adds `their`; current adds `ours`. The merge
    // must union both additions (each added on exactly one side).
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "shared", "base\n");
    try addManaged(gpa, io, tmp.dir, &.{"shared"});
    const base = try commitHashDup(gpa, io, tmp.dir, "root");
    defer gpa.free(base);

    try writeFile(io, tmp.dir, "their", "theirs\n");
    try addManaged(gpa, io, tmp.dir, &.{"their"});
    const other = try commitHashDup(gpa, io, tmp.dir, "other-adds");
    defer gpa.free(other);

    var up = try runZit(gpa, io, tmp.dir, &.{ "update", base });
    defer freeResult(gpa, &up);
    try expectExit(up.term, 0);
    try writeFile(io, tmp.dir, "ours", "ours\n");
    try addManaged(gpa, io, tmp.dir, &.{"ours"});
    const current = try commitHashDup(gpa, io, tmp.dir, "current-adds");
    defer gpa.free(current);

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

    // `their` materialized from the other side; `ours`/`shared` retained.
    const their = try readFile(gpa, io, tmp.dir, "their");
    defer gpa.free(their);
    try std.testing.expectEqualStrings("theirs\n", their);
    try std.testing.expect(fileExists(io, tmp.dir, "ours"));
    try std.testing.expect(fileExists(io, tmp.dir, "shared"));
    try expectMergeState(io, tmp.dir, current, other);
}

test "cli merge B-merge-7: an overlapping edit conflicts — markers in-tree, path on stderr, exit 1, parent recorded" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Both sides change the SAME line differently → a content conflict.
    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n");
    defer fx.deinit(gpa);

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);

    // Conflict surfacing: stdout empty; each conflicted path plus a summary on
    // stderr.
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit merge: conflict in poem\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 conflict(s); resolve and commit, or run 'zit undo'") != null);

    // The file carries fossil-style diff3 markers.
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expect(std.mem.indexOf(u8, poem, "<<<<<<< current\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, poem, "||||||| base\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, poem, "=======\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, poem, ">>>>>>> other\n") != null);

    // The merge is NOT abandoned — the parent is recorded despite the conflict.
    try expectMergeState(io, tmp.dir, fx.current, fx.other);
}

test "cli merge B-merge-19: delete-vs-modify is a whole-file conflict" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Root has `poem`. Other modifies it; current deletes it (rm + commit).
    try initRepo(gpa, io, tmp.dir);
    try writeFile(io, tmp.dir, "poem", "A\nB\nC\n");
    try addManaged(gpa, io, tmp.dir, &.{"poem"});
    const base = try commitHashDup(gpa, io, tmp.dir, "root");
    defer gpa.free(base);

    try writeFile(io, tmp.dir, "poem", "A\nB\nMODIFIED\n");
    const other = try commitHashDup(gpa, io, tmp.dir, "other-modifies");
    defer gpa.free(other);

    var up = try runZit(gpa, io, tmp.dir, &.{ "update", base });
    defer freeResult(gpa, &up);
    try expectExit(up.term, 0);
    var rmres = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "poem" });
    defer freeResult(gpa, &rmres);
    try expectExit(rmres.term, 0);
    const current = try commitHashDup(gpa, io, tmp.dir, "current-deletes");
    defer gpa.free(current);

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", other });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit merge: conflict in poem\n") != null);
    // The conflicting path is written with markers (never silently resolved).
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expect(std.mem.indexOf(u8, poem, "<<<<<<< current\n") != null);
    try expectMergeState(io, tmp.dir, current, other);
}

test "cli commit B-merge-8/-9/-11/-12: a merge commit mints the two-token P card, clears it, and round-trips" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // A clean disjoint diamond → the merge is conflict-free, so `zit commit`
    // records the merge check-in.
    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);

    var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &mg);
    try expectExit(mg.term, 0);
    try expectMergeState(io, tmp.dir, fx.current, fx.other);

    // B-merge-8: exactly one P card, two tokens — the pre-merge checkout (the
    // primary ancestor) first, the merge parent second.
    const merge_commit = try commitHashDup(gpa, io, tmp.dir, "merge poem");
    defer gpa.free(merge_commit);

    var stored = try parseCheckin(gpa, io, tmp.dir, merge_commit);
    defer stored.deinit();
    var pcard_buf: [160]u8 = undefined;
    const want_pcard = try std.fmt.bufPrint(&pcard_buf, "P {s} {s}\n", .{ fx.current, fx.other });
    try std.testing.expect(std.mem.indexOf(u8, stored.bytes, want_pcard) != null);
    try std.testing.expectEqual(@as(usize, 1), stored.m.p.len);
    try std.testing.expectEqual(@as(usize, 2), stored.m.p[0].count);

    // B-merge-11: a baseline manifest — no B card — whose F set is the full
    // reconstructed file set (here the single merged file), never a delta.
    try std.testing.expect(stored.m.b == null);
    try std.testing.expectEqual(@as(usize, 1), stored.m.f.len);
    try std.testing.expectEqualStrings("poem", stored.m.f[0].filename);

    // B-merge-9: the commit consumes the pending merge — the checkout advances to
    // the merge check-in with the merge parent cleared (one-shot).
    try expectMergeState(io, tmp.dir, merge_commit, "");

    // B-merge-12: `zit info` renders the merge parent through the existing
    // `renderMergeParents` path (a `merge:` line naming the other side), with the
    // pre-merge checkout still shown as the primary `parent:` line.
    var info = try runZit(gpa, io, tmp.dir, &.{ "info", merge_commit });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    var pline: [160]u8 = undefined;
    const want_parent_line = try std.fmt.bufPrint(&pline, "parent:  {s}\n", .{fx.current});
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_parent_line) != null);
    var mline: [160]u8 = undefined;
    const want_merge_line = try std.fmt.bufPrint(&mline, "merge:   {s}\n", .{fx.other});
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_merge_line) != null);

    // B-merge-9: a second commit with no fresh `zit merge` is an ordinary
    // single-parent check-in — the two-token path does not persist.
    try writeFile(io, tmp.dir, "poem", "A1\nB\nC1\nD\n");
    const next = try commitHashDup(gpa, io, tmp.dir, "follow-up");
    defer gpa.free(next);
    var stored2 = try parseCheckin(gpa, io, tmp.dir, next);
    defer stored2.deinit();
    try std.testing.expectEqual(@as(usize, 1), stored2.m.p.len);
    try std.testing.expectEqual(@as(usize, 1), stored2.m.p[0].count);
    try std.testing.expectEqualStrings(merge_commit, stored2.m.p[0].parents.?);
}

test "cli commit B-merge-10: a merge with unresolved conflict markers refuses, exit 1, mints nothing, keeps the merge pending" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Overlapping edits → a content conflict; markers land in `poem`.
    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n");
    defer fx.deinit(gpa);

    var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &mg);
    try expectExit(mg.term, 1); // a conflict merge; the parent is recorded anyway

    // The commit refuses while the marker is still in the working file.
    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "try merge" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit commit: unresolved conflict in poem\n") != null);

    // Nothing minted: the checkout is un-advanced and the merge stays pending.
    try expectMergeState(io, tmp.dir, fx.current, fx.other);

    // Resolving the file (markers removed) lets the same commit through, and it
    // still records the two-token merge P card and clears the pending merge.
    try writeFile(io, tmp.dir, "poem", "A\nRESOLVED\nC\n");
    const resolved = try commitHashDup(gpa, io, tmp.dir, "resolved merge");
    defer gpa.free(resolved);
    var stored = try parseCheckin(gpa, io, tmp.dir, resolved);
    defer stored.deinit();
    try std.testing.expectEqual(@as(usize, 1), stored.m.p.len);
    try std.testing.expectEqual(@as(usize, 2), stored.m.p[0].count);
    try expectMergeState(io, tmp.dir, resolved, "");
}

test "cli commit B-merge-9 negative space: a non-merge commit is byte-identical across independent repos (no P-card regression)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Two independent repos built through the SAME pinned-epoch, non-merge
    // sequence must produce byte-for-byte identical child manifests, and each
    // child carries a single-token P card — proof the merge extension never
    // touches a commit with no pending merge.
    const build = struct {
        fn run(g: std.mem.Allocator, ioo: std.Io, dir: std.Io.Dir) ![]u8 {
            try initRepo(g, ioo, dir);
            try writeFile(ioo, dir, "poem", "A\nB\nC\n");
            try addManaged(g, ioo, dir, &.{"poem"});
            var r0 = try runCommitEnv(g, ioo, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "root" });
            defer freeResult(g, &r0);
            try expectExit(r0.term, 0);
            try writeFile(ioo, dir, "poem", "A\nB\nC1\n");
            var r1 = try runCommitEnv(g, ioo, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "child" });
            defer freeResult(g, &r1);
            try expectExit(r1.term, 0);
            const h = try checkinHash(r1.stdout);
            var stored = try parseCheckin(g, ioo, dir, h);
            defer stored.deinit();
            try std.testing.expectEqual(@as(usize, 1), stored.m.p.len);
            try std.testing.expectEqual(@as(usize, 1), stored.m.p[0].count);
            return g.dupe(u8, stored.bytes);
        }
    };

    var tmp_a = std.testing.tmpDir(.{});
    defer tmp_a.cleanup();
    var tmp_b = std.testing.tmpDir(.{});
    defer tmp_b.cleanup();

    const a = try build.run(gpa, io, tmp_a.dir);
    defer gpa.free(a);
    const b = try build.run(gpa, io, tmp_b.dir);
    defer gpa.free(b);
    try std.testing.expectEqualSlices(u8, a, b);
}

test "cli merge B-merge-13: undo restores the pre-merge files and clears the merge parent" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n");
    defer fx.deinit(gpa);

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1); // a conflict merge, but still recorded

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

    // The working file is back to the pre-merge current content, and the merge
    // parent is cleared — the tree is indistinguishable from before the merge.
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expectEqualStrings("A\nX\nC\n", poem);
    try expectMergeState(io, tmp.dir, fx.current, "");
}

test "cli merge B-merge-14: a dirty working tree refuses, exit 1, nothing mutated" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);

    // An uncommitted edit makes the tree dirty.
    try writeFile(io, tmp.dir, "poem", "A1\nB\nC\nDIRTY\n");

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit merge: working tree is not clean\n", res.stderr);

    // Nothing mutated: no pending merge, and the dirty bytes are untouched.
    try expectMergeState(io, tmp.dir, fx.current, "");
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expectEqualStrings("A1\nB\nC\nDIRTY\n", poem);
}

test "cli merge B-merge-15: a merge already pending refuses, exit 1, mutating nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n");
    defer fx.deinit(gpa);

    var first = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &first);
    try expectExit(first.term, 1); // records the pending merge (conflict)

    var second = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other });
    defer freeResult(gpa, &second);
    try expectExit(second.term, 1);
    try std.testing.expectEqualStrings("", second.stdout);
    try std.testing.expectEqualStrings(
        "zit merge: a merge is already pending; resolve and commit it, or run 'zit undo'\n",
        second.stderr,
    );
    // Still the first merge's recorded parent — the second refusal changed nothing.
    try expectMergeState(io, tmp.dir, fx.current, fx.other);
}

test "cli merge B-merge-16: NAME is the checkout or an ancestor is up to date, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);

    // base is a proper ancestor of the current checkout.
    var anc = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.base });
    defer freeResult(gpa, &anc);
    try expectExit(anc.term, 1);
    try std.testing.expectEqualStrings("", anc.stdout);
    try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", anc.stderr);
    try expectMergeState(io, tmp.dir, fx.current, "");

    // The checkout itself is trivially up to date.
    var self = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.current });
    defer freeResult(gpa, &self);
    try expectExit(self.term, 1);
    try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", self.stderr);
}

test "cli merge B-merge-16: a proper ancestor the merge-base mtime rule skips is still up to date, exit 1" {
    // The bug the `base == other` proxy missed: a linear chain root <- mid <- tip
    // whose event.mtimes are NON-monotone in DAG order (root carries the greatest
    // mtime). Merging `mid` — a proper ancestor of the checkout `tip` — must be
    // refused. But `mergeBaseOf(tip, mid)` selects the greatest-mtime common
    // ancestor, which is `root`, NOT `mid`; so `base == other` (mid) is false and
    // the old refusal wrongly let the merge proceed, recording an ancestor as a
    // merge parent. The mtime-independent `isAncestorOf` gate catches it.
    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, "poem", "A\n");
    try addManaged(gpa, io, tmp.dir, &.{"poem"});
    // root: the LATEST mtime, so it wins mergeBaseOf's greatest-mtime selection.
    var c_root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &c_root);
    try expectExit(c_root.term, 0);

    try writeFile(io, tmp.dir, "poem", "A\nB\n");
    // mid: an EARLIER mtime than its own parent — a proper ancestor of tip that
    // the merge-base walk therefore never selects.
    var c_mid = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "mid" });
    defer freeResult(gpa, &c_mid);
    try expectExit(c_mid.term, 0);
    const mid = try gpa.dupe(u8, try checkinHash(c_mid.stdout));
    defer gpa.free(mid);

    try writeFile(io, tmp.dir, "poem", "A\nB\nC\n");
    var c_tip = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "3000000000" }, &.{ "commit", "-m", "tip" });
    defer freeResult(gpa, &c_tip);
    try expectExit(c_tip.term, 0);
    const tip = try gpa.dupe(u8, try checkinHash(c_tip.stdout));
    defer gpa.free(tip);

    // Merge the proper ancestor `mid`: up to date, refuse, mutate nothing.
    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", mid });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", res.stderr);
    // No merge parent recorded; checkout still at tip; working tree untouched.
    try expectMergeState(io, tmp.dir, tip, "");
    const poem = try readFile(gpa, io, tmp.dir, "poem");
    defer gpa.free(poem);
    try std.testing.expectEqualStrings("A\nB\nC\n", poem);
}

test "cli merge B-merge-18: an unresolvable NAME fails with the resolver diagnostic, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);

    const absent = "deadbeef" ** 8; // 64 hex, no such check-in
    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", absent });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
    // Nothing mutated.
    try expectMergeState(io, tmp.dir, fx.current, "");
}

test "cli merge B-merge-25: -h and --help print the verbatim help, exit 0, change 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);

    for ([_][]const u8{ "-h", "--help" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "merge", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(merge_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    // help wins even over an otherwise-invalid trailing token.
    var win = try runZit(gpa, io, tmp.dir, &.{ "merge", "--help", "-z" });
    defer freeResult(gpa, &win);
    try expectExit(win.term, 0);
    try std.testing.expectEqualStrings(merge_help_expected, win.stdout);
}

test "cli merge B-merge-26: a missing NAME, an unknown option, or a second positional 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);

    const cases = [_][]const []const u8{
        &.{"merge"}, // missing NAME
        &.{ "merge", "-x" }, // unknown option
        &.{ "merge", "--force" }, // no override flag exists
        &.{ "merge", "a", "b" }, // second positional
    };
    for (cases) |argv| {
        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.expect(std.mem.indexOf(u8, res.stderr, "try 'zit merge --help'") != null);
    }
}

test "cli merge B-merge-27: outside a repository fails with the not-a-repo diagnostic, exit 1" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{}); // NOT initialized
    defer tmp.cleanup();

    var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "trunk" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}

test "cli merge B-merge-28/-29: streams stay separated and the verb is wired into help" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // B-merge-29: the top-level help names the merge verb.
    {
        var help = try runZit(gpa, io, tmp.dir, &.{"help"});
        defer freeResult(gpa, &help);
        try expectExit(help.term, 0);
        try std.testing.expect(std.mem.indexOf(u8, help.stdout, "\n  merge      ") != null);
    }

    // B-merge-28: a clean merge writes the confirmation to stdout only.
    var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n");
    defer fx.deinit(gpa);
    {
        var script_buf: [128]u8 = undefined;
        const script = try std.fmt.bufPrint(&script_buf, "\"$ZIT\" merge {s} > out.txt 2> err.txt", .{fx.other});
        var ok = try runShell(gpa, io, tmp.dir, script);
        defer freeResult(gpa, &ok);
        try expectExit(ok.term, 0);
        const out = try readFile(gpa, io, tmp.dir, "out.txt");
        defer gpa.free(out);
        const err = try readFile(gpa, io, tmp.dir, "err.txt");
        defer gpa.free(err);
        try std.testing.expect(std.mem.startsWith(u8, out, "merged "));
        try std.testing.expectEqualStrings("", err);
    }

    // B-merge-28: a conflict merge writes diagnostics to stderr only. Fresh repo.
    var tmp2 = std.testing.tmpDir(.{});
    defer tmp2.cleanup();
    var fx2 = try buildMergeDiamond(gpa, io, tmp2.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n");
    defer fx2.deinit(gpa);
    {
        var script_buf: [128]u8 = undefined;
        const script = try std.fmt.bufPrint(&script_buf, "\"$ZIT\" merge {s} > out.txt 2> err.txt", .{fx2.other});
        var bad = try runShell(gpa, io, tmp2.dir, script);
        defer freeResult(gpa, &bad);
        try expectExit(bad.term, 1);
        const out = try readFile(gpa, io, tmp2.dir, "out.txt");
        defer gpa.free(out);
        const err = try readFile(gpa, io, tmp2.dir, "err.txt");
        defer gpa.free(err);
        try std.testing.expectEqualStrings("", out);
        try std.testing.expect(std.mem.indexOf(u8, err, "zit merge: conflict in poem\n") != 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, .merge_parent = "" });
}

/// 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 diff` end-to-end. The public surface is deliberately a compact
// metadata report, not a text patch: only managed paths participate, compared
// with the checkout's resolved F-set. Each invocation must be read-only.
// ==========================================================================

test "cli diff: an unborn managed regular file 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 writeFile(io, tmp.dir, "new.txt", "new\n");
    try addManaged(gpa, io, tmp.dir, &.{"new.txt"});

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("A new.txt\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli diff: a clean committed file is silent and arbitrary untracked paths are omitted" {
    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, "tracked.txt", "same\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    try writeFile(io, tmp.dir, "untracked.txt", "ignored\n");

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli diff: a changed committed managed file is modified and leaves repository metadata byte-identical" {
    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", "before\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    try writeFile(io, tmp.dir, "a.txt", "after\n");

    const before_zit = try readFile(gpa, io, tmp.dir, ".zit");
    defer gpa.free(before_zit);
    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);

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

    const after_zit = try readFile(gpa, io, tmp.dir, ".zit");
    defer gpa.free(after_zit);
    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    try std.testing.expectEqualSlices(u8, before_zit, after_zit);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
}

test "cli diff: a baseline file removed from management is deleted and mixed changes are path-sorted" {
    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", "a0\n");
    try writeFile(io, tmp.dir, "b", "b0\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    try writeFile(io, tmp.dir, "a", "a1\n");
    try writeFile(io, tmp.dir, "c", "c0\n");
    try addManaged(gpa, io, tmp.dir, &.{"c"});
    var remove = try runZit(gpa, io, tmp.dir, &.{ "rm", "b" });
    defer freeResult(gpa, &remove);
    try expectExit(remove.term, 0);

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("M a\nD b\nA c\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try std.testing.expect(fileExists(io, tmp.dir, "b")); // `rm` forgets, it does not unlink
}

test "cli diff: a managed filename uses section 4.3 escapes" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    const filename = "a b\tc\\d";
    try writeFile(io, tmp.dir, filename, "new\n");
    try addManaged(gpa, io, tmp.dir, &.{filename});

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("A a\\sb\\tc\\\\d\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli diff: a lowercase SHA-1 baseline is clean unchanged and modified when altered" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    const original = "same\n";
    try writeFile(io, tmp.dir, "sha1.txt", original);
    try addManaged(gpa, io, tmp.dir, &.{"sha1.txt"});

    const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
    defer gpa.free(blob);
    const f = [_]artifact.FCard{.{
        .filename = "sha1.txt",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    var manifest: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = "sha1-root" },
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &f,
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = &.{},
    };
    defer manifest.deinit();
    const manifest_bytes = try artifact.create(&manifest, gpa);
    defer gpa.free(manifest_bytes);
    const checkin = try appendArtifactAs(gpa, io, tmp.dir, .sha1, manifest_bytes);
    defer gpa.free(checkin);
    try std.testing.expectEqual(@as(usize, 40), checkin.len);
    for (checkin) |ch| try std.testing.expect(std.ascii.isHex(ch) and !std.ascii.isUpper(ch));
    try seedCheckout(io, tmp.dir, checkin, "trunk");

    var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &clean);
    try expectExit(clean.term, 0);
    try std.testing.expectEqualStrings("", clean.stdout);
    try std.testing.expectEqualStrings("", clean.stderr);

    try writeFile(io, tmp.dir, "sha1.txt", "changed\n");
    var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &changed);
    try expectExit(changed.term, 0);
    try std.testing.expectEqualStrings("M sha1.txt\n", changed.stdout);
    try std.testing.expectEqualStrings("", changed.stderr);
}

test "cli diff: a managed path replaced by a symlink is deleted without following its target" {
    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, "target", "same\n");
    try writeFile(io, tmp.dir, "link", "same\n");
    try addManaged(gpa, io, tmp.dir, &.{"link"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    try tmp.dir.deleteFile(io, "link");
    try tmp.dir.symLink(io, "target", "link", .{});

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("D link\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli diff: executable-bit-only change to a managed regular file is modified" {
    if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;

    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const original = "#!/bin/zit\nsame\n";
    try writeFile(io, tmp.dir, "script", original);
    try addManaged(gpa, io, tmp.dir, &.{"script"});
    {
        var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
        defer script.close(io);
        try script.setPermissions(io, File.Permissions.executable_file);
    }
    const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "script",
        .hash = blob,
        .perm = "x",
        .old_name = null,
        .mode = .executable,
    }};
    const baseline = try appendDiffManifest(gpa, io, tmp.dir, "executable-root", null, &files);
    defer gpa.free(baseline);
    var stored_baseline = try parseCheckin(gpa, io, tmp.dir, baseline);
    defer stored_baseline.deinit();
    try std.testing.expectEqual(artifact.FileMode.executable, (findF(stored_baseline.m, "script") orelse return error.MissingFCard).mode);
    try seedCheckout(io, tmp.dir, baseline, "trunk");

    var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &clean);
    try expectExit(clean.term, 0);
    try std.testing.expectEqualStrings("", clean.stdout);
    try std.testing.expectEqualStrings("", clean.stderr);

    {
        var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
        defer script.close(io);
        try script.setPermissions(io, File.Permissions.default_file);
    }
    var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &changed);
    try expectExit(changed.term, 0);
    try std.testing.expectEqualStrings("M script\n", changed.stdout);
    try std.testing.expectEqualStrings("", changed.stderr);
}

test "cli commit §4.7.6: chmod +x on a managed file is captured, recorded once, then quiesces" {
    if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;

    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 regular managed file, committed through zit's own capture path: §4.7.6
    // permits omitting the perm token for a regular file, so its F card is
    // mode-regular with no perm.
    try writeFile(io, tmp.dir, "script", "#!/bin/sh\necho hi\n");
    try addManaged(gpa, io, tmp.dir, &.{"script"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "regular" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(root.stdout));
    defer gpa.free(root_hash);
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, root_hash);
        defer mc.deinit();
        const f = findF(mc.m, "script") orelse return error.MissingFCard;
        try std.testing.expectEqual(artifact.FileMode.regular, f.mode);
        try std.testing.expectEqual(@as(?[]const u8, null), f.perm);
    }

    // chmod +x — the content is unchanged; only the executable bit flips.
    {
        var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
        defer script.close(io);
        try script.setPermissions(io, File.Permissions.executable_file);
    }

    // The drift surfaces in diff exactly once (metadata-only M, no content read).
    var drift = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &drift);
    try expectExit(drift.term, 0);
    try std.testing.expectEqualStrings("M script\n", drift.stdout);
    try std.testing.expectEqualStrings("", drift.stderr);

    // commit records the mode change: a real child whose F card carries an `x`
    // permission token (§4.7.6 — a token containing `x` marks the entry
    // executable), not a silent revert to regular.
    var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "chmod +x" });
    defer freeResult(gpa, &rec);
    try expectExit(rec.term, 0);
    const child_hash = try checkinHash(rec.stdout);
    try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash));
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
        defer mc.deinit();
        const f = findF(mc.m, "script") orelse return error.MissingFCard;
        try std.testing.expectEqual(artifact.FileMode.executable, f.mode);
        try std.testing.expectEqualStrings("x", f.perm.?);
    }

    // The tree quiesces: diff is clean and a re-commit is a no-op (B-commit-9),
    // where before the fix the drift re-appeared on every diff forever.
    var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &clean);
    try expectExit(clean.term, 0);
    try std.testing.expectEqualStrings("", clean.stdout);
    try std.testing.expectEqualStrings("", clean.stderr);

    var noop = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "again" });
    defer freeResult(gpa, &noop);
    try expectExit(noop.term, 1);
    try std.testing.expectEqualStrings("", noop.stdout);
    try std.testing.expectEqualStrings("zit commit: nothing to commit (no changes)\n", noop.stderr);
}

test "cli diff: a delta checkout resolves inherited managed files through its B-card" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const original = "same\n";
    try writeFile(io, tmp.dir, "inherited.txt", original);
    try addManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
    const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "inherited.txt",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const baseline = try appendDiffManifest(gpa, io, tmp.dir, "full-root", null, &files);
    defer gpa.free(baseline);
    const delta = try appendDiffManifest(gpa, io, tmp.dir, "delta", baseline, &.{});
    defer gpa.free(delta);
    var stored_delta = try parseCheckin(gpa, io, tmp.dir, delta);
    defer stored_delta.deinit();
    try std.testing.expectEqualStrings(baseline, stored_delta.m.b.?.hash);
    try std.testing.expectEqual(@as(usize, 0), stored_delta.m.f.len);
    try seedCheckout(io, tmp.dir, delta, "trunk");

    var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &clean);
    try expectExit(clean.term, 0);
    try std.testing.expectEqualStrings("", clean.stdout);
    try std.testing.expectEqualStrings("", clean.stderr);

    try writeFile(io, tmp.dir, "inherited.txt", "changed\n");
    var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &changed);
    try expectExit(changed.term, 0);
    try std.testing.expectEqualStrings("M inherited.txt\n", changed.stdout);
    try std.testing.expectEqualStrings("", changed.stderr);
}

test "cli diff: outside a repository fails with the shared discovery diagnostic" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    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 diff: a corrupt .zit-checkout fails without a partial diff" {
    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, checkout.filename, .{});
        defer f.close(io);
        try f.writePositionalAll(io, &.{ 0xff, 0xff, 0xaa }, 0);
    }

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

test "cli diff: a corrupt .zit-managed fails without a partial diff" {
    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\n", 0);
    }

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

test "cli diff: a current delta checkout with an absent B-card baseline fails without a partial diff" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    const absent_baseline = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
    const delta = try appendDiffManifest(gpa, io, tmp.dir, "missing-baseline", absent_baseline, &.{});
    defer gpa.free(delta);
    try seedCheckout(io, tmp.dir, delta, "trunk");

    var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit diff: cannot resolve baseline\n", res.stderr);
}

test "cli diff: help is the sole option and invalid arguments are usage errors" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    inline for (.{ "--help", "-h" }) |flag| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var help = try runZit(gpa, io, tmp.dir, &.{ "diff", flag });
        defer freeResult(gpa, &help);
        try expectExit(help.term, 0);
        try std.testing.expect(std.mem.startsWith(u8, help.stdout, "usage: zit diff [-h|--help]\n"));
        try std.testing.expect(std.mem.indexOf(u8, help.stdout, "--exit-code") == null);
        try std.testing.expectEqualStrings("", help.stderr);
    }

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

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

fn appendDiffManifest(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    label: []const u8,
    baseline: ?[]const u8,
    files: []const artifact.FCard,
) ![]u8 {
    var manifest: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = if (baseline) |hash| .{ .hash = hash } else null,
        .c = .{ .comment = label },
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = files,
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = &.{},
    };
    defer manifest.deinit();
    const bytes = try artifact.create(&manifest, gpa);
    defer gpa.free(bytes);
    return appendArtifact(gpa, io, dir, bytes);
}
/// Writes a legal non-leaf control artifact that mutates the named target's
/// tag state (a single T card). Synthetic control artifacts are necessary
/// because this public behavior cannot be produced by an existing CLI command.
fn appendTagControl(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    timestamp: []const u8,
    sign: artifact.TSign,
    name: []const u8,
    target: []const u8,
    value: ?[]const u8,
) ![]u8 {
    const cards = [_]artifact.TCard{.{
        .sign = sign,
        .name = name,
        .target = target,
        .value = value,
    }};
    var control: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = timestamp },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = &cards,
    };
    defer control.deinit();
    const bytes = try artifact.create(&control, gpa);
    defer gpa.free(bytes);
    return appendArtifact(gpa, io, dir, bytes);
}

/// Writes a control artifact that flips `target`'s branch tag — the branch-only
/// wrapper the update tests already rely on.
fn appendBranchControl(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    timestamp: []const u8,
    sign: artifact.TSign,
    target: []const u8,
    value: ?[]const u8,
) ![]u8 {
    return appendTagControl(gpa, io, dir, timestamp, sign, "branch", target, value);
}

/// Writes a control artifact that applies an active `closed` tag to `target`
/// (repo-state §7.4). No CLI verb closes a leaf yet, so the closed state is
/// synthesized directly for the `leaves` enumeration tests.
fn appendClosedControl(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    timestamp: []const u8,
    target: []const u8,
) ![]u8 {
    return appendTagControl(gpa, io, dir, timestamp, .add, "closed", target, null);
}

// ==========================================================================
// `zit commit` end-to-end (behavior ids 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>] [--branch <name>] [--close]
    \\                  [-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).
    \\
    \\--branch <name> starts a new branch at this check-in: the check-in moves
    \\onto <name> and later commits inherit it. <name> must not already name an
    \\open branch, and there must be a parent to fork from. --close marks this
    \\check-in a closed leaf, so it no longer shows as an open head of its branch.
    \\
    \\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)
    \\      --branch <name>       start a new branch at this check-in
    \\      --close               mark this check-in a closed leaf
    \\  -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, an
    \\     existing branch name, 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);
}

// --- commit --branch / --close (repostate §6.2, artifact §4.7) --------------

/// True iff `m` carries a T card with the given sign and (sign-stripped) name.
fn hasTag(m: artifact.Manifest, sign: artifact.TSign, name: []const u8) bool {
    for (m.t) |t| {
        if (t.sign == sign and std.mem.eql(u8, t.name, name)) return true;
    }
    return false;
}

test "cli commit --branch repostate §6.1/§6.2: a new check-in starts NAME and becomes its tip" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Root check-in on trunk.
    try writeFile(io, tmp.dir, "f", "v1\n");
    try addManaged(gpa, io, tmp.dir, &.{"f"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(root.stdout));
    defer gpa.free(root_hash);

    // A child that forks onto branch 'feature'.
    try writeFile(io, tmp.dir, "f", "v2\n");
    var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "start feature" });
    defer freeResult(gpa, &child);
    try expectExit(child.term, 0);
    // The success line names the branch it landed on (§6.1).
    try std.testing.expect(std.mem.endsWith(u8, child.stdout, " on feature\n"));
    const child_hash = try gpa.dupe(u8, try checkinHash(child.stdout));
    defer gpa.free(child_hash);
    try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash));

    // §6.2 branch-creation cards: the propagating branch/sym tags plus the
    // sym-trunk cancellation, byte-checked in the minted manifest.
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
        defer mc.deinit();
        try std.testing.expect(hasTag(mc.m, .propagate, "branch"));
        try std.testing.expect(hasTag(mc.m, .propagate, "sym-feature"));
        try std.testing.expect(hasTag(mc.m, .cancel, "sym-trunk"));
        // The branch card carries NAME as its value.
        for (mc.m.t) |t| {
            if (t.sign == .propagate and std.mem.eql(u8, t.name, "branch"))
                try std.testing.expectEqualStrings("feature", t.value.?);
        }
    }

    // Derived view (§6.3/§6.4): both branches exist, feature is current tip.
    var br = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &br);
    try expectExit(br.term, 0);
    try std.testing.expectEqualStrings("* feature\n  trunk\n", br.stdout);

    // The branch name resolves to the child check-in (`ls feature` lists it).
    var lf = try runZit(gpa, io, tmp.dir, &.{ "ls", "feature" });
    defer freeResult(gpa, &lf);
    try expectExit(lf.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, lf.stdout, " f\n") != null);
}

test "cli commit --branch repostate §6.2: rejected (exit 1) when NAME already names an open branch" {
    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, "f", "v1\n");
    try addManaged(gpa, io, tmp.dir, &.{"f"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    // Create branch 'feature' (now an open branch).
    try writeFile(io, tmp.dir, "f", "v2\n");
    var first = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "start" });
    defer freeResult(gpa, &first);
    try expectExit(first.term, 0);

    // A second `--branch feature` must not re-create the existing open branch.
    try writeFile(io, tmp.dir, "f", "v3\n");
    var second = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "again" });
    defer freeResult(gpa, &second);
    try expectExit(second.term, 1);
    try std.testing.expectEqualStrings("", second.stdout);
    try std.testing.expect(std.mem.indexOf(u8, second.stderr, "already exists") != null);
}

test "cli commit --branch repostate §6.2: rejected (exit 1) on an unborn 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);

    // No root yet: there is no parent to fork a branch from (§6.2).
    var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "x" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "needs a parent") != null);
}

test "cli commit --branch --close repostate §7.4: the check-in is a closed leaf on NAME" {
    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, "f", "v1\n");
    try addManaged(gpa, io, tmp.dir, &.{"f"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(root.stdout));
    defer gpa.free(root_hash);

    // Fork onto feature AND close the new leaf in one commit.
    try writeFile(io, tmp.dir, "f", "v2\n");
    var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "--close", "-m", "closed feature" });
    defer freeResult(gpa, &child);
    try expectExit(child.term, 0);
    const child_hash = try gpa.dupe(u8, try checkinHash(child.stdout));
    defer gpa.free(child_hash);

    // §4.7 +closed is present alongside the branch cards.
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
        defer mc.deinit();
        try std.testing.expect(hasTag(mc.m, .add, "closed"));
        try std.testing.expect(hasTag(mc.m, .propagate, "branch"));
    }

    // §7.4: the closed check-in is not an open leaf, so `leaves` omits it; the
    // still-open trunk root remains.
    var lv = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &lv);
    try expectExit(lv.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, lv.stdout, child_hash) == null);
    try std.testing.expect(std.mem.indexOf(u8, lv.stdout, root_hash) != null);

    // The branch view marks feature current and closed.
    var br = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &br);
    try expectExit(br.term, 0);
    try std.testing.expectEqualStrings("* feature  (closed)\n  trunk\n", br.stdout);
}

// --- revert (artifact §8.4 effective file + §7.2 verified blob) -------------

test "cli revert artifact §8.4/§7.2: a modified managed file is restored to its check-in blob byte-for-byte" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const original = "line one\nline two\n";
    try writeFile(io, tmp.dir, "doc.txt", original);
    try addManaged(gpa, io, tmp.dir, &.{"doc.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // A working edit that revert must discard.
    try writeFile(io, tmp.dir, "doc.txt", "line one\nCHANGED\nline three\n");

    var res = try runZit(gpa, io, tmp.dir, &.{"revert"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("reverted doc.txt\n", res.stdout);

    const restored = try readFile(gpa, io, tmp.dir, "doc.txt");
    defer gpa.free(restored);
    try std.testing.expectEqualStrings(original, restored);

    // The tree quiesces: after revert, diff is clean.
    var diff = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &diff);
    try expectExit(diff.term, 0);
    try std.testing.expectEqualStrings("", diff.stdout);
}

test "cli revert §4.7.6: a PATH-scoped revert restores only the named file, leaving other edits" {
    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", "a-original\n");
    try writeFile(io, tmp.dir, "b", "b-original\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // Edit both; revert only 'a'.
    try writeFile(io, tmp.dir, "a", "a-edited\n");
    try writeFile(io, tmp.dir, "b", "b-edited\n");
    var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "a" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("reverted a\n", res.stdout);

    const a_after = try readFile(gpa, io, tmp.dir, "a");
    defer gpa.free(a_after);
    const b_after = try readFile(gpa, io, tmp.dir, "b");
    defer gpa.free(b_after);
    try std.testing.expectEqualStrings("a-original\n", a_after); // restored
    try std.testing.expectEqualStrings("b-edited\n", b_after); // untouched
}

test "cli revert §4.7.6: a managed file absent from the effective set is reported and skipped, never fabricated" {
    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, "committed", "c\n");
    try addManaged(gpa, io, tmp.dir, &.{"committed"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // 'staged' is managed but never committed — it is not in the check-in's
    // effective set, so revert cannot restore it: reported, left as-is.
    try writeFile(io, tmp.dir, "staged", "s\n");
    try addManaged(gpa, io, tmp.dir, &.{"staged"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "staged" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0); // a skip is informational, not a hard error
    try std.testing.expectEqualStrings("", res.stdout); // nothing reverted
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not in the current check-in") != null);

    // The working file is left exactly as it was — never fabricated/removed.
    const kept = try readFile(gpa, io, tmp.dir, "staged");
    defer gpa.free(kept);
    try std.testing.expectEqualStrings("s\n", kept);
}

test "cli revert artifact §4.7.6-m5: a deleted executable file is restored with its exec bit" {
    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, "script", "#!/bin/sh\necho hi\n");
    try addManaged(gpa, io, tmp.dir, &.{"script"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    // chmod +x and record the executable mode in a child check-in.
    {
        var f = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
        defer f.close(io);
        try f.setPermissions(io, File.Permissions.executable_file);
    }
    var exe = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "chmod +x" });
    defer freeResult(gpa, &exe);
    try expectExit(exe.term, 0);

    // Delete the file, then revert recreates it from the check-in.
    try tmp.dir.deleteFile(io, "script");
    var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "script" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("reverted script\n", res.stdout);

    // Content restored, and — the §4.7.6-m5 point — the exec bit is set again,
    // so diff is clean (a content- or mode-only drift would surface as M).
    const restored = try readFile(gpa, io, tmp.dir, "script");
    defer gpa.free(restored);
    try std.testing.expectEqualStrings("#!/bin/sh\necho hi\n", restored);
    if (comptime File.Permissions.has_executable_bit) {
        const st = try tmp.dir.statFile(io, "script", .{ .follow_symlinks = false });
        try std.testing.expect(st.permissions.toMode() & 0o111 != 0);
    }
    var diff = try runZit(gpa, io, tmp.dir, &.{"diff"});
    defer freeResult(gpa, &diff);
    try expectExit(diff.term, 0);
    try std.testing.expectEqualStrings("", diff.stdout);
}

test "cli revert artifact §4.7.6-m5: a symlink entry surfaces SymlinkUnsupported and is left 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);

    // Track a regular file so the managed set names 'link'; the worktree copy
    // stays a placeholder revert must not overwrite (it will fault first).
    try writeFile(io, tmp.dir, "link", "placeholder\n");
    try addManaged(gpa, io, tmp.dir, &.{"link"});

    // Inject a check-in whose F card for 'link' is a symlink, and point the
    // checkout at it. `zit commit` never mints a symlink card, so this is the
    // one legitimate way to exercise the reader's §4.7.6-m5 rejection.
    const blob = try appendArtifact(gpa, io, tmp.dir, "target/path\n");
    defer gpa.free(blob);
    // The wire mode is the F-card perm token ('l' ⇒ symlink, §4.7.6-m5), so it
    // is set explicitly — `mode` alone is not serialized.
    const files = [_]artifact.FCard{.{
        .filename = "link",
        .hash = blob,
        .perm = "l",
        .old_name = null,
        .mode = .symlink,
    }};
    const manifest_hash = try appendDiffManifest(gpa, io, tmp.dir, "symlink check-in", null, &files);
    defer gpa.free(manifest_hash);
    try checkout.write(io, tmp.dir, .{ .version = manifest_hash, .branch = "trunk", .merge_parent = "" });

    var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "link" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "symlink") != null);

    // The placeholder file is untouched (the fault precedes any write).
    const kept = try readFile(gpa, io, tmp.dir, "link");
    defer gpa.free(kept);
    try std.testing.expectEqualStrings("placeholder\n", kept);
}

test "cli revert: fails exit 1 on an unborn checkout (no check-in to restore)" {
    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, &.{"revert"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unborn") != null);
}

// ==========================================================================
// `zit undo` / `zit redo` — single-level undo/redo (contracts/undo.md). The
// real binary is driven end to end; state is read back through the library.
// ==========================================================================

const undo_help_text =
    \\usage: zit undo [-h|--help]
    \\
    \\Undo the last working-file change made by 'zit update' or 'zit revert',
    \\restoring every affected file and the checkout record to their state before
    \\that change. Single level: only the most recent change can be undone; a second
    \\'zit undo' in a row has nothing left to undo.
    \\
    \\Use 'zit redo' to reapply a change you just undid. A new update or revert
    \\clears the redo slot.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the change was undone
    \\  1  nothing to undo, the journal is damaged, or this is not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

const redo_help_text =
    \\usage: zit redo [-h|--help]
    \\
    \\Reapply the change you just undid with 'zit undo', restoring every affected
    \\file and the checkout record to their state after that change. Single level:
    \\redo is available only immediately after an undo; a new update or revert
    \\clears it.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the change was reapplied
    \\  1  nothing to redo, the journal is damaged, or this is not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// Commits `content` for `name` and returns the resulting check-in hash borrowed
/// from `commit_out` (kept alive by the caller). Two calls build a root→child
/// history the undo tests reverse.
fn commitFile(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    name: []const u8,
    content: []const u8,
    message: []const u8,
    commit_out: *std.process.RunResult,
) ![]const u8 {
    try writeFile(io, dir, name, content);
    try addManaged(gpa, io, dir, &.{name});
    commit_out.* = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", message });
    try expectExit(commit_out.term, 0);
    return checkinHash(commit_out.stdout);
}

test "cli undo B-undo-1: update then undo restores working files and .zit-checkout byte-for-byte" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    // Pre-update state (the child checkout): the exact bytes undo must restore.
    const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(before_content);
    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);
    try expectCheckoutVersion(io, tmp.dir, root);

    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 0);
    try std.testing.expectEqualStrings("undo: working tree restored\n", un.stdout);
    try std.testing.expectEqualStrings("", un.stderr);

    const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_content);
    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    try std.testing.expectEqualStrings("later\n", after_content);
    try std.testing.expectEqualSlices(u8, before_content, after_content);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
}

test "cli undo B-undo-2: revert then undo recovers the discarded working bytes" {
    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 c: std.process.RunResult = undefined;
    defer freeResult(gpa, &c);
    _ = try commitFile(gpa, io, tmp.dir, "f.txt", "v1\n", "v1", &c);

    // A dirty working edit revert will discard.
    try writeFile(io, tmp.dir, "f.txt", "edited\n");

    var rev = try runZit(gpa, io, tmp.dir, &.{"revert"});
    defer freeResult(gpa, &rev);
    try expectExit(rev.term, 0);
    {
        const restored = try readFile(gpa, io, tmp.dir, "f.txt");
        defer gpa.free(restored);
        try std.testing.expectEqualStrings("v1\n", restored); // revert restored the blob
    }

    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 0);
    try std.testing.expectEqualStrings("undo: working tree restored\n", un.stdout);
    const recovered = try readFile(gpa, io, tmp.dir, "f.txt");
    defer gpa.free(recovered);
    try std.testing.expectEqualStrings("edited\n", recovered); // discarded edit recovered
}

test "cli redo B-undo-4/5: update then undo then redo returns to the post-update state" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);
    // Post-update state: tracked.txt == "earlier\n", checkout == root.
    const post_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(post_content);
    const post_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(post_checkout);

    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 0);
    try expectCheckoutVersion(io, tmp.dir, try checkinHash(child_c.stdout));

    var re = try runZit(gpa, io, tmp.dir, &.{"redo"});
    defer freeResult(gpa, &re);
    try expectExit(re.term, 0);
    try std.testing.expectEqualStrings("redo: working tree restored\n", re.stdout);
    try std.testing.expectEqualStrings("", re.stderr);

    const back_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(back_content);
    const back_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(back_checkout);
    try std.testing.expectEqualStrings("earlier\n", back_content);
    try std.testing.expectEqualSlices(u8, post_content, back_content);
    try std.testing.expectEqualSlices(u8, post_checkout, back_checkout);
}

test "cli undo B-undo-6: a second consecutive undo is exit 1, mutating 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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);

    var un1 = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un1);
    try expectExit(un1.term, 0);
    const after_first = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_first);

    var un2 = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un2);
    try expectExit(un2.term, 1);
    try std.testing.expectEqualStrings("", un2.stdout);
    try std.testing.expectEqualStrings("zit undo: nothing to undo\n", un2.stderr);
    const after_second = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_second);
    try std.testing.expectEqualSlices(u8, after_first, after_second); // no mutation
}

test "cli redo B-undo-7: a new update after undo invalidates redo (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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    const child = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd_a = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd_a);
    try expectExit(upd_a.term, 0);
    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 0);
    // A second sheltered mutation clears the redo slot the undo populated.
    var upd_b = try runZit(gpa, io, tmp.dir, &.{ "update", child });
    defer freeResult(gpa, &upd_b);
    try expectExit(upd_b.term, 0);

    var re = try runZit(gpa, io, tmp.dir, &.{"redo"});
    defer freeResult(gpa, &re);
    try expectExit(re.term, 1);
    try std.testing.expectEqualStrings("", re.stdout);
    try std.testing.expectEqualStrings("zit redo: nothing to redo\n", re.stderr);
}

test "cli undo B-undo-8: with two updates, undo restores the last one's pre-image, not the earlier" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    const child = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    // update A (-> root/"earlier"), then update B (-> child/"later").
    var ua = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &ua);
    try expectExit(ua.term, 0);
    var ub = try runZit(gpa, io, tmp.dir, &.{ "update", child });
    defer freeResult(gpa, &ub);
    try expectExit(ub.term, 0);

    // undo restores B's pre-image (the post-A tree: "earlier"/root), NOT A's.
    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 0);
    const content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(content);
    try std.testing.expectEqualStrings("earlier\n", content);
    try expectCheckoutVersion(io, tmp.dir, root);
}

test "cli undo B-undo-9: an empty journal is exit 1, stdout empty, no mutation" {
    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, &.{"undo"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit undo: nothing to undo\n", res.stderr);
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit-undo"));
}

test "cli redo B-undo-10: no redo image is exit 1, stdout empty, no mutation" {
    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, &.{"redo"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit redo: nothing to redo\n", res.stderr);
}

test "cli undo B-undo-11: a corrupt .zit-undo is exit 1 with no partial restore" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);
    const before = try readFile(gpa, io, tmp.dir, "tracked.txt"); // "earlier\n"
    defer gpa.free(before);

    // Overwrite the journal with garbage.
    try writeFile(io, tmp.dir, ".zit-undo", "not a real journal");

    var un = try runZit(gpa, io, tmp.dir, &.{"undo"});
    defer freeResult(gpa, &un);
    try expectExit(un.term, 1);
    try std.testing.expectEqualStrings("", un.stdout);
    try std.testing.expectEqualStrings("zit undo: the undo journal is damaged\n", un.stderr);
    const after = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after);
    try std.testing.expectEqualSlices(u8, before, after); // byte-identical, no partial restore
}

test "cli undo B-undo-13: 'zit add .zit-undo' refuses it as a control file" {
    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-undo" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit add: '.zit-undo' is a zit control file and cannot be tracked\n",
        res.stderr,
    );
}

test "cli undo B-undo-14: update leaves its output/exit unchanged and .zit-undo is the only new side-file" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);
    try std.testing.expectEqualStrings("", upd.stdout); // update's happy path is silent, as today
    try std.testing.expectEqualStrings("", upd.stderr);
    // The one new observable: the journal exists, and update never named it.
    try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo"));
    try std.testing.expect(std.mem.indexOf(u8, upd.stdout, ".zit-undo") == null);
    try std.testing.expect(std.mem.indexOf(u8, upd.stderr, ".zit-undo") == null);
}

test "cli undo B-undo-15: update with no undo invoked reproduces today's tree and 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);

    var root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);

    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);
    // Exactly today's observable result (matches the pre-journal update e2e).
    const content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(content);
    try std.testing.expectEqualStrings("earlier\n", content);
    try expectCheckoutVersion(io, tmp.dir, root);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}

test "cli undo B-undo-16: undo/redo -h/--help print the verbatim help to stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const cases = [_]struct { verb: []const u8, text: []const u8 }{
        .{ .verb = "undo", .text = undo_help_text },
        .{ .verb = "redo", .text = redo_help_text },
    };
    for (cases) |case| {
        for ([_][]const u8{ "--help", "-h" }) |flag| {
            var res = try runZit(gpa, io, tmp.dir, &.{ case.verb, flag });
            defer freeResult(gpa, &res);
            try expectExit(res.term, 0);
            try std.testing.expectEqualStrings(case.text, res.stdout);
            try std.testing.expectEqualStrings("", res.stderr);
        }
        // `-h` wins over an otherwise-invalid trailing token.
        var win = try runZit(gpa, io, tmp.dir, &.{ case.verb, "-h", "bogus" });
        defer freeResult(gpa, &win);
        try expectExit(win.term, 0);
        try std.testing.expectEqualStrings(case.text, win.stdout);
        // `zit help <verb>` teaches identical text (DX D4).
        var routed = try runZit(gpa, io, tmp.dir, &.{ "help", case.verb });
        defer freeResult(gpa, &routed);
        try expectExit(routed.term, 0);
        try std.testing.expectEqualStrings(case.text, routed.stdout);
    }
}

test "cli undo B-undo-17: an unknown option is a usage error (exit 2, stderr try-help, no journal read)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const cases = [_]struct { verb: []const u8, arg: []const u8 }{
        .{ .verb = "undo", .arg = "--force" },
        .{ .verb = "redo", .arg = "-x" },
    };
    for (cases) |case| {
        var res = try runZit(gpa, io, tmp.dir, &.{ case.verb, case.arg });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        var buf: [128]u8 = undefined;
        const expected = try std.fmt.bufPrint(&buf, "zit {s}: unknown option '{s}'\ntry 'zit {s} --help'\n", .{ case.verb, case.arg, case.verb });
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli undo B-undo-18: a positional or token after '--' is a usage error (exit 2)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // A bare positional, and a token after `--`, both reject with the try-help line.
    const cases = [_]struct { verb: []const u8, args: []const []const u8, tok: []const u8 }{
        .{ .verb = "undo", .args = &.{ "undo", "foo" }, .tok = "foo" },
        .{ .verb = "redo", .args = &.{ "redo", "--", "3" }, .tok = "3" },
    };
    for (cases) |case| {
        var res = try runZit(gpa, io, tmp.dir, case.args);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        var buf: [128]u8 = undefined;
        const expected = try std.fmt.bufPrint(&buf, "zit {s}: unexpected argument '{s}'\ntry 'zit {s} --help'\n", .{ case.verb, case.tok, case.verb });
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli undo B-undo-19: outside a repository is exit 1, stdout empty" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "undo", "redo" }) |verb| {
        var res = try runZit(gpa, io, tmp.dir, &.{verb});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
    }
}

test "cli undo B-undo-20: a redirected undo keeps stdout and stderr unmixed" {
    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 root_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &root_c);
    const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c);
    var child_c: std.process.RunResult = undefined;
    defer freeResult(gpa, &child_c);
    _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c);
    var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &upd);
    try expectExit(upd.term, 0);

    // Success row to stdout only; the failed second undo's diagnostic to stderr
    // only — the two files never cross-contaminate.
    var sh = try runShell(gpa, io, tmp.dir, "\"$ZIT\" undo >out.txt 2>err.txt; \"$ZIT\" undo >out2.txt 2>err2.txt");
    defer freeResult(gpa, &sh);

    const out = try readFile(gpa, io, tmp.dir, "out.txt");
    defer gpa.free(out);
    const err = try readFile(gpa, io, tmp.dir, "err.txt");
    defer gpa.free(err);
    try std.testing.expectEqualStrings("undo: working tree restored\n", out);
    try std.testing.expectEqualStrings("", err);

    const out2 = try readFile(gpa, io, tmp.dir, "out2.txt");
    defer gpa.free(out2);
    const err2 = try readFile(gpa, io, tmp.dir, "err2.txt");
    defer gpa.free(err2);
    try std.testing.expectEqualStrings("", out2);
    try std.testing.expectEqualStrings("zit undo: nothing to undo\n", err2);
}

test "cli undo B-undo-21: undo and redo are listed in the top-level help" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\n  undo       undo the last update or revert\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\n  redo       reapply an undone change\n") != null);
}

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

// ==========================================================================
// `zit update CHECKIN` end-to-end. These tests exercise the public command
// against real check-ins and synthetic delta fixtures without reaching into
// the update implementation.
// ==========================================================================

test "cli update: a clean canonical target restores earlier content and checkout ID" {
    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, "tracked.txt", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});

    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);

    try writeFile(io, tmp.dir, "tracked.txt", "later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 0);
    try std.testing.expectEqualStrings("", update.stderr);

    const restored = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(restored);
    try std.testing.expectEqualStrings("earlier\n", restored);
    try expectCheckoutVersion(io, tmp.dir, root);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}

test "cli update: a non-manifest target leaves the clean checkout 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 writeFile(io, tmp.dir, "tracked.txt", "kept\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "kept" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const current = try checkinHash(commit.stdout);

    const non_manifest = try appendArtifact(gpa, io, tmp.dir, "not a manifest\n");
    defer gpa.free(non_manifest);
    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(before_content);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", non_manifest });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_content);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_content, after_content);
    try expectCheckoutVersion(io, tmp.dir, current);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}

test "cli update: a dirty checkout leaves its managed content and checkout 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 writeFile(io, tmp.dir, "tracked.txt", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});

    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);

    try writeFile(io, tmp.dir, "tracked.txt", "committed later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);

    try writeFile(io, tmp.dir, "tracked.txt", "uncommitted change\n");
    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(before_content);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_content);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_content, after_content);
    try expectCheckoutVersion(io, tmp.dir, child);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}

test "cli update: a delta target materializes content inherited from its baseline" {
    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, "inherited.txt", "current content\n");
    try addManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
    var current_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "current" });
    defer freeResult(gpa, &current_commit);
    try expectExit(current_commit.term, 0);

    const inherited = "inherited baseline content\n";
    const blob = try appendArtifact(gpa, io, tmp.dir, inherited);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "inherited.txt",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const baseline = try appendDiffManifest(gpa, io, tmp.dir, "baseline", null, &files);
    defer gpa.free(baseline);
    const delta = try appendDiffManifest(gpa, io, tmp.dir, "delta", baseline, &.{});
    defer gpa.free(delta);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", delta });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 0);
    try std.testing.expectEqualStrings("", update.stderr);

    const materialized = try readFile(gpa, io, tmp.dir, "inherited.txt");
    defer gpa.free(materialized);
    try std.testing.expectEqualStrings(inherited, materialized);
    try expectCheckoutVersion(io, tmp.dir, delta);
    try expectManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
}

test "cli update: non-ancestor control branch records set then cancel the target branch" {
    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, "tracked.txt", "content\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "target" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const target = try checkinHash(commit.stdout);

    try writeFile(io, tmp.dir, "tracked.txt", "child content\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "child" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);

    const set_control_id = try appendBranchControl(
        gpa,
        io,
        tmp.dir,
        "2020-01-01T00:00:00",
        .propagate,
        target,
        "feature",
    );
    defer gpa.free(set_control_id);

    var set_update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &set_update);
    try expectExit(set_update.term, 0);
    try std.testing.expectEqualStrings("", set_update.stderr);
    {
        var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        const current = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
        try std.testing.expectEqualStrings(target, current.version);
        try std.testing.expectEqualStrings("feature", current.branch);
    }

    var reset = try runZit(gpa, io, tmp.dir, &.{ "update", child });
    defer freeResult(gpa, &reset);
    try expectExit(reset.term, 0);
    try std.testing.expectEqualStrings("", reset.stderr);
    try expectCheckoutVersion(io, tmp.dir, child);

    const cancel_control_id = try appendBranchControl(
        gpa,
        io,
        tmp.dir,
        "2020-01-02T00:00:00",
        .cancel,
        target,
        null,
    );
    defer gpa.free(cancel_control_id);

    var cancel_update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &cancel_update);
    try expectExit(cancel_update.term, 0);
    try std.testing.expectEqualStrings("", cancel_update.stderr);
    {
        var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        const current = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
        try std.testing.expectEqualStrings(target, current.version);
        try std.testing.expectEqualStrings("trunk", current.branch);
    }
}

test "cli update: unsafe and root-control target paths leave the checkout and outside sentinel unchanged" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    inline for (.{ ".zit", ".zit-checkout", ".zit-managed", "../outside/sentinel" }) |target_name| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try tmp.dir.createDir(io, "repo", .default_dir);
        try tmp.dir.createDir(io, "outside", .default_dir);
        var repo = try tmp.dir.openDir(io, "repo", .{});
        defer repo.close(io);
        var outside = try tmp.dir.openDir(io, "outside", .{});
        defer outside.close(io);

        try initRepo(gpa, io, repo);
        try writeFile(io, repo, "tracked.txt", "current\n");
        try addManaged(gpa, io, repo, &.{"tracked.txt"});
        var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);
        const current = try checkinHash(commit.stdout);
        try writeFile(io, outside, "sentinel", "outside must survive\n");

        const payload = try appendArtifact(gpa, io, repo, "target content\n");
        defer gpa.free(payload);
        const files = [_]artifact.FCard{.{
            .filename = target_name,
            .hash = payload,
            .perm = null,
            .old_name = null,
            .mode = .regular,
        }};
        const target = try appendDiffManifest(gpa, io, repo, "unsafe target", null, &files);
        defer gpa.free(target);

        const before_log = try readFile(gpa, io, repo, ".zit");
        defer gpa.free(before_log);
        const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
        defer gpa.free(before_checkout);
        const before_managed = try readFile(gpa, io, repo, ".zit-managed");
        defer gpa.free(before_managed);
        const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
        defer gpa.free(before_tracked);
        const before_sentinel = try readFile(gpa, io, outside, "sentinel");
        defer gpa.free(before_sentinel);

        var update = try runZit(gpa, io, repo, &.{ "update", target });
        defer freeResult(gpa, &update);
        try expectExit(update.term, 1);
        try std.testing.expectEqualStrings("", update.stdout);

        const after_log = try readFile(gpa, io, repo, ".zit");
        defer gpa.free(after_log);
        const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
        defer gpa.free(after_checkout);
        const after_managed = try readFile(gpa, io, repo, ".zit-managed");
        defer gpa.free(after_managed);
        const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
        defer gpa.free(after_tracked);
        const after_sentinel = try readFile(gpa, io, outside, "sentinel");
        defer gpa.free(after_sentinel);
        try std.testing.expectEqualSlices(u8, before_log, after_log);
        try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
        try std.testing.expectEqualSlices(u8, before_managed, after_managed);
        try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
        try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
        try expectCheckoutVersion(io, repo, current);
        try expectManaged(gpa, io, repo, &.{"tracked.txt"});
    }
}

test "cli update: a target F-card cannot replace a symlink with an outside target" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDir(io, "repo", .default_dir);
    try tmp.dir.createDir(io, "outside", .default_dir);
    var repo = try tmp.dir.openDir(io, "repo", .{});
    defer repo.close(io);
    var outside = try tmp.dir.openDir(io, "outside", .{});
    defer outside.close(io);

    try initRepo(gpa, io, repo);
    try writeFile(io, repo, "tracked.txt", "current\n");
    try addManaged(gpa, io, repo, &.{"tracked.txt"});
    var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const current = try checkinHash(commit.stdout);
    try writeFile(io, outside, "sentinel", "outside must survive\n");
    try repo.symLink(io, "../outside/sentinel", "link", .{});

    const payload = try appendArtifact(gpa, io, repo, "target content\n");
    defer gpa.free(payload);
    const files = [_]artifact.FCard{.{
        .filename = "link",
        .hash = payload,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const target = try appendDiffManifest(gpa, io, repo, "symlink target", null, &files);
    defer gpa.free(target);

    const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, repo, ".zit-managed");
    defer gpa.free(before_managed);
    const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
    defer gpa.free(before_tracked);
    const before_sentinel = try readFile(gpa, io, outside, "sentinel");
    defer gpa.free(before_sentinel);

    var update = try runZit(gpa, io, repo, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, repo, ".zit-managed");
    defer gpa.free(after_managed);
    const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
    defer gpa.free(after_tracked);
    const after_link = try readFile(gpa, io, repo, "link");
    defer gpa.free(after_link);
    const after_sentinel = try readFile(gpa, io, outside, "sentinel");
    defer gpa.free(after_sentinel);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
    try std.testing.expectEqualSlices(u8, before_sentinel, after_link);
    try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
    try expectCheckoutVersion(io, repo, current);
    try expectManaged(gpa, io, repo, &.{"tracked.txt"});
}

test "cli update: a symlink ancestor is rejected without following it outside the repository" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDir(io, "repo", .default_dir);
    try tmp.dir.createDir(io, "outside", .default_dir);
    var repo = try tmp.dir.openDir(io, "repo", .{});
    defer repo.close(io);
    var outside = try tmp.dir.openDir(io, "outside", .{});
    defer outside.close(io);

    try initRepo(gpa, io, repo);
    try writeFile(io, repo, "tracked.txt", "current\n");
    try addManaged(gpa, io, repo, &.{"tracked.txt"});
    var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const current = try checkinHash(commit.stdout);
    try writeFile(io, outside, "sentinel", "outside must survive\n");
    try repo.symLink(io, "../outside", "link", .{});

    const payload = try appendArtifact(gpa, io, repo, "target content\n");
    defer gpa.free(payload);
    const files = [_]artifact.FCard{.{
        .filename = "link/sentinel",
        .hash = payload,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const target = try appendDiffManifest(gpa, io, repo, "symlink ancestor", null, &files);
    defer gpa.free(target);

    const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, repo, ".zit-managed");
    defer gpa.free(before_managed);
    const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
    defer gpa.free(before_tracked);
    const before_sentinel = try readFile(gpa, io, outside, "sentinel");
    defer gpa.free(before_sentinel);

    var update = try runZit(gpa, io, repo, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, repo, ".zit-managed");
    defer gpa.free(after_managed);
    const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
    defer gpa.free(after_tracked);
    const after_sentinel = try readFile(gpa, io, outside, "sentinel");
    defer gpa.free(after_sentinel);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
    try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
    try expectCheckoutVersion(io, repo, current);
    try expectManaged(gpa, io, repo, &.{"tracked.txt"});
}

test "cli update: a clean managed file can become a directory containing a managed file" {
    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, "node", "old file\n");
    try addManaged(gpa, io, tmp.dir, &.{"node"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "file" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    const child_content = "new child\n";
    const blob = try appendArtifact(gpa, io, tmp.dir, child_content);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "node/child.txt",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const target = try appendDiffManifest(gpa, io, tmp.dir, "file to directory", null, &files);
    defer gpa.free(target);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 0);
    try std.testing.expectEqualStrings("", update.stderr);
    const child = try readFile(gpa, io, tmp.dir, "node/child.txt");
    defer gpa.free(child);
    try std.testing.expectEqualStrings(child_content, child);
    try expectCheckoutVersion(io, tmp.dir, target);
    try expectManaged(gpa, io, tmp.dir, &.{"node/child.txt"});
}

test "cli update: a clean managed directory can become a file" {
    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 tmp.dir.createDir(io, "node", .default_dir);
    try writeFile(io, tmp.dir, "node/child.txt", "old child\n");
    try addManaged(gpa, io, tmp.dir, &.{"node/child.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "directory" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    const file_content = "new file\n";
    const blob = try appendArtifact(gpa, io, tmp.dir, file_content);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "node",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const target = try appendDiffManifest(gpa, io, tmp.dir, "directory to file", null, &files);
    defer gpa.free(target);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 0);
    try std.testing.expectEqualStrings("", update.stderr);
    const node = try readFile(gpa, io, tmp.dir, "node");
    defer gpa.free(node);
    try std.testing.expectEqualStrings(file_content, node);
    try std.testing.expect(!fileExists(io, tmp.dir, "node/child.txt"));
    try expectCheckoutVersion(io, tmp.dir, target);
    try expectManaged(gpa, io, tmp.dir, &.{"node"});
}

test "cli update: an unmanaged descendant blocks a directory-to-file transition without mutation" {
    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 tmp.dir.createDir(io, "node", .default_dir);
    try writeFile(io, tmp.dir, "node/tracked.txt", "managed child\n");
    try addManaged(gpa, io, tmp.dir, &.{"node/tracked.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "directory" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const current = try checkinHash(commit.stdout);
    try writeFile(io, tmp.dir, "node/unmanaged.txt", "must survive\n");

    const blob = try appendArtifact(gpa, io, tmp.dir, "would replace directory\n");
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "node",
        .hash = blob,
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const target = try appendDiffManifest(gpa, io, tmp.dir, "blocked directory to file", null, &files);
    defer gpa.free(target);

    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_tracked = try readFile(gpa, io, tmp.dir, "node/tracked.txt");
    defer gpa.free(before_tracked);
    const before_unmanaged = try readFile(gpa, io, tmp.dir, "node/unmanaged.txt");
    defer gpa.free(before_unmanaged);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_tracked = try readFile(gpa, io, tmp.dir, "node/tracked.txt");
    defer gpa.free(after_tracked);
    const after_unmanaged = try readFile(gpa, io, tmp.dir, "node/unmanaged.txt");
    defer gpa.free(after_unmanaged);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
    try std.testing.expectEqualSlices(u8, before_unmanaged, after_unmanaged);
    try expectCheckoutVersion(io, tmp.dir, current);
    try expectManaged(gpa, io, tmp.dir, &.{"node/tracked.txt"});
}

test "cli update: an executable F-card materializes an executable regular file" {
    if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;

    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, "script", "old\n");
    try addManaged(gpa, io, tmp.dir, &.{"script"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "regular" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    const script_content = "#!/bin/sh\necho updated\n";
    const blob = try appendArtifact(gpa, io, tmp.dir, script_content);
    defer gpa.free(blob);
    const files = [_]artifact.FCard{.{
        .filename = "script",
        .hash = blob,
        .perm = "x",
        .old_name = null,
        .mode = .executable,
    }};
    const target = try appendDiffManifest(gpa, io, tmp.dir, "executable target", null, &files);
    defer gpa.free(target);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 0);
    try std.testing.expectEqualStrings("", update.stderr);
    const script = try readFile(gpa, io, tmp.dir, "script");
    defer gpa.free(script);
    try std.testing.expectEqualStrings(script_content, script);
    var script_file = try tmp.dir.openFile(io, "script", .{ .mode = .read_only });
    defer script_file.close(io);
    try std.testing.expect((try script_file.stat(io)).permissions.toMode() & 0o111 != 0);
    try expectCheckoutVersion(io, tmp.dir, target);
    try expectManaged(gpa, io, tmp.dir, &.{"script"});
}

test "cli update: dirty managed membership preserves the current 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, "tracked.txt", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);
    try writeFile(io, tmp.dir, "tracked.txt", "later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);
    try writeFile(io, tmp.dir, "added.txt", "new membership\n");
    try addManaged(gpa, io, tmp.dir, &.{"added.txt"});

    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(before_tracked);
    const before_added = try readFile(gpa, io, tmp.dir, "added.txt");
    defer gpa.free(before_added);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
    defer gpa.free(after_tracked);
    const after_added = try readFile(gpa, io, tmp.dir, "added.txt");
    defer gpa.free(after_added);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
    try std.testing.expectEqualSlices(u8, before_added, after_added);
    try expectCheckoutVersion(io, tmp.dir, child);
    try expectManaged(gpa, io, tmp.dir, &.{ "tracked.txt", "added.txt" });
}

test "cli update: a missing managed file preserves the current 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, "tracked.txt", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);
    try writeFile(io, tmp.dir, "tracked.txt", "later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);
    try tmp.dir.deleteFile(io, "tracked.txt");

    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expect(!fileExists(io, tmp.dir, "tracked.txt"));
    try expectCheckoutVersion(io, tmp.dir, child);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}

test "cli update: managed executable-mode drift preserves the current checkout" {
    if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;

    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, "script", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"script"});
    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);
    try writeFile(io, tmp.dir, "script", "later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);
    {
        var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
        defer script.close(io);
        try script.setPermissions(io, File.Permissions.executable_file);
    }

    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_script = try readFile(gpa, io, tmp.dir, "script");
    defer gpa.free(before_script);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_script = try readFile(gpa, io, tmp.dir, "script");
    defer gpa.free(after_script);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_script, after_script);
    var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_only });
    defer script.close(io);
    try std.testing.expect((try script.stat(io)).permissions.toMode() & 0o111 != 0);
    try expectCheckoutVersion(io, tmp.dir, child);
    try expectManaged(gpa, io, tmp.dir, &.{"script"});
}

test "cli update: managed type drift preserves the current 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, "tracked", "earlier\n");
    try addManaged(gpa, io, tmp.dir, &.{"tracked"});
    var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
    defer freeResult(gpa, &root_commit);
    try expectExit(root_commit.term, 0);
    const root = try checkinHash(root_commit.stdout);
    try writeFile(io, tmp.dir, "tracked", "later\n");
    var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
    defer freeResult(gpa, &child_commit);
    try expectExit(child_commit.term, 0);
    const child = try checkinHash(child_commit.stdout);
    try tmp.dir.deleteFile(io, "tracked");
    try tmp.dir.createDir(io, "tracked", .default_dir);
    try writeFile(io, tmp.dir, "tracked/nested.txt", "type drift\n");

    const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(before_checkout);
    const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(before_managed);
    const before_nested = try readFile(gpa, io, tmp.dir, "tracked/nested.txt");
    defer gpa.free(before_nested);

    var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
    defer freeResult(gpa, &update);
    try expectExit(update.term, 1);
    try std.testing.expectEqualStrings("", update.stdout);

    const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(after_checkout);
    const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
    defer gpa.free(after_managed);
    const after_nested = try readFile(gpa, io, tmp.dir, "tracked/nested.txt");
    defer gpa.free(after_nested);
    try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
    try std.testing.expectEqualSlices(u8, before_managed, after_managed);
    try std.testing.expectEqualSlices(u8, before_nested, after_nested);
    try expectCheckoutVersion(io, tmp.dir, child);
    try expectManaged(gpa, io, tmp.dir, &.{"tracked"});
}

test "cli update: malformed and missing check-in IDs do not mutate a repository" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    const Case = struct {
        id: []const u8,
        diagnostic: []const u8,
    };
    const cases = [_]Case{
        .{ .id = "not-an-id", .diagnostic = "invalid check-in ID" },
        .{
            .id = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
            .diagnostic = "not found",
        },
    };

    inline for (cases) |case| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "tracked.txt", "current\n");
        try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "current" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);
        const current = try checkinHash(commit.stdout);

        const before_log = try readFile(gpa, io, tmp.dir, ".zit");
        defer gpa.free(before_log);
        const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
        defer gpa.free(before_checkout);
        const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
        defer gpa.free(before_managed);
        const before_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
        defer gpa.free(before_tracked);

        var update = try runZit(gpa, io, tmp.dir, &.{ "update", case.id });
        defer freeResult(gpa, &update);
        try expectExit(update.term, 1);
        try std.testing.expectEqualStrings("", update.stdout);
        try std.testing.expect(std.mem.startsWith(u8, update.stderr, "zit update: "));
        try std.testing.expect(std.mem.indexOf(u8, update.stderr, case.diagnostic) != null);
        try std.testing.expect(std.mem.endsWith(u8, update.stderr, "\n"));

        const after_log = try readFile(gpa, io, tmp.dir, ".zit");
        defer gpa.free(after_log);
        const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
        defer gpa.free(after_checkout);
        const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
        defer gpa.free(after_managed);
        const after_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
        defer gpa.free(after_tracked);
        try std.testing.expectEqualSlices(u8, before_log, after_log);
        try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
        try std.testing.expectEqualSlices(u8, before_managed, after_managed);
        try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
        try expectCheckoutVersion(io, tmp.dir, current);
        try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
    }
}

test "cli update: direct and routed help agree, and missing, extra, and invalid arguments are usage errors" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var direct = try runZit(gpa, io, tmp.dir, &.{ "update", "--help" });
        defer freeResult(gpa, &direct);
        try expectExit(direct.term, 0);
        try std.testing.expect(std.mem.startsWith(u8, direct.stdout, "usage: zit update "));
        try std.testing.expectEqualStrings("", direct.stderr);

        var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "update" });
        defer freeResult(gpa, &routed);
        try expectExit(routed.term, 0);
        try std.testing.expectEqualSlices(u8, direct.stdout, routed.stdout);
        try std.testing.expectEqualStrings("", routed.stderr);
    }

    const Case = struct {
        argv: []const []const u8,
        stderr: []const u8,
    };
    const cases = [_]Case{
        .{
            .argv = &.{"update"},
            .stderr = "zit update: no check-in given\ntry 'zit update --help'\n",
        },
        .{
            .argv = &.{ "update", "a", "extra" },
            .stderr = "zit update: unexpected argument 'extra'\ntry 'zit update --help'\n",
        },
        .{
            .argv = &.{ "update", "-x" },
            .stderr = "zit update: unknown option '-x'\ntry 'zit update --help'\n",
        },
    };
    inline for (cases) |case| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, case.argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings(case.stderr, res.stderr);
    }
}

// ==========================================================================
// `zit log CHECKIN` end-to-end. These tests spawn the real binary to observe
// log output. Synthetic fixtures write artifacts directly when a graph or
// corruption state cannot be constructed through the public CLI.
// ==========================================================================

test "cli log: a root check-in emits exactly its full ID" {
    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 commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const root = try checkinHash(commit.stdout);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [80]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{root});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: a child emits itself then its parent" {
    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", "one\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var c1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &c1);
    try expectExit(c1.term, 0);
    const root = try checkinHash(c1.stdout);

    try writeFile(io, tmp.dir, "a.txt", "two\n");
    var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "child" });
    defer freeResult(gpa, &c2);
    try expectExit(c2.term, 0);
    const child = try checkinHash(c2.stdout);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [160]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\ncheckin {s}\n", .{ child, root });
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: an uppercase P-card parent ID remains a reachable invalid target" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const parent = try appendSyntheticCheckin(gpa, io, tmp.dir, "parent", null);
    defer gpa.free(parent);
    var uppercase_parent: [64]u8 = undefined;
    for (parent, &uppercase_parent) |byte, *destination| {
        destination.* = std.ascii.toUpper(byte);
    }
    try std.testing.expect(std.mem.indexOfAny(u8, uppercase_parent[0..], "ABCDEF") != null);
    const child = try appendRawCheckinWithUppercaseParent(gpa, io, tmp.dir, uppercase_parent[0..]);
    defer gpa.free(child);

    var stored_child = try parseCheckin(gpa, io, tmp.dir, child);
    defer stored_child.deinit();
    var expected_parent_card_buf: [67]u8 = undefined;
    const expected_parent_card = try std.fmt.bufPrint(
        &expected_parent_card_buf,
        "P {s}\n",
        .{uppercase_parent},
    );
    try std.testing.expect(std.mem.indexOf(u8, stored_child.bytes, expected_parent_card) != null);
    try std.testing.expectEqual(@as(usize, 1), stored_child.m.p.len);
    try std.testing.expectEqualStrings(uppercase_parent[0..], stored_child.m.p[0].parents.?);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    var want: [80]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{child});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings(
        "zit log: reachable check-in is not a valid manifest\n",
        res.stderr,
    );
}

fn appendArtifactAs(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    algo: artifact.HashAlgo,
    bytes: []const u8,
) ![]u8 {
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(algo, bytes, &name_buf);
    var digest: [32]u8 = undefined;
    const decoded = digest[0 .. name.len / 2];
    _ = try std.fmt.hexToBytes(decoded, name);

    var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
    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);
    _ = try log.put(decoded, bytes);
    try log.commit();
    return gpa.dupe(u8, name);
}

fn appendArtifact(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, bytes: []const u8) ![]u8 {
    return appendArtifactAs(gpa, io, dir, .sha3_256, bytes);
}

fn appendRawCheckinWithUppercaseParent(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    uppercase_parent: []const u8,
) ![]u8 {
    std.debug.assert(uppercase_parent.len == 64);

    var body_buf: [256]u8 = undefined;
    const body = try std.fmt.bufPrint(
        &body_buf,
        "C child\n" ++
            "D 2020-01-01T00:00:00\n" ++
            "F f 0000000000000000000000000000000000000000000000000000000000000000\n" ++
            "P {s}\n" ++
            "U u\n",
        .{uppercase_parent},
    );
    var raw_buf: [256]u8 = undefined;
    @memcpy(raw_buf[0..body.len], body);
    var checksum: [32]u8 = undefined;
    const digest = artifact.selfChecksum(body, &checksum);
    raw_buf[body.len] = 'Z';
    raw_buf[body.len + 1] = ' ';
    @memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
    raw_buf[body.len + 34] = '\n';

    return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}

fn appendSyntheticCheckinAs(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    algo: artifact.HashAlgo,
    label: []const u8,
    parents: ?[]const u8,
) ![]u8 {
    const f = [_]artifact.FCard{.{
        .filename = "f",
        .hash = "0000000000000000000000000000000000000000000000000000000000000000",
        .perm = null,
        .old_name = null,
        .mode = .regular,
    }};
    const p = [_]artifact.PCard{.{
        .parents = parents,
        .count = if (parents) |wire| std.mem.countScalar(u8, wire, ' ') + 1 else 0,
    }};
    var manifest: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = label },
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &f,
        .j = &.{},
        .m = &.{},
        .p = if (parents == null) &.{} else &p,
        .q = &.{},
        .t = &.{},
    };
    defer manifest.deinit();
    const bytes = try artifact.create(&manifest, gpa);
    defer gpa.free(bytes);
    return appendArtifactAs(gpa, io, dir, algo, bytes);
}

fn appendSyntheticCheckin(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    label: []const u8,
    parents: ?[]const u8,
) ![]u8 {
    return appendSyntheticCheckinAs(gpa, io, dir, .sha3_256, label, parents);
}

test "cli log: merge parents are traversed depth-first in P-card 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);

    const a = try appendSyntheticCheckin(gpa, io, tmp.dir, "a", null);
    defer gpa.free(a);
    const b = try appendSyntheticCheckin(gpa, io, tmp.dir, "b", null);
    defer gpa.free(b);
    const c = try appendSyntheticCheckin(gpa, io, tmp.dir, "c", a);
    defer gpa.free(c);
    var parents_buf: [129]u8 = undefined;
    const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ c, b });
    const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents);
    defer gpa.free(merge);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", merge });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [320]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &want,
        "checkin {s}\ncheckin {s}\ncheckin {s}\ncheckin {s}\n",
        .{ merge, c, a, b },
    );
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: first visit wins across a diamond" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const base = try appendSyntheticCheckin(gpa, io, tmp.dir, "base", null);
    defer gpa.free(base);
    const left = try appendSyntheticCheckin(gpa, io, tmp.dir, "left", base);
    defer gpa.free(left);
    const right = try appendSyntheticCheckin(gpa, io, tmp.dir, "right", base);
    defer gpa.free(right);
    var parents_buf: [129]u8 = undefined;
    const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ left, right });
    const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "top", parents);
    defer gpa.free(top);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [320]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &want,
        "checkin {s}\ncheckin {s}\ncheckin {s}\ncheckin {s}\n",
        .{ top, left, base, right },
    );
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: a valid missing reachable parent is printed once as a phantom" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const missing = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
    var parents_buf: [129]u8 = undefined;
    const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ missing, missing });
    const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "phantom-parent", parents);
    defer gpa.free(top);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [160]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\nphantom {s}\n", .{ top, missing });
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: a missing root check-in fails without output" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const missing = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
    var res = try runZit(gpa, io, tmp.dir, &.{ "log", missing });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    var want: [160]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "zit log: check-in '{s}' not found\n", .{missing});
    try std.testing.expectEqualStrings(expected, res.stderr);
}

test "cli log: a malformed stored root is rejected as an invalid manifest" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const bad = try appendArtifact(gpa, io, tmp.dir, "not a manifest");
    defer gpa.free(bad);
    var res = try runZit(gpa, io, tmp.dir, &.{ "log", bad });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    var want: [192]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "zit log: check-in '{s}' is not a valid manifest\n", .{bad});
    try std.testing.expectEqualStrings(expected, res.stderr);
}

test "cli log: a malformed reachable target fails after its valid ancestor" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const bad = try appendArtifact(gpa, io, tmp.dir, "not a manifest");
    defer gpa.free(bad);
    const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "bad-parent", bad);
    defer gpa.free(top);
    var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    var want: [80]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{top});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings(
        "zit log: reachable check-in is not a valid manifest\n",
        res.stderr,
    );
}

fn appendSyntheticCluster(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    const members = [_]artifact.MCard{.{
        .hash = "1111111111111111111111111111111111111111111111111111111111111111",
    }};
    var manifest: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .cluster,
        .a = null,
        .b = null,
        .c = null,
        .d = null,
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = null,
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &members,
        .p = &.{},
        .q = &.{},
        .t = &.{},
    };
    defer manifest.deinit();
    const bytes = try artifact.create(&manifest, gpa);
    defer gpa.free(bytes);
    return appendArtifact(gpa, io, dir, bytes);
}

test "cli log: a reachable non-check-in artifact is rejected" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const cluster = try appendSyntheticCluster(gpa, io, tmp.dir);
    defer gpa.free(cluster);
    const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "cluster-parent", cluster);
    defer gpa.free(top);
    var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    var want: [80]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{top});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings(
        "zit log: reachable check-in is not a valid manifest\n",
        res.stderr,
    );
}

test "cli log: a torn tail is rejected without truncating the repository" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
    defer gpa.free(root);
    var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
    const before = (try file.stat(io)).size;
    try file.writePositionalAll(io, "\x00", before);
    const torn_size = (try file.stat(io)).size;
    file.close(io);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit log: cannot read repository: repository is corrupt\n",
        res.stderr,
    );
    var after_file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
    defer after_file.close(io);
    try std.testing.expectEqual(torn_size, (try after_file.stat(io)).size);
}

test "cli log: a stored SHA-1 parent is traversed by its 40-hex P-card ID" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const parent = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha1, "sha1-parent", null);
    defer gpa.free(parent);
    try std.testing.expectEqual(@as(usize, 40), parent.len);
    const child = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha3_256, "sha3-child", parent);
    defer gpa.free(child);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [128]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\ncheckin {s}\n", .{ child, parent });
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: a stored SHA-1 root is addressed by its 40-hex full ID" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const root = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha1, "sha1-root", null);
    defer gpa.free(root);
    try std.testing.expectEqual(@as(usize, 40), root.len);
    var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var want: [64]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{root});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli log: content stored under the wrong full ID fails integrity" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const real = try appendSyntheticCheckin(gpa, io, tmp.dir, "real", null);
    defer gpa.free(real);
    var checkin = try parseCheckin(gpa, io, tmp.dir, real);
    defer checkin.deinit();
    const wrong = "2222222222222222222222222222222222222222222222222222222222222222";
    var wrong_digest: [32]u8 = undefined;
    _ = try std.fmt.hexToBytes(&wrong_digest, wrong);
    var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
    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);
    _ = try log.put(&wrong_digest, checkin.bytes);
    try log.commit();

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

test "cli log: a corrupt complete frame is rejected without truncation" {
    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 initial = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
    const frame_offset = (try initial.stat(io)).size;
    initial.close(io);
    const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
    defer gpa.free(root);

    var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
    const stable_size = (try file.stat(io)).size;
    var byte: [1]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 1), try file.readPositionalAll(io, &byte, frame_offset + 10));
    byte[0] ^= 0xff;
    try file.writePositionalAll(io, &byte, frame_offset + 10);
    file.close(io);

    var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit log: cannot read repository: repository is corrupt\n",
        res.stderr,
    );
    var after = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
    defer after.close(io);
    try std.testing.expectEqual(stable_size, (try after.stat(io)).size);
}

test "cli log: help documents only the signed one-verb surface" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const expected =
        \\usage: zit log CHECKIN
        \\
        \\Show CHECKIN followed by every reachable parent check-in in depth-first
        \\P-card order. CHECKIN must be an exact 40- or 64-character lowercase
        \\hexadecimal artifact ID. Each check-in is shown once; a referenced parent
        \\missing from this repository is shown once as a phantom.
        \\
        \\options:
        \\  -h, --help    show this help and exit
        \\
    ;

    var direct = try runZit(gpa, io, tmp.dir, &.{ "log", "--help", "ignored" });
    defer freeResult(gpa, &direct);
    try expectExit(direct.term, 0);
    try std.testing.expectEqualStrings(expected, direct.stdout);
    try std.testing.expectEqualStrings("", direct.stderr);

    var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "log" });
    defer freeResult(gpa, &topic);
    try expectExit(topic.term, 0);
    try std.testing.expectEqualStrings(expected, topic.stdout);
    try std.testing.expectEqualStrings("", topic.stderr);
}

test "cli log: usage rejects missing, extra, flags, and non-canonical IDs" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

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

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

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

    const uppercase = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    var invalid = try runZit(gpa, io, tmp.dir, &.{ "log", uppercase });
    defer freeResult(gpa, &invalid);
    try expectExit(invalid.term, 1);
    try std.testing.expectEqualStrings("", invalid.stdout);
    var want: [192]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &want,
        "zit log: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
        .{uppercase},
    );
    try std.testing.expectEqualStrings(expected, invalid.stderr);
}

// ==========================================================================
// `zit leaves` end-to-end (repo-state §7.1/§7.4/§7.5/§7.7 + CLI discipline).
// Each test spawns the REAL binary in an isolated tmp dir, asserts exit code
// and stdout/stderr, and builds its DAG through real commits (a fork via
// `commit` → `update` → `commit`) plus, where a closed leaf is needed, a
// synthesized `closed` control artifact (no CLI verb closes a leaf yet).
// Teardown is unconditional.
// ==========================================================================

/// Counts newline-terminated lines in `s` (the leaf listing is one line per
/// leaf, so this is the count a `| wc -l` pipe would report).
fn lineCount(s: []const u8) usize {
    return std.mem.count(u8, s, "\n");
}

test "cli leaves B-leaves-8: 'leaves' is listed in the command help and suggested for a near-miss" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // The top-level command list names the verb (discoverability).
    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "leaves") != null);

    // A near-miss typo suggests the real verb (did-you-mean over known_verbs).
    var typo = try runZit(gpa, io, tmp.dir, &.{"leavez"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'leaves'?") != null);
}

test "cli leaves B-leaves-9: --help and 'help leaves' print the verbatim help to stdout, exit 0, empty stderr" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const expected =
        \\usage: zit leaves [--all] [--no-cache] [-h|--help]
        \\
        \\List the open leaves of the check-in DAG — check-ins with no child on the
        \\same branch that carry no active closed tag. Each line shows the check-in's
        \\full artifact ID and its branch. Leaves are ordered most-recent first; the
        \\leaf matching the current checkout is marked with a leading "*".
        \\
        \\options:
        \\      --all       also list closed leaves, each marked "(closed)"
        \\      --no-cache  bypass the cache and re-derive from the full log,
        \\                  re-verifying whole-log integrity (the cache is refreshed)
        \\  -h, --help      show this help and exit
        \\
    ;
    for ([_][]const u8{ "--help", "-h" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "leaves", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    // `zit help leaves` teaches the identical text (DX D4).
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "leaves" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(expected, routed.stdout);
}

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

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

test "cli leaves B-leaves-11: outside a repository fails exit 1 on stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}

test "cli leaves B-leaves-12: an unborn repository lists no leaves (empty stdout, exit 0)" {
    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, &.{"leaves"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try std.testing.expectEqual(@as(usize, 0), lineCount(res.stdout));
}

test "cli leaves §7.1: a single-commit repo lists its lone check-in as the open leaf, marked '*'" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

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

    var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // Data on stdout, diagnostics on stderr — the streams never mix (cli-stream).
    try std.testing.expectEqualStrings("", res.stderr);
    var want: [96]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "* {s}  trunk\n", .{root});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout));
}

/// Builds a two-open-leaf trunk fork with distinct modification times: a root,
/// then `child1` (older), then — after `update` back to the root — `child2`
/// (newer), which becomes the current checkout. Returns both leaf IDs (caller
/// frees each).
fn buildFork(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !struct { child1: []const u8, child2: []const u8 } {
    try initRepo(gpa, io, dir);
    try writeFile(io, dir, "a.txt", "root\n");
    try addManaged(gpa, io, dir, &.{"a.txt"});

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

    try writeFile(io, dir, "a.txt", "one\n");
    var c1 = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "child1" });
    defer freeResult(gpa, &c1);
    try expectExit(c1.term, 0);
    const child1 = try gpa.dupe(u8, try checkinHash(c1.stdout));
    errdefer gpa.free(child1);

    var up = try runZit(gpa, io, dir, &.{ "update", root });
    defer freeResult(gpa, &up);
    try expectExit(up.term, 0);

    try writeFile(io, dir, "a.txt", "two\n");
    var c2 = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "child2" });
    defer freeResult(gpa, &c2);
    try expectExit(c2.term, 0);
    const child2 = try gpa.dupe(u8, try checkinHash(c2.stdout));

    return .{ .child1 = child1, .child2 = child2 };
}

test "cli leaves §7.5: two open leaves are ordered mtime-descending and the current checkout is marked '*'" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);

    var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    // §7.5: newest first (child2, epoch 2e9), then child1 (epoch 1e9); child2 is
    // the current checkout, so it is marked '*'.
    var want: [200]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "* {s}  trunk\n  {s}  trunk\n", .{ fork.child2, fork.child1 });
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expectEqual(@as(usize, 2), lineCount(res.stdout));
}

test "cli leaves --no-cache forces a full derive and yields output identical to the cached path" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // A two-open-leaf fork gives a non-trivial derived view to compare across
    // the cache-hit path and the forced full re-derive.
    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);

    // The first read misses, derives from the full log, and writes the memo.
    var warm = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &warm);
    try expectExit(warm.term, 0);
    try std.testing.expect(fileExists(io, tmp.dir, ".zit-cache"));

    // With the cache present, the default read replays it (the O(1) fast path).
    var cached = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &cached);
    try expectExit(cached.term, 0);

    // --no-cache bypasses `tryLoad` and re-scans the whole log. It is the same
    // derivation, so the output is byte-identical — the flag changes only which
    // path produced it, never what it produces.
    var bypass = try runZit(gpa, io, tmp.dir, &.{ "leaves", "--no-cache" });
    defer freeResult(gpa, &bypass);
    try expectExit(bypass.term, 0);
    try std.testing.expectEqualStrings("", bypass.stderr);
    try std.testing.expectEqualStrings(cached.stdout, bypass.stdout);
    // Non-empty ⇒ the equality is a real comparison, not two empty listings.
    try std.testing.expectEqual(@as(usize, 2), lineCount(bypass.stdout));
    // The bypass path just validated the whole log, so it refreshes the memo
    // rather than leaving a stale one — the next reader gets a trustworthy cache.
    try std.testing.expect(fileExists(io, tmp.dir, ".zit-cache"));
}

test "cli leaves §7.4/§7.7: --all lists a closed leaf marked (closed) that the default listing omits" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);

    // Close the older leaf (child1) with a synthesized control artifact (§7.4).
    const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child1);
    defer gpa.free(ctl);

    // Default listing: §7.4 open predicate excludes the closed leaf — only the
    // open child2 remains (enumeration filters by leaf-table membership + the
    // open test, never by name resolution — §7.7).
    var def = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &def);
    try expectExit(def.term, 0);
    try std.testing.expectEqualStrings("", def.stderr);
    var want_def: [96]u8 = undefined;
    const expected_def = try std.fmt.bufPrint(&want_def, "* {s}  trunk\n", .{fork.child2});
    try std.testing.expectEqualStrings(expected_def, def.stdout);

    // --all: the closed leaf stays a leaf-table member and is listed, marked
    // "(closed)"; still mtime-descending, still the checkout marked '*'.
    var all = try runZit(gpa, io, tmp.dir, &.{ "leaves", "--all" });
    defer freeResult(gpa, &all);
    try expectExit(all.term, 0);
    try std.testing.expectEqualStrings("", all.stderr);
    var want_all: [220]u8 = undefined;
    const expected_all = try std.fmt.bufPrint(&want_all, "* {s}  trunk\n  {s}  trunk  (closed)\n", .{ fork.child2, fork.child1 });
    try std.testing.expectEqualStrings(expected_all, all.stdout);
}

// ==========================================================================
// `zit timeline` end-to-end (repo-state §2/§3.2/§6.1/§6.4/§8.3 + CLI
// discipline). The global chronological history: every check-in, event.mtime
// descending, ties by full artifact hash. Each test spawns the REAL binary in
// an isolated tmp dir. Behavior ids cite contracts/timeline.md B-timeline-*.
// ==========================================================================

// ISO §4.6.4 writer forms of the injected commit epochs (whole Unix seconds,
// verified with `date -u`): the timeline date column derives from the stored
// event.mtime, which is the injected epoch carried in milliseconds.
const iso_epoch_900e6 = "1998-07-09T16:00:00"; // SOURCE_DATE_EPOCH=900000000
const iso_epoch_1e9 = "2001-09-09T01:46:40"; // SOURCE_DATE_EPOCH=1000000000
const iso_epoch_2e9 = "2033-05-18T03:33:20"; // SOURCE_DATE_EPOCH=2000000000

/// Writes `content` to `name`, optionally stages it (`stage` on the first
/// commit of a path), then commits at `epoch` with `msg`. Returns the new
/// check-in's 64-hex hash (caller frees) — the setup for the timeline ordering
/// tests, which need distinct, injected mtimes.
fn timelineCommit(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    name: []const u8,
    content: []const u8,
    stage: bool,
    epoch: []const u8,
    msg: []const u8,
) ![]u8 {
    try writeFile(io, dir, name, content);
    if (stage) try addManaged(gpa, io, dir, &.{name});
    var c = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = epoch }, &.{ "commit", "-m", msg });
    defer freeResult(gpa, &c);
    try expectExit(c.term, 0);
    return gpa.dupe(u8, try checkinHash(c.stdout));
}

test "cli timeline B-timeline-1: full listing, one line per check-in, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "900000000", "root");
    defer gpa.free(root);
    const mid = try timelineCommit(gpa, io, tmp.dir, "a.txt", "mid\n", false, "1000000000", "mid");
    defer gpa.free(mid);
    const tip = try timelineCommit(gpa, io, tmp.dir, "a.txt", "tip\n", false, "2000000000", "tip");
    defer gpa.free(tip);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // Data on stdout, diagnostics on stderr — the streams never mix (cli-stream).
    try std.testing.expectEqualStrings("", res.stderr);
    // Every crosslinked check-in appears exactly once: three commits ⇒ three lines.
    try std.testing.expectEqual(@as(usize, 3), lineCount(res.stdout));
}

test "cli timeline B-timeline-2: rows are ordered event.mtime descending, each dated from its mtime" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "900000000", "root");
    defer gpa.free(root);
    const mid = try timelineCommit(gpa, io, tmp.dir, "a.txt", "mid\n", false, "1000000000", "mid");
    defer gpa.free(mid);
    const tip = try timelineCommit(gpa, io, tmp.dir, "a.txt", "tip\n", false, "2000000000", "tip");
    defer gpa.free(tip);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    // §2/§8.3 item 7: most recent first — tip (2e9), mid (1e9), root (900e6) —
    // the whole-repo clock, not one check-in's ancestry (timeline's diff vs log).
    var want: [512]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &want,
        "  {s}  " ++ iso_epoch_2e9 ++ "  trunk  tip\n" ++
            "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  mid\n" ++
            "  {s}  " ++ iso_epoch_900e6 ++ "  trunk  root\n",
        .{ tip[0..12], mid[0..12], root[0..12] },
    );
    try std.testing.expectEqualStrings(expected, res.stdout);
}

test "cli timeline B-timeline-3: exact-mtime ties break by ascending hash, byte-reproducible across runs" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Two check-ins at the SAME epoch ⇒ equal event.mtime ⇒ the total order falls
    // back to ascending full artifact hash (deterministic, byte-reproducible).
    const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "1000000000", "root");
    defer gpa.free(root);
    const child = try timelineCommit(gpa, io, tmp.dir, "a.txt", "child\n", false, "1000000000", "child");
    defer gpa.free(child);

    var res1 = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res1);
    try expectExit(res1.term, 0);
    try std.testing.expectEqualStrings("", res1.stderr);
    var res2 = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res2);
    try expectExit(res2.term, 0);
    // Byte-reproducible: a second run yields identical bytes.
    try std.testing.expectEqualStrings(res1.stdout, res2.stdout);
    // Genuinely two distinct check-ins tied on mtime (non-vacuous comparison).
    try std.testing.expect(!std.mem.eql(u8, root, child));

    const root_first = std.mem.order(u8, root, child) == .lt;
    var want: [512]u8 = undefined;
    const expected = if (root_first)
        try std.fmt.bufPrint(
            &want,
            "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  root\n" ++
                "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  child\n",
            .{ root[0..12], child[0..12] },
        )
    else
        try std.fmt.bufPrint(
            &want,
            "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  child\n" ++
                "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  root\n",
            .{ child[0..12], root[0..12] },
        );
    try std.testing.expectEqualStrings(expected, res1.stdout);
}

test "cli timeline B-timeline-4 (§6.1): the branch column is each check-in's own branch" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "1000000000", "root");
    defer gpa.free(root);
    // A `--branch feature` fork lands the newer check-in on feature (§6.1/§6.2).
    try writeFile(io, tmp.dir, "a.txt", "two\n");
    var c = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "feature work", "--branch", "feature" });
    defer freeResult(gpa, &c);
    try expectExit(c.term, 0);
    const feat = try gpa.dupe(u8, try checkinHash(c.stdout));
    defer gpa.free(feat);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    var want: [512]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &want,
        "  {s}  " ++ iso_epoch_2e9 ++ "  feature  feature work\n" ++
            "  {s}  " ++ iso_epoch_1e9 ++ "  trunk  root\n",
        .{ feat[0..12], root[0..12] },
    );
    try std.testing.expectEqualStrings(expected, res.stdout);
}

test "cli timeline B-timeline-4 (§6.4): a tagless check-in shows the configured main-branch, never literal 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);
    // §6.4: set main-branch to a non-default name, then add a check-in with NO
    // branch tag. Its branch is the CONFIGURED value, never the literal "trunk".
    try writeDefaultBranch(gpa, io, tmp.dir, "main");
    const id = try appendSyntheticCheckin(gpa, io, tmp.dir, "tagless", null);
    defer gpa.free(id);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    var want: [256]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "  {s}  2020-01-01T00:00:00  main  tagless\n", .{id[0..12]});
    try std.testing.expectEqualStrings(expected, res.stdout);
    // §6.4 refutation: the configured name is emitted, the literal never is.
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "trunk") == null);
}

test "cli timeline B-timeline-5: a merge check-in is marked M; primary-only check-ins are not" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Two roots and a merge joining them (§3.2: base primary, other a merge
    // parent). Built by hand — a two-P-card manifest — since no `zit merge` exists.
    const base = try appendSyntheticCheckin(gpa, io, tmp.dir, "base", null);
    defer gpa.free(base);
    const other = try appendSyntheticCheckin(gpa, io, tmp.dir, "other", null);
    defer gpa.free(other);
    var pbuf: [160]u8 = undefined;
    const parents = try std.fmt.bufPrint(&pbuf, "{s} {s}", .{ base, other });
    const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents);
    defer gpa.free(merge);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    // Three check-ins, one line each (the merge is never folded into a parent).
    try std.testing.expectEqual(@as(usize, 3), lineCount(res.stdout));
    // §3.2: the merge line (>=1 merge parent) carries "M "; the two roots carry "  ".
    var mbuf: [160]u8 = undefined;
    const merge_line = try std.fmt.bufPrint(&mbuf, "M {s}  2020-01-01T00:00:00  trunk  merge\n", .{merge[0..12]});
    var bbuf: [160]u8 = undefined;
    const base_line = try std.fmt.bufPrint(&bbuf, "  {s}  2020-01-01T00:00:00  trunk  base\n", .{base[0..12]});
    var obuf: [160]u8 = undefined;
    const other_line = try std.fmt.bufPrint(&obuf, "  {s}  2020-01-01T00:00:00  trunk  other\n", .{other[0..12]});
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, merge_line) != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, base_line) != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, other_line) != null);
}

test "cli timeline B-timeline-6: a multi-line comment contributes only its first line" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    const id = try appendSyntheticCheckin(gpa, io, tmp.dir, "first line\nsecond line", null);
    defer gpa.free(id);

    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);
    // The embedded newline is cut: one line, first line only (invariant held).
    try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout));
    var want: [256]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "  {s}  2020-01-01T00:00:00  trunk  first line\n", .{id[0..12]});
    try std.testing.expectEqualStrings(expected, res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "second line") == null);
}

test "cli timeline B-timeline-7: an empty repository yields empty stdout, exit 0" {
    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, &.{"timeline"});
    defer freeResult(gpa, &res);
    // Emptiness is defined out of existence: a success with no rows, no diagnostic.
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try std.testing.expectEqual(@as(usize, 0), lineCount(res.stdout));
}

test "cli timeline B-timeline-8: -h/--help print the verbatim help to stdout, exit 0, help wins" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const expected =
        \\usage: zit timeline [-h|--help]
        \\
        \\List every check-in in the repository, most recent first. Each line shows
        \\the check-in's short hash, its ISO timestamp, its branch, and the first
        \\line of its comment. A check-in that joins two or more lines of development
        \\is marked "M". Order is by event time descending, ties broken by artifact
        \\hash, so the listing is byte-identical across runs.
        \\
        \\options:
        \\  -h, --help    show this help and exit
        \\
    ;
    // -h/--help win even over an otherwise-invalid trailing token.
    for ([_][]const []const u8{ &.{ "timeline", "-h" }, &.{ "timeline", "--help" }, &.{ "timeline", "-h", "foo" } }) |args| {
        var res = try runZit(gpa, io, tmp.dir, args);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    // `zit help timeline` teaches the identical text (DX D4).
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "timeline" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(expected, routed.stdout);
}

test "cli timeline B-timeline-9: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "-n", "--bogus" }) |opt| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "timeline", opt });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        var want: [160]u8 = undefined;
        const expected = try std.fmt.bufPrint(&want, "zit timeline: unknown option '{s}'\ntry 'zit timeline --help'\n", .{opt});
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli timeline B-timeline-10: an extra argument is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // A bare positional and any token after `--` are operands timeline rejects.
    for ([_][]const []const u8{ &.{ "timeline", "foo" }, &.{ "timeline", "--", "foo" } }) |args| {
        var res = try runZit(gpa, io, tmp.dir, args);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings("zit timeline: unexpected argument 'foo'\ntry 'zit timeline --help'\n", res.stderr);
    }
}

test "cli timeline B-timeline-11: outside a repository fails exit 1 on stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Data-layer failure (exit 1), distinct from the exit-2 usage class.
    var res = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}

test "cli timeline B-timeline-12: `zit timeline >> log` appends (O_APPEND honored), rows to 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);
    const z = try appendSyntheticCheckin(gpa, io, tmp.dir, "z", null);
    defer gpa.free(z);

    // A streaming stdout writer honors the inherited O_APPEND offset: the row
    // lands after the pre-existing content, never clobbering it (cli-stream).
    var res = try runShell(gpa, io, tmp.dir, "printf 'PRE\\n' > log; \"$ZIT\" timeline >> log");
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);

    const log = try readFile(gpa, io, tmp.dir, "log");
    defer gpa.free(log);
    var want: [256]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "PRE\n  {s}  2020-01-01T00:00:00  trunk  z\n", .{z[0..12]});
    try std.testing.expectEqualStrings(expected, log);
}

test "cli timeline: 'timeline' is listed in the command help and suggested for a near-miss" {
    // cli-verb-enum-wiring: the verb joins the single-source Verb enum, so the
    // command list names it and the did-you-mean suggester finds it (mirror leaves).
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "timeline") != null);

    var typo = try runZit(gpa, io, tmp.dir, &.{"timelin"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'timeline'?") != null);
}

// ==========================================================================
// `zit branch` end-to-end (repo-state §6.1/§6.3/§6.4/§9 + CLI discipline).
// The multi-branch, closed, forked, and non-trunk-config derivations are unit-
// tested (no CLI creates a branch yet), so these e2e cases exercise the single
// trunk the CLI can build plus the full usage/help/stream/exit contract.
// ==========================================================================

const branch_help_expected =
    \\usage: zit branch [--no-cache] [-h|--help]
    \\
    \\List the repository's branches, one per line, ordered by name. Branches are
    \\derived from the active branch tags of the check-ins; the branch of the current
    \\checkout is marked with a leading "*". A branch whose most recent check-in is
    \\closed is marked "(closed)".
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -h, --help      show this help and exit
    \\
;

test "cli branch §6.3: a single-commit repo lists trunk marked '*'" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    _ = try checkinHash(commit.stdout);

    // §6.3: one branch derived from the root's active branch tag, named "trunk";
    // §6.1/§6.4: the checkout is on it, so it is marked. Data on stdout only.
    var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("* trunk\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli branch §6.4: the current mark is the active-tag branch-of the checkout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // A trunk fork (two open leaves, checkout on the newer). §6.3 still groups
    // them into one "trunk" entry; §6.4 marks it because the checkout resolves
    // to trunk through the active-tag filter.
    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);

    var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("* trunk\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout));
}

test "cli branch B-branch-3: an unborn repository lists nothing (empty stdout, exit 0)" {
    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, &.{"branch"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli branch B-branch-4: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

test "cli branch B-branch-5: an extra argument is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

test "cli branch B-branch-6: --help / -h / 'help branch' print the verbatim help (stdout, exit 0)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "--help", "-h" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "branch", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(branch_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "branch" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(branch_help_expected, routed.stdout);
}

test "cli branch B-branch-7: outside a repository fails exit 1 on stderr, empty stdout" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}

test "cli branch B-branch-8: a corrupt repository fails exit 1 with a stderr diagnostic" {
    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 initial = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
    const frame_offset = (try initial.stat(io)).size;
    initial.close(io);
    const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
    defer gpa.free(root);

    // Flip a byte inside the appended frame so the log reader rejects it.
    var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
    var byte: [1]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 1), try file.readPositionalAll(io, &byte, frame_offset + 10));
    byte[0] ^= 0xff;
    try file.writePositionalAll(io, &byte, frame_offset + 10);
    file.close(io);

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

test "cli branch B-branch-9: 'branch' is listed in top help and suggested for a near-miss" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "branch") != null);

    var typo = try runZit(gpa, io, tmp.dir, &.{"brnach"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'branch'?") != null);
}

test "cli branch §6.4: a hand-edited .zit-checkout naming an unknown version leaves nothing marked (exit 0)" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    _ = try checkinHash(commit.stdout);

    // Overwrite the checkout record with a well-formed-but-unknown version. The
    // current-mark resolver returns null (never interned), so trunk lists with
    // no "*"; the branch set itself (§6.3) is unaffected. Never an error.
    const unknown = "b" ** 64;
    try checkout.write(io, tmp.dir, .{ .version = unknown, .branch = "trunk", .merge_parent = "" });

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

// --- tag verb (repo-state §5.1/§5.2, read-only enumeration) ----------------

/// An independent transcription of the `zit tag` §Help block (must match
/// `main.zig`'s `tag_help` byte-for-byte; drift fails the test).
const tag_help_expected =
    \\usage: zit tag list [CHECKIN] [--no-cache]
    \\
    \\List tags. With no CHECKIN, print the tag names in use across the
    \\repository, one per line, sorted. With a CHECKIN, print the tags visible
    \\on that check-in and their values.
    \\
    \\  ls          alias for list
    \\
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -h, --help  show this help
    \\
;

/// Builds a raw check-in manifest with an injected T-card block and appends it
/// to `.zit`, returning its 64-hex artifact name (caller frees). The card run
/// C/D/F/<t>/U/Z is a valid check-in skeleton; a malformed `t_block` makes
/// `artifact.parse` reject the whole frame, so the loader skips it. Mirrors
/// `appendRawCheckinWithUppercaseParent`'s hand-framed, self-checksummed shape.
fn appendRawManifestWithT(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    comment: []const u8,
    t_block: []const u8,
) ![]u8 {
    var body_buf: [512]u8 = undefined;
    const body = try std.fmt.bufPrint(
        &body_buf,
        "C {s}\n" ++
            "D 2020-01-01T00:00:00\n" ++
            "F f 0000000000000000000000000000000000000000000000000000000000000000\n" ++
            "{s}" ++
            "U u\n",
        .{ comment, t_block },
    );
    var raw_buf: [576]u8 = undefined;
    @memcpy(raw_buf[0..body.len], body);
    var checksum: [32]u8 = undefined;
    const digest = artifact.selfChecksum(body, &checksum);
    raw_buf[body.len] = 'Z';
    raw_buf[body.len + 1] = ' ';
    @memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
    raw_buf[body.len + 34] = '\n';
    return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}

/// Builds a valid cluster artifact (card run M/Z — a legitimate non-check-in
/// artifact type the crosslink loader admits then skips) and appends it,
/// returning its 64-hex name (caller frees).
fn appendCluster(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    var body_buf: [128]u8 = undefined;
    const body = try std.fmt.bufPrint(
        &body_buf,
        "M 1111111111111111111111111111111111111111111111111111111111111111\n",
        .{},
    );
    var raw_buf: [192]u8 = undefined;
    @memcpy(raw_buf[0..body.len], body);
    var checksum: [32]u8 = undefined;
    const digest = artifact.selfChecksum(body, &checksum);
    raw_buf[body.len] = 'Z';
    raw_buf[body.len + 1] = ' ';
    @memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
    raw_buf[body.len + 34] = '\n';
    return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}

test "cli tag §2: tag list shows branch names with sym- stripped" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    _ = try checkinHash(commit.stdout);

    // §2: a symbolic name is stored 'sym-trunk' and surfaced 'trunk'. The
    // machinery 'branch' tag is not listed as a bare name in the no-arg view.
    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("trunk\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli tag §5.2: tag list CHECKIN shows branch trunk and the sym tag" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

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

    // §5.1/§5.2/§6.4: a root trunk tip carries the active 'branch'=trunk tag
    // (surfaced 'branch trunk') and the 'sym-trunk' tag (surfaced 'trunk'),
    // ascending by raw name.
    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("branch trunk\ntrunk\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli tag B-tag-11: --help / -h / 'help tag' print the verbatim help (stdout, exit 0)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "--help", "-h" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "tag", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(tag_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "tag" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(tag_help_expected, routed.stdout);
}

test "cli tag B-tag-12: no subcommand is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

test "cli tag B-tag-13: an unknown subcommand is a usage error (exit 2, stderr, empty stdout)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

test "cli tag B-tag-14: an unresolvable CHECKIN fails exit 1 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, &.{ "tag", "list", "nosuchname" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit tag: check-in 'nosuchname' not found\n", res.stderr);
}

test "cli tag B-tag-15: 'tag' is listed in the command help and suggested for a near-miss" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "tag") != null);

    var typo = try runZit(gpa, io, tmp.dir, &.{"tags"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'tag'?") != null);
}

test "cli tag B-tag-16 (ls): the ls alias equals list; data on stdout only" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    _ = try checkinHash(commit.stdout);

    var list = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
    defer freeResult(gpa, &list);
    var ls = try runZit(gpa, io, tmp.dir, &.{ "tag", "ls" });
    defer freeResult(gpa, &ls);
    try expectExit(ls.term, 0);
    try std.testing.expectEqualStrings("trunk\n", ls.stdout);
    try std.testing.expectEqualStrings("", ls.stderr);
    try std.testing.expectEqualStrings(list.stdout, ls.stdout);
}

test "cli tag B-tag-17: an unborn repository lists no tags (empty stdout, exit 0)" {
    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, &.{ "tag", "list" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli tag §5.1: an accepted self-tag T-card surfaces on its check-in" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Anchors the raw skeleton: a valid `*sym-alpha` self-tag (§5.1 propagate
    // sign) crosslinks and surfaces stripped as 'alpha'. The rejection tests
    // below reuse this skeleton, changing only the T-card.
    const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "tagged", "T *sym-alpha *\n");
    defer gpa.free(hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("alpha\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli tag §5.1: a T-card with a bad leading char contributes no tag" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // §5.1: a leading char other than +/-/* is a fatal crosslink error, so the
    // manifest is rejected at parse and never becomes a resolvable check-in.
    const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T Qsym-bogus *\n");
    defer gpa.free(hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}

test "cli tag §4.3: an unsigned T-card name contributes no tag" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // §4.3: a tag name lacking a -, +, or * prefix is rejected at parse time.
    const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T sym-bogus *\n");
    defer gpa.free(hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}

test "cli tag §4.3: an all-hex tag name contributes no tag" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // §4.3: a tag name that is entirely hexadecimal after prefix removal is
    // rejected (it must stay distinguishable from a hash prefix).
    const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T +deadbeef *\n");
    defer gpa.free(hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}

test "cli tag §4.3: a non-ascending T-card manifest applies none of its tags" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // §4.3: a T-card sequence not strictly ascending by (name, uuid) is
    // rejected, and no crosslink effect is applied on the manifest's behalf.
    const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T *sym-zebra *\nT *sym-apple *\n");
    defer gpa.free(hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}

test "cli tag §4.2: a non-control/manifest artifact contributes no tag" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    _ = try checkinHash(commit.stdout);

    // §4.2 step 2: the tag loop runs only for control/manifest/event artifacts;
    // a cluster is another type, so it contributes no tag. The listing stays
    // exactly the trunk from the real commit.
    const cluster = try appendCluster(gpa, io, tmp.dir);
    defer gpa.free(cluster);

    var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("trunk\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

// ==========================================================================
// `zit info NAME` end-to-end (repo-state §3/§6/§7/§8/§9/§10 + CLI discipline).
// `info` resolves NAME through the §8.3 procedure and describes the check-in it
// names. The load-bearing property is that resolution is recency-based (§8.2),
// NOT open-leaf enumeration (§8.5/§10): a name can name a closed or non-leaf
// check-in that `leaves` would omit. These cases re-observe that divergence
// through the CLI so the wiring itself is proven.
// ==========================================================================

/// An independent transcription of `info`'s §Help block (must match
/// `main.zig`'s `info_help` byte-for-byte; drift fails the test).
const info_help_expected =
    \\usage: zit info NAME [--no-cache]
    \\
    \\Resolve NAME to a single check-in and describe it. NAME may be a full
    \\40- or 64-character artifact ID, a unique ID prefix, a branch or tag name,
    \\or one of the reserved forms (tip, rid:, tag:, root:, start:, merge-in:).
    \\Resolution follows recency, not open-leaf status: a name can resolve to a
    \\closed or non-leaf check-in that 'zit leaves' would not list.
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -h, --help      show this help and exit
    \\
;

/// Writes a config frame naming `branch` as the repository's main-branch
/// (repo-state §6.4), opening `.zit` read-write exactly as `appendArtifactAs`
/// does — so `info` can be shown to thread the configured default branch rather
/// than assuming the literal "trunk".
fn writeDefaultBranch(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, branch: []const u8) !void {
    var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
    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);
    try log.putConfig(.{ .hash_policy = 0, .default_branch = branch, .project_id = "p" });
    try log.commit();
}

test "cli info §10.2/§8.5: on a fork whose newer sibling is closed, info trunk names the newer closed check-in while leaves surfaces the older open one" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // child1 (mtime 1e9, open) and child2 (mtime 2e9, the checkout). Close the
    // newer sibling child2 (§7.4 control artifact).
    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);
    const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child2);
    defer gpa.free(ctl);

    // §8.2 max-mtime: `info trunk` resolves child2 — its closed state and
    // leaf-table membership are immaterial to the selection (§8.5, §10.3).
    var info = try runZit(gpa, io, tmp.dir, &.{ "info", "trunk" });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expectEqualStrings("", info.stderr);
    var pbuf: [200]u8 = undefined;
    const prefix = try std.fmt.bufPrint(&pbuf, "{s}\nbranch:  trunk\nleaf:    closed\nparent:  ", .{fork.child2});
    try std.testing.expect(std.mem.startsWith(u8, info.stdout, prefix));
    try std.testing.expect(std.mem.endsWith(u8, info.stdout, "\n"));

    // §7.4/§7.7: the default `leaves` view surfaces only the older OPEN leaf
    // child1 — the divergence that proves name resolution is NOT open-leaf
    // enumeration (§8.1-not-conflated, §8.5-not-openleaf-substitute).
    var leaves = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &leaves);
    try expectExit(leaves.term, 0);
    var lbuf: [128]u8 = undefined;
    const expected_leaves = try std.fmt.bufPrint(&lbuf, "  {s}  trunk\n", .{fork.child1});
    try std.testing.expectEqualStrings(expected_leaves, leaves.stdout);
    // info named child2; leaves named child1 — the two answers genuinely differ.
    try std.testing.expect(!std.mem.eql(u8, fork.child1, fork.child2));
}

test "cli info §10.1: on a fork with the older sibling closed, info trunk and leaves agree on the open newer leaf" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const fork = try buildFork(gpa, io, tmp.dir);
    defer gpa.free(fork.child1);
    defer gpa.free(fork.child2);
    // Close the OLDER sibling child1; the newer child2 stays open.
    const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child1);
    defer gpa.free(ctl);

    // §8.2: `info trunk` resolves child2 (max mtime, open leaf here).
    var info = try runZit(gpa, io, tmp.dir, &.{ "info", "trunk" });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expectEqualStrings("", info.stderr);
    var pbuf: [200]u8 = undefined;
    const prefix = try std.fmt.bufPrint(&pbuf, "{s}\nbranch:  trunk\nleaf:    open\nparent:  ", .{fork.child2});
    try std.testing.expect(std.mem.startsWith(u8, info.stdout, prefix));

    // §7.4: `leaves` lists only the open child2 — so here info and leaves AGREE.
    var leaves = try runZit(gpa, io, tmp.dir, &.{"leaves"});
    defer freeResult(gpa, &leaves);
    try expectExit(leaves.term, 0);
    var lbuf: [128]u8 = undefined;
    const expected_leaves = try std.fmt.bufPrint(&lbuf, "* {s}  trunk\n", .{fork.child2});
    try std.testing.expectEqualStrings(expected_leaves, leaves.stdout);
}

test "cli info §6.1: a check-in on trunk reports branch: 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 writeFile(io, tmp.dir, "a.txt", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});

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

    // §6.1: the root carries the active branch=trunk tag, so branchOf is trunk.
    // A lone root check-in is an open leaf with no parent line.
    var info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expectEqualStrings("", info.stderr);
    var want: [128]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "{s}\nbranch:  trunk\nleaf:    open\n", .{root});
    try std.testing.expectEqualStrings(expected, info.stdout);
}

test "cli info §6.4/§9: a tagless check-in reports the configured main-branch, not literal 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);
    // §6.4: set main-branch to a non-default name, then add a check-in with NO
    // branch tag. Its branch is the CONFIGURED value, never the literal "trunk".
    try writeDefaultBranch(gpa, io, tmp.dir, "main");
    const id = try appendSyntheticCheckin(gpa, io, tmp.dir, "tagless", null);
    defer gpa.free(id);

    var info = try runZit(gpa, io, tmp.dir, &.{ "info", id });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expectEqualStrings("", info.stderr);
    var want: [128]u8 = undefined;
    const expected = try std.fmt.bufPrint(&want, "{s}\nbranch:  main\nleaf:    open\n", .{id});
    try std.testing.expectEqualStrings(expected, info.stdout);
}

test "cli info §7.1/§7.4: leaf status renders open, closed, and no" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) a lone check-in is an open leaf; (b) closing it makes it a closed
    // leaf-table member (§7.4) — still a leaf, so 'closed', not 'no'.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "root\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);
        const root = try gpa.dupe(u8, try checkinHash(commit.stdout));
        defer gpa.free(root);

        var open_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
        defer freeResult(gpa, &open_info);
        try expectExit(open_info.term, 0);
        var want_open: [128]u8 = undefined;
        const expect_open = try std.fmt.bufPrint(&want_open, "{s}\nbranch:  trunk\nleaf:    open\n", .{root});
        try std.testing.expectEqualStrings(expect_open, open_info.stdout);

        const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", root);
        defer gpa.free(ctl);
        var closed_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
        defer freeResult(gpa, &closed_info);
        try expectExit(closed_info.term, 0);
        var want_closed: [128]u8 = undefined;
        const expect_closed = try std.fmt.bufPrint(&want_closed, "{s}\nbranch:  trunk\nleaf:    closed\n", .{root});
        try std.testing.expectEqualStrings(expect_closed, closed_info.stdout);
    }

    // (c) a check-in with a same-branch child is not a leaf at all: 'no'.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "root\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var c_root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &c_root);
        try expectExit(c_root.term, 0);
        const root = try gpa.dupe(u8, try checkinHash(c_root.stdout));
        defer gpa.free(root);
        try writeFile(io, tmp.dir, "a.txt", "edit\n");
        var c_child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "child" });
        defer freeResult(gpa, &c_child);
        try expectExit(c_child.term, 0);
        const child = try gpa.dupe(u8, try checkinHash(c_child.stdout));
        defer gpa.free(child);

        var root_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
        defer freeResult(gpa, &root_info);
        try expectExit(root_info.term, 0);
        var want_no: [128]u8 = undefined;
        const expect_no = try std.fmt.bufPrint(&want_no, "{s}\nbranch:  trunk\nleaf:    no\n", .{root});
        try std.testing.expectEqualStrings(expect_no, root_info.stdout);
    }
}

test "cli info §3.2/§3.3: renders the primary parent and each merge parent; a root has none" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Two independent roots and a merge child whose P card lists p1 then p2.
    const p1 = try appendSyntheticCheckin(gpa, io, tmp.dir, "p1", null);
    defer gpa.free(p1);
    const p2 = try appendSyntheticCheckin(gpa, io, tmp.dir, "p2", null);
    defer gpa.free(p2);
    var parents_buf: [129]u8 = undefined;
    const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ p1, p2 });
    const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents);
    defer gpa.free(merge);

    // §3.2: p1 (first P-card token) is the primary parent; §3.3: p2 is a merge
    // parent, shown on its own line.
    var mi = try runZit(gpa, io, tmp.dir, &.{ "info", merge });
    defer freeResult(gpa, &mi);
    try expectExit(mi.term, 0);
    try std.testing.expectEqualStrings("", mi.stderr);
    var want_merge: [320]u8 = undefined;
    const expect_merge = try std.fmt.bufPrint(
        &want_merge,
        "{s}\nbranch:  trunk\nleaf:    open\nparent:  {s}\nmerge:   {s}\n",
        .{ merge, p1, p2 },
    );
    try std.testing.expectEqualStrings(expect_merge, mi.stdout);

    // A root has no parent line (and, having a same-branch child, is not a leaf).
    var pi = try runZit(gpa, io, tmp.dir, &.{ "info", p1 });
    defer freeResult(gpa, &pi);
    try expectExit(pi.term, 0);
    var want_root: [128]u8 = undefined;
    const expect_root = try std.fmt.bufPrint(&want_root, "{s}\nbranch:  trunk\nleaf:    no\n", .{p1});
    try std.testing.expectEqualStrings(expect_root, pi.stdout);
}

test "cli info §8.1: help lists info and did-you-mean suggests it" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // The top-level command list names the verb (discoverability).
    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "info") != null);

    // A single-substitution typo maps uniquely to 'info' (distance 1; every
    // other verb is farther), so did-you-mean suggests it.
    var typo = try runZit(gpa, io, tmp.dir, &.{"infp"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'info'?") != null);
}

test "cli info §8.1/§8.5: -h/--help and 'help info' print the verbatim help on stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "--help", "-h" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "info", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(info_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "info" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(info_help_expected, routed.stdout);
}

test "cli info §8.1: missing NAME, extra positional, and unknown option each exit 2 to stderr" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Missing NAME — detected before any repo discovery, so no repo is needed.
    var no_name = try runZit(gpa, io, tmp.dir, &.{"info"});
    defer freeResult(gpa, &no_name);
    try expectExit(no_name.term, 2);
    try std.testing.expectEqualStrings("", no_name.stdout);
    try std.testing.expectEqualStrings(
        "zit info: no name given\ntry 'zit info --help'\n",
        no_name.stderr,
    );

    // A second positional is an unexpected argument.
    var extra = try runZit(gpa, io, tmp.dir, &.{ "info", "a", "b" });
    defer freeResult(gpa, &extra);
    try expectExit(extra.term, 2);
    try std.testing.expectEqualStrings("", extra.stdout);
    try std.testing.expectEqualStrings(
        "zit info: unexpected argument 'b'\ntry 'zit info --help'\n",
        extra.stderr,
    );

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

test "cli info §8.3: an unresolved name exits 1 with the diagnostic on stderr only" {
    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", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // A name no check-in carries is a resolution failure (§8.3 NotFound): exit 1,
    // diagnostic on stderr, nothing on stdout (cli-stream-separation).
    var res = try runZit(gpa, io, tmp.dir, &.{ "info", "nosuch" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("zit info: check-in 'nosuch' not found\n", res.stderr);
}

// ==========================================================================
// `zit ls` end-to-end. Lists a check-in's effective file set, one
// `<blob-id> <filename>` line per file in canonical §4.5.3 order. The blob id
// oracle is the artifact §7.2 sha3-256 of the file content, computed here from
// the RFC rule — never captured from zit or Fossil.
// ==========================================================================

/// Appends one `ls` line — `<sha3-256 hex> SP <name> LF` — computed from the
/// §7.2 hash-over-content rule, so the e2e expectation is RFC-derived.
fn appendLsLine(gpa: std.mem.Allocator, list: *std.ArrayList(u8), name: []const u8, content: []const u8) !void {
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
    const hex = "0123456789abcdef";
    for (digest) |b| {
        try list.append(gpa, hex[b >> 4]);
        try list.append(gpa, hex[b & 0x0f]);
    }
    try list.append(gpa, ' ');
    try list.appendSlice(gpa, name);
    try list.append(gpa, '\n');
}

test "cli ls §8.2: prints each effective file's hash and name for a resolved check-in" {
    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", "alpha\n");
    try writeFile(io, tmp.dir, "b.txt", "beta\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const hash = try checkinHash(commit.stdout);

    // The effective set is both files, in canonical filename order (a.txt < b.txt),
    // each `<sha3-256(content)> <name>`.
    var expected: std.ArrayList(u8) = .empty;
    defer expected.deinit(gpa);
    try appendLsLine(gpa, &expected, "a.txt", "alpha\n");
    try appendLsLine(gpa, &expected, "b.txt", "beta\n");

    var res = try runZit(gpa, io, tmp.dir, &.{ "ls", hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings(expected.items, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli ls: no VERSION lists the current checkout; unborn checkout is a failure" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) After a commit the checkout names the new check-in; bare `zit ls` lists
    // exactly that check-in's files.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "only.txt", "content\n");
        try addManaged(gpa, io, tmp.dir, &.{"only.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);

        var expected: std.ArrayList(u8) = .empty;
        defer expected.deinit(gpa);
        try appendLsLine(gpa, &expected, "only.txt", "content\n");

        var res = try runZit(gpa, io, tmp.dir, &.{"ls"});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(expected.items, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }

    // (b) A fresh repository's checkout is unborn: bare `zit ls` has nothing to
    // list — exit 1, diagnostic on stderr, empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);

        var res = try runZit(gpa, io, tmp.dir, &.{"ls"});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unborn") != null);
    }
}

test "cli ls §8.3: an unknown version is a resolve failure (exit 1); not-a-repo is a failure" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) In a real repo, a name no check-in carries is a §8.3 resolution failure:
    // exit 1, diagnostic on stderr, empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "root\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);

        var res = try runZit(gpa, io, tmp.dir, &.{ "ls", "nosuch" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings("zit ls: check-in 'nosuch' not found\n", res.stderr);
    }

    // (b) Outside any repository, `zit ls` fails to discover a repo: exit 1,
    // not-a-repo diagnostic on stderr, empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, &.{"ls"});
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
    }
}

// ==========================================================================
// `zit cat` end-to-end. Emits one file's uncompressed content at a resolved
// check-in, byte-for-byte on stdout. The content oracle is the exact bytes the
// test wrote before committing — never captured from zit or Fossil. Whether a
// blob is stored full or as a delta is a storage detail cat must see through:
// its output is always the uncompressed bytes, hash-verified against the F card
// (artifact §7.2).
// ==========================================================================

test "cli cat §7.2/delta §4: emits the reconstructed uncompressed bytes for a file stored as a delta chain" {
    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 large file whose second version differs in one byte: commit stores the
    // child's blob as a profitable delta against the parent's blob for the same
    // path (§4.1/§7.2 storage), so one of the two versions is reconstructed by
    // walking a delta frame — cat must emit the exact uncompressed bytes either
    // way, and blob reconstruction re-verifies the §7.2 hash.
    var v1: std.ArrayList(u8) = .empty;
    defer v1.deinit(gpa);
    for (0..300) |_| try v1.appendSlice(gpa, "the quick brown fox jumps over\n");
    var v2: std.ArrayList(u8) = .empty;
    defer v2.deinit(gpa);
    try v2.appendSlice(gpa, v1.items);
    v2.items[1500] = 'X'; // a single-byte change deep in the middle

    try writeFile(io, tmp.dir, "big.txt", v1.items);
    try addManaged(gpa, io, tmp.dir, &.{"big.txt"});
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "v1" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    const root_hash = try gpa.dupe(u8, try checkinHash(root.stdout));
    defer gpa.free(root_hash);

    try writeFile(io, tmp.dir, "big.txt", v2.items);
    var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "v2" });
    defer freeResult(gpa, &child);
    try expectExit(child.term, 0);

    // Current checkout (child) → v2; the explicit root check-in → v1. Both
    // reconstruct their stored blob (one of them across the delta chain).
    var cur = try runZit(gpa, io, tmp.dir, &.{ "cat", "big.txt" });
    defer freeResult(gpa, &cur);
    try expectExit(cur.term, 0);
    try std.testing.expectEqualStrings(v2.items, cur.stdout);
    try std.testing.expectEqualStrings("", cur.stderr);

    var old = try runZit(gpa, io, tmp.dir, &.{ "cat", "big.txt", root_hash });
    defer freeResult(gpa, &old);
    try expectExit(old.term, 0);
    try std.testing.expectEqualStrings(v1.items, old.stdout);
    try std.testing.expectEqualStrings("", old.stderr);
}

test "cli cat §8.2: resolves FILE within a check-in's effective set — the unchanged and the changed file each read correctly across two commits" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Root {a:"a0", b:"b0"}; child changes only a → "a1". At the child check-in
    // the effective set (§8.2) is {a:"a1", b:"b0"}: cat of the untouched file
    // yields its original bytes, cat of the changed file yields the new bytes.
    try writeFile(io, tmp.dir, "a.txt", "a0\n");
    try writeFile(io, tmp.dir, "b.txt", "b0\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
    var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    try writeFile(io, tmp.dir, "a.txt", "a1\n");
    var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "edit a" });
    defer freeResult(gpa, &child);
    try expectExit(child.term, 0);

    var ca = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" });
    defer freeResult(gpa, &ca);
    try expectExit(ca.term, 0);
    try std.testing.expectEqualStrings("a1\n", ca.stdout); // the changed file

    var cb = try runZit(gpa, io, tmp.dir, &.{ "cat", "b.txt" });
    defer freeResult(gpa, &cb);
    try expectExit(cb.term, 0);
    try std.testing.expectEqualStrings("b0\n", cb.stdout); // carried forward unchanged
    try std.testing.expectEqualStrings("", cb.stderr);
}

test "cli cat §8.2: a file removed at a check-in is absent from its effective set — not found, 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);

    // Root tracks a and b; the child stops tracking b (`zit rm`) and commits, so
    // b is not in the child's effective set. cat of b at the child reports
    // not-found on stderr, exit 1, empty stdout (cli-stream-separation).
    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 root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);

    var rm = try runZit(gpa, io, tmp.dir, &.{ "rm", "b.txt" });
    defer freeResult(gpa, &rm);
    try expectExit(rm.term, 0);
    var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "drop b" });
    defer freeResult(gpa, &child);
    try expectExit(child.term, 0);

    var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "b.txt" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not in the check-in") != null);

    // a is still tracked, so it reads back its content.
    var ca = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" });
    defer freeResult(gpa, &ca);
    try expectExit(ca.term, 0);
    try std.testing.expectEqualStrings("a\n", ca.stdout);
}

test "cli cat §7.2: binary content with embedded NUL is emitted verbatim — no truncation, no added newline" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Every byte value 0..255 twice, so a NUL sits well before the end and the
    // content has no trailing LF. cat MUST pass all bytes through unchanged — the
    // content path is []const u8 (NUL-legal), and cat adds no framing.
    var content: [512]u8 = undefined;
    for (&content, 0..) |*b, i| b.* = @intCast(i % 256);
    try writeFile(io, tmp.dir, "blob.bin", &content);
    try addManaged(gpa, io, tmp.dir, &.{"blob.bin"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "binary" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "blob.bin" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualSlices(u8, &content, res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli cat: no FILE is a usage error (exit 2); an unborn checkout and an unresolvable VERSION each fail (exit 1)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) `zit cat` with no FILE is a usage error: exit 2, diagnostic on stderr,
    // empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        var res = try runZit(gpa, io, tmp.dir, &.{"cat"});
        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, "no file given") != null);
    }

    // (b) A fresh repository's checkout is unborn (§7.5): bare `zit cat FILE` has
    // no check-in to read — exit 1, diagnostic on stderr, empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unborn") != null);
    }

    // (c) A name no check-in carries is a §8.3 resolution failure (NotFound):
    // exit 1, the resolve diagnostic on stderr, empty stdout.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "root\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);

        var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt", "nosuch" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings("zit cat: check-in 'nosuch' not found\n", res.stderr);
    }
}

// ==========================================================================
// `zit artifact` end-to-end (contracts/artifact.md B-artifact-1..14). zit's
// plumbing window: a read-only describe (`whatis`) / dump (`--raw`) of ONE
// stored artifact addressed by its hash — every artifact kind, file blobs
// included. Card interpretation flows through the ONE artifact parser; the
// oracles here are RFC-derived (sha3-256 of content for a blob name; the
// stored manifest bytes read back through the library), never fossil.
// ==========================================================================

const artifact_help_expected =
    \\usage: zit artifact HASH [--raw]
    \\
    \\Describe or dump a single stored artifact addressed by its hash. HASH is a full
    \\or abbreviated (>=4 character) lowercase hexadecimal artifact ID, resolved
    \\against every stored artifact — file blobs included. Branch and tag names are
    \\not accepted; use 'zit info' for those.
    \\
    \\Without --raw, print a summary: the full hash, the artifact's size, its type,
    \\and — for a structural artifact — one line per card and whether its Z
    \\self-checksum verifies. A blob reports its size and whether it is binary.
    \\
    \\With --raw, write the artifact's exact stored bytes to standard output,
    \\verbatim — no trailing newline is added and binary content passes through
    \\unchanged. Hashing those bytes reproduces HASH.
    \\
    \\options:
    \\      --raw       write the exact stored bytes instead of a summary
    \\  -h, --help      show this help and exit
    \\
;

/// Lowercase-hex sha3-256 of `content` — the §7.2 artifact name of stored
/// content, copied onto `gpa` (caller frees). The RFC-derived blob-hash oracle.
fn sha3HexAlloc(gpa: std.mem.Allocator, content: []const u8) ![]u8 {
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
    const hex = "0123456789abcdef";
    const out = try gpa.alloc(u8, 64);
    for (digest, 0..) |b, i| {
        out[i * 2] = hex[b >> 4];
        out[i * 2 + 1] = hex[b & 0x0f];
    }
    return out;
}

/// Appends one artifact frame keyed by `digest` (its raw 32-byte name) holding
/// `content`, straight into the repo's `.zit` through the library — the only
/// way to seed an artifact the CLI itself would never mint: a Z-mismatched
/// structural artifact (B-artifact-5) and a prefix-colliding pair
/// (B-artifact-7). A read-write reopen + append + commit, exactly like a real
/// writer. The derive tolerates a non-parseable/Z-mismatched frame as an opaque
/// blob (repoview.linkOne "crosslink whatever parses"), so `repo.open` succeeds.
fn injectFrame(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, digest: [32]u8, content: []const u8) !void {
    var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
    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);
    _ = try log.put(&digest, content);
    try log.commit();
}

test "cli artifact B-artifact-1: a manifest summary carries the hash, type, size, and one line per card" {
    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", "alpha\n");
    try writeFile(io, tmp.dir, "b.txt", "beta\n");
    try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const ck = try gpa.dupe(u8, try checkinHash(commit.stdout));
    defer gpa.free(ck);

    // The stored-manifest byte length is the size oracle, read back through the
    // library (never a hand-count).
    var mc = try parseCheckin(gpa, io, tmp.dir, ck);
    defer mc.deinit();
    const size = mc.bytes.len;

    var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", ck });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr); // stream discipline

    // Header line is the full hash; the type and size lines are exact.
    var lines = std.mem.splitScalar(u8, res.stdout, '\n');
    try std.testing.expectEqualStrings(ck, lines.first());
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: manifest\n") != null);
    const size_line = try std.fmt.allocPrint(gpa, "size: {d} bytes\n", .{size});
    defer gpa.free(size_line);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, size_line) != null);

    // One F line per file, each `F <name> <sha3(content)>` (§4.7.6 decoded via
    // the ONE parser). The blob hashes are RFC-derived here.
    const a_hash = try sha3HexAlloc(gpa, "alpha\n");
    defer gpa.free(a_hash);
    const b_hash = try sha3HexAlloc(gpa, "beta\n");
    defer gpa.free(b_hash);
    const a_line = try std.fmt.allocPrint(gpa, "\nF a.txt {s}\n", .{a_hash});
    defer gpa.free(a_line);
    const b_line = try std.fmt.allocPrint(gpa, "\nF b.txt {s}\n", .{b_hash});
    defer gpa.free(b_line);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, a_line) != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, b_line) != null);
    // The comment, user, and the verified Z verdict are shown too.
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nC root\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nU u\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: verified\n") != null);
}

test "cli artifact B-artifact-2: a control artifact reports type control and its T cards' sign, name, target" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // `amend --branch` mints a control artifact whose T cards move the check-in
    // onto a new branch (§4.7.16): a `*sym-feature` add and a `-sym-trunk`
    // cancel, both targeting the root check-in.
    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "amend", root_hash, "--branch", "feature" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    const control_hash = try gpa.dupe(u8, try amendHash(am.stdout));
    defer gpa.free(control_hash);

    var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", control_hash });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stderr);

    var lines = std.mem.splitScalar(u8, res.stdout, '\n');
    try std.testing.expectEqualStrings(control_hash, lines.first());
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: control\n") != null);
    // T <sign><name> <target>: sign+name glued, self/hash target rendered.
    const sym_add = try std.fmt.allocPrint(gpa, "\nT *sym-feature {s}\n", .{root_hash});
    defer gpa.free(sym_add);
    const sym_cancel = try std.fmt.allocPrint(gpa, "\nT -sym-trunk {s}\n", .{root_hash});
    defer gpa.free(sym_cancel);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, sym_add) != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, sym_cancel) != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: verified\n") != null);
}

test "cli artifact B-artifact-3: a file blob reports type file, its size, and its binary/text verdict" {
    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 text file and a NUL-bearing binary file. Each blob's name is the
    // §7.2 sha3 of its content.
    const text = "plain text content\n";
    var binary: [64]u8 = undefined;
    for (&binary, 0..) |*b, i| b.* = @intCast(i % 256); // includes NUL at 0, no trailing LF
    try writeFile(io, tmp.dir, "t.txt", text);
    try writeFile(io, tmp.dir, "b.bin", &binary);
    try addManaged(gpa, io, tmp.dir, &.{ "t.txt", "b.bin" });
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    const text_hash = try sha3HexAlloc(gpa, text);
    defer gpa.free(text_hash);
    const bin_hash = try sha3HexAlloc(gpa, &binary);
    defer gpa.free(bin_hash);

    // Text blob: type file, exact size, binary: no. No card listing, no Z line.
    {
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", text_hash });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings("", res.stderr);
        const expected = try std.fmt.allocPrint(gpa, "{s}\ntype: file\nsize: {d} bytes\nbinary: no\n", .{ text_hash, text.len });
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, res.stdout);
    }
    // Binary blob: binary: yes, exact size.
    {
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", bin_hash });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        const expected = try std.fmt.allocPrint(gpa, "{s}\ntype: file\nsize: {d} bytes\nbinary: yes\n", .{ bin_hash, binary.len });
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, res.stdout);
    }
}

test "cli artifact B-artifact-4: --raw is byte-exact and its bytes hash back to HASH for manifest, control, and blob" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir); // one manifest + one blob "v1\n"
    defer gpa.free(root_hash);
    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "amend", root_hash, "--branch", "feature" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    const control_hash = try gpa.dupe(u8, try amendHash(am.stdout));
    defer gpa.free(control_hash);
    const blob_hash = try sha3HexAlloc(gpa, "v1\n");
    defer gpa.free(blob_hash);

    // For each kind: --raw emits the exact stored bytes, and sha3-256 of those
    // bytes reproduces HASH (§3.4 round-trip). The blob additionally equals its
    // source content verbatim — no trailing newline added.
    for ([_][]const u8{ root_hash, control_hash, blob_hash }) |h| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", h, "--raw" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings("", res.stderr);
        const round = try sha3HexAlloc(gpa, res.stdout);
        defer gpa.free(round);
        try std.testing.expectEqualStrings(h, round); // hashing the emitted bytes reproduces HASH
    }
    var blob = try runZit(gpa, io, tmp.dir, &.{ "artifact", blob_hash, "--raw" });
    defer freeResult(gpa, &blob);
    try expectExit(blob.term, 0);
    try std.testing.expectEqualStrings("v1\n", blob.stdout); // verbatim, no added framing
}

test "cli artifact B-artifact-5: a Z-mismatched structural artifact reports the mismatch, exit 0 (window, not gate)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Build a structural manifest (C ⇒ §5.3 MANIFEST fallback) and seal it with
    // a DELIBERATELY WRONG §6.2 Z digest, then inject it under its real §7.2
    // name so `blob` accepts it. `parse` (gate 4) would reject it — the derive
    // therefore skips it as a blob — but the summary's `parseWindow` assembles
    // it and REPORTS the mismatch.
    const body = "C zmismatch\nD 2026-01-01T00:00:00\n";
    var zbuf: [32]u8 = undefined;
    const correct = artifact.selfChecksum(body, &zbuf);
    var full: std.ArrayList(u8) = .empty;
    defer full.deinit(gpa);
    try full.appendSlice(gpa, body);
    try full.appendSlice(gpa, "Z ");
    var bad: [32]u8 = undefined;
    @memcpy(&bad, correct);
    bad[0] = if (correct[0] == 'a') 'b' else 'a'; // corrupt one hex digit ⇒ mismatch
    try full.appendSlice(gpa, &bad);
    try full.append(gpa, '\n');

    var raw_digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(full.items, &raw_digest, .{});
    try injectFrame(gpa, io, tmp.dir, raw_digest, full.items);
    const name = try sha3HexAlloc(gpa, full.items);
    defer gpa.free(name);

    var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", name });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0); // mismatch is reported, not enforced
    try std.testing.expectEqualStrings("", res.stderr);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: manifest\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: checksum mismatch\n") != null);
}

test "cli artifact B-artifact-6: an uppercase abbreviated prefix resolves; the header carries the full lowercase hash" {
    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, "only.txt", "unique blob content\n");
    try addManaged(gpa, io, tmp.dir, &.{"only.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    const blob_hash = try sha3HexAlloc(gpa, "unique blob content\n");
    defer gpa.free(blob_hash);
    // A 12-char UPPERCASE prefix — case-insensitive resolution (§3.6).
    var upper: [12]u8 = undefined;
    for (blob_hash[0..12], 0..) |c, i| upper[i] = std.ascii.toUpper(c);

    var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", &upper });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    var lines = std.mem.splitScalar(u8, res.stdout, '\n');
    try std.testing.expectEqualStrings(blob_hash, lines.first()); // full LOWERCASE hash, however addressed
    try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: file\n") != null);
}

test "cli artifact B-artifact-7: an ambiguous prefix is refused with exit 1, diagnostic 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);

    // Two injected artifacts whose names both begin "abcd" (§8.3 item 8 makes a
    // multi-match a refusal, never a silent pick). The prefix names both.
    const d1 = [_]u8{ 0xab, 0xcd, 0x00 } ++ [_]u8{0} ** 29;
    const d2 = [_]u8{ 0xab, 0xcd, 0x11 } ++ [_]u8{0} ** 29;
    try injectFrame(gpa, io, tmp.dir, d1, "one");
    try injectFrame(gpa, io, tmp.dir, d2, "two");

    var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", "abcd" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expect(std.mem.indexOf(u8, res.stderr, "ambiguous") != null);
}

test "cli artifact B-artifact-8: an unknown hash and a syntactically invalid one are one not-found family (exit 1, 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);
    try writeFile(io, tmp.dir, "a.txt", "root\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // A well-formed but unknown hex hash, a non-hex token, a too-short token,
    // and a too-long token all refuse identically — no distinct error surface.
    for ([_][]const u8{ "deadbeefdeadbeef", "zznothex", "abc", "a" ** 65 }) |bad| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", bad });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "no artifact matches") != null);
    }
}

test "cli artifact B-artifact-9: no invocation mutates .zit, .zit-checkout, or the working tree" {
    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", "content\n");
    try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
    var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);
    const ck = try gpa.dupe(u8, try checkinHash(commit.stdout));
    defer gpa.free(ck);
    const blob_hash = try sha3HexAlloc(gpa, "content\n");
    defer gpa.free(blob_hash);

    const zit_before = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
    defer gpa.free(zit_before);
    const ck_before = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(ck_before);
    const tree_before = try readFile(gpa, io, tmp.dir, "a.txt");
    defer gpa.free(tree_before);

    // A summary, a raw dump, and two refusals — none may touch stored state.
    inline for (.{
        &[_][]const u8{ "artifact", ck },
        &[_][]const u8{ "artifact", ck, "--raw" },
        &[_][]const u8{ "artifact", blob_hash },
        &[_][]const u8{ "artifact", "deadbeef" },
    }) |tail| {
        var res = try runZit(gpa, io, tmp.dir, tail);
        freeResult(gpa, &res);
    }

    const zit_after = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
    defer gpa.free(zit_after);
    const ck_after = try readFile(gpa, io, tmp.dir, ".zit-checkout");
    defer gpa.free(ck_after);
    const tree_after = try readFile(gpa, io, tmp.dir, "a.txt");
    defer gpa.free(tree_after);
    try std.testing.expectEqualSlices(u8, zit_before, zit_after);
    try std.testing.expectEqualSlices(u8, ck_before, ck_after);
    try std.testing.expectEqualSlices(u8, tree_before, tree_after);
}

test "cli artifact B-artifact-10: -h/--help and 'help artifact' print the verbatim help on stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    for ([_][]const u8{ "--help", "-h" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(artifact_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
    var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "artifact" });
    defer freeResult(gpa, &routed);
    try expectExit(routed.term, 0);
    try std.testing.expectEqualStrings(artifact_help_expected, routed.stdout);
}

test "cli artifact B-artifact-11: missing HASH, an unknown option, and a second positional each exit 2 to stderr" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const cases = [_]struct { tail: []const []const u8, needle: []const u8 }{
        .{ .tail = &.{"artifact"}, .needle = "zit artifact: no hash given\n" },
        .{ .tail = &.{ "artifact", "--bogus" }, .needle = "zit artifact: unknown option '--bogus'\n" },
        .{ .tail = &.{ "artifact", "abcd", "efgh" }, .needle = "zit artifact: unexpected argument 'efgh'\n" },
    };
    for (cases) |c| {
        var res = try runZit(gpa, io, tmp.dir, c.tail);
        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, c.needle) != null);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "try 'zit artifact --help'\n") != null);
    }
}

test "cli artifact B-artifact-12/nongoal: outside a repo exits 1 with not-a-repo; a symbolic name is a not-found refusal" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // (a) Outside any repository: the standard not-a-repo diagnostic, exit 1,
    // empty stdout (openRepo runs before hash resolution).
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", "deadbeef" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
    }

    // (b) A symbolic name (a branch) is NOT an artifact address — `zit info`
    // owns names — so `zit artifact trunk` is the not-found refusal, exit 1.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try writeFile(io, tmp.dir, "a.txt", "root\n");
        try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
        var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
        defer freeResult(gpa, &commit);
        try expectExit(commit.term, 0);

        var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", "trunk" });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 1);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expect(std.mem.indexOf(u8, res.stderr, "no artifact matches 'trunk'") != null);
    }
}

test "cli artifact B-artifact-14: help lists artifact and did-you-mean suggests it; there is no whatis sibling verb" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // The top-level command list names the verb (discoverability).
    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "artifact") != null);

    // `whatis` is not a zit verb — the summary IS the whatis mode (single
    // spelling). It resolves to unknown-command, exit 2.
    var whatis = try runZit(gpa, io, tmp.dir, &.{ "whatis", "deadbeef" });
    defer freeResult(gpa, &whatis);
    try expectExit(whatis.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, whatis.stderr, "unknown command 'whatis'") != null);
}

// --- clone interop (sync §3.7/§4/§8) --------------------------------------
//
// The milestone e2e: a real `zit clone` client converging against a real
// `zit serve` server over loopback HTTP. Repo A is populated with a committed
// file; A is served; clone builds a fresh repo B; B's on-disk artifact set is
// read back through the library and must equal A's — proving the client store
// converged to the server's content over the wire, not from any fossil oracle.

const net = std.Io.net;

/// Spawns `zit serve --port <port>` as a detached child whose cwd is `dir`
/// (so `discoverRoot` finds `dir/.zit`). stdout/stderr are discarded — the
/// test drives it purely over the socket. Caller `kill`s the returned child.
fn spawnServe(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, port: u16) !std.process.Child {
    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);
    const port_str = try std.fmt.allocPrint(gpa, "{d}", .{port});
    defer gpa.free(port_str);

    // `spawn` builds the child image during this call; once it returns the
    // parent no longer references the argv/env buffers, so the defers above
    // free them safely after the child exists.
    return std.process.spawn(io, .{
        .argv = &.{ abs_exe, "serve", "--port", port_str },
        .cwd = .{ .dir = dir },
        .environ_map = &env,
        .stdout = .ignore,
        .stderr = .ignore,
    });
}

/// Bounded wait for `zit serve` to bind: retry-connect to the loopback port
/// until a connection is accepted or a wall-clock deadline elapses (never a
/// fixed sleep). A successful TCP connect proves the listener is up (connect
/// completes on the listen backlog, before `accept`). The deadline guarantees
/// termination if the server never binds (e.g. the port is already in use).
fn waitForListen(io: std.Io, port: u16) !void {
    const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress;
    const deadline_ns: i96 = 15_000_000_000; // 15 s cap — orders of magnitude of slack
    const start = std.Io.Clock.now(.awake, io).nanoseconds;
    while (true) {
        if (addr.connect(io, .{ .mode = .stream })) |stream| {
            stream.close(io);
            return;
        } else |_| {}
        if (std.Io.Clock.now(.awake, io).nanoseconds - start > deadline_ns) return error.ServerNeverBound;
    }
}

/// Reads every content-bearing, non-config artifact digest from `dir/.zit`
/// through the library (LogStore keys frames by the raw binary digest), sorted
/// so two repositories' sets compare directly. Caller frees via `freeDigests`.
fn collectArtifactDigests(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![][]u8 {
    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.openReadOnly(gpa, fs.storageFile());
    defer log.deinit(gpa);

    const rids = try log.ridsInPersistedOrder(gpa); // non-phantom, persisted order
    defer gpa.free(rids);
    var list: std.ArrayListUnmanaged([]u8) = .empty;
    errdefer {
        for (list.items) |h| gpa.free(h);
        list.deinit(gpa);
    }
    for (rids) |rid| {
        if (log.kindOf(rid) == .config) continue; // config is repo metadata, not synced
        try list.append(gpa, try gpa.dupe(u8, log.hashOf(rid)));
    }
    const slice = try list.toOwnedSlice(gpa);
    std.mem.sort([]u8, slice, {}, struct {
        fn lt(_: void, a: []u8, b: []u8) bool {
            return std.mem.order(u8, a, b) == .lt;
        }
    }.lt);
    return slice;
}

fn freeDigests(gpa: std.mem.Allocator, digests: [][]u8) void {
    for (digests) |h| gpa.free(h);
    gpa.free(digests);
}

/// Reads a repository's stored sync project code (§9.1) from its `.zit`
/// config frame. Caller owns the returned bytes.
fn readProjectCode(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    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.openReadOnly(gpa, fs.storageFile());
    defer log.deinit(gpa);
    var oc = (try log.readConfig(gpa)) orelse return error.TestUnexpectedResult;
    defer oc.deinit(gpa);
    return gpa.dupe(u8, oc.config.project_code);
}

/// Asserts two artifact-digest sets are identical (same count, same members).
/// Both inputs are sorted by `collectArtifactDigests`, so an elementwise walk
/// is a set-equality proof.
fn expectSameDigestSet(a: []const []const u8, b: []const []const u8) !void {
    try std.testing.expectEqual(a.len, b.len);
    for (a, b) |x, y| try std.testing.expectEqualSlices(u8, x, y);
}

test "cli remote sync §4/§6: bare prints, a URL sets, and --unset clears the saved remote" {
    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 fresh repo has no saved remote.
    {
        var r = try runZit(gpa, io, tmp.dir, &.{"remote"});
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
        try std.testing.expectEqualStrings("no remote set\n", r.stdout);
    }
    // The §9.1 project code `init` seeded — the oracle for field preservation:
    // writing a remote must not disturb it (guards the config-RMW field-drop
    // regression the shared `updateConfig` helper closes).
    const code_before = try readProjectCode(gpa, io, tmp.dir);
    defer gpa.free(code_before);
    // Setting one echoes it back and persists it.
    {
        var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "http://h.example/xfer" });
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
        try std.testing.expectEqualStrings("remote set to http://h.example/xfer\n", r.stdout);
    }
    // The project code survived the remote write untouched.
    {
        const code_after = try readProjectCode(gpa, io, tmp.dir);
        defer gpa.free(code_after);
        try std.testing.expectEqualStrings(code_before, code_after);
    }
    {
        var r = try runZit(gpa, io, tmp.dir, &.{"remote"});
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
        try std.testing.expectEqualStrings("http://h.example/xfer\n", r.stdout);
    }
    // Clearing it returns the repo to "no remote set".
    {
        var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "--unset" });
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
        try std.testing.expectEqualStrings("remote cleared\n", r.stdout);
    }
    {
        var r = try runZit(gpa, io, tmp.dir, &.{"remote"});
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
        try std.testing.expectEqualStrings("no remote set\n", r.stdout);
    }
}

test "cli sync §4/§6: an omitted URL with no saved remote is a usage error for pull, push, and sync" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // No remote saved: each sync-family verb with no URL is a usage error (exit 2),
    // diagnostic on stderr, nothing on stdout.
    for ([_][]const u8{ "pull", "push", "sync" }) |verb| {
        var r = try runZit(gpa, io, tmp.dir, &.{verb});
        defer freeResult(gpa, &r);
        try expectExit(r.term, 2);
        try std.testing.expectEqualStrings("", r.stdout);
        try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no saved remote") != null);
    }
}

test "cli pull sync §4: a saved remote is resolved and used verbatim when the URL arg is omitted" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Save an out-of-spec (unsupported-scheme) remote — stored verbatim, validated
    // only at use time (§4 defines no URL grammar). `zit pull` with no URL must
    // resolve THIS URL and hand it to the transport, whose scheme rejection quotes
    // it back — proving verbatim use (not a usage error, not "not a repo").
    {
        var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "gopher://h.example/xfer" });
        defer freeResult(gpa, &r);
        try expectExit(r.term, 0);
    }
    var r = try runZit(gpa, io, tmp.dir, &.{"pull"});
    defer freeResult(gpa, &r);
    try expectExit(r.term, 1); // an open failure, not a usage error
    try std.testing.expect(std.mem.indexOf(u8, r.stderr, "gopher://h.example/xfer") != null);
}

test "cli clone: a fresh clone converges to the served repo's artifacts over HTTP (sync §3.7/§8)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Repo A: init + a committed file, so A's log holds real artifacts (the
    // file blob and its manifest) — genuine content for the wire to carry.
    var tmp_a = std.testing.tmpDir(.{});
    defer tmp_a.cleanup();
    try initRepo(gpa, io, tmp_a.dir);
    try writeFile(io, tmp_a.dir, "hello.txt", "hello from zit over http\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"hello.txt"});
    var commit = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
    defer freeResult(gpa, &commit);
    try expectExit(commit.term, 0);

    // A's artifact-digest set is the convergence oracle: the RFC + the actual
    // committed content decide it, never a second fossil.
    const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
    defer freeDigests(gpa, a_digests);
    try std.testing.expect(a_digests.len >= 1);

    // Serve A on a loopback port; wait (bounded) for the listener to accept.
    const port: u16 = 47187;
    var server = try spawnServe(gpa, io, tmp_a.dir, port);
    defer server.kill(io);
    try waitForListen(io, port);

    // Clone A into a fresh repo B over real HTTP, targeting the BARE ROOT path
    // "/" (sync §4 L759-764 — the client sends the repo URL path unchanged and
    // MUST NOT append /xfer; a conformant `fossil clone http://host:port/`
    // POSTs to "/"). `zit serve` dispatches sync on method + Content-Type
    // regardless of path, so the root URL clones. (The fossil-client-from-root
    // case belongs in `zig build smoke`.)
    const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port});
    defer gpa.free(url);
    var tmp_b = std.testing.tmpDir(.{});
    defer tmp_b.cleanup();
    var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", url, "repo" });
    defer freeResult(gpa, &clone);
    try expectExit(clone.term, 0);
    try std.testing.expectEqualStrings("", clone.stderr);

    // THE PROOF: B exists and its on-disk artifact set equals A's — a real zit
    // client store converged to a real zit server's content over the wire
    // (sync §3.7). A partial/empty clone fails here; the assertion is not weakened.
    var b_dir = try tmp_b.dir.openDir(io, "repo", .{});
    defer b_dir.close(io);
    const b_digests = try collectArtifactDigests(gpa, io, b_dir);
    defer freeDigests(gpa, b_digests);
    try expectSameDigestSet(a_digests, b_digests);

    // §9.1 — B adopted A's project code: the shared identity travels in the
    // clone reply's `push` card (§8.2/§8.3) and is persisted into B's config.
    // Both are the same real, 40-hex, non-placeholder code (not all zeros).
    const a_code = try readProjectCode(gpa, io, tmp_a.dir);
    defer gpa.free(a_code);
    const b_code = try readProjectCode(gpa, io, b_dir);
    defer gpa.free(b_code);
    try std.testing.expectEqual(@as(usize, 40), a_code.len);
    try std.testing.expect(!std.mem.eql(u8, a_code, "0" ** 40));
    try std.testing.expectEqualStrings(a_code, b_code);
}

// --- pull interop (sync §3.2/§3.3/§3.7) ------------------------------------
//
// The pull milestone e2e: an incremental `zit pull` into an EXISTING repo B over
// loopback HTTP. B is cloned from A, then A grows a second check-in, then B pulls
// A's new artifacts and its on-disk set must equal A's new full set — proving the
// pull client fetched exactly what B lacked and converged (sync §3.7), over the
// wire, not from any fossil oracle. `zit serve` reads a fixed snapshot of the
// repository at startup, so A is re-served after the second commit; the restart
// is a harness detail, not part of what pull is asked to prove.

test "cli pull: an incremental pull fetches a repo's new artifacts over HTTP (sync §3.2/§3.3/§3.7)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Repo A: init + a committed file — A's first check-in, real artifacts to seed.
    var tmp_a = std.testing.tmpDir(.{});
    defer tmp_a.cleanup();
    try initRepo(gpa, io, tmp_a.dir);
    try writeFile(io, tmp_a.dir, "one.txt", "first artifact over http\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"one.txt"});
    var commit1 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
    defer freeResult(gpa, &commit1);
    try expectExit(commit1.term, 0);

    // Serve A's seed state and clone it into B; B now shares A's project-code and
    // holds A's first-check-in artifacts (the pull starts from a real subset).
    const clone_port: u16 = 47251;
    var clone_server = try spawnServe(gpa, io, tmp_a.dir, clone_port);
    try waitForListen(io, clone_port);
    const clone_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{clone_port});
    defer gpa.free(clone_url);
    var tmp_b = std.testing.tmpDir(.{});
    defer tmp_b.cleanup();
    var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", clone_url, "repo" });
    defer freeResult(gpa, &clone);
    try expectExit(clone.term, 0);
    // The seed server has served its purpose; stop it before A grows so the next
    // server starts from a snapshot that includes the second check-in.
    clone_server.kill(io);

    // A grows: a second committed file, so A now holds artifacts B does not.
    try writeFile(io, tmp_a.dir, "two.txt", "second artifact B has never seen\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"two.txt"});
    var commit2 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "grow" });
    defer freeResult(gpa, &commit2);
    try expectExit(commit2.term, 0);

    // A's new full artifact-digest set is the convergence oracle for the pull.
    const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
    defer freeDigests(gpa, a_digests);
    try std.testing.expect(a_digests.len > b_seed_len(gpa, io, tmp_b.dir));

    // Re-serve A (now two check-ins) on a fresh port; pull from within B.
    const pull_port: u16 = 47252;
    var pull_server = try spawnServe(gpa, io, tmp_a.dir, pull_port);
    defer pull_server.kill(io);
    try waitForListen(io, pull_port);
    const pull_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{pull_port});
    defer gpa.free(pull_url);

    var b_repo = try tmp_b.dir.openDir(io, "repo", .{});
    defer b_repo.close(io);
    var pull = try runZit(gpa, io, b_repo, &.{ "pull", pull_url });
    defer freeResult(gpa, &pull);
    try expectExit(pull.term, 0);
    try std.testing.expectEqualStrings("", pull.stderr);

    // THE PROOF: B's on-disk artifact set now equals A's NEW full set — B received
    // the second check-in's artifacts over the wire and converged (sync §3.7). A
    // pull that fetched nothing (or only part) fails here; the assertion is not weakened.
    const b_digests = try collectArtifactDigests(gpa, io, b_repo);
    defer freeDigests(gpa, b_digests);
    try expectSameDigestSet(a_digests, b_digests);
}

/// The count of artifacts B holds straight after its clone — the pull's starting
/// subset. Read back through the library so the incremental assertion (A grew
/// past this) rests on B's real on-disk state, not an assumed number.
fn b_seed_len(gpa: std.mem.Allocator, io: std.Io, tmp_b: std.Io.Dir) usize {
    var b_repo = tmp_b.openDir(io, "repo", .{}) catch return 0;
    defer b_repo.close(io);
    const d = collectArtifactDigests(gpa, io, b_repo) catch return 0;
    defer freeDigests(gpa, d);
    return d.len;
}

// --- incremental pull, no server restart (sync §3.8) -----------------------
//
// Direction A of the fossil incremental-pull interop: a `zit serve` must
// advertise the CURRENT on-disk repository per request, so a check-in committed
// to A *while the server is live* is pulled by B without restarting serve. The
// server is stateless per request (§3.8), so it re-reads A's log every request
// rather than snapshotting its holdings once at startup. One server, spawned
// before the second commit and never restarted, is the whole point.

test "cli pull: a commit made while the server runs is pulled without a restart (sync §3.8)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Repo A: init + a first committed file — real seed artifacts.
    var tmp_a = std.testing.tmpDir(.{});
    defer tmp_a.cleanup();
    try initRepo(gpa, io, tmp_a.dir);
    try writeFile(io, tmp_a.dir, "one.txt", "first artifact over http\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"one.txt"});
    var commit1 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
    defer freeResult(gpa, &commit1);
    try expectExit(commit1.term, 0);

    // ONE server for A, spawned now and never restarted — the invariant under test.
    const port: u16 = 47253;
    var server = try spawnServe(gpa, io, tmp_a.dir, port);
    defer server.kill(io);
    try waitForListen(io, port);
    const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{port});
    defer gpa.free(url);

    // Clone A into B over the live server; B holds A's seed check-in.
    var tmp_b = std.testing.tmpDir(.{});
    defer tmp_b.cleanup();
    var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", url, "repo" });
    defer freeResult(gpa, &clone);
    try expectExit(clone.term, 0);
    var b_repo = try tmp_b.dir.openDir(io, "repo", .{});
    defer b_repo.close(io);
    const seed_digests = try collectArtifactDigests(gpa, io, b_repo);
    const seed_len = seed_digests.len;
    freeDigests(gpa, seed_digests);

    // A grows a SECOND check-in while the SAME server keeps running — no restart.
    try writeFile(io, tmp_a.dir, "two.txt", "second artifact committed with serve still live\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"two.txt"});
    var commit2 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "grow" });
    defer freeResult(gpa, &commit2);
    try expectExit(commit2.term, 0);

    // A's new full digest set is the convergence oracle.
    const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
    defer freeDigests(gpa, a_digests);
    try std.testing.expect(a_digests.len > seed_len);

    // Pull from B against the still-live server. It MUST see the second check-in.
    var pull = try runZit(gpa, io, b_repo, &.{ "pull", url });
    defer freeResult(gpa, &pull);
    try expectExit(pull.term, 0);
    try std.testing.expectEqualStrings("", pull.stderr);

    // THE PROOF: B's on-disk artifact set equals A's NEW full set — the server
    // advertised the live commit and B converged (§3.7), no restart involved.
    const b_digests = try collectArtifactDigests(gpa, io, b_repo);
    defer freeDigests(gpa, b_digests);
    try expectSameDigestSet(a_digests, b_digests);
}

// --- push interop (sync §3.3/§3.7) -----------------------------------------
//
// The push milestone e2e: an outgoing `zit push` that UPLOADS a repo's new
// artifacts into an EXISTING served repo over loopback HTTP. B is cloned from A,
// then B grows a second check-in, then B pushes its new artifacts to A, and A's
// on-disk set must equal B's full set — proving the push client uploaded exactly
// what A lacked and the server persisted it durably (sync §3.3/§3.7), over the
// wire, not from any fossil oracle. This is the mirror of pull: content flows the
// other way, and the durable landing is on the SERVER (A), not the client.

test "cli push: an outgoing push lands a repo's new artifacts on the server over HTTP (sync §3.3/§3.7)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // Repo A: init + a committed file — A's first check-in, real artifacts to seed.
    var tmp_a = std.testing.tmpDir(.{});
    defer tmp_a.cleanup();
    try initRepo(gpa, io, tmp_a.dir);
    try writeFile(io, tmp_a.dir, "one.txt", "first artifact over http\n");
    try addManaged(gpa, io, tmp_a.dir, &.{"one.txt"});
    var commit1 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
    defer freeResult(gpa, &commit1);
    try expectExit(commit1.term, 0);

    // Serve A's seed state and clone it into B; B now shares A's project-code and
    // holds A's first-check-in artifacts (the push starts from a real subset).
    const clone_port: u16 = 47261;
    var clone_server = try spawnServe(gpa, io, tmp_a.dir, clone_port);
    try waitForListen(io, clone_port);
    const clone_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{clone_port});
    defer gpa.free(clone_url);
    var tmp_b = std.testing.tmpDir(.{});
    defer tmp_b.cleanup();
    var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", clone_url, "repo" });
    defer freeResult(gpa, &clone);
    try expectExit(clone.term, 0);
    clone_server.kill(io);

    // B grows: a second committed file, so B now holds artifacts A does not.
    var b_repo = try tmp_b.dir.openDir(io, "repo", .{});
    defer b_repo.close(io);
    try writeFile(io, b_repo, "two.txt", "second artifact A has never seen\n");
    try addManaged(gpa, io, b_repo, &.{"two.txt"});
    var commit2 = try runCommitEnv(gpa, io, b_repo, .{ .user = "u" }, &.{ "commit", "-m", "grow" });
    defer freeResult(gpa, &commit2);
    try expectExit(commit2.term, 0);

    // B's full artifact-digest set is the convergence oracle: after the push, A
    // must hold exactly this set. Captured now, before the wire runs.
    const b_digests = try collectArtifactDigests(gpa, io, b_repo);
    defer freeDigests(gpa, b_digests);
    try std.testing.expect(b_digests.len > b_seed_len(gpa, io, tmp_b.dir) - 1);

    // Serve A (still only the seed check-in) and push B's content up to it.
    const push_port: u16 = 47262;
    var push_server = try spawnServe(gpa, io, tmp_a.dir, push_port);
    defer push_server.kill(io);
    try waitForListen(io, push_port);
    const push_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{push_port});
    defer gpa.free(push_url);

    var push = try runZit(gpa, io, b_repo, &.{ "push", push_url });
    defer freeResult(gpa, &push);
    try expectExit(push.term, 0);
    try std.testing.expectEqualStrings("", push.stderr);

    // Stop the server so its file handles are released before A is read back — the
    // durable landing is on disk, independent of the live process.
    push_server.kill(io);

    // THE PROOF: A's on-disk artifact set now equals B's full set — A received the
    // second check-in's artifacts over the wire and the server persisted them
    // durably (sync §3.3/§3.7). A push the server dropped (or only half-stored)
    // fails here; the assertion is not weakened.
    const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
    defer freeDigests(gpa, a_digests);
    try expectSameDigestSet(a_digests, b_digests);
}

// ==========================================================================
// `zit extras` (contracts/extras.md, B-extras-1..18). The sweep lists every
// on-disk file the repo is not tracking, one sorted root-relative path per
// line. Each test drives the real binary; expected bytes come from the clause
// text and inline ASCII, never from running fossil.
// ==========================================================================

/// The verbatim `zit extras` help — an independent transcription of `main.zig`'s
/// `extras_help` (B-extras-11), so a drift between the two is caught here.
const extras_help_expected =
    \\usage: zit extras [-h|--help]
    \\
    \\List every file in the checkout that zit is not tracking — each on-disk file
    \\under the checkout root that is neither a tracked file nor a zit control file.
    \\Paths are shown relative to the checkout root, one per line, sorted. Files in
    \\subdirectories are included; directories themselves are not. Symlinks are
    \\listed but never followed. With nothing untracked, the output is empty.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

test "cli extras B-extras-1: lists every untracked file once, sorted, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    // Three untracked files in reverse-of-sorted creation order; `.zit` and
    // `.zit-checkout` (control) are present but never listed.
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    var res = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // ASCII ascending: apple < mango < zebra. Each appears exactly once.
    try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli extras B-extras-3: a nested file prints as sub/dir/name, no ./ or leading /" {
    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 tmp.dir.createDirPath(io, "sub/dir");
    try touch(io, tmp.dir, "sub/dir/name");

    var res = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("sub/dir/name\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli extras B-extras-4: a tracked file is not listed even when 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, "keep");
    try touch(io, tmp.dir, "loose");
    try addManaged(gpa, io, tmp.dir, &.{"keep"});

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

test "cli extras B-extras-10: an empty checkout yields empty stdout, exit 0" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir); // only the control files exist

    var res = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
}

test "cli extras B-extras-11: -h and --help print verbatim help to stdout, exit 0, help wins over a trailing token" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);

    // Help wins even when an otherwise-invalid token trails it (B-extras-11).
    const invocations = [_][]const []const u8{
        &.{ "extras", "--help" },
        &.{ "extras", "-h" },
        &.{ "extras", "--help", "foo" },
        &.{ "extras", "-h", "--all" },
    };
    for (invocations) |argv| {
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(extras_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
}

test "cli extras B-extras-12: an unknown option is a usage error, exit 2, 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);

    const cases = [_]struct { flag: []const u8 }{ .{ .flag = "--all" }, .{ .flag = "-0" } };
    for (cases) |c| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "extras", c.flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        const expected = try std.fmt.allocPrint(
            gpa,
            "zit extras: unknown option '{s}'\ntry 'zit extras --help'\n",
            .{c.flag},
        );
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli extras B-extras-13: a positional argument is a usage error, exit 2, 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);

    // A bare operand and any token after `--` are both extra arguments.
    const invocations = [_][]const []const u8{
        &.{ "extras", "foo" },
        &.{ "extras", "--", "foo" },
    };
    for (invocations) |argv| {
        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 extras: unexpected argument 'foo'\ntry 'zit extras --help'\n",
            res.stderr,
        );
    }
}

test "cli extras B-extras-14: outside a repository, exit 1, not_a_repo on stderr, empty stdout" {
    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, &.{"extras"});
    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 extras B-extras-15: a corrupt .zit-managed fails cleanly, 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, &.{"extras"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit extras: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
}

test "cli extras B-extras-16: read-only — the tree is unchanged after a run" {
    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, "one");
    try tmp.dir.createDirPath(io, "d");
    try touch(io, tmp.dir, "d/two");

    var res = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("d/two\none\n", res.stdout);

    // extras writes no state and moves no working file (B-extras-16): the user
    // files remain, and none of the artifacts a mutator would create appeared.
    try std.testing.expect(fileExists(io, tmp.dir, "one"));
    try std.testing.expect(fileExists(io, tmp.dir, "d/two"));
    try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit-cache"));
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit-pack-tmp"));
    try std.testing.expect(!fileExists(io, tmp.dir, ".zit-undo"));
}

test "cli extras B-extras-17: `zit extras >> log` appends, rows to stdout (O_APPEND honored)" {
    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");

    // The shell creates `log` (itself now an untracked file) BEFORE extras runs,
    // so the sweep lists both `g1` and `log`; appending must preserve the
    // pre-existing line. ASCII: `g1` < `log`.
    var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" extras >> 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\ng1\nlog\n", log);
}

test "cli extras B-extras-11: 'extras' is listed in the command help and suggested for a near-miss" {
    // The verb joins the single-source Verb enum, so the command list names it,
    // `zit help extras` routes to the verb's own verbatim help (B-extras-11), and
    // the did-you-mean suggester finds a near-miss.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "extras") != null);

    var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "extras" });
    defer freeResult(gpa, &topic);
    try expectExit(topic.term, 0);
    try std.testing.expectEqualStrings(extras_help_expected, topic.stdout);

    var typo = try runZit(gpa, io, tmp.dir, &.{"extraz"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'extras'?") != null);
}

// ==========================================================================
// `zit clean` (contracts/clean.md, B-clean-1..18). The destructive companion to
// `extras`: it consumes the SAME sweep unchanged and adds deletion, gated behind
// `--force`. Each test drives the real binary; expected bytes come from the
// clause text and the settled help/refusal strings, never from running fossil.
// ==========================================================================

/// The verbatim `zit clean` help — an independent transcription of `main.zig`'s
/// `clean_help` (B-clean-12/cli-help-verbatim-stdout-exit-0), so a drift between
/// the two is caught here.
const clean_help_expected =
    \\usage: zit clean [--dry-run] [--force]
    \\
    \\Delete every file in the checkout that zit is not tracking — the same set
    \\`zit extras` lists. This is destructive and cannot be undone: `zit undo` does
    \\not recover a clean.
    \\
    \\By default clean refuses and deletes nothing: it reports how many untracked
    \\files would be deleted and stops. Pass --force to delete them, or --dry-run to
    \\preview the exact list first. clean never prompts.
    \\
    \\Tracked files and zit's control files are never touched. Only files are
    \\deleted; directories left empty are not pruned.
    \\
    \\options:
    \\  --dry-run     list what would be deleted, one path per line; delete nothing
    \\  --force       delete the untracked files
    \\  -h, --help    show this help and exit
    \\
    \\If both --dry-run and --force are given, --dry-run wins and nothing is deleted.
    \\
    \\exit status:
    \\  0  nothing to delete, a --dry-run preview, or every file deleted
    \\  1  refused (no --force), a file could not be deleted, or not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// The verbatim B-clean-1 refusal, naming a plural count of extras.
const clean_refuse_three =
    "zit clean: 3 untracked files would be deleted; refusing without --force\n" ++
    "run 'zit clean --dry-run' to preview, or 'zit clean --force' to delete\n";

test "cli clean B-clean-1: no flags with extras present refuses, exit 1, names the count, deletes 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);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    var res = try runZit(gpa, io, tmp.dir, &.{"clean"});
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    // The gate: stdout stays empty; the two-line refusal (count + pointers) is on
    // stderr; and every extra is still on disk (nothing was deleted).
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(clean_refuse_three, res.stderr);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name|
        try std.testing.expect(fileExists(io, tmp.dir, name));
}

test "cli clean B-clean-2: the refusal is non-interactive — stdin closed behaves identically" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    // stdin redirected from /dev/null: clean never reads it, never prompts — the
    // output is byte-identical to the plain B-clean-1 run.
    var res = try runShell(gpa, io, tmp.dir, "\"$ZIT\" clean < /dev/null");
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(clean_refuse_three, res.stderr);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name|
        try std.testing.expect(fileExists(io, tmp.dir, name));
}

test "cli clean B-clean-3: --dry-run previews to stdout, exit 0, leaving the tree byte-identical" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    // --dry-run deletes nothing (B-clean-3): every file is still present.
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name|
        try std.testing.expect(fileExists(io, tmp.dir, name));
}

test "cli clean B-clean-4: the --dry-run preview is byte-identical to `zit extras`" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    var dry = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" });
    defer freeResult(gpa, &dry);
    var ext = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &ext);
    try expectExit(dry.term, 0);
    try expectExit(ext.term, 0);
    // Same paths, same order, same form — clean and extras share the ONE sweep.
    try std.testing.expectEqualStrings(ext.stdout, dry.stdout);
    try std.testing.expectEqualStrings("apple\nmango\nzebra\n", dry.stdout);
}

test "cli clean B-clean-5: --force deletes exactly the extras set, exit 0, tracked and control survive" {
    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, "keep");
    try touch(io, tmp.dir, "loose1");
    try touch(io, tmp.dir, "loose2");
    try addManaged(gpa, io, tmp.dir, &.{"keep"});

    var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    // Full success is silent on both streams (cli-stream-separation).
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings("", res.stderr);
    // Exactly the extras set is gone; the tracked file and every control entry
    // remain — untouchable by construction (B-clean-7).
    try std.testing.expect(!fileExists(io, tmp.dir, "loose1"));
    try std.testing.expect(!fileExists(io, tmp.dir, "loose2"));
    try std.testing.expect(fileExists(io, tmp.dir, "keep"));
    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));
}

test "cli clean B-clean-6: --force is idempotent — a subsequent extras lists 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, "one");
    try touch(io, tmp.dir, "two");

    var first = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &first);
    try expectExit(first.term, 0);

    // The extras that were deleted are gone and nothing tracked/control became an
    // extra: extras (and a re-run clean --dry-run) over the now-clean tree is empty.
    var ext = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &ext);
    try expectExit(ext.term, 0);
    try std.testing.expectEqualStrings("", ext.stdout);

    var again = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" });
    defer freeResult(gpa, &again);
    try expectExit(again.term, 0);
    try std.testing.expectEqualStrings("", again.stdout);
}

test "cli clean B-clean-7: a tracked file and every control entry survive a --force byte-for-byte" {
    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, "tracked", .{});
        defer f.close(io);
        try f.writePositionalAll(io, "PAYLOAD\n", 0);
    }
    try addManaged(gpa, io, tmp.dir, &.{"tracked"});
    try touch(io, tmp.dir, "extra");

    var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 0);
    try std.testing.expect(!fileExists(io, tmp.dir, "extra"));
    // The tracked file's content is unchanged, and the object store / checkout
    // record / managed list are all intact — clean never re-checks membership.
    const body = try readFile(gpa, io, tmp.dir, "tracked");
    defer gpa.free(body);
    try std.testing.expectEqualStrings("PAYLOAD\n", body);
    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));
}

test "cli clean B-clean-8: an empty extras set is a success no-op for both --force and un-forced" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir); // only the control files exist — no extras

    const invocations = [_][]const []const u8{
        &.{ "clean", "--force" },
        &.{"clean"},
    };
    for (invocations) |argv| {
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        // Emptiness is a success, never a refusal: no diagnostic, no count.
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings("", res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
    }
}

test "cli clean B-clean-9: partial failure continues siblings, reports the 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);
    try touch(io, tmp.dir, "a");
    try tmp.dir.createDirPath(io, "sub");
    try touch(io, tmp.dir, "sub/stuck");

    // Make `sub` non-writable so unlinking `sub/stuck` fails with EACCES, while
    // the sweep can still read `sub` (r-x). `a` (sorted first) deletes cleanly.
    var sub = try tmp.dir.openDir(io, "sub", .{});
    defer sub.close(io);
    try sub.setPermissions(io, File.Permissions.fromMode(0o555));
    defer sub.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};

    var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit clean: 'sub/stuck': could not delete: permission denied\n",
        res.stderr,
    );

    // Restore write access before the readback so traversal (and cleanup) is
    // unhindered: the deletable sibling `a` is gone, the undeletable one remains.
    try sub.setPermissions(io, File.Permissions.fromMode(0o777));
    try std.testing.expect(!fileExists(io, tmp.dir, "a"));
    try std.testing.expect(fileExists(io, tmp.dir, "sub/stuck"));
}

test "cli clean B-clean-10: exit is 0 on full success and exactly 1 on a partial failure, never 2" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    // All deletions succeed → exit 0.
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try touch(io, tmp.dir, "x");
        var ok = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
        defer freeResult(gpa, &ok);
        try expectExit(ok.term, 0);
    }

    // At least one deletion fails → exit exactly 1 (operation class, not usage 2).
    {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try initRepo(gpa, io, tmp.dir);
        try tmp.dir.createDirPath(io, "sub");
        try touch(io, tmp.dir, "sub/stuck");
        var sub = try tmp.dir.openDir(io, "sub", .{});
        defer sub.close(io);
        try sub.setPermissions(io, File.Permissions.fromMode(0o555));
        defer sub.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};

        var bad = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
        defer freeResult(gpa, &bad);
        try expectExit(bad.term, 1);

        try sub.setPermissions(io, File.Permissions.fromMode(0o777));
    }
}

test "cli clean B-clean-11: --dry-run wins over --force — preview, exit 0, nothing 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);
    for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name);

    const invocations = [_][]const []const u8{
        &.{ "clean", "--dry-run", "--force" },
        &.{ "clean", "--force", "--dry-run" },
    };
    for (invocations) |argv| {
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        // --dry-run is the safety override: it can never be upgraded into a delete.
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout);
        for ([_][]const u8{ "zebra", "apple", "mango" }) |name|
            try std.testing.expect(fileExists(io, tmp.dir, name));
    }
}

test "cli clean B-clean-12: -h/--help print verbatim help to stdout, exit 0, help wins and deletes 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, "extra");

    // Help wins over a trailing invalid token, an unknown option, and --force.
    const invocations = [_][]const []const u8{
        &.{ "clean", "--help" },
        &.{ "clean", "-h" },
        &.{ "clean", "--help", "foo" },
        &.{ "clean", "-h", "--all" },
        &.{ "clean", "--force", "--help" },
    };
    for (invocations) |argv| {
        var res = try runZit(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 0);
        try std.testing.expectEqualStrings(clean_help_expected, res.stdout);
        try std.testing.expectEqualStrings("", res.stderr);
        // Help never sweeps and never deletes (B-clean-12).
        try std.testing.expect(fileExists(io, tmp.dir, "extra"));
    }
}

test "cli clean B-clean-13: an unknown option is a usage error, exit 2, 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);

    // `-f` and `-n` are NOT accepted short aliases (B-clean-13).
    inline for (.{ "--all", "-0", "-f", "-n" }) |flag| {
        var res = try runZit(gpa, io, tmp.dir, &.{ "clean", flag });
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        const expected = try std.fmt.allocPrint(
            gpa,
            "zit clean: unknown option '{s}'\ntry 'zit clean --help'\n",
            .{flag},
        );
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli clean B-clean-14: a positional or a token after -- is a usage error, exit 2, 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);

    const Case = struct { argv: []const []const u8, token: []const u8 };
    const cases = [_]Case{
        .{ .argv = &.{ "clean", "foo" }, .token = "foo" },
        .{ .argv = &.{ "clean", "--", "foo" }, .token = "foo" },
        .{ .argv = &.{ "clean", "--", "--force" }, .token = "--force" },
    };
    for (cases) |c| {
        var res = try runZit(gpa, io, tmp.dir, c.argv);
        defer freeResult(gpa, &res);
        try expectExit(res.term, 2);
        try std.testing.expectEqualStrings("", res.stdout);
        const expected = try std.fmt.allocPrint(
            gpa,
            "zit clean: unexpected argument '{s}'\ntry 'zit clean --help'\n",
            .{c.token},
        );
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, res.stderr);
    }
}

test "cli clean B-clean-15: outside a repository, exit 1, not_a_repo on stderr, empty stdout" {
    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

    // The repository check precedes any sweep, regardless of --force/--dry-run.
    const invocations = [_][]const []const u8{
        &.{"clean"},
        &.{ "clean", "--force" },
        &.{ "clean", "--dry-run" },
    };
    for (invocations) |argv| {
        var res = try runZit(gpa, io, tmp.dir, argv);
        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 clean B-clean-16: a corrupt .zit-managed fails cleanly BEFORE any deletion, 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, "extra");
    {
        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);
    }

    // Even with --force, a tracked set that cannot be read is a tree clean must
    // not touch: nothing is deleted (the extra is still on disk).
    var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &res);
    try expectExit(res.term, 1);
    try std.testing.expectEqualStrings("", res.stdout);
    try std.testing.expectEqualStrings(
        "zit clean: cannot read tracked files: .zit-managed is corrupt\n",
        res.stderr,
    );
    try std.testing.expect(fileExists(io, tmp.dir, "extra"));
}

test "cli clean B-clean-17: `zit clean --dry-run >> log` appends, rows to stdout (O_APPEND honored)" {
    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");

    // The shell creates `log` (itself now an untracked file) BEFORE clean runs, so
    // the preview lists both `g1` and `log`; appending must preserve the
    // pre-existing line. ASCII: `g1` < `log`.
    var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" clean --dry-run >> 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\ng1\nlog\n", log);
}

test "cli clean B-clean-18: over a deep nested tree --dry-run equals extras and --force deletes that exact 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 tmp.dir.createDirPath(io, "x/y/z");
    try touch(io, tmp.dir, "x/y/z/deep");
    try touch(io, tmp.dir, "top");

    // The ONE shared sweep, bounds included: preview equals extras and equals the
    // derived ascending listing ("top" < "x/y/z/deep").
    var ext = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &ext);
    var dry = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" });
    defer freeResult(gpa, &dry);
    try expectExit(dry.term, 0);
    try std.testing.expectEqualStrings(ext.stdout, dry.stdout);
    try std.testing.expectEqualStrings("top\nx/y/z/deep\n", dry.stdout);

    // --force deletes exactly that set; a subsequent extras lists nothing.
    var force = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" });
    defer freeResult(gpa, &force);
    try expectExit(force.term, 0);
    try std.testing.expect(!fileExists(io, tmp.dir, "top"));
    try std.testing.expect(!fileExists(io, tmp.dir, "x/y/z/deep"));

    var after = try runZit(gpa, io, tmp.dir, &.{"extras"});
    defer freeResult(gpa, &after);
    try expectExit(after.term, 0);
    try std.testing.expectEqualStrings("", after.stdout);
}

test "cli clean B-clean-12: 'clean' is listed in the command help and suggested for a near-miss" {
    // The verb joins the single-source Verb enum, so the command list names it,
    // `zit help clean` routes to the verb's own verbatim help (B-clean-12), and
    // the did-you-mean suggester finds a near-miss.
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var help = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &help);
    try expectExit(help.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, help.stdout, "clean") != null);

    var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "clean" });
    defer freeResult(gpa, &topic);
    try expectExit(topic.term, 0);
    try std.testing.expectEqualStrings(clean_help_expected, topic.stdout);

    var typo = try runZit(gpa, io, tmp.dir, &.{"cleen"});
    defer freeResult(gpa, &typo);
    try expectExit(typo.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'clean'?") != null);
}

test "cli add B-extras-5: 'zit add .zit-cache' and 'zit add .zit-pack-tmp' are refused as control files" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try initRepo(gpa, io, tmp.dir);
    // The two transient artifacts join the never-tracked set: add/rm delegate to
    // the same predicate `extras` uses, so the boundary cannot drift.
    try touch(io, tmp.dir, ".zit-cache");
    try touch(io, tmp.dir, ".zit-pack-tmp");

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

// --- amend (contracts/amend.md B-amend-1..17) -------------------------------

/// An independent transcription of `main.zig`'s `amend_help` block (must match
/// byte-for-byte; drift fails B-amend-13).
const amend_help_expected =
    \\zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME]
    \\
    \\Attach display overrides to a check-in by minting one control artifact.
    \\History is never rewritten; the original check-in is untouched.
    \\
    \\  --comment TEXT   override the displayed comment
    \\  --user NAME      override the displayed author
    \\  --date ISO       override the displayed date (YYYY-MM-DDTHH:MM:SS)
    \\  --branch NAME    move the check-in (and its descendants) to branch NAME
    \\  -h, --help       show this help
    \\
    \\At least one override flag is required.
    \\
;

/// Extracts the 64-hex control-artifact hash from an `amend <hash>\n` success
/// line, asserting the fixed structure (never a wall clock).
fn amendHash(stdout: []const u8) ![]const u8 {
    const prefix = "amend ";
    try std.testing.expect(std.mem.startsWith(u8, stdout, prefix));
    try std.testing.expect(std.mem.endsWith(u8, stdout, "\n"));
    const hash = stdout[prefix.len .. stdout.len - 1];
    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;
}

/// Seeds a repository with one root check-in (comment "orig", file "f") and
/// returns its 64-hex hash (caller frees). The shared setup for the amend e2e.
fn seedRootCheckin(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
    try initRepo(gpa, io, dir);
    try writeFile(io, dir, "f", "v1\n");
    try addManaged(gpa, io, dir, &.{"f"});
    var root = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", "orig" });
    defer freeResult(gpa, &root);
    try expectExit(root.term, 0);
    return gpa.dupe(u8, try checkinHash(root.stdout));
}

test "cli amend B-amend-1: --comment mints a control that overrides the displayed comment" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "overridden text" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    try std.testing.expectEqualStrings("", am.stderr); // B-amend-16 stream discipline
    const control_hash = try gpa.dupe(u8, try amendHash(am.stdout));
    defer gpa.free(control_hash);
    // A NEW, distinct artifact (not the check-in) carries the override.
    try std.testing.expect(!std.mem.eql(u8, control_hash, root_hash));

    // The minted artifact is a CONTROL (§5.2 D,T,U,Z) carrying `+comment` targeted
    // at the check-in, never `*` (§5.4).
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, control_hash);
        defer mc.deinit();
        try std.testing.expectEqual(artifact.ArtifactType.control, mc.m.kind);
        try std.testing.expect(mc.m.d != null and mc.m.u != null and mc.m.z != null);
        try std.testing.expect(mc.m.c == null and mc.m.f.len == 0 and mc.m.p.len == 0);
        var found = false;
        for (mc.m.t) |t| {
            if (t.sign == .add and std.mem.eql(u8, t.name, "comment")) {
                found = true;
                try std.testing.expectEqualStrings("overridden text", t.value.?);
                try std.testing.expect(t.target != null);
                try std.testing.expectEqualStrings(root_hash, t.target.?);
            }
        }
        try std.testing.expect(found);
    }

    // Option B observables: timeline comment column, info `comment:` line, and
    // `tag list` all reflect the override after crosslink.
    var tl = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &tl);
    try expectExit(tl.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, tl.stdout, "overridden text") != null);

    var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: overridden text") != null);

    var tg = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", root_hash });
    defer freeResult(gpa, &tg);
    try expectExit(tg.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, tg.stdout, "comment overridden text") != null);
}

test "cli amend B-amend-2: --user/--date surface in info; a malformed --date is a usage error" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--user", "alice", "--date", "2020-06-07T08:09:10" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    _ = try amendHash(am.stdout);

    var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "user:") != null);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "alice") != null);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "date:") != null);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "2020-06-07T08:09:10") != null);

    // A malformed --date is rejected (exit 2) before anything is minted (§4.6.4).
    var bad = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--date", "2020-06-07" });
    defer freeResult(gpa, &bad);
    try expectExit(bad.term, 2);
    try std.testing.expectEqualStrings("", bad.stdout);
    try std.testing.expect(std.mem.indexOf(u8, bad.stderr, "invalid date") != null);
    try std.testing.expect(std.mem.indexOf(u8, bad.stderr, "try 'zit amend --help'") != null);
}

test "cli amend B-amend-3: --branch moves the check-in to the new branch (repo-state §6.2)" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // A later mtime (higher epoch) so the branch override supersedes the commit's
    // own `branch=trunk` tag (repo-state §5.3 last-wins).
    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--branch", "feature" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    const control_hash = try gpa.dupe(u8, try amendHash(am.stdout));
    defer gpa.free(control_hash);

    // The §6.2 branch-move tag set, retargeted at the check-in.
    {
        var mc = try parseCheckin(gpa, io, tmp.dir, control_hash);
        defer mc.deinit();
        try std.testing.expect(hasTag(mc.m, .propagate, "branch"));
        try std.testing.expect(hasTag(mc.m, .propagate, "sym-feature"));
        try std.testing.expect(hasTag(mc.m, .cancel, "sym-trunk"));
        for (mc.m.t) |t| try std.testing.expect(t.target != null); // never `*`
    }

    var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "branch:  feature") != null);

    var br = try runZit(gpa, io, tmp.dir, &.{"branch"});
    defer freeResult(gpa, &br);
    try expectExit(br.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, br.stdout, "feature") != null);
}

test "cli amend B-amend-4: several flags mint ONE control artifact" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--comment", "c", "--user", "u2", "--branch", "feature" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    // Exactly one success line ⇒ one control artifact (never one per flag).
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, am.stdout, "\n"));
    const control_hash = try gpa.dupe(u8, try amendHash(am.stdout));
    defer gpa.free(control_hash);

    var mc = try parseCheckin(gpa, io, tmp.dir, control_hash);
    defer mc.deinit();
    try std.testing.expectEqual(artifact.ArtifactType.control, mc.m.kind);
    // All the overrides ride in this single artifact's T run.
    try std.testing.expect(hasTag(mc.m, .add, "comment"));
    try std.testing.expect(hasTag(mc.m, .add, "user"));
    try std.testing.expect(hasTag(mc.m, .propagate, "branch"));
    try std.testing.expect(hasTag(mc.m, .propagate, "sym-feature"));
}

test "cli amend B-amend-5: history is append-only — the manifest is untouched, no new check-in" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "shown" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);

    // The original manifest still parses byte-for-byte the same: same kind, same
    // C-card comment "orig" (the override lives on a separate control artifact).
    {
        var mm = try parseCheckin(gpa, io, tmp.dir, root_hash);
        defer mm.deinit();
        try std.testing.expectEqual(artifact.ArtifactType.manifest, mm.m.kind);
        try std.testing.expectEqualStrings("orig", mm.m.c.?.comment);
    }

    // The timeline gains no new check-in — a control artifact is not a check-in.
    var tl = try runZit(gpa, io, tmp.dir, &.{"timeline"});
    defer freeResult(gpa, &tl);
    try expectExit(tl.term, 0);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, tl.stdout, "\n"));

    // A subsequent commit on the checkout still behaves — history moved forward.
    try writeFile(io, tmp.dir, "f", "v2\n");
    var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "next" });
    defer freeResult(gpa, &c2);
    try expectExit(c2.term, 0);
}

test "cli amend B-amend-6: amend is repeatable and last-wins by event mtime" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // The greater-mtime control wins regardless of ingest order — mint the later
    // one (higher epoch) first, then an earlier one.
    var late = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--comment", "second" });
    defer freeResult(gpa, &late);
    try expectExit(late.term, 0);
    var early = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "1500000000" }, &.{ "amend", root_hash, "--comment", "first" });
    defer freeResult(gpa, &early);
    try expectExit(early.term, 0);
    // Two distinct control artifacts.
    try std.testing.expect(!std.mem.eql(u8, try amendHash(late.stdout), try amendHash(early.stdout)));

    var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash });
    defer freeResult(gpa, &info);
    try expectExit(info.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: second") != null);
    try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: first") == null);
}

test "cli amend B-amend-8: no override flag is a usage error, nothing minted" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 2);
    try std.testing.expectEqualStrings("", am.stdout);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "no override given") != null);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "try 'zit amend --help'") != null);
}

test "cli amend B-amend-9: an unresolvable NAME fails with exit 1, nothing minted" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var am = try runZit(gpa, io, tmp.dir, &.{ "amend", "d" ** 64, "--comment", "x" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 1);
    try std.testing.expectEqualStrings("", am.stdout);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not found") != null);
}

test "cli amend B-amend-10: a NAME that names a control artifact is refused by its type" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // First amend mints a control artifact; feeding ITS hash back to amend must
    // refuse — T cards target check-ins, not control artifacts.
    var first = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "x" });
    defer freeResult(gpa, &first);
    try expectExit(first.term, 0);
    const control_hash = try gpa.dupe(u8, try amendHash(first.stdout));
    defer gpa.free(control_hash);

    var am = try runZit(gpa, io, tmp.dir, &.{ "amend", control_hash, "--comment", "y" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 1);
    try std.testing.expectEqualStrings("", am.stdout);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not a check-in") != null);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "tag") != null); // §5.1 external name
}

test "cli amend B-amend-11: an empty value and a repeated flag are usage errors" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    var empty = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "" });
    defer freeResult(gpa, &empty);
    try expectExit(empty.term, 2);
    try std.testing.expectEqualStrings("", empty.stdout);
    try std.testing.expect(std.mem.indexOf(u8, empty.stderr, "non-empty") != null);

    var rep = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "a", "--comment", "b" });
    defer freeResult(gpa, &rep);
    try expectExit(rep.term, 2);
    try std.testing.expectEqualStrings("", rep.stdout);
    try std.testing.expect(std.mem.indexOf(u8, rep.stderr, "more than once") != null);
}

test "cli amend B-amend-12: amend dispatches to the real handler; a surviving reflex still teaches" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // `zit amend NAME --comment` succeeds — it is a real verb, not the reflex lesson.
    var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "real" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "is not a zit command") == null);

    // A surviving reflex entry (rebase) still teaches the immutable-history lesson.
    var rb = try runZit(gpa, io, tmp.dir, &.{"rebase"});
    defer freeResult(gpa, &rb);
    try expectExit(rb.term, 2);
    try std.testing.expect(std.mem.indexOf(u8, rb.stderr, "is not a zit command") != null);
}

test "cli amend B-amend-13: -h/--help writes the verbatim help to stdout, exit 0, mints nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // Help resolves nothing, so it works even outside a repository.
    var h = try runZit(gpa, io, tmp.dir, &.{ "amend", "--help" });
    defer freeResult(gpa, &h);
    try expectExit(h.term, 0);
    try std.testing.expectEqualStrings(amend_help_expected, h.stdout);
    try std.testing.expectEqualStrings("", h.stderr);

    // -h wins over an otherwise-invalid trailing token.
    var h2 = try runZit(gpa, io, tmp.dir, &.{ "amend", "--bogus", "-h" });
    defer freeResult(gpa, &h2);
    try expectExit(h2.term, 0);
    try std.testing.expectEqualStrings(amend_help_expected, h2.stdout);
}

test "cli amend B-amend-14/16: usage taxonomy and stream discipline" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const root_hash = try seedRootCheckin(gpa, io, tmp.dir);
    defer gpa.free(root_hash);

    // A missing NAME.
    var no_name = try runZit(gpa, io, tmp.dir, &.{"amend"});
    defer freeResult(gpa, &no_name);
    try expectExit(no_name.term, 2);
    try std.testing.expectEqualStrings("", no_name.stdout);
    try std.testing.expect(std.mem.indexOf(u8, no_name.stderr, "no name given") != null);

    // An unknown option.
    var unknown = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--nope" });
    defer freeResult(gpa, &unknown);
    try expectExit(unknown.term, 2);
    try std.testing.expectEqualStrings("", unknown.stdout);
    try std.testing.expect(std.mem.indexOf(u8, unknown.stderr, "unknown option '--nope'") != null);

    // A second positional.
    var extra = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "x", "also" });
    defer freeResult(gpa, &extra);
    try expectExit(extra.term, 2);
    try std.testing.expectEqualStrings("", extra.stdout);
    try std.testing.expect(std.mem.indexOf(u8, extra.stderr, "unexpected argument 'also'") != null);
}

test "cli amend B-amend-15: outside any repository, exit 1 with the standard diagnostic" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // No `zit init` here.
    var am = try runZit(gpa, io, tmp.dir, &.{ "amend", "abc", "--comment", "x" });
    defer freeResult(gpa, &am);
    try expectExit(am.term, 1);
    try std.testing.expectEqualStrings("", am.stdout);
    try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not a zit repository") != null);
}

test "cli amend B-amend-17: `zit help amend` teaches amend's help; the command list names it" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var h = try runZit(gpa, io, tmp.dir, &.{ "help", "amend" });
    defer freeResult(gpa, &h);
    try expectExit(h.term, 0);
    try std.testing.expectEqualStrings(amend_help_expected, h.stdout);

    var top = try runZit(gpa, io, tmp.dir, &.{"help"});
    defer freeResult(gpa, &top);
    try expectExit(top.term, 0);
    try std.testing.expect(std.mem.indexOf(u8, top.stdout, "amend") != null);
}