Skip to content
zit Artifact [8213ee2655]
Login

Artifact [8213ee2655]

Artifact 8213ee2655629f39d3e895d6e0ff93c5fd4836d92cc3eda2a2ba431c854c6122:


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

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

// The storage-layer readers used to decode repository state. Reached by
// relative import (they depend on the `zit` module rather than belonging to
// it); the `zit` dependency the build declares for this module is what lets
// their internal `@import("zit")` resolve.
const logstore = @import("logstore.zig");
const checkout = @import("checkout.zig");

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

const success_prefix = "Initialized empty zit repository in ";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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