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