Artifact 31920ab487881d5bcb7c196cde6bc5f8591072bbeffea62fef79a90ef43ec541:
- File
src/e2e.zig
— part of check-in
[9636157a85]
at
2026-07-17 15:15:53
on branch recover-inflight
— Dispatch sync on method + Content-Type at any URL path, not just /xfer
A conformant Fossil client POSTs sync to the repository URL path unchanged and MUST NOT append /xfer (sync §4 L759-764): a bare 'fossil clone http://host:port/' POSTs to "/". The server previously required the /xfer path and answered 404 for anything else, so a root-URL clone from a stock client failed.
Drop the path from the dispatch decision entirely. classifyXfer now keys only on method (POST) and a request-valid, outer-compressed Content-Type (application/x-fossil); the URL path is accepted but unused. Remove xfer_path, requestPath, and the not_found/404 disposition; Disposition collapses to ok / method_not_allowed / unsupported_media_type. A non-POST stays 405 rather than a home redirect this sync-only server can't honor (and a 302 to "/" would loop now that "/" is a valid endpoint).
The over-real-HTTP e2e clone now targets the bare root "/" to prove a root-URL client interoperates. Unit + e2e green; fmt clean. (user: dmestas, size: 308229) [annotate] [blame] [check-ins using] [more...]
//! 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` carrying the header plus a single config frame (its §9.1 project /// code and defaults) and no content artifacts, and a `.zit-checkout` whose /// trunk is unborn (`version == ""`, `branch == "trunk"`). The project code is /// a real 40-hex value, never the all-zero placeholder. Returns the `.zit` /// bytes (caller frees) so a same-directory caller can prove 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.expect(bytes.len > logstore.header_len); // header + config frame const digests = try collectArtifactDigests(gpa, io, dir); defer freeDigests(gpa, digests); try std.testing.expectEqual(@as(usize, 0), digests.len); // no content yet const code = try readProjectCode(gpa, io, dir); defer gpa.free(code); try std.testing.expectEqual(@as(usize, 40), code.len); // §9.1 artifact-hash width try std.testing.expect(!std.mem.eql(u8, code, "0" ** 40)); // a real, non-placeholder code 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); // `zit init` and `zit init .` are equivalent: both parse to the cwd and // yield a valid fresh repo (asserted above). They are NOT byte-identical — // each repository gets its own §9.1 project code, so the two codes are // well-formed and, being per-repo, distinct. const code_bare = try readProjectCode(gpa, io, tmp_bare.dir); defer gpa.free(code_bare); const code_dot = try readProjectCode(gpa, io, tmp_dot.dir); defer gpa.free(code_dot); try std.testing.expect(!std.mem.eql(u8, code_bare, code_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); // `-q` changes only stdout: both runs create a full, valid fresh repo // (asserted above via expectFreshRepo). The on-disk repos are not // byte-identical because each carries its own §9.1 project code — a // per-repo identity, not a run-to-run nondeterminism. const code_loud = try readProjectCode(gpa, io, tmp_loud.dir); defer gpa.free(code_loud); const code_quiet = try readProjectCode(gpa, io, tmp_quiet.dir); defer gpa.free(code_quiet); try std.testing.expect(!std.mem.eql(u8, code_loud, code_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>] [--branch <name>] [--close] \\ [-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). \\ \\--branch <name> starts a new branch at this check-in: the check-in moves \\onto <name> and later commits inherit it. <name> must not already name an \\open branch, and there must be a parent to fork from. --close marks this \\check-in a closed leaf, so it no longer shows as an open head of its branch. \\ \\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) \\ --branch <name> start a new branch at this check-in \\ --close mark this check-in a closed leaf \\ -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, an \\ existing branch name, 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); } // --- commit --branch / --close (repostate §6.2, artifact §4.7) -------------- /// True iff `m` carries a T card with the given sign and (sign-stripped) name. fn hasTag(m: artifact.Manifest, sign: artifact.TSign, name: []const u8) bool { for (m.t) |t| { if (t.sign == sign and std.mem.eql(u8, t.name, name)) return true; } return false; } test "cli commit --branch repostate §6.1/§6.2: a new check-in starts NAME and becomes its tip" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Root check-in on trunk. try writeFile(io, tmp.dir, "f", "v1\n"); try addManaged(gpa, io, tmp.dir, &.{"f"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); 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); // A child that forks onto branch 'feature'. try writeFile(io, tmp.dir, "f", "v2\n"); var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "start feature" }); defer freeResult(gpa, &child); try expectExit(child.term, 0); // The success line names the branch it landed on (§6.1). try std.testing.expect(std.mem.endsWith(u8, child.stdout, " on feature\n")); const child_hash = try gpa.dupe(u8, try checkinHash(child.stdout)); defer gpa.free(child_hash); try std.testing.expect(!std.mem.eql(u8, child_hash, root_hash)); // §6.2 branch-creation cards: the propagating branch/sym tags plus the // sym-trunk cancellation, byte-checked in the minted manifest. { var mc = try parseCheckin(gpa, io, tmp.dir, child_hash); defer mc.deinit(); try std.testing.expect(hasTag(mc.m, .propagate, "branch")); try std.testing.expect(hasTag(mc.m, .propagate, "sym-feature")); try std.testing.expect(hasTag(mc.m, .cancel, "sym-trunk")); // The branch card carries NAME as its value. for (mc.m.t) |t| { if (t.sign == .propagate and std.mem.eql(u8, t.name, "branch")) try std.testing.expectEqualStrings("feature", t.value.?); } } // Derived view (§6.3/§6.4): both branches exist, feature is current tip. var br = try runZit(gpa, io, tmp.dir, &.{"branch"}); defer freeResult(gpa, &br); try expectExit(br.term, 0); try std.testing.expectEqualStrings("* feature\n trunk\n", br.stdout); // The branch name resolves to the child check-in (`ls feature` lists it). var lf = try runZit(gpa, io, tmp.dir, &.{ "ls", "feature" }); defer freeResult(gpa, &lf); try expectExit(lf.term, 0); try std.testing.expect(std.mem.indexOf(u8, lf.stdout, " f\n") != null); } test "cli commit --branch repostate §6.2: rejected (exit 1) when NAME already names an open 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, "f", "v1\n"); try addManaged(gpa, io, tmp.dir, &.{"f"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); // Create branch 'feature' (now an open branch). try writeFile(io, tmp.dir, "f", "v2\n"); var first = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "start" }); defer freeResult(gpa, &first); try expectExit(first.term, 0); // A second `--branch feature` must not re-create the existing open branch. try writeFile(io, tmp.dir, "f", "v3\n"); var second = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "again" }); defer freeResult(gpa, &second); try expectExit(second.term, 1); try std.testing.expectEqualStrings("", second.stdout); try std.testing.expect(std.mem.indexOf(u8, second.stderr, "already exists") != null); } test "cli commit --branch repostate §6.2: rejected (exit 1) on an unborn 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); // No root yet: there is no parent to fork a branch from (§6.2). var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "x" }); 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, "needs a parent") != null); } test "cli commit --branch --close repostate §7.4: the check-in is a closed leaf on NAME" { 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, "f", "v1\n"); try addManaged(gpa, io, tmp.dir, &.{"f"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); 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); // Fork onto feature AND close the new leaf in one commit. try writeFile(io, tmp.dir, "f", "v2\n"); var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "--close", "-m", "closed feature" }); defer freeResult(gpa, &child); try expectExit(child.term, 0); const child_hash = try gpa.dupe(u8, try checkinHash(child.stdout)); defer gpa.free(child_hash); // §4.7 +closed is present alongside the branch cards. { var mc = try parseCheckin(gpa, io, tmp.dir, child_hash); defer mc.deinit(); try std.testing.expect(hasTag(mc.m, .add, "closed")); try std.testing.expect(hasTag(mc.m, .propagate, "branch")); } // §7.4: the closed check-in is not an open leaf, so `leaves` omits it; the // still-open trunk root remains. var lv = try runZit(gpa, io, tmp.dir, &.{"leaves"}); defer freeResult(gpa, &lv); try expectExit(lv.term, 0); try std.testing.expect(std.mem.indexOf(u8, lv.stdout, child_hash) == null); try std.testing.expect(std.mem.indexOf(u8, lv.stdout, root_hash) != null); // The branch view marks feature current and closed. var br = try runZit(gpa, io, tmp.dir, &.{"branch"}); defer freeResult(gpa, &br); try expectExit(br.term, 0); try std.testing.expectEqualStrings("* feature (closed)\n trunk\n", br.stdout); } // --- revert (artifact §8.4 effective file + §7.2 verified blob) ------------- test "cli revert artifact §8.4/§7.2: a modified managed file is restored to its check-in blob byte-for-byte" { 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 = "line one\nline two\n"; try writeFile(io, tmp.dir, "doc.txt", original); try addManaged(gpa, io, tmp.dir, &.{"doc.txt"}); var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); // A working edit that revert must discard. try writeFile(io, tmp.dir, "doc.txt", "line one\nCHANGED\nline three\n"); var res = try runZit(gpa, io, tmp.dir, &.{"revert"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("reverted doc.txt\n", res.stdout); const restored = try readFile(gpa, io, tmp.dir, "doc.txt"); defer gpa.free(restored); try std.testing.expectEqualStrings(original, restored); // The tree quiesces: after revert, diff is clean. var diff = try runZit(gpa, io, tmp.dir, &.{"diff"}); defer freeResult(gpa, &diff); try expectExit(diff.term, 0); try std.testing.expectEqualStrings("", diff.stdout); } test "cli revert §4.7.6: a PATH-scoped revert restores only the named file, leaving other edits" { 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", "a-original\n"); try writeFile(io, tmp.dir, "b", "b-original\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); // Edit both; revert only 'a'. try writeFile(io, tmp.dir, "a", "a-edited\n"); try writeFile(io, tmp.dir, "b", "b-edited\n"); var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "a" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("reverted a\n", res.stdout); const a_after = try readFile(gpa, io, tmp.dir, "a"); defer gpa.free(a_after); const b_after = try readFile(gpa, io, tmp.dir, "b"); defer gpa.free(b_after); try std.testing.expectEqualStrings("a-original\n", a_after); // restored try std.testing.expectEqualStrings("b-edited\n", b_after); // untouched } test "cli revert §4.7.6: a managed file absent from the effective set is reported and skipped, never fabricated" { 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, "committed", "c\n"); try addManaged(gpa, io, tmp.dir, &.{"committed"}); var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); // 'staged' is managed but never committed — it is not in the check-in's // effective set, so revert cannot restore it: reported, left as-is. try writeFile(io, tmp.dir, "staged", "s\n"); try addManaged(gpa, io, tmp.dir, &.{"staged"}); var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "staged" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // a skip is informational, not a hard error try std.testing.expectEqualStrings("", res.stdout); // nothing reverted try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not in the current check-in") != null); // The working file is left exactly as it was — never fabricated/removed. const kept = try readFile(gpa, io, tmp.dir, "staged"); defer gpa.free(kept); try std.testing.expectEqualStrings("s\n", kept); } test "cli revert artifact §4.7.6-m5: a deleted executable file is restored with its exec bit" { 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", "#!/bin/sh\necho hi\n"); try addManaged(gpa, io, tmp.dir, &.{"script"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); // chmod +x and record the executable mode in a child check-in. { var f = try tmp.dir.openFile(io, "script", .{ .mode = .read_write }); defer f.close(io); try f.setPermissions(io, File.Permissions.executable_file); } var exe = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "chmod +x" }); defer freeResult(gpa, &exe); try expectExit(exe.term, 0); // Delete the file, then revert recreates it from the check-in. try tmp.dir.deleteFile(io, "script"); var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "script" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("reverted script\n", res.stdout); // Content restored, and — the §4.7.6-m5 point — the exec bit is set again, // so diff is clean (a content- or mode-only drift would surface as M). const restored = try readFile(gpa, io, tmp.dir, "script"); defer gpa.free(restored); try std.testing.expectEqualStrings("#!/bin/sh\necho hi\n", restored); if (comptime File.Permissions.has_executable_bit) { const st = try tmp.dir.statFile(io, "script", .{ .follow_symlinks = false }); try std.testing.expect(st.permissions.toMode() & 0o111 != 0); } var diff = try runZit(gpa, io, tmp.dir, &.{"diff"}); defer freeResult(gpa, &diff); try expectExit(diff.term, 0); try std.testing.expectEqualStrings("", diff.stdout); } test "cli revert artifact §4.7.6-m5: a symlink entry surfaces SymlinkUnsupported and is left 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); // Track a regular file so the managed set names 'link'; the worktree copy // stays a placeholder revert must not overwrite (it will fault first). try writeFile(io, tmp.dir, "link", "placeholder\n"); try addManaged(gpa, io, tmp.dir, &.{"link"}); // Inject a check-in whose F card for 'link' is a symlink, and point the // checkout at it. `zit commit` never mints a symlink card, so this is the // one legitimate way to exercise the reader's §4.7.6-m5 rejection. const blob = try appendArtifact(gpa, io, tmp.dir, "target/path\n"); defer gpa.free(blob); // The wire mode is the F-card perm token ('l' ⇒ symlink, §4.7.6-m5), so it // is set explicitly — `mode` alone is not serialized. const files = [_]artifact.FCard{.{ .filename = "link", .hash = blob, .perm = "l", .old_name = null, .mode = .symlink, }}; const manifest_hash = try appendDiffManifest(gpa, io, tmp.dir, "symlink check-in", null, &files); defer gpa.free(manifest_hash); try checkout.write(io, tmp.dir, .{ .version = manifest_hash, .branch = "trunk" }); var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "link" }); 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, "symlink") != null); // The placeholder file is untouched (the fault precedes any write). const kept = try readFile(gpa, io, tmp.dir, "link"); defer gpa.free(kept); try std.testing.expectEqualStrings("placeholder\n", kept); } test "cli revert: fails exit 1 on an unborn checkout (no check-in to restore)" { 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, &.{"revert"}); 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, "unborn") != null); } 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] [--no-cache] [-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)" \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -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 --no-cache forces a full derive and yields output identical to the cached path" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A two-open-leaf fork gives a non-trivial derived view to compare across // the cache-hit path and the forced full re-derive. const fork = try buildFork(gpa, io, tmp.dir); defer gpa.free(fork.child1); defer gpa.free(fork.child2); // The first read misses, derives from the full log, and writes the memo. var warm = try runZit(gpa, io, tmp.dir, &.{"leaves"}); defer freeResult(gpa, &warm); try expectExit(warm.term, 0); try std.testing.expect(fileExists(io, tmp.dir, ".zit-cache")); // With the cache present, the default read replays it (the O(1) fast path). var cached = try runZit(gpa, io, tmp.dir, &.{"leaves"}); defer freeResult(gpa, &cached); try expectExit(cached.term, 0); // --no-cache bypasses `tryLoad` and re-scans the whole log. It is the same // derivation, so the output is byte-identical — the flag changes only which // path produced it, never what it produces. var bypass = try runZit(gpa, io, tmp.dir, &.{ "leaves", "--no-cache" }); defer freeResult(gpa, &bypass); try expectExit(bypass.term, 0); try std.testing.expectEqualStrings("", bypass.stderr); try std.testing.expectEqualStrings(cached.stdout, bypass.stdout); // Non-empty ⇒ the equality is a real comparison, not two empty listings. try std.testing.expectEqual(@as(usize, 2), lineCount(bypass.stdout)); // The bypass path just validated the whole log, so it refreshes the memo // rather than leaving a stale one — the next reader gets a trustworthy cache. try std.testing.expect(fileExists(io, tmp.dir, ".zit-cache")); } 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 [--no-cache] [-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: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -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] [--no-cache] \\ \\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 \\ \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -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 [--no-cache] \\ \\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: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -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); } // ========================================================================== // `zit ls` end-to-end. Lists a check-in's effective file set, one // `<blob-id> <filename>` line per file in canonical §4.5.3 order. The blob id // oracle is the artifact §7.2 sha3-256 of the file content, computed here from // the RFC rule — never captured from zit or Fossil. // ========================================================================== /// Appends one `ls` line — `<sha3-256 hex> SP <name> LF` — computed from the /// §7.2 hash-over-content rule, so the e2e expectation is RFC-derived. fn appendLsLine(gpa: std.mem.Allocator, list: *std.ArrayList(u8), name: []const u8, content: []const u8) !void { var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); const hex = "0123456789abcdef"; for (digest) |b| { try list.append(gpa, hex[b >> 4]); try list.append(gpa, hex[b & 0x0f]); } try list.append(gpa, ' '); try list.appendSlice(gpa, name); try list.append(gpa, '\n'); } test "cli ls §8.2: prints each effective file's hash and name for a resolved 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); try writeFile(io, tmp.dir, "a.txt", "alpha\n"); try writeFile(io, tmp.dir, "b.txt", "beta\n"); try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.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 checkinHash(commit.stdout); // The effective set is both files, in canonical filename order (a.txt < b.txt), // each `<sha3-256(content)> <name>`. var expected: std.ArrayList(u8) = .empty; defer expected.deinit(gpa); try appendLsLine(gpa, &expected, "a.txt", "alpha\n"); try appendLsLine(gpa, &expected, "b.txt", "beta\n"); var res = try runZit(gpa, io, tmp.dir, &.{ "ls", hash }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected.items, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli ls: no VERSION lists the current checkout; unborn checkout is a failure" { const gpa = std.testing.allocator; const io = std.testing.io; // (a) After a commit the checkout names the new check-in; bare `zit ls` lists // exactly that check-in's files. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "only.txt", "content\n"); try addManaged(gpa, io, tmp.dir, &.{"only.txt"}); var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); var expected: std.ArrayList(u8) = .empty; defer expected.deinit(gpa); try appendLsLine(gpa, &expected, "only.txt", "content\n"); var res = try runZit(gpa, io, tmp.dir, &.{"ls"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected.items, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // (b) A fresh repository's checkout is unborn: bare `zit ls` has nothing to // list — exit 1, diagnostic on stderr, empty stdout. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{"ls"}); 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, "unborn") != null); } } test "cli ls §8.3: an unknown version is a resolve failure (exit 1); not-a-repo is a failure" { const gpa = std.testing.allocator; const io = std.testing.io; // (a) In a real repo, a name no check-in carries is a §8.3 resolution failure: // exit 1, diagnostic on stderr, empty stdout. { 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); var res = try runZit(gpa, io, tmp.dir, &.{ "ls", "nosuch" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit ls: check-in 'nosuch' not found\n", res.stderr); } // (b) Outside any repository, `zit ls` fails to discover a repo: exit 1, // not-a-repo diagnostic on stderr, empty stdout. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var res = try runZit(gpa, io, tmp.dir, &.{"ls"}); 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); } } // ========================================================================== // `zit cat` end-to-end. Emits one file's uncompressed content at a resolved // check-in, byte-for-byte on stdout. The content oracle is the exact bytes the // test wrote before committing — never captured from zit or Fossil. Whether a // blob is stored full or as a delta is a storage detail cat must see through: // its output is always the uncompressed bytes, hash-verified against the F card // (artifact §7.2). // ========================================================================== test "cli cat §7.2/delta §4: emits the reconstructed uncompressed bytes for a file stored as a delta chain" { 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 large file whose second version differs in one byte: commit stores the // child's blob as a profitable delta against the parent's blob for the same // path (§4.1/§7.2 storage), so one of the two versions is reconstructed by // walking a delta frame — cat must emit the exact uncompressed bytes either // way, and blob reconstruction re-verifies the §7.2 hash. var v1: std.ArrayList(u8) = .empty; defer v1.deinit(gpa); for (0..300) |_| try v1.appendSlice(gpa, "the quick brown fox jumps over\n"); var v2: std.ArrayList(u8) = .empty; defer v2.deinit(gpa); try v2.appendSlice(gpa, v1.items); v2.items[1500] = 'X'; // a single-byte change deep in the middle try writeFile(io, tmp.dir, "big.txt", v1.items); try addManaged(gpa, io, tmp.dir, &.{"big.txt"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "v1" }); 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); try writeFile(io, tmp.dir, "big.txt", v2.items); var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "v2" }); defer freeResult(gpa, &child); try expectExit(child.term, 0); // Current checkout (child) → v2; the explicit root check-in → v1. Both // reconstruct their stored blob (one of them across the delta chain). var cur = try runZit(gpa, io, tmp.dir, &.{ "cat", "big.txt" }); defer freeResult(gpa, &cur); try expectExit(cur.term, 0); try std.testing.expectEqualStrings(v2.items, cur.stdout); try std.testing.expectEqualStrings("", cur.stderr); var old = try runZit(gpa, io, tmp.dir, &.{ "cat", "big.txt", root_hash }); defer freeResult(gpa, &old); try expectExit(old.term, 0); try std.testing.expectEqualStrings(v1.items, old.stdout); try std.testing.expectEqualStrings("", old.stderr); } test "cli cat §8.2: resolves FILE within a check-in's effective set — the unchanged and the changed file each read correctly across two commits" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Root {a:"a0", b:"b0"}; child changes only a → "a1". At the child check-in // the effective set (§8.2) is {a:"a1", b:"b0"}: cat of the untouched file // yields its original bytes, cat of the changed file yields the new bytes. try writeFile(io, tmp.dir, "a.txt", "a0\n"); try writeFile(io, tmp.dir, "b.txt", "b0\n"); try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" }); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); try writeFile(io, tmp.dir, "a.txt", "a1\n"); var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "edit a" }); defer freeResult(gpa, &child); try expectExit(child.term, 0); var ca = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" }); defer freeResult(gpa, &ca); try expectExit(ca.term, 0); try std.testing.expectEqualStrings("a1\n", ca.stdout); // the changed file var cb = try runZit(gpa, io, tmp.dir, &.{ "cat", "b.txt" }); defer freeResult(gpa, &cb); try expectExit(cb.term, 0); try std.testing.expectEqualStrings("b0\n", cb.stdout); // carried forward unchanged try std.testing.expectEqualStrings("", cb.stderr); } test "cli cat §8.2: a file removed at a check-in is absent from its effective set — not found, 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); // Root tracks a and b; the child stops tracking b (`zit rm`) and commits, so // b is not in the child's effective set. cat of b at the child reports // not-found on stderr, exit 1, empty stdout (cli-stream-separation). 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 root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); var rm = try runZit(gpa, io, tmp.dir, &.{ "rm", "b.txt" }); defer freeResult(gpa, &rm); try expectExit(rm.term, 0); var child = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "drop b" }); defer freeResult(gpa, &child); try expectExit(child.term, 0); var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "b.txt" }); 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 in the check-in") != null); // a is still tracked, so it reads back its content. var ca = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" }); defer freeResult(gpa, &ca); try expectExit(ca.term, 0); try std.testing.expectEqualStrings("a\n", ca.stdout); } test "cli cat §7.2: binary content with embedded NUL is emitted verbatim — no truncation, no added newline" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Every byte value 0..255 twice, so a NUL sits well before the end and the // content has no trailing LF. cat MUST pass all bytes through unchanged — the // content path is []const u8 (NUL-legal), and cat adds no framing. var content: [512]u8 = undefined; for (&content, 0..) |*b, i| b.* = @intCast(i % 256); try writeFile(io, tmp.dir, "blob.bin", &content); try addManaged(gpa, io, tmp.dir, &.{"blob.bin"}); var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "binary" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "blob.bin" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualSlices(u8, &content, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli cat: no FILE is a usage error (exit 2); an unborn checkout and an unresolvable VERSION each fail (exit 1)" { const gpa = std.testing.allocator; const io = std.testing.io; // (a) `zit cat` with no FILE is a usage error: exit 2, diagnostic on stderr, // empty stdout. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{"cat"}); 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, "no file given") != null); } // (b) A fresh repository's checkout is unborn (§7.5): bare `zit cat FILE` has // no check-in to read — exit 1, diagnostic on stderr, empty stdout. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt" }); 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, "unborn") != null); } // (c) A name no check-in carries is a §8.3 resolution failure (NotFound): // exit 1, the resolve diagnostic on stderr, empty stdout. { 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); var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt", "nosuch" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit cat: 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); } /// Reads a repository's stored sync project code (§9.1) from its `.zit` /// config frame. Caller owns the returned bytes. fn readProjectCode(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); var oc = (try log.readConfig(gpa)) orelse return error.TestUnexpectedResult; defer oc.deinit(gpa); return gpa.dupe(u8, oc.config.project_code); } /// 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 remote sync §4/§6: bare prints, a URL sets, and --unset clears the saved remote" { 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 fresh repo has no saved remote. { var r = try runZit(gpa, io, tmp.dir, &.{"remote"}); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expectEqualStrings("no remote set\n", r.stdout); } // The §9.1 project code `init` seeded — the oracle for field preservation: // writing a remote must not disturb it (guards the config-RMW field-drop // regression the shared `updateConfig` helper closes). const code_before = try readProjectCode(gpa, io, tmp.dir); defer gpa.free(code_before); // Setting one echoes it back and persists it. { var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "http://h.example/xfer" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expectEqualStrings("remote set to http://h.example/xfer\n", r.stdout); } // The project code survived the remote write untouched. { const code_after = try readProjectCode(gpa, io, tmp.dir); defer gpa.free(code_after); try std.testing.expectEqualStrings(code_before, code_after); } { var r = try runZit(gpa, io, tmp.dir, &.{"remote"}); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expectEqualStrings("http://h.example/xfer\n", r.stdout); } // Clearing it returns the repo to "no remote set". { var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "--unset" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expectEqualStrings("remote cleared\n", r.stdout); } { var r = try runZit(gpa, io, tmp.dir, &.{"remote"}); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expectEqualStrings("no remote set\n", r.stdout); } } test "cli sync §4/§6: an omitted URL with no saved remote is a usage error for pull, push, and sync" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // No remote saved: each sync-family verb with no URL is a usage error (exit 2), // diagnostic on stderr, nothing on stdout. for ([_][]const u8{ "pull", "push", "sync" }) |verb| { var r = try runZit(gpa, io, tmp.dir, &.{verb}); defer freeResult(gpa, &r); try expectExit(r.term, 2); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no saved remote") != null); } } test "cli pull sync §4: a saved remote is resolved and used verbatim when the URL arg is 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); // Save an out-of-spec (unsupported-scheme) remote — stored verbatim, validated // only at use time (§4 defines no URL grammar). `zit pull` with no URL must // resolve THIS URL and hand it to the transport, whose scheme rejection quotes // it back — proving verbatim use (not a usage error, not "not a repo"). { var r = try runZit(gpa, io, tmp.dir, &.{ "remote", "gopher://h.example/xfer" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); } var r = try runZit(gpa, io, tmp.dir, &.{"pull"}); defer freeResult(gpa, &r); try expectExit(r.term, 1); // an open failure, not a usage error try std.testing.expect(std.mem.indexOf(u8, r.stderr, "gopher://h.example/xfer") != null); } 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, targeting the BARE ROOT path // "/" (sync §4 L759-764 — the client sends the repo URL path unchanged and // MUST NOT append /xfer; a conformant `fossil clone http://host:port/` // POSTs to "/"). `zit serve` dispatches sync on method + Content-Type // regardless of path, so the root URL clones. (The fossil-client-from-root // case belongs in `zig build smoke`.) const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{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); // §9.1 — B adopted A's project code: the shared identity travels in the // clone reply's `push` card (§8.2/§8.3) and is persisted into B's config. // Both are the same real, 40-hex, non-placeholder code (not all zeros). const a_code = try readProjectCode(gpa, io, tmp_a.dir); defer gpa.free(a_code); const b_code = try readProjectCode(gpa, io, b_dir); defer gpa.free(b_code); try std.testing.expectEqual(@as(usize, 40), a_code.len); try std.testing.expect(!std.mem.eql(u8, a_code, "0" ** 40)); try std.testing.expectEqualStrings(a_code, b_code); } // --- 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; } // --- incremental pull, no server restart (sync §3.8) ----------------------- // // Direction A of the fossil incremental-pull interop: a `zit serve` must // advertise the CURRENT on-disk repository per request, so a check-in committed // to A *while the server is live* is pulled by B without restarting serve. The // server is stateless per request (§3.8), so it re-reads A's log every request // rather than snapshotting its holdings once at startup. One server, spawned // before the second commit and never restarted, is the whole point. test "cli pull: a commit made while the server runs is pulled without a restart (sync §3.8)" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: init + a first committed file — real seed artifacts. 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); // ONE server for A, spawned now and never restarted — the invariant under test. const port: u16 = 47253; var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{port}); defer gpa.free(url); // Clone A into B over the live server; B holds A's seed check-in. 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); var b_repo = try tmp_b.dir.openDir(io, "repo", .{}); defer b_repo.close(io); const seed_digests = try collectArtifactDigests(gpa, io, b_repo); const seed_len = seed_digests.len; freeDigests(gpa, seed_digests); // A grows a SECOND check-in while the SAME server keeps running — no restart. try writeFile(io, tmp_a.dir, "two.txt", "second artifact committed with serve still live\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 digest set is the convergence oracle. const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); try std.testing.expect(a_digests.len > seed_len); // Pull from B against the still-live server. It MUST see the second check-in. var pull = try runZit(gpa, io, b_repo, &.{ "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 equals A's NEW full set — the server // advertised the live commit and B converged (§3.7), no restart involved. const b_digests = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_digests); try expectSameDigestSet(a_digests, b_digests); } // --- push interop (sync §3.3/§3.7) ----------------------------------------- // // The push milestone e2e: an outgoing `zit push` that UPLOADS a repo's new // artifacts into an EXISTING served repo over loopback HTTP. B is cloned from A, // then B grows a second check-in, then B pushes its new artifacts to A, and A's // on-disk set must equal B's full set — proving the push client uploaded exactly // what A lacked and the server persisted it durably (sync §3.3/§3.7), over the // wire, not from any fossil oracle. This is the mirror of pull: content flows the // other way, and the durable landing is on the SERVER (A), not the client. test "cli push: an outgoing push lands a repo's new artifacts on the server over HTTP (sync §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 push starts from a real subset). const clone_port: u16 = 47261; 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); clone_server.kill(io); // B grows: a second committed file, so B now holds artifacts A does not. var b_repo = try tmp_b.dir.openDir(io, "repo", .{}); defer b_repo.close(io); try writeFile(io, b_repo, "two.txt", "second artifact A has never seen\n"); try addManaged(gpa, io, b_repo, &.{"two.txt"}); var commit2 = try runCommitEnv(gpa, io, b_repo, .{ .user = "u" }, &.{ "commit", "-m", "grow" }); defer freeResult(gpa, &commit2); try expectExit(commit2.term, 0); // B's full artifact-digest set is the convergence oracle: after the push, A // must hold exactly this set. Captured now, before the wire runs. const b_digests = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_digests); try std.testing.expect(b_digests.len > b_seed_len(gpa, io, tmp_b.dir) - 1); // Serve A (still only the seed check-in) and push B's content up to it. const push_port: u16 = 47262; var push_server = try spawnServe(gpa, io, tmp_a.dir, push_port); defer push_server.kill(io); try waitForListen(io, push_port); const push_url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{push_port}); defer gpa.free(push_url); var push = try runZit(gpa, io, b_repo, &.{ "push", push_url }); defer freeResult(gpa, &push); try expectExit(push.term, 0); try std.testing.expectEqualStrings("", push.stderr); // Stop the server so its file handles are released before A is read back — the // durable landing is on disk, independent of the live process. push_server.kill(io); // THE PROOF: A's on-disk artifact set now equals B's full set — A received the // second check-in's artifacts over the wire and the server persisted them // durably (sync §3.3/§3.7). A push the server dropped (or only half-stored) // fails here; the assertion is not weakened. const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); try expectSameDigestSet(a_digests, b_digests); }