//! End-to-end tests for the `zit` CLI: they run the REAL compiled binary via
//! `std.process.Child`, with a pinned, isolated environment (TZ=UTC, an
//! isolated HOME, cwd bound to a fresh `std.testing.tmpDir`), assert the exit
//! code and the exact/structural stdout+stderr, then verify the resulting
//! `.zit`/`.zit-checkout` state READ BACK THROUGH THE LIBRARY. Teardown is
//! unconditional (`defer tmp.cleanup()`).
//!
//! Timestamps and hashes are never asserted against a wall clock: the success
//! line is checked structurally (its trailing token is an absolute `.zit`
//! path), and repository state is decoded with the library's own reader.
//!
//! Built by `zig build e2e`, which compiles `zit` first and passes its path in
//! via `build_options.zit_exe`. Behavior ids cite contracts/init.md.
const std = @import("std");
const build_options = @import("build_options");
// Private storage modules share the build graph's single `zit` module instance.
const logstore = @import("zit_logstore");
const storage = @import("zit_storage");
const checkout = @import("zit_checkout");
// The managed-path set is read back through the library to verify `add`'s
// on-disk effect (contracts/add.md); re-exported by the `zit` module.
const managed = @import("zit").managed;
// The artifact reader used to decode a minted check-in back through the
// library — the `commit` e2e's state oracle is its own decoded manifest
// fields, never raw bytes compared to a hand-written expectation.
const artifact = @import("zit").artifact;
const File = std.Io.File;
/// Absolute path to the freshly built `zit` executable (a build-time
/// `LazyPath`, so the exe is always rebuilt before these tests run).
const zit_exe: []const u8 = build_options.zit_exe;
const success_prefix = "Initialized empty zit repository in ";
// --- helpers --------------------------------------------------------------
/// Spawns `zit init <tail...>` with a pinned environment and cwd bound to
/// `tmp_dir`. Caller owns `result.stdout` / `result.stderr`.
fn spawnInit(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
// `zit_exe` is relative to the build root (the test's cwd); the child
// chdir's into `tmp_dir` before exec, so resolve it to an absolute path
// first. `resolve` returns it unchanged if it is already absolute.
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [8][]const u8 = undefined;
argv_buf[0] = abs_exe;
argv_buf[1] = "init";
std.debug.assert(tail.len <= argv_buf.len - 2);
for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
const argv = argv_buf[0 .. 2 + tail.len];
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
fn freeResult(gpa: std.mem.Allocator, res: *std.process.RunResult) void {
gpa.free(res.stdout);
gpa.free(res.stderr);
}
fn expectExit(term: std.process.Child.Term, code: u8) !void {
switch (term) {
.exited => |c| try std.testing.expectEqual(code, c),
else => {
std.debug.print("expected exit {d}, got {any}\n", .{ code, term });
return error.TestUnexpectedTerm;
},
}
}
/// Asserts, through the library, that `dir` holds a freshly-initialized repo:
/// a `.zit` 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);
}
}
// ==========================================================================
// `zit diff` end-to-end. The public surface is deliberately a compact
// metadata report, not a text patch: only managed paths participate, compared
// with the checkout's resolved F-set. Each invocation must be read-only.
// ==========================================================================
test "cli diff: an unborn managed regular file is added" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "new.txt", "new\n");
try addManaged(gpa, io, tmp.dir, &.{"new.txt"});
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("A new.txt\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli diff: a clean committed file is silent and arbitrary untracked paths are omitted" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "same\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
try writeFile(io, tmp.dir, "untracked.txt", "ignored\n");
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli diff: a changed committed managed file is modified and leaves repository metadata byte-identical" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "before\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
try writeFile(io, tmp.dir, "a.txt", "after\n");
const before_zit = try readFile(gpa, io, tmp.dir, ".zit");
defer gpa.free(before_zit);
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("M a.txt\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
const after_zit = try readFile(gpa, io, tmp.dir, ".zit");
defer gpa.free(after_zit);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
try std.testing.expectEqualSlices(u8, before_zit, after_zit);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
}
test "cli diff: a baseline file removed from management is deleted and mixed changes are path-sorted" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a", "a0\n");
try writeFile(io, tmp.dir, "b", "b0\n");
try addManaged(gpa, io, tmp.dir, &.{ "a", "b" });
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
try writeFile(io, tmp.dir, "a", "a1\n");
try writeFile(io, tmp.dir, "c", "c0\n");
try addManaged(gpa, io, tmp.dir, &.{"c"});
var remove = try runZit(gpa, io, tmp.dir, &.{ "rm", "b" });
defer freeResult(gpa, &remove);
try expectExit(remove.term, 0);
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("M a\nD b\nA c\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expect(fileExists(io, tmp.dir, "b")); // `rm` forgets, it does not unlink
}
test "cli diff: a managed filename uses section 4.3 escapes" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const filename = "a b\tc\\d";
try writeFile(io, tmp.dir, filename, "new\n");
try addManaged(gpa, io, tmp.dir, &.{filename});
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("A a\\sb\\tc\\\\d\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli diff: a lowercase SHA-1 baseline is clean unchanged and modified when altered" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const original = "same\n";
try writeFile(io, tmp.dir, "sha1.txt", original);
try addManaged(gpa, io, tmp.dir, &.{"sha1.txt"});
const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
defer gpa.free(blob);
const f = [_]artifact.FCard{.{
.filename = "sha1.txt",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "sha1-root" },
.d = .{ .timestamp = "2020-01-01T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &f,
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = &.{},
};
defer manifest.deinit();
const manifest_bytes = try artifact.create(&manifest, gpa);
defer gpa.free(manifest_bytes);
const checkin = try appendArtifactAs(gpa, io, tmp.dir, .sha1, manifest_bytes);
defer gpa.free(checkin);
try std.testing.expectEqual(@as(usize, 40), checkin.len);
for (checkin) |ch| try std.testing.expect(std.ascii.isHex(ch) and !std.ascii.isUpper(ch));
try seedCheckout(io, tmp.dir, checkin, "trunk");
var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &clean);
try expectExit(clean.term, 0);
try std.testing.expectEqualStrings("", clean.stdout);
try std.testing.expectEqualStrings("", clean.stderr);
try writeFile(io, tmp.dir, "sha1.txt", "changed\n");
var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &changed);
try expectExit(changed.term, 0);
try std.testing.expectEqualStrings("M sha1.txt\n", changed.stdout);
try std.testing.expectEqualStrings("", changed.stderr);
}
test "cli diff: a managed path replaced by a symlink is deleted without following its target" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "target", "same\n");
try writeFile(io, tmp.dir, "link", "same\n");
try addManaged(gpa, io, tmp.dir, &.{"link"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
try tmp.dir.deleteFile(io, "link");
try tmp.dir.symLink(io, "target", "link", .{});
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("D link\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli diff: executable-bit-only change to a managed regular file is modified" {
if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const original = "#!/bin/zit\nsame\n";
try writeFile(io, tmp.dir, "script", original);
try addManaged(gpa, io, tmp.dir, &.{"script"});
{
var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
defer script.close(io);
try script.setPermissions(io, File.Permissions.executable_file);
}
const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "script",
.hash = blob,
.perm = "x",
.old_name = null,
.mode = .executable,
}};
const baseline = try appendDiffManifest(gpa, io, tmp.dir, "executable-root", null, &files);
defer gpa.free(baseline);
var stored_baseline = try parseCheckin(gpa, io, tmp.dir, baseline);
defer stored_baseline.deinit();
try std.testing.expectEqual(artifact.FileMode.executable, (findF(stored_baseline.m, "script") orelse return error.MissingFCard).mode);
try seedCheckout(io, tmp.dir, baseline, "trunk");
var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &clean);
try expectExit(clean.term, 0);
try std.testing.expectEqualStrings("", clean.stdout);
try std.testing.expectEqualStrings("", clean.stderr);
{
var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
defer script.close(io);
try script.setPermissions(io, File.Permissions.default_file);
}
var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &changed);
try expectExit(changed.term, 0);
try std.testing.expectEqualStrings("M script\n", changed.stdout);
try std.testing.expectEqualStrings("", changed.stderr);
}
test "cli commit §4.7.6: chmod +x on a managed file is captured, recorded once, then quiesces" {
if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// A regular managed file, committed through zit's own capture path: §4.7.6
// permits omitting the perm token for a regular file, so its F card is
// mode-regular with no perm.
try writeFile(io, tmp.dir, "script", "#!/bin/sh\necho hi\n");
try addManaged(gpa, io, tmp.dir, &.{"script"});
var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "regular" });
defer freeResult(gpa, &root);
try expectExit(root.term, 0);
const root_hash = try gpa.dupe(u8, try checkinHash(root.stdout));
defer gpa.free(root_hash);
{
var mc = try parseCheckin(gpa, io, tmp.dir, root_hash);
defer mc.deinit();
const f = findF(mc.m, "script") orelse return error.MissingFCard;
try std.testing.expectEqual(artifact.FileMode.regular, f.mode);
try std.testing.expectEqual(@as(?[]const u8, null), f.perm);
}
// chmod +x — the content is unchanged; only the executable bit flips.
{
var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
defer script.close(io);
try script.setPermissions(io, File.Permissions.executable_file);
}
// The drift surfaces in diff exactly once (metadata-only M, no content read).
var drift = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &drift);
try expectExit(drift.term, 0);
try std.testing.expectEqualStrings("M script\n", drift.stdout);
try std.testing.expectEqualStrings("", drift.stderr);
// commit records the mode change: a real child whose F card carries an `x`
// permission token (§4.7.6 — a token containing `x` marks the entry
// executable), not a silent revert to regular.
var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "chmod +x" });
defer freeResult(gpa, &rec);
try expectExit(rec.term, 0);
const child_hash = try checkinHash(rec.stdout);
try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash));
{
var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
defer mc.deinit();
const f = findF(mc.m, "script") orelse return error.MissingFCard;
try std.testing.expectEqual(artifact.FileMode.executable, f.mode);
try std.testing.expectEqualStrings("x", f.perm.?);
}
// The tree quiesces: diff is clean and a re-commit is a no-op (B-commit-9),
// where before the fix the drift re-appeared on every diff forever.
var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &clean);
try expectExit(clean.term, 0);
try std.testing.expectEqualStrings("", clean.stdout);
try std.testing.expectEqualStrings("", clean.stderr);
var noop = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "again" });
defer freeResult(gpa, &noop);
try expectExit(noop.term, 1);
try std.testing.expectEqualStrings("", noop.stdout);
try std.testing.expectEqualStrings("zit commit: nothing to commit (no changes)\n", noop.stderr);
}
test "cli diff: a delta checkout resolves inherited managed files through its B-card" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const original = "same\n";
try writeFile(io, tmp.dir, "inherited.txt", original);
try addManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "inherited.txt",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const baseline = try appendDiffManifest(gpa, io, tmp.dir, "full-root", null, &files);
defer gpa.free(baseline);
const delta = try appendDiffManifest(gpa, io, tmp.dir, "delta", baseline, &.{});
defer gpa.free(delta);
var stored_delta = try parseCheckin(gpa, io, tmp.dir, delta);
defer stored_delta.deinit();
try std.testing.expectEqualStrings(baseline, stored_delta.m.b.?.hash);
try std.testing.expectEqual(@as(usize, 0), stored_delta.m.f.len);
try seedCheckout(io, tmp.dir, delta, "trunk");
var clean = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &clean);
try expectExit(clean.term, 0);
try std.testing.expectEqualStrings("", clean.stdout);
try std.testing.expectEqualStrings("", clean.stderr);
try writeFile(io, tmp.dir, "inherited.txt", "changed\n");
var changed = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &changed);
try expectExit(changed.term, 0);
try std.testing.expectEqualStrings("M inherited.txt\n", changed.stdout);
try std.testing.expectEqualStrings("", changed.stderr);
}
test "cli diff: outside a repository fails with the shared discovery diagnostic" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
res.stderr,
);
}
test "cli diff: a corrupt .zit-checkout fails without a partial diff" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
{
var f = try tmp.dir.createFile(io, checkout.filename, .{});
defer f.close(io);
try f.writePositionalAll(io, &.{ 0xff, 0xff, 0xaa }, 0);
}
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit diff: cannot read checkout state: .zit-checkout is corrupt\n",
res.stderr,
);
}
test "cli diff: a corrupt .zit-managed fails without a partial diff" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
{
var f = try tmp.dir.createFile(io, managed.filename, .{});
defer f.close(io);
try f.writePositionalAll(io, "this is not a valid managed file\n", 0);
}
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit diff: cannot read tracked files: .zit-managed is corrupt\n",
res.stderr,
);
}
test "cli diff: a current delta checkout with an absent B-card baseline fails without a partial diff" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const absent_baseline = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
const delta = try appendDiffManifest(gpa, io, tmp.dir, "missing-baseline", absent_baseline, &.{});
defer gpa.free(delta);
try seedCheckout(io, tmp.dir, delta, "trunk");
var res = try runZit(gpa, io, tmp.dir, &.{"diff"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit diff: cannot resolve baseline\n", res.stderr);
}
test "cli diff: help is the sole option and invalid arguments are usage errors" {
const gpa = std.testing.allocator;
const io = std.testing.io;
inline for (.{ "--help", "-h" }) |flag| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var help = try runZit(gpa, io, tmp.dir, &.{ "diff", flag });
defer freeResult(gpa, &help);
try expectExit(help.term, 0);
try std.testing.expect(std.mem.startsWith(u8, help.stdout, "usage: zit diff [-h|--help]\n"));
try std.testing.expect(std.mem.indexOf(u8, help.stdout, "--exit-code") == null);
try std.testing.expectEqualStrings("", help.stderr);
}
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var unknown = try runZit(gpa, io, tmp.dir, &.{ "diff", "-x" });
defer freeResult(gpa, &unknown);
try expectExit(unknown.term, 2);
try std.testing.expectEqualStrings("", unknown.stdout);
try std.testing.expectEqualStrings(
"zit diff: unknown option '-x'\ntry 'zit diff --help'\n",
unknown.stderr,
);
}
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var positional = try runZit(gpa, io, tmp.dir, &.{ "diff", "extra" });
defer freeResult(gpa, &positional);
try expectExit(positional.term, 2);
try std.testing.expectEqualStrings("", positional.stdout);
try std.testing.expectEqualStrings(
"zit diff: unexpected argument 'extra'\ntry 'zit diff --help'\n",
positional.stderr,
);
}
}
fn appendDiffManifest(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
label: []const u8,
baseline: ?[]const u8,
files: []const artifact.FCard,
) ![]u8 {
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = if (baseline) |hash| .{ .hash = hash } else null,
.c = .{ .comment = label },
.d = .{ .timestamp = "2020-01-01T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = files,
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = &.{},
};
defer manifest.deinit();
const bytes = try artifact.create(&manifest, gpa);
defer gpa.free(bytes);
return appendArtifact(gpa, io, dir, bytes);
}
/// Writes a legal non-leaf control artifact that mutates the named target's
/// tag state (a single T card). Synthetic control artifacts are necessary
/// because this public behavior cannot be produced by an existing CLI command.
fn appendTagControl(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
timestamp: []const u8,
sign: artifact.TSign,
name: []const u8,
target: []const u8,
value: ?[]const u8,
) ![]u8 {
const cards = [_]artifact.TCard{.{
.sign = sign,
.name = name,
.target = target,
.value = value,
}};
var control: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .control,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = timestamp },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = &cards,
};
defer control.deinit();
const bytes = try artifact.create(&control, gpa);
defer gpa.free(bytes);
return appendArtifact(gpa, io, dir, bytes);
}
/// Writes a control artifact that flips `target`'s branch tag — the branch-only
/// wrapper the update tests already rely on.
fn appendBranchControl(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
timestamp: []const u8,
sign: artifact.TSign,
target: []const u8,
value: ?[]const u8,
) ![]u8 {
return appendTagControl(gpa, io, dir, timestamp, sign, "branch", target, value);
}
/// Writes a control artifact that applies an active `closed` tag to `target`
/// (repo-state §7.4). No CLI verb closes a leaf yet, so the closed state is
/// synthesized directly for the `leaves` enumeration tests.
fn appendClosedControl(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
timestamp: []const u8,
target: []const u8,
) ![]u8 {
return appendTagControl(gpa, io, dir, timestamp, .add, "closed", target, null);
}
// ==========================================================================
// `zit commit` end-to-end (behavior ids B-commit-1..20). Each test
// spawns the REAL binary in an isolated tmp dir with a pinned environment
// (TZ=UTC, isolated HOME, and — for reproducibility — a fixed
// SOURCE_DATE_EPOCH and USER), asserts exit code + stdout/stderr
// (structurally around the hash/timestamp), and reads the resulting `.zit` /
// `.zit-checkout` state back THROUGH THE LIBRARY. Teardown is unconditional.
// ==========================================================================
/// A fixed clock for reproducible check-ins: 1234567890 == 2009-02-13T23:31:30Z.
const commit_epoch = "1234567890";
const commit_ts = "2009-02-13T23:31:30";
/// Optional environment overrides for a `commit` spawn. Defaults pin a
/// reproducible clock and a known author; a test sets a field to null to
/// exercise its absence (e.g. `.{ .user = null }` for the author-missing path).
const CommitEnv = struct {
epoch: ?[]const u8 = commit_epoch,
user: ?[]const u8 = "e2e-user",
};
/// Spawns `zit <tail...>` with the pinned base environment plus the commit
/// overrides. Caller owns `result.stdout`/`result.stderr`.
fn runCommitEnv(
gpa: std.mem.Allocator,
io: std.Io,
tmp_dir: std.Io.Dir,
env_opts: CommitEnv,
tail: []const []const u8,
) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
if (env_opts.epoch) |e| try env.put("SOURCE_DATE_EPOCH", e);
if (env_opts.user) |u| try env.put("USER", u);
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
var argv_buf: [16][]const u8 = undefined;
argv_buf[0] = abs_exe;
std.debug.assert(tail.len <= argv_buf.len - 1);
for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;
return std.process.run(gpa, io, .{
.argv = argv_buf[0 .. 1 + tail.len],
.cwd = .{ .dir = tmp_dir },
.environ_map = &env,
});
}
/// Writes `content` to `name` under `dir` (truncating any existing file), so a
/// second commit sees the edited bytes.
fn writeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8) !void {
var f = try dir.createFile(io, name, .{});
defer f.close(io);
try f.writePositionalAll(io, content, 0);
}
/// Reads `.zit-checkout` through the library and returns whether `version`
/// equals `expected` (empty string ⇒ unborn).
fn expectCheckoutVersion(io: std.Io, dir: std.Io.Dir, expected: []const u8) !void {
var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, dir, &ckbuf)).?;
try std.testing.expectEqualStrings(expected, ck.version);
}
/// Returns the checkout's current `version` copied onto `gpa` (caller frees).
fn checkoutVersionAlloc(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, dir, &ckbuf)).?;
return gpa.dupe(u8, ck.version);
}
/// A parsed check-in plus the raw manifest bytes it borrows. `Manifest`'s `hash`
/// (F card) and `parents` (P card) fields are RAW slices into these bytes, so the
/// bytes must outlive the manifest — `deinit` frees both, in order.
const Checkin = struct {
m: artifact.Manifest,
bytes: []u8,
gpa: std.mem.Allocator,
fn deinit(self: *Checkin) void {
self.m.deinit();
self.gpa.free(self.bytes);
}
};
/// Opens `.zit` read-only through the library and parses the artifact named
/// `hash` into a `Checkin` (caller `deinit`s). The e2e state oracle for a minted
/// check-in — never reads Fossil, only zit's own reader.
fn parseCheckin(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, hash: []const u8) !Checkin {
var file = try dir.openFile(io, ".zit", .{ .mode = .read_only });
defer file.close(io);
const st = try file.stat(io);
var fs = storage.FileStorage.init(io, file, st.size);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
defer log.deinit(gpa);
// The log keys frames by the 32-byte binary digest; the check-in `hash` is
// its 64-hex name, so decode before lookup (the same bridge `commit` uses).
var digest: [32]u8 = undefined;
if (hash.len != 64) return error.CheckinNotFound;
_ = try std.fmt.hexToBytes(&digest, hash);
const rid = log.lookup(&digest) orelse return error.CheckinNotFound;
const bytes = (try log.content(gpa, rid)) orelse return error.CheckinNotFound;
errdefer gpa.free(bytes);
const m = try artifact.parse(gpa, bytes);
return .{ .m = m, .bytes = bytes, .gpa = gpa };
}
/// Finds the F card named `name`, or null.
fn findF(m: artifact.Manifest, name: []const u8) ?artifact.FCard {
for (m.f) |f| if (std.mem.eql(u8, f.filename, name)) return f;
return null;
}
/// Extracts the 64-hex artifact hash from a `check-in <hash> on <branch>\n`
/// success line, asserting the fixed structure (never a wall clock).
fn checkinHash(stdout: []const u8) ![]const u8 {
const prefix = "check-in ";
try std.testing.expect(std.mem.startsWith(u8, stdout, prefix));
try std.testing.expect(std.mem.endsWith(u8, stdout, "\n"));
const rest = stdout[prefix.len .. stdout.len - 1];
const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.MalformedSuccessLine;
const hash = rest[0..sp];
try std.testing.expectEqual(@as(usize, 64), hash.len);
for (hash) |ch| try std.testing.expect(std.ascii.isHex(ch) and !std.ascii.isUpper(ch));
return hash;
}
/// An independent transcription of the B-commit-20 §Help block (must match
/// `main.zig`'s `commit_help` byte-for-byte; drift fails the test).
const commit_help_expected =
\\usage: zit commit -m <message> [--user <login>] [-q|--quiet] [--] [<path>...]
\\
\\Record a check-in: save the current contents of your tracked files as a
\\new, permanent point in history on the current branch.
\\
\\zit has no staging area. `commit` reads each tracked file straight from
\\the working tree right now — there is nothing to stage first. With no
\\paths, every tracked file is captured; name one or more paths to commit
\\just those (the rest of the tree is carried forward unchanged).
\\
\\The first commit in a new repository creates the root check-in and starts
\\the default branch 'trunk'. Each check-in is named by its full artifact
\\hash (SHA3-256).
\\
\\History is immutable: a check-in is never rewritten. There is no amend and
\\no --force; to undo a file, change it and commit again. To stop tracking a
\\file (and record its deletion), use `zit rm` then commit.
\\
\\A check-in message is required (-m). Only regular files are captured.
\\
\\options:
\\ -m, --message <message> the check-in comment (required)
\\ --user <login> author to record (default: $USER)
\\ -q, --quiet do not print the check-in line
\\ -h, --help show this help and exit
\\
\\exit status:
\\ 0 the check-in was created
\\ 1 nothing to commit, a tracked file is missing, an empty message, or
\\ this is not a zit repository
\\ 2 usage error (no message, or an unknown option)
\\
;
test "cli commit B-commit-18a: --help and -h print the help to stdout, exit 0" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var r1 = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "--help" });
defer freeResult(gpa, &r1);
try expectExit(r1.term, 0);
try std.testing.expectEqualStrings(commit_help_expected, r1.stdout);
try std.testing.expectEqualStrings("", r1.stderr);
// -h wins even mixed with other tokens (no repo needed).
var r2 = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x", "-h", "-z" });
defer freeResult(gpa, &r2);
try expectExit(r2.term, 0);
try std.testing.expectEqualStrings(commit_help_expected, r2.stdout);
}
test "cli commit B-commit-5: a missing message is a usage error (exit 2, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{"commit"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
res.stderr,
);
}
test "cli commit B-commit-18b: an unknown option is a usage error (exit 2)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x", "-z" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: unknown option '-z'\ntry 'zit commit --help'\n",
res.stderr,
);
}
test "cli commit B-commit-20 (DX): --amend/--force teach the immutable-history model, exit 2, stderr only" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// The reflex flag is rejected right after parse, before any repo discovery,
// so no repo is needed; the message must teach that history is immutable
// (check-ins are never rewritten) rather than print a bare "unknown option"
// that reads as a typo. Still a usage error: exit 2, stderr only, try-help kept.
inline for (.{
.{ &[_][]const u8{ "commit", "-m", "x", "--amend" }, "--amend" },
.{ &[_][]const u8{ "commit", "-m", "x", "--force" }, "--force" },
.{ &[_][]const u8{ "commit", "-m", "x", "-f" }, "-f" },
}) |case| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, case[0]);
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: '" ++ case[1] ++ "' is not supported. zit history is immutable — " ++
"check-ins are never rewritten; record a new check-in that supersedes the old one.\n" ++
"try 'zit commit --help'\n",
res.stderr,
);
}
}
test "cli commit B-commit-14: outside a repository is an expected failure (exit 1)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{}); // no init
defer tmp.cleanup();
var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
res.stderr,
);
}
test "cli commit B-commit-4: an empty or whitespace-only message is rejected (exit 1), nothing written" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a.txt");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
inline for (.{ "", " " }) |msg| {
var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", msg });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: aborting — empty check-in message\n",
res.stderr,
);
}
// Unborn trunk untouched: nothing was committed.
try expectCheckoutVersion(io, tmp.dir, "");
}
test "cli commit B-commit-9: a root with no tracked files has nothing to commit (exit 1)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "commit", "-m", "x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: nothing to commit (no tracked files) — use 'zit add <file>'\n",
res.stderr,
);
try expectCheckoutVersion(io, tmp.dir, "");
}
test "cli commit B-commit-2/13/16/17: the root check-in mints trunk, decodes back through the library" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "hello\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "alice" }, &.{ "commit", "-m", "first" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stderr);
// Structural success line: `check-in <64-hex> on trunk` (B-commit-2/16/19).
const hash = try checkinHash(res.stdout);
try std.testing.expect(std.mem.endsWith(u8, res.stdout, " on trunk\n"));
// The checkout advanced to the new hash (B-commit-15).
try expectCheckoutVersion(io, tmp.dir, hash);
// Decode the manifest back through the library (B-commit-17).
var mc = try parseCheckin(gpa, io, tmp.dir, hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expectEqual(artifact.ArtifactType.manifest, m.kind);
try std.testing.expect(m.c != null and std.mem.eql(u8, m.c.?.comment, "first"));
try std.testing.expect(m.u != null and std.mem.eql(u8, m.u.?.login, "alice"));
// Root: no P card, an F card for a.txt, and the two trunk tags (B-commit-13).
try std.testing.expectEqual(@as(usize, 0), m.p.len);
const fa = findF(m, "a.txt") orelse return error.MissingFCard;
try std.testing.expectEqual(@as(usize, 64), fa.hash.?.len); // SHA3-256 blob name
try std.testing.expect(fa.mode == .regular);
try std.testing.expectEqual(@as(usize, 2), m.t.len);
try std.testing.expect(m.t[0].sign == .propagate and std.mem.eql(u8, m.t[0].name, "branch"));
try std.testing.expect(m.t[0].value != null and std.mem.eql(u8, m.t[0].value.?, "trunk"));
try std.testing.expect(m.t[1].sign == .propagate and std.mem.eql(u8, m.t[1].name, "sym-trunk"));
try std.testing.expect(m.t[1].value == null);
}
test "cli commit B-commit-7: the D card is the pinned UTC writer timestamp; commits are reproducible" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var hashes: [2][]u8 = undefined;
inline for (0..2) |i| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "same bytes\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "bob" }, &.{ "commit", "-m", "msg" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const hash = try checkinHash(res.stdout);
var mc = try parseCheckin(gpa, io, tmp.dir, hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expect(m.d != null);
try std.testing.expectEqualStrings(commit_ts, m.d.?.timestamp);
hashes[i] = try gpa.dupe(u8, hash);
}
defer gpa.free(hashes[0]);
defer gpa.free(hashes[1]);
// Identical inputs + pinned clock/author ⇒ identical manifest hash.
try std.testing.expectEqualStrings(hashes[0], hashes[1]);
}
test "cli commit B-commit-6: author is --user, else $USER, else an expected failure" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// (a) --user overrides the environment author.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "x\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "env-user" }, &.{ "commit", "-m", "m", "--user", "flag-user" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const hash = try checkinHash(res.stdout);
var mc = try parseCheckin(gpa, io, tmp.dir, hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expectEqualStrings("flag-user", m.u.?.login);
}
// (b) no --user ⇒ the $USER environment default.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "x\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "env-user" }, &.{ "commit", "-m", "m" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
const hash = try checkinHash(res.stdout);
var mc = try parseCheckin(gpa, io, tmp.dir, hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expectEqualStrings("env-user", m.u.?.login);
}
// (c) neither ⇒ author-missing, exit 1, nothing written.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "x\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = null }, &.{ "commit", "-m", "m" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: cannot determine the check-in author; pass --user <login>\n",
res.stderr,
);
try expectCheckoutVersion(io, tmp.dir, "");
}
}
test "cli commit B-commit-3/9: a child check-in carries a P card; an unchanged re-commit is a no-op" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "v1\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
defer freeResult(gpa, &r1);
try expectExit(r1.term, 0);
const root_hash = try gpa.dupe(u8, try checkinHash(r1.stdout));
defer gpa.free(root_hash);
// No change ⇒ B-commit-9 child no-op, checkout unchanged.
var rnoop = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "again" });
defer freeResult(gpa, &rnoop);
try expectExit(rnoop.term, 1);
try std.testing.expectEqualStrings("", rnoop.stdout);
try std.testing.expectEqualStrings("zit commit: nothing to commit (no changes)\n", rnoop.stderr);
try expectCheckoutVersion(io, tmp.dir, root_hash);
// Edit ⇒ a real child: new hash, one P card == root, no T card, checkout advanced.
try writeFile(io, tmp.dir, "a.txt", "v2\n");
var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "second" });
defer freeResult(gpa, &r2);
try expectExit(r2.term, 0);
const child_hash = try checkinHash(r2.stdout);
try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash));
try expectCheckoutVersion(io, tmp.dir, child_hash);
var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expectEqual(@as(usize, 1), m.p.len);
try std.testing.expect(m.p[0].parents != null and std.mem.eql(u8, m.p[0].parents.?, root_hash));
try std.testing.expectEqual(@as(usize, 0), m.t.len); // child inherits trunk by propagation
}
test "cli commit B-commit-10: a managed file missing/non-regular is refused, nothing written" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try touch(io, tmp.dir, "a.txt");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
// Remove the working-tree file (but keep it managed): an all-files commit
// must refuse, teaching `zit rm` for a genuine deletion.
try tmp.dir.deleteFile(io, "a.txt");
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "m" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: cannot commit 'a.txt': missing from the working tree (use 'zit rm' to stop tracking it)\n",
res.stderr,
);
try expectCheckoutVersion(io, tmp.dir, "");
}
test "cli commit B-commit-11: a partial commit re-hashes named files and carries the rest forward" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "a1\n");
try writeFile(io, tmp.dir, "b.txt", "b1\n");
try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
defer freeResult(gpa, &r1);
try expectExit(r1.term, 0);
const root_hash = try gpa.dupe(u8, try checkinHash(r1.stdout));
defer gpa.free(root_hash);
var root_mc = try parseCheckin(gpa, io, tmp.dir, root_hash);
defer root_mc.deinit();
const root_m = root_mc.m;
const b_orig_hash = try gpa.dupe(u8, (findF(root_m, "b.txt") orelse return error.MissingFCard).hash.?);
defer gpa.free(b_orig_hash);
// Edit both, but commit only a.txt: b.txt must be carried at its parent hash.
try writeFile(io, tmp.dir, "a.txt", "a2\n");
try writeFile(io, tmp.dir, "b.txt", "b2-uncommitted\n");
var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "partial", "a.txt" });
defer freeResult(gpa, &r2);
try expectExit(r2.term, 0);
const child_hash = try checkinHash(r2.stdout);
var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
defer mc.deinit();
const m = mc.m;
const fa = findF(m, "a.txt") orelse return error.MissingFCard;
const fb = findF(m, "b.txt") orelse return error.MissingFCard;
// a.txt re-hashed (differs from its root card); b.txt carried unchanged.
const fa_root = findF(root_m, "a.txt").?;
try std.testing.expect(!std.mem.eql(u8, fa.hash.?, fa_root.hash.?));
try std.testing.expectEqualStrings(b_orig_hash, fb.hash.?);
}
test "cli commit B-commit-11: a named unmanaged path is not tracked (exit 1)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "a\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
try writeFile(io, tmp.dir, "b.txt", "b\n"); // exists but never `zit add`ed
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "m", "b.txt" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit commit: 'b.txt' is not tracked (use 'zit add' first)\n",
res.stderr,
);
try expectCheckoutVersion(io, tmp.dir, "");
}
test "cli commit B-commit-12: after `zit rm`, an all-files commit records the deletion" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "a\n");
try writeFile(io, tmp.dir, "b.txt", "b\n");
try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
var r1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "first" });
defer freeResult(gpa, &r1);
try expectExit(r1.term, 0);
// Forget b.txt, then commit: the new manifest omits b's F card (deletion).
var rrm = try runZit(gpa, io, tmp.dir, &.{ "rm", "b.txt" });
defer freeResult(gpa, &rrm);
try expectExit(rrm.term, 0);
var r2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "drop b" });
defer freeResult(gpa, &r2);
try expectExit(r2.term, 0);
const child_hash = try checkinHash(r2.stdout);
var mc = try parseCheckin(gpa, io, tmp.dir, child_hash);
defer mc.deinit();
const m = mc.m;
try std.testing.expect(findF(m, "a.txt") != null);
try std.testing.expect(findF(m, "b.txt") == null); // deletion recorded
}
test "cli commit B-commit-19: -q suppresses the success line but still advances the checkout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "a\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-q", "-m", "quiet" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stdout); // no success line
try std.testing.expectEqualStrings("", res.stderr);
// The commit still happened: the checkout advanced off the unborn state.
const version = try checkoutVersionAlloc(gpa, io, tmp.dir);
defer gpa.free(version);
try std.testing.expectEqual(@as(usize, 64), version.len);
}
// ==========================================================================
// `zit update CHECKIN` end-to-end. These tests exercise the public command
// against real check-ins and synthetic delta fixtures without reaching into
// the update implementation.
// ==========================================================================
test "cli update: a clean canonical target restores earlier content and checkout ID" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 0);
try std.testing.expectEqualStrings("", update.stderr);
const restored = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(restored);
try std.testing.expectEqualStrings("earlier\n", restored);
try expectCheckoutVersion(io, tmp.dir, root);
try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}
test "cli update: a non-manifest target leaves the clean checkout unchanged" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "kept\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "kept" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
const non_manifest = try appendArtifact(gpa, io, tmp.dir, "not a manifest\n");
defer gpa.free(non_manifest);
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(before_content);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", non_manifest });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(after_content);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_content, after_content);
try expectCheckoutVersion(io, tmp.dir, current);
try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}
test "cli update: a dirty checkout leaves its managed content and checkout unchanged" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "committed later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "uncommitted change\n");
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(before_content);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(after_content);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_content, after_content);
try expectCheckoutVersion(io, tmp.dir, child);
try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}
test "cli update: a delta target materializes content inherited from its baseline" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "inherited.txt", "current content\n");
try addManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
var current_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "current" });
defer freeResult(gpa, ¤t_commit);
try expectExit(current_commit.term, 0);
const inherited = "inherited baseline content\n";
const blob = try appendArtifact(gpa, io, tmp.dir, inherited);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "inherited.txt",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const baseline = try appendDiffManifest(gpa, io, tmp.dir, "baseline", null, &files);
defer gpa.free(baseline);
const delta = try appendDiffManifest(gpa, io, tmp.dir, "delta", baseline, &.{});
defer gpa.free(delta);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", delta });
defer freeResult(gpa, &update);
try expectExit(update.term, 0);
try std.testing.expectEqualStrings("", update.stderr);
const materialized = try readFile(gpa, io, tmp.dir, "inherited.txt");
defer gpa.free(materialized);
try std.testing.expectEqualStrings(inherited, materialized);
try expectCheckoutVersion(io, tmp.dir, delta);
try expectManaged(gpa, io, tmp.dir, &.{"inherited.txt"});
}
test "cli update: non-ancestor control branch records set then cancel the target branch" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "content\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "target" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const target = try checkinHash(commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "child content\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "child" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
const set_control_id = try appendBranchControl(
gpa,
io,
tmp.dir,
"2020-01-01T00:00:00",
.propagate,
target,
"feature",
);
defer gpa.free(set_control_id);
var set_update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &set_update);
try expectExit(set_update.term, 0);
try std.testing.expectEqualStrings("", set_update.stderr);
{
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const current = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try std.testing.expectEqualStrings(target, current.version);
try std.testing.expectEqualStrings("feature", current.branch);
}
var reset = try runZit(gpa, io, tmp.dir, &.{ "update", child });
defer freeResult(gpa, &reset);
try expectExit(reset.term, 0);
try std.testing.expectEqualStrings("", reset.stderr);
try expectCheckoutVersion(io, tmp.dir, child);
const cancel_control_id = try appendBranchControl(
gpa,
io,
tmp.dir,
"2020-01-02T00:00:00",
.cancel,
target,
null,
);
defer gpa.free(cancel_control_id);
var cancel_update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &cancel_update);
try expectExit(cancel_update.term, 0);
try std.testing.expectEqualStrings("", cancel_update.stderr);
{
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const current = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try std.testing.expectEqualStrings(target, current.version);
try std.testing.expectEqualStrings("trunk", current.branch);
}
}
test "cli update: unsafe and root-control target paths leave the checkout and outside sentinel unchanged" {
const gpa = std.testing.allocator;
const io = std.testing.io;
inline for (.{ ".zit", ".zit-checkout", ".zit-managed", "../outside/sentinel" }) |target_name| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(io, "repo", .default_dir);
try tmp.dir.createDir(io, "outside", .default_dir);
var repo = try tmp.dir.openDir(io, "repo", .{});
defer repo.close(io);
var outside = try tmp.dir.openDir(io, "outside", .{});
defer outside.close(io);
try initRepo(gpa, io, repo);
try writeFile(io, repo, "tracked.txt", "current\n");
try addManaged(gpa, io, repo, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
try writeFile(io, outside, "sentinel", "outside must survive\n");
const payload = try appendArtifact(gpa, io, repo, "target content\n");
defer gpa.free(payload);
const files = [_]artifact.FCard{.{
.filename = target_name,
.hash = payload,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, repo, "unsafe target", null, &files);
defer gpa.free(target);
const before_log = try readFile(gpa, io, repo, ".zit");
defer gpa.free(before_log);
const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(before_tracked);
const before_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(before_sentinel);
var update = try runZit(gpa, io, repo, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_log = try readFile(gpa, io, repo, ".zit");
defer gpa.free(after_log);
const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(after_tracked);
const after_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(after_sentinel);
try std.testing.expectEqualSlices(u8, before_log, after_log);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
try expectCheckoutVersion(io, repo, current);
try expectManaged(gpa, io, repo, &.{"tracked.txt"});
}
}
test "cli update: a target F-card cannot replace a symlink with an outside target" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(io, "repo", .default_dir);
try tmp.dir.createDir(io, "outside", .default_dir);
var repo = try tmp.dir.openDir(io, "repo", .{});
defer repo.close(io);
var outside = try tmp.dir.openDir(io, "outside", .{});
defer outside.close(io);
try initRepo(gpa, io, repo);
try writeFile(io, repo, "tracked.txt", "current\n");
try addManaged(gpa, io, repo, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
try writeFile(io, outside, "sentinel", "outside must survive\n");
try repo.symLink(io, "../outside/sentinel", "link", .{});
const payload = try appendArtifact(gpa, io, repo, "target content\n");
defer gpa.free(payload);
const files = [_]artifact.FCard{.{
.filename = "link",
.hash = payload,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, repo, "symlink target", null, &files);
defer gpa.free(target);
const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(before_tracked);
const before_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(before_sentinel);
var update = try runZit(gpa, io, repo, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(after_tracked);
const after_link = try readFile(gpa, io, repo, "link");
defer gpa.free(after_link);
const after_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(after_sentinel);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try std.testing.expectEqualSlices(u8, before_sentinel, after_link);
try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
try expectCheckoutVersion(io, repo, current);
try expectManaged(gpa, io, repo, &.{"tracked.txt"});
}
test "cli update: a symlink ancestor is rejected without following it outside the repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(io, "repo", .default_dir);
try tmp.dir.createDir(io, "outside", .default_dir);
var repo = try tmp.dir.openDir(io, "repo", .{});
defer repo.close(io);
var outside = try tmp.dir.openDir(io, "outside", .{});
defer outside.close(io);
try initRepo(gpa, io, repo);
try writeFile(io, repo, "tracked.txt", "current\n");
try addManaged(gpa, io, repo, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, repo, .{ .user = "u" }, &.{ "commit", "-m", "current" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
try writeFile(io, outside, "sentinel", "outside must survive\n");
try repo.symLink(io, "../outside", "link", .{});
const payload = try appendArtifact(gpa, io, repo, "target content\n");
defer gpa.free(payload);
const files = [_]artifact.FCard{.{
.filename = "link/sentinel",
.hash = payload,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, repo, "symlink ancestor", null, &files);
defer gpa.free(target);
const before_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(before_tracked);
const before_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(before_sentinel);
var update = try runZit(gpa, io, repo, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, repo, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, repo, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, repo, "tracked.txt");
defer gpa.free(after_tracked);
const after_sentinel = try readFile(gpa, io, outside, "sentinel");
defer gpa.free(after_sentinel);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try std.testing.expectEqualSlices(u8, before_sentinel, after_sentinel);
try expectCheckoutVersion(io, repo, current);
try expectManaged(gpa, io, repo, &.{"tracked.txt"});
}
test "cli update: a clean managed file can become a directory containing a managed file" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "node", "old file\n");
try addManaged(gpa, io, tmp.dir, &.{"node"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "file" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const child_content = "new child\n";
const blob = try appendArtifact(gpa, io, tmp.dir, child_content);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "node/child.txt",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, tmp.dir, "file to directory", null, &files);
defer gpa.free(target);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 0);
try std.testing.expectEqualStrings("", update.stderr);
const child = try readFile(gpa, io, tmp.dir, "node/child.txt");
defer gpa.free(child);
try std.testing.expectEqualStrings(child_content, child);
try expectCheckoutVersion(io, tmp.dir, target);
try expectManaged(gpa, io, tmp.dir, &.{"node/child.txt"});
}
test "cli update: a clean managed directory can become a file" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try tmp.dir.createDir(io, "node", .default_dir);
try writeFile(io, tmp.dir, "node/child.txt", "old child\n");
try addManaged(gpa, io, tmp.dir, &.{"node/child.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "directory" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const file_content = "new file\n";
const blob = try appendArtifact(gpa, io, tmp.dir, file_content);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "node",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, tmp.dir, "directory to file", null, &files);
defer gpa.free(target);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 0);
try std.testing.expectEqualStrings("", update.stderr);
const node = try readFile(gpa, io, tmp.dir, "node");
defer gpa.free(node);
try std.testing.expectEqualStrings(file_content, node);
try std.testing.expect(!fileExists(io, tmp.dir, "node/child.txt"));
try expectCheckoutVersion(io, tmp.dir, target);
try expectManaged(gpa, io, tmp.dir, &.{"node"});
}
test "cli update: an unmanaged descendant blocks a directory-to-file transition without mutation" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try tmp.dir.createDir(io, "node", .default_dir);
try writeFile(io, tmp.dir, "node/tracked.txt", "managed child\n");
try addManaged(gpa, io, tmp.dir, &.{"node/tracked.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "directory" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
try writeFile(io, tmp.dir, "node/unmanaged.txt", "must survive\n");
const blob = try appendArtifact(gpa, io, tmp.dir, "would replace directory\n");
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "node",
.hash = blob,
.perm = null,
.old_name = null,
.mode = .regular,
}};
const target = try appendDiffManifest(gpa, io, tmp.dir, "blocked directory to file", null, &files);
defer gpa.free(target);
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, tmp.dir, "node/tracked.txt");
defer gpa.free(before_tracked);
const before_unmanaged = try readFile(gpa, io, tmp.dir, "node/unmanaged.txt");
defer gpa.free(before_unmanaged);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, tmp.dir, "node/tracked.txt");
defer gpa.free(after_tracked);
const after_unmanaged = try readFile(gpa, io, tmp.dir, "node/unmanaged.txt");
defer gpa.free(after_unmanaged);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try std.testing.expectEqualSlices(u8, before_unmanaged, after_unmanaged);
try expectCheckoutVersion(io, tmp.dir, current);
try expectManaged(gpa, io, tmp.dir, &.{"node/tracked.txt"});
}
test "cli update: an executable F-card materializes an executable regular file" {
if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "script", "old\n");
try addManaged(gpa, io, tmp.dir, &.{"script"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "regular" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const script_content = "#!/bin/sh\necho updated\n";
const blob = try appendArtifact(gpa, io, tmp.dir, script_content);
defer gpa.free(blob);
const files = [_]artifact.FCard{.{
.filename = "script",
.hash = blob,
.perm = "x",
.old_name = null,
.mode = .executable,
}};
const target = try appendDiffManifest(gpa, io, tmp.dir, "executable target", null, &files);
defer gpa.free(target);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", target });
defer freeResult(gpa, &update);
try expectExit(update.term, 0);
try std.testing.expectEqualStrings("", update.stderr);
const script = try readFile(gpa, io, tmp.dir, "script");
defer gpa.free(script);
try std.testing.expectEqualStrings(script_content, script);
var script_file = try tmp.dir.openFile(io, "script", .{ .mode = .read_only });
defer script_file.close(io);
try std.testing.expect((try script_file.stat(io)).permissions.toMode() & 0o111 != 0);
try expectCheckoutVersion(io, tmp.dir, target);
try expectManaged(gpa, io, tmp.dir, &.{"script"});
}
test "cli update: dirty managed membership preserves the current checkout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
try writeFile(io, tmp.dir, "added.txt", "new membership\n");
try addManaged(gpa, io, tmp.dir, &.{"added.txt"});
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(before_tracked);
const before_added = try readFile(gpa, io, tmp.dir, "added.txt");
defer gpa.free(before_added);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(after_tracked);
const after_added = try readFile(gpa, io, tmp.dir, "added.txt");
defer gpa.free(after_added);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try std.testing.expectEqualSlices(u8, before_added, after_added);
try expectCheckoutVersion(io, tmp.dir, child);
try expectManaged(gpa, io, tmp.dir, &.{ "tracked.txt", "added.txt" });
}
test "cli update: a missing managed file preserves the current checkout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "tracked.txt", "later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
try tmp.dir.deleteFile(io, "tracked.txt");
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expect(!fileExists(io, tmp.dir, "tracked.txt"));
try expectCheckoutVersion(io, tmp.dir, child);
try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}
test "cli update: managed executable-mode drift preserves the current checkout" {
if (comptime !File.Permissions.has_executable_bit) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "script", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"script"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "script", "later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
{
var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_write });
defer script.close(io);
try script.setPermissions(io, File.Permissions.executable_file);
}
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_script = try readFile(gpa, io, tmp.dir, "script");
defer gpa.free(before_script);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_script = try readFile(gpa, io, tmp.dir, "script");
defer gpa.free(after_script);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_script, after_script);
var script = try tmp.dir.openFile(io, "script", .{ .mode = .read_only });
defer script.close(io);
try std.testing.expect((try script.stat(io)).permissions.toMode() & 0o111 != 0);
try expectCheckoutVersion(io, tmp.dir, child);
try expectManaged(gpa, io, tmp.dir, &.{"script"});
}
test "cli update: managed type drift preserves the current checkout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked", "earlier\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked"});
var root_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "earlier" });
defer freeResult(gpa, &root_commit);
try expectExit(root_commit.term, 0);
const root = try checkinHash(root_commit.stdout);
try writeFile(io, tmp.dir, "tracked", "later\n");
var child_commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "later" });
defer freeResult(gpa, &child_commit);
try expectExit(child_commit.term, 0);
const child = try checkinHash(child_commit.stdout);
try tmp.dir.deleteFile(io, "tracked");
try tmp.dir.createDir(io, "tracked", .default_dir);
try writeFile(io, tmp.dir, "tracked/nested.txt", "type drift\n");
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_nested = try readFile(gpa, io, tmp.dir, "tracked/nested.txt");
defer gpa.free(before_nested);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", root });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_nested = try readFile(gpa, io, tmp.dir, "tracked/nested.txt");
defer gpa.free(after_nested);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_nested, after_nested);
try expectCheckoutVersion(io, tmp.dir, child);
try expectManaged(gpa, io, tmp.dir, &.{"tracked"});
}
test "cli update: malformed and missing check-in IDs do not mutate a repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
const Case = struct {
id: []const u8,
diagnostic: []const u8,
};
const cases = [_]Case{
.{ .id = "not-an-id", .diagnostic = "invalid check-in ID" },
.{
.id = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
.diagnostic = "not found",
},
};
inline for (cases) |case| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "tracked.txt", "current\n");
try addManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "current" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const current = try checkinHash(commit.stdout);
const before_log = try readFile(gpa, io, tmp.dir, ".zit");
defer gpa.free(before_log);
const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(before_checkout);
const before_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(before_managed);
const before_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(before_tracked);
var update = try runZit(gpa, io, tmp.dir, &.{ "update", case.id });
defer freeResult(gpa, &update);
try expectExit(update.term, 1);
try std.testing.expectEqualStrings("", update.stdout);
try std.testing.expect(std.mem.startsWith(u8, update.stderr, "zit update: "));
try std.testing.expect(std.mem.indexOf(u8, update.stderr, case.diagnostic) != null);
try std.testing.expect(std.mem.endsWith(u8, update.stderr, "\n"));
const after_log = try readFile(gpa, io, tmp.dir, ".zit");
defer gpa.free(after_log);
const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout");
defer gpa.free(after_checkout);
const after_managed = try readFile(gpa, io, tmp.dir, ".zit-managed");
defer gpa.free(after_managed);
const after_tracked = try readFile(gpa, io, tmp.dir, "tracked.txt");
defer gpa.free(after_tracked);
try std.testing.expectEqualSlices(u8, before_log, after_log);
try std.testing.expectEqualSlices(u8, before_checkout, after_checkout);
try std.testing.expectEqualSlices(u8, before_managed, after_managed);
try std.testing.expectEqualSlices(u8, before_tracked, after_tracked);
try expectCheckoutVersion(io, tmp.dir, current);
try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"});
}
}
test "cli update: direct and routed help agree, and missing, extra, and invalid arguments are usage errors" {
const gpa = std.testing.allocator;
const io = std.testing.io;
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var direct = try runZit(gpa, io, tmp.dir, &.{ "update", "--help" });
defer freeResult(gpa, &direct);
try expectExit(direct.term, 0);
try std.testing.expect(std.mem.startsWith(u8, direct.stdout, "usage: zit update "));
try std.testing.expectEqualStrings("", direct.stderr);
var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "update" });
defer freeResult(gpa, &routed);
try expectExit(routed.term, 0);
try std.testing.expectEqualSlices(u8, direct.stdout, routed.stdout);
try std.testing.expectEqualStrings("", routed.stderr);
}
const Case = struct {
argv: []const []const u8,
stderr: []const u8,
};
const cases = [_]Case{
.{
.argv = &.{"update"},
.stderr = "zit update: no check-in given\ntry 'zit update --help'\n",
},
.{
.argv = &.{ "update", "a", "extra" },
.stderr = "zit update: unexpected argument 'extra'\ntry 'zit update --help'\n",
},
.{
.argv = &.{ "update", "-x" },
.stderr = "zit update: unknown option '-x'\ntry 'zit update --help'\n",
},
};
inline for (cases) |case| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, case.argv);
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(case.stderr, res.stderr);
}
}
// ==========================================================================
// `zit log CHECKIN` end-to-end. These tests spawn the real binary to observe
// log output. Synthetic fixtures write artifacts directly when a graph or
// corruption state cannot be constructed through the public CLI.
// ==========================================================================
test "cli log: a root check-in emits exactly its full ID" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "a\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const root = try checkinHash(commit.stdout);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [80]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{root});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: a child emits itself then its parent" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "one\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var c1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &c1);
try expectExit(c1.term, 0);
const root = try checkinHash(c1.stdout);
try writeFile(io, tmp.dir, "a.txt", "two\n");
var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "child" });
defer freeResult(gpa, &c2);
try expectExit(c2.term, 0);
const child = try checkinHash(c2.stdout);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [160]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\ncheckin {s}\n", .{ child, root });
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: an uppercase P-card parent ID remains a reachable invalid target" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const parent = try appendSyntheticCheckin(gpa, io, tmp.dir, "parent", null);
defer gpa.free(parent);
var uppercase_parent: [64]u8 = undefined;
for (parent, &uppercase_parent) |byte, *destination| {
destination.* = std.ascii.toUpper(byte);
}
try std.testing.expect(std.mem.indexOfAny(u8, uppercase_parent[0..], "ABCDEF") != null);
const child = try appendRawCheckinWithUppercaseParent(gpa, io, tmp.dir, uppercase_parent[0..]);
defer gpa.free(child);
var stored_child = try parseCheckin(gpa, io, tmp.dir, child);
defer stored_child.deinit();
var expected_parent_card_buf: [67]u8 = undefined;
const expected_parent_card = try std.fmt.bufPrint(
&expected_parent_card_buf,
"P {s}\n",
.{uppercase_parent},
);
try std.testing.expect(std.mem.indexOf(u8, stored_child.bytes, expected_parent_card) != null);
try std.testing.expectEqual(@as(usize, 1), stored_child.m.p.len);
try std.testing.expectEqualStrings(uppercase_parent[0..], stored_child.m.p[0].parents.?);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
var want: [80]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{child});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings(
"zit log: reachable check-in is not a valid manifest\n",
res.stderr,
);
}
fn appendArtifactAs(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
algo: artifact.HashAlgo,
bytes: []const u8,
) ![]u8 {
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(algo, bytes, &name_buf);
var digest: [32]u8 = undefined;
const decoded = digest[0 .. name.len / 2];
_ = try std.fmt.hexToBytes(decoded, name);
var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
defer file.close(io);
const st = try file.stat(io);
var fs = storage.FileStorage.init(io, file, st.size);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
defer log.deinit(gpa);
_ = try log.put(decoded, bytes);
try log.commit();
return gpa.dupe(u8, name);
}
fn appendArtifact(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, bytes: []const u8) ![]u8 {
return appendArtifactAs(gpa, io, dir, .sha3_256, bytes);
}
fn appendRawCheckinWithUppercaseParent(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
uppercase_parent: []const u8,
) ![]u8 {
std.debug.assert(uppercase_parent.len == 64);
var body_buf: [256]u8 = undefined;
const body = try std.fmt.bufPrint(
&body_buf,
"C child\n" ++
"D 2020-01-01T00:00:00\n" ++
"F f 0000000000000000000000000000000000000000000000000000000000000000\n" ++
"P {s}\n" ++
"U u\n",
.{uppercase_parent},
);
var raw_buf: [256]u8 = undefined;
@memcpy(raw_buf[0..body.len], body);
var checksum: [32]u8 = undefined;
const digest = artifact.selfChecksum(body, &checksum);
raw_buf[body.len] = 'Z';
raw_buf[body.len + 1] = ' ';
@memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
raw_buf[body.len + 34] = '\n';
return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}
fn appendSyntheticCheckinAs(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
algo: artifact.HashAlgo,
label: []const u8,
parents: ?[]const u8,
) ![]u8 {
const f = [_]artifact.FCard{.{
.filename = "f",
.hash = "0000000000000000000000000000000000000000000000000000000000000000",
.perm = null,
.old_name = null,
.mode = .regular,
}};
const p = [_]artifact.PCard{.{
.parents = parents,
.count = if (parents) |wire| std.mem.countScalar(u8, wire, ' ') + 1 else 0,
}};
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = label },
.d = .{ .timestamp = "2020-01-01T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &f,
.j = &.{},
.m = &.{},
.p = if (parents == null) &.{} else &p,
.q = &.{},
.t = &.{},
};
defer manifest.deinit();
const bytes = try artifact.create(&manifest, gpa);
defer gpa.free(bytes);
return appendArtifactAs(gpa, io, dir, algo, bytes);
}
fn appendSyntheticCheckin(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
label: []const u8,
parents: ?[]const u8,
) ![]u8 {
return appendSyntheticCheckinAs(gpa, io, dir, .sha3_256, label, parents);
}
test "cli log: merge parents are traversed depth-first in P-card order" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const a = try appendSyntheticCheckin(gpa, io, tmp.dir, "a", null);
defer gpa.free(a);
const b = try appendSyntheticCheckin(gpa, io, tmp.dir, "b", null);
defer gpa.free(b);
const c = try appendSyntheticCheckin(gpa, io, tmp.dir, "c", a);
defer gpa.free(c);
var parents_buf: [129]u8 = undefined;
const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ c, b });
const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents);
defer gpa.free(merge);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", merge });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [320]u8 = undefined;
const expected = try std.fmt.bufPrint(
&want,
"checkin {s}\ncheckin {s}\ncheckin {s}\ncheckin {s}\n",
.{ merge, c, a, b },
);
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: first visit wins across a diamond" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const base = try appendSyntheticCheckin(gpa, io, tmp.dir, "base", null);
defer gpa.free(base);
const left = try appendSyntheticCheckin(gpa, io, tmp.dir, "left", base);
defer gpa.free(left);
const right = try appendSyntheticCheckin(gpa, io, tmp.dir, "right", base);
defer gpa.free(right);
var parents_buf: [129]u8 = undefined;
const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ left, right });
const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "top", parents);
defer gpa.free(top);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [320]u8 = undefined;
const expected = try std.fmt.bufPrint(
&want,
"checkin {s}\ncheckin {s}\ncheckin {s}\ncheckin {s}\n",
.{ top, left, base, right },
);
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: a valid missing reachable parent is printed once as a phantom" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const missing = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
var parents_buf: [129]u8 = undefined;
const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ missing, missing });
const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "phantom-parent", parents);
defer gpa.free(top);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [160]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\nphantom {s}\n", .{ top, missing });
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: a missing root check-in fails without output" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const missing = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
var res = try runZit(gpa, io, tmp.dir, &.{ "log", missing });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
var want: [160]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "zit log: check-in '{s}' not found\n", .{missing});
try std.testing.expectEqualStrings(expected, res.stderr);
}
test "cli log: a malformed stored root is rejected as an invalid manifest" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const bad = try appendArtifact(gpa, io, tmp.dir, "not a manifest");
defer gpa.free(bad);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", bad });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
var want: [192]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "zit log: check-in '{s}' is not a valid manifest\n", .{bad});
try std.testing.expectEqualStrings(expected, res.stderr);
}
test "cli log: a malformed reachable target fails after its valid ancestor" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const bad = try appendArtifact(gpa, io, tmp.dir, "not a manifest");
defer gpa.free(bad);
const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "bad-parent", bad);
defer gpa.free(top);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
var want: [80]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{top});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings(
"zit log: reachable check-in is not a valid manifest\n",
res.stderr,
);
}
fn appendSyntheticCluster(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
const members = [_]artifact.MCard{.{
.hash = "1111111111111111111111111111111111111111111111111111111111111111",
}};
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .cluster,
.a = null,
.b = null,
.c = null,
.d = null,
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = null,
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &members,
.p = &.{},
.q = &.{},
.t = &.{},
};
defer manifest.deinit();
const bytes = try artifact.create(&manifest, gpa);
defer gpa.free(bytes);
return appendArtifact(gpa, io, dir, bytes);
}
test "cli log: a reachable non-check-in artifact is rejected" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const cluster = try appendSyntheticCluster(gpa, io, tmp.dir);
defer gpa.free(cluster);
const top = try appendSyntheticCheckin(gpa, io, tmp.dir, "cluster-parent", cluster);
defer gpa.free(top);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", top });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
var want: [80]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{top});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings(
"zit log: reachable check-in is not a valid manifest\n",
res.stderr,
);
}
test "cli log: a torn tail is rejected without truncating the repository" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
defer gpa.free(root);
var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
const before = (try file.stat(io)).size;
try file.writePositionalAll(io, "\x00", before);
const torn_size = (try file.stat(io)).size;
file.close(io);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit log: cannot read repository: repository is corrupt\n",
res.stderr,
);
var after_file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
defer after_file.close(io);
try std.testing.expectEqual(torn_size, (try after_file.stat(io)).size);
}
test "cli log: a stored SHA-1 parent is traversed by its 40-hex P-card ID" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const parent = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha1, "sha1-parent", null);
defer gpa.free(parent);
try std.testing.expectEqual(@as(usize, 40), parent.len);
const child = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha3_256, "sha3-child", parent);
defer gpa.free(child);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", child });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [128]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\ncheckin {s}\n", .{ child, parent });
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: a stored SHA-1 root is addressed by its 40-hex full ID" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const root = try appendSyntheticCheckinAs(gpa, io, tmp.dir, .sha1, "sha1-root", null);
defer gpa.free(root);
try std.testing.expectEqual(@as(usize, 40), root.len);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
var want: [64]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "checkin {s}\n", .{root});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli log: content stored under the wrong full ID fails integrity" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
const real = try appendSyntheticCheckin(gpa, io, tmp.dir, "real", null);
defer gpa.free(real);
var checkin = try parseCheckin(gpa, io, tmp.dir, real);
defer checkin.deinit();
const wrong = "2222222222222222222222222222222222222222222222222222222222222222";
var wrong_digest: [32]u8 = undefined;
_ = try std.fmt.hexToBytes(&wrong_digest, wrong);
var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
defer file.close(io);
const st = try file.stat(io);
var fs = storage.FileStorage.init(io, file, st.size);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
defer log.deinit(gpa);
_ = try log.put(&wrong_digest, checkin.bytes);
try log.commit();
var res = try runZit(gpa, io, tmp.dir, &.{ "log", wrong });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit log: cannot read repository: repository is corrupt\n",
res.stderr,
);
}
test "cli log: a corrupt complete frame is rejected without truncation" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var initial = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
const frame_offset = (try initial.stat(io)).size;
initial.close(io);
const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
defer gpa.free(root);
var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
const stable_size = (try file.stat(io)).size;
var byte: [1]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), try file.readPositionalAll(io, &byte, frame_offset + 10));
byte[0] ^= 0xff;
try file.writePositionalAll(io, &byte, frame_offset + 10);
file.close(io);
var res = try runZit(gpa, io, tmp.dir, &.{ "log", root });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit log: cannot read repository: repository is corrupt\n",
res.stderr,
);
var after = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
defer after.close(io);
try std.testing.expectEqual(stable_size, (try after.stat(io)).size);
}
test "cli log: help documents only the signed one-verb surface" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const expected =
\\usage: zit log CHECKIN
\\
\\Show CHECKIN followed by every reachable parent check-in in depth-first
\\P-card order. CHECKIN must be an exact 40- or 64-character lowercase
\\hexadecimal artifact ID. Each check-in is shown once; a referenced parent
\\missing from this repository is shown once as a phantom.
\\
\\options:
\\ -h, --help show this help and exit
\\
;
var direct = try runZit(gpa, io, tmp.dir, &.{ "log", "--help", "ignored" });
defer freeResult(gpa, &direct);
try expectExit(direct.term, 0);
try std.testing.expectEqualStrings(expected, direct.stdout);
try std.testing.expectEqualStrings("", direct.stderr);
var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "log" });
defer freeResult(gpa, &topic);
try expectExit(topic.term, 0);
try std.testing.expectEqualStrings(expected, topic.stdout);
try std.testing.expectEqualStrings("", topic.stderr);
}
test "cli log: usage rejects missing, extra, flags, and non-canonical IDs" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var missing = try runZit(gpa, io, tmp.dir, &.{"log"});
defer freeResult(gpa, &missing);
try expectExit(missing.term, 2);
try std.testing.expectEqualStrings("", missing.stdout);
try std.testing.expectEqualStrings(
"zit log: no check-in given\ntry 'zit log --help'\n",
missing.stderr,
);
var flag = try runZit(gpa, io, tmp.dir, &.{ "log", "--all" });
defer freeResult(gpa, &flag);
try expectExit(flag.term, 2);
try std.testing.expectEqualStrings("", flag.stdout);
try std.testing.expectEqualStrings(
"zit log: unknown option '--all'\ntry 'zit log --help'\n",
flag.stderr,
);
var terminator = try runZit(gpa, io, tmp.dir, &.{ "log", "--", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" });
defer freeResult(gpa, &terminator);
try expectExit(terminator.term, 2);
try std.testing.expectEqualStrings("", terminator.stdout);
try std.testing.expectEqualStrings(
"zit log: unknown option '--'\ntry 'zit log --help'\n",
terminator.stderr,
);
var extra = try runZit(gpa, io, tmp.dir, &.{ "log", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "second" });
defer freeResult(gpa, &extra);
try expectExit(extra.term, 2);
try std.testing.expectEqualStrings("", extra.stdout);
try std.testing.expectEqualStrings(
"zit log: unexpected argument 'second'\ntry 'zit log --help'\n",
extra.stderr,
);
const uppercase = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
var invalid = try runZit(gpa, io, tmp.dir, &.{ "log", uppercase });
defer freeResult(gpa, &invalid);
try expectExit(invalid.term, 1);
try std.testing.expectEqualStrings("", invalid.stdout);
var want: [192]u8 = undefined;
const expected = try std.fmt.bufPrint(
&want,
"zit log: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
.{uppercase},
);
try std.testing.expectEqualStrings(expected, invalid.stderr);
}
// ==========================================================================
// `zit leaves` end-to-end (repo-state §7.1/§7.4/§7.5/§7.7 + CLI discipline).
// Each test spawns the REAL binary in an isolated tmp dir, asserts exit code
// and stdout/stderr, and builds its DAG through real commits (a fork via
// `commit` → `update` → `commit`) plus, where a closed leaf is needed, a
// synthesized `closed` control artifact (no CLI verb closes a leaf yet).
// Teardown is unconditional.
// ==========================================================================
/// Counts newline-terminated lines in `s` (the leaf listing is one line per
/// leaf, so this is the count a `| wc -l` pipe would report).
fn lineCount(s: []const u8) usize {
return std.mem.count(u8, s, "\n");
}
test "cli leaves B-leaves-8: 'leaves' is listed in the command help and suggested for a near-miss" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// The top-level command list names the verb (discoverability).
var help = try runZit(gpa, io, tmp.dir, &.{"help"});
defer freeResult(gpa, &help);
try expectExit(help.term, 0);
try std.testing.expect(std.mem.indexOf(u8, help.stdout, "leaves") != null);
// A near-miss typo suggests the real verb (did-you-mean over known_verbs).
var typo = try runZit(gpa, io, tmp.dir, &.{"leavez"});
defer freeResult(gpa, &typo);
try expectExit(typo.term, 2);
try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'leaves'?") != null);
}
test "cli leaves B-leaves-9: --help and 'help leaves' print the verbatim help to stdout, exit 0, empty stderr" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const expected =
\\usage: zit leaves [--all] [-h|--help]
\\
\\List the open leaves of the check-in DAG — check-ins with no child on the
\\same branch that carry no active closed tag. Each line shows the check-in's
\\full artifact ID and its branch. Leaves are ordered most-recent first; the
\\leaf matching the current checkout is marked with a leading "*".
\\
\\options:
\\ --all also list closed leaves, each marked "(closed)"
\\ -h, --help show this help and exit
\\
;
for ([_][]const u8{ "--help", "-h" }) |flag| {
var res = try runZit(gpa, io, tmp.dir, &.{ "leaves", flag });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
// `zit help leaves` teaches the identical text (DX D4).
var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "leaves" });
defer freeResult(gpa, &routed);
try expectExit(routed.term, 0);
try std.testing.expectEqualStrings(expected, routed.stdout);
}
test "cli leaves B-leaves-10: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{ "leaves", "-x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit leaves: unknown option '-x'\ntry 'zit leaves --help'\n",
res.stderr,
);
}
test "cli leaves B-leaves-11: outside a repository fails exit 1 on stderr, empty stdout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}
test "cli leaves B-leaves-12: an unborn repository lists no leaves (empty stdout, exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expectEqual(@as(usize, 0), lineCount(res.stdout));
}
test "cli leaves §7.1: a single-commit repo lists its lone check-in as the open leaf, marked '*'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const root = try checkinHash(commit.stdout);
var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
// Data on stdout, diagnostics on stderr — the streams never mix (cli-stream).
try std.testing.expectEqualStrings("", res.stderr);
var want: [96]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "* {s} trunk\n", .{root});
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout));
}
/// Builds a two-open-leaf trunk fork with distinct modification times: a root,
/// then `child1` (older), then — after `update` back to the root — `child2`
/// (newer), which becomes the current checkout. Returns both leaf IDs (caller
/// frees each).
fn buildFork(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !struct { child1: []const u8, child2: []const u8 } {
try initRepo(gpa, io, dir);
try writeFile(io, dir, "a.txt", "root\n");
try addManaged(gpa, io, dir, &.{"a.txt"});
var c0 = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = "900000000" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &c0);
try expectExit(c0.term, 0);
const root = try gpa.dupe(u8, try checkinHash(c0.stdout));
defer gpa.free(root);
try writeFile(io, dir, "a.txt", "one\n");
var c1 = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "child1" });
defer freeResult(gpa, &c1);
try expectExit(c1.term, 0);
const child1 = try gpa.dupe(u8, try checkinHash(c1.stdout));
errdefer gpa.free(child1);
var up = try runZit(gpa, io, dir, &.{ "update", root });
defer freeResult(gpa, &up);
try expectExit(up.term, 0);
try writeFile(io, dir, "a.txt", "two\n");
var c2 = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "child2" });
defer freeResult(gpa, &c2);
try expectExit(c2.term, 0);
const child2 = try gpa.dupe(u8, try checkinHash(c2.stdout));
return .{ .child1 = child1, .child2 = child2 };
}
test "cli leaves §7.5: two open leaves are ordered mtime-descending and the current checkout is marked '*'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const fork = try buildFork(gpa, io, tmp.dir);
defer gpa.free(fork.child1);
defer gpa.free(fork.child2);
var res = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stderr);
// §7.5: newest first (child2, epoch 2e9), then child1 (epoch 1e9); child2 is
// the current checkout, so it is marked '*'.
var want: [200]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "* {s} trunk\n {s} trunk\n", .{ fork.child2, fork.child1 });
try std.testing.expectEqualStrings(expected, res.stdout);
try std.testing.expectEqual(@as(usize, 2), lineCount(res.stdout));
}
test "cli leaves §7.4/§7.7: --all lists a closed leaf marked (closed) that the default listing omits" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const fork = try buildFork(gpa, io, tmp.dir);
defer gpa.free(fork.child1);
defer gpa.free(fork.child2);
// Close the older leaf (child1) with a synthesized control artifact (§7.4).
const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child1);
defer gpa.free(ctl);
// Default listing: §7.4 open predicate excludes the closed leaf — only the
// open child2 remains (enumeration filters by leaf-table membership + the
// open test, never by name resolution — §7.7).
var def = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &def);
try expectExit(def.term, 0);
try std.testing.expectEqualStrings("", def.stderr);
var want_def: [96]u8 = undefined;
const expected_def = try std.fmt.bufPrint(&want_def, "* {s} trunk\n", .{fork.child2});
try std.testing.expectEqualStrings(expected_def, def.stdout);
// --all: the closed leaf stays a leaf-table member and is listed, marked
// "(closed)"; still mtime-descending, still the checkout marked '*'.
var all = try runZit(gpa, io, tmp.dir, &.{ "leaves", "--all" });
defer freeResult(gpa, &all);
try expectExit(all.term, 0);
try std.testing.expectEqualStrings("", all.stderr);
var want_all: [220]u8 = undefined;
const expected_all = try std.fmt.bufPrint(&want_all, "* {s} trunk\n {s} trunk (closed)\n", .{ fork.child2, fork.child1 });
try std.testing.expectEqualStrings(expected_all, all.stdout);
}
// ==========================================================================
// `zit branch` end-to-end (repo-state §6.1/§6.3/§6.4/§9 + CLI discipline).
// The multi-branch, closed, forked, and non-trunk-config derivations are unit-
// tested (no CLI creates a branch yet), so these e2e cases exercise the single
// trunk the CLI can build plus the full usage/help/stream/exit contract.
// ==========================================================================
const branch_help_expected =
\\usage: zit branch [-h|--help]
\\
\\List the repository's branches, one per line, ordered by name. Branches are
\\derived from the active branch tags of the check-ins; the branch of the current
\\checkout is marked with a leading "*". A branch whose most recent check-in is
\\closed is marked "(closed)".
\\
\\options:
\\ -h, --help show this help and exit
\\
;
test "cli branch §6.3: a single-commit repo lists trunk marked '*'" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
_ = try checkinHash(commit.stdout);
// §6.3: one branch derived from the root's active branch tag, named "trunk";
// §6.1/§6.4: the checkout is on it, so it is marked. Data on stdout only.
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("* trunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli branch §6.4: the current mark is the active-tag branch-of the checkout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// A trunk fork (two open leaves, checkout on the newer). §6.3 still groups
// them into one "trunk" entry; §6.4 marks it because the checkout resolves
// to trunk through the active-tag filter.
const fork = try buildFork(gpa, io, tmp.dir);
defer gpa.free(fork.child1);
defer gpa.free(fork.child2);
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("* trunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout));
}
test "cli branch B-branch-3: an unborn repository lists nothing (empty stdout, exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli branch B-branch-4: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{ "branch", "-x" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit branch: unknown option '-x'\ntry 'zit branch --help'\n",
res.stderr,
);
}
test "cli branch B-branch-5: an extra argument is a usage error (exit 2, stderr, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{ "branch", "feature" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit branch: unexpected argument 'feature'\ntry 'zit branch --help'\n",
res.stderr,
);
}
test "cli branch B-branch-6: --help / -h / 'help branch' print the verbatim help (stdout, exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
for ([_][]const u8{ "--help", "-h" }) |flag| {
var res = try runZit(gpa, io, tmp.dir, &.{ "branch", flag });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(branch_help_expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "branch" });
defer freeResult(gpa, &routed);
try expectExit(routed.term, 0);
try std.testing.expectEqualStrings(branch_help_expected, routed.stdout);
}
test "cli branch B-branch-7: outside a repository fails exit 1 on stderr, empty stdout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not a zit repository") != null);
}
test "cli branch B-branch-8: a corrupt repository fails exit 1 with a stderr diagnostic" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var initial = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only });
const frame_offset = (try initial.stat(io)).size;
initial.close(io);
const root = try appendSyntheticCheckin(gpa, io, tmp.dir, "root", null);
defer gpa.free(root);
// Flip a byte inside the appended frame so the log reader rejects it.
var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
var byte: [1]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), try file.readPositionalAll(io, &byte, frame_offset + 10));
byte[0] ^= 0xff;
try file.writePositionalAll(io, &byte, frame_offset + 10);
file.close(io);
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit branch: cannot read repository: repository is corrupt\n",
res.stderr,
);
}
test "cli branch B-branch-9: 'branch' is listed in top help and suggested for a near-miss" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var help = try runZit(gpa, io, tmp.dir, &.{"help"});
defer freeResult(gpa, &help);
try expectExit(help.term, 0);
try std.testing.expect(std.mem.indexOf(u8, help.stdout, "branch") != null);
var typo = try runZit(gpa, io, tmp.dir, &.{"brnach"});
defer freeResult(gpa, &typo);
try expectExit(typo.term, 2);
try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'branch'?") != null);
}
test "cli branch §6.4: a hand-edited .zit-checkout naming an unknown version leaves nothing marked (exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
_ = try checkinHash(commit.stdout);
// Overwrite the checkout record with a well-formed-but-unknown version. The
// current-mark resolver returns null (never interned), so trunk lists with
// no "*"; the branch set itself (§6.3) is unaffected. Never an error.
const unknown = "b" ** 64;
try checkout.write(io, tmp.dir, .{ .version = unknown, .branch = "trunk" });
var res = try runZit(gpa, io, tmp.dir, &.{"branch"});
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(" trunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
// --- tag verb (repo-state §5.1/§5.2, read-only enumeration) ----------------
/// An independent transcription of the `zit tag` §Help block (must match
/// `main.zig`'s `tag_help` byte-for-byte; drift fails the test).
const tag_help_expected =
\\usage: zit tag list [CHECKIN]
\\
\\List tags. With no CHECKIN, print the tag names in use across the
\\repository, one per line, sorted. With a CHECKIN, print the tags visible
\\on that check-in and their values.
\\
\\ ls alias for list
\\
\\ -h, --help show this help
\\
;
/// Builds a raw check-in manifest with an injected T-card block and appends it
/// to `.zit`, returning its 64-hex artifact name (caller frees). The card run
/// C/D/F/<t>/U/Z is a valid check-in skeleton; a malformed `t_block` makes
/// `artifact.parse` reject the whole frame, so the loader skips it. Mirrors
/// `appendRawCheckinWithUppercaseParent`'s hand-framed, self-checksummed shape.
fn appendRawManifestWithT(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
comment: []const u8,
t_block: []const u8,
) ![]u8 {
var body_buf: [512]u8 = undefined;
const body = try std.fmt.bufPrint(
&body_buf,
"C {s}\n" ++
"D 2020-01-01T00:00:00\n" ++
"F f 0000000000000000000000000000000000000000000000000000000000000000\n" ++
"{s}" ++
"U u\n",
.{ comment, t_block },
);
var raw_buf: [576]u8 = undefined;
@memcpy(raw_buf[0..body.len], body);
var checksum: [32]u8 = undefined;
const digest = artifact.selfChecksum(body, &checksum);
raw_buf[body.len] = 'Z';
raw_buf[body.len + 1] = ' ';
@memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
raw_buf[body.len + 34] = '\n';
return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}
/// Builds a valid cluster artifact (card run M/Z — a legitimate non-check-in
/// artifact type the crosslink loader admits then skips) and appends it,
/// returning its 64-hex name (caller frees).
fn appendCluster(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 {
var body_buf: [128]u8 = undefined;
const body = try std.fmt.bufPrint(
&body_buf,
"M 1111111111111111111111111111111111111111111111111111111111111111\n",
.{},
);
var raw_buf: [192]u8 = undefined;
@memcpy(raw_buf[0..body.len], body);
var checksum: [32]u8 = undefined;
const digest = artifact.selfChecksum(body, &checksum);
raw_buf[body.len] = 'Z';
raw_buf[body.len + 1] = ' ';
@memcpy(raw_buf[body.len + 2 ..][0..digest.len], digest);
raw_buf[body.len + 34] = '\n';
return appendArtifact(gpa, io, dir, raw_buf[0 .. body.len + 35]);
}
test "cli tag §2: tag list shows branch names with sym- stripped" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
_ = try checkinHash(commit.stdout);
// §2: a symbolic name is stored 'sym-trunk' and surfaced 'trunk'. The
// machinery 'branch' tag is not listed as a bare name in the no-arg view.
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("trunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli tag §5.2: tag list CHECKIN shows branch trunk and the sym tag" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const hash = try gpa.dupe(u8, try checkinHash(commit.stdout));
defer gpa.free(hash);
// §5.1/§5.2/§6.4: a root trunk tip carries the active 'branch'=trunk tag
// (surfaced 'branch trunk') and the 'sym-trunk' tag (surfaced 'trunk'),
// ascending by raw name.
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("branch trunk\ntrunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli tag B-tag-11: --help / -h / 'help tag' print the verbatim help (stdout, exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
for ([_][]const u8{ "--help", "-h" }) |flag| {
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", flag });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(tag_help_expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "tag" });
defer freeResult(gpa, &routed);
try expectExit(routed.term, 0);
try std.testing.expectEqualStrings(tag_help_expected, routed.stdout);
}
test "cli tag B-tag-12: no subcommand is a usage error (exit 2, stderr, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{"tag"});
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit tag: no subcommand given\ntry 'zit tag --help'\n",
res.stderr,
);
}
test "cli tag B-tag-13: an unknown subcommand is a usage error (exit 2, stderr, empty stdout)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "add" });
defer freeResult(gpa, &res);
try expectExit(res.term, 2);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings(
"zit tag: unknown subcommand 'add'\ntry 'zit tag --help'\n",
res.stderr,
);
}
test "cli tag B-tag-14: an unresolvable CHECKIN fails exit 1 on stderr, empty stdout" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", "nosuchname" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit tag: check-in 'nosuchname' not found\n", res.stderr);
}
test "cli tag B-tag-15: 'tag' is listed in the command help and suggested for a near-miss" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var help = try runZit(gpa, io, tmp.dir, &.{"help"});
defer freeResult(gpa, &help);
try expectExit(help.term, 0);
try std.testing.expect(std.mem.indexOf(u8, help.stdout, "tag") != null);
var typo = try runZit(gpa, io, tmp.dir, &.{"tags"});
defer freeResult(gpa, &typo);
try expectExit(typo.term, 2);
try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'tag'?") != null);
}
test "cli tag B-tag-16 (ls): the ls alias equals list; data on stdout only" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
_ = try checkinHash(commit.stdout);
var list = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
defer freeResult(gpa, &list);
var ls = try runZit(gpa, io, tmp.dir, &.{ "tag", "ls" });
defer freeResult(gpa, &ls);
try expectExit(ls.term, 0);
try std.testing.expectEqualStrings("trunk\n", ls.stdout);
try std.testing.expectEqualStrings("", ls.stderr);
try std.testing.expectEqualStrings(list.stdout, ls.stdout);
}
test "cli tag B-tag-17: an unborn repository lists no tags (empty stdout, exit 0)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli tag §5.1: an accepted self-tag T-card surfaces on its check-in" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// Anchors the raw skeleton: a valid `*sym-alpha` self-tag (§5.1 propagate
// sign) crosslinks and surfaces stripped as 'alpha'. The rejection tests
// below reuse this skeleton, changing only the T-card.
const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "tagged", "T *sym-alpha *\n");
defer gpa.free(hash);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("alpha\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
test "cli tag §5.1: a T-card with a bad leading char contributes no tag" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// §5.1: a leading char other than +/-/* is a fatal crosslink error, so the
// manifest is rejected at parse and never becomes a resolvable check-in.
const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T Qsym-bogus *\n");
defer gpa.free(hash);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}
test "cli tag §4.3: an unsigned T-card name contributes no tag" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// §4.3: a tag name lacking a -, +, or * prefix is rejected at parse time.
const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T sym-bogus *\n");
defer gpa.free(hash);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}
test "cli tag §4.3: an all-hex tag name contributes no tag" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// §4.3: a tag name that is entirely hexadecimal after prefix removal is
// rejected (it must stay distinguishable from a hash prefix).
const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T +deadbeef *\n");
defer gpa.free(hash);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}
test "cli tag §4.3: a non-ascending T-card manifest applies none of its tags" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// §4.3: a T-card sequence not strictly ascending by (name, uuid) is
// rejected, and no crosslink effect is applied on the manifest's behalf.
const hash = try appendRawManifestWithT(gpa, io, tmp.dir, "bad", "T *sym-zebra *\nT *sym-apple *\n");
defer gpa.free(hash);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", hash });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null);
}
test "cli tag §4.2: a non-control/manifest artifact contributes no tag" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
_ = try checkinHash(commit.stdout);
// §4.2 step 2: the tag loop runs only for control/manifest/event artifacts;
// a cluster is another type, so it contributes no tag. The listing stays
// exactly the trunk from the real commit.
const cluster = try appendCluster(gpa, io, tmp.dir);
defer gpa.free(cluster);
var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings("trunk\n", res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
// ==========================================================================
// `zit info NAME` end-to-end (repo-state §3/§6/§7/§8/§9/§10 + CLI discipline).
// `info` resolves NAME through the §8.3 procedure and describes the check-in it
// names. The load-bearing property is that resolution is recency-based (§8.2),
// NOT open-leaf enumeration (§8.5/§10): a name can name a closed or non-leaf
// check-in that `leaves` would omit. These cases re-observe that divergence
// through the CLI so the wiring itself is proven.
// ==========================================================================
/// An independent transcription of `info`'s §Help block (must match
/// `main.zig`'s `info_help` byte-for-byte; drift fails the test).
const info_help_expected =
\\usage: zit info NAME
\\
\\Resolve NAME to a single check-in and describe it. NAME may be a full
\\40- or 64-character artifact ID, a unique ID prefix, a branch or tag name,
\\or one of the reserved forms (tip, rid:, tag:, root:, start:, merge-in:).
\\Resolution follows recency, not open-leaf status: a name can resolve to a
\\closed or non-leaf check-in that 'zit leaves' would not list.
\\
\\options:
\\ -h, --help show this help and exit
\\
;
/// Writes a config frame naming `branch` as the repository's main-branch
/// (repo-state §6.4), opening `.zit` read-write exactly as `appendArtifactAs`
/// does — so `info` can be shown to thread the configured default branch rather
/// than assuming the literal "trunk".
fn writeDefaultBranch(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, branch: []const u8) !void {
var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
defer file.close(io);
const st = try file.stat(io);
var fs = storage.FileStorage.init(io, file, st.size);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
defer log.deinit(gpa);
try log.putConfig(.{ .hash_policy = 0, .default_branch = branch, .project_id = "p" });
try log.commit();
}
test "cli info §10.2/§8.5: on a fork whose newer sibling is closed, info trunk names the newer closed check-in while leaves surfaces the older open one" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// child1 (mtime 1e9, open) and child2 (mtime 2e9, the checkout). Close the
// newer sibling child2 (§7.4 control artifact).
const fork = try buildFork(gpa, io, tmp.dir);
defer gpa.free(fork.child1);
defer gpa.free(fork.child2);
const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child2);
defer gpa.free(ctl);
// §8.2 max-mtime: `info trunk` resolves child2 — its closed state and
// leaf-table membership are immaterial to the selection (§8.5, §10.3).
var info = try runZit(gpa, io, tmp.dir, &.{ "info", "trunk" });
defer freeResult(gpa, &info);
try expectExit(info.term, 0);
try std.testing.expectEqualStrings("", info.stderr);
var pbuf: [200]u8 = undefined;
const prefix = try std.fmt.bufPrint(&pbuf, "{s}\nbranch: trunk\nleaf: closed\nparent: ", .{fork.child2});
try std.testing.expect(std.mem.startsWith(u8, info.stdout, prefix));
try std.testing.expect(std.mem.endsWith(u8, info.stdout, "\n"));
// §7.4/§7.7: the default `leaves` view surfaces only the older OPEN leaf
// child1 — the divergence that proves name resolution is NOT open-leaf
// enumeration (§8.1-not-conflated, §8.5-not-openleaf-substitute).
var leaves = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &leaves);
try expectExit(leaves.term, 0);
var lbuf: [128]u8 = undefined;
const expected_leaves = try std.fmt.bufPrint(&lbuf, " {s} trunk\n", .{fork.child1});
try std.testing.expectEqualStrings(expected_leaves, leaves.stdout);
// info named child2; leaves named child1 — the two answers genuinely differ.
try std.testing.expect(!std.mem.eql(u8, fork.child1, fork.child2));
}
test "cli info §10.1: on a fork with the older sibling closed, info trunk and leaves agree on the open newer leaf" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const fork = try buildFork(gpa, io, tmp.dir);
defer gpa.free(fork.child1);
defer gpa.free(fork.child2);
// Close the OLDER sibling child1; the newer child2 stays open.
const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", fork.child1);
defer gpa.free(ctl);
// §8.2: `info trunk` resolves child2 (max mtime, open leaf here).
var info = try runZit(gpa, io, tmp.dir, &.{ "info", "trunk" });
defer freeResult(gpa, &info);
try expectExit(info.term, 0);
try std.testing.expectEqualStrings("", info.stderr);
var pbuf: [200]u8 = undefined;
const prefix = try std.fmt.bufPrint(&pbuf, "{s}\nbranch: trunk\nleaf: open\nparent: ", .{fork.child2});
try std.testing.expect(std.mem.startsWith(u8, info.stdout, prefix));
// §7.4: `leaves` lists only the open child2 — so here info and leaves AGREE.
var leaves = try runZit(gpa, io, tmp.dir, &.{"leaves"});
defer freeResult(gpa, &leaves);
try expectExit(leaves.term, 0);
var lbuf: [128]u8 = undefined;
const expected_leaves = try std.fmt.bufPrint(&lbuf, "* {s} trunk\n", .{fork.child2});
try std.testing.expectEqualStrings(expected_leaves, leaves.stdout);
}
test "cli info §6.1: a check-in on trunk reports branch: trunk" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const root = try checkinHash(commit.stdout);
// §6.1: the root carries the active branch=trunk tag, so branchOf is trunk.
// A lone root check-in is an open leaf with no parent line.
var info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
defer freeResult(gpa, &info);
try expectExit(info.term, 0);
try std.testing.expectEqualStrings("", info.stderr);
var want: [128]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "{s}\nbranch: trunk\nleaf: open\n", .{root});
try std.testing.expectEqualStrings(expected, info.stdout);
}
test "cli info §6.4/§9: a tagless check-in reports the configured main-branch, not literal trunk" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// §6.4: set main-branch to a non-default name, then add a check-in with NO
// branch tag. Its branch is the CONFIGURED value, never the literal "trunk".
try writeDefaultBranch(gpa, io, tmp.dir, "main");
const id = try appendSyntheticCheckin(gpa, io, tmp.dir, "tagless", null);
defer gpa.free(id);
var info = try runZit(gpa, io, tmp.dir, &.{ "info", id });
defer freeResult(gpa, &info);
try expectExit(info.term, 0);
try std.testing.expectEqualStrings("", info.stderr);
var want: [128]u8 = undefined;
const expected = try std.fmt.bufPrint(&want, "{s}\nbranch: main\nleaf: open\n", .{id});
try std.testing.expectEqualStrings(expected, info.stdout);
}
test "cli info §7.1/§7.4: leaf status renders open, closed, and no" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// (a) a lone check-in is an open leaf; (b) closing it makes it a closed
// leaf-table member (§7.4) — still a leaf, so 'closed', not 'no'.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
const root = try gpa.dupe(u8, try checkinHash(commit.stdout));
defer gpa.free(root);
var open_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
defer freeResult(gpa, &open_info);
try expectExit(open_info.term, 0);
var want_open: [128]u8 = undefined;
const expect_open = try std.fmt.bufPrint(&want_open, "{s}\nbranch: trunk\nleaf: open\n", .{root});
try std.testing.expectEqualStrings(expect_open, open_info.stdout);
const ctl = try appendClosedControl(gpa, io, tmp.dir, "2020-01-01T00:00:00", root);
defer gpa.free(ctl);
var closed_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
defer freeResult(gpa, &closed_info);
try expectExit(closed_info.term, 0);
var want_closed: [128]u8 = undefined;
const expect_closed = try std.fmt.bufPrint(&want_closed, "{s}\nbranch: trunk\nleaf: closed\n", .{root});
try std.testing.expectEqualStrings(expect_closed, closed_info.stdout);
}
// (c) a check-in with a same-branch child is not a leaf at all: 'no'.
{
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var c_root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &c_root);
try expectExit(c_root.term, 0);
const root = try gpa.dupe(u8, try checkinHash(c_root.stdout));
defer gpa.free(root);
try writeFile(io, tmp.dir, "a.txt", "edit\n");
var c_child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "child" });
defer freeResult(gpa, &c_child);
try expectExit(c_child.term, 0);
const child = try gpa.dupe(u8, try checkinHash(c_child.stdout));
defer gpa.free(child);
var root_info = try runZit(gpa, io, tmp.dir, &.{ "info", root });
defer freeResult(gpa, &root_info);
try expectExit(root_info.term, 0);
var want_no: [128]u8 = undefined;
const expect_no = try std.fmt.bufPrint(&want_no, "{s}\nbranch: trunk\nleaf: no\n", .{root});
try std.testing.expectEqualStrings(expect_no, root_info.stdout);
}
}
test "cli info §3.2/§3.3: renders the primary parent and each merge parent; a root has none" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
// Two independent roots and a merge child whose P card lists p1 then p2.
const p1 = try appendSyntheticCheckin(gpa, io, tmp.dir, "p1", null);
defer gpa.free(p1);
const p2 = try appendSyntheticCheckin(gpa, io, tmp.dir, "p2", null);
defer gpa.free(p2);
var parents_buf: [129]u8 = undefined;
const parents = try std.fmt.bufPrint(&parents_buf, "{s} {s}", .{ p1, p2 });
const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents);
defer gpa.free(merge);
// §3.2: p1 (first P-card token) is the primary parent; §3.3: p2 is a merge
// parent, shown on its own line.
var mi = try runZit(gpa, io, tmp.dir, &.{ "info", merge });
defer freeResult(gpa, &mi);
try expectExit(mi.term, 0);
try std.testing.expectEqualStrings("", mi.stderr);
var want_merge: [320]u8 = undefined;
const expect_merge = try std.fmt.bufPrint(
&want_merge,
"{s}\nbranch: trunk\nleaf: open\nparent: {s}\nmerge: {s}\n",
.{ merge, p1, p2 },
);
try std.testing.expectEqualStrings(expect_merge, mi.stdout);
// A root has no parent line (and, having a same-branch child, is not a leaf).
var pi = try runZit(gpa, io, tmp.dir, &.{ "info", p1 });
defer freeResult(gpa, &pi);
try expectExit(pi.term, 0);
var want_root: [128]u8 = undefined;
const expect_root = try std.fmt.bufPrint(&want_root, "{s}\nbranch: trunk\nleaf: no\n", .{p1});
try std.testing.expectEqualStrings(expect_root, pi.stdout);
}
test "cli info §8.1: help lists info and did-you-mean suggests it" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// The top-level command list names the verb (discoverability).
var help = try runZit(gpa, io, tmp.dir, &.{"help"});
defer freeResult(gpa, &help);
try expectExit(help.term, 0);
try std.testing.expect(std.mem.indexOf(u8, help.stdout, "info") != null);
// A single-substitution typo maps uniquely to 'info' (distance 1; every
// other verb is farther), so did-you-mean suggests it.
var typo = try runZit(gpa, io, tmp.dir, &.{"infp"});
defer freeResult(gpa, &typo);
try expectExit(typo.term, 2);
try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'info'?") != null);
}
test "cli info §8.1/§8.5: -h/--help and 'help info' print the verbatim help on stdout, exit 0" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
for ([_][]const u8{ "--help", "-h" }) |flag| {
var res = try runZit(gpa, io, tmp.dir, &.{ "info", flag });
defer freeResult(gpa, &res);
try expectExit(res.term, 0);
try std.testing.expectEqualStrings(info_help_expected, res.stdout);
try std.testing.expectEqualStrings("", res.stderr);
}
var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "info" });
defer freeResult(gpa, &routed);
try expectExit(routed.term, 0);
try std.testing.expectEqualStrings(info_help_expected, routed.stdout);
}
test "cli info §8.1: missing NAME, extra positional, and unknown option each exit 2 to stderr" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Missing NAME — detected before any repo discovery, so no repo is needed.
var no_name = try runZit(gpa, io, tmp.dir, &.{"info"});
defer freeResult(gpa, &no_name);
try expectExit(no_name.term, 2);
try std.testing.expectEqualStrings("", no_name.stdout);
try std.testing.expectEqualStrings(
"zit info: no name given\ntry 'zit info --help'\n",
no_name.stderr,
);
// A second positional is an unexpected argument.
var extra = try runZit(gpa, io, tmp.dir, &.{ "info", "a", "b" });
defer freeResult(gpa, &extra);
try expectExit(extra.term, 2);
try std.testing.expectEqualStrings("", extra.stdout);
try std.testing.expectEqualStrings(
"zit info: unexpected argument 'b'\ntry 'zit info --help'\n",
extra.stderr,
);
// An unknown option.
var opt = try runZit(gpa, io, tmp.dir, &.{ "info", "--bogus" });
defer freeResult(gpa, &opt);
try expectExit(opt.term, 2);
try std.testing.expectEqualStrings("", opt.stdout);
try std.testing.expectEqualStrings(
"zit info: unknown option '--bogus'\ntry 'zit info --help'\n",
opt.stderr,
);
}
test "cli info §8.3: an unresolved name exits 1 with the diagnostic on stderr only" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try initRepo(gpa, io, tmp.dir);
try writeFile(io, tmp.dir, "a.txt", "root\n");
try addManaged(gpa, io, tmp.dir, &.{"a.txt"});
var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
// A name no check-in carries is a resolution failure (§8.3 NotFound): exit 1,
// diagnostic on stderr, nothing on stdout (cli-stream-separation).
var res = try runZit(gpa, io, tmp.dir, &.{ "info", "nosuch" });
defer freeResult(gpa, &res);
try expectExit(res.term, 1);
try std.testing.expectEqualStrings("", res.stdout);
try std.testing.expectEqualStrings("zit info: check-in 'nosuch' not found\n", res.stderr);
}
// --- clone interop (sync §3.7/§4/§8) --------------------------------------
//
// The milestone e2e: a real `zit clone` client converging against a real
// `zit serve` server over loopback HTTP. Repo A is populated with a committed
// file; A is served; clone builds a fresh repo B; B's on-disk artifact set is
// read back through the library and must equal A's — proving the client store
// converged to the server's content over the wire, not from any fossil oracle.
const net = std.Io.net;
/// Spawns `zit serve --port <port>` as a detached child whose cwd is `dir`
/// (so `discoverRoot` finds `dir/.zit`). stdout/stderr are discarded — the
/// test drives it purely over the socket. Caller `kill`s the returned child.
fn spawnServe(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, port: u16) !std.process.Child {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const cwd_abs = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(cwd_abs);
const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
defer gpa.free(abs_exe);
const port_str = try std.fmt.allocPrint(gpa, "{d}", .{port});
defer gpa.free(port_str);
// `spawn` builds the child image during this call; once it returns the
// parent no longer references the argv/env buffers, so the defers above
// free them safely after the child exists.
return std.process.spawn(io, .{
.argv = &.{ abs_exe, "serve", "--port", port_str },
.cwd = .{ .dir = dir },
.environ_map = &env,
.stdout = .ignore,
.stderr = .ignore,
});
}
/// Bounded wait for `zit serve` to bind: retry-connect to the loopback port
/// until a connection is accepted or a wall-clock deadline elapses (never a
/// fixed sleep). A successful TCP connect proves the listener is up (connect
/// completes on the listen backlog, before `accept`). The deadline guarantees
/// termination if the server never binds (e.g. the port is already in use).
fn waitForListen(io: std.Io, port: u16) !void {
const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress;
const deadline_ns: i96 = 15_000_000_000; // 15 s cap — orders of magnitude of slack
const start = std.Io.Clock.now(.awake, io).nanoseconds;
while (true) {
if (addr.connect(io, .{ .mode = .stream })) |stream| {
stream.close(io);
return;
} else |_| {}
if (std.Io.Clock.now(.awake, io).nanoseconds - start > deadline_ns) return error.ServerNeverBound;
}
}
/// Reads every content-bearing, non-config artifact digest from `dir/.zit`
/// through the library (LogStore keys frames by the raw binary digest), sorted
/// so two repositories' sets compare directly. Caller frees via `freeDigests`.
fn collectArtifactDigests(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![][]u8 {
var file = try dir.openFile(io, ".zit", .{ .mode = .read_only });
defer file.close(io);
const st = try file.stat(io);
var fs = storage.FileStorage.init(io, file, st.size);
var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile());
defer log.deinit(gpa);
const rids = try log.ridsInPersistedOrder(gpa); // non-phantom, persisted order
defer gpa.free(rids);
var list: std.ArrayListUnmanaged([]u8) = .empty;
errdefer {
for (list.items) |h| gpa.free(h);
list.deinit(gpa);
}
for (rids) |rid| {
if (log.kindOf(rid) == .config) continue; // config is repo metadata, not synced
try list.append(gpa, try gpa.dupe(u8, log.hashOf(rid)));
}
const slice = try list.toOwnedSlice(gpa);
std.mem.sort([]u8, slice, {}, struct {
fn lt(_: void, a: []u8, b: []u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.lt);
return slice;
}
fn freeDigests(gpa: std.mem.Allocator, digests: [][]u8) void {
for (digests) |h| gpa.free(h);
gpa.free(digests);
}
/// Asserts two artifact-digest sets are identical (same count, same members).
/// Both inputs are sorted by `collectArtifactDigests`, so an elementwise walk
/// is a set-equality proof.
fn expectSameDigestSet(a: []const []const u8, b: []const []const u8) !void {
try std.testing.expectEqual(a.len, b.len);
for (a, b) |x, y| try std.testing.expectEqualSlices(u8, x, y);
}
test "cli clone: a fresh clone converges to the served repo's artifacts over HTTP (sync §3.7/§8)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Repo A: init + a committed file, so A's log holds real artifacts (the
// file blob and its manifest) — genuine content for the wire to carry.
var tmp_a = std.testing.tmpDir(.{});
defer tmp_a.cleanup();
try initRepo(gpa, io, tmp_a.dir);
try writeFile(io, tmp_a.dir, "hello.txt", "hello from zit over http\n");
try addManaged(gpa, io, tmp_a.dir, &.{"hello.txt"});
var commit = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
defer freeResult(gpa, &commit);
try expectExit(commit.term, 0);
// A's artifact-digest set is the convergence oracle: the RFC + the actual
// committed content decide it, never a second fossil.
const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
defer freeDigests(gpa, a_digests);
try std.testing.expect(a_digests.len >= 1);
// Serve A on a loopback port; wait (bounded) for the listener to accept.
const port: u16 = 47187;
var server = try spawnServe(gpa, io, tmp_a.dir, port);
defer server.kill(io);
try waitForListen(io, port);
// Clone A into a fresh repo B over real HTTP. The URL names the served
// /xfer endpoint verbatim (sync §4 r1 — the client sends the repo URL path
// unchanged and never appends /xfer; `zit serve` advertises this endpoint).
const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{port});
defer gpa.free(url);
var tmp_b = std.testing.tmpDir(.{});
defer tmp_b.cleanup();
var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", url, "repo" });
defer freeResult(gpa, &clone);
try expectExit(clone.term, 0);
try std.testing.expectEqualStrings("", clone.stderr);
// THE PROOF: B exists and its on-disk artifact set equals A's — a real zit
// client store converged to a real zit server's content over the wire
// (sync §3.7). A partial/empty clone fails here; the assertion is not weakened.
var b_dir = try tmp_b.dir.openDir(io, "repo", .{});
defer b_dir.close(io);
const b_digests = try collectArtifactDigests(gpa, io, b_dir);
defer freeDigests(gpa, b_digests);
try expectSameDigestSet(a_digests, b_digests);
}
// --- pull interop (sync §3.2/§3.3/§3.7) ------------------------------------
//
// The pull milestone e2e: an incremental `zit pull` into an EXISTING repo B over
// loopback HTTP. B is cloned from A, then A grows a second check-in, then B pulls
// A's new artifacts and its on-disk set must equal A's new full set — proving the
// pull client fetched exactly what B lacked and converged (sync §3.7), over the
// wire, not from any fossil oracle. `zit serve` reads a fixed snapshot of the
// repository at startup, so A is re-served after the second commit; the restart
// is a harness detail, not part of what pull is asked to prove.
test "cli pull: an incremental pull fetches a repo's new artifacts over HTTP (sync §3.2/§3.3/§3.7)" {
const gpa = std.testing.allocator;
const io = std.testing.io;
// Repo A: init + a committed file — A's first check-in, real artifacts to seed.
var tmp_a = std.testing.tmpDir(.{});
defer tmp_a.cleanup();
try initRepo(gpa, io, tmp_a.dir);
try writeFile(io, tmp_a.dir, "one.txt", "first artifact over http\n");
try addManaged(gpa, io, tmp_a.dir, &.{"one.txt"});
var commit1 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" });
defer freeResult(gpa, &commit1);
try expectExit(commit1.term, 0);
// Serve A's seed state and clone it into B; B now shares A's project-code and
// holds A's first-check-in artifacts (the pull starts from a real subset).
const clone_port: u16 = 47251;
var clone_server = try spawnServe(gpa, io, tmp_a.dir, clone_port);
try waitForListen(io, clone_port);
const clone_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{clone_port});
defer gpa.free(clone_url);
var tmp_b = std.testing.tmpDir(.{});
defer tmp_b.cleanup();
var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", clone_url, "repo" });
defer freeResult(gpa, &clone);
try expectExit(clone.term, 0);
// The seed server has served its purpose; stop it before A grows so the next
// server starts from a snapshot that includes the second check-in.
clone_server.kill(io);
// A grows: a second committed file, so A now holds artifacts B does not.
try writeFile(io, tmp_a.dir, "two.txt", "second artifact B has never seen\n");
try addManaged(gpa, io, tmp_a.dir, &.{"two.txt"});
var commit2 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "grow" });
defer freeResult(gpa, &commit2);
try expectExit(commit2.term, 0);
// A's new full artifact-digest set is the convergence oracle for the pull.
const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir);
defer freeDigests(gpa, a_digests);
try std.testing.expect(a_digests.len > b_seed_len(gpa, io, tmp_b.dir));
// Re-serve A (now two check-ins) on a fresh port; pull from within B.
const pull_port: u16 = 47252;
var pull_server = try spawnServe(gpa, io, tmp_a.dir, pull_port);
defer pull_server.kill(io);
try waitForListen(io, pull_port);
const pull_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{pull_port});
defer gpa.free(pull_url);
var b_repo = try tmp_b.dir.openDir(io, "repo", .{});
defer b_repo.close(io);
var pull = try runZit(gpa, io, b_repo, &.{ "pull", pull_url });
defer freeResult(gpa, &pull);
try expectExit(pull.term, 0);
try std.testing.expectEqualStrings("", pull.stderr);
// THE PROOF: B's on-disk artifact set now equals A's NEW full set — B received
// the second check-in's artifacts over the wire and converged (sync §3.7). A
// pull that fetched nothing (or only part) fails here; the assertion is not weakened.
const b_digests = try collectArtifactDigests(gpa, io, b_repo);
defer freeDigests(gpa, b_digests);
try expectSameDigestSet(a_digests, b_digests);
}
/// The count of artifacts B holds straight after its clone — the pull's starting
/// subset. Read back through the library so the incremental assertion (A grew
/// past this) rests on B's real on-disk state, not an assumed number.
fn b_seed_len(gpa: std.mem.Allocator, io: std.Io, tmp_b: std.Io.Dir) usize {
var b_repo = tmp_b.openDir(io, "repo", .{}) catch return 0;
defer b_repo.close(io);
const d = collectArtifactDigests(gpa, io, b_repo) catch return 0;
defer freeDigests(gpa, d);
return d.len;
}