Artifact 81f5aa45d532493aa89c12781e3dad0e8ca3d1c5fee6b17e5a0a4b2a77e23e36:
- File
src/e2e.zig
— part of check-in
[6c6d1babc2]
at
2026-07-18 13:19:39
on branch cli
— Add the amend command: mint display-metadata tags, never rewrite history
zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME] mints ONE control artifact whose T cards change how the target check-in displays — comment, user, date, and the repo-state 6.2 branch-move set — retargeted at the check-in hash, never '*'. History stays append-only: the original manifest is untouched and a new control artifact is crosslinked in (artifact 4.7.16 T / 4.7.5 D / 4.7.17 U / 4.7.19 Z; repo-state 4/5 crosslink, 5 tag propagation). Display is honored through a new general RepoState.activeTagValue / Repository.override consumed by timeline and info.
Also removes amend from the git-reflex rejection table: teaching against fossil-semantics amend with the immutability lesson conflated it with git's history-rewriting amend — amend is in fact the proof of zit's immutability, not a violation. 1643 unit + 322 e2e (15 new B-amend) green; fmt clean. (user: dmestas, size: 453704) [annotate] [blame] [check-ins using]
//! 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", .merge_parent = "" }); 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 mv OLD NEW` end-to-end tests (contracts/mv.md B-mv-1..18; derived // manifest bytes from artifact §4.7.6/§4.6). Each spawns the REAL binary in an // isolated, pinned environment, asserts exit code + exact stdout/stderr, and // reads `.zit-managed`/the working tree/the minted manifest back through the // library to prove the state the mv (and the following commit) produced. // ========================================================================== /// An independent transcription of contracts/mv.md §Help. Kept separate from the /// binary's own `mv_help` so the e2e compares two hand-made copies — drift in /// either is caught (B-mv-12). const mv_help_expected = \\usage: zit mv OLD NEW \\ \\Rename a tracked file: move the working file from OLD to NEW on disk, swap \\the tracked-name entry, and record the rename so your next `zit commit` \\records NEW with OLD as its former name. \\ \\OLD must be tracked and present in the working tree; NEW must not already be \\tracked or exist on disk. Renaming never overwrites -- a collision is \\refused, and there is no --force. mv takes exactly two operands; it is never \\a glob or multi-file move, and it does not rename directories. \\ \\mv is not undone by `zit undo`; to reverse a rename, run `zit mv NEW OLD`. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the file was renamed \\ 1 OLD is not tracked or missing, NEW is already tracked or on disk, or \\ this is not a zit repository \\ 2 usage error (wrong operand count, or an unknown option) \\ ; test "cli mv B-mv-1: happy path moves the file, swaps the managed entry, 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, "old.txt", "contents\n"); try addManaged(gpa, io, tmp.dir, &.{"old.txt"}); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "old.txt", "new.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("renamed old.txt -> new.txt\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // On disk: OLD gone, NEW present with the moved bytes. try std.testing.expect(!fileExists(io, tmp.dir, "old.txt")); try std.testing.expect(fileExists(io, tmp.dir, "new.txt")); const moved = try readFile(gpa, io, tmp.dir, "new.txt"); defer gpa.free(moved); try std.testing.expectEqualStrings("contents\n", moved); // Managed set: OLD dropped, NEW gained. try expectManaged(gpa, io, tmp.dir, &.{"new.txt"}); } test "cli mv B-mv-2: the managed set names NEW and the file is at NEW (end state consistent)" { 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", "x\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // The persisted managed set names NEW and NOT OLD, and the file sits at NEW — // the write-then-move end state (the crash seam is covered by managed unit // tests); the two never disagree. var set = try managed.ManagedSet.read(gpa, io, tmp.dir); defer set.deinit(); try std.testing.expect(set.contains("b")); try std.testing.expect(!set.contains("a")); try std.testing.expect(fileExists(io, tmp.dir, "b")); try std.testing.expect(!fileExists(io, tmp.dir, "a")); } test "cli mv B-mv-3: OLD not tracked refuses, exit 1, nothing moves" { 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, "loose", "y\n"); // on disk but never added var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "loose", "dest" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit mv: 'loose' is not tracked\n", res.stderr); // Untouched: source still there, dest never created, set still empty. try std.testing.expect(fileExists(io, tmp.dir, "loose")); try std.testing.expect(!fileExists(io, tmp.dir, "dest")); try expectManaged(gpa, io, tmp.dir, &.{}); } test "cli mv B-mv-4: NEW already tracked refuses, 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", "1\n"); try writeFile(io, tmp.dir, "b", "2\n"); try addManaged(gpa, io, tmp.dir, &.{ "a", "b" }); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit mv: 'b' is already tracked\n", res.stderr); // No clobber: both still tracked, both files intact with original bytes. try expectManaged(gpa, io, tmp.dir, &.{ "a", "b" }); const bb = try readFile(gpa, io, tmp.dir, "b"); defer gpa.free(bb); try std.testing.expectEqualStrings("2\n", bb); } test "cli mv B-mv-5: NEW exists on disk refuses, exit 1, OLD unmoved" { 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", "src\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); try writeFile(io, tmp.dir, "b", "untracked-but-present\n"); // NEW exists on disk, not tracked var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "a", "b" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit mv: 'b' already exists on disk\n", res.stderr); // OLD unmoved and still tracked; NEW file untouched (never overwritten). try expectManaged(gpa, io, tmp.dir, &.{"a"}); const bb = try readFile(gpa, io, tmp.dir, "b"); defer gpa.free(bb); try std.testing.expectEqualStrings("untracked-but-present\n", bb); } test "cli mv B-mv-6: status after mv reports NEW tracked, OLD absent" { 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, "old", "z\n"); try addManaged(gpa, io, tmp.dir, &.{"old"}); var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "old", "new" }); defer freeResult(gpa, &mvres); try expectExit(mvres.term, 0); var st = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &st); try expectExit(st.term, 0); // status reads the same managed set mv wrote: NEW appears, OLD does not. try std.testing.expect(std.mem.indexOf(u8, st.stdout, "new") != null); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "old") == null); } test "cli mv §4.7.6 B-mv-7: mv then commit emits the four-token old-name F card, sorted under NEW" { 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 straddling file "mike" sits alphabetically between OLD ("alpha") and NEW // ("yankee"), so the sort order distinguishes current-name from old-name. try writeFile(io, tmp.dir, "alpha", "alpha bytes\n"); try writeFile(io, tmp.dir, "mike", "mike bytes\n"); try addManaged(gpa, io, tmp.dir, &.{ "alpha", "mike" }); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" }); defer freeResult(gpa, &mvres); try expectExit(mvres.term, 0); var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" }); defer freeResult(gpa, &rec); try expectExit(rec.term, 0); const child_hash = try checkinHash(rec.stdout); var mc = try parseCheckin(gpa, io, tmp.dir, child_hash); defer mc.deinit(); const f = findF(mc.m, "yankee") orelse return error.MissingFCard; // §4.7.6: NEW filename, an explicit `w` perm token (regular-file SHOULD-w so // old-name stays in the fourth field), OLD as the fourth (old-name) token, // and a `w` token yields regular-file behavior (contains no x/l). try std.testing.expectEqualStrings("yankee", f.filename); try std.testing.expectEqualStrings("w", f.perm.?); try std.testing.expectEqualStrings("alpha", f.old_name.?); try std.testing.expectEqual(artifact.FileMode.regular, f.mode); // Hash is over the moved (unchanged) bytes now at NEW (§4.7.1 artifact name). var namebuf: [64]u8 = undefined; const expected_hash = artifact.artifactName(.sha3_256, "alpha bytes\n", &namebuf); try std.testing.expectEqualStrings(expected_hash, f.hash.?); // §4.6 strict-ascending-by-current-filename: mike (straddle) sorts BEFORE // yankee — proving the entry sorts under NEW, not OLD ("alpha"). try std.testing.expectEqual(@as(usize, 2), mc.m.f.len); try std.testing.expectEqualStrings("mike", mc.m.f[0].filename); try std.testing.expectEqualStrings("yankee", mc.m.f[1].filename); } test "cli mv B-mv-8: the record is one-shot — a later commit emits NEW three-token" { 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, "alpha", "a\n"); try writeFile(io, tmp.dir, "mike", "m\n"); try addManaged(gpa, io, tmp.dir, &.{ "alpha", "mike" }); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" }); defer freeResult(gpa, &mvres); try expectExit(mvres.term, 0); var c1 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" }); defer freeResult(gpa, &c1); try expectExit(c1.term, 0); // A second commit with NO further mv (a change elsewhere forces a non-no-op). try writeFile(io, tmp.dir, "mike", "m edited\n"); var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "edit mike" }); defer freeResult(gpa, &c2); try expectExit(c2.term, 0); const h2 = try checkinHash(c2.stdout); var mc = try parseCheckin(gpa, io, tmp.dir, h2); defer mc.deinit(); const f = findF(mc.m, "yankee") orelse return error.MissingFCard; // The record was consumed + cleared by the first commit: NEW is now a plain // current-name card — no perm, no old-name (§4.7.6 optional positions omitted). try std.testing.expectEqual(@as(?[]const u8, null), f.perm); try std.testing.expectEqual(@as(?[]const u8, null), f.old_name); } test "cli mv B-mv-9: with no rename pending, a commit is byte-identical to a fresh non-mv repo" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: a plain add+commit, no mv anywhere. var ta = std.testing.tmpDir(.{}); defer ta.cleanup(); try initRepo(gpa, io, ta.dir); try writeFile(io, ta.dir, "f", "same bytes\n"); try addManaged(gpa, io, ta.dir, &.{"f"}); var ca = try runCommitEnv(gpa, io, ta.dir, .{ .user = "u" }, &.{ "commit", "-m", "msg" }); defer freeResult(gpa, &ca); try expectExit(ca.term, 0); const ha = try gpa.dupe(u8, try checkinHash(ca.stdout)); defer gpa.free(ha); // Repo B: identical inputs. Same pinned epoch/user ⇒ identical manifest bytes // ⇒ identical check-in hash, proving the no-rename F card is unchanged (three // tokens: `F <name> <hash>`), since the mv mechanism never touched this path. var tb = std.testing.tmpDir(.{}); defer tb.cleanup(); try initRepo(gpa, io, tb.dir); try writeFile(io, tb.dir, "f", "same bytes\n"); try addManaged(gpa, io, tb.dir, &.{"f"}); var cb = try runCommitEnv(gpa, io, tb.dir, .{ .user = "u" }, &.{ "commit", "-m", "msg" }); defer freeResult(gpa, &cb); try expectExit(cb.term, 0); const hb = try checkinHash(cb.stdout); try std.testing.expectEqualStrings(ha, hb); // And the F card really is three-token (no perm, no old-name). var mc = try parseCheckin(gpa, io, ta.dir, ha); defer mc.deinit(); const f = findF(mc.m, "f") orelse return error.MissingFCard; try std.testing.expectEqual(@as(?[]const u8, null), f.perm); try std.testing.expectEqual(@as(?[]const u8, null), f.old_name); } test "cli mv B-mv-10: an uncommitted edit to OLD is preserved and hashed at NEW" { 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, "alpha", "v1\n"); try addManaged(gpa, io, tmp.dir, &.{"alpha"}); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); // Edit OLD after the check-in, THEN rename it. try writeFile(io, tmp.dir, "alpha", "v2 edited\n"); var mvres = try runZit(gpa, io, tmp.dir, &.{ "mv", "alpha", "yankee" }); defer freeResult(gpa, &mvres); try expectExit(mvres.term, 0); var rec = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename" }); defer freeResult(gpa, &rec); try expectExit(rec.term, 0); const child_hash = try checkinHash(rec.stdout); var mc = try parseCheckin(gpa, io, tmp.dir, child_hash); defer mc.deinit(); const f = findF(mc.m, "yankee") orelse return error.MissingFCard; // The commit captures the CURRENT bytes at NEW (the edit is preserved), with // OLD as the old-name token — mv moves, it never reverts or snapshots content. var namebuf: [64]u8 = undefined; const expected_hash = artifact.artifactName(.sha3_256, "v2 edited\n", &namebuf); try std.testing.expectEqualStrings(expected_hash, f.hash.?); try std.testing.expectEqualStrings("alpha", f.old_name.?); try std.testing.expectEqualStrings("w", f.perm.?); } test "cli mv B-mv-11: mv OLD OLD is a refusal, not a self-clobber" { 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", "same\n"); try addManaged(gpa, io, tmp.dir, &.{"f"}); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "f", "f" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); // caught by the already-tracked / on-disk checks try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit mv: 'f' is already tracked\n", res.stderr); // Nothing changed: still tracked, file intact. try expectManaged(gpa, io, tmp.dir, &.{"f"}); const ff = try readFile(gpa, io, tmp.dir, "f"); defer gpa.free(ff); try std.testing.expectEqualStrings("same\n", ff); } test "cli mv B-mv-12: -h and --help print the verbatim help, exit 0, change nothing" { const gpa = std.testing.allocator; const io = std.testing.io; inline for (.{ "-h", "--help" }) |flag| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "a", "x\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); // -h/--help win even over otherwise-valid positionals (B-mv-12). var res = try runZit(gpa, io, tmp.dir, &.{ "mv", flag, "a", "b" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(mv_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // No mutation: 'a' still tracked and on disk, 'b' never created. try expectManaged(gpa, io, tmp.dir, &.{"a"}); try std.testing.expect(fileExists(io, tmp.dir, "a")); try std.testing.expect(!fileExists(io, tmp.dir, "b")); } } test "cli mv B-mv-13: an unknown option is a usage error, exit 2, stderr only" { const gpa = std.testing.allocator; const io = std.testing.io; inline for (.{ &[_][]const u8{ "mv", "--force", "a", "b" }, &[_][]const u8{ "mv", "-f", "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, 2); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unknown option '" ++ argv[1] ++ "'") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "try 'zit mv --help'") != null); } } test "cli mv B-mv-14: the wrong operand count is a usage error, exit 2, no mutation" { const gpa = std.testing.allocator; const io = std.testing.io; inline for (.{ &[_][]const u8{"mv"}, // zero operands &[_][]const u8{ "mv", "a" }, // one operand &[_][]const u8{ "mv", "a", "b", "c" }, // three operands }) |argv| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "a", "x\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); 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.expect(std.mem.indexOf(u8, res.stderr, "try 'zit mv --help'") != null); // No mutation regardless of which count failed. try expectManaged(gpa, io, tmp.dir, &.{"a"}); } } test "cli mv B-mv-15: outside a repository is an expected failure, exit 1" { 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, &.{ "mv", "a", "b" }); 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 mv B-mv-16: a corrupt .zit-managed fails cleanly before any move, 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", "x\n"); { 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, &.{ "mv", "a", "b" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit mv: cannot read tracked files: .zit-managed is corrupt\n", res.stderr, ); // Nothing moved: source intact, dest never created. try std.testing.expect(fileExists(io, tmp.dir, "a")); try std.testing.expect(!fileExists(io, tmp.dir, "b")); } test "cli mv B-mv-17: streams stay separated under redirection (success vs refusal)" { 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", "x\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); // Success: the confirmation goes to stdout only; stderr stays empty. var ok = try runShell(gpa, io, tmp.dir, "\"$ZIT\" mv a b > out.txt 2> err.txt"); defer freeResult(gpa, &ok); try expectExit(ok.term, 0); const ok_out = try readFile(gpa, io, tmp.dir, "out.txt"); defer gpa.free(ok_out); const ok_err = try readFile(gpa, io, tmp.dir, "err.txt"); defer gpa.free(ok_err); try std.testing.expectEqualStrings("renamed a -> b\n", ok_out); try std.testing.expectEqualStrings("", ok_err); // Refusal: the diagnostic goes to stderr only; stdout stays empty. var bad = try runShell(gpa, io, tmp.dir, "\"$ZIT\" mv missing dest > out2.txt 2> err2.txt"); defer freeResult(gpa, &bad); try expectExit(bad.term, 1); const bad_out = try readFile(gpa, io, tmp.dir, "out2.txt"); defer gpa.free(bad_out); const bad_err = try readFile(gpa, io, tmp.dir, "err2.txt"); defer gpa.free(bad_err); try std.testing.expectEqualStrings("", bad_out); try std.testing.expectEqualStrings("zit mv: 'missing' is not tracked\n", bad_err); } // ========================================================================== // `zit merge` end-to-end tests (contracts/merge.md B-merge-1..29). Each spawns // the REAL binary in an isolated, pinned environment and builds its DAG through // real check-ins (commit → update → commit forks a diamond), then drives // `zit merge` and asserts exit code + stdout/stderr + on-disk state read back // through the library. Cases that need the commit P-card extension (the next, // separate component) — B-merge-8/9/10/11/12 — are deferred to that slice. // ========================================================================== /// The verbatim `zit merge` help — must match `main.zig`'s `merge_help` /// byte-for-byte (drift fails B-merge-25). const merge_help_expected = \\usage: zit merge NAME \\ \\Merge the check-in NAME into the working tree with a three-way merge and \\record it as a pending merge parent. NAME resolves like 'zit info' — a full \\or abbreviated artifact ID, a branch, or a tag. This does not create a \\check-in; run 'zit commit' to record the merge. Conflicts are written into \\the working files with conflict markers and listed on stderr — resolve them \\and commit, or run 'zit undo' to abandon the merge. \\ \\options: \\ -h, --help show this help and exit \\ ; /// Commits the current working tree (message `msg`, pinned author/epoch), asserts /// exit 0, and returns the new check-in's 64-hex hash copied onto `gpa`. fn commitHashDup(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, msg: []const u8) ![]const u8 { var res = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", msg }); defer freeResult(gpa, &res); try expectExit(res.term, 0); const h = try checkinHash(res.stdout); return gpa.dupe(u8, h); } const MergeDiamond = struct { base: []const u8, other: []const u8, current: []const u8, fn deinit(self: *MergeDiamond, gpa: std.mem.Allocator) void { gpa.free(self.base); gpa.free(self.other); gpa.free(self.current); } }; /// Builds a diamond over one file `poem`: root(base_content) → other(other_content) /// on trunk, then `update base` and commit current(current_content). The two /// leaves share `base` as their §3.3 merge base. On return the checkout sits at /// `current` on a clean trunk with `poem` == current_content — ready to merge. fn buildMergeDiamond( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, base_content: []const u8, other_content: []const u8, current_content: []const u8, ) !MergeDiamond { try initRepo(gpa, io, dir); try writeFile(io, dir, "poem", base_content); try addManaged(gpa, io, dir, &.{"poem"}); const base = try commitHashDup(gpa, io, dir, "root"); errdefer gpa.free(base); try writeFile(io, dir, "poem", other_content); const other = try commitHashDup(gpa, io, dir, "other"); errdefer gpa.free(other); var up = try runZit(gpa, io, dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, dir, "poem", current_content); const current = try commitHashDup(gpa, io, dir, "current"); return .{ .base = base, .other = other, .current = current }; } /// Reads `.zit-checkout` and asserts its version and merge-parent fields. fn expectMergeState(io: std.Io, dir: std.Io.Dir, version: []const u8, merge_parent: []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(merge_parent, ck.merge_parent); } test "cli merge B-merge-1/-2/-3: clean disjoint merge lands both sides, records the parent, mints no check-in" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Disjoint edits: current changed line 1, other changed line 3. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); // B-merge-2 oracle: `.zit` is byte-identical across the merge — no blob and // no manifest is appended, so the merge is provably not a check-in. const zit_before = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_before); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // B-merge-1: the three-way result lands byte-for-byte, exit 0, one line to // stdout naming the other side and the changed-file count; stderr empty. const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A1\nB\nC1\n", poem); var line_buf: [256]u8 = undefined; const want_line = try std.fmt.bufPrint(&line_buf, "merged {s} into the working tree (1 file(s) changed)\n", .{fx.other}); try std.testing.expectEqualStrings(want_line, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // B-merge-1: the merge parent is recorded; the version is unchanged (no // advance — merge does not mint). try expectMergeState(io, tmp.dir, fx.current, fx.other); // B-merge-2: `.zit` unchanged. const zit_after = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_after); try std.testing.expectEqualSlices(u8, zit_before, zit_after); // B-merge-3: the undo journal is armed (present after the merge). try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo")); } test "cli merge B-merge-5: a disjoint add/delete file set merges from the §8.2 walk" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Root with two files. Other adds `their`; current adds `ours`. The merge // must union both additions (each added on exactly one side). try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "shared", "base\n"); try addManaged(gpa, io, tmp.dir, &.{"shared"}); const base = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(base); try writeFile(io, tmp.dir, "their", "theirs\n"); try addManaged(gpa, io, tmp.dir, &.{"their"}); const other = try commitHashDup(gpa, io, tmp.dir, "other-adds"); defer gpa.free(other); var up = try runZit(gpa, io, tmp.dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "ours", "ours\n"); try addManaged(gpa, io, tmp.dir, &.{"ours"}); const current = try commitHashDup(gpa, io, tmp.dir, "current-adds"); defer gpa.free(current); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", other }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // `their` materialized from the other side; `ours`/`shared` retained. const their = try readFile(gpa, io, tmp.dir, "their"); defer gpa.free(their); try std.testing.expectEqualStrings("theirs\n", their); try std.testing.expect(fileExists(io, tmp.dir, "ours")); try std.testing.expect(fileExists(io, tmp.dir, "shared")); try expectMergeState(io, tmp.dir, current, other); } test "cli merge B-merge-7: an overlapping edit conflicts — markers in-tree, path on stderr, exit 1, parent recorded" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Both sides change the SAME line differently → a content conflict. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n"); defer fx.deinit(gpa); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 1); // Conflict surfacing: stdout empty; each conflicted path plus a summary on // stderr. try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit merge: conflict in poem\n") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 conflict(s); resolve and commit, or run 'zit undo'") != null); // The file carries fossil-style diff3 markers. const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expect(std.mem.indexOf(u8, poem, "<<<<<<< current\n") != null); try std.testing.expect(std.mem.indexOf(u8, poem, "||||||| base\n") != null); try std.testing.expect(std.mem.indexOf(u8, poem, "=======\n") != null); try std.testing.expect(std.mem.indexOf(u8, poem, ">>>>>>> other\n") != null); // The merge is NOT abandoned — the parent is recorded despite the conflict. try expectMergeState(io, tmp.dir, fx.current, fx.other); } test "cli merge B-merge-19: delete-vs-modify is a whole-file conflict" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Root has `poem`. Other modifies it; current deletes it (rm + commit). try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const base = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(base); try writeFile(io, tmp.dir, "poem", "A\nB\nMODIFIED\n"); const other = try commitHashDup(gpa, io, tmp.dir, "other-modifies"); defer gpa.free(other); var up = try runZit(gpa, io, tmp.dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); var rmres = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "poem" }); defer freeResult(gpa, &rmres); try expectExit(rmres.term, 0); const current = try commitHashDup(gpa, io, tmp.dir, "current-deletes"); defer gpa.free(current); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", other }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit merge: conflict in poem\n") != null); // The conflicting path is written with markers (never silently resolved). const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expect(std.mem.indexOf(u8, poem, "<<<<<<< current\n") != null); try expectMergeState(io, tmp.dir, current, other); } test "cli commit B-merge-8/-9/-11/-12: a merge commit mints the two-token P card, clears it, and round-trips" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A clean disjoint diamond → the merge is conflict-free, so `zit commit` // records the merge check-in. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &mg); try expectExit(mg.term, 0); try expectMergeState(io, tmp.dir, fx.current, fx.other); // B-merge-8: exactly one P card, two tokens — the pre-merge checkout (the // primary ancestor) first, the merge parent second. const merge_commit = try commitHashDup(gpa, io, tmp.dir, "merge poem"); defer gpa.free(merge_commit); var stored = try parseCheckin(gpa, io, tmp.dir, merge_commit); defer stored.deinit(); var pcard_buf: [160]u8 = undefined; const want_pcard = try std.fmt.bufPrint(&pcard_buf, "P {s} {s}\n", .{ fx.current, fx.other }); try std.testing.expect(std.mem.indexOf(u8, stored.bytes, want_pcard) != null); try std.testing.expectEqual(@as(usize, 1), stored.m.p.len); try std.testing.expectEqual(@as(usize, 2), stored.m.p[0].count); // B-merge-11: a baseline manifest — no B card — whose F set is the full // reconstructed file set (here the single merged file), never a delta. try std.testing.expect(stored.m.b == null); try std.testing.expectEqual(@as(usize, 1), stored.m.f.len); try std.testing.expectEqualStrings("poem", stored.m.f[0].filename); // B-merge-9: the commit consumes the pending merge — the checkout advances to // the merge check-in with the merge parent cleared (one-shot). try expectMergeState(io, tmp.dir, merge_commit, ""); // B-merge-12: `zit info` renders the merge parent through the existing // `renderMergeParents` path (a `merge:` line naming the other side), with the // pre-merge checkout still shown as the primary `parent:` line. var info = try runZit(gpa, io, tmp.dir, &.{ "info", merge_commit }); defer freeResult(gpa, &info); try expectExit(info.term, 0); var pline: [160]u8 = undefined; const want_parent_line = try std.fmt.bufPrint(&pline, "parent: {s}\n", .{fx.current}); try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_parent_line) != null); var mline: [160]u8 = undefined; const want_merge_line = try std.fmt.bufPrint(&mline, "merge: {s}\n", .{fx.other}); try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_merge_line) != null); // B-merge-9: a second commit with no fresh `zit merge` is an ordinary // single-parent check-in — the two-token path does not persist. try writeFile(io, tmp.dir, "poem", "A1\nB\nC1\nD\n"); const next = try commitHashDup(gpa, io, tmp.dir, "follow-up"); defer gpa.free(next); var stored2 = try parseCheckin(gpa, io, tmp.dir, next); defer stored2.deinit(); try std.testing.expectEqual(@as(usize, 1), stored2.m.p.len); try std.testing.expectEqual(@as(usize, 1), stored2.m.p[0].count); try std.testing.expectEqualStrings(merge_commit, stored2.m.p[0].parents.?); } test "cli commit B-merge-10: a merge with unresolved conflict markers refuses, exit 1, mints nothing, keeps the merge pending" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Overlapping edits → a content conflict; markers land in `poem`. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n"); defer fx.deinit(gpa); var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &mg); try expectExit(mg.term, 1); // a conflict merge; the parent is recorded anyway // The commit refuses while the marker is still in the working file. var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "try merge" }); 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, "zit commit: unresolved conflict in poem\n") != null); // Nothing minted: the checkout is un-advanced and the merge stays pending. try expectMergeState(io, tmp.dir, fx.current, fx.other); // Resolving the file (markers removed) lets the same commit through, and it // still records the two-token merge P card and clears the pending merge. try writeFile(io, tmp.dir, "poem", "A\nRESOLVED\nC\n"); const resolved = try commitHashDup(gpa, io, tmp.dir, "resolved merge"); defer gpa.free(resolved); var stored = try parseCheckin(gpa, io, tmp.dir, resolved); defer stored.deinit(); try std.testing.expectEqual(@as(usize, 1), stored.m.p.len); try std.testing.expectEqual(@as(usize, 2), stored.m.p[0].count); try expectMergeState(io, tmp.dir, resolved, ""); } test "cli commit B-merge-9 negative space: a non-merge commit is byte-identical across independent repos (no P-card regression)" { const gpa = std.testing.allocator; const io = std.testing.io; // Two independent repos built through the SAME pinned-epoch, non-merge // sequence must produce byte-for-byte identical child manifests, and each // child carries a single-token P card — proof the merge extension never // touches a commit with no pending merge. const build = struct { fn run(g: std.mem.Allocator, ioo: std.Io, dir: std.Io.Dir) ![]u8 { try initRepo(g, ioo, dir); try writeFile(ioo, dir, "poem", "A\nB\nC\n"); try addManaged(g, ioo, dir, &.{"poem"}); var r0 = try runCommitEnv(g, ioo, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "root" }); defer freeResult(g, &r0); try expectExit(r0.term, 0); try writeFile(ioo, dir, "poem", "A\nB\nC1\n"); var r1 = try runCommitEnv(g, ioo, dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "child" }); defer freeResult(g, &r1); try expectExit(r1.term, 0); const h = try checkinHash(r1.stdout); var stored = try parseCheckin(g, ioo, dir, h); defer stored.deinit(); try std.testing.expectEqual(@as(usize, 1), stored.m.p.len); try std.testing.expectEqual(@as(usize, 1), stored.m.p[0].count); return g.dupe(u8, stored.bytes); } }; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); var tmp_b = std.testing.tmpDir(.{}); defer tmp_b.cleanup(); const a = try build.run(gpa, io, tmp_a.dir); defer gpa.free(a); const b = try build.run(gpa, io, tmp_b.dir); defer gpa.free(b); try std.testing.expectEqualSlices(u8, a, b); } test "cli merge B-merge-13: undo restores the pre-merge files and clears the merge parent" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n"); defer fx.deinit(gpa); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 1); // a conflict merge, but still recorded var undo = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &undo); try expectExit(undo.term, 0); // The working file is back to the pre-merge current content, and the merge // parent is cleared — the tree is indistinguishable from before the merge. const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A\nX\nC\n", poem); try expectMergeState(io, tmp.dir, fx.current, ""); } test "cli merge B-merge-14: a dirty working tree refuses, exit 1, nothing mutated" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); // An uncommitted edit makes the tree dirty. try writeFile(io, tmp.dir, "poem", "A1\nB\nC\nDIRTY\n"); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit merge: working tree is not clean\n", res.stderr); // Nothing mutated: no pending merge, and the dirty bytes are untouched. try expectMergeState(io, tmp.dir, fx.current, ""); const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A1\nB\nC\nDIRTY\n", poem); } test "cli merge B-merge-15: a merge already pending refuses, exit 1, mutating nothing" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n"); defer fx.deinit(gpa); var first = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &first); try expectExit(first.term, 1); // records the pending merge (conflict) var second = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &second); try expectExit(second.term, 1); try std.testing.expectEqualStrings("", second.stdout); try std.testing.expectEqualStrings( "zit merge: a merge is already pending; resolve and commit it, or run 'zit undo'\n", second.stderr, ); // Still the first merge's recorded parent — the second refusal changed nothing. try expectMergeState(io, tmp.dir, fx.current, fx.other); } test "cli merge B-merge-16: NAME is the checkout or an ancestor is up to date, exit 1" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); // base is a proper ancestor of the current checkout. var anc = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.base }); defer freeResult(gpa, &anc); try expectExit(anc.term, 1); try std.testing.expectEqualStrings("", anc.stdout); try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", anc.stderr); try expectMergeState(io, tmp.dir, fx.current, ""); // The checkout itself is trivially up to date. var self = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.current }); defer freeResult(gpa, &self); try expectExit(self.term, 1); try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", self.stderr); } test "cli merge B-merge-16: a proper ancestor the merge-base mtime rule skips is still up to date, exit 1" { // The bug the `base == other` proxy missed: a linear chain root <- mid <- tip // whose event.mtimes are NON-monotone in DAG order (root carries the greatest // mtime). Merging `mid` — a proper ancestor of the checkout `tip` — must be // refused. But `mergeBaseOf(tip, mid)` selects the greatest-mtime common // ancestor, which is `root`, NOT `mid`; so `base == other` (mid) is false and // the old refusal wrongly let the merge proceed, recording an ancestor as a // merge parent. The mtime-independent `isAncestorOf` gate catches it. 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, "poem", "A\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); // root: the LATEST mtime, so it wins mergeBaseOf's greatest-mtime selection. var c_root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &c_root); try expectExit(c_root.term, 0); try writeFile(io, tmp.dir, "poem", "A\nB\n"); // mid: an EARLIER mtime than its own parent — a proper ancestor of tip that // the merge-base walk therefore never selects. var c_mid = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "1000000000" }, &.{ "commit", "-m", "mid" }); defer freeResult(gpa, &c_mid); try expectExit(c_mid.term, 0); const mid = try gpa.dupe(u8, try checkinHash(c_mid.stdout)); defer gpa.free(mid); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); var c_tip = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "3000000000" }, &.{ "commit", "-m", "tip" }); defer freeResult(gpa, &c_tip); try expectExit(c_tip.term, 0); const tip = try gpa.dupe(u8, try checkinHash(c_tip.stdout)); defer gpa.free(tip); // Merge the proper ancestor `mid`: up to date, refuse, mutate nothing. var res = try runZit(gpa, io, tmp.dir, &.{ "merge", mid }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit merge: already up to date; nothing to merge\n", res.stderr); // No merge parent recorded; checkout still at tip; working tree untouched. try expectMergeState(io, tmp.dir, tip, ""); const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A\nB\nC\n", poem); } test "cli merge B-merge-18: an unresolvable NAME fails with the resolver diagnostic, exit 1" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); const absent = "deadbeef" ** 8; // 64 hex, no such check-in var res = try runZit(gpa, io, tmp.dir, &.{ "merge", absent }); 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); // Nothing mutated. try expectMergeState(io, tmp.dir, fx.current, ""); } test "cli merge B-merge-25: -h and --help print the verbatim help, exit 0, change 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); for ([_][]const u8{ "-h", "--help" }) |flag| { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", flag }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(merge_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // help wins even over an otherwise-invalid trailing token. var win = try runZit(gpa, io, tmp.dir, &.{ "merge", "--help", "-z" }); defer freeResult(gpa, &win); try expectExit(win.term, 0); try std.testing.expectEqualStrings(merge_help_expected, win.stdout); } test "cli merge B-merge-26: a missing NAME, an unknown option, or a second positional 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); const cases = [_][]const []const u8{ &.{"merge"}, // missing NAME &.{ "merge", "-x" }, // unknown option &.{ "merge", "--force" }, // no override flag exists &.{ "merge", "a", "b" }, // second positional }; for (cases) |argv| { 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.expect(std.mem.indexOf(u8, res.stderr, "try 'zit merge --help'") != null); } } test "cli merge B-merge-27: outside a repository fails with the not-a-repo diagnostic, exit 1" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); // NOT initialized defer tmp.cleanup(); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "trunk" }); 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 merge B-merge-28/-29: streams stay separated and the verb is wired into help" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // B-merge-29: the top-level help names the merge verb. { 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, "\n merge ") != null); } // B-merge-28: a clean merge writes the confirmation to stdout only. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A1\nB\nC\n"); defer fx.deinit(gpa); { var script_buf: [128]u8 = undefined; const script = try std.fmt.bufPrint(&script_buf, "\"$ZIT\" merge {s} > out.txt 2> err.txt", .{fx.other}); var ok = try runShell(gpa, io, tmp.dir, script); defer freeResult(gpa, &ok); try expectExit(ok.term, 0); const out = try readFile(gpa, io, tmp.dir, "out.txt"); defer gpa.free(out); const err = try readFile(gpa, io, tmp.dir, "err.txt"); defer gpa.free(err); try std.testing.expect(std.mem.startsWith(u8, out, "merged ")); try std.testing.expectEqualStrings("", err); } // B-merge-28: a conflict merge writes diagnostics to stderr only. Fresh repo. var tmp2 = std.testing.tmpDir(.{}); defer tmp2.cleanup(); var fx2 = try buildMergeDiamond(gpa, io, tmp2.dir, "A\nB\nC\n", "A\nY\nC\n", "A\nX\nC\n"); defer fx2.deinit(gpa); { var script_buf: [128]u8 = undefined; const script = try std.fmt.bufPrint(&script_buf, "\"$ZIT\" merge {s} > out.txt 2> err.txt", .{fx2.other}); var bad = try runShell(gpa, io, tmp2.dir, script); defer freeResult(gpa, &bad); try expectExit(bad.term, 1); const out = try readFile(gpa, io, tmp2.dir, "out.txt"); defer gpa.free(out); const err = try readFile(gpa, io, tmp2.dir, "err.txt"); defer gpa.free(err); try std.testing.expectEqualStrings("", out); try std.testing.expect(std.mem.indexOf(u8, err, "zit merge: conflict in poem\n") != 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, .merge_parent = "" }); } /// 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", .merge_parent = "" }); 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); } // ========================================================================== // `zit undo` / `zit redo` — single-level undo/redo (contracts/undo.md). The // real binary is driven end to end; state is read back through the library. // ========================================================================== const undo_help_text = \\usage: zit undo [-h|--help] \\ \\Undo the last working-file change made by 'zit update' or 'zit revert', \\restoring every affected file and the checkout record to their state before \\that change. Single level: only the most recent change can be undone; a second \\'zit undo' in a row has nothing left to undo. \\ \\Use 'zit redo' to reapply a change you just undid. A new update or revert \\clears the redo slot. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the change was undone \\ 1 nothing to undo, the journal is damaged, or this is not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; const redo_help_text = \\usage: zit redo [-h|--help] \\ \\Reapply the change you just undid with 'zit undo', restoring every affected \\file and the checkout record to their state after that change. Single level: \\redo is available only immediately after an undo; a new update or revert \\clears it. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the change was reapplied \\ 1 nothing to redo, the journal is damaged, or this is not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; /// Commits `content` for `name` and returns the resulting check-in hash borrowed /// from `commit_out` (kept alive by the caller). Two calls build a root→child /// history the undo tests reverse. fn commitFile( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8, message: []const u8, commit_out: *std.process.RunResult, ) ![]const u8 { try writeFile(io, dir, name, content); try addManaged(gpa, io, dir, &.{name}); commit_out.* = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", message }); try expectExit(commit_out.term, 0); return checkinHash(commit_out.stdout); } test "cli undo B-undo-1: update then undo restores working files and .zit-checkout 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); var root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); // Pre-update state (the child checkout): the exact bytes undo must restore. const before_content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(before_content); const before_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(before_checkout); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); try expectCheckoutVersion(io, tmp.dir, root); var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); try std.testing.expectEqualStrings("undo: working tree restored\n", un.stdout); try std.testing.expectEqualStrings("", un.stderr); const after_content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(after_content); const after_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(after_checkout); try std.testing.expectEqualStrings("later\n", after_content); try std.testing.expectEqualSlices(u8, before_content, after_content); try std.testing.expectEqualSlices(u8, before_checkout, after_checkout); } test "cli undo B-undo-2: revert then undo recovers the discarded working bytes" { 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 c: std.process.RunResult = undefined; defer freeResult(gpa, &c); _ = try commitFile(gpa, io, tmp.dir, "f.txt", "v1\n", "v1", &c); // A dirty working edit revert will discard. try writeFile(io, tmp.dir, "f.txt", "edited\n"); var rev = try runZit(gpa, io, tmp.dir, &.{"revert"}); defer freeResult(gpa, &rev); try expectExit(rev.term, 0); { const restored = try readFile(gpa, io, tmp.dir, "f.txt"); defer gpa.free(restored); try std.testing.expectEqualStrings("v1\n", restored); // revert restored the blob } var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); try std.testing.expectEqualStrings("undo: working tree restored\n", un.stdout); const recovered = try readFile(gpa, io, tmp.dir, "f.txt"); defer gpa.free(recovered); try std.testing.expectEqualStrings("edited\n", recovered); // discarded edit recovered } test "cli redo B-undo-4/5: update then undo then redo returns to the post-update state" { 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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); // Post-update state: tracked.txt == "earlier\n", checkout == root. const post_content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(post_content); const post_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(post_checkout); var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); try expectCheckoutVersion(io, tmp.dir, try checkinHash(child_c.stdout)); var re = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &re); try expectExit(re.term, 0); try std.testing.expectEqualStrings("redo: working tree restored\n", re.stdout); try std.testing.expectEqualStrings("", re.stderr); const back_content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(back_content); const back_checkout = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(back_checkout); try std.testing.expectEqualStrings("earlier\n", back_content); try std.testing.expectEqualSlices(u8, post_content, back_content); try std.testing.expectEqualSlices(u8, post_checkout, back_checkout); } test "cli undo B-undo-6: a second consecutive undo is exit 1, mutating 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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); var un1 = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un1); try expectExit(un1.term, 0); const after_first = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(after_first); var un2 = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un2); try expectExit(un2.term, 1); try std.testing.expectEqualStrings("", un2.stdout); try std.testing.expectEqualStrings("zit undo: nothing to undo\n", un2.stderr); const after_second = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(after_second); try std.testing.expectEqualSlices(u8, after_first, after_second); // no mutation } test "cli redo B-undo-7: a new update after undo invalidates redo (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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); const child = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd_a = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd_a); try expectExit(upd_a.term, 0); var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); // A second sheltered mutation clears the redo slot the undo populated. var upd_b = try runZit(gpa, io, tmp.dir, &.{ "update", child }); defer freeResult(gpa, &upd_b); try expectExit(upd_b.term, 0); var re = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &re); try expectExit(re.term, 1); try std.testing.expectEqualStrings("", re.stdout); try std.testing.expectEqualStrings("zit redo: nothing to redo\n", re.stderr); } test "cli undo B-undo-8: with two updates, undo restores the last one's pre-image, not the earlier" { 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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); const child = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); // update A (-> root/"earlier"), then update B (-> child/"later"). var ua = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &ua); try expectExit(ua.term, 0); var ub = try runZit(gpa, io, tmp.dir, &.{ "update", child }); defer freeResult(gpa, &ub); try expectExit(ub.term, 0); // undo restores B's pre-image (the post-A tree: "earlier"/root), NOT A's. var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); const content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(content); try std.testing.expectEqualStrings("earlier\n", content); try expectCheckoutVersion(io, tmp.dir, root); } test "cli undo B-undo-9: an empty journal is exit 1, stdout empty, no 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); var res = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit undo: nothing to undo\n", res.stderr); try std.testing.expect(!fileExists(io, tmp.dir, ".zit-undo")); } test "cli redo B-undo-10: no redo image is exit 1, stdout empty, no 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); var res = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit redo: nothing to redo\n", res.stderr); } test "cli undo B-undo-11: a corrupt .zit-undo is exit 1 with no partial 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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); const before = try readFile(gpa, io, tmp.dir, "tracked.txt"); // "earlier\n" defer gpa.free(before); // Overwrite the journal with garbage. try writeFile(io, tmp.dir, ".zit-undo", "not a real journal"); var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 1); try std.testing.expectEqualStrings("", un.stdout); try std.testing.expectEqualStrings("zit undo: the undo journal is damaged\n", un.stderr); const after = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); // byte-identical, no partial restore } test "cli undo B-undo-13: 'zit add .zit-undo' refuses it as a control 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); var res = try runZit(gpa, io, tmp.dir, &.{ "add", ".zit-undo" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit add: '.zit-undo' is a zit control file and cannot be tracked\n", res.stderr, ); } test "cli undo B-undo-14: update leaves its output/exit unchanged and .zit-undo is the only new side-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); var root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); try std.testing.expectEqualStrings("", upd.stdout); // update's happy path is silent, as today try std.testing.expectEqualStrings("", upd.stderr); // The one new observable: the journal exists, and update never named it. try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo")); try std.testing.expect(std.mem.indexOf(u8, upd.stdout, ".zit-undo") == null); try std.testing.expect(std.mem.indexOf(u8, upd.stderr, ".zit-undo") == null); } test "cli undo B-undo-15: update with no undo invoked reproduces today's tree and 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); var root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); // Exactly today's observable result (matches the pre-journal update e2e). const content = try readFile(gpa, io, tmp.dir, "tracked.txt"); defer gpa.free(content); try std.testing.expectEqualStrings("earlier\n", content); try expectCheckoutVersion(io, tmp.dir, root); try expectManaged(gpa, io, tmp.dir, &.{"tracked.txt"}); } test "cli undo B-undo-16: undo/redo -h/--help print the verbatim help to stdout, exit 0" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const cases = [_]struct { verb: []const u8, text: []const u8 }{ .{ .verb = "undo", .text = undo_help_text }, .{ .verb = "redo", .text = redo_help_text }, }; for (cases) |case| { for ([_][]const u8{ "--help", "-h" }) |flag| { var res = try runZit(gpa, io, tmp.dir, &.{ case.verb, flag }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(case.text, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // `-h` wins over an otherwise-invalid trailing token. var win = try runZit(gpa, io, tmp.dir, &.{ case.verb, "-h", "bogus" }); defer freeResult(gpa, &win); try expectExit(win.term, 0); try std.testing.expectEqualStrings(case.text, win.stdout); // `zit help <verb>` teaches identical text (DX D4). var routed = try runZit(gpa, io, tmp.dir, &.{ "help", case.verb }); defer freeResult(gpa, &routed); try expectExit(routed.term, 0); try std.testing.expectEqualStrings(case.text, routed.stdout); } } test "cli undo B-undo-17: an unknown option is a usage error (exit 2, stderr try-help, no journal read)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const cases = [_]struct { verb: []const u8, arg: []const u8 }{ .{ .verb = "undo", .arg = "--force" }, .{ .verb = "redo", .arg = "-x" }, }; for (cases) |case| { var res = try runZit(gpa, io, tmp.dir, &.{ case.verb, case.arg }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); var buf: [128]u8 = undefined; const expected = try std.fmt.bufPrint(&buf, "zit {s}: unknown option '{s}'\ntry 'zit {s} --help'\n", .{ case.verb, case.arg, case.verb }); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli undo B-undo-18: a positional or token after '--' is a usage error (exit 2)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A bare positional, and a token after `--`, both reject with the try-help line. const cases = [_]struct { verb: []const u8, args: []const []const u8, tok: []const u8 }{ .{ .verb = "undo", .args = &.{ "undo", "foo" }, .tok = "foo" }, .{ .verb = "redo", .args = &.{ "redo", "--", "3" }, .tok = "3" }, }; for (cases) |case| { var res = try runZit(gpa, io, tmp.dir, case.args); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); var buf: [128]u8 = undefined; const expected = try std.fmt.bufPrint(&buf, "zit {s}: unexpected argument '{s}'\ntry 'zit {s} --help'\n", .{ case.verb, case.tok, case.verb }); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli undo B-undo-19: outside a repository is exit 1, stdout empty" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); for ([_][]const u8{ "undo", "redo" }) |verb| { var res = try runZit(gpa, io, tmp.dir, &.{verb}); 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 undo B-undo-20: a redirected undo keeps stdout and stderr unmixed" { 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 root_c: std.process.RunResult = undefined; defer freeResult(gpa, &root_c); const root = try commitFile(gpa, io, tmp.dir, "tracked.txt", "earlier\n", "earlier", &root_c); var child_c: std.process.RunResult = undefined; defer freeResult(gpa, &child_c); _ = try commitFile(gpa, io, tmp.dir, "tracked.txt", "later\n", "later", &child_c); var upd = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &upd); try expectExit(upd.term, 0); // Success row to stdout only; the failed second undo's diagnostic to stderr // only — the two files never cross-contaminate. var sh = try runShell(gpa, io, tmp.dir, "\"$ZIT\" undo >out.txt 2>err.txt; \"$ZIT\" undo >out2.txt 2>err2.txt"); defer freeResult(gpa, &sh); const out = try readFile(gpa, io, tmp.dir, "out.txt"); defer gpa.free(out); const err = try readFile(gpa, io, tmp.dir, "err.txt"); defer gpa.free(err); try std.testing.expectEqualStrings("undo: working tree restored\n", out); try std.testing.expectEqualStrings("", err); const out2 = try readFile(gpa, io, tmp.dir, "out2.txt"); defer gpa.free(out2); const err2 = try readFile(gpa, io, tmp.dir, "err2.txt"); defer gpa.free(err2); try std.testing.expectEqualStrings("", out2); try std.testing.expectEqualStrings("zit undo: nothing to undo\n", err2); } test "cli undo B-undo-21: undo and redo are listed in the top-level help" { 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, &.{"help"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\n undo undo the last update or revert\n") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\n redo reapply an undone change\n") != 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 timeline` end-to-end (repo-state §2/§3.2/§6.1/§6.4/§8.3 + CLI // discipline). The global chronological history: every check-in, event.mtime // descending, ties by full artifact hash. Each test spawns the REAL binary in // an isolated tmp dir. Behavior ids cite contracts/timeline.md B-timeline-*. // ========================================================================== // ISO §4.6.4 writer forms of the injected commit epochs (whole Unix seconds, // verified with `date -u`): the timeline date column derives from the stored // event.mtime, which is the injected epoch carried in milliseconds. const iso_epoch_900e6 = "1998-07-09T16:00:00"; // SOURCE_DATE_EPOCH=900000000 const iso_epoch_1e9 = "2001-09-09T01:46:40"; // SOURCE_DATE_EPOCH=1000000000 const iso_epoch_2e9 = "2033-05-18T03:33:20"; // SOURCE_DATE_EPOCH=2000000000 /// Writes `content` to `name`, optionally stages it (`stage` on the first /// commit of a path), then commits at `epoch` with `msg`. Returns the new /// check-in's 64-hex hash (caller frees) — the setup for the timeline ordering /// tests, which need distinct, injected mtimes. fn timelineCommit( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8, stage: bool, epoch: []const u8, msg: []const u8, ) ![]u8 { try writeFile(io, dir, name, content); if (stage) try addManaged(gpa, io, dir, &.{name}); var c = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = epoch }, &.{ "commit", "-m", msg }); defer freeResult(gpa, &c); try expectExit(c.term, 0); return gpa.dupe(u8, try checkinHash(c.stdout)); } test "cli timeline B-timeline-1: full listing, one line per check-in, 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); const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "900000000", "root"); defer gpa.free(root); const mid = try timelineCommit(gpa, io, tmp.dir, "a.txt", "mid\n", false, "1000000000", "mid"); defer gpa.free(mid); const tip = try timelineCommit(gpa, io, tmp.dir, "a.txt", "tip\n", false, "2000000000", "tip"); defer gpa.free(tip); var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); 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); // Every crosslinked check-in appears exactly once: three commits ⇒ three lines. try std.testing.expectEqual(@as(usize, 3), lineCount(res.stdout)); } test "cli timeline B-timeline-2: rows are ordered event.mtime descending, each dated from its mtime" { 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 timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "900000000", "root"); defer gpa.free(root); const mid = try timelineCommit(gpa, io, tmp.dir, "a.txt", "mid\n", false, "1000000000", "mid"); defer gpa.free(mid); const tip = try timelineCommit(gpa, io, tmp.dir, "a.txt", "tip\n", false, "2000000000", "tip"); defer gpa.free(tip); var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // §2/§8.3 item 7: most recent first — tip (2e9), mid (1e9), root (900e6) — // the whole-repo clock, not one check-in's ancestry (timeline's diff vs log). var want: [512]u8 = undefined; const expected = try std.fmt.bufPrint( &want, " {s} " ++ iso_epoch_2e9 ++ " trunk tip\n" ++ " {s} " ++ iso_epoch_1e9 ++ " trunk mid\n" ++ " {s} " ++ iso_epoch_900e6 ++ " trunk root\n", .{ tip[0..12], mid[0..12], root[0..12] }, ); try std.testing.expectEqualStrings(expected, res.stdout); } test "cli timeline B-timeline-3: exact-mtime ties break by ascending hash, byte-reproducible across runs" { 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 check-ins at the SAME epoch ⇒ equal event.mtime ⇒ the total order falls // back to ascending full artifact hash (deterministic, byte-reproducible). const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "1000000000", "root"); defer gpa.free(root); const child = try timelineCommit(gpa, io, tmp.dir, "a.txt", "child\n", false, "1000000000", "child"); defer gpa.free(child); var res1 = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res1); try expectExit(res1.term, 0); try std.testing.expectEqualStrings("", res1.stderr); var res2 = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res2); try expectExit(res2.term, 0); // Byte-reproducible: a second run yields identical bytes. try std.testing.expectEqualStrings(res1.stdout, res2.stdout); // Genuinely two distinct check-ins tied on mtime (non-vacuous comparison). try std.testing.expect(!std.mem.eql(u8, root, child)); const root_first = std.mem.order(u8, root, child) == .lt; var want: [512]u8 = undefined; const expected = if (root_first) try std.fmt.bufPrint( &want, " {s} " ++ iso_epoch_1e9 ++ " trunk root\n" ++ " {s} " ++ iso_epoch_1e9 ++ " trunk child\n", .{ root[0..12], child[0..12] }, ) else try std.fmt.bufPrint( &want, " {s} " ++ iso_epoch_1e9 ++ " trunk child\n" ++ " {s} " ++ iso_epoch_1e9 ++ " trunk root\n", .{ child[0..12], root[0..12] }, ); try std.testing.expectEqualStrings(expected, res1.stdout); } test "cli timeline B-timeline-4 (§6.1): the branch column is each check-in's own 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); const root = try timelineCommit(gpa, io, tmp.dir, "a.txt", "root\n", true, "1000000000", "root"); defer gpa.free(root); // A `--branch feature` fork lands the newer check-in on feature (§6.1/§6.2). try writeFile(io, tmp.dir, "a.txt", "two\n"); var c = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u", .epoch = "2000000000" }, &.{ "commit", "-m", "feature work", "--branch", "feature" }); defer freeResult(gpa, &c); try expectExit(c.term, 0); const feat = try gpa.dupe(u8, try checkinHash(c.stdout)); defer gpa.free(feat); var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); var want: [512]u8 = undefined; const expected = try std.fmt.bufPrint( &want, " {s} " ++ iso_epoch_2e9 ++ " feature feature work\n" ++ " {s} " ++ iso_epoch_1e9 ++ " trunk root\n", .{ feat[0..12], root[0..12] }, ); try std.testing.expectEqualStrings(expected, res.stdout); } test "cli timeline B-timeline-4 (§6.4): a tagless check-in shows the configured main-branch, never 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 res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); var want: [256]u8 = undefined; const expected = try std.fmt.bufPrint(&want, " {s} 2020-01-01T00:00:00 main tagless\n", .{id[0..12]}); try std.testing.expectEqualStrings(expected, res.stdout); // §6.4 refutation: the configured name is emitted, the literal never is. try std.testing.expect(std.mem.indexOf(u8, res.stdout, "trunk") == null); } test "cli timeline B-timeline-5: a merge check-in is marked M; primary-only check-ins are not" { 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 roots and a merge joining them (§3.2: base primary, other a merge // parent). Built by hand — a two-P-card manifest — since no `zit merge` exists. const base = try appendSyntheticCheckin(gpa, io, tmp.dir, "base", null); defer gpa.free(base); const other = try appendSyntheticCheckin(gpa, io, tmp.dir, "other", null); defer gpa.free(other); var pbuf: [160]u8 = undefined; const parents = try std.fmt.bufPrint(&pbuf, "{s} {s}", .{ base, other }); const merge = try appendSyntheticCheckin(gpa, io, tmp.dir, "merge", parents); defer gpa.free(merge); var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // Three check-ins, one line each (the merge is never folded into a parent). try std.testing.expectEqual(@as(usize, 3), lineCount(res.stdout)); // §3.2: the merge line (>=1 merge parent) carries "M "; the two roots carry " ". var mbuf: [160]u8 = undefined; const merge_line = try std.fmt.bufPrint(&mbuf, "M {s} 2020-01-01T00:00:00 trunk merge\n", .{merge[0..12]}); var bbuf: [160]u8 = undefined; const base_line = try std.fmt.bufPrint(&bbuf, " {s} 2020-01-01T00:00:00 trunk base\n", .{base[0..12]}); var obuf: [160]u8 = undefined; const other_line = try std.fmt.bufPrint(&obuf, " {s} 2020-01-01T00:00:00 trunk other\n", .{other[0..12]}); try std.testing.expect(std.mem.indexOf(u8, res.stdout, merge_line) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, base_line) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, other_line) != null); } test "cli timeline B-timeline-6: a multi-line comment contributes only its first line" { 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 id = try appendSyntheticCheckin(gpa, io, tmp.dir, "first line\nsecond line", null); defer gpa.free(id); var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // The embedded newline is cut: one line, first line only (invariant held). try std.testing.expectEqual(@as(usize, 1), lineCount(res.stdout)); var want: [256]u8 = undefined; const expected = try std.fmt.bufPrint(&want, " {s} 2020-01-01T00:00:00 trunk first line\n", .{id[0..12]}); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "second line") == null); } test "cli timeline B-timeline-7: an empty repository yields 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, &.{"timeline"}); defer freeResult(gpa, &res); // Emptiness is defined out of existence: a success with no rows, no diagnostic. 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 timeline B-timeline-8: -h/--help print the verbatim help to stdout, exit 0, help wins" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const expected = \\usage: zit timeline [-h|--help] \\ \\List every check-in in the repository, most recent first. Each line shows \\the check-in's short hash, its ISO timestamp, its branch, and the first \\line of its comment. A check-in that joins two or more lines of development \\is marked "M". Order is by event time descending, ties broken by artifact \\hash, so the listing is byte-identical across runs. \\ \\options: \\ -h, --help show this help and exit \\ ; // -h/--help win even over an otherwise-invalid trailing token. for ([_][]const []const u8{ &.{ "timeline", "-h" }, &.{ "timeline", "--help" }, &.{ "timeline", "-h", "foo" } }) |args| { var res = try runZit(gpa, io, tmp.dir, args); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // `zit help timeline` teaches the identical text (DX D4). var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "timeline" }); defer freeResult(gpa, &routed); try expectExit(routed.term, 0); try std.testing.expectEqualStrings(expected, routed.stdout); } test "cli timeline B-timeline-9: 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(); for ([_][]const u8{ "-n", "--bogus" }) |opt| { var res = try runZit(gpa, io, tmp.dir, &.{ "timeline", opt }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); var want: [160]u8 = undefined; const expected = try std.fmt.bufPrint(&want, "zit timeline: unknown option '{s}'\ntry 'zit timeline --help'\n", .{opt}); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli timeline B-timeline-10: 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(); // A bare positional and any token after `--` are operands timeline rejects. for ([_][]const []const u8{ &.{ "timeline", "foo" }, &.{ "timeline", "--", "foo" } }) |args| { var res = try runZit(gpa, io, tmp.dir, args); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit timeline: unexpected argument 'foo'\ntry 'zit timeline --help'\n", res.stderr); } } test "cli timeline B-timeline-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(); // Data-layer failure (exit 1), distinct from the exit-2 usage class. var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); 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 timeline B-timeline-12: `zit timeline >> log` appends (O_APPEND honored), rows to 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); const z = try appendSyntheticCheckin(gpa, io, tmp.dir, "z", null); defer gpa.free(z); // A streaming stdout writer honors the inherited O_APPEND offset: the row // lands after the pre-existing content, never clobbering it (cli-stream). var res = try runShell(gpa, io, tmp.dir, "printf 'PRE\\n' > log; \"$ZIT\" timeline >> log"); defer freeResult(gpa, &res); try expectExit(res.term, 0); const log = try readFile(gpa, io, tmp.dir, "log"); defer gpa.free(log); var want: [256]u8 = undefined; const expected = try std.fmt.bufPrint(&want, "PRE\n {s} 2020-01-01T00:00:00 trunk z\n", .{z[0..12]}); try std.testing.expectEqualStrings(expected, log); } test "cli timeline: 'timeline' is listed in the command help and suggested for a near-miss" { // cli-verb-enum-wiring: the verb joins the single-source Verb enum, so the // command list names it and the did-you-mean suggester finds it (mirror leaves). 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, "timeline") != null); var typo = try runZit(gpa, io, tmp.dir, &.{"timelin"}); defer freeResult(gpa, &typo); try expectExit(typo.term, 2); try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'timeline'?") != null); } // ========================================================================== // `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", .merge_parent = "" }); 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); } // ========================================================================== // `zit extras` (contracts/extras.md, B-extras-1..18). The sweep lists every // on-disk file the repo is not tracking, one sorted root-relative path per // line. Each test drives the real binary; expected bytes come from the clause // text and inline ASCII, never from running fossil. // ========================================================================== /// The verbatim `zit extras` help — an independent transcription of `main.zig`'s /// `extras_help` (B-extras-11), so a drift between the two is caught here. const extras_help_expected = \\usage: zit extras [-h|--help] \\ \\List every file in the checkout that zit is not tracking — each on-disk file \\under the checkout root that is neither a tracked file nor a zit control file. \\Paths are shown relative to the checkout root, one per line, sorted. Files in \\subdirectories are included; directories themselves are not. Symlinks are \\listed but never followed. With nothing untracked, the output is empty. \\ \\options: \\ -h, --help show this help and exit \\ ; test "cli extras B-extras-1: lists every untracked file once, sorted, 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); // Three untracked files in reverse-of-sorted creation order; `.zit` and // `.zit-checkout` (control) are present but never listed. for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); // ASCII ascending: apple < mango < zebra. Each appears exactly once. try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli extras B-extras-3: a nested file prints as sub/dir/name, no ./ or leading /" { 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.createDirPath(io, "sub/dir"); try touch(io, tmp.dir, "sub/dir/name"); var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("sub/dir/name\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli extras B-extras-4: a tracked file is not listed even when 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, "keep"); try touch(io, tmp.dir, "loose"); try addManaged(gpa, io, tmp.dir, &.{"keep"}); var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("loose\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli extras B-extras-10: an empty checkout yields 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); // only the control files exist var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli extras B-extras-11: -h and --help print verbatim help to stdout, exit 0, help wins over a trailing token" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Help wins even when an otherwise-invalid token trails it (B-extras-11). const invocations = [_][]const []const u8{ &.{ "extras", "--help" }, &.{ "extras", "-h" }, &.{ "extras", "--help", "foo" }, &.{ "extras", "-h", "--all" }, }; for (invocations) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(extras_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } } test "cli extras B-extras-12: 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(); try initRepo(gpa, io, tmp.dir); const cases = [_]struct { flag: []const u8 }{ .{ .flag = "--all" }, .{ .flag = "-0" } }; for (cases) |c| { var res = try runZit(gpa, io, tmp.dir, &.{ "extras", c.flag }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); const expected = try std.fmt.allocPrint( gpa, "zit extras: unknown option '{s}'\ntry 'zit extras --help'\n", .{c.flag}, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli extras B-extras-13: a positional argument is a usage error, exit 2, 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); // A bare operand and any token after `--` are both extra arguments. const invocations = [_][]const []const u8{ &.{ "extras", "foo" }, &.{ "extras", "--", "foo" }, }; for (invocations) |argv| { 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 extras: unexpected argument 'foo'\ntry 'zit extras --help'\n", res.stderr, ); } } test "cli extras B-extras-14: outside a repository, exit 1, not_a_repo on stderr, empty stdout" { 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, &.{"extras"}); 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 extras B-extras-15: a corrupt .zit-managed fails cleanly, 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, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit extras: cannot read tracked files: .zit-managed is corrupt\n", res.stderr, ); } test "cli extras B-extras-16: read-only — the tree is unchanged after a run" { 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, "one"); try tmp.dir.createDirPath(io, "d"); try touch(io, tmp.dir, "d/two"); var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("d/two\none\n", res.stdout); // extras writes no state and moves no working file (B-extras-16): the user // files remain, and none of the artifacts a mutator would create appeared. try std.testing.expect(fileExists(io, tmp.dir, "one")); try std.testing.expect(fileExists(io, tmp.dir, "d/two")); try std.testing.expect(!fileExists(io, tmp.dir, managed.filename)); try std.testing.expect(!fileExists(io, tmp.dir, ".zit-cache")); try std.testing.expect(!fileExists(io, tmp.dir, ".zit-pack-tmp")); try std.testing.expect(!fileExists(io, tmp.dir, ".zit-undo")); } test "cli extras B-extras-17: `zit extras >> log` appends, rows to stdout (O_APPEND honored)" { 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"); // The shell creates `log` (itself now an untracked file) BEFORE extras runs, // so the sweep lists both `g1` and `log`; appending must preserve the // pre-existing line. ASCII: `g1` < `log`. var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" extras >> 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\ng1\nlog\n", log); } test "cli extras B-extras-11: 'extras' is listed in the command help and suggested for a near-miss" { // The verb joins the single-source Verb enum, so the command list names it, // `zit help extras` routes to the verb's own verbatim help (B-extras-11), and // the did-you-mean suggester finds 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, "extras") != null); var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "extras" }); defer freeResult(gpa, &topic); try expectExit(topic.term, 0); try std.testing.expectEqualStrings(extras_help_expected, topic.stdout); var typo = try runZit(gpa, io, tmp.dir, &.{"extraz"}); defer freeResult(gpa, &typo); try expectExit(typo.term, 2); try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'extras'?") != null); } // ========================================================================== // `zit clean` (contracts/clean.md, B-clean-1..18). The destructive companion to // `extras`: it consumes the SAME sweep unchanged and adds deletion, gated behind // `--force`. Each test drives the real binary; expected bytes come from the // clause text and the settled help/refusal strings, never from running fossil. // ========================================================================== /// The verbatim `zit clean` help — an independent transcription of `main.zig`'s /// `clean_help` (B-clean-12/cli-help-verbatim-stdout-exit-0), so a drift between /// the two is caught here. const clean_help_expected = \\usage: zit clean [--dry-run] [--force] \\ \\Delete every file in the checkout that zit is not tracking — the same set \\`zit extras` lists. This is destructive and cannot be undone: `zit undo` does \\not recover a clean. \\ \\By default clean refuses and deletes nothing: it reports how many untracked \\files would be deleted and stops. Pass --force to delete them, or --dry-run to \\preview the exact list first. clean never prompts. \\ \\Tracked files and zit's control files are never touched. Only files are \\deleted; directories left empty are not pruned. \\ \\options: \\ --dry-run list what would be deleted, one path per line; delete nothing \\ --force delete the untracked files \\ -h, --help show this help and exit \\ \\If both --dry-run and --force are given, --dry-run wins and nothing is deleted. \\ \\exit status: \\ 0 nothing to delete, a --dry-run preview, or every file deleted \\ 1 refused (no --force), a file could not be deleted, or not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; /// The verbatim B-clean-1 refusal, naming a plural count of extras. const clean_refuse_three = "zit clean: 3 untracked files would be deleted; refusing without --force\n" ++ "run 'zit clean --dry-run' to preview, or 'zit clean --force' to delete\n"; test "cli clean B-clean-1: no flags with extras present refuses, exit 1, names the count, deletes 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); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); var res = try runZit(gpa, io, tmp.dir, &.{"clean"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); // The gate: stdout stays empty; the two-line refusal (count + pointers) is on // stderr; and every extra is still on disk (nothing was deleted). try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings(clean_refuse_three, res.stderr); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try std.testing.expect(fileExists(io, tmp.dir, name)); } test "cli clean B-clean-2: the refusal is non-interactive — stdin closed behaves identically" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); // stdin redirected from /dev/null: clean never reads it, never prompts — the // output is byte-identical to the plain B-clean-1 run. var res = try runShell(gpa, io, tmp.dir, "\"$ZIT\" clean < /dev/null"); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings(clean_refuse_three, res.stderr); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try std.testing.expect(fileExists(io, tmp.dir, name)); } test "cli clean B-clean-3: --dry-run previews to stdout, exit 0, leaving the tree 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); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // --dry-run deletes nothing (B-clean-3): every file is still present. for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try std.testing.expect(fileExists(io, tmp.dir, name)); } test "cli clean B-clean-4: the --dry-run preview is byte-identical to `zit extras`" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); var dry = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" }); defer freeResult(gpa, &dry); var ext = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext); try expectExit(dry.term, 0); try expectExit(ext.term, 0); // Same paths, same order, same form — clean and extras share the ONE sweep. try std.testing.expectEqualStrings(ext.stdout, dry.stdout); try std.testing.expectEqualStrings("apple\nmango\nzebra\n", dry.stdout); } test "cli clean B-clean-5: --force deletes exactly the extras set, exit 0, tracked and control survive" { 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, "keep"); try touch(io, tmp.dir, "loose1"); try touch(io, tmp.dir, "loose2"); try addManaged(gpa, io, tmp.dir, &.{"keep"}); var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Full success is silent on both streams (cli-stream-separation). try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // Exactly the extras set is gone; the tracked file and every control entry // remain — untouchable by construction (B-clean-7). try std.testing.expect(!fileExists(io, tmp.dir, "loose1")); try std.testing.expect(!fileExists(io, tmp.dir, "loose2")); try std.testing.expect(fileExists(io, tmp.dir, "keep")); 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)); } test "cli clean B-clean-6: --force is idempotent — a subsequent extras lists 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, "one"); try touch(io, tmp.dir, "two"); var first = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &first); try expectExit(first.term, 0); // The extras that were deleted are gone and nothing tracked/control became an // extra: extras (and a re-run clean --dry-run) over the now-clean tree is empty. var ext = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext); try expectExit(ext.term, 0); try std.testing.expectEqualStrings("", ext.stdout); var again = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" }); defer freeResult(gpa, &again); try expectExit(again.term, 0); try std.testing.expectEqualStrings("", again.stdout); } test "cli clean B-clean-7: a tracked file and every control entry survive a --force 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); { var f = try tmp.dir.createFile(io, "tracked", .{}); defer f.close(io); try f.writePositionalAll(io, "PAYLOAD\n", 0); } try addManaged(gpa, io, tmp.dir, &.{"tracked"}); try touch(io, tmp.dir, "extra"); var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(!fileExists(io, tmp.dir, "extra")); // The tracked file's content is unchanged, and the object store / checkout // record / managed list are all intact — clean never re-checks membership. const body = try readFile(gpa, io, tmp.dir, "tracked"); defer gpa.free(body); try std.testing.expectEqualStrings("PAYLOAD\n", body); 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)); } test "cli clean B-clean-8: an empty extras set is a success no-op for both --force and un-forced" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // only the control files exist — no extras const invocations = [_][]const []const u8{ &.{ "clean", "--force" }, &.{"clean"}, }; for (invocations) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &res); // Emptiness is a success, never a refusal: no diagnostic, no count. try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } } test "cli clean B-clean-9: partial failure continues siblings, reports the 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); try touch(io, tmp.dir, "a"); try tmp.dir.createDirPath(io, "sub"); try touch(io, tmp.dir, "sub/stuck"); // Make `sub` non-writable so unlinking `sub/stuck` fails with EACCES, while // the sweep can still read `sub` (r-x). `a` (sorted first) deletes cleanly. var sub = try tmp.dir.openDir(io, "sub", .{}); defer sub.close(io); try sub.setPermissions(io, File.Permissions.fromMode(0o555)); defer sub.setPermissions(io, File.Permissions.fromMode(0o777)) catch {}; var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit clean: 'sub/stuck': could not delete: permission denied\n", res.stderr, ); // Restore write access before the readback so traversal (and cleanup) is // unhindered: the deletable sibling `a` is gone, the undeletable one remains. try sub.setPermissions(io, File.Permissions.fromMode(0o777)); try std.testing.expect(!fileExists(io, tmp.dir, "a")); try std.testing.expect(fileExists(io, tmp.dir, "sub/stuck")); } test "cli clean B-clean-10: exit is 0 on full success and exactly 1 on a partial failure, never 2" { const gpa = std.testing.allocator; const io = std.testing.io; // All deletions succeed → exit 0. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touch(io, tmp.dir, "x"); var ok = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &ok); try expectExit(ok.term, 0); } // At least one deletion fails → exit exactly 1 (operation class, not usage 2). { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try tmp.dir.createDirPath(io, "sub"); try touch(io, tmp.dir, "sub/stuck"); var sub = try tmp.dir.openDir(io, "sub", .{}); defer sub.close(io); try sub.setPermissions(io, File.Permissions.fromMode(0o555)); defer sub.setPermissions(io, File.Permissions.fromMode(0o777)) catch {}; var bad = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &bad); try expectExit(bad.term, 1); try sub.setPermissions(io, File.Permissions.fromMode(0o777)); } } test "cli clean B-clean-11: --dry-run wins over --force — preview, exit 0, nothing 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); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try touch(io, tmp.dir, name); const invocations = [_][]const []const u8{ &.{ "clean", "--dry-run", "--force" }, &.{ "clean", "--force", "--dry-run" }, }; for (invocations) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &res); // --dry-run is the safety override: it can never be upgraded into a delete. try expectExit(res.term, 0); try std.testing.expectEqualStrings("apple\nmango\nzebra\n", res.stdout); for ([_][]const u8{ "zebra", "apple", "mango" }) |name| try std.testing.expect(fileExists(io, tmp.dir, name)); } } test "cli clean B-clean-12: -h/--help print verbatim help to stdout, exit 0, help wins and deletes 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, "extra"); // Help wins over a trailing invalid token, an unknown option, and --force. const invocations = [_][]const []const u8{ &.{ "clean", "--help" }, &.{ "clean", "-h" }, &.{ "clean", "--help", "foo" }, &.{ "clean", "-h", "--all" }, &.{ "clean", "--force", "--help" }, }; for (invocations) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(clean_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // Help never sweeps and never deletes (B-clean-12). try std.testing.expect(fileExists(io, tmp.dir, "extra")); } } test "cli clean B-clean-13: 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(); try initRepo(gpa, io, tmp.dir); // `-f` and `-n` are NOT accepted short aliases (B-clean-13). inline for (.{ "--all", "-0", "-f", "-n" }) |flag| { var res = try runZit(gpa, io, tmp.dir, &.{ "clean", flag }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); const expected = try std.fmt.allocPrint( gpa, "zit clean: unknown option '{s}'\ntry 'zit clean --help'\n", .{flag}, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli clean B-clean-14: a positional or a token after -- is a usage error, exit 2, 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); const Case = struct { argv: []const []const u8, token: []const u8 }; const cases = [_]Case{ .{ .argv = &.{ "clean", "foo" }, .token = "foo" }, .{ .argv = &.{ "clean", "--", "foo" }, .token = "foo" }, .{ .argv = &.{ "clean", "--", "--force" }, .token = "--force" }, }; for (cases) |c| { var res = try runZit(gpa, io, tmp.dir, c.argv); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); const expected = try std.fmt.allocPrint( gpa, "zit clean: unexpected argument '{s}'\ntry 'zit clean --help'\n", .{c.token}, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stderr); } } test "cli clean B-clean-15: outside a repository, exit 1, not_a_repo on stderr, empty stdout" { 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 // The repository check precedes any sweep, regardless of --force/--dry-run. const invocations = [_][]const []const u8{ &.{"clean"}, &.{ "clean", "--force" }, &.{ "clean", "--dry-run" }, }; for (invocations) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); 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 clean B-clean-16: a corrupt .zit-managed fails cleanly BEFORE any deletion, 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, "extra"); { 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); } // Even with --force, a tracked set that cannot be read is a tree clean must // not touch: nothing is deleted (the extra is still on disk). var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit clean: cannot read tracked files: .zit-managed is corrupt\n", res.stderr, ); try std.testing.expect(fileExists(io, tmp.dir, "extra")); } test "cli clean B-clean-17: `zit clean --dry-run >> log` appends, rows to stdout (O_APPEND honored)" { 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"); // The shell creates `log` (itself now an untracked file) BEFORE clean runs, so // the preview lists both `g1` and `log`; appending must preserve the // pre-existing line. ASCII: `g1` < `log`. var res = try runShell(gpa, io, tmp.dir, "printf 'PRE-EXISTING-LINE\\n' > log; \"$ZIT\" clean --dry-run >> 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\ng1\nlog\n", log); } test "cli clean B-clean-18: over a deep nested tree --dry-run equals extras and --force deletes that exact 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 tmp.dir.createDirPath(io, "x/y/z"); try touch(io, tmp.dir, "x/y/z/deep"); try touch(io, tmp.dir, "top"); // The ONE shared sweep, bounds included: preview equals extras and equals the // derived ascending listing ("top" < "x/y/z/deep"). var ext = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext); var dry = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" }); defer freeResult(gpa, &dry); try expectExit(dry.term, 0); try std.testing.expectEqualStrings(ext.stdout, dry.stdout); try std.testing.expectEqualStrings("top\nx/y/z/deep\n", dry.stdout); // --force deletes exactly that set; a subsequent extras lists nothing. var force = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &force); try expectExit(force.term, 0); try std.testing.expect(!fileExists(io, tmp.dir, "top")); try std.testing.expect(!fileExists(io, tmp.dir, "x/y/z/deep")); var after = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &after); try expectExit(after.term, 0); try std.testing.expectEqualStrings("", after.stdout); } test "cli clean B-clean-12: 'clean' is listed in the command help and suggested for a near-miss" { // The verb joins the single-source Verb enum, so the command list names it, // `zit help clean` routes to the verb's own verbatim help (B-clean-12), and // the did-you-mean suggester finds 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, "clean") != null); var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "clean" }); defer freeResult(gpa, &topic); try expectExit(topic.term, 0); try std.testing.expectEqualStrings(clean_help_expected, topic.stdout); var typo = try runZit(gpa, io, tmp.dir, &.{"cleen"}); defer freeResult(gpa, &typo); try expectExit(typo.term, 2); try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'clean'?") != null); } test "cli add B-extras-5: 'zit add .zit-cache' and 'zit add .zit-pack-tmp' are refused as control files" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // The two transient artifacts join the never-tracked set: add/rm delegate to // the same predicate `extras` uses, so the boundary cannot drift. try touch(io, tmp.dir, ".zit-cache"); try touch(io, tmp.dir, ".zit-pack-tmp"); var res = try runZit(gpa, io, tmp.dir, &.{ "add", ".zit-cache", ".zit-pack-tmp" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit add: '.zit-cache' is a zit control file and cannot be tracked\n" ++ "zit add: '.zit-pack-tmp' is a zit control file and cannot be tracked\n", res.stderr, ); try std.testing.expect(!fileExists(io, tmp.dir, managed.filename)); } // --- amend (contracts/amend.md B-amend-1..17) ------------------------------- /// An independent transcription of `main.zig`'s `amend_help` block (must match /// byte-for-byte; drift fails B-amend-13). const amend_help_expected = \\zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME] \\ \\Attach display overrides to a check-in by minting one control artifact. \\History is never rewritten; the original check-in is untouched. \\ \\ --comment TEXT override the displayed comment \\ --user NAME override the displayed author \\ --date ISO override the displayed date (YYYY-MM-DDTHH:MM:SS) \\ --branch NAME move the check-in (and its descendants) to branch NAME \\ -h, --help show this help \\ \\At least one override flag is required. \\ ; /// Extracts the 64-hex control-artifact hash from an `amend <hash>\n` success /// line, asserting the fixed structure (never a wall clock). fn amendHash(stdout: []const u8) ![]const u8 { const prefix = "amend "; try std.testing.expect(std.mem.startsWith(u8, stdout, prefix)); try std.testing.expect(std.mem.endsWith(u8, stdout, "\n")); const hash = stdout[prefix.len .. stdout.len - 1]; 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; } /// Seeds a repository with one root check-in (comment "orig", file "f") and /// returns its 64-hex hash (caller frees). The shared setup for the amend e2e. fn seedRootCheckin(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 { try initRepo(gpa, io, dir); try writeFile(io, dir, "f", "v1\n"); try addManaged(gpa, io, dir, &.{"f"}); var root = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", "orig" }); defer freeResult(gpa, &root); try expectExit(root.term, 0); return gpa.dupe(u8, try checkinHash(root.stdout)); } test "cli amend B-amend-1: --comment mints a control that overrides the displayed comment" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "overridden text" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); try std.testing.expectEqualStrings("", am.stderr); // B-amend-16 stream discipline const control_hash = try gpa.dupe(u8, try amendHash(am.stdout)); defer gpa.free(control_hash); // A NEW, distinct artifact (not the check-in) carries the override. try std.testing.expect(!std.mem.eql(u8, control_hash, root_hash)); // The minted artifact is a CONTROL (§5.2 D,T,U,Z) carrying `+comment` targeted // at the check-in, never `*` (§5.4). { var mc = try parseCheckin(gpa, io, tmp.dir, control_hash); defer mc.deinit(); try std.testing.expectEqual(artifact.ArtifactType.control, mc.m.kind); try std.testing.expect(mc.m.d != null and mc.m.u != null and mc.m.z != null); try std.testing.expect(mc.m.c == null and mc.m.f.len == 0 and mc.m.p.len == 0); var found = false; for (mc.m.t) |t| { if (t.sign == .add and std.mem.eql(u8, t.name, "comment")) { found = true; try std.testing.expectEqualStrings("overridden text", t.value.?); try std.testing.expect(t.target != null); try std.testing.expectEqualStrings(root_hash, t.target.?); } } try std.testing.expect(found); } // Option B observables: timeline comment column, info `comment:` line, and // `tag list` all reflect the override after crosslink. var tl = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &tl); try expectExit(tl.term, 0); try std.testing.expect(std.mem.indexOf(u8, tl.stdout, "overridden text") != null); var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash }); defer freeResult(gpa, &info); try expectExit(info.term, 0); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: overridden text") != null); var tg = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", root_hash }); defer freeResult(gpa, &tg); try expectExit(tg.term, 0); try std.testing.expect(std.mem.indexOf(u8, tg.stdout, "comment overridden text") != null); } test "cli amend B-amend-2: --user/--date surface in info; a malformed --date is a usage error" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--user", "alice", "--date", "2020-06-07T08:09:10" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); _ = try amendHash(am.stdout); var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash }); defer freeResult(gpa, &info); try expectExit(info.term, 0); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "user:") != null); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "alice") != null); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "date:") != null); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "2020-06-07T08:09:10") != null); // A malformed --date is rejected (exit 2) before anything is minted (§4.6.4). var bad = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--date", "2020-06-07" }); defer freeResult(gpa, &bad); try expectExit(bad.term, 2); try std.testing.expectEqualStrings("", bad.stdout); try std.testing.expect(std.mem.indexOf(u8, bad.stderr, "invalid date") != null); try std.testing.expect(std.mem.indexOf(u8, bad.stderr, "try 'zit amend --help'") != null); } test "cli amend B-amend-3: --branch moves the check-in to the new branch (repo-state §6.2)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); // A later mtime (higher epoch) so the branch override supersedes the commit's // own `branch=trunk` tag (repo-state §5.3 last-wins). var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--branch", "feature" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); const control_hash = try gpa.dupe(u8, try amendHash(am.stdout)); defer gpa.free(control_hash); // The §6.2 branch-move tag set, retargeted at the check-in. { var mc = try parseCheckin(gpa, io, tmp.dir, control_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")); for (mc.m.t) |t| try std.testing.expect(t.target != null); // never `*` } var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash }); defer freeResult(gpa, &info); try expectExit(info.term, 0); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "branch: feature") != null); var br = try runZit(gpa, io, tmp.dir, &.{"branch"}); defer freeResult(gpa, &br); try expectExit(br.term, 0); try std.testing.expect(std.mem.indexOf(u8, br.stdout, "feature") != null); } test "cli amend B-amend-4: several flags mint ONE control artifact" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--comment", "c", "--user", "u2", "--branch", "feature" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); // Exactly one success line ⇒ one control artifact (never one per flag). try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, am.stdout, "\n")); const control_hash = try gpa.dupe(u8, try amendHash(am.stdout)); defer gpa.free(control_hash); var mc = try parseCheckin(gpa, io, tmp.dir, control_hash); defer mc.deinit(); try std.testing.expectEqual(artifact.ArtifactType.control, mc.m.kind); // All the overrides ride in this single artifact's T run. try std.testing.expect(hasTag(mc.m, .add, "comment")); try std.testing.expect(hasTag(mc.m, .add, "user")); try std.testing.expect(hasTag(mc.m, .propagate, "branch")); try std.testing.expect(hasTag(mc.m, .propagate, "sym-feature")); } test "cli amend B-amend-5: history is append-only — the manifest is untouched, no new check-in" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "shown" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); // The original manifest still parses byte-for-byte the same: same kind, same // C-card comment "orig" (the override lives on a separate control artifact). { var mm = try parseCheckin(gpa, io, tmp.dir, root_hash); defer mm.deinit(); try std.testing.expectEqual(artifact.ArtifactType.manifest, mm.m.kind); try std.testing.expectEqualStrings("orig", mm.m.c.?.comment); } // The timeline gains no new check-in — a control artifact is not a check-in. var tl = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &tl); try expectExit(tl.term, 0); try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, tl.stdout, "\n")); // A subsequent commit on the checkout still behaves — history moved forward. try writeFile(io, tmp.dir, "f", "v2\n"); var c2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "next" }); defer freeResult(gpa, &c2); try expectExit(c2.term, 0); } test "cli amend B-amend-6: amend is repeatable and last-wins by event mtime" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); // The greater-mtime control wins regardless of ingest order — mint the later // one (higher epoch) first, then an earlier one. var late = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "2000000000" }, &.{ "amend", root_hash, "--comment", "second" }); defer freeResult(gpa, &late); try expectExit(late.term, 0); var early = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender", .epoch = "1500000000" }, &.{ "amend", root_hash, "--comment", "first" }); defer freeResult(gpa, &early); try expectExit(early.term, 0); // Two distinct control artifacts. try std.testing.expect(!std.mem.eql(u8, try amendHash(late.stdout), try amendHash(early.stdout))); var info = try runZit(gpa, io, tmp.dir, &.{ "info", root_hash }); defer freeResult(gpa, &info); try expectExit(info.term, 0); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: second") != null); try std.testing.expect(std.mem.indexOf(u8, info.stdout, "comment: first") == null); } test "cli amend B-amend-8: no override flag is a usage error, nothing minted" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash }); defer freeResult(gpa, &am); try expectExit(am.term, 2); try std.testing.expectEqualStrings("", am.stdout); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "no override given") != null); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "try 'zit amend --help'") != null); } test "cli amend B-amend-9: an unresolvable NAME fails with exit 1, nothing minted" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var am = try runZit(gpa, io, tmp.dir, &.{ "amend", "d" ** 64, "--comment", "x" }); defer freeResult(gpa, &am); try expectExit(am.term, 1); try std.testing.expectEqualStrings("", am.stdout); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not found") != null); } test "cli amend B-amend-10: a NAME that names a control artifact is refused by its type" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); // First amend mints a control artifact; feeding ITS hash back to amend must // refuse — T cards target check-ins, not control artifacts. var first = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "x" }); defer freeResult(gpa, &first); try expectExit(first.term, 0); const control_hash = try gpa.dupe(u8, try amendHash(first.stdout)); defer gpa.free(control_hash); var am = try runZit(gpa, io, tmp.dir, &.{ "amend", control_hash, "--comment", "y" }); defer freeResult(gpa, &am); try expectExit(am.term, 1); try std.testing.expectEqualStrings("", am.stdout); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not a check-in") != null); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "tag") != null); // §5.1 external name } test "cli amend B-amend-11: an empty value and a repeated flag are usage errors" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); var empty = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "" }); defer freeResult(gpa, &empty); try expectExit(empty.term, 2); try std.testing.expectEqualStrings("", empty.stdout); try std.testing.expect(std.mem.indexOf(u8, empty.stderr, "non-empty") != null); var rep = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "a", "--comment", "b" }); defer freeResult(gpa, &rep); try expectExit(rep.term, 2); try std.testing.expectEqualStrings("", rep.stdout); try std.testing.expect(std.mem.indexOf(u8, rep.stderr, "more than once") != null); } test "cli amend B-amend-12: amend dispatches to the real handler; a surviving reflex still teaches" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); // `zit amend NAME --comment` succeeds — it is a real verb, not the reflex lesson. var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "amender" }, &.{ "amend", root_hash, "--comment", "real" }); defer freeResult(gpa, &am); try expectExit(am.term, 0); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "is not a zit command") == null); // A surviving reflex entry (rebase) still teaches the immutable-history lesson. var rb = try runZit(gpa, io, tmp.dir, &.{"rebase"}); defer freeResult(gpa, &rb); try expectExit(rb.term, 2); try std.testing.expect(std.mem.indexOf(u8, rb.stderr, "is not a zit command") != null); } test "cli amend B-amend-13: -h/--help writes the verbatim help to stdout, exit 0, mints nothing" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Help resolves nothing, so it works even outside a repository. var h = try runZit(gpa, io, tmp.dir, &.{ "amend", "--help" }); defer freeResult(gpa, &h); try expectExit(h.term, 0); try std.testing.expectEqualStrings(amend_help_expected, h.stdout); try std.testing.expectEqualStrings("", h.stderr); // -h wins over an otherwise-invalid trailing token. var h2 = try runZit(gpa, io, tmp.dir, &.{ "amend", "--bogus", "-h" }); defer freeResult(gpa, &h2); try expectExit(h2.term, 0); try std.testing.expectEqualStrings(amend_help_expected, h2.stdout); } test "cli amend B-amend-14/16: usage taxonomy and stream discipline" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root_hash = try seedRootCheckin(gpa, io, tmp.dir); defer gpa.free(root_hash); // A missing NAME. var no_name = try runZit(gpa, io, tmp.dir, &.{"amend"}); defer freeResult(gpa, &no_name); try expectExit(no_name.term, 2); try std.testing.expectEqualStrings("", no_name.stdout); try std.testing.expect(std.mem.indexOf(u8, no_name.stderr, "no name given") != null); // An unknown option. var unknown = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--nope" }); defer freeResult(gpa, &unknown); try expectExit(unknown.term, 2); try std.testing.expectEqualStrings("", unknown.stdout); try std.testing.expect(std.mem.indexOf(u8, unknown.stderr, "unknown option '--nope'") != null); // A second positional. var extra = try runZit(gpa, io, tmp.dir, &.{ "amend", root_hash, "--comment", "x", "also" }); defer freeResult(gpa, &extra); try expectExit(extra.term, 2); try std.testing.expectEqualStrings("", extra.stdout); try std.testing.expect(std.mem.indexOf(u8, extra.stderr, "unexpected argument 'also'") != null); } test "cli amend B-amend-15: outside any repository, exit 1 with the standard diagnostic" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // No `zit init` here. var am = try runZit(gpa, io, tmp.dir, &.{ "amend", "abc", "--comment", "x" }); defer freeResult(gpa, &am); try expectExit(am.term, 1); try std.testing.expectEqualStrings("", am.stdout); try std.testing.expect(std.mem.indexOf(u8, am.stderr, "not a zit repository") != null); } test "cli amend B-amend-17: `zit help amend` teaches amend's help; the command list names it" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var h = try runZit(gpa, io, tmp.dir, &.{ "help", "amend" }); defer freeResult(gpa, &h); try expectExit(h.term, 0); try std.testing.expectEqualStrings(amend_help_expected, h.stdout); var top = try runZit(gpa, io, tmp.dir, &.{"help"}); defer freeResult(gpa, &top); try expectExit(top.term, 0); try std.testing.expect(std.mem.indexOf(u8, top.stdout, "amend") != null); }