//! End-to-end tests for the `zit` CLI: they run the REAL compiled binary via
//! `std.process.Child`, with a pinned, isolated environment (TZ=UTC, an
//! isolated HOME, cwd bound to a fresh `std.testing.tmpDir`), assert the exit
//! code and the exact/structural stdout+stderr, then verify the resulting
//! `.zit`/`.zit-checkout` state READ BACK THROUGH THE LIBRARY. Teardown is
//! unconditional (`defer tmp.cleanup()`).
//!
//! Timestamps and hashes are never asserted against a wall clock: the success
//! line is checked structurally (its trailing token is an absolute `.zit`
//! path), and repository state is decoded with the library's own reader.
//!
//! Built by `zig build e2e`, which compiles `zit` first and passes its path in
//! via `build_options.zit_exe`. Behavior ids cite contracts/init.md.
const std = @import("std");
const build_options = @import("build_options");
// The storage-layer readers used to decode repository state. Reached by
// relative import (they depend on the `zit` module rather than belonging to
// it); the `zit` dependency the build declares for this module is what lets
// their internal `@import("zit")` resolve.
const logstore = @import("logstore.zig");
const checkout = @import("checkout.zig");
// The managed-path set is read back through the library to verify `add`'s
// on-disk effect (contracts/add.md); re-exported by the `zit` module.
const managed = @import("zit").managed;
const File = std.Io.File;
/// Absolute path to the freshly built `zit` executable (a build-time
/// `LazyPath`, so the exe is always rebuilt before these tests run).
const zit_exe: []const u8 = build_options.zit_exe;
const success_prefix = "Initialized empty zit repository in ";
// --- helpers --------------------------------------------------------------
/// Spawns `zit init <tail...>` with a pinned environment and cwd bound to
/// `tmp_dir`. Caller owns `result.stdout` / `result.stderr`.
fn spawnInit(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
// `zit_exe` is relative to the build root (the test's cwd); the child
// chdir's into `tmp_dir` before exec, so resolve it to an absolute path
// first. `resolve` returns it unchanged if it is already absolute.
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [8][]const u8 = undefined;
argv_buf[0] = abs_exe;
argv_buf[1] = "init";
std.debug.assert(tail.len <= argv_buf.len - 2);
for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
const argv = argv_buf[0 .. 2 + tail.len];
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
fn freeResult(gpa: std.mem.Allocator, res: *std.process.RunResult) void {
gpa.free(res.stdout);
gpa.free(res.stderr);
}
fn expectExit(term: std.process.Child.Term, code: u8) !void {
switch (term) {
.exited => |c| try std.testing.expectEqual(code, c),
else => {
std.debug.print("expected exit {d}, got {any}\n", .{ code, term });
return error.TestUnexpectedTerm;
},
}
}
/// Asserts, through the library, that `dir` holds a freshly-initialized repo:
/// a `.zit` of exactly the header length (zero frames) and a `.zit-checkout`
/// whose trunk is unborn (`version == ""`, `branch == "trunk"`). Returns the
/// `.zit` bytes (caller frees) so callers can compare byte-identity.
fn expectFreshRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
const bytes = try dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
errdefer gpa.free(bytes);
try std.testing.expectEqual(@as(usize, logstore.header_len), bytes.len);
var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, dir, &ckbuf)).?;
try std.testing.expectEqualStrings("", ck.version);
try std.testing.expectEqualStrings("trunk", ck.branch);
return bytes;
}
fn fileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
var f = dir.openFile(io, name, .{}) catch return false;
f.close(io);
return true;
}
// --- add helpers ----------------------------------------------------------
/// Spawns `zit <tail...>` with a pinned environment and cwd bound to `tmp_dir`.
/// The generic sibling of `spawnInit` — any verb. Caller owns `result.stdout` /
/// `result.stderr`.
fn runZit(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [16][]const u8 = undefined;
argv_buf[0] = abs_exe;
std.debug.assert(tail.len <= argv_buf.len - 1);
for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;
const argv = argv_buf[0 .. 1 + tail.len];
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
/// Materializes a fresh repository in `dir` by running the real `zit init`
/// (exit 0 asserted) — the same path a user takes before `add`.
fn initRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void {
var res = try runZit(gpa, io, dir, &.{"init"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
}
/// Creates an empty regular file `name` under `dir`.
fn touch(io: std.Io, dir: std.Io.Dir, name: []const u8) !void {
var f = try dir.createFile(io, name, .{});
f.close(io);
}
/// Reads `.zit-managed` back through the library and asserts its membership is
/// exactly `expected` (order-independent — the set is sorted internally). This
/// readback is the on-disk oracle for every `add` e2e.
fn expectManaged(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
expected: []const []const u8,
) !void {
var set = try managed.ManagedSet.read(gpa, io, dir);
defer set.deinit();
try std.testing.expectEqual(expected.len, set.iterate().len);
for (expected) |key| try std.testing.expect(set.contains(key));
}
/// Marks `paths` managed by running the real `zit add` (exit 0 asserted) — the
/// same path a user takes to set up state before exercising `rm`.
fn addManaged(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
paths: []const []const u8,
) !void {
var argv_buf: [16][]const u8 = undefined;
argv_buf[0] = "add";
std.debug.assert(paths.len <= argv_buf.len - 1);
for (paths, argv_buf[1..][0..paths.len]) |src, *dst| dst.* = src;
var res = try runZit(gpa, io, dir, argv_buf[0 .. 1 + paths.len]);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
}
/// Runs `/bin/sh -c <script>` under the same pinned, isolated environment as
/// `runZit`, with cwd bound to `tmp_dir` and the absolute `zit` path exposed as
/// `$ZIT`. Driving the binary through a real shell is the only faithful way to
/// exercise the output-composition guarantees (B-rm-14): `>> logfile` (O_APPEND)
/// and grouped/shared-fd redirections (`{ …; } > log`) inherit an OS file offset
/// that a process's stdout must honor. `std.process.run`'s captured pipe is not
/// seekable and so cannot reproduce these; the shell can. Caller owns the
/// returned `stdout`/`stderr`.
fn runShell(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
script: []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
try env.put("ZIT", abs_exe);
return std.process.run(gpa, io, .{
.argv = &.{ "/bin/sh", "-c", script },
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
/// Reads `name` under `dir` fully and returns its bytes (caller frees). Used by
/// the composition tests to read back a redirected log file.
fn readFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8) ![]u8 {
return dir.readFileAlloc(io, name, gpa, .limited(1 << 16));
}
// --- tests ----------------------------------------------------------------
test "cli init B-init-2/-3/-4: happy path writes the header, unborn trunk, prints the absolute path" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// stdout: the success line; its last whitespace-delimited token is an
// absolute `.zit` path. Structural — no wall-clock, no hash asserted.
try std.testing.expect(std.mem.startsWith(u8, res.stdout, success_prefix));
try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/.zit\n"));
const path = res.stdout[success_prefix.len .. res.stdout.len - 1];
try std.testing.expect(std.fs.path.isAbsolute(path));
// discipline: nothing on stderr for a success (B-init-12).
try std.testing.expectEqualStrings("", res.stderr);
const bytes = try expectFreshRepo(gpa, io, tmp.dir);
gpa.free(bytes);
}
test "cli init B-init-5: an explicit missing directory (and its parents) is created" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"sub/deep"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expect(std.mem.endsWith(u8, res.stdout, "/sub/deep/.zit\n"));
try std.testing.expectEqualStrings("", res.stderr);
var sub = try tmp.dir.openDir(io, "sub/deep", .{});
defer sub.close(io);
const bytes = try expectFreshRepo(gpa, io, sub);
gpa.free(bytes);
}
test "cli init B-init-5: 'init .' produces the same observable state as the bare form" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp_bare = std.testing.tmpDir(.{});
defer tmp_bare.cleanup();
var res_bare = try spawnInit(gpa, io, tmp_bare.dir, &.{});
defer freeResult(gpa, &res_bare);
try expectExit(res_bare.term, 0);
const bytes_bare = try expectFreshRepo(gpa, io, tmp_bare.dir);
defer gpa.free(bytes_bare);
var tmp_dot = std.testing.tmpDir(.{});
defer tmp_dot.cleanup();
var res_dot = try spawnInit(gpa, io, tmp_dot.dir, &.{"."});
defer freeResult(gpa, &res_dot);
try expectExit(res_dot.term, 0);
const bytes_dot = try expectFreshRepo(gpa, io, tmp_dot.dir);
defer gpa.free(bytes_dot);
// Same repository bytes: init is deterministic (no timestamp/hash in an
// empty repo's header content that differs run-to-run).
try std.testing.expectEqualSlices(u8, bytes_bare, bytes_dot);
}
test "cli init B-init-6: -q suppresses the success line; on-disk state is identical" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp_loud = std.testing.tmpDir(.{});
defer tmp_loud.cleanup();
var res_loud = try spawnInit(gpa, io, tmp_loud.dir, &.{});
defer freeResult(gpa, &res_loud);
try expectExit(res_loud.term, 0);
const bytes_loud = try expectFreshRepo(gpa, io, tmp_loud.dir);
defer gpa.free(bytes_loud);
var tmp_quiet = std.testing.tmpDir(.{});
defer tmp_quiet.cleanup();
var res_quiet = try spawnInit(gpa, io, tmp_quiet.dir, &.{"-q"});
defer freeResult(gpa, &res_quiet);
try expectExit(res_quiet.term, 0);
// quiet: nothing on stdout, nothing on stderr.
try std.testing.expectEqualStrings("", res_quiet.stdout);
try std.testing.expectEqualStrings("", res_quiet.stderr);
const bytes_quiet = try expectFreshRepo(gpa, io, tmp_quiet.dir);
defer gpa.free(bytes_quiet);
try std.testing.expectEqualSlices(u8, bytes_loud, bytes_quiet);
}
test "cli init B-init-7: an existing repository refuses with exit 1 and no change" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var first = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &first);
try expectExit(first.term, 0);
const before = try expectFreshRepo(gpa, io, tmp.dir);
defer gpa.free(before);
var second = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &second);
try expectExit(second.term, 1);
try std.testing.expectEqualStrings("", second.stdout);
try std.testing.expect(std.mem.startsWith(u8, second.stderr, "zit: a zit repository already exists at "));
try std.testing.expect(std.mem.endsWith(u8, second.stderr, "/.zit\n"));
// No change: the `.zit` bytes are byte-identical to after the first init.
const after = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20));
defer gpa.free(after);
try std.testing.expectEqualSlices(u8, before, after);
}
test "cli init B-init-7: an orphan .zit-checkout does not trigger already-exists" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Pre-seed a stale checkout with NO `.zit` present.
try checkout.write(io, tmp.dir, .{ .version = "deadbeef", .branch = "feature" });
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// `.zit` created and the orphan checkout atomically overwritten to unborn.
const bytes = try expectFreshRepo(gpa, io, tmp.dir);
gpa.free(bytes);
}
test "cli init B-init-8: a target path that is a file reports not-a-directory, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
{
var f = try tmp.dir.createFile(io, "notdir", .{});
f.close(io);
}
var res = try spawnInit(gpa, io, tmp.dir, &.{"notdir"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize: "));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, " is not a directory\n"));
// Nothing created: no `.zit` beside the offending file.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-9: a failure after .zit is created rolls it back (all-or-nothing), exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Force the checkout-write step to fail deterministically: a DIRECTORY
// named `.zit-checkout` cannot be atomically replaced by a file, so
// `checkout.write` returns error.Storage AFTER `.zit` was created — the
// exact rollback path.
try tmp.dir.createDir(io, checkout.filename, .default_dir);
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.startsWith(u8, res.stderr, "zit: cannot initialize repository at "));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
// Rollback: `.zit` must be absent so a re-run is not poisoned into B-init-7.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-10: an unknown option prints usage to stderr, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit init: unknown option '-x'\ntry 'zit init --help'\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
test "cli init B-init-10: a second positional prints usage to stderr, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{ "a", "b" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit init: unexpected argument 'b'\ntry 'zit init --help'\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
}
/// Independent transcription of the B-init-11 help text (contracts/init.md).
/// Hand-copied from the contract — never generated from the binary — so the
/// binary's output is checked against the spec, not against itself.
const expected_init_help =
\\usage: zit init [-q|--quiet] [--] [directory]
\\
\\Create an empty zit repository in <directory> (default: the current
\\directory). Writes two files there:
\\
\\ .zit the repository — a single, self-contained content log
\\ .zit-checkout the working-state record (branch trunk, no check-in yet)
\\
\\The new repository has no check-ins: the default branch 'trunk' is unborn
\\until your first `zit commit`, which records the root check-in. Artifacts
\\are named with SHA3-256 by default.
\\
\\If <directory> does not exist it is created.
\\
\\options:
\\ -q, --quiet do not print the confirmation line
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 repository created
\\ 1 a repository already exists, or the path could not be initialized
\\ 2 usage error (unknown option or extra argument)
\\
;
test "cli init B-init-11: --help and -h print the verbatim help to stdout, exit 0, no changes" {
const gpa = std.testing.allocator;
const io = std.testing.io;
inline for (.{ "--help", "-h" }) |flag| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{flag});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_init_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
// help makes no changes.
try std.testing.expect(!fileExists(io, tmp.dir, ".zit"));
try std.testing.expect(!fileExists(io, tmp.dir, ".zit-checkout"));
}
}
test "cli init B-init-12: data goes to stdout and diagnostics to stderr, never mixed" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Success: stdout carries the line, stderr is empty.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expect(res.stdout.len > 0);
try std.testing.expectEqualStrings("", res.stderr);
}
// Usage error: stderr carries the diagnostic, stdout is empty.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try spawnInit(gpa, io, tmp.dir, &.{"-x"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(res.stderr.len > 0);
}
}
// ==========================================================================
// `zit add` end-to-end (contracts/add.md B-add-1..18). Each spawns the REAL
// binary in an isolated, pinned environment and reads `.zit-managed` back
// through the library.
// ==========================================================================
test "cli add B-add-2/-14/-16: happy path marks files managed, prints added, persists the set" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("added a\nadded b\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}
test "cli add B-add-3: re-adding a managed path is idempotent (notice to stderr, set unchanged)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
var first = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "b" });
defer freeResult(gpa, &first);
try expectExit(first.term, 0);
var second = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &second);
try expectExit(second.term, 0);
try std.testing.expectEqualStrings("", second.stdout);
try std.testing.expectEqualStrings("zit: 'a' is already tracked\n", second.stderr);
try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" });
}
test "cli add B-add-15: ./a, a, and sub/../a denote one managed entry" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try tmp.dir.createDir(io, "sub", .default_dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "./a", "a", "sub/../a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// First normalizes-and-adds; the later two are already tracked.
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings(
"zit: 'a' is already tracked\nzit: 'a' is already tracked\n",
res.stderr,
);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-4/-18: a missing path is partial — the valid sibling is still added, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-5: a directory and a symlink are rejected; the regular sibling is added" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try tmp.dir.createDir(io, "d", .default_dir);
try tmp.dir.symLink(io, "a", "s", .{});
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a", "d", "s" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("added a\n", res.stdout);
try std.testing.expectEqualStrings(
"zit add: 'd' is a directory; name individual files (directory recursion is not yet supported)\n" ++
"zit add: 's' is not a regular file\n",
res.stderr,
);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli add B-add-6: a path outside the root is refused, nothing added" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "../x", "/etc/hosts" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '../x' is outside the repository\n" ++
"zit add: '/etc/hosts' is outside the repository\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-7: the three control files are refused" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", ".zit", ".zit-checkout", ".zit-managed" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '.zit' is a zit control file and cannot be tracked\n" ++
"zit add: '.zit-checkout' is a zit control file and cannot be tracked\n" ++
"zit add: '.zit-managed' is a zit control file and cannot be tracked\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-8: not a zit repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try touch(io, tmp.dir, "a"); // a file, but no repo anywhere above
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-9: no paths is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{"add"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit add: no paths given\ntry 'zit add --help'\n", res.stderr);
}
test "cli add B-add-10: an unknown option is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: unknown option '-x'\ntry 'zit add --help'\n",
res.stderr,
);
}
test "cli add B-add-11: --dry-run previews and persists nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-n", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("would add a\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
// No write occurred: `.zit-managed` is absent.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
// A dry run with a missing sibling still previews the valid one and reports
// the error, exits 1, and writes nothing.
var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "--dry-run", "a", "nope" });
defer freeResult(gpa, &res2);
try expectExit(res2.term, 1);
try std.testing.expectEqualStrings("would add a\n", res2.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res2.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-12: --quiet suppresses chatter but keeps effect and errors" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
// The `added a` line is suppressed; the error still prints.
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit add: 'nope': no such file\n", res.stderr);
// Effect persisted despite quiet.
try expectManaged(gpa, io, tmp.dir, &.{"a"});
// Quiet also suppresses the already-tracked notice.
var res2 = try runZit(gpa, io, tmp.dir, &.{ "add", "-q", "a" });
defer freeResult(gpa, &res2);
try expectExit(res2.term, 0);
try std.testing.expectEqualStrings("", res2.stdout);
try std.testing.expectEqualStrings("", res2.stderr);
}
/// Independent transcription of the B-add-13 help text (contracts/add.md §Help).
/// Hand-copied from the contract — never generated from the binary.
const expected_add_help =
\\usage: zit add [-n|--dry-run] [-q|--quiet] [--] <path>...
\\
\\Mark one or more working-tree files as managed, so they are included in
\\your next check-in. zit has no staging area: `add` records only *which*
\\files to track — their contents are read from the working tree when you
\\run `zit commit`, not now. Re-adding an already-tracked file does nothing.
\\
\\Files stay tracked until `zit rm` forgets them. You can add files before
\\the first check-in (the default branch 'trunk' is unborn until then).
\\
\\Only individual files are accepted; naming a directory is an error.
\\
\\options:
\\ -n, --dry-run show what would be added; change nothing
\\ -q, --quiet do not print the added files
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 all named files are now tracked (or already were)
\\ 1 a file could not be added (missing, a directory, outside the repo),
\\ or this is not a zit repository
\\ 2 usage error (no files given, or an unknown option)
\\
;
test "cli add B-add-13: --help/-h prints the verbatim help, exit 0, changes nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Help wins even with no repo, no path, and extra tokens present.
inline for (.{
&[_][]const u8{ "add", "--help" },
&[_][]const u8{ "add", "-h" },
&[_][]const u8{ "add", "--help", "a", "b" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_add_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
}
test "cli add B-add-16: an all-invalid invocation writes nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "nope", "also-nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
// No managed file created when nothing valid was added.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-17: a corrupt .zit-managed fails cleanly, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
// Pre-write garbage: the leading u32 decodes to an impossible entry count.
{
var f = try tmp.dir.createFile(io, managed.filename, .{});
defer f.close(io);
try f.writePositionalAll(io, "this is not a valid managed file", 0);
}
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: cannot read tracked files: .zit-managed is corrupt\n",
res.stderr,
);
}
test "cli add B-add-16/-17: a write failure leaves the prior managed set intact, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
// Make the checkout root read-only so `ManagedSet.write` cannot create its
// temp file → error.Storage, AFTER the (absent) set reads as empty. Restore
// write permission before cleanup can run (defers are LIFO).
try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings(
"zit add: cannot update tracked files: input/output error\n",
res.stderr,
);
// The prior state is intact: no managed file was left behind.
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
// ==========================================================================
// Regression coverage for the three refuted behaviors (deep-cwd crash;
// help-vs-unknown-option precedence; `zit add .` misclassification).
// ==========================================================================
/// Creates `levels` nested subdirectories each named `seg` under `base`, and
/// returns an open handle to the deepest one (caller closes it). Built one
/// component at a time through directory handles, so the total path may exceed
/// the platform PATH_MAX without any single syscall ever seeing an over-long
/// path — the only portable way to place a subprocess's cwd past that limit.
fn makeDeepDir(io: std.Io, base: std.Io.Dir, seg: []const u8, levels: usize) !std.Io.Dir {
var cur = try base.openDir(io, ".", .{});
errdefer cur.close(io);
for (0..levels) |_| {
cur.createDir(io, seg, .default_dir) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return e,
};
const child = try cur.openDir(io, seg, .{});
cur.close(io);
cur = child;
}
return cur;
}
test "cli add B-add-17/-18: a deep checkout diagnoses instead of crashing (no panic)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// A cwd whose absolute path exceeds the platform PATH_MAX. Running `zit add`
// from there made std's `currentPathAlloc` abort the process (SIGABRT); the
// fix must instead exit cleanly with a diagnostic. 24 × 200-char levels
// ≈ 4800 bytes, past both the 1024 (macOS) and 4096 (Linux) limits.
const seg = "d" ** 200;
var deep = try makeDeepDir(io, tmp.dir, seg, 24);
defer deep.close(io);
try touch(io, deep, "deep.txt");
var res = try runZit(gpa, io, deep, &.{ "add", "deep.txt" });
defer freeResult(gpa, &res);
// The regression guard: a clean exit 1, never a signal/abort term, and no
// panic trace on stderr.
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "panic") == null);
// Where getcwd names the deep path, the repo-relative key exceeds
// managed.max_path_len → the honest B-add-17 message; where getcwd refuses
// an over-long path, discovery reports no repository. Both are honest exit-1
// diagnostics; neither is a crash.
try std.testing.expect(
std.mem.indexOf(u8, res.stderr, "path is too long to track") != null or
std.mem.indexOf(u8, res.stderr, "not a zit repository") != null,
);
}
test "cli add B-add-17: a path longer than managed.max_path_len is rejected as too long" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// A single arg whose normalized repo-relative key exceeds
// managed.max_path_len (4096), built from short "aa/" components so every
// path component is well under NAME_MAX. The file need not exist: the length
// bound is enforced before any filesystem stat, so this is deterministic on
// every platform (cwd stays short).
var buf: [managed.max_path_len + 64]u8 = undefined;
var w: usize = 0;
while (w + 3 <= buf.len) : (w += 3) {
buf[w] = 'a';
buf[w + 1] = 'a';
buf[w + 2] = '/';
}
buf[w] = 'f';
w += 1;
const long_arg = buf[0..w];
try std.testing.expect(long_arg.len > managed.max_path_len);
var res = try runZit(gpa, io, tmp.dir, &.{ "add", long_arg });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
var ebuf: [managed.max_path_len + 128]u8 = undefined;
const want = try std.fmt.bufPrint(&ebuf, "zit add: '{s}': path is too long to track\n", .{long_arg});
try std.testing.expectEqualStrings(want, res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli add B-add-13/-10: --help wins over an unknown option (exit 0, verbatim help)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Both orders print the help and exit 0, never the unknown-option usage
// error — B-add-13's explicit precedence ("help wins over any other token")
// beats B-add-10.
inline for (.{
&[_][]const u8{ "add", "--help", "-x" },
&[_][]const u8{ "add", "-x", "--help" },
&[_][]const u8{ "add", "-x", "-h", "nope.txt" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_add_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
}
test "cli add B-add-5: 'zit add .' at the root gives the directory guidance, not 'outside'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// `.` resolves to the checkout root itself — a directory inside the repo, so
// it must produce the B-add-5 directory diagnostic, not B-add-6's "outside
// the repository" (the most likely novice invocation).
var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit add: '.' is a directory; name individual files (directory recursion is not yet supported)\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
// ==========================================================================
// `zit rm` end-to-end (contracts/rm.md B-rm-1..19). Each spawns the REAL binary
// in an isolated, pinned environment, sets up tracked state via the real `zit
// add`, and reads `.zit-managed` back through the library.
// ==========================================================================
test "cli rm B-rm-2/-14/-16: happy path forgets tracked files, prints removed, persists, keeps files on disk" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
try touch(io, tmp.dir, "c");
try addManaged(gpa, io, tmp.dir, &.{ "a", "b", "c" });
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a", "b" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// stdout carries the removals in argument order, each stating the file was
// kept on disk (D1); stderr is empty (B-rm-14).
try std.testing.expectEqualStrings(
"removed a (file kept on disk)\nremoved b (file kept on disk)\n",
res.stdout,
);
try std.testing.expectEqualStrings("", res.stderr);
// Only c remains tracked (readback is the oracle, B-rm-16).
try expectManaged(gpa, io, tmp.dir, &.{"c"});
// Default rm keeps the working files on disk untouched (B-rm-2).
try std.testing.expect(fileExists(io, tmp.dir, "a"));
try std.testing.expect(fileExists(io, tmp.dir, "b"));
}
test "cli rm B-rm-2: membership not existence — a tracked file already deleted from disk is still forgettable" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "gone");
try addManaged(gpa, io, tmp.dir, &.{"gone"});
// The user deletes the working file themselves — the primary reason to rm.
try tmp.dir.deleteFile(io, "gone");
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "gone" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("removed gone (file kept on disk)\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-3: --hard also deletes the working file from disk" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try touch(io, tmp.dir, "b");
try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// Under --hard the line states the file was deleted from disk (D1).
try std.testing.expectEqualStrings("removed a (deleted from disk)\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{"b"});
// --hard removed the working file; the untouched sibling remains.
try std.testing.expect(!fileExists(io, tmp.dir, "a"));
try std.testing.expect(fileExists(io, tmp.dir, "b"));
}
test "cli rm B-rm-3: --hard on a tracked file already gone from disk is success" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
try tmp.dir.deleteFile(io, "a"); // already gone before --hard
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
defer freeResult(gpa, &res);
// FileNotFound ⇒ postcondition ("no working file") already holds ⇒ success.
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("removed a (deleted from disk)\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-4/-19: a not-tracked path is partial — the tracked sibling is still forgotten, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("removed a (file kept on disk)\n", res.stdout);
try std.testing.expectEqualStrings("zit rm: 'nope' is not tracked\n", res.stderr);
// The valid forget still persisted (do-as-much-as-possible).
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-5: 'zit rm .' at the root gives the directory guidance, not 'outside'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "." });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: '.' is a directory; name individual files (directory recursion is not yet supported)\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli rm B-rm-6: a path outside the root is refused" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "../x", "/etc/hosts" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: '../x' is outside the repository\n" ++
"zit rm: '/etc/hosts' is outside the repository\n",
res.stderr,
);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
test "cli rm B-rm-7: the three control files are refused and never --hard-deleted" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// Give the set a real entry so a successful write would occur if any control
// file slipped through; it must not, and the control files must survive.
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", ".zit", ".zit-checkout", ".zit-managed" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: '.zit' is a zit control file and cannot be tracked\n" ++
"zit rm: '.zit-checkout' is a zit control file and cannot be tracked\n" ++
"zit rm: '.zit-managed' is a zit control file and cannot be tracked\n",
res.stderr,
);
// The guard held: every control file still on disk, set unchanged.
try std.testing.expect(fileExists(io, tmp.dir, ".zit"));
try std.testing.expect(fileExists(io, tmp.dir, ".zit-checkout"));
try std.testing.expect(fileExists(io, tmp.dir, managed.filename));
try expectManaged(gpa, io, tmp.dir, &.{"a"});
}
test "cli rm B-rm-8: not a zit repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try touch(io, tmp.dir, "a"); // a file, but no repo anywhere above
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
res.stderr,
);
}
test "cli rm B-rm-9: no paths is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{"rm"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit rm: no paths given\ntry 'zit rm --help'\n", res.stderr);
}
test "cli rm B-rm-10: an unknown option is a usage error, exit 2" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: unknown option '-x'\ntry 'zit rm --help'\n",
res.stderr,
);
}
test "cli rm B-rm-11: --dry-run previews and persists/deletes nothing (even with --hard)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-n", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("would remove a (file kept on disk)\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
// Nothing persisted: the set still tracks a, the file still exists.
try expectManaged(gpa, io, tmp.dir, &.{"a"});
try std.testing.expect(fileExists(io, tmp.dir, "a"));
// `-n --hard` previews without unlinking.
var res2 = try runZit(gpa, io, tmp.dir, &.{ "rm", "--dry-run", "--hard", "a" });
defer freeResult(gpa, &res2);
try expectExit(res2.term, 0);
// `-n --hard` previews the disk deletion without performing it (D1: the
// whole line is subjunctive under `would remove`).
try std.testing.expectEqualStrings("would remove a (deleted from disk)\n", res2.stdout);
try std.testing.expectEqualStrings("", res2.stderr);
try expectManaged(gpa, io, tmp.dir, &.{"a"});
try std.testing.expect(fileExists(io, tmp.dir, "a"));
}
test "cli rm B-rm-12: --quiet suppresses removed lines but keeps effect and errors" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-q", "a", "nope" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
// The `removed a` line is suppressed; the error still prints (B-rm-12).
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit rm: 'nope' is not tracked\n", res.stderr);
// Effect persisted despite quiet.
try expectManaged(gpa, io, tmp.dir, &.{});
}
/// Independent transcription of the B-rm-13 help text (contracts/rm.md §Help).
/// Hand-copied from the contract — never generated from the binary.
const expected_rm_help =
\\usage: zit rm [-n|--dry-run] [--hard] [-q|--quiet] [--] <path>...
\\
\\Stop tracking one or more files: remove their paths from the managed set,
\\so your next check-in records their deletion. zit has no staging area, so
\\this takes effect when you run `zit commit`, not now.
\\
\\By default your working copy is left on disk untouched -- `rm` only forgets
\\the file. Pass --hard to also delete the file from disk. (git deletes by
\\default; zit keeps your file unless you ask for --hard.)
\\
\\Removing a path that is not tracked is an error. Only individual files are
\\accepted; naming a directory is an error.
\\
\\options:
\\ -n, --dry-run show what would be removed; change nothing
\\ --hard also delete the working-tree file from disk
\\ -q, --quiet do not print the removed files
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 all named files are no longer tracked (and, with --hard, deleted)
\\ 1 a file could not be removed (not tracked, a directory, outside the
\\ repo, or --hard could not delete it), or this is not a zit repository
\\ 2 usage error (no files given, or an unknown option)
\\
;
test "cli rm B-rm-13: --help/-h prints the verbatim help, exit 0, changes nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Help wins even with no repo, no path, and extra/unknown tokens present.
inline for (.{
&[_][]const u8{ "rm", "--help" },
&[_][]const u8{ "rm", "-h" },
&[_][]const u8{ "rm", "--help", "a", "b" },
&[_][]const u8{ "rm", "-x", "--help" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected_rm_help, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(!fileExists(io, tmp.dir, managed.filename));
}
}
test "cli rm B-rm-15: ./a, a, and sub/../a denote one managed entry (forget once, rest not tracked)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try tmp.dir.createDir(io, "sub", .default_dir);
try addManaged(gpa, io, tmp.dir, &.{"a"});
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "./a", "a", "sub/../a" });
defer freeResult(gpa, &res);
// First forgets the normalized key `a`; the later two are now not tracked.
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("removed a (file kept on disk)\n", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: 'a' is not tracked\nzit rm: 'a' is not tracked\n",
res.stderr,
);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-16/-17: a write failure leaves the prior set intact and deletes nothing, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a");
try addManaged(gpa, io, tmp.dir, &.{"a"});
// Make the checkout root read-only so `ManagedSet.write` cannot create its
// temp file → error.Storage, AFTER `a` is forgotten in memory. Restore write
// permission before cleanup can run (defers are LIFO).
try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings(
"zit rm: cannot update tracked files: input/output error\n",
res.stderr,
);
// The write failed, so NO `removed` line may reach stdout — the report is
// withheld until after a durable write (B-rm-14/-17). stdout must be empty.
try std.testing.expectEqualStrings("", res.stdout);
// Persist-before-destroy (B-rm-16): the write failed, so `a` is still tracked
// AND still on disk — nothing was unlinked.
try expectManaged(gpa, io, tmp.dir, &.{"a"});
try std.testing.expect(fileExists(io, tmp.dir, "a"));
}
test "cli rm B-rm-17/-14: a write failure claims NO removal on stdout; every named path stays tracked" {
// The exact verify refutation: two tracked files, the checkout root made
// unwritable so `ManagedSet.write` cannot create its temp file. The forgets
// happen in memory, the atomic write fails → exit 1 with only the storage
// error on stderr. stdout must NOT print `removed f1`/`removed f2` (nothing
// was persisted), and a readback must show BOTH paths still tracked so that
// `zit rm <files> | wc -l` counts exactly the real (zero) removals.
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "f1.txt");
try touch(io, tmp.dir, "f2.txt");
try addManaged(gpa, io, tmp.dir, &.{ "f1.txt", "f2.txt" });
try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o555));
defer tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777)) catch {};
// Default (non-hard) rm, two paths — mirrors the refutation repro exactly.
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "f1.txt", "f2.txt" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings(
"zit rm: cannot update tracked files: input/output error\n",
res.stderr,
);
try std.testing.expectEqualStrings("", res.stdout);
// Restore write access so the readback (and cleanup) can open the set, then
// confirm the on-disk set is unchanged — both paths still tracked.
try tmp.dir.setPermissions(io, File.Permissions.fromMode(0o777));
try expectManaged(gpa, io, tmp.dir, &.{ "f1.txt", "f2.txt" });
}
test "cli rm B-rm-17: a corrupt .zit-managed fails cleanly, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// Pre-write garbage: the leading u32 decodes to an impossible entry count.
{
var f = try tmp.dir.createFile(io, managed.filename, .{});
defer f.close(io);
try f.writePositionalAll(io, "this is not a valid managed file", 0);
}
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "a" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: cannot read tracked files: .zit-managed is corrupt\n",
res.stderr,
);
}
test "cli rm B-rm-18: --hard forgets and persists but reports an unlink failure, exit 1" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// Seed the managed set (through the library) with a key that names a
// DIRECTORY on disk. `rm --hard d` forgets `d` and writes the set, then the
// unlink fails (deleteFile refuses a directory) — the honest partial: the
// forget stands, only the disk delete failed. Directory recursion is out of
// scope, so this is the deterministic non-FileNotFound unlink failure.
try tmp.dir.createDir(io, "d", .default_dir);
{
var set = managed.ManagedSet.init(gpa);
defer set.deinit();
_ = try set.insert("d");
try set.write(io, tmp.dir);
}
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "d" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
// The unlink FAILED, so `d` is a failed path (B-rm-19): NOTHING about it may
// reach stdout (B-rm-4/-14). The earlier defect printed
// `removed d (deleted from disk)\n` here — a suffix claiming a deletion that
// never happened — while the file remained on disk; stdout must be empty.
try std.testing.expectEqualStrings("", res.stdout);
// The unlink failure is an honest partial on stderr (the forget stands).
try std.testing.expect(std.mem.startsWith(
u8,
res.stderr,
"zit rm: 'd': removed from tracking but could not delete from disk: ",
));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
// The forget is persisted (it preceded the failed unlink); the dir remains.
try expectManaged(gpa, io, tmp.dir, &.{});
var d = try tmp.dir.openDir(io, "d", .{});
d.close(io);
}
test "cli rm B-rm-3/-18/-14: --hard routes a deleted file to stdout and an unlink failure to stderr" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// `ok` is a regular file that unlinks cleanly (→ stdout removal line); `d` is
// a directory whose deleteFile fails (directory recursion is out of scope) —
// a deterministic non-FileNotFound unlink failure (→ stderr only). Seeding
// both through the library keeps this independent of `zit add`'s file check.
try touch(io, tmp.dir, "ok");
try tmp.dir.createDir(io, "d", .default_dir);
{
var set = managed.ManagedSet.init(gpa);
defer set.deinit();
_ = try set.insert("ok");
_ = try set.insert("d");
try set.write(io, tmp.dir);
}
var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "ok", "d" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
// Only the successfully-deleted file prints a stdout removal line (D6): the
// failed path is stderr-only, so `| wc -l` counts exactly the real deletions
// and no disposition suffix ever lies (B-rm-3/-4/-14/-18).
try std.testing.expectEqualStrings("removed ok (deleted from disk)\n", res.stdout);
try std.testing.expect(std.mem.startsWith(
u8,
res.stderr,
"zit rm: 'd': removed from tracking but could not delete from disk: ",
));
try std.testing.expect(std.mem.endsWith(u8, res.stderr, "\n"));
// Both forgets persisted (they precede the unlink); `ok` gone, `d` remains.
try expectManaged(gpa, io, tmp.dir, &.{});
try std.testing.expect(!fileExists(io, tmp.dir, "ok"));
var d = try tmp.dir.openDir(io, "d", .{});
d.close(io);
}
test "cli rm B-rm-14: `zit rm >> log` appends, never clobbers prior file content (O_APPEND honored)" {
// Repro 1 of the composition refutation: a positional stdout writer wrote
// from offset 0, overwriting `PRE-EXISTING-LI` with `removed g1\n`. A
// streaming writer honors the inherited O_APPEND offset, so the line is
// appended after the pre-existing content.
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "g1");
try addManaged(gpa, io, tmp.dir, &.{"g1"});
var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" rm g1 >> log");
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const log = try readFile(gpa, io, tmp.dir, "log");
defer gpa.free(log);
try std.testing.expectEqualStrings("PRE-EXISTING-LINE\nremoved g1 (file kept on disk)\n", log);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-14: a grouped redirect keeps the shell's own lines around zit's output (shared fd offset)" {
// Repro 2: `{ echo …; zit rm …; echo …; } > log` — zit shares one open file
// description with the shell's `echo`s. A positional write at offset 0
// overwrote `SHELL-LINE-1`; a streaming write lands at the shared, advanced
// offset, so all three lines survive in order.
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "g2");
try addManaged(gpa, io, tmp.dir, &.{"g2"});
var res = try runShell(gpa, io, tmp.dir, "{ echo SHELL-LINE-1; \"$ZIT\" rm g2; echo SHELL-LINE-3; } > log");
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const log = try readFile(gpa, io, tmp.dir, "log");
defer gpa.free(log);
try std.testing.expectEqualStrings("SHELL-LINE-1\nremoved g2 (file kept on disk)\nSHELL-LINE-3\n", log);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-14: two zit invocations sharing one redirected fd do not overwrite each other" {
// Repro 3: `{ zit rm a; zit rm b; } > log` — the second invocation inherits
// the fd whose offset the first advanced. Positional writes made the second
// clobber the first (`removed g3a\n` → `.txt\n`); streaming writes append.
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "g3a");
try touch(io, tmp.dir, "g3b");
try addManaged(gpa, io, tmp.dir, &.{ "g3a", "g3b" });
var res = try runShell(gpa, io, tmp.dir, "{ \"$ZIT\" rm g3a; \"$ZIT\" rm g3b; } > log");
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const log = try readFile(gpa, io, tmp.dir, "log");
defer gpa.free(log);
try std.testing.expectEqualStrings(
"removed g3a (file kept on disk)\nremoved g3b (file kept on disk)\n",
log,
);
try expectManaged(gpa, io, tmp.dir, &.{});
}
test "cli rm B-rm-10 (DX): --force/-f teaches the safe-by-default model, exit 2, stderr only" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Parse rejects the flag before repo discovery, so no repo is needed; the
// message must educate (files kept by default, --hard to delete) rather than
// print a bare "unknown option".
inline for (.{
&[_][]const u8{ "rm", "--force", "a" },
&[_][]const u8{ "rm", "-f", "a" },
}) |argv| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n",
res.stderr,
);
}
}
test "cli rm D4: 'zit remove'/'zit delete' suggest 'rm' (git-synonym redirect)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Unknown-command dispatch runs before any repo lookup; a git user's synonym
// is redirected to the real verb instead of dead-ending (exit 2, stderr).
inline for (.{ "remove", "delete" }) |cmd| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{cmd});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unknown command '" ++ cmd ++ "'") != null);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "did you mean 'rm'?") != null);
}
}
// ==========================================================================
// `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 });
}
/// 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);
}
}