//! End-to-end tests for the `zit` CLI: they run the REAL compiled binary via
//! `std.process.Child`, with a pinned, isolated environment (TZ=UTC, an
//! isolated HOME, cwd bound to a fresh `std.testing.tmpDir`), assert the exit
//! code and the exact/structural stdout+stderr, then verify the resulting
//! `.zit`/`.zit-checkout` state READ BACK THROUGH THE LIBRARY. Teardown is
//! unconditional (`defer tmp.cleanup()`).
//!
//! Timestamps and hashes are never asserted against a wall clock: the success
//! line is checked structurally (its trailing token is an absolute `.zit`
//! path), and repository state is decoded with the library's own reader.
//!
//! Built by `zig build e2e`, which compiles `zit` first and passes its path in
//! via `build_options.zit_exe`. Behavior ids cite contracts/init.md.
const std = @import("std");
const build_options = @import("build_options");
// The storage-layer readers used to decode repository state. Reached by
// relative import (they depend on the `zit` module rather than belonging to
// it); the `zit` dependency the build declares for this module is what lets
// their internal `@import("zit")` resolve.
const logstore = @import("logstore.zig");
const checkout = @import("checkout.zig");
// The managed-path set is read back through the library to verify `add`'s
// on-disk effect (contracts/add.md); re-exported by the `zit` module.
const managed = @import("zit").managed;
const File = std.Io.File;
/// Absolute path to the freshly built `zit` executable (a build-time
/// `LazyPath`, so the exe is always rebuilt before these tests run).
const zit_exe: []const u8 = build_options.zit_exe;
const success_prefix = "Initialized empty zit repository in ";
// --- helpers --------------------------------------------------------------
/// Spawns `zit init <tail...>` with a pinned environment and cwd bound to
/// `tmp_dir`. Caller owns `result.stdout` / `result.stderr`.
fn spawnInit(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
// `zit_exe` is relative to the build root (the test's cwd); the child
// chdir's into `tmp_dir` before exec, so resolve it to an absolute path
// first. `resolve` returns it unchanged if it is already absolute.
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [8][]const u8 = undefined;
argv_buf[0] = abs_exe;
argv_buf[1] = "init";
std.debug.assert(tail.len <= argv_buf.len - 2);
for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
const argv = argv_buf[0 .. 2 + tail.len];
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
fn freeResult(gpa: std.mem.Allocator, res: *std.process.RunResult) void {
gpa.free(res.stdout);
gpa.free(res.stderr);
}
fn expectExit(term: std.process.Child.Term, code: u8) !void {
switch (term) {
.exited => |c| try std.testing.expectEqual(code, c),
else => {
std.debug.print("expected exit {d}, got {any}\n", .{ code, term });
return error.TestUnexpectedTerm;
},
}
}
/// Asserts, through the library, that `dir` holds a freshly-initialized repo:
/// a `.zit` of exactly the header length (zero frames) and a `.zit-checkout`
/// whose trunk is unborn (`version == ""`, `branch == "trunk"`). Returns the
/// `.zit` bytes (caller frees) so callers can compare byte-identity.
fn expectFreshRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
const bytes = try dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
errdefer gpa.free(bytes);
try std.testing.expectEqual(@as(usize, logstore.header_len), bytes.len);
var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, dir, &ckbuf)).?;
try std.testing.expectEqualStrings("", ck.version);
try std.testing.expectEqualStrings("trunk", ck.branch);
return bytes;
}
fn fileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
var f = dir.openFile(io, name, .{}) catch return false;
f.close(io);
return true;
}
// --- add helpers ----------------------------------------------------------
/// Spawns `zit <tail...>` with a pinned environment and cwd bound to `tmp_dir`.
/// The generic sibling of `spawnInit` — any verb. Caller owns `result.stdout` /
/// `result.stderr`.
fn runZit(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [16][]const u8 = undefined;
argv_buf[0] = abs_exe;
std.debug.assert(tail.len <= argv_buf.len - 1);
for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;
const argv = argv_buf[0 .. 1 + tail.len];
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
/// Materializes a fresh repository in `dir` by running the real `zit init`
/// (exit 0 asserted) — the same path a user takes before `add`.
fn initRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void {
var res = try runZit(gpa, io, dir, &.{"init"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
}
/// Creates an empty regular file `name` under `dir`.
fn touch(io: std.Io, dir: std.Io.Dir, name: []const u8) !void {
var f = try dir.createFile(io, name, .{});
f.close(io);
}
/// Reads `.zit-managed` back through the library and asserts its membership is
/// exactly `expected` (order-independent — the set is sorted internally). This
/// readback is the on-disk oracle for every `add` e2e.
fn expectManaged(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
expected: []const []const u8,
) !void {
var set = try managed.ManagedSet.read(gpa, io, dir);
defer set.deinit();
try std.testing.expectEqual(expected.len, set.iterate().len);
for (expected) |key| try std.testing.expect(set.contains(key));
}
// --- tests ----------------------------------------------------------------
test "cli init B-init-2/-3/-4: happy path writes the header, unborn trunk, prints the absolute path" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// stdout: the success line; its last whitespace-delimited token is an
// absolute `.zit` path. Structural — no wall-clock, no hash asserted.
try std.testing.expect(std.mem.startsWith(u8, res.stdout, success_prefix));
try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/.zit\n"));
const path = res.stdout[success_prefix.len .. res.stdout.len - 1];
try std.testing.expect(std.fs.path.isAbsolute(path));
// discipline: nothing on stderr for a success (B-init-12).
try std.testing.expectEqualStrings("", res.stderr);
const bytes = try expectFreshRepo(gpa, io, tmp.dir);
gpa.free(bytes);
}
test "cli init B-init-5: an explicit missing directory (and its parents) is created" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"sub/deep"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/sub/deep/.zit\n"));
try std.testing.expectEqualStrings("", res.stderr);
var sub = try tmp.dir.openDir(io, "sub/deep", .{});
defer sub.close(io);
const bytes = try expectFreshRepo(gpa, io, sub);
gpa.free(bytes);
}
test "cli init B-init-5: 'init .' produces the same observable state as the bare form" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp_bare = std.testing.tmpDir(.{});
defer tmp_bare.cleanup();
var res_bare = try spawnInit(gpa, io, tmp_bare.dir, &.{});
defer freeResult(gpa, &res_bare);
try expectExit(res_bare.term, 0);
const bytes_bare = try expectFreshRepo(gpa, io, tmp_bare.dir);
defer gpa.free(bytes_bare);
var tmp_dot = std.testing.tmpDir(.{});
defer tmp_dot.cleanup();
var res_dot = try spawnInit(gpa, io, tmp_dot.dir, &.{"."});
defer freeResult(gpa, &res_dot);
try expectExit(res_dot.term, 0);
const bytes_dot = try expectFreshRepo(gpa, io, tmp_dot.dir);
defer gpa.free(bytes_dot);
// Same repository bytes: init is deterministic (no timestamp/hash in an
// empty repo's header content that differs run-to-run).
try std.testing.expectEqualSlices(u8, bytes_bare, bytes_dot);
}
test "cli init B-init-6: -q suppresses the success line; on-disk state is identical" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp_loud = std.testing.tmpDir(.{});
defer tmp_loud.cleanup();
var res_loud = try spawnInit(gpa, io, tmp_loud.dir, &.{});
defer freeResult(gpa, &res_loud);
try expectExit(res_loud.term, 0);
const bytes_loud = try expectFreshRepo(gpa, io, tmp_loud.dir);
defer gpa.free(bytes_loud);
var tmp_quiet = std.testing.tmpDir(.{});
defer tmp_quiet.cleanup();
var res_quiet = try spawnInit(gpa, io, tmp_quiet.dir, &.{"-q"});
defer freeResult(gpa, &res_quiet);
try expectExit(res_quiet.term, 0);
// quiet: nothing on stdout, nothing on stderr.
try std.testing.expectEqualStrings("", res_quiet.stdout);
try std.testing.expectEqualStrings("", res_quiet.stderr);
const bytes_quiet = try expectFreshRepo(gpa, io, tmp_quiet.dir);
defer gpa.free(bytes_quiet);
try std.testing.expectEqualSlices(u8, bytes_loud, bytes_quiet);
}
test "cli init B-init-7: an existing repository refuses with exit 1 and no change" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var first = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &first);
try expectExit(first.term, 0);
const before = try expectFreshRepo(gpa, io, tmp.dir);
defer gpa.free(before);
var second = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &second);
try expectExit(second.term, 1);
try std.testing.expectEqualStrings("", second.stdout);
try std.testing.expect(std.mem.startsWith(u8, second.stderr, "zit: a zit repository already exists at "));
try std.testing.expect(std.mem.endsWith(u8, second.stderr, "/.zit\n"));
// No change: the `.zit` bytes are byte-identical to after the first init.
const after = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
defer gpa.free(after);
try std.testing.expectEqualSlices(u8, before, after);
}
test "cli init B-init-7: an orphan .zit-checkout does not trigger already-exists" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Pre-seed a stale checkout with NO `.zit` present.
try checkout.write(io, tmp.dir, .{ .version = "deadbeef", .branch = "feature" });
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// `.zit` created and the orphan checkout atomically overwritten to unborn.
const bytes = try expectFreshRepo(gpa, io, tmp.dir);
gpa.free(bytes);
}
test "cli init B-init-8: a target path that is a file reports not-a-directory, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
{
var f = try tmp.dir.createFile(io, "notdir", .{});
f.close(io);
}
var res = try spawnInit(gpa, io, tmp.dir, &.{"notdir"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize: "));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, " is not a directory\n"));
// Nothing created: no `.zit` beside the offending file.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-9: a failure after .zit is created rolls it back (all-or-nothing), exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Force the checkout-write step to fail deterministically: a DIRECTORY
// named `.zit-checkout` cannot be atomically replaced by a file, so
// `checkout.write` returns error.Storage AFTER `.zit` was created — the
// exact rollback path.
try tmp.dir.createDir(io, checkout.filename, .default_dir);
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize repository at "));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
// Rollback: `.zit` must be absent so a re-run is not poisoned into B-init-7.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-10: an unknown option prints usage to stderr, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit init: unknown option '-x'\ntry 'zit init --help'\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-10: a second positional prints usage to stderr, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{ "a", "b" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit init: unexpected argument 'b'\ntry 'zit init --help'\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
/// Independent transcription of the B-init-11 help text (contracts/init.md).
/// Hand-copied from the contract — never generated from the binary — so the
/// binary's output is checked against the spec, not against itself.
const expected_init_help =
\\usage: zit init [-q|--quiet] [--] [directory]
\\
\\Create an empty zit repository in <directory> (default: the current
\\directory). Writes two files there:
\\
\\ .zit the repository — a single, self-contained content log
\\ .zit-checkout the working-state record (branch trunk, no check-in yet)
\\
\\The new repository has no check-ins: the default branch 'trunk' is unborn
\\until your first `zit commit`, which records the root check-in. Artifacts
\\are named with SHA3-256 by default.
\\
\\If <directory> does not exist it is created.
\\
\\options:
\\ -q, --quiet do not print the confirmation line
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 repository created
\\ 1 a repository already exists, or the path could not be initialized
\\ 2 usage error (unknown option or extra argument)
\\
;
test "cli init B-init-11: --help and -h print the verbatim help to stdout, exit 0, no changes" {
const gpa = std.testing.allocator;
const io = std.testing.io;
inline for (.{ "--help", "-h" }) |flag| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{flag});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_init_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
// help makes no changes.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
try std.testing.expect(!fileExists(io, tmp.dir, ".zit-checkout"));
}
}
test "cli init B-init-12: data goes to stdout and diagnostics to stderr, never mixed" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Success: stdout carries the line, stderr is empty.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expect(res.stdout.len > 0);
try std.testing.expectEqualStrings("", res.stderr);
}
// Usage error: stderr carries the diagnostic, stdout is empty.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(res.stderr.len > 0);
}
}
// ==========================================================================
// `zit add` end-to-end (contracts/add.md B-add-1..18). Each spawns the REAL
// binary in an isolated, pinned environment and reads `.zit-managed` back
// through the library.
// ==========================================================================
test "cli add B-add-2/-14/-16: happy path marks files managed, prints added, persists the set" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("added a\nadded b\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}
test "cli add B-add-3: re-adding a managed path is idempotent (notice to stderr, set unchanged)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
var first = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
defer freeResult(gpa, &first);
try expectExit(first.term, 0);
var second = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &second);
try expectExit(second.term, 0);
try std.testing.expectEqualStrings("", second.stdout);
try std.testing.expectEqualStrings("zit: 'a' is already tracked\n", second.stderr);
try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}
test "cli add B-add-15: ./a, a, and sub/../a denote one managed entry" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try tmp.dir.createDir(io, "sub", .default_dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "./a", "a", "sub/../a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// First normalizes-and-adds; the later two are already tracked.
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings(
"zit: 'a' is already tracked\nzit: 'a' is already tracked\n",
res.stderr,
);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-4/-18: a missing path is partial — the valid sibling is still added, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-5: a directory and a symlink are rejected; the regular sibling is added" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try tmp.dir.createDir(io, "d", .default_dir);
try tmp.dir.symLink(io, "a", "s", .{});
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "d", "s" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings(
"zit add: 'd' is a directory; name individual files (directory recursion is not yet supported)\n" ++
"zit add: 's' is not a regular file\n",
res.stderr,
);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-6: a path outside the root is refused, nothing added" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "../x", "/etc/hosts" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '../x' is outside the repository\n" ++
"zit add: '/etc/hosts' is outside the repository\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-7: the three control files are refused" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", ".zit", ".zit-checkout", ".zit-managed" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '.zit' is a zit control file and cannot be tracked\n" ++
"zit add: '.zit-checkout' is a zit control file and cannot be tracked\n" ++
"zit add: '.zit-managed' is a zit control file and cannot be tracked\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-8: not a zit repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try touch(io, tmp.dir, "a"); // a file, but no repo anywhere above
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-9: no paths is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{"add"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit add: no paths given\ntry 'zit add --help'\n", res.stderr);
}
test "cli add B-add-10: an unknown option is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: unknown option '-x'\ntry 'zit add --help'\n",
res.stderr,
);
}
test "cli add B-add-11: --dry-run previews and persists nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-n", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("would add a\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
// No write occurred: `.zit-managed` is absent.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
// A dry run with a missing sibling still previews the valid one and reports
// the error, exits 1, and writes nothing.
var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "--dry-run", "a", "nope" });
defer freeResult(gpa, &res2);
try expectExit(res2.term, 1);
try std.testing.expectEqualStrings("would add a\n", res2.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res2.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-12: --quiet suppresses chatter but keeps effect and errors" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
// The `added a` line is suppressed; the error still prints.
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
// Effect persisted despite quiet.
try expectManaged(gpa, io, tmp.dir, &.{"a"});
// Quiet also suppresses the already-tracked notice.
var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a" });
defer freeResult(gpa, &res2);
try expectExit(res2.term, 0);
try std.testing.expectEqualStrings("", res2.stdout);
try std.testing.expectEqualStrings("", res2.stderr);
}
/// Independent transcription of the B-add-13 help text (contracts/add.md §Help).
/// Hand-copied from the contract — never generated from the binary.
const expected_add_help =
\\usage: zit add [-n|--dry-run] [-q|--quiet] [--] <path>...
\\
\\Mark one or more working-tree files as managed, so they are included in
\\your next check-in. zit has no staging area: `add` records only *which*
\\files to track — their contents are read from the working tree when you
\\run `zit commit`, not now. Re-adding an already-tracked file does nothing.
\\
\\Files stay tracked until `zit rm` forgets them. You can add files before
\\the first check-in (the default branch 'trunk' is unborn until then).
\\
\\Only individual files are accepted; naming a directory is an error.
\\
\\options:
\\ -n, --dry-run show what would be added; change nothing
\\ -q, --quiet do not print the added files
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 all named files are now tracked (or already were)
\\ 1 a file could not be added (missing, a directory, outside the repo),
\\ or this is not a zit repository
\\ 2 usage error (no files given, or an unknown option)
\\
;
test "cli add B-add-13: --help/-h prints the verbatim help, exit 0, changes nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Help wins even with no repo, no path, and extra tokens present.
inline for (.{
&[_][]const u8{ "add", "--help" },
&[_][]const u8{ "add", "-h" },
&[_][]const u8{ "add", "--help", "a", "b" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_add_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
}
test "cli add B-add-16: an all-invalid invocation writes nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "nope", "also-nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
// No managed file created when nothing valid was added.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-17: a corrupt .zit-managed fails cleanly, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
// Pre-write garbage: the leading u32 decodes to an impossible entry count.
{
var f = try tmp.dir.createFile(io, managed.filename, .{});
defer f.close(io);
try f.writePositionalAll(io, "this is not a valid managed file", 0);
}
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: cannot read tracked files: .zit-managed is corrupt\n",
res.stderr,
);
}
test "cli add B-add-16/-17: a write failure leaves the prior managed set intact, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
// Make the checkout root read-only so `ManagedSet.write` cannot create its
// temp file → error.Storage, AFTER the (absent) set reads as empty. Restore
// write permission before cleanup can run (defers are LIFO).
try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings(
"zit add: cannot update tracked files: input/output error\n",
res.stderr,
);
// The prior state is intact: no managed file was left behind.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
// ==========================================================================
// Regression coverage for the three refuted behaviors (deep-cwd crash;
// help-vs-unknown-option precedence; `zit add .` misclassification).
// ==========================================================================
/// Creates `levels` nested subdirectories each named `seg` under `base`, and
/// returns an open handle to the deepest one (caller closes it). Built one
/// component at a time through directory handles, so the total path may exceed
/// the platform PATH_MAX without any single syscall ever seeing an over-long
/// path — the only portable way to place a subprocess's cwd past that limit.
fn makeDeepDir(io: std.Io, base: std.Io.Dir, seg: []const u8, levels: usize) !std.Io.Dir {
var cur = try base.openDir(io, ".", .{});
errdefer cur.close(io);
for (0..levels) |_| {
cur.createDir(io, seg, .default_dir) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return e,
};
const child = try cur.openDir(io, seg, .{});
cur.close(io);
cur = child;
}
return cur;
}
test "cli add B-add-17/-18: a deep checkout diagnoses instead of crashing (no panic)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// A cwd whose absolute path exceeds the platform PATH_MAX. Running `zit add`
// from there made std's `currentPathAlloc` abort the process (SIGABRT); the
// fix must instead exit cleanly with a diagnostic. 24 × 200-char levels
// ≈ 4800 bytes, past both the 1024 (macOS) and 4096 (Linux) limits.
const seg = "d" ** 200;
var deep = try makeDeepDir(io, tmp.dir, seg, 24);
defer deep.close(io);
try touch(io, deep, "deep.txt");
var res = try runZit(gpa, io, deep, &.{ "add", "deep.txt" });
defer freeResult(gpa, &res);
// The regression guard: a clean exit 1, never a signal/abort term, and no
// panic trace on stderr.
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "panic") == null);
// Where getcwd names the deep path, the repo-relative key exceeds
// managed.max_path_len → the honest B-add-17 message; where getcwd refuses
// an over-long path, discovery reports no repository. Both are honest exit-1
// diagnostics; neither is a crash.
try std.testing.expect(
std.mem.indexOf(u8, res.stderr, "path is too long to track") != null or
std.mem.indexOf(u8, res.stderr, "not a zit repository") != null,
);
}
test "cli add B-add-17: a path longer than managed.max_path_len is rejected as too long" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// A single arg whose normalized repo-relative key exceeds
// managed.max_path_len (4096), built from short "aa/" components so every
// path component is well under NAME_MAX. The file need not exist: the length
// bound is enforced before any filesystem stat, so this is deterministic on
// every platform (cwd stays short).
var buf: [managed.max_path_len + 64]u8 = undefined;
var w: usize = 0;
while (w + 3 <= buf.len) : (w += 3) {
buf[w] = 'a';
buf[w + 1] = 'a';
buf[w + 2] = '/';
}
buf[w] = 'f';
w += 1;
const long_arg = buf[0..w];
try std.testing.expect(long_arg.len > managed.max_path_len);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", long_arg });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
var ebuf: [managed.max_path_len + 128]u8 = undefined;
const want = try std.fmt.bufPrint(&ebuf, "zit add: '{s}': path is too long to track\n", .{long_arg});
try std.testing.expectEqualStrings(want, res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-13/-10: --help wins over an unknown option (exit 0, verbatim help)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Both orders print the help and exit 0, never the unknown-option usage
// error — B-add-13's explicit precedence ("help wins over any other token")
// beats B-add-10.
inline for (.{
&[_][]const u8{ "add", "--help", "-x" },
&[_][]const u8{ "add", "-x", "--help" },
&[_][]const u8{ "add", "-x", "-h", "nope.txt" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_add_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
}
test "cli add B-add-5: 'zit add .' at the root gives the directory guidance, not 'outside'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// `.` resolves to the checkout root itself — a directory inside the repo, so
// it must produce the B-add-5 directory diagnostic, not B-add-6's "outside
// the repository" (the most likely novice invocation).
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '.' is a directory; name individual files (directory recursion is not yet supported)\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}