Artifact 4e374bb30b33ce11bb33ee186339eb86d5a5336c5febe20a6b8c7a245572e4f3:
- File
src/e2e.zig
— part of check-in
[e5db38a6df]
at
2026-08-05 21:16:30
on branch sub-manifest
— citations: re-key 31 verified line ranges, baseline 512 to 481
Each was located by reading the draft text that supports what the code comment actually claims, then adversarially re-checked against a verbatim quote of those lines. Clamping a range into its section would satisfy the checker while pointing at the wrong paragraph, so the checker alone was never the gate.
Of 33 proposals, 2 were refuted on exactly that ground -- the quote verified byte-for-byte and still did not state the claim -- and 4 came back unresolved because no draft's §4.1 states what those comments assert at all. Those are mis-sectioned, not line-rotted, and need a section decision rather than a re-key.
Also restores container.zig's `decode` doc block, which 7d26a31305 left stranded above `decodePrefix` when that function was inserted between the comment and the function it describes.
test 3495/3495, fmt. Issue #18. (user: dmestas, size: 867249) [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; /// draft-fossil-submanifests-00 §6.3/§8.1 — the settings semantics the CLI /// tests below assert against the stored config frame. const submanifest = @import("zit").submanifest; 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; test { _ = @import("git_import_e2e.zig"); } // The §4.1 container codec and the sync vocabulary: the §9.3 configuration // tests below hand-build a request card no zit client emits, so they frame it // with the same codec the transport uses and name the settings with the same // constants the wire does. const container = @import("zit").container; const sync = @import("zit").sync; // The dispatched-verb enum, imported straight from the source it lives in (as // main.zig does) so the help-audit guard below drives off the SAME list the // dispatcher switches on — a verb added there without a help line fails the // test, never drifts. const cli = @import("cli.zig"); 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; }, } } /// Like `expectExit`, but on mismatch prints the child's captured stdout and /// stderr before failing. Reserved for the spawned-CLIENT assertions in the /// server-interop scenarios (clone/pull/push against a live `zit serve`): there /// the child's stderr is the ONLY evidence of *why* it exited non-zero — a push /// that reached the wrong server, a sync that never converged — and plain /// `expectExit` discards it, leaving a failure that is countable but not /// diagnosable (issue #12). Elsewhere `expectExit` stays: those call sites /// assert the child's output separately, so the code alone is enough. fn expectExitDiag(term: std.process.Child.Term, code: u8, res: std.process.RunResult) !void { switch (term) { .exited => |c| { if (c == code) return; std.debug.print( "expected exit {d}, got {d}\n--- child stdout ---\n{s}\n--- child stderr ---\n{s}\n", .{ code, c, res.stdout, res.stderr }, ); return error.TestUnexpectedExitCode; }, else => { std.debug.print( "expected exit {d}, got {any}\n--- child stdout ---\n{s}\n--- child stderr ---\n{s}\n", .{ code, term, res.stdout, res.stderr }, ); 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 = "", .special_op = "", .manifest_flags = "" }); 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 named symlink is rejected; a directory recurses; 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", .{}); // Since P3-F4 a directory argument recurses (here `d` is empty, so it // contributes nothing and is not an error). Naming a symlink directly is // still B-add-5's refusal — since F-B3 with the symlink-specific wording, // so it reads the same as the skip `add <dir>` prints for the same entry // and does not read like a fifo/socket refusal. 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: 's': symlink (not versioned in 0.1.0)\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). \\ \\Naming a directory adds every untracked file beneath it, recursively; \\`zit add .` adds the whole tree. zit's control files are never added. \\ \\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, not a regular file, 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.setFilePermissions(io, ".", File.Permissions.fromMode(0o555), .{ .follow_symlinks = false }); defer tmp.dir.setFilePermissions(io, ".", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }) 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 sweeps the tree, never 'outside 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); // `.` resolves to the checkout root itself — a directory INSIDE the repo, // never B-add-6's "outside the repository" (the most likely novice // invocation). Since P3-F4 it means the whole tree; a tree holding only the // control files therefore adds nothing and succeeds. var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", 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 .' with nothing tracked is 'not tracked', never '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); // `.` is the checkout root — inside the repo, never B-rm-6's "outside". // Since P3-F4 it expands over the tracked keys; with none tracked the // expansion is empty, which is the existing not-tracked diagnostic. 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 not tracked\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. Naming a directory forgets \\every tracked file beneath it, whether or not the directory still exists. \\ \\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, 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.setFilePermissions(io, ".", File.Permissions.fromMode(0o555), .{ .follow_symlinks = false }); defer tmp.dir.setFilePermissions(io, ".", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }) 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.setFilePermissions(io, ".", File.Permissions.fromMode(0o555), .{ .follow_symlinks = false }); defer tmp.dir.setFilePermissions(io, ".", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }) 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.setFilePermissions(io, ".", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }); 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 \\ zit merge --cherrypick NAME \\ zit merge --backout 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. \\ \\--cherrypick and --backout are special merges that pick an alternate \\baseline instead of the common ancestor: --cherrypick applies only NAME's \\own change onto the working tree, --backout reverses it. 'zit commit' then \\records the operation as a cherry-pick/backout, not as a merge parent. \\ \\options: \\ -h, --help show this help and exit \\ --cherrypick NAME apply only NAME's own change onto the working tree \\ --backout NAME reverse NAME's change on the working tree \\ ; /// 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 merge --cherrypick / --backout` end-to-end tests (contracts/ // merge-flags.md B-merge-30..41). Special merges are alternate baseline choices // inside the SAME merge machinery — no new verb. Each builds its DAG through // real check-ins and asserts exit code + streams + on-disk state (working file // bytes, the `.zit-checkout` special_op field, and the minted Q card) read back // through the library. // ========================================================================== /// Reads `.zit-checkout` and asserts version, an EMPTY merge_parent (a special /// merge is never a plain merge), and the pending special_op (sign + REV hash). fn expectSpecialState(io: std.Io, dir: std.Io.Dir, version: []const u8, special_op: []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("", ck.merge_parent); try std.testing.expectEqualStrings(special_op, ck.special_op); } test "cli merge B-merge-30/-36: cherrypick lands REV's own change, preserves current's edit, records +REV" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // R0 root; R1 changes line 3 (C->Z) — its OWN change relative to its parent. // A fork off R0 (current, R2) changes a DISJOINT line 1 (A->Q). try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); var up = try runZit(gpa, io, tmp.dir, &.{ "update", r0 }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "poem", "Q\nB\nC\n"); const r2 = try commitHashDup(gpa, io, tmp.dir, "fork"); defer gpa.free(r2); const zit_before = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_before); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r1 }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Exactly REV's edit (line 3) is applied; current's own edit (line 1) is // preserved — a three-way merge, not a copy. const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("Q\nB\nZ\n", poem); var line_buf: [256]u8 = undefined; const want_line = try std.fmt.bufPrint(&line_buf, "cherry-picked {s} into the working tree (1 file(s) changed)\n", .{r1}); try std.testing.expectEqualStrings(want_line, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // The pending +REV op is recorded; version unchanged (no advance). var sbuf: [70]u8 = undefined; const want_op = try std.fmt.bufPrint(&sbuf, "+{s}", .{r1}); try expectSpecialState(io, tmp.dir, r2, want_op); // Mints nothing: `.zit` byte-identical (B-merge-30). B-merge-36: the undo // journal is armed (present) — durability ordering inherited from applyMerge. const zit_after = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_after); try std.testing.expectEqualSlices(u8, zit_before, zit_after); try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo")); } test "cli merge B-merge-32: backout reverses REV's change, records -REV" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // R0 root; R1 changes line 3 (C->Z). Checkout sits at R1; backing out R1 // reverses its change onto the working tree (base = R1, other = R1's parent). try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--backout", r1 }); defer freeResult(gpa, &res); try expectExit(res.term, 0); const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A\nB\nC\n", poem); // line 3 reverted Z->C var line_buf: [256]u8 = undefined; const want_line = try std.fmt.bufPrint(&line_buf, "backed out {s} into the working tree (1 file(s) changed)\n", .{r1}); try std.testing.expectEqualStrings(want_line, res.stdout); try std.testing.expectEqualStrings("", res.stderr); var sbuf: [70]u8 = undefined; const want_op = try std.fmt.bufPrint(&sbuf, "-{s}", .{r1}); try expectSpecialState(io, tmp.dir, r1, want_op); } test "cli commit B-merge-33/-34: a cherrypick commit mints one Q +REV card, single P parent, cleared one-shot" { const 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\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); var up = try runZit(gpa, io, tmp.dir, &.{ "update", r0 }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "poem", "Q\nB\nC\n"); const r2 = try commitHashDup(gpa, io, tmp.dir, "fork"); defer gpa.free(r2); var cp = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r1 }); defer freeResult(gpa, &cp); try expectExit(cp.term, 0); const commit = try commitHashDup(gpa, io, tmp.dir, "cherry commit"); defer gpa.free(commit); var stored = try parseCheckin(gpa, io, tmp.dir, commit); defer stored.deinit(); // B-merge-33: exactly one Q card, sign glued onto the target, NO baseline // token — byte-asserted against §4.7.14's grammar. var qbuf: [80]u8 = undefined; const want_q = try std.fmt.bufPrint(&qbuf, "Q +{s}\n", .{r1}); try std.testing.expect(std.mem.indexOf(u8, stored.bytes, want_q) != null); try std.testing.expectEqual(@as(usize, 1), stored.m.q.len); try std.testing.expect(stored.m.q[0].sign == .cherrypick); try std.testing.expectEqualStrings(r1, stored.m.q[0].target); try std.testing.expect(stored.m.q[0].baseline == null); // The P card is the ORDINARY single-token parent (R2) — REV is not a merge // parent, so linkage gains no edge to it. try std.testing.expectEqual(@as(usize, 1), stored.m.p.len); try std.testing.expectEqual(@as(usize, 1), stored.m.p[0].count); try std.testing.expectEqualStrings(r2, stored.m.p[0].parents.?); // B-merge-34: the pending op is cleared one-shot; the checkout advanced. var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try std.testing.expectEqualStrings(commit, ck.version); try std.testing.expectEqualStrings("", ck.special_op); // A following commit with no fresh cherrypick emits NO Q card. try writeFile(io, tmp.dir, "poem", "Q\nB\nZ\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, 0), stored2.m.q.len); // B-merge-41: `zit info` renders the cherrypick provenance — a // `cherrypick: <REV>` line derived from the parsed Q card (target = r1). var info = try runZit(gpa, io, tmp.dir, &.{ "info", commit }); defer freeResult(gpa, &info); try expectExit(info.term, 0); var qline: [80]u8 = undefined; const want_line = try std.fmt.bufPrint(&qline, "cherrypick: {s}\n", .{r1}); try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_line) != null); // Negative space: the plain follow-up check-in `next` carries no Q card, so // its info output is byte-identical to its pre-B-merge-41 baseline — no // cherrypick/backout line appears. var ninfo = try runZit(gpa, io, tmp.dir, &.{ "info", next }); defer freeResult(gpa, &ninfo); try expectExit(ninfo.term, 0); var nbuf: [256]u8 = undefined; const want_plain = try std.fmt.bufPrint(&nbuf, "{s}\nbranch: trunk\nleaf: open\nparent: {s}\n", .{ next, commit }); try std.testing.expectEqualStrings(want_plain, ninfo.stdout); } test "cli commit B-merge-33: a backout commit mints Q -REV" { const 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\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); var bo = try runZit(gpa, io, tmp.dir, &.{ "merge", "--backout", r1 }); defer freeResult(gpa, &bo); try expectExit(bo.term, 0); const commit = try commitHashDup(gpa, io, tmp.dir, "backout commit"); defer gpa.free(commit); var stored = try parseCheckin(gpa, io, tmp.dir, commit); defer stored.deinit(); var qbuf: [80]u8 = undefined; const want_q = try std.fmt.bufPrint(&qbuf, "Q -{s}\n", .{r1}); try std.testing.expect(std.mem.indexOf(u8, stored.bytes, want_q) != null); try std.testing.expectEqual(@as(usize, 1), stored.m.q.len); try std.testing.expect(stored.m.q[0].sign == .backout); try std.testing.expectEqualStrings(r1, stored.m.q[0].target); // Single-parent (R1) — a backout is never a merge parent. try std.testing.expectEqual(@as(usize, 1), stored.m.p[0].count); try std.testing.expectEqualStrings(r1, stored.m.p[0].parents.?); // B-merge-41: `zit info` surfaces the backout provenance — a // `backout: <REV>` line derived from the parsed Q card (target = r1). var info = try runZit(gpa, io, tmp.dir, &.{ "info", commit }); defer freeResult(gpa, &info); try expectExit(info.term, 0); var qline: [80]u8 = undefined; const want_line = try std.fmt.bufPrint(&qline, "backout: {s}\n", .{r1}); try std.testing.expect(std.mem.indexOf(u8, info.stdout, want_line) != null); } test "cli merge B-merge-31: a plain merge mints NO Q card (negative-space regression)" { 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); var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &mg); try expectExit(mg.term, 0); // The plain merge records a merge_parent, never a special_op. var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try std.testing.expectEqualStrings(fx.other, ck.merge_parent); try std.testing.expectEqualStrings("", ck.special_op); const commit = try commitHashDup(gpa, io, tmp.dir, "plain merge"); defer gpa.free(commit); var stored = try parseCheckin(gpa, io, tmp.dir, commit); defer stored.deinit(); // Two-token P card, NO Q card — the shipped merge surface, byte-unchanged. try std.testing.expectEqual(@as(usize, 2), stored.m.p[0].count); try std.testing.expectEqual(@as(usize, 0), stored.m.q.len); } test "cli merge B-merge-35: a cherrypick conflict writes markers, refuses commit, undo unwinds the pending Q state" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // R1 and R2 both change line 3 differently (Z vs W) → overlapping edit. try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); var up = try runZit(gpa, io, tmp.dir, &.{ "update", r0 }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "poem", "A\nB\nW\n"); const r2 = try commitHashDup(gpa, io, tmp.dir, "fork"); defer gpa.free(r2); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r1 }); 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 merge: conflict in poem\n") != null); 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); // The pending op is recorded despite the conflict (not abandoned). var sbuf: [70]u8 = undefined; const want_op = try std.fmt.bufPrint(&sbuf, "+{s}", .{r1}); try expectSpecialState(io, tmp.dir, r2, want_op); // commit REFUSES over the unresolved marker, minting nothing. var bad = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "x" }); defer freeResult(gpa, &bad); try expectExit(bad.term, 1); try std.testing.expect(std.mem.indexOf(u8, bad.stderr, "unresolved conflict in poem") != null); // undo unwinds the working file AND the pending Q state. var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); const restored = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(restored); try std.testing.expectEqualStrings("A\nB\nW\n", restored); var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try std.testing.expectEqualStrings("", ck.special_op); try std.testing.expectEqualStrings(r2, ck.version); } test "cli merge B-merge-37: cherrypick of a root applies its adds; backout of a root deletes them" { const gpa = std.testing.allocator; const io = std.testing.io; // Cherrypick of a root: base = EMPTY parent set, other = the root's tree. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "a", "hello\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root adds a"); defer gpa.free(r0); // A child deletes `a`; the checkout then has no `a`. var rm = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "a" }); defer freeResult(gpa, &rm); try expectExit(rm.term, 0); const r1 = try commitHashDup(gpa, io, tmp.dir, "child deletes a"); defer gpa.free(r1); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r0 }); defer freeResult(gpa, &res); try expectExit(res.term, 0); const a = try readFile(gpa, io, tmp.dir, "a"); defer gpa.free(a); try std.testing.expectEqualStrings("hello\n", a); // the root's add applied } // Backout of a root: base = the root's tree, other = EMPTY → its adds deleted. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "a", "hello\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root adds a"); defer gpa.free(r0); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--backout", r0 }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(!fileExists(io, tmp.dir, "a")); // the root's add reversed } } test "cli merge B-merge-38: dirty tree, pending op, and unresolvable REV all refuse" { const gpa = std.testing.allocator; const io = std.testing.io; // Shared fork DAG: R0 root, R1 edits line 3, R2 forks off R0. const build = struct { fn dag(g: std.mem.Allocator, i: std.Io, d: std.Io.Dir, r: *[3][]const u8) !void { try initRepo(g, i, d); try writeFile(i, d, "poem", "A\nB\nC\n"); try addManaged(g, i, d, &.{"poem"}); r[0] = try commitHashDup(g, i, d, "root"); try writeFile(i, d, "poem", "A\nB\nZ\n"); r[1] = try commitHashDup(g, i, d, "rev"); var up = try runZit(g, i, d, &.{ "update", r[0] }); defer freeResult(g, &up); try expectExit(up.term, 0); try writeFile(i, d, "poem", "Q\nB\nC\n"); r[2] = try commitHashDup(g, i, d, "fork"); } }; // (a) dirty working tree refuses (B-merge-14 inherited). { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var r: [3][]const u8 = undefined; try build.dag(gpa, io, tmp.dir, &r); defer for (r) |h| gpa.free(h); try writeFile(io, tmp.dir, "poem", "dirty\n"); // uncommitted edit var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r[1] }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "working tree is not clean") != null); } // (b) a second cherrypick over a pending cherrypick refuses. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var r: [3][]const u8 = undefined; try build.dag(gpa, io, tmp.dir, &r); defer for (r) |h| gpa.free(h); var first = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r[1] }); defer freeResult(gpa, &first); try expectExit(first.term, 0); var second = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r[1] }); defer freeResult(gpa, &second); try expectExit(second.term, 1); try std.testing.expect(std.mem.indexOf(u8, second.stderr, "already pending") != null); } // (c) a plain merge over a pending cherrypick refuses. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var r: [3][]const u8 = undefined; try build.dag(gpa, io, tmp.dir, &r); defer for (r) |h| gpa.free(h); var first = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r[1] }); defer freeResult(gpa, &first); try expectExit(first.term, 0); var plain = try runZit(gpa, io, tmp.dir, &.{ "merge", r[1] }); defer freeResult(gpa, &plain); try expectExit(plain.term, 1); try std.testing.expect(std.mem.indexOf(u8, plain.stderr, "already pending") != null); } // (d) a cherrypick over a pending PLAIN merge refuses. { 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); var plain = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &plain); try expectExit(plain.term, 0); var cp = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", fx.other }); defer freeResult(gpa, &cp); try expectExit(cp.term, 1); try std.testing.expect(std.mem.indexOf(u8, cp.stderr, "already pending") != null); } // (e) an unresolvable REV fails with the resolver diagnostic (B-merge-18). { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var r: [3][]const u8 = undefined; try build.dag(gpa, io, tmp.dir, &r); defer for (r) |h| gpa.free(h); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", "nope-not-a-rev" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not found") != null); } } test "cli merge B-merge-39: a cherrypick already contained in the checkout refuses, nothing mutated" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Checkout sits at R1 itself; cherrypicking R1 there classifies to zero // entries (the change is already present) → "nothing to apply". try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "A\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const r0 = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(r0); try writeFile(io, tmp.dir, "poem", "A\nB\nZ\n"); const r1 = try commitHashDup(gpa, io, tmp.dir, "rev"); defer gpa.free(r1); const zit_before = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_before); var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", r1 }); 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, "nothing to apply") != null); // Nothing mutated: no pending op recorded, `.zit` untouched. var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try std.testing.expectEqualStrings("", ck.special_op); try std.testing.expectEqualStrings(r1, ck.version); const zit_after = try readFile(gpa, io, tmp.dir, ".zit"); defer gpa.free(zit_after); try std.testing.expectEqualSlices(u8, zit_before, zit_after); } test "cli merge B-merge-40: usage errors — both flags, a flag with no REV, an extra positional, unknown option" { 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", "--cherrypick", "--backout", "rev" }, // mutually exclusive &.{ "merge", "--cherrypick" }, // a flag with no REV &.{ "merge", "--backout", "a", "b" }, // an extra positional beyond REV &.{ "merge", "--bogus", "rev" }, // unknown option }; 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); } // The combined-flags diagnostic names the conflict. var both = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", "--backout", "rev" }); defer freeResult(gpa, &both); try std.testing.expect(std.mem.indexOf(u8, both.stderr, "--cherrypick and --backout cannot be combined") != null); // B-merge-40: the help text carries the two flag lines. var help = try runZit(gpa, io, tmp.dir, &.{ "merge", "--help" }); defer freeResult(gpa, &help); try expectExit(help.term, 0); try std.testing.expect(std.mem.indexOf(u8, help.stdout, "--cherrypick NAME") != null); try std.testing.expect(std.mem.indexOf(u8, help.stdout, "--backout NAME") != 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 = "", .special_op = "", .manifest_flags = "" }); } /// 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 \\ edited <path> tracked, and its bytes or mode differ from the check-in \\ missing <path> tracked, but not present in the working tree \\ \\A tracked file whose contents match the current check-in is not listed. \\ \\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. \\ \\Two things `status` does not report, because it walks only your tracked \\files: a file deleted from the check-in but no longer tracked (`zit diff` \\lists it) and untracked "extra" files (`zit extras` lists them). \\ \\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); } /// Seeds a born checkout whose current check-in is a real, resolvable manifest /// listing `files` — the shape `status` and `diff` both need now that both /// compare the working tree against the check-in's effective F-set. Returns the /// owned check-in hash so a test can quote it verbatim in the header line. fn seedBornCheckin( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, label: []const u8, files: []const artifact.FCard, ) ![]u8 { const hash = try appendDiffManifest(gpa, io, dir, label, null, files); errdefer gpa.free(hash); try seedCheckout(io, dir, hash, "trunk"); return hash; } /// Appends `content` as a SHA-1 blob and returns the F card naming it at `path`. /// The returned card borrows `path` and the returned hash, which the caller owns. fn seedRegularFCard( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8, content: []const u8, ) !artifact.FCard { const blob = try appendArtifactAs(gpa, io, dir, .sha1, content); return .{ .filename = path, .hash = blob, .perm = null, .old_name = null, .mode = .regular }; } test "cli status B-status-16: born state, an edited tracked file is reported as edited" { const 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 addManaged(gpa, io, tmp.dir, &.{"a"}); const card = try seedRegularFCard(gpa, io, tmp.dir, "a", "a0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); try writeFile(io, tmp.dir, "a", "a1\n"); // same path, different bytes var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nedited a\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectCheckout(io, tmp.dir, born_hash, "trunk"); // read-only } test "cli status B-status-16: a rewrite with identical bytes (mtime bump) is not reported" { const 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 addManaged(gpa, io, tmp.dir, &.{"a"}); const card = try seedRegularFCard(gpa, io, tmp.dir, "a", "a0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); // Rewrite byte-identical content: the mtime moves, the artifact hash does // not. Classification is by content hash, so this is clean, not edited. try writeFile(io, tmp.dir, "a", "a0\n"); var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nnothing to report\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-6/-16: edited, missing and newly added interleave in path 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 writeFile(io, tmp.dir, "a", "a0\n"); try writeFile(io, tmp.dir, "b", "b0\n"); try writeFile(io, tmp.dir, "d", "d0\n"); try addManaged(gpa, io, tmp.dir, &.{ "a", "b", "d" }); const card_a = try seedRegularFCard(gpa, io, tmp.dir, "a", "a0\n"); defer gpa.free(card_a.hash.?); const card_b = try seedRegularFCard(gpa, io, tmp.dir, "b", "b0\n"); defer gpa.free(card_b.hash.?); const card_d = try seedRegularFCard(gpa, io, tmp.dir, "d", "d0\n"); defer gpa.free(card_d.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{ card_a, card_b, card_d }); defer gpa.free(born_hash); try writeFile(io, tmp.dir, "a", "a1\n"); // edited try tmp.dir.deleteFile(io, "b"); // missing try writeFile(io, tmp.dir, "c", "c0\n"); try addManaged(gpa, io, tmp.dir, &.{"c"}); // added: tracked, not in the check-in // `d` is untouched → clean → no line. var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint( gpa, "On branch trunk (check-in {s})\nedited a\nmissing b\nadded c\n", .{born_hash}, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-16: an executable-bit-only change is edited" { 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"}); const blob = try appendArtifactAs(gpa, io, tmp.dir, .sha1, original); defer gpa.free(blob); const card: artifact.FCard = .{ .filename = "script", .hash = blob, .perm = "x", .old_name = null, .mode = .executable, }; const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "executable-root", &.{card}); defer gpa.free(born_hash); // The bytes match the baseline; only the mode differs from the F card's `x`. var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nedited script\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-7/-16: born state, every tracked file clean → 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 writeFile(io, tmp.dir, "p", "p0\n"); try addManaged(gpa, io, tmp.dir, &.{"p"}); const card = try seedRegularFCard(gpa, io, tmp.dir, "p", "p0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nnothing to report\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-5/-16: a tracked path in the check-in replaced by a directory is 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 writeFile(io, tmp.dir, "a", "a0\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); const card = try seedRegularFCard(gpa, io, tmp.dir, "a", "a0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); 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); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nmissing a\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-16: a file deleted from the check-in but untracked is still not reported" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // `gone` is in the check-in but was never added to the managed set here, so // it is a deleted-from-check-in file: `--help` still defers those, and // `status` iterates only the managed set, so it must stay silent. const card = try seedRegularFCard(gpa, io, tmp.dir, "gone", "g0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nnothing to report\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-10/-15: an unresolvable current check-in fails without a partial 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 writeFile(io, tmp.dir, "a", "a0\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); try seedCheckout(io, tmp.dir, "a" ** 64, "trunk"); // no such artifact in the log 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: current check-in is not a valid manifest\n", res.stderr, ); } /// Sets `name`'s permission bits through its own handle — `Dir.setPermissions` /// applies to the directory itself, so a per-file chmod needs the open file. fn chmodFile(io: std.Io, dir: std.Io.Dir, name: []const u8, mode: std.posix.mode_t) !void { var f = try dir.openFile(io, name, .{}); defer f.close(io); try f.setPermissions(io, File.Permissions.fromMode(mode)); } test "cli status B-status-15: an unreadable tracked file fails the walk, leaving stdout empty" { 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, "a", "a0\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); // Unreadable, not absent: the no-follow stat still succeeds and reports a // regular file, so the walk reaches `openFile` and takes EACCES. That is a // comparison failure, not a dirty working copy — exit 1, and NOTHING on // stdout, not even the header line that precedes the rows. try chmodFile(io, tmp.dir, "a", 0o000); defer chmodFile(io, tmp.dir, "a", 0o644) catch {}; 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 compare working tree: input/output error\n", res.stderr, ); } test "cli status B-status-15: a walk that aborts after a printable row still leaves stdout empty" { 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` sorts first and IS reportable (added); `z` is unreadable. Were the // report streamed, stdout would already carry the header and "added a" // before the failure — this is the negative space that check exists for. try writeFile(io, tmp.dir, "a", "a0\n"); try writeFile(io, tmp.dir, "z", "z0\n"); try addManaged(gpa, io, tmp.dir, &.{ "a", "z" }); try chmodFile(io, tmp.dir, "z", 0o000); defer chmodFile(io, tmp.dir, "z", 0o644) catch {}; 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 compare working tree: input/output error\n", res.stderr, ); } test "cli status B-status-5/-16: a tracked path in the check-in replaced by a symlink is 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 writeFile(io, tmp.dir, "a", "a0\n"); try addManaged(gpa, io, tmp.dir, &.{"a"}); const card = try seedRegularFCard(gpa, io, tmp.dir, "a", "a0\n"); defer gpa.free(card.hash.?); const born_hash = try seedBornCheckin(gpa, io, tmp.dir, "root", &.{card}); defer gpa.free(born_hash); // Replace the tracked regular file with a symlink whose target holds the // check-in's exact bytes: only the no-follow stat distinguishes them. A // followed link would compare equal and report nothing. try tmp.dir.deleteFile(io, "a"); try writeFile(io, tmp.dir, "target", "a0\n"); tmp.dir.symLink(io, "target", "a", .{}) catch |e| switch (e) { error.AccessDenied, error.Unexpected => return error.SkipZigTest, else => return e, }; var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "On branch trunk (check-in {s})\nmissing a\n", .{born_hash}); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli status B-status-16: a current delta checkout with an absent B-card baseline fails" { 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 = "f" ** 64; 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, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("zit status: cannot resolve baseline\n", 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] [--no-submanifest-advice] [--] [<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. \\ \\On a repository with sub-manifests enabled and pinned, a commit that \\drifts far enough from the analyzed optimum (draft-fossil-submanifests-00 \\§6.4) prints one advisory line and, interactively, offers to retune. \\"Far enough" is zit's §6.4 relative margin m: a re-tune is advised only \\when it would improve the analyzed read cost by more than 15% of the \\pinned value's own cost. --no-submanifest-advice suppresses the advisory \\entirely. Never affects whether the commit itself succeeds. \\ \\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 \\ --no-submanifest-advice \\ suppress the sub-manifest drift advisory \\ -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 materializes 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); // Track a regular file so the managed set names 'link'. Revert must replace // this placeholder with the symlink specified by the check-in. 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. `zit commit` // never mints a symlink card, so this directly exercises §4.7.6-m5 // materialization from the reader's wire representation. 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 = "", .special_op = "", .manifest_flags = "" }); var res = try runZit(gpa, io, tmp.dir, &.{ "revert", "link" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("reverted link\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // readLink verifies the entry is native symlink materialization, rather // than a regular file merely containing the target's bytes. var target: [std.fs.max_path_bytes]u8 = undefined; const target_len = try tmp.dir.readLink(io, "link", &target); try std.testing.expectEqualStrings("target/path\n", target[0..target_len]); } 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"}); } // The shape that made `zit update` accuse an intact fossil clone of corruption: // a store that also holds an anonymous wiki edit (cards D L W Z, no U). The // artifact parses and is well-framed, but §5.2 requires a U card on a WIKI // artifact, so classification declines with MissingRequiredCard; its bytes // still hash to the name it is stored under, so the repository is undamaged and // the artifact is opaque to state. `update` derives state over the whole log // and so meets that artifact, exactly as `timeline`/`ls`/`info`/`branch` do. // // This pins the general form of the defect (Repository State erratum // `repo-state-00-s4-unclassifiable-artifact`): no read verb may succeed on a // store where `update` fails for a reason the reader tolerated. Pre-fix, update // exited 1 while every read verb read the same store fine; post-fix, all agree. test "cli update: an unclassifiable artifact in the store is tolerated, and update agrees with the read verbs" { 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 real check-ins: `root` (earlier) is the update target, and a later // commit is the current tip, so `update root` is a genuine backward update. 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); // Inject the anonymous wiki artifact under its true hash (appendArtifact // names it by content), so it is unclassifiable yet undamaged. const wiki_body = "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n"; var sum_buf: [32]u8 = undefined; const sum = artifact.selfChecksum(wiki_body, &sum_buf); const wiki_bytes = try std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ wiki_body, sum }); defer gpa.free(wiki_bytes); const wiki_name = try appendArtifact(gpa, io, tmp.dir, wiki_bytes); defer gpa.free(wiki_name); // The fix: update tolerates the verified-but-unclassifiable artifact and // restores the target. Pre-fix this exited 1 (Corrupt). var update_res = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &update_res); try expectExit(update_res.term, 0); 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); // Read/assembly agreement: every read verb reads the same store — the one // update just tolerated — and must also exit 0. const read_verbs = [_][]const []const u8{ &.{"timeline"}, &.{"branch"}, &.{ "info", root }, &.{ "ls", root }, }; for (read_verbs) |argv| { var res = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &res); try expectExit(res.term, 0); } } 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: file-to-directory transition round-trips through undo and redo" { const 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 current = try checkinHash(commit.stdout); 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"}); var undo_res = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &undo_res); try expectExit(undo_res.term, 0); try std.testing.expectEqualStrings("", undo_res.stderr); const restored = try readFile(gpa, io, tmp.dir, "node"); defer gpa.free(restored); try std.testing.expectEqualStrings("old file\n", restored); try std.testing.expect(!fileExists(io, tmp.dir, "node/child.txt")); try expectCheckoutVersion(io, tmp.dir, current); try expectManaged(gpa, io, tmp.dir, &.{"node"}); var redo_res = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &redo_res); try expectExit(redo_res.term, 0); try std.testing.expectEqualStrings("", redo_res.stderr); const redone = try readFile(gpa, io, tmp.dir, "node/child.txt"); defer gpa.free(redone); try std.testing.expectEqualStrings(child_content, redone); 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: directory-to-file transition undo/redo treats descendants below restored file as 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 tmp.dir.createDir(io, "a", .default_dir); try writeFile(io, tmp.dir, "a/b", "old child\n"); try addManaged(gpa, io, tmp.dir, &.{"a/b"}); 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); 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 = "a", .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); try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo")); var undo_res = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &undo_res); try expectExit(undo_res.term, 0); try std.testing.expectEqualStrings("undo: working tree restored\n", undo_res.stdout); try std.testing.expectEqualStrings("", undo_res.stderr); const restored = try readFile(gpa, io, tmp.dir, "a/b"); defer gpa.free(restored); try std.testing.expectEqualStrings("old child\n", restored); try expectCheckoutVersion(io, tmp.dir, current); try expectManaged(gpa, io, tmp.dir, &.{"a/b"}); var redo_res = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &redo_res); try expectExit(redo_res.term, 0); try std.testing.expectEqualStrings("redo: working tree restored\n", redo_res.stdout); try std.testing.expectEqualStrings("", redo_res.stderr); const redone = try readFile(gpa, io, tmp.dir, "a"); defer gpa.free(redone); try std.testing.expectEqualStrings(file_content, redone); try std.testing.expect(!fileExists(io, tmp.dir, "a/b")); try expectCheckoutVersion(io, tmp.dir, target); try expectManaged(gpa, io, tmp.dir, &.{"a"}); try std.testing.expect(fileExists(io, tmp.dir, ".zit-undo")); var second_redo = try runZit(gpa, io, tmp.dir, &.{"redo"}); defer freeResult(gpa, &second_redo); try expectExit(second_redo.term, 1); try std.testing.expectEqualStrings("", second_redo.stdout); try std.testing.expectEqualStrings("zit redo: nothing to redo\n", second_redo.stderr); const final_content = try readFile(gpa, io, tmp.dir, "a"); defer gpa.free(final_content); try std.testing.expectEqualSlices(u8, redone, final_content); } 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: unresolvable check-in names 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{ // CHANGED EXPECTATION (was "invalid check-in ID"): SF1 made `update`'s // argument a check-in NAME resolved through the shared §8.3 procedure, // so a non-hex string is no longer malformed by construction — it is a // symbolic name that resolves to nothing. The property this test exists // for is unchanged and still asserted below: exit 1, a `zit update: ` // diagnostic, and a byte-identical repository. .{ .id = "not-an-id", .diagnostic = "not found" }, .{ .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 [-n N] [-R <path>] CHECKIN [PATH] \\ \\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. \\ \\With PATH, show only the reachable check-ins that change PATH — where \\PATH's effective content differs from its primary parent's, including \\where PATH is added or deleted. PATH is an exact repo-relative path, not \\a pattern; a path that never changed yields no output. \\ \\options: \\ -n, --limit N show only the N most recent entries; 0 means all \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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, ); // The second positional now binds PATH; a THIRD positional is the extra // argument (B-log-8). var extra = try runZit(gpa, io, tmp.dir, &.{ "log", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "path", "third" }); defer freeResult(gpa, &extra); try expectExit(extra.term, 2); try std.testing.expectEqualStrings("", extra.stdout); try std.testing.expectEqualStrings( "zit log: unexpected argument 'third'\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 log CHECKIN PATH` end-to-end — the finfo fold (contract B-log-2..9). // Each test builds its DAG through real commits (add/modify/`rm`/`mv`/`merge`) // and asserts the filtered walk names exactly the check-ins that change PATH, // in the unfiltered DFS order (B-log-1's layout preserved). Teardown is // unconditional. // ========================================================================== test "cli log B-log-2/-4/-1: PATH lists only the check-ins that change it; no-PATH is 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); // root: a.txt is added (a root's presence alone is a change). try writeFile(io, tmp.dir, "a.txt", "one\n"); try addManaged(gpa, io, tmp.dir, &.{"a.txt"}); const root = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(root); // c2: a.txt modified (content hash differs from root's). try writeFile(io, tmp.dir, "a.txt", "two\n"); const c2 = try commitHashDup(gpa, io, tmp.dir, "modify-a"); defer gpa.free(c2); // c3: b.txt added; a.txt untouched — c3 must NOT appear in an a.txt log. try writeFile(io, tmp.dir, "b.txt", "b\n"); try addManaged(gpa, io, tmp.dir, &.{"b.txt"}); const c3 = try commitHashDup(gpa, io, tmp.dir, "add-b"); defer gpa.free(c3); // c4: a.txt modified again. try writeFile(io, tmp.dir, "a.txt", "three\n"); const c4 = try commitHashDup(gpa, io, tmp.dir, "modify-a-again"); defer gpa.free(c4); // B-log-2/-4: filtered by a.txt = exactly {c4 modify, c2 modify, root add}, // in DFS order; c3 (b.txt only) is walked but not named. var res = try runZit(gpa, io, tmp.dir, &.{ "log", c4, "a.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var wbuf: [256]u8 = undefined; const want = try std.fmt.bufPrint(&wbuf, "checkin {s}\ncheckin {s}\ncheckin {s}\n", .{ c4, c2, root }); try std.testing.expectEqualStrings(want, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // B-log-1: the no-PATH walk is unchanged — every reachable check-in, DFS. var full = try runZit(gpa, io, tmp.dir, &.{ "log", c4 }); defer freeResult(gpa, &full); try expectExit(full.term, 0); var fbuf: [320]u8 = undefined; const fwant = try std.fmt.bufPrint(&fbuf, "checkin {s}\ncheckin {s}\ncheckin {s}\ncheckin {s}\n", .{ c4, c3, c2, root }); try std.testing.expectEqualStrings(fwant, full.stdout); try std.testing.expectEqualStrings("", full.stderr); // B-log-6: a path that never existed in the span yields empty stdout, exit 0. var none = try runZit(gpa, io, tmp.dir, &.{ "log", c4, "never.txt" }); defer freeResult(gpa, &none); try expectExit(none.term, 0); try std.testing.expectEqualStrings("", none.stdout); try std.testing.expectEqualStrings("", none.stderr); // B-log-7: PATH is a path, not a pattern — a glob matches no entry (empty). var pat = try runZit(gpa, io, tmp.dir, &.{ "log", c4, "a.*" }); defer freeResult(gpa, &pat); try expectExit(pat.term, 0); try std.testing.expectEqualStrings("", pat.stdout); try std.testing.expectEqualStrings("", pat.stderr); } test "cli log B-log-4: adds, deletes, and renames are changes at the affected paths" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // r: x.txt and old.txt both added. try writeFile(io, tmp.dir, "x.txt", "x\n"); try writeFile(io, tmp.dir, "old.txt", "old\n"); try addManaged(gpa, io, tmp.dir, &.{ "x.txt", "old.txt" }); const r = try commitHashDup(gpa, io, tmp.dir, "add-both"); defer gpa.free(r); // d: x.txt deleted (old.txt untouched). var rm = try runZit(gpa, io, tmp.dir, &.{ "rm", "x.txt" }); defer freeResult(gpa, &rm); try expectExit(rm.term, 0); const d = try commitHashDup(gpa, io, tmp.dir, "delete-x"); defer gpa.free(d); // m: old.txt renamed to new.txt (a delete+add at the two names). var mv = try runZit(gpa, io, tmp.dir, &.{ "mv", "old.txt", "new.txt" }); defer freeResult(gpa, &mv); try expectExit(mv.term, 0); const m = try commitHashDup(gpa, io, tmp.dir, "rename-old-to-new"); defer gpa.free(m); // x.txt changed at its add (r) and its delete (d); m does not touch it. var lx = try runZit(gpa, io, tmp.dir, &.{ "log", m, "x.txt" }); defer freeResult(gpa, &lx); try expectExit(lx.term, 0); var xb: [256]u8 = undefined; try std.testing.expectEqualStrings( try std.fmt.bufPrint(&xb, "checkin {s}\ncheckin {s}\n", .{ d, r }), lx.stdout, ); try std.testing.expectEqualStrings("", lx.stderr); // old.txt: added at r, departs at the rename m; unchanged at d. var lo = try runZit(gpa, io, tmp.dir, &.{ "log", m, "old.txt" }); defer freeResult(gpa, &lo); try expectExit(lo.term, 0); var ob: [256]u8 = undefined; try std.testing.expectEqualStrings( try std.fmt.bufPrint(&ob, "checkin {s}\ncheckin {s}\n", .{ m, r }), lo.stdout, ); try std.testing.expectEqualStrings("", lo.stderr); // new.txt: arrives only at the rename m (no old-name following). var ln = try runZit(gpa, io, tmp.dir, &.{ "log", m, "new.txt" }); defer freeResult(gpa, &ln); try expectExit(ln.term, 0); var nb: [128]u8 = undefined; try std.testing.expectEqualStrings( try std.fmt.bufPrint(&nb, "checkin {s}\n", .{m}), ln.stdout, ); try std.testing.expectEqualStrings("", ln.stderr); } test "cli log B-log-3: the PATH filter sees full ancestry across a merge 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); // base: poem added. try writeFile(io, tmp.dir, "poem", "base\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const base = try commitHashDup(gpa, io, tmp.dir, "base"); defer gpa.free(base); // other: poem changed on a side line reachable only via the merge parent. try writeFile(io, tmp.dir, "poem", "otherchange\n"); const other = try commitHashDup(gpa, io, tmp.dir, "other"); defer gpa.free(other); // current: back at base, add side.txt; poem is left at base content. var up = try runZit(gpa, io, tmp.dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "side.txt", "s\n"); try addManaged(gpa, io, tmp.dir, &.{"side.txt"}); const current = try commitHashDup(gpa, io, tmp.dir, "current"); defer gpa.free(current); // Merge other in (poem changed only on the other side → clean), then record // the merge as a check-in whose primary parent is current, merge parent other. var mg = try runZit(gpa, io, tmp.dir, &.{ "merge", other }); defer freeResult(gpa, &mg); try expectExit(mg.term, 0); const merge_ci = try commitHashDup(gpa, io, tmp.dir, "merge"); defer gpa.free(merge_ci); // Filtered by poem: merge_ci (adopts other's content vs current), base (add), // and other (the merged-in change) — current is walked but unchanged for poem. // `other` is reachable ONLY through the merge parent, so its presence proves // the walk spans full ancestry (a mainline-only walk would omit it). var res = try runZit(gpa, io, tmp.dir, &.{ "log", merge_ci, "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var wbuf: [320]u8 = undefined; const want = try std.fmt.bufPrint(&wbuf, "checkin {s}\ncheckin {s}\ncheckin {s}\n", .{ merge_ci, base, other }); try std.testing.expectEqualStrings(want, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli log B-log-9: PATH adds no new refusal surface — an unresolvable CHECKIN still fails cleanly" { 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, "a.txt" }); 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); } // ========================================================================== // `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) \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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 [-n N] [-R <path>] [-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: \\ -n, --limit N show only the N most recent entries; 0 means all \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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) \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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 = "", .special_op = "", .manifest_flags = "" }); 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] \\ zit tag add NAME CHECKIN \\ \\List tags, or give a check-in a name. With no CHECKIN, list prints the tag \\names in use across the repository, one per line, sorted. With a CHECKIN, \\it prints the tags visible on that check-in and their values. \\ \\add names one check-in: NAME then resolves to it everywhere a check-in \\name is accepted. The name stays on that check-in — unlike a branch name, \\it is not inherited by the check-ins committed after it. \\ \\ 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) \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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(); // `add` was this test's stand-in for "not a subcommand"; it IS one now // (REGRESSION B6), so the case uses a word that still is not. var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "cancel" }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit tag: unknown subcommand 'cancel'\ntry 'zit tag --help'\n", res.stderr, ); } test "cli REGRESSION B6: tag add's two positionals are both required (exit 2, stderr)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var no_name = try runZit(gpa, io, tmp.dir, &.{ "tag", "add" }); defer freeResult(gpa, &no_name); try expectExit(no_name.term, 2); try std.testing.expectEqualStrings("", no_name.stdout); try std.testing.expectEqualStrings( "zit tag: no name given\ntry 'zit tag --help'\n", no_name.stderr, ); var no_checkin = try runZit(gpa, io, tmp.dir, &.{ "tag", "add", "v1" }); defer freeResult(gpa, &no_checkin); try expectExit(no_checkin.term, 2); try std.testing.expectEqualStrings("", no_checkin.stdout); try std.testing.expectEqualStrings( "zit tag: no check-in given\ntry 'zit tag --help'\n", no_checkin.stderr, ); } test "cli REGRESSION B6: tag add on an unresolvable CHECKIN mints nothing (exit 1)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try b6BuildRoot(gpa, io, tmp.dir); defer gpa.free(root); { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", "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); } // Resolution precedes the mint, so the failed run left no tag behind. { 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); } } 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) \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -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); } } // ========================================================================== // `zit artifact` end-to-end (contracts/artifact.md B-artifact-1..14). zit's // plumbing window: a read-only describe (`whatis`) / dump (`--raw`) of ONE // stored artifact addressed by its hash — every artifact kind, file blobs // included. Card interpretation flows through the ONE artifact parser; the // oracles here are RFC-derived (sha3-256 of content for a blob name; the // stored manifest bytes read back through the library), never fossil. // ========================================================================== const artifact_help_expected = \\usage: zit artifact HASH [--raw] \\ \\Describe or dump a single stored artifact addressed by its hash. HASH is a full \\or abbreviated (>=4 character) lowercase hexadecimal artifact ID, resolved \\against every stored artifact — file blobs included. Branch and tag names are \\not accepted; use 'zit info' for those. \\ \\Without --raw, print a summary: the full hash, the artifact's size, its type, \\and — for a structural artifact — one line per card and whether its Z \\self-checksum verifies. A blob reports its size and whether it is binary. \\ \\With --raw, write the artifact's exact stored bytes to standard output, \\verbatim — no trailing newline is added and binary content passes through \\unchanged. Hashing those bytes reproduces HASH. \\ \\options: \\ --raw write the exact stored bytes instead of a summary \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -h, --help show this help and exit \\ ; /// Lowercase-hex sha3-256 of `content` — the §7.2 artifact name of stored /// content, copied onto `gpa` (caller frees). The RFC-derived blob-hash oracle. fn sha3HexAlloc(gpa: std.mem.Allocator, content: []const u8) ![]u8 { var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); const hex = "0123456789abcdef"; const out = try gpa.alloc(u8, 64); for (digest, 0..) |b, i| { out[i * 2] = hex[b >> 4]; out[i * 2 + 1] = hex[b & 0x0f]; } return out; } /// Appends one artifact frame keyed by `digest` (its raw 32-byte name) holding /// `content`, straight into the repo's `.zit` through the library — the only /// way to seed an artifact the CLI itself would never mint: a Z-mismatched /// structural artifact (B-artifact-5) and a prefix-colliding pair /// (B-artifact-7). A read-write reopen + append + commit, exactly like a real /// writer. The derive tolerates a non-parseable/Z-mismatched frame as an opaque /// blob (repoview.linkOne "crosslink whatever parses"), so `repo.open` succeeds. fn injectFrame(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, digest: [32]u8, content: []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.put(&digest, content); try log.commit(); } test "cli artifact B-artifact-1: a manifest summary carries the hash, type, size, and one line per 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); 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 ck = try gpa.dupe(u8, try checkinHash(commit.stdout)); defer gpa.free(ck); // The stored-manifest byte length is the size oracle, read back through the // library (never a hand-count). var mc = try parseCheckin(gpa, io, tmp.dir, ck); defer mc.deinit(); const size = mc.bytes.len; var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", ck }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // stream discipline // Header line is the full hash; the type and size lines are exact. var lines = std.mem.splitScalar(u8, res.stdout, '\n'); try std.testing.expectEqualStrings(ck, lines.first()); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: manifest\n") != null); const size_line = try std.fmt.allocPrint(gpa, "size: {d} bytes\n", .{size}); defer gpa.free(size_line); try std.testing.expect(std.mem.indexOf(u8, res.stdout, size_line) != null); // One F line per file, each `F <name> <sha3(content)>` (§4.7.6 decoded via // the ONE parser). The blob hashes are RFC-derived here. const a_hash = try sha3HexAlloc(gpa, "alpha\n"); defer gpa.free(a_hash); const b_hash = try sha3HexAlloc(gpa, "beta\n"); defer gpa.free(b_hash); const a_line = try std.fmt.allocPrint(gpa, "\nF a.txt {s}\n", .{a_hash}); defer gpa.free(a_line); const b_line = try std.fmt.allocPrint(gpa, "\nF b.txt {s}\n", .{b_hash}); defer gpa.free(b_line); try std.testing.expect(std.mem.indexOf(u8, res.stdout, a_line) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, b_line) != null); // The comment, user, and the verified Z verdict are shown too. try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nC root\n") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nU u\n") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: verified\n") != null); } test "cli artifact B-artifact-2: a control artifact reports type control and its T cards' sign, name, target" { 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); // `amend --branch` mints a control artifact whose T cards move the check-in // onto a new branch (§4.7.16): a `*sym-feature` add and a `-sym-trunk` // cancel, both targeting the root check-in. var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "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); var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", control_hash }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); var lines = std.mem.splitScalar(u8, res.stdout, '\n'); try std.testing.expectEqualStrings(control_hash, lines.first()); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: control\n") != null); // T <sign><name> <target>: sign+name glued, self/hash target rendered. const sym_add = try std.fmt.allocPrint(gpa, "\nT *sym-feature {s}\n", .{root_hash}); defer gpa.free(sym_add); const sym_cancel = try std.fmt.allocPrint(gpa, "\nT -sym-trunk {s}\n", .{root_hash}); defer gpa.free(sym_cancel); try std.testing.expect(std.mem.indexOf(u8, res.stdout, sym_add) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, sym_cancel) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: verified\n") != null); } test "cli artifact B-artifact-3: a file blob reports type file, its size, and its binary/text verdict" { 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 text file and a NUL-bearing binary file. Each blob's name is the // §7.2 sha3 of its content. const text = "plain text content\n"; var binary: [64]u8 = undefined; for (&binary, 0..) |*b, i| b.* = @intCast(i % 256); // includes NUL at 0, no trailing LF try writeFile(io, tmp.dir, "t.txt", text); try writeFile(io, tmp.dir, "b.bin", &binary); try addManaged(gpa, io, tmp.dir, &.{ "t.txt", "b.bin" }); var commit = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); const text_hash = try sha3HexAlloc(gpa, text); defer gpa.free(text_hash); const bin_hash = try sha3HexAlloc(gpa, &binary); defer gpa.free(bin_hash); // Text blob: type file, exact size, binary: no. No card listing, no Z line. { var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", text_hash }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); const expected = try std.fmt.allocPrint(gpa, "{s}\ntype: file\nsize: {d} bytes\nbinary: no\n", .{ text_hash, text.len }); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); } // Binary blob: binary: yes, exact size. { var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", bin_hash }); defer freeResult(gpa, &res); try expectExit(res.term, 0); const expected = try std.fmt.allocPrint(gpa, "{s}\ntype: file\nsize: {d} bytes\nbinary: yes\n", .{ bin_hash, binary.len }); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stdout); } } test "cli artifact B-artifact-4: --raw is byte-exact and its bytes hash back to HASH for manifest, control, and blob" { 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); // one manifest + one blob "v1\n" defer gpa.free(root_hash); var am = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "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); const blob_hash = try sha3HexAlloc(gpa, "v1\n"); defer gpa.free(blob_hash); // For each kind: --raw emits the exact stored bytes, and sha3-256 of those // bytes reproduces HASH (§3.4 round-trip). The blob additionally equals its // source content verbatim — no trailing newline added. for ([_][]const u8{ root_hash, control_hash, blob_hash }) |h| { var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", h, "--raw" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); const round = try sha3HexAlloc(gpa, res.stdout); defer gpa.free(round); try std.testing.expectEqualStrings(h, round); // hashing the emitted bytes reproduces HASH } var blob = try runZit(gpa, io, tmp.dir, &.{ "artifact", blob_hash, "--raw" }); defer freeResult(gpa, &blob); try expectExit(blob.term, 0); try std.testing.expectEqualStrings("v1\n", blob.stdout); // verbatim, no added framing } test "cli artifact B-artifact-5: a Z-mismatched structural artifact reports the mismatch, exit 0 (window, not gate)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Build a structural manifest (C ⇒ §5.3 MANIFEST fallback) and seal it with // a DELIBERATELY WRONG §6.2 Z digest, then inject it under its real §7.2 // name so `blob` accepts it. `parse` (gate 4) would reject it — the derive // therefore skips it as a blob — but the summary's `parseWindow` assembles // it and REPORTS the mismatch. const body = "C zmismatch\nD 2026-01-01T00:00:00\n"; var zbuf: [32]u8 = undefined; const correct = artifact.selfChecksum(body, &zbuf); var full: std.ArrayList(u8) = .empty; defer full.deinit(gpa); try full.appendSlice(gpa, body); try full.appendSlice(gpa, "Z "); var bad: [32]u8 = undefined; @memcpy(&bad, correct); bad[0] = if (correct[0] == 'a') 'b' else 'a'; // corrupt one hex digit ⇒ mismatch try full.appendSlice(gpa, &bad); try full.append(gpa, '\n'); var raw_digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(full.items, &raw_digest, .{}); try injectFrame(gpa, io, tmp.dir, raw_digest, full.items); const name = try sha3HexAlloc(gpa, full.items); defer gpa.free(name); var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", name }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // mismatch is reported, not enforced try std.testing.expectEqualStrings("", res.stderr); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: manifest\n") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nZ: checksum mismatch\n") != null); } test "cli artifact B-artifact-6: an uppercase abbreviated prefix resolves; the header carries the full lowercase hash" { const 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, "only.txt", "unique blob 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); const blob_hash = try sha3HexAlloc(gpa, "unique blob content\n"); defer gpa.free(blob_hash); // A 12-char UPPERCASE prefix — case-insensitive resolution (§3.6). var upper: [12]u8 = undefined; for (blob_hash[0..12], 0..) |c, i| upper[i] = std.ascii.toUpper(c); var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", &upper }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var lines = std.mem.splitScalar(u8, res.stdout, '\n'); try std.testing.expectEqualStrings(blob_hash, lines.first()); // full LOWERCASE hash, however addressed try std.testing.expect(std.mem.indexOf(u8, res.stdout, "type: file\n") != null); } test "cli artifact B-artifact-7: an ambiguous prefix is refused with exit 1, diagnostic 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); // Two injected artifacts whose names both begin "abcd" (§8.3 item 8 makes a // multi-match a refusal, never a silent pick). The prefix names both. const d1 = [_]u8{ 0xab, 0xcd, 0x00 } ++ [_]u8{0} ** 29; const d2 = [_]u8{ 0xab, 0xcd, 0x11 } ++ [_]u8{0} ** 29; try injectFrame(gpa, io, tmp.dir, d1, "one"); try injectFrame(gpa, io, tmp.dir, d2, "two"); var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", "abcd" }); 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, "ambiguous") != null); } test "cli artifact B-artifact-8: an unknown hash and a syntactically invalid one are one not-found family (exit 1, 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); 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 well-formed but unknown hex hash, a non-hex token, a too-short token, // and a too-long token all refuse identically — no distinct error surface. for ([_][]const u8{ "deadbeefdeadbeef", "zznothex", "abc", "a" ** 65 }) |bad| { var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", bad }); 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, "no artifact matches") != null); } } test "cli artifact B-artifact-9: no invocation mutates .zit, .zit-checkout, or the working tree" { const 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", "content\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 ck = try gpa.dupe(u8, try checkinHash(commit.stdout)); defer gpa.free(ck); const blob_hash = try sha3HexAlloc(gpa, "content\n"); defer gpa.free(blob_hash); const zit_before = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20)); defer gpa.free(zit_before); const ck_before = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(ck_before); const tree_before = try readFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(tree_before); // A summary, a raw dump, and two refusals — none may touch stored state. inline for (.{ &[_][]const u8{ "artifact", ck }, &[_][]const u8{ "artifact", ck, "--raw" }, &[_][]const u8{ "artifact", blob_hash }, &[_][]const u8{ "artifact", "deadbeef" }, }) |tail| { var res = try runZit(gpa, io, tmp.dir, tail); freeResult(gpa, &res); } const zit_after = try tmp.dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 20)); defer gpa.free(zit_after); const ck_after = try readFile(gpa, io, tmp.dir, ".zit-checkout"); defer gpa.free(ck_after); const tree_after = try readFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(tree_after); try std.testing.expectEqualSlices(u8, zit_before, zit_after); try std.testing.expectEqualSlices(u8, ck_before, ck_after); try std.testing.expectEqualSlices(u8, tree_before, tree_after); } test "cli artifact B-artifact-10: -h/--help and 'help artifact' 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, &.{ "artifact", flag }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(artifact_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "artifact" }); defer freeResult(gpa, &routed); try expectExit(routed.term, 0); try std.testing.expectEqualStrings(artifact_help_expected, routed.stdout); } test "cli artifact B-artifact-11: missing HASH, an unknown option, and a second positional each exit 2 to stderr" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const cases = [_]struct { tail: []const []const u8, needle: []const u8 }{ .{ .tail = &.{"artifact"}, .needle = "zit artifact: no hash given\n" }, .{ .tail = &.{ "artifact", "--bogus" }, .needle = "zit artifact: unknown option '--bogus'\n" }, .{ .tail = &.{ "artifact", "abcd", "efgh" }, .needle = "zit artifact: unexpected argument 'efgh'\n" }, }; for (cases) |c| { var res = try runZit(gpa, io, tmp.dir, c.tail); 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, c.needle) != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "try 'zit artifact --help'\n") != null); } } test "cli artifact B-artifact-12/nongoal: outside a repo exits 1 with not-a-repo; a symbolic name is a not-found refusal" { const gpa = std.testing.allocator; const io = std.testing.io; // (a) Outside any repository: the standard not-a-repo diagnostic, exit 1, // empty stdout (openRepo runs before hash resolution). { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var res = try runZit(gpa, io, tmp.dir, &.{ "artifact", "deadbeef" }); 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); } // (b) A symbolic name (a branch) is NOT an artifact address — `zit info` // owns names — so `zit artifact trunk` is the not-found refusal, exit 1. { 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, &.{ "artifact", "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, "no artifact matches 'trunk'") != null); } } test "cli artifact B-artifact-14: help lists artifact and did-you-mean suggests it; there is no whatis sibling verb" { 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, "artifact") != null); // `whatis` is not a zit verb — the summary IS the whatis mode (single // spelling). It resolves to unknown-command, exit 2. var whatis = try runZit(gpa, io, tmp.dir, &.{ "whatis", "deadbeef" }); defer freeResult(gpa, &whatis); try expectExit(whatis.term, 2); try std.testing.expect(std.mem.indexOf(u8, whatis.stderr, "unknown command 'whatis'") != null); } // --- 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; /// Allocates a fresh loopback port for a child `zit serve` by binding /// `127.0.0.1:0`, reading back the kernel-assigned port, then closing the /// reservation listener so the child can bind it. Every server-interop scenario /// draws its port here instead of a hardcoded constant, so overlapping runs of /// this suite (or unrelated host load) never collide on a fixed port — the /// defect behind issue #12, where a concurrent run's `zit serve` failed to bind /// a shared port and the client's push crossed to the wrong server. /// /// The reservation listener closes before the child binds, so a close→child-bind /// race window remains; it is unavoidable because a child server cannot inherit /// the listener, and it is orders of magnitude narrower than a fixed-port /// collision. This mirrors `freshLoopbackPort` in src/smoke.zig (the 0.1.1 work /// that moved the Fossil smoke tests off fixed ports for the same reason); /// duplicated rather than shared because e2e and smoke are independent test /// binaries that already each carry their own `waitForListen`. fn freshLoopbackPort(io: std.Io) !u16 { const addr = try net.IpAddress.parseIp4("127.0.0.1", 0); var server = try addr.listen(io, .{}); const port = server.socket.address.getPort(); server.deinit(io); if (port == 0) return error.NoLoopbackPort; return port; } /// 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 = try freshLoopbackPort(io); 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 L815-819 — 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 expectExitDiag(clone.term, 0, clone); 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); } // --- §9.3 configuration exchange: the project-scope submanifest settings --- // // draft-fossil-submanifests-00 §6.3 requires the partition settings to reach // every writer of a project ("configuration propagating to clones ... is // REQUIRED"); §8.1 says the same of `enable-submanifests`, and §6.4 adds // `submanifest-auto-retune`. [FossilSync] §9.3's scalar // `/config` channel is the transport. These tests run TWO real repositories // over real HTTP — the gap the feature closes is exactly "the clone comes up // with different settings from the origin", which nothing below the CLI can // observe. /// The stored `submanifest-auto-retune`, read back through the library. The /// §6.4 setting is not in `submanifest.Settings` (which models only §6.3's /// partition state), so it is read off the config frame directly. fn readAutoRetune(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !bool { 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 oc.config.submanifest_auto_retune; } /// POST one hand-built request-card stream to a running `zit serve` and return /// the decoded reply cards. A raw socket rather than `zit push`: the §10.3 r30 /// `config` card is one no zit client emits, so there is no verb that produces /// this request and the wire has to be written by hand. Caller owns the result. fn postXferCards(gpa: std.mem.Allocator, io: std.Io, port: u16, request_cards: []const u8) ![]u8 { const framed = try container.encode(gpa, request_cards, .default); defer gpa.free(framed); const stream = try dialServe(io, port); defer stream.close(io); { var wbuf: [4096]u8 = undefined; var w = stream.writer(io, &wbuf); try w.interface.print( "POST /xfer HTTP/1.1\r\nHost: z\r\nContent-Type: application/x-fossil\r\n" ++ "Content-Length: {d}\r\nConnection: close\r\n\r\n", .{framed.len}, ); try w.interface.writeAll(framed); try w.interface.flush(); } var rbuf: [4096]u8 = undefined; var r = stream.reader(io, &rbuf); // `keep_alive = false` on the server side means Content-Length plus a close, // so the read ends at EOF. const raw = try r.interface.allocRemaining(gpa, .limited(1 << 20)); defer gpa.free(raw); const sep = std.mem.indexOf(u8, raw, "\r\n\r\n") orelse return error.TestUnexpectedResult; return container.decode(gpa, raw[sep + 4 ..], 1 << 20); } test "cli clone submanifests §6.3/§8.1: a clone adopts the origin's three project-scope settings" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: a real check-in, then all three settings moved off their // defaults. `enable` is the verb surface; `submanifest-auto-retune` has no // flag yet, so it is written through the config frame (see `forceAutoRetune`). 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); { var en = try runZit(gpa, io, tmp_a.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &en); try expectExitDiag(en.term, 0, en); } try forceAutoRetune(gpa, io, tmp_a.dir, true); const port = try freshLoopbackPort(io); 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}/", .{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 expectExitDiag(clone.term, 0, clone); try std.testing.expectEqualStrings("", clone.stderr); // THE PROOF: B came up with A's settings, not with the `init` defaults — // §6.3's project scope, over the §9.3 scalar channel, between two real // repositories. A clone that ignored the config card fails here. var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); const b = try readSubmanifestSettings(gpa, io, b_dir); try std.testing.expect(b.enabled); try std.testing.expectEqual(@as(u8, 3), b.k); try std.testing.expect(try readAutoRetune(gpa, io, b_dir)); // The same three values as the operator sees them in the clone. { var show = try runZit(gpa, io, b_dir, &.{ "submanifest", "show" }); defer freeResult(gpa, &show); try expectExitDiag(show.term, 0, show); try std.testing.expect(std.mem.indexOf(u8, show.stdout, "enable-submanifests: on") != null); try std.testing.expect(std.mem.indexOf(u8, show.stdout, "submanifest-k: 3") != null); try std.testing.expect(std.mem.indexOf(u8, show.stdout, "submanifest-auto-retune: on") != null); } // And they are A's values, read back from A rather than restated. const a = try readSubmanifestSettings(gpa, io, tmp_a.dir); try std.testing.expectEqual(a.enabled, b.enabled); try std.testing.expectEqual(a.k, b.k); try std.testing.expectEqual( try readAutoRetune(gpa, io, tmp_a.dir), try readAutoRetune(gpa, io, b_dir), ); } test "cli clone submanifests §8.1: cloning a defaulted origin yields a defaulted clone" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: never configured. The origin's answer is three explicit // default-valued rows, not silence, so this pins that adopting them leaves // the clone exactly where `init` put it — off, unpinned, no auto-retune. 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", "plain repository\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); const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); 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}/", .{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 expectExitDiag(clone.term, 0, clone); try std.testing.expectEqualStrings("", clone.stderr); var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); const b = try readSubmanifestSettings(gpa, io, b_dir); try std.testing.expect(!b.enabled); try std.testing.expect(!b.pinned()); try std.testing.expect(!try readAutoRetune(gpa, io, b_dir)); // The clone is otherwise unchanged: the configuration round costs the // convergence nothing. const b_digests = try collectArtifactDigests(gpa, io, b_dir); defer freeDigests(gpa, b_digests); try expectSameDigestSet(a_digests, b_digests); } test "cli serve §10.3 r31: an unauthorized pushed config card is refused and changes nothing" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: enabled at k=3, auto-retune off — a settled configuration a // stranger is about to try to rewrite. 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", "served repository\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); { var en = try runZit(gpa, io, tmp_a.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &en); try expectExitDiag(en.term, 0, en); } const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // §10.3 r30 — a well-formed scalar config push, from nobody: `zit serve` // holds no credential, so no requester can ever reach Admin. var payload_buf: [sync.submanifest_config_payload_max]u8 = undefined; const payload = try sync.writeSubmanifestConfigPayload(false, 11, true, &payload_buf); const request = try std.fmt.allocPrint( gpa, "pragma client-version 20000\nconfig /config {d}\n{s}\n", .{ payload.len, payload }, ); defer gpa.free(request); const reply = try postXferCards(gpa, io, port, request); defer gpa.free(reply); // §10.3 r31 L2427-2429 — the exact required text (§11.1 r2 fossilizes its // spaces on the wire), and nothing else in the reply: the rejection resets // the partial output. try std.testing.expectEqualStrings("error not\\sauthorized\\sto\\spush\\sconfiguration\n", reply); // THE PROOF: A's stored configuration is byte-for-byte what it was. A // server that applied the push would show k=11 and auto-retune on here. const a = try readSubmanifestSettings(gpa, io, tmp_a.dir); try std.testing.expect(a.enabled); try std.testing.expectEqual(@as(u8, 3), a.k); try std.testing.expect(!try readAutoRetune(gpa, io, tmp_a.dir)); } test "cli serve §9.3: a peer that never asks for configuration still clones unchanged" { const gpa = std.testing.allocator; const io = std.testing.io; // The interop case for a peer predating this feature: a request that // carries NO reqconfig must draw no config card and no error — the "group // mask zero" no-op extended to a peer that simply does not ask. Simulated // at the wire, because every zit clone now does ask. 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", "served repository\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); { var en = try runZit(gpa, io, tmp_a.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &en); try expectExitDiag(en.term, 0, en); } const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); const silent = try postXferCards(gpa, io, port, "pragma client-version 20000\npull s p\n"); defer gpa.free(silent); try std.testing.expect(std.mem.indexOf(u8, silent, "config") == null); try std.testing.expect(std.mem.indexOf(u8, silent, "error") == null); // A request for a group this server does not model is the same silent // no-op, and the live clone path over the same server still converges. const table = try postXferCards(gpa, io, port, "pragma client-version 20000\nreqconfig /shun\n"); defer gpa.free(table); try std.testing.expect(std.mem.indexOf(u8, table, "config /") == null); try std.testing.expect(std.mem.indexOf(u8, table, "error") == null); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); try expectCloneConverges(gpa, io, &tmp_a, url, a_digests); } // --- pull interop (sync §3.2/§3.3/§3.7) ------------------------------------ // // The pull milestone e2e: an incremental `zit pull` into an EXISTING repo B over // loopback HTTP. B is cloned from A, then A grows a second check-in, then B pulls // A's new artifacts and its on-disk set must equal A's new full set — proving the // pull client fetched exactly what B lacked and converged (sync §3.7), over the // wire, not from any fossil oracle. `zit serve` reads a fixed snapshot of the // repository at startup, so A is re-served after the second commit; the restart // is a harness detail, not part of what pull is asked to prove. test "cli pull: an incremental pull fetches a repo's new artifacts over HTTP (sync §3.2/§3.3/§3.7)" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: init + a committed file — A's first check-in, real artifacts to seed. var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); try initRepo(gpa, io, tmp_a.dir); try writeFile(io, tmp_a.dir, "one.txt", "first artifact over http\n"); try addManaged(gpa, io, tmp_a.dir, &.{"one.txt"}); var commit1 = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" }); defer freeResult(gpa, &commit1); try expectExit(commit1.term, 0); // Serve A's seed state and clone it into B; B now shares A's project-code and // holds A's first-check-in artifacts (the pull starts from a real subset). const clone_port = try freshLoopbackPort(io); 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 expectExitDiag(clone.term, 0, clone); // 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 = try freshLoopbackPort(io); 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 expectExitDiag(pull.term, 0, pull); 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 = try freshLoopbackPort(io); 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 expectExitDiag(clone.term, 0, clone); 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 expectExitDiag(pull.term, 0, pull); 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 = try freshLoopbackPort(io); 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 expectExitDiag(clone.term, 0, clone); 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 = try freshLoopbackPort(io); 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 expectExitDiag(push.term, 0, push); 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); } // --- real-socket robustness (serve accept/read loop) ----------------------- // // `serve_fuzz` drives the in-memory `driveHttp` seam; the socket accept/read // loop — the part that can HANG — is only exercised by the happy-path interop // tests above. `zit serve` accepts one connection to completion before the next // (single-threaded, `serve.zig`), so a peer that connects and then stalls its // request — a byte-dribbling slow-loris, a half-sent body, an idle hold — would // pin the accept loop and wedge the server for every other client (the hung // spawned server `ci.yml` names as the feared failure). These four scenarios // abuse a LIVE `zit serve` over loopback and assert, with bounded waits, that // the server survives: a fresh request is still answered within a bounded // window, and a full `clone` sync round still converges. The bound is the // per-connection deadline in `serve.zig` (`default_conn_read_timeout_ns`); the // stalled connection is abandoned when it wins, freeing the loop. /// Open a raw loopback TCP connection to a live `zit serve` on `port`. Unlike a /// `zit` client this sends bytes the harness controls verbatim, so it can stall /// mid-request. Caller `close`s the returned stream. fn dialServe(io: std.Io, port: u16) !net.Stream { const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress; return addr.connect(io, .{ .mode = .stream }); } /// Read the first bytes of the server's HTTP reply on `stream`, returning true /// iff they are a status line (`HTTP/…`). Run as a `Select` task so a wedged /// server (no reply ever comes) is a cancellation point the deadline interrupts. fn probeReadOk(io: std.Io, stream: net.Stream) bool { var buf: [64]u8 = undefined; var r = stream.reader(io, &buf); const head = r.interface.take(5) catch return false; return std.mem.eql(u8, head, "HTTP/"); } fn probeDeadline(io: std.Io, ns: u64) void { io.sleep(.{ .nanoseconds = @intCast(ns) }, .awake) catch {}; } /// Bounded liveness proof: open a FRESH connection, send a well-formed HTTP /// request head, and require a status-line reply within `window_ns`. On a /// single-threaded server this reply cannot arrive until any prior stalled /// connection is abandoned by the serve deadline, so `window_ns` must exceed it; /// the race converts a persistent wedge into `error.ServerWedged` rather than an /// unbounded hang (the bounded-waits rule: interval AND deadline). A clean /// reply proves the accept loop was freed and the server still serves. fn expectServesFreshRequest(io: std.Io, port: u16, window_ns: u64) !void { const stream = dialServe(io, port) catch return error.ProbeConnectFailed; defer stream.close(io); { var wbuf: [128]u8 = undefined; var w = stream.writer(io, &wbuf); // A GET is a well-formed request the server routes and answers (with a // §4 classification status); the point is that a fresh connection is // accepted and answered at all — proof the loop is not pinned. try w.interface.writeAll("GET /xfer HTTP/1.1\r\nHost: z\r\nConnection: close\r\n\r\n"); try w.interface.flush(); } const Outcome = union(enum) { replied: bool, expired: void }; var slots: [2]Outcome = undefined; var race = std.Io.Select(Outcome).init(io, &slots); try race.concurrent(.replied, probeReadOk, .{ io, stream }); race.concurrent(.expired, probeDeadline, .{ io, window_ns }) catch { // No concurrency for the deadline task: fall back to an unbounded read // (the server is expected to reply; this only loses the wedge ceiling). const got = race.await() catch { race.cancelDiscard(); return error.ProbeCanceled; }; race.cancelDiscard(); if (got == .replied and got.replied) return; return error.ServerWedged; }; const outcome = race.await() catch { race.cancelDiscard(); return error.ProbeCanceled; }; race.cancelDiscard(); switch (outcome) { .replied => |ok| if (!ok) return error.ServerBadReply, .expired => return error.ServerWedged, } } /// Prove a full sync round still converges after the abuse: clone the served /// repo into a fresh tree and require its on-disk artifact set to equal the /// server's (`expectSameDigestSet`) — the interop oracle, unweakened. fn expectCloneConverges(gpa: std.mem.Allocator, io: std.Io, tmp_parent: *std.testing.TmpDir, url: []const u8, want: []const []const u8) !void { var clone = try runZit(gpa, io, tmp_parent.dir, &.{ "clone", url, "recover" }); defer freeResult(gpa, &clone); try expectExitDiag(clone.term, 0, clone); var b_dir = try tmp_parent.dir.openDir(io, "recover", .{}); defer b_dir.close(io); const got = try collectArtifactDigests(gpa, io, b_dir); defer freeDigests(gpa, got); try expectSameDigestSet(want, got); } /// The bounded ceiling for `expectServesFreshRequest`: a stalled connection is /// abandoned by the serve deadline (`serve.default_conn_read_timeout_ns`, 5 s), /// after which a fresh loopback request is answered in milliseconds. 15 s is 3× /// that deadline — ample slack for a loaded runner while still bounding a true /// wedge into a clean failure well inside the e2e step timeout. const serve_probe_window_ns: u64 = 15_000_000_000; /// Seed a one-commit repo in `dir` and return its artifact-digest set (the /// convergence oracle). Caller frees via `freeDigests`. fn seedServedRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![][]u8 { try initRepo(gpa, io, dir); try writeFile(io, dir, "seed.txt", "served content for robustness\n"); try addManaged(gpa, io, dir, &.{"seed.txt"}); var commit = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" }); defer freeResult(gpa, &commit); try expectExit(commit.term, 0); return collectArtifactDigests(gpa, io, dir); } test "serve robustness: a byte-dribbled slow-loris head does not wedge the server" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const a_digests = try seedServedRepo(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // Slow-loris: dribble a partial request head one byte per 50 ms and NEVER // complete it, holding the connection open. Bounded: 8 bytes × 50 ms = 400 ms // of dribble, then hold — the head terminator (\r\n\r\n) never arrives, so // `receiveHead` on the server blocks until its deadline abandons it. const loris = try dialServe(io, port); defer loris.close(io); { var wbuf: [8]u8 = undefined; var w = loris.writer(io, &wbuf); const dribble = "POST /xf"; // a partial, never-terminated request head for (dribble) |c| { w.interface.writeByte(c) catch break; w.interface.flush() catch break; io.sleep(.{ .nanoseconds = 50_000_000 }, .awake) catch {}; } } // The server survives: a fresh request is answered within the bounded window // (served only once the loris connection is abandoned by the deadline). try expectServesFreshRequest(io, port, serve_probe_window_ns); // And a full sync round still converges against the same live server. const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); try expectCloneConverges(gpa, io, &tmp_a, url, a_digests); } test "serve robustness: a valid head then a half-sent body does not wedge the server" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const a_digests = try seedServedRepo(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // Send a COMPLETE, well-formed sync request head declaring a 100-byte body, // then send only 50 bytes and HOLD. The server accepts the head, then blocks // reading the remaining body bytes that never come, until the deadline. const partial = try dialServe(io, port); defer partial.close(io); { var wbuf: [256]u8 = undefined; var w = partial.writer(io, &wbuf); try w.interface.writeAll( "POST /xfer HTTP/1.1\r\nHost: z\r\n" ++ "Content-Type: application/x-fossil\r\nContent-Length: 100\r\n" ++ "Connection: close\r\n\r\n", ); try w.interface.writeAll("x" ** 50); // half of the declared 100-byte body try w.interface.flush(); } try expectServesFreshRequest(io, port, serve_probe_window_ns); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); try expectCloneConverges(gpa, io, &tmp_a, url, a_digests); } test "serve robustness: a client that disconnects mid-response does not wedge the server" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const a_digests = try seedServedRepo(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // Send a complete well-formed request, then close the connection WITHOUT // reading the reply — the peer vanishes while the server is answering. The // server's write to a gone peer fails per-connection and the loop moves on. { const abrupt = try dialServe(io, port); var wbuf: [128]u8 = undefined; var w = abrupt.writer(io, &wbuf); try w.interface.writeAll("GET /xfer HTTP/1.1\r\nHost: z\r\nConnection: close\r\n\r\n"); try w.interface.flush(); abrupt.close(io); // disconnect without draining the response } try expectServesFreshRequest(io, port, serve_probe_window_ns); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); try expectCloneConverges(gpa, io, &tmp_a, url, a_digests); } test "serve robustness: a burst of idle connections that never send a byte does not wedge the server" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const a_digests = try seedServedRepo(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // A burst of connections that connect and immediately give up without ever // sending a byte. Each is a clean EOF the accept loop must drain and move // past — a connection-churn peer must not pin or leak the server. Bounded: // a fixed count, each opened and closed in turn (sequential, never held). const idle_connections: u32 = 5; var i: u32 = 0; while (i < idle_connections) : (i += 1) { const idle = try dialServe(io, port); idle.close(io); // zero bytes sent, then gone } try expectServesFreshRequest(io, port, serve_probe_window_ns); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); try expectCloneConverges(gpa, io, &tmp_a, url, a_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. try tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o555), .{ .follow_symlinks = false }); defer tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }) 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 tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }); 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"); try tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o555), .{ .follow_symlinks = false }); defer tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }) catch {}; var bad = try runZit(gpa, io, tmp.dir, &.{ "clean", "--force" }); defer freeResult(gpa, &bad); try expectExit(bad.term, 1); try tmp.dir.setFilePermissions(io, "sub", File.Permissions.fromMode(0o777), .{ .follow_symlinks = false }); } } 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); } // ========================================================================== // `zit addremove` (contracts/addremove.md, B-addremove-1..13). One scan, two // verdicts: every untracked file the SAME `extras.zig` sweep reports is ADDED, // every tracked file missing from disk is REMOVED — fossil/hg parity (git spells // it `add -A`). Mutates `.zit-managed` only; working files are never touched. // Each test drives the real binary; expected bytes come from the clause text and // the settled help/report strings, never from running fossil. // ========================================================================== /// The verbatim `zit addremove` help — an independent transcription of /// `main.zig`'s `addremove_help` (B-addremove-9/cli-help-verbatim-stdout-exit-0), /// so a drift between the two is caught here. const addremove_help_expected = \\usage: zit addremove [--dry-run] \\ \\Reconcile the tracked set with the working tree in one pass: mark every \\untracked file to include in your next check-in, and stop tracking every \\file you deleted from disk. This is the set `zit extras` lists, added, plus \\the tracked files now missing, removed — fossil and hg spell it `addremove`; \\git spells it `add -A`. \\ \\addremove changes only what zit tracks; it never edits, creates, or deletes \\a working file, and the next `zit commit` records the result. Each action is \\reported, sorted and relative to the checkout root: \\ \\ ADDED <path> a newly tracked file \\ REMOVED <path> a tracked file no longer on disk \\ \\A tracked file that is merely modified is left alone (content does not affect \\membership), and zit's control files are never tracked. With nothing to do, \\the output is empty. \\ \\options: \\ --dry-run report what would change, one action per line; change nothing \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the tracked set was reconciled (with or without changes), or a --dry-run preview \\ 1 a path could not be tracked, or this is not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; test "cli addremove B-addremove-1: adds two untracked, removes one missing, sorted report, 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); // Track `gone` then delete it (a missing-managed removal); leave two untracked // files in reverse-of-sorted order (an ADD each). try touch(io, tmp.dir, "gone"); try addManaged(gpa, io, tmp.dir, &.{"gone"}); try tmp.dir.deleteFile(io, "gone"); try touch(io, tmp.dir, "zebra"); try touch(io, tmp.dir, "apple"); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); // ADDED lines (sorted ascending) then the REMOVED line; diagnostics-free. try std.testing.expectEqualStrings("ADDED apple\nADDED zebra\nREMOVED gone\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // The on-disk oracle: the set is exactly the two adds (gone forgotten). try expectManaged(gpa, io, tmp.dir, &.{ "apple", "zebra" }); // `zit status` then shows the transitions and `zit extras` shows nothing. var st = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &st); try expectExit(st.term, 0); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "added apple") != null); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "added zebra") != null); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "gone") == null); var ext = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext); try expectExit(ext.term, 0); try std.testing.expectEqualStrings("", ext.stdout); } test "cli addremove B-addremove-2: the ADDED paths are `zit extras` minus symlinks — same sweep, subdirs included, control files excluded" { 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 nested file, a symlink (listed, never followed), and two root files. The // control artifacts `.zit`/`.zit-checkout` are on disk but never swept. try tmp.dir.createDirPath(io, "sub/dir"); try touch(io, tmp.dir, "sub/dir/name"); try tmp.dir.symLink(io, "target", "link", .{}); try touch(io, tmp.dir, "zebra"); try touch(io, tmp.dir, "apple"); // Capture what extras prints at this instant (the add-set oracle). var ext = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext); try expectExit(ext.term, 0); try std.testing.expectEqualStrings("apple\nlink\nsub/dir/name\nzebra\n", ext.stdout); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); // The ADDED paths are the extras set MINUS symlinks (an `ADDED ` prefix per // line, no REMOVED lines — nothing was tracked to go missing). Still ONE // sweep: a second walker would diverge on the subdir or the control names. // `link` is absent since F-B3 — 0.1.0 does not version symlinks, and // tracking one here is exactly what wedged `commit` (blocker B3). The // withholding is silent: addremove's report is a ledger of transitions, and // a skipped symlink is a non-transition (`zit extras` still shows it). try std.testing.expectEqualStrings( "ADDED apple\nADDED sub/dir/name\nADDED zebra\n", res.stdout, ); try std.testing.expectEqualStrings("", res.stderr); // The three regular files are tracked, the symlink is not, and no control // name leaked in. try expectManaged(gpa, io, tmp.dir, &.{ "apple", "sub/dir/name", "zebra" }); // And it is still an extra — withheld from tracking, not hidden. var ext2 = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &ext2); try expectExit(ext2.term, 0); try std.testing.expectEqualStrings("link\n", ext2.stdout); } test "cli addremove B-addremove-3: the rm set is the missing-managed set; a present-but-modified file is never removed or re-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); // `keep` is tracked and stays on disk but is MODIFIED in place; `gone` is // tracked and deleted. Only `gone` is a removal; `keep` is untouched. try writeFile(io, tmp.dir, "keep", "original"); try touch(io, tmp.dir, "gone"); try addManaged(gpa, io, tmp.dir, &.{ "keep", "gone" }); try writeFile(io, tmp.dir, "keep", "changed contents"); try tmp.dir.deleteFile(io, "gone"); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Exactly one removal; `keep` is neither removed nor re-added (content is // irrelevant to membership). try std.testing.expectEqualStrings("REMOVED gone\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{"keep"}); // The modified working file is byte-identical after the run (never touched). const keep_bytes = try tmp.dir.readFileAlloc(io, "keep", gpa, .limited(1 << 16)); defer gpa.free(keep_bytes); try std.testing.expectEqualStrings("changed contents", keep_bytes); } test "cli addremove B-addremove-4: --dry-run previews exactly and leaves .zit-managed byte-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, "gone"); try addManaged(gpa, io, tmp.dir, &.{"gone"}); try tmp.dir.deleteFile(io, "gone"); try touch(io, tmp.dir, "zebra"); try touch(io, tmp.dir, "apple"); // The exact `.zit-managed` bytes before the dry run. const before = try tmp.dir.readFileAlloc(io, ".zit-managed", gpa, .limited(1 << 16)); defer gpa.free(before); var dry = try runZit(gpa, io, tmp.dir, &.{ "addremove", "--dry-run" }); defer freeResult(gpa, &dry); try expectExit(dry.term, 0); try std.testing.expectEqualStrings("ADDED apple\nADDED zebra\nREMOVED gone\n", dry.stdout); try std.testing.expectEqualStrings("", dry.stderr); // `.zit-managed` is byte-identical — the dry run persisted nothing. const after = try tmp.dir.readFileAlloc(io, ".zit-managed", gpa, .limited(1 << 16)); defer gpa.free(after); try std.testing.expectEqualStrings(before, after); // Dry-run output equals the real run's report on the same tree. var real = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &real); try expectExit(real.term, 0); try std.testing.expectEqualStrings(dry.stdout, real.stdout); } test "cli addremove B-addremove-5: a clean tree (no extras, no missing) prints nothing and exits 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); // One tracked file that is present, nothing untracked: nothing to do. try touch(io, tmp.dir, "keep"); try addManaged(gpa, io, tmp.dir, &.{"keep"}); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{"keep"}); } test "cli addremove B-addremove-6/-8: a control-looking file is never added and the working tree is byte-identical afterward" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // `.zit-cache` is a never-tracked control name (B-add-7 set): present on disk // but the sweep excludes it, so addremove never tracks it — the sibling `real` // is still added, and the run succeeds. try touch(io, tmp.dir, ".zit-cache"); try writeFile(io, tmp.dir, "real", "payload"); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("ADDED real\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{"real"}); // B-addremove-8: working files are NEVER touched — every file still present // and byte-identical (no deletion, no write). try std.testing.expect(fileExists(io, tmp.dir, ".zit-cache")); const real_bytes = try tmp.dir.readFileAlloc(io, "real", gpa, .limited(1 << 16)); defer gpa.free(real_bytes); try std.testing.expectEqualStrings("payload", real_bytes); } test "cli addremove B-addremove-8: a deleted-managed file is forgotten, never recreated 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, "gone"); try addManaged(gpa, io, tmp.dir, &.{"gone"}); try tmp.dir.deleteFile(io, "gone"); var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("REMOVED gone\n", res.stdout); // addremove touches only `.zit-managed`: the deleted file is NOT resurrected. try std.testing.expect(!fileExists(io, tmp.dir, "gone")); try expectManaged(gpa, io, tmp.dir, &.{}); } test "cli addremove B-addremove-9: -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); const invocations = [_][]const []const u8{ &.{ "addremove", "--help" }, &.{ "addremove", "-h" }, &.{ "addremove", "--help", "foo" }, &.{ "addremove", "-h", "--dry-run" }, }; 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(addremove_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } } test "cli addremove B-addremove-10: an unknown option or a positional is a usage error, exit 2, stderr, empty stdout, 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); // Pre-state: a missing-managed file that a real run WOULD remove — so the // "no mutation" assertion has teeth. try touch(io, tmp.dir, "gone"); try addManaged(gpa, io, tmp.dir, &.{"gone"}); try tmp.dir.deleteFile(io, "gone"); try touch(io, tmp.dir, "extra"); const cases = [_]struct { argv: []const []const u8, tok: []const u8, kind: []const u8 }{ .{ .argv = &.{ "addremove", "--force" }, .tok = "--force", .kind = "unknown option" }, .{ .argv = &.{ "addremove", "-n" }, .tok = "-n", .kind = "unknown option" }, .{ .argv = &.{ "addremove", "foo" }, .tok = "foo", .kind = "unexpected argument" }, .{ .argv = &.{ "addremove", "--", "foo" }, .tok = "foo", .kind = "unexpected argument" }, }; 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 addremove: {s} '{s}'\ntry 'zit addremove --help'\n", .{ c.kind, c.tok }, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, res.stderr); } // No usage error mutated the set: `gone` is still tracked, `extra` still not. try expectManaged(gpa, io, tmp.dir, &.{"gone"}); } test "cli addremove B-addremove-11: 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 const invocations = [_][]const []const u8{ &.{"addremove"}, &.{ "addremove", "--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 addremove B-addremove-12: action lines to stdout, diagnostics to stderr, no interleaving under redirection" { const 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"}); try tmp.dir.deleteFile(io, "gone"); try touch(io, tmp.dir, "apple"); // The child's stdout and stderr are dup2'd onto independent OS pipes (the same // fd split any shell redirection uses). Only the ADDED/REMOVED report reaches // stdout; stderr is empty on a clean run — proving no interleaving. An in-tree // `> file` cannot be used here: the redirect target would itself be swept as an // extra and pollute the report. var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("ADDED apple\nREMOVED gone\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli addremove B-addremove-13: 'addremove' is listed in the command help, routes to its own help, and is suggested for a near-miss" { // The verb joins the single-source Verb enum, so the command list names it, // `zit help addremove` routes to the verb's own verbatim help, 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, "addremove") != null); var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "addremove" }); defer freeResult(gpa, &topic); try expectExit(topic.term, 0); try std.testing.expectEqualStrings(addremove_help_expected, topic.stdout); var typo = try runZit(gpa, io, tmp.dir, &.{"addremov"}); defer freeResult(gpa, &typo); try expectExit(typo.term, 2); try std.testing.expect(std.mem.indexOf(u8, typo.stderr, "did you mean 'addremove'?") != 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); } // --- bisect (contracts/bisect.md B-bisect-1..20) ------------------------------ /// Commits the current tree at a caller-chosen `epoch` (SOURCE_DATE_EPOCH), so a /// bisect chain has strictly increasing, deterministic event mtimes — the probe /// selection orders by ascending mtime, so distinct epochs pin every midpoint. fn bisectCommit(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, epoch: []const u8, msg: []const u8) ![]const u8 { var res = try runCommitEnv(gpa, io, dir, .{ .epoch = epoch }, &.{ "commit", "-m", msg }); defer freeResult(gpa, &res); try expectExit(res.term, 0); const h = try checkinHash(res.stdout); return gpa.dupe(u8, h); } test "cli bisect B-bisect-1/-3/-4/-7/-8: a full linear bisection narrows, names the culprit, resets" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A five-check-in chain c0<-c1<-c2<-c3<-c4 with strictly increasing mtimes. try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "v0\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const c0 = try bisectCommit(gpa, io, tmp.dir, "1000000000", "c0"); defer gpa.free(c0); try writeFile(io, tmp.dir, "poem", "v1\n"); const c1 = try bisectCommit(gpa, io, tmp.dir, "1000000100", "c1"); defer gpa.free(c1); try writeFile(io, tmp.dir, "poem", "v2\n"); const c2 = try bisectCommit(gpa, io, tmp.dir, "1000000200", "defect lands here"); defer gpa.free(c2); try writeFile(io, tmp.dir, "poem", "v3\n"); const c3 = try bisectCommit(gpa, io, tmp.dir, "1000000300", "c3"); defer gpa.free(c3); try writeFile(io, tmp.dir, "poem", "v4\n"); const c4 = try bisectCommit(gpa, io, tmp.dir, "1000000400", "c4"); defer gpa.free(c4); // The checkout is at c4 (the head) — the bisection's restore point. // B-bisect-1 first half: a lone bad is recorded WITHOUT moving the checkout. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "bad", c4 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c4[0..12]) != null); try std.testing.expectEqualStrings("", r.stderr); try std.testing.expect(fileExists(io, tmp.dir, ".zit-bisect")); try expectCheckoutVersion(io, tmp.dir, c4); } // B-bisect-1 second half: good completes the span and moves to the probe c2. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "good", c0 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c2[0..12]) != null); try std.testing.expectEqualStrings("", r.stderr); try expectCheckoutVersion(io, tmp.dir, c2); } // B-bisect-7: status reports the probe WITHOUT moving; stream to stdout. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "status" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c2[0..12]) != null); try std.testing.expectEqualStrings("", r.stderr); try expectCheckoutVersion(io, tmp.dir, c2); } // next re-announces the current probe (idempotent), checkout unchanged. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "next" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c2[0..12]) != null); try expectCheckoutVersion(io, tmp.dir, c2); } // B-bisect-3: mark the probe bad → the span narrows and moves to c1. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "bad", c2 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try expectCheckoutVersion(io, tmp.dir, c1); } // B-bisect-4: mark c1 good → span exhausted; culprit c2 named with its // comment on stdout; the checkout is moved to the culprit. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "good", c1 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "first bad check-in") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c2[0..12]) != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "defect lands here") != null); try std.testing.expectEqualStrings("", r.stderr); try expectCheckoutVersion(io, tmp.dir, c2); } // B-bisect-8: reset restores the starting check-in (c4) and clears the state. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "reset" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try expectCheckoutVersion(io, tmp.dir, c4); try std.testing.expect(!fileExists(io, tmp.dir, ".zit-bisect")); } // B-bisect-7: status with no session is exit 1, nothing on stdout. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "status" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no bisect in progress") != null); } } test "cli bisect B-bisect-16/-17: help to stdout, and the usage-error taxonomy" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // B-bisect-16: -h writes the help block to stdout, exit 0, stderr empty. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "-h" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "usage: zit bisect") != null); try std.testing.expectEqualStrings("", r.stderr); } // B-bisect-17: each offense is exit 2, nothing on stdout, a stderr diagnostic. const bad_cases = [_][]const []const u8{ &.{"bisect"}, // no subcommand &.{ "bisect", "frobnicate" }, // unknown subcommand &.{ "bisect", "bad" }, // missing NAME &.{ "bisect", "good" }, // missing NAME &.{ "bisect", "next", "extra" }, // extra argument &.{ "bisect", "status", "extra" }, // extra argument &.{ "bisect", "--wat" }, // unknown option }; for (bad_cases) |argv| { var r = try runZit(gpa, io, tmp.dir, argv); defer freeResult(gpa, &r); try expectExit(r.term, 2); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(r.stderr.len != 0); } } test "cli bisect B-bisect-13/-18: no-session refusals and run outside a repository" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // B-bisect-18: outside any repository, every subcommand is the standard // not-a-zit-repository refusal on stderr, exit 1, stdout empty. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "status" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "not a zit repository") != null); } try initRepo(gpa, io, tmp.dir); // B-bisect-13: next / reset with no session refuse exit 1. for ([_][]const u8{ "next", "reset" }) |sub| { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", sub }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no bisect in progress") != null); } // B-bisect-13: next with only one endpoint marked names what is missing. try writeFile(io, tmp.dir, "poem", "v0\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const c0 = try bisectCommit(gpa, io, tmp.dir, "1000000000", "c0"); defer gpa.free(c0); { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "bad", c0 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); } { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "next" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "good") != null); } } test "cli bisect B-bisect-9/-10: dirty-tree and inverted-marks refusals mutate nothing" { const gpa = std.testing.allocator; const io = std.testing.io; // Inverted marks: bad is an ancestor of good → refuse, state unchanged. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "v0\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const c0 = try bisectCommit(gpa, io, tmp.dir, "1000000000", "c0"); defer gpa.free(c0); try writeFile(io, tmp.dir, "poem", "v1\n"); const c1 = try bisectCommit(gpa, io, tmp.dir, "1000000100", "c1"); defer gpa.free(c1); // checkout at c1. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "bad", c0 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); } { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "good", c1 }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "inverted") != null); try expectCheckoutVersion(io, tmp.dir, c1); // no move } // State unchanged: only the bad endpoint is recorded (good not persisted). { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "status" }); defer freeResult(gpa, &r); try expectExit(r.term, 0); try std.testing.expect(std.mem.indexOf(u8, r.stdout, c0[0..12]) != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "mark a good") != null); } } // Dirty working tree: a move-completing mark refuses, checkout unchanged. { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "poem", "v0\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const c0 = try bisectCommit(gpa, io, tmp.dir, "1000000000", "c0"); defer gpa.free(c0); try writeFile(io, tmp.dir, "poem", "v1\n"); const c1 = try bisectCommit(gpa, io, tmp.dir, "1000000100", "c1"); defer gpa.free(c1); try writeFile(io, tmp.dir, "poem", "v2\n"); const c2 = try bisectCommit(gpa, io, tmp.dir, "1000000200", "c2"); defer gpa.free(c2); // checkout at c2. { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "bad", c2 }); defer freeResult(gpa, &r); try expectExit(r.term, 0); } // Dirty the tree, then complete the span → refuse over the dirty tree. try writeFile(io, tmp.dir, "poem", "uncommitted edit\n"); { var r = try runZit(gpa, io, tmp.dir, &.{ "bisect", "good", c0 }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expectEqualStrings("", r.stdout); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "not clean") != null); try expectCheckoutVersion(io, tmp.dir, c2); // no move } } } // =========================================================================== // `zit annotate PATH` (contracts/annotate.md B-annotate-1..16). Per-line // provenance of one tracked file at the current check-out, driven through the // real binary over real multi-commit histories (a linear chain and a merge // DAG built by commit → update → commit → merge → commit). // =========================================================================== /// The verbatim `zit annotate` help — must match `main.zig`'s `annotate_help` /// byte-for-byte (drift fails B-annotate-12). const annotate_help_expected = \\usage: zit annotate PATH \\ \\Show, for each line of PATH at the current check-out, the check-in that \\introduced that line and when. PATH is an exact repo-relative path to a \\tracked file; each output line is the introducing check-in's 10-character \\artifact-ID prefix, the ISO date it was recorded (UTC), then the file line. \\ \\Provenance is computed over the file's whole version history: a line is \\attributed to the oldest check-in that carries it. A line that arrived from \\a merged branch is credited to its origin on that branch, never to the merge \\check-in itself. Only committed content is annotated — uncommitted edits in \\the working file are not shown. \\ \\options: \\ -R, --repo <path> \\ read this repository instead of the one found from the \\ current directory; <path> is the directory holding '.zit' \\ (or the '.zit' file itself). No working tree is required. \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the annotation was written \\ 1 PATH is not tracked at the current check-out, its content is binary, \\ or this is not a zit repository \\ 2 usage error (a missing PATH, an unknown option, or a second positional) \\ ; /// Commits the working tree at a pinned epoch/author and returns the new /// check-in's 64-hex hash on `gpa` (caller frees). The epoch fixes both the /// event mtime (deterministic chain order) and the displayed ISO date. fn annotateCommit( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, epoch: []const u8, msg: []const u8, ) ![]const u8 { var res = try runCommitEnv(gpa, io, dir, .{ .user = "u", .epoch = epoch }, &.{ "commit", "-m", msg }); defer freeResult(gpa, &res); try expectExit(res.term, 0); return gpa.dupe(u8, try checkinHash(res.stdout)); } test "cli annotate B-annotate-1/-2/-5: linear add/edit/append history, oldest-origin per line, deterministic" { const 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\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const h0 = try annotateCommit(gpa, io, tmp.dir, "1000000000", "root"); // 2001-09-09 defer gpa.free(h0); try writeFile(io, tmp.dir, "poem", "A\nB1\nC\n"); const h1 = try annotateCommit(gpa, io, tmp.dir, "1100000000", "edit"); // 2004-11-09 defer gpa.free(h1); try writeFile(io, tmp.dir, "poem", "A\nB1\nC\nD\n"); const h2 = try annotateCommit(gpa, io, tmp.dir, "1200000000", "append"); // 2008-01-10 defer gpa.free(h2); // A,C unchanged since root → h0 ; B1 introduced at h1 ; D appended at h2. const expected = try std.fmt.allocPrint( gpa, "{s} 2001-09-09 A\n{s} 2004-11-09 B1\n{s} 2001-09-09 C\n{s} 2008-01-10 D\n", .{ h0[0..10], h1[0..10], h0[0..10], h2[0..10] }, ); defer gpa.free(expected); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); // B-annotate-15 stream discipline // B-annotate-5: a second run is byte-identical. var res2 = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res2); try std.testing.expectEqualStrings(res.stdout, res2.stdout); } test "cli annotate B-annotate-11: uncommitted working-tree edits are invisible" { const 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\nB\nC\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const h0 = try annotateCommit(gpa, io, tmp.dir, "1000000000", "root"); defer gpa.free(h0); const expected = try std.fmt.allocPrint( gpa, "{s} 2001-09-09 A\n{s} 2001-09-09 B\n{s} 2001-09-09 C\n", .{ h0[0..10], h0[0..10], h0[0..10] }, ); defer gpa.free(expected); // Edit the working file WITHOUT committing — annotate reads checked-in bytes. try writeFile(io, tmp.dir, "poem", "totally\ndifferent\nuncommitted\ncontent\n"); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); } test "cli annotate B-annotate-3/-4: merge pass-through and merge-only resolution lines" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // base A,B,C. current edits C→C1 (primary side). other edits A→A1 (merge // side). Merge is clean (disjoint), then a resolution edit inserts R (in no // parent). Expected: A1→other, B→base(oldest), R→merge, C1→current. 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 annotateCommit(gpa, io, tmp.dir, "1000000000", "base"); // 2001-09-09 defer gpa.free(base); try writeFile(io, tmp.dir, "poem", "A\nB\nC1\n"); const current = try annotateCommit(gpa, io, tmp.dir, "1100000000", "current"); // 2004-11-09 defer gpa.free(current); var up = try runZit(gpa, io, tmp.dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "poem", "A1\nB\nC\n"); const other = try annotateCommit(gpa, io, tmp.dir, "1200000000", "other"); // 2008-01-10 defer gpa.free(other); var back = try runZit(gpa, io, tmp.dir, &.{ "update", current }); defer freeResult(gpa, &back); try expectExit(back.term, 0); var merged = try runZit(gpa, io, tmp.dir, &.{ "merge", other }); defer freeResult(gpa, &merged); try expectExit(merged.term, 0); // clean, disjoint edits // A resolution edit present in NEITHER parent: R. try writeFile(io, tmp.dir, "poem", "A1\nB\nR\nC1\n"); const merge_ci = try annotateCommit(gpa, io, tmp.dir, "1300000000", "merge"); // 2011-03-13 defer gpa.free(merge_ci); const expected = try std.fmt.allocPrint( gpa, "{s} 2008-01-10 A1\n{s} 2001-09-09 B\n{s} 2011-03-13 R\n{s} 2004-11-09 C1\n", .{ other[0..10], base[0..10], merge_ci[0..10], current[0..10] }, ); defer gpa.free(expected); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli annotate topology: a child with an EARLIER mtime than its parent still attributes by ancestry" { // Non-monotone clocks: the child commit is BACKDATED before its parent. The // shared line L was introduced by the parent and must stay charged to the // parent (older by topology), not the too-recent child. The old mtime-ascending // chain order processed the child first and misattributed L to the child. const 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", "L\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const parent = try annotateCommit(gpa, io, tmp.dir, "1200000000", "parent"); // 2008-01-10, LATER defer gpa.free(parent); try writeFile(io, tmp.dir, "poem", "L\nM\n"); const child = try annotateCommit(gpa, io, tmp.dir, "1000000000", "child"); // 2001-09-09, EARLIER defer gpa.free(child); // L → parent (older by topology, despite its LATER mtime) ; M → child. const expected = try std.fmt.allocPrint( gpa, "{s} 2008-01-10 L\n{s} 2001-09-09 M\n", .{ parent[0..10], child[0..10] }, ); defer gpa.free(expected); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli annotate topology: a merge BACKDATED before its parents credits the primary parent's line, not the merge" { // Both branch tips independently add the identical line X; the merge keeps it. // The merge check-in is BACKDATED before every parent (non-monotone). Attribution // must pass X through the PRIMARY parent (current), never charge it to the merge. // The old mtime-ascending order processed the earliest-mtime merge FIRST — before // its parents were resolved — and wrongly charged X (and A) to the merge itself. const 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\nB\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const base = try annotateCommit(gpa, io, tmp.dir, "1000000000", "base"); // 2001-09-09 defer gpa.free(base); // Primary branch inserts X. try writeFile(io, tmp.dir, "poem", "A\nX\nB\n"); const current = try annotateCommit(gpa, io, tmp.dir, "1200000000", "current"); // 2008-01-10 defer gpa.free(current); // Other branch independently inserts the identical X, plus a distinct Z (so the // merge actually changes the working tree — otherwise there is nothing to commit). var up = try runZit(gpa, io, tmp.dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try writeFile(io, tmp.dir, "poem", "A\nX\nB\nZ\n"); const other = try annotateCommit(gpa, io, tmp.dir, "1300000000", "other"); // 2011-03-13 defer gpa.free(other); // Merge other into current, then commit the merge BACKDATED before all parents. var back = try runZit(gpa, io, tmp.dir, &.{ "update", current }); defer freeResult(gpa, &back); try expectExit(back.term, 0); var merged = try runZit(gpa, io, tmp.dir, &.{ "merge", other }); defer freeResult(gpa, &merged); try expectExit(merged.term, 0); // X agrees on both sides, Z only on other ⇒ clean const merge_ci = try annotateCommit(gpa, io, tmp.dir, "500000000", "merge"); // 1985-11-05, EARLIEST defer gpa.free(merge_ci); // A,B → base ; X → current (the PRIMARY parent, preferred over other) ; Z → other. // Nothing is charged to the backdated merge itself. const expected = try std.fmt.allocPrint( gpa, "{s} 2001-09-09 A\n{s} 2008-01-10 X\n{s} 2001-09-09 B\n{s} 2011-03-13 Z\n", .{ base[0..10], current[0..10], base[0..10], other[0..10] }, ); defer gpa.free(expected); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "poem" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } test "cli annotate B-annotate-9: a binary file refuses with exit 1, nothing on 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); try writeFile(io, tmp.dir, "blob.bin", "before\x00after\n"); // a NUL ⇒ binary try addManaged(gpa, io, tmp.dir, &.{"blob.bin"}); const h0 = try annotateCommit(gpa, io, tmp.dir, "1000000000", "add binary"); defer gpa.free(h0); var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", "blob.bin" }); 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, "binary") != null); } test "cli annotate B-annotate-10: untracked-but-present vs nonexistent, both 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, "poem", "A\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const h0 = try annotateCommit(gpa, io, tmp.dir, "1000000000", "root"); defer gpa.free(h0); // Present on disk but never added → untracked. try writeFile(io, tmp.dir, "scratch.txt", "not added\n"); var untracked = try runZit(gpa, io, tmp.dir, &.{ "annotate", "scratch.txt" }); defer freeResult(gpa, &untracked); try expectExit(untracked.term, 1); try std.testing.expectEqualStrings("", untracked.stdout); try std.testing.expect(std.mem.indexOf(u8, untracked.stderr, "not tracked") != null); // Neither on disk nor tracked → nonexistent. var missing = try runZit(gpa, io, tmp.dir, &.{ "annotate", "ghost.txt" }); defer freeResult(gpa, &missing); try expectExit(missing.term, 1); try std.testing.expectEqualStrings("", missing.stdout); try std.testing.expect(std.mem.indexOf(u8, missing.stderr, "no such tracked file") != null); } test "cli annotate B-annotate-12: -h and --help print the verbatim help, 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); for ([_][]const u8{ "-h", "--help" }) |flag| { var res = try runZit(gpa, io, tmp.dir, &.{ "annotate", flag }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings(annotate_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, &.{ "annotate", "--help", "-z" }); defer freeResult(gpa, &win); try expectExit(win.term, 0); try std.testing.expectEqualStrings(annotate_help_expected, win.stdout); } test "cli annotate B-annotate-13: missing PATH, 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{ &.{"annotate"}, // missing PATH &.{ "annotate", "-x" }, // unknown option &.{ "annotate", "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 annotate --help'") != null); } } test "cli annotate B-annotate-14: 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, &.{ "annotate", "poem" }); 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 annotate B-annotate-16: the top-level help names the annotate verb" { 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, &.{"help"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "annotate") != null); } // --- open: clone → working tree (P3-F3) ------------------------------------ // // `zit clone` builds a repository file and nothing else: the checkout it leaves // behind is the same UNBORN record `init` writes (`version == ""`, branch // `trunk`), with no managed set and no files. `zit open` is the second half of // fossil's own `clone` + `open` UX — it resolves the tip of the main branch and // materializes it through the shared update engine. // // The proof chain below is the missing end-to-end the pre-release assessment // called out: the existing clone e2e asserts artifact-digest-set equality only, // which proves the wire converged but says nothing about whether a cloned repo // can become a usable working tree. const open_help_expected = \\usage: zit open [--manifest[=r,u,t]] [-h|--help] \\ \\Materialize the tip of this repository's main branch in the working tree. \\This is the second half of 'zit clone URL DIR': clone builds DIR and its \\'.zit', open fills DIR with the files of the newest check-in. Run it from \\inside the checkout directory. \\ \\A working tree that is already open at a check-in is left untouched; use \\'zit update' to move an open tree to a different check-in. A repository \\with no check-in yet is already in its correct (empty) state, which open \\reports and leaves alone. \\ \\--manifest asks this checkout for the files some build systems read to \\stamp a version into their output: 'manifest' (the check-in artifact, r), \\'manifest.uuid' (its hash, u) and 'manifest.tags' (its symbolic tags, t). \\The bare flag means 'ru'. The choice is remembered, so every later \\'zit update' rewrites the files for the check-in it moves to. Without the \\flag none are written, and a file of one of those names that the check-in \\itself tracks is never replaced. \\ \\options: \\ --manifest[=r,u,t] write manifest, manifest.uuid, manifest.tags \\ -h, --help show this help and exit \\ ; test "cli open P3-F3: clone → open → byte-identical tree → update → commit round-trips" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: two check-ins, the second adding a nested path and rewriting the // first file — so materializing the TIP is distinguishable from materializing // the root, and directory creation is exercised. 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", "first revision\n"); try addManaged(gpa, io, tmp_a.dir, &.{"hello.txt"}); const root = try commitHashDup(gpa, io, tmp_a.dir, "root"); defer gpa.free(root); try writeFile(io, tmp_a.dir, "hello.txt", "second revision, longer than the first\n"); try tmp_a.dir.createDirPath(io, "sub"); try writeFile(io, tmp_a.dir, "sub/nested.txt", "nested payload\n"); try addManaged(gpa, io, tmp_a.dir, &.{ "hello.txt", "sub/nested.txt" }); const tip = try commitHashDup(gpa, io, tmp_a.dir, "tip"); defer gpa.free(tip); // Serve A and clone it into a fresh directory B/repo over real HTTP. const open_chain_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, open_chain_port); defer server.kill(io); try waitForListen(io, open_chain_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{open_chain_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); // The state clone leaves: an UNBORN checkout record and no working files. try expectCheckoutVersion(io, b_dir, ""); try std.testing.expect(!fileExists(io, b_dir, "hello.txt")); // OPEN. The tip of trunk is resolved from the cloned repository's own state // — `update` takes only literal 40/64-hex IDs, so this resolution is open's // own load-bearing work, not something the caller can supply. { var op = try runZit(gpa, io, b_dir, &.{"open"}); defer freeResult(gpa, &op); try expectExit(op.term, 0); try std.testing.expectEqualStrings("", op.stderr); try std.testing.expect(std.mem.indexOf(u8, op.stdout, tip) != null); try std.testing.expect(std.mem.indexOf(u8, op.stdout, "trunk") != null); } try expectCheckoutVersion(io, b_dir, tip); // THE PROOF: the files on disk in B byte-match the source tree in A. Digest- // set equality (what the clone e2e asserts) cannot see this. for ([_][]const u8{ "hello.txt", "sub/nested.txt" }) |path| { const a_bytes = try readFile(gpa, io, tmp_a.dir, path); defer gpa.free(a_bytes); const b_bytes = try readFile(gpa, io, b_dir, path); defer gpa.free(b_bytes); try std.testing.expectEqualSlices(u8, a_bytes, b_bytes); } try expectManaged(gpa, io, b_dir, &.{ "hello.txt", "sub/nested.txt" }); // `update` works in the opened tree: moving back to the root drops the // nested file and restores the earlier bytes. { var up = try runZit(gpa, io, b_dir, &.{ "update", root }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } try expectCheckoutVersion(io, b_dir, root); { const bytes = try readFile(gpa, io, b_dir, "hello.txt"); defer gpa.free(bytes); try std.testing.expectEqualStrings("first revision\n", bytes); } try std.testing.expect(!fileExists(io, b_dir, "sub/nested.txt")); // …and back to the tip, so the commit below has the tip as its parent. { var up = try runZit(gpa, io, b_dir, &.{ "update", tip }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } // A `commit` in the opened tree round-trips: it mints a real check-in whose // P card names the tip we opened at, and the checkout advances onto it. try writeFile(io, b_dir, "hello.txt", "third revision, authored in the cloned tree\n"); const child = try commitHashDup(gpa, io, b_dir, "from the clone"); defer gpa.free(child); try expectCheckoutVersion(io, b_dir, child); var parsed = try parseCheckin(gpa, io, b_dir, child); defer parsed.deinit(); try std.testing.expectEqual(@as(usize, 1), parsed.m.p.len); try std.testing.expectEqual(@as(usize, 1), parsed.m.p[0].count); try std.testing.expectEqualStrings(tip, parsed.m.p[0].parents.?); } test "cli open P3-F3: a plain clone's unborn checkout is a state 'update' tolerates" { const gpa = std.testing.allocator; const io = std.testing.io; // The "interrupted / never-run open" state is EXACTLY what a plain clone // produces — an unborn checkout record, no managed set, no files. Setting it // up any other way (hand-writing the record) would keep passing even if // clone's bootstrap drifted away from what the shared layer tolerates, so // the setup here is nothing but `zit clone`. var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); try initRepo(gpa, io, tmp_a.dir); try writeFile(io, tmp_a.dir, "only.txt", "recoverable\n"); try addManaged(gpa, io, tmp_a.dir, &.{"only.txt"}); const tip = try commitHashDup(gpa, io, tmp_a.dir, "seed"); defer gpa.free(tip); const open_unborn_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, open_unborn_port); defer server.kill(io); try waitForListen(io, open_unborn_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{open_unborn_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); // `update` straight onto the clone-produced unborn record: the record is // present but names no check-in, and the managed set is absent (which reads // as empty) — so the tree is clean and the update must proceed. { var up = try runZit(gpa, io, b_dir, &.{ "update", tip }); defer freeResult(gpa, &up); try expectExit(up.term, 0); try std.testing.expectEqualStrings("", up.stderr); } try expectCheckoutVersion(io, b_dir, tip); const bytes = try readFile(gpa, io, b_dir, "only.txt"); defer gpa.free(bytes); try std.testing.expectEqualStrings("recoverable\n", bytes); } // The one materialization failure a user genuinely reaches on this verb: clone // into a directory, leave a stray untracked file whose path a checked-in path // needs, then `open`. Every other `ApplyError` arm needs a damaged repository or // a failing device; this one needs only a file the user put there. It is also // the arm that pins open's diagnostics to the real engine — a mis-mapped // diagnostic table survives every success-path test. test "cli open P3-F3: an untracked file in the way fails the open and materializes nothing" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); try initRepo(gpa, io, tmp_a.dir); try writeFile(io, tmp_a.dir, "only.txt", "from the repository\n"); try addManaged(gpa, io, tmp_a.dir, &.{"only.txt"}); const tip = try commitHashDup(gpa, io, tmp_a.dir, "seed"); defer gpa.free(tip); const open_collision_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, open_collision_port); defer server.kill(io); try waitForListen(io, open_collision_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{open_collision_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); // The stray file: same path as the check-in's, untracked, different bytes. try writeFile(io, b_dir, "only.txt", "written by the user, never added\n"); var res = try runZit(gpa, io, b_dir, &.{"open"}); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings( "zit open: an untracked file or directory is in the way\n", res.stderr, ); // Nothing materialized and nothing clobbered: the record is still unborn and // the user's bytes are exactly as they were left. try expectCheckoutVersion(io, b_dir, ""); const bytes = try readFile(gpa, io, b_dir, "only.txt"); defer gpa.free(bytes); try std.testing.expectEqualStrings("written by the user, never added\n", bytes); } test "cli open P3-F3: re-opening an already-open tree is refused and changes 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 writeFile(io, tmp.dir, "poem", "line\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const head = try commitHashDup(gpa, io, tmp.dir, "seed"); defer gpa.free(head); // A dirty edit proves the refusal happens BEFORE any materialization: a // gate that ran the update anyway would either overwrite this or fail with // update's own not-clean diagnostic. try writeFile(io, tmp.dir, "poem", "edited but not committed\n"); var res = try runZit(gpa, io, tmp.dir, &.{"open"}); 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, "already open") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, head) != null); const bytes = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(bytes); try std.testing.expectEqualStrings("edited but not committed\n", bytes); } test "cli open P3-F3: a repository with no check-in is reported, not failed" { 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, &.{"open"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "no check-in") != null); // The unborn record is the correct end state for an empty repository; open // leaves it exactly as it found it. try expectCheckoutVersion(io, tmp.dir, ""); } test "cli open P3-F3: 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, &.{"open"}); 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 open P3-F3: an unknown option or a 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{ &.{ "open", "-x" }, // unknown option &.{ "open", "somewhere" }, // open takes no operand }; 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 open --help'") != null); } } test "cli open P3-F3: the verb is in the top-level help and teaches one help text" { 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 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, " open ") != null); // `zit open --help` and `zit help open` teach byte-identical text (DX D4). var flag = try runZit(gpa, io, tmp.dir, &.{ "open", "--help" }); defer freeResult(gpa, &flag); try expectExit(flag.term, 0); try std.testing.expectEqualStrings(open_help_expected, flag.stdout); var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "open" }); defer freeResult(gpa, &topic); try expectExit(topic.term, 0); try std.testing.expectEqualStrings(open_help_expected, topic.stdout); } // -------------------------------------------------------------------------- // `open --manifest[=r,u,t]`: the working-tree manifest files, as an explicit // per-checkout opt-in. The three characters select the check-in artifact // (`r` -> `manifest`), its hash (`u` -> `manifest.uuid`) and its symbolic tags // (`t` -> `manifest.tags`); the bare flag means `ru`. The opt-in is recorded in // the checkout record, so every later `update` refreshes the files. No flag // writes nothing, which is the default and stays the default. // -------------------------------------------------------------------------- /// The line fossil appends to the on-disk `manifest`, so the file is not itself /// a well-formed artifact. Pinned here as a literal, not imported: it is a /// cross-implementation compatibility fact, and a test that read it from the /// implementation could not notice the implementation changing it. const manifest_sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n"; test "cli open: --manifest writes the manifest files and a later update refreshes them" { const gpa = std.testing.allocator; const io = std.testing.io; 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", "first revision\n"); try addManaged(gpa, io, tmp_a.dir, &.{"hello.txt"}); const root = try commitHashDup(gpa, io, tmp_a.dir, "root"); defer gpa.free(root); try writeFile(io, tmp_a.dir, "hello.txt", "second revision, longer than the first\n"); try addManaged(gpa, io, tmp_a.dir, &.{"hello.txt"}); const tip = try commitHashDup(gpa, io, tmp_a.dir, "tip"); defer gpa.free(tip); const open_manifest_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, open_manifest_port); defer server.kill(io); try waitForListen(io, open_manifest_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{open_manifest_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); { var op = try runZit(gpa, io, b_dir, &.{ "open", "--manifest=rut" }); defer freeResult(gpa, &op); try expectExit(op.term, 0); try std.testing.expectEqualStrings("", op.stderr); } // `u`: the check-in hash and a newline, verbatim. { const uuid = try readFile(gpa, io, b_dir, "manifest.uuid"); defer gpa.free(uuid); const want = try std.fmt.allocPrint(gpa, "{s}\n", .{tip}); defer gpa.free(want); try std.testing.expectEqualStrings(want, uuid); } // `r`: the check-in artifact ITSELF, plus fossil's sterilizing line — // asserted by hashing the leading bytes back to `tip`. A weaker oracle (the // file exists, or it mentions a card name) would pass on approximately-right // bytes; this one cannot. { const man = try readFile(gpa, io, b_dir, "manifest"); defer gpa.free(man); try std.testing.expect(std.mem.endsWith(u8, man, manifest_sterilizer)); try std.testing.expect(artifact.verify(tip, man[0 .. man.len - manifest_sterilizer.len])); } // `t`: fossil's layout — the branch line, then one line per symbolic tag. { const tags = try readFile(gpa, io, b_dir, "manifest.tags"); defer gpa.free(tags); try std.testing.expectEqualStrings("branch trunk\ntag trunk\n", tags); } // The opt-in is per checkout, not per command: `update` refreshes all three // against the new check-in without the flag being repeated. { var up = try runZit(gpa, io, b_dir, &.{ "update", root }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } { const uuid = try readFile(gpa, io, b_dir, "manifest.uuid"); defer gpa.free(uuid); const want = try std.fmt.allocPrint(gpa, "{s}\n", .{root}); defer gpa.free(want); try std.testing.expectEqualStrings(want, uuid); const man = try readFile(gpa, io, b_dir, "manifest"); defer gpa.free(man); try std.testing.expect(artifact.verify(root, man[0 .. man.len - manifest_sterilizer.len])); } } test "cli open: --manifest never overwrites a tracked file of the same name" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); try initRepo(gpa, io, tmp_a.dir); // A repository that TRACKS a file called `manifest`. Fossil refuses to // clobber it and so must zit: the user's versioned content outranks a // convenience file. try writeFile(io, tmp_a.dir, "manifest", "a tracked file, not the check-in\n"); try writeFile(io, tmp_a.dir, "hello.txt", "payload\n"); try addManaged(gpa, io, tmp_a.dir, &.{ "manifest", "hello.txt" }); const tip = try commitHashDup(gpa, io, tmp_a.dir, "seed"); defer gpa.free(tip); const open_manifest_guard_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, open_manifest_guard_port); defer server.kill(io); try waitForListen(io, open_manifest_guard_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{open_manifest_guard_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); { var op = try runZit(gpa, io, b_dir, &.{ "open", "--manifest=ru" }); defer freeResult(gpa, &op); try expectExit(op.term, 0); try std.testing.expectEqualStrings("", op.stderr); } // The tracked bytes survive; the un-tracked sibling is still written. const kept = try readFile(gpa, io, b_dir, "manifest"); defer gpa.free(kept); try std.testing.expectEqualStrings("a tracked file, not the check-in\n", kept); const uuid = try readFile(gpa, io, b_dir, "manifest.uuid"); defer gpa.free(uuid); const want = try std.fmt.allocPrint(gpa, "{s}\n", .{tip}); defer gpa.free(want); try std.testing.expectEqualStrings(want, uuid); } test "cli open: without --manifest no manifest file is 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 writeFile(io, tmp.dir, "poem", "line\n"); try addManaged(gpa, io, tmp.dir, &.{"poem"}); const head = try commitHashDup(gpa, io, tmp.dir, "seed"); defer gpa.free(head); for ([_][]const u8{ "manifest", "manifest.uuid", "manifest.tags" }) |name| { try std.testing.expect(!fileExists(io, tmp.dir, name)); } } // ========================================================================== // `zit add` / `zit rm` directory recursion (P3-F4). `add <dir>` scopes ONE // `extras.sweep` of the checkout root by a directory prefix — the same walk, // the same exclusions, no second walker; `rm <dir>` expands the argument over // the TRACKED keys (`ManagedSet.iterate`) and never touches the disk, so a // directory the user already deleted is still forgettable. Both verbs consume // exactly one enumeration each. // ========================================================================== /// Creates the directory chain `path` under `dir`, component by component. /// `createDir` is single-level; the recursion tests need nested trees. fn mkDirPath(io: std.Io, dir: std.Io.Dir, path: []const u8) !void { var it = std.mem.splitScalar(u8, path, '/'); var cur = try dir.openDir(io, ".", .{}); defer cur.close(io); while (it.next()) |seg| { if (seg.len == 0) continue; 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; } } /// Creates every `dir/name` file, making the parent directories first. fn touchTree(io: std.Io, dir: std.Io.Dir, paths: []const []const u8) !void { for (paths) |p| { if (std.mem.lastIndexOfScalar(u8, p, '/')) |i| try mkDirPath(io, dir, p[0..i]); try touch(io, dir, p); } } test "cli add P3-F4: 'add <dir>' adds every untracked file beneath it, recursively and scoped" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "src/a", "src/deep/b", "src/deep/deeper/c", "other/d", "top" }); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "src" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Ascending byte order (the sweep's own order), scoped to `src`: the // sibling tree and the root file are untouched. try std.testing.expectEqualStrings( "added src/a\nadded src/deep/b\nadded src/deep/deeper/c\n", res.stdout, ); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{ "src/a", "src/deep/b", "src/deep/deeper/c" }); } test "cli add P3-F4: a directory prefix never leaks across a shared name boundary" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // `src2/…` shares src's bytes but is NOT under `src/` — a plain // startsWith would wrongly swallow it. try touchTree(io, tmp.dir, &.{ "src/a", "src2/b", "srcx" }); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "src" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("added src/a\n", res.stdout); try expectManaged(gpa, io, tmp.dir, &.{"src/a"}); } test "cli add P3-F4: 'add .' at the root adds the whole tree; root control names are never 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 touchTree(io, tmp.dir, &.{ "a", "sub/b" }); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("added a\nadded sub/b\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); // `.zit`, `.zit-checkout` (and any other root control name) are excluded by // the sweep itself, so `add .` can never track them. try expectManaged(gpa, io, tmp.dir, &.{ "a", "sub/b" }); } test "cli add P3-F4: the control-name exclusion is depth-0 only — a nested '.zit-cache' 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); // Below the root these names carry no special meaning (extras.zig:200). try touchTree(io, tmp.dir, &.{ "sub/.zit-cache", "sub/.zit" }); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("added sub/.zit\nadded sub/.zit-cache\n", res.stdout); try expectManaged(gpa, io, tmp.dir, &.{ "sub/.zit", "sub/.zit-cache" }); } test "cli add P3-F4: 'add .' inside a subdirectory scopes to that subdirectory" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/deep/b", "other/c" }); var sub = try tmp.dir.openDir(io, "sub", .{}); defer sub.close(io); var res = try runZit(gpa, io, sub, &.{ "add", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Keys stay root-relative even though the cwd is the subdirectory. try std.testing.expectEqualStrings("added sub/a\nadded sub/deep/b\n", res.stdout); try expectManaged(gpa, io, tmp.dir, &.{ "sub/a", "sub/deep/b" }); } test "cli add P3-F4: a partially-tracked directory adds exactly the untracked remainder" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/b", "sub/c" }); try addManaged(gpa, io, tmp.dir, &.{"sub/b"}); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // The tracked entry is excluded by the sweep — neither re-inserted nor // reported as "already tracked" (it was never named). try std.testing.expectEqualStrings("added sub/a\nadded sub/c\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{ "sub/a", "sub/b", "sub/c" }); } test "cli add P3-F4: --dry-run over a directory previews without persisting" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/deep/b" }); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-n", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("would add sub/a\nwould add sub/deep/b\n", res.stdout); try std.testing.expect(!fileExists(io, tmp.dir, managed.filename)); } test "cli add P3-F4: a directory holding nothing untracked adds nothing and succeeds" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{"sub/a"}); try addManaged(gpa, io, tmp.dir, &.{"sub/a"}); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expectEqualStrings("", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{"sub/a"}); } test "cli rm P3-F4: 'rm <dir>' forgets every tracked key beneath it, scoped, files kept 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 touchTree(io, tmp.dir, &.{ "src/a", "src/deep/b", "src2/c", "top" }); try addManaged(gpa, io, tmp.dir, &.{ "src/a", "src/deep/b", "src2/c", "top" }); var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "src" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings( "removed src/a (file kept on disk)\nremoved src/deep/b (file kept on disk)\n", res.stdout, ); try std.testing.expectEqualStrings("", res.stderr); // `src2/c` shares src's bytes but is not beneath `src/`; `top` is untouched. try expectManaged(gpa, io, tmp.dir, &.{ "src2/c", "top" }); try std.testing.expect(fileExists(io, tmp.dir, "src/a")); } test "cli rm P3-F4: a directory already deleted from disk is still forgettable (membership, not existence)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/b" }); try addManaged(gpa, io, tmp.dir, &.{ "sub/a", "sub/b" }); // The user removes the whole directory themselves — the primary reason to // reach for `zit rm <dir>`. A filesystem walk could not serve this. try tmp.dir.deleteFile(io, "sub/a"); try tmp.dir.deleteFile(io, "sub/b"); try tmp.dir.deleteDir(io, "sub"); var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings( "removed sub/a (file kept on disk)\nremoved sub/b (file kept on disk)\n", res.stdout, ); try expectManaged(gpa, io, tmp.dir, &.{}); } test "cli rm P3-F4: 'rm .' at the root forgets the whole tracked tree" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "a", "sub/b" }); try addManaged(gpa, io, tmp.dir, &.{ "a", "sub/b" }); var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings( "removed a (file kept on disk)\nremoved sub/b (file kept on disk)\n", res.stdout, ); try expectManaged(gpa, io, tmp.dir, &.{}); } test "cli rm P3-F4: a directory with no tracked keys beneath it 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 touchTree(io, tmp.dir, &.{ "sub/a", "keep" }); try addManaged(gpa, io, tmp.dir, &.{"keep"}); var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); // No new diagnostic class: an argument matching neither a tracked entry nor // any tracked prefix keeps the existing message. try std.testing.expectEqualStrings("zit rm: 'sub' is not tracked\n", res.stderr); try expectManaged(gpa, io, tmp.dir, &.{"keep"}); } test "cli rm P3-F4: 'rm --hard <dir>' deletes the working files only after the write" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/deep/b", "keep" }); try addManaged(gpa, io, tmp.dir, &.{ "sub/a", "sub/deep/b", "keep" }); var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings( "removed sub/a (deleted from disk)\nremoved sub/deep/b (deleted from disk)\n", res.stdout, ); try expectManaged(gpa, io, tmp.dir, &.{"keep"}); try std.testing.expect(!fileExists(io, tmp.dir, "sub/a")); try std.testing.expect(!fileExists(io, tmp.dir, "sub/deep/b")); try std.testing.expect(fileExists(io, tmp.dir, "keep")); } test "cli rm P3-F4: a partially-tracked directory forgets only its tracked keys" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try touchTree(io, tmp.dir, &.{ "sub/a", "sub/b" }); try addManaged(gpa, io, tmp.dir, &.{"sub/a"}); // sub/b was never tracked var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "-n", "sub" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("would remove sub/a (file kept on disk)\n", res.stdout); // A dry run persists nothing. try expectManaged(gpa, io, tmp.dir, &.{"sub/a"}); } // --- REGRESSION B1 -------------------------------------------------------- // // Same-second commits resolve to the WRONG check-in, silently (blocker B1/B2, // docs/plans/CLI-VERB-BREAKDOWN.md §B1/B2). `repostate.zig`'s selection sites // break event.mtime ties by artifact-hash order rather than DAG ancestry, so a // three-commit chain minted inside one second can resolve `tip`/`trunk` to an // ANCESTOR of the newest check-in. These tests must NOT sleep between commits // — same-second IS the repro. /// Mints the exact CLI repro from the breakdown doc: three same-second commits /// c1/c2/c3 rewriting `f.txt` v1 → v2 → v3 on a linear trunk. Returns c3's /// hash (caller frees); the checkout is left at c3 with `f.txt` == "v3\n". fn buildSameSecondChain(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]const u8 { try initRepo(gpa, io, dir); try writeFile(io, dir, "f.txt", "v1\n"); try addManaged(gpa, io, dir, &.{"f.txt"}); const c1 = try commitHashDup(gpa, io, dir, "c1"); defer gpa.free(c1); try writeFile(io, dir, "f.txt", "v2\n"); const c2 = try commitHashDup(gpa, io, dir, "c2"); defer gpa.free(c2); try writeFile(io, dir, "f.txt", "v3\n"); return commitHashDup(gpa, io, dir, "c3"); } test "cli REGRESSION B1: same-second commits resolve tip and trunk to the newest check-in" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const c3 = try buildSameSecondChain(gpa, io, tmp.dir); defer gpa.free(c3); // Ground truth: the working file holds v3, and `leaves` — which reads the // leaf table rather than the mtime-tie selection — names c3. const on_disk = try readFile(gpa, io, tmp.dir, "f.txt"); defer gpa.free(on_disk); try std.testing.expectEqualStrings("v3\n", on_disk); // `cat f.txt tip` must be the newest content, not an ancestor's. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "f.txt", "tip" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("v3\n", res.stdout); } // `cat f.txt trunk` must agree with `tip` — two symbolic names for the // same check-in disagreeing in one binary is the disqualifying symptom. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "f.txt", "trunk" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("v3\n", res.stdout); } // `info tip` must name c3 and report it as a leaf. SF5's tripwire: `tip` // resolving to a non-leaf is self-contradictory in a linear history. { var res = try runZit(gpa, io, tmp.dir, &.{ "info", "tip" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.startsWith(u8, res.stdout, c3)); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "leaf:") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "leaf: no") == null); } // Cross-verb agreement (§5.3's invariant): `info tip` and `leaves` must // name the same check-in. { var res = try runZit(gpa, io, tmp.dir, &.{"leaves"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, c3[0..12]) != null); } // `timeline` must order newest-first: c3, then c2, then c1. { var res = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); const at_c3 = std.mem.indexOf(u8, res.stdout, " c3").?; const at_c2 = std.mem.indexOf(u8, res.stdout, " c2").?; const at_c1 = std.mem.indexOf(u8, res.stdout, " c1").?; try std.testing.expect(at_c3 < at_c2); try std.testing.expect(at_c2 < at_c1); } } test "cli REGRESSION B1 (SF1): update accepts a symbolic name and lands on the newest check-in" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const c3 = try buildSameSecondChain(gpa, io, tmp.dir); defer gpa.free(c3); // Move the checkout off the tip so `update trunk` has somewhere to travel. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "f.txt", "tip" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // SF1: `update` rejects symbolic names today ("invalid check-in ID // 'trunk'"); it goes through the same resolution surface B1 touches. { var res = try runZit(gpa, io, tmp.dir, &.{ "update", "trunk" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // CONTRACT PARTNER chain (§5.2.3): update → status → commit → open. The // checkout must sit at c3 with a clean tree. try expectCheckoutVersion(io, tmp.dir, c3); { var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, c3) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to report") != null); } // A commit on top of the updated checkout must record c3 as its parent — // proof that `update trunk` resolved to the newest check-in, not an // ancestor. try writeFile(io, tmp.dir, "f.txt", "v4\n"); const c4 = try commitHashDup(gpa, io, tmp.dir, "c4"); defer gpa.free(c4); { var res = try runZit(gpa, io, tmp.dir, &.{ "info", c4 }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, c3) != null); } } test "cli REGRESSION B1: open materializes the newest same-second check-in into a fresh clone" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const c3 = try buildSameSecondChain(gpa, io, tmp_a.dir); defer gpa.free(c3); const b1_open_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, b1_open_port); defer server.kill(io); try waitForListen(io, b1_open_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{b1_open_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); // `open` resolves the tip of trunk from the cloned repository's own state // — the same mtime-tie selection B1 breaks. { var res = try runZit(gpa, io, b_dir, &.{"open"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, c3) != null); } try expectCheckoutVersion(io, b_dir, c3); // B2: the materialized bytes must be v3, not an ancestor's v1/v2. const opened = try readFile(gpa, io, b_dir, "f.txt"); defer gpa.free(opened); try std.testing.expectEqualStrings("v3\n", opened); } test "cli REGRESSION B1: a same-second two-branch merge picks the correct base and is never up to date" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // CONTRACT PARTNER (§5.2.3): `mergeBaseOf` shares the mtime-tie selection // being rewritten. A same-second diamond's two leaves share exactly one // common ancestor; a wrong base silently re-applies or drops a side. 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 res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Never a false "already up to date": the two sides are genuinely divergent. try std.testing.expect(std.mem.indexOf(u8, res.stdout, "up to date") == null); // Correct base content: base A/B/C, current A1/B/C, other A/B/C1 → the // three-way result takes BOTH disjoint edits. A base selected off the // ancestor chain would conflict or drop one side. const poem = try readFile(gpa, io, tmp.dir, "poem"); defer gpa.free(poem); try std.testing.expectEqualStrings("A1\nB\nC1\n", poem); // status must surface the pending merge parent. try expectMergeState(io, tmp.dir, fx.current, fx.other); } // --- REGRESSION B5 -------------------------------------------------------- // // A merge whose only change is an ADDED file is silently lost (blocker B5, // docs/plans/CLI-VERB-BREAKDOWN.md §B5). `merge` writes the incoming file to // the working tree but never inserts it into `.zit-managed`, so the file is // simultaneously present on disk and untracked: `status` reports "nothing to // report", `extras` lists the merged file as junk, and `commit` refuses with // "nothing to commit (no changes)" — dropping the merge parent that `merge` // recorded in `.zit-checkout`. The user's only escape is `undo`. // // The symmetric half of the same omission: a merge that DELETES a file leaves // the path managed, so `status` reports a phantom "missing" and `commit` // refuses with "cannot commit … missing from the working tree". Both ride the // same `applyMerge` entry stream, so both are encoded here. // // ORDERING: the defect is a managed-set mutation, so each repro is built in // both orderings that could hide it — the merge direction (feature→trunk and // trunk→feature) and the added path's position in the sorted managed set // (`feat.txt` inserts AFTER `base.txt`; `aaa.txt` inserts BEFORE it). Identical // failures in both is what proves the mechanism rather than luck. /// Builds the §B5 repro shape: root(`base.txt`) on trunk, then a `feature` /// branch whose ONLY change is an added `feat.txt`. Leaves the checkout back on /// trunk at the root, clean, ready for `zit merge feature`. Returns /// {base, other, current} where `current` == `base` (trunk never advanced). /// /// Hashes are captured from each `commit`'s own stdout, never from `info tip`: /// with two same-mtime leaves `tip` legitimately picks either one, and reading /// it here would make the fixture — not the defect — decide the outcome. fn buildAddOnlyBranch(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !MergeDiamond { try initRepo(gpa, io, dir); try writeFile(io, dir, "base.txt", "base\n"); try addManaged(gpa, io, dir, &.{"base.txt"}); const base = try commitHashDup(gpa, io, dir, "root"); errdefer gpa.free(base); try writeFile(io, dir, "feat.txt", "feat\n"); try addManaged(gpa, io, dir, &.{"feat.txt"}); var branch = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "addfeat" }); defer freeResult(gpa, &branch); try expectExit(branch.term, 0); const other = try gpa.dupe(u8, try checkinHash(branch.stdout)); errdefer gpa.free(other); var up = try runZit(gpa, io, dir, &.{ "update", base }); defer freeResult(gpa, &up); try expectExit(up.term, 0); const current = try gpa.dupe(u8, base); return .{ .base = base, .other = other, .current = current }; } /// Asserts `zit extras` reports an empty working tree — the on-disk oracle that /// a merged-in file is TRACKED, not junk. fn expectNoExtras(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { var res = try runZit(gpa, io, dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); } test "cli REGRESSION B5: an add-only merge registers the file, status shows it added, commit lands two parents" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var fx = try buildAddOnlyBranch(gpa, io, tmp.dir); defer fx.deinit(gpa); // The merge itself already works: it writes feat.txt and records the parent. { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "feature" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 file(s) changed") != null); } const merged = try readFile(gpa, io, tmp.dir, "feat.txt"); defer gpa.free(merged); try std.testing.expectEqualStrings("feat\n", merged); try expectMergeState(io, tmp.dir, fx.current, fx.other); // THE DEFECT: the merged-in file must be managed. `feat.txt` sorts AFTER // `base.txt`, so this is the append end of the sorted set. try expectManaged(gpa, io, tmp.dir, &.{ "base.txt", "feat.txt" }); // …and therefore must not be reported as junk. try expectNoExtras(gpa, io, tmp.dir); // status must surface BOTH the pending merge (by naming its parent) and // the merge-added file. "nothing to report" over a pending merge that // changed a file is the silent half of the blocker. { var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to report") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, fx.other) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "added feat.txt") != null); } // commit must succeed and mint a real merge check-in. const m = try commitHashDup(gpa, io, tmp.dir, "m"); defer gpa.free(m); // Two parents: the primary is the pre-merge trunk tip, the merge parent is // the feature tip. A dropped merge parent is a lost branch. { var res = try runZit(gpa, io, tmp.dir, &.{ "info", m }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var buf: [96]u8 = undefined; const want_parent = try std.fmt.bufPrint(&buf, "parent: {s}\n", .{fx.current}); try std.testing.expect(std.mem.indexOf(u8, res.stdout, want_parent) != null); var buf2: [96]u8 = undefined; const want_merge = try std.fmt.bufPrint(&buf2, "merge: {s}\n", .{fx.other}); try std.testing.expect(std.mem.indexOf(u8, res.stdout, want_merge) != null); } // The pending state is consumed, not left dangling. try expectMergeState(io, tmp.dir, m, ""); try expectNoExtras(gpa, io, tmp.dir); } test "cli REGRESSION B5: an add-only merge in the reverse direction registers the same way" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // SECOND ORDERING. The first test merges feature INTO a trunk checkout and // inserts at the tail of the sorted managed set. This one merges the trunk // tip INTO a `feature` checkout and inserts `aaa.txt` at the HEAD, before // both existing entries. Same failure in both ⇒ the mechanism is the // missing managed-set mutation, not the direction or the insert position. try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "base.txt", "base\n"); try addManaged(gpa, io, tmp.dir, &.{"base.txt"}); const root = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(root); try writeFile(io, tmp.dir, "mark.txt", "mark\n"); try addManaged(gpa, io, tmp.dir, &.{"mark.txt"}); var branch = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "featwork" }); defer freeResult(gpa, &branch); try expectExit(branch.term, 0); const feature_tip = try gpa.dupe(u8, try checkinHash(branch.stdout)); defer gpa.free(feature_tip); { var up = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } try writeFile(io, tmp.dir, "aaa.txt", "aaa\n"); try addManaged(gpa, io, tmp.dir, &.{"aaa.txt"}); const trunk_tip = try commitHashDup(gpa, io, tmp.dir, "trunkadd"); defer gpa.free(trunk_tip); { var up = try runZit(gpa, io, tmp.dir, &.{ "update", feature_tip }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", trunk_tip }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 file(s) changed") != null); } // Head-of-set insertion. try expectManaged(gpa, io, tmp.dir, &.{ "aaa.txt", "base.txt", "mark.txt" }); try expectNoExtras(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.expect(std.mem.indexOf(u8, res.stdout, "nothing to report") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, trunk_tip) != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "added aaa.txt") != null); } const m = try commitHashDup(gpa, io, tmp.dir, "m"); defer gpa.free(m); { var res = try runZit(gpa, io, tmp.dir, &.{ "info", m }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var buf: [96]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, res.stdout, try std.fmt.bufPrint(&buf, "parent: {s}\n", .{feature_tip})) != null); var buf2: [96]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, res.stdout, try std.fmt.bufPrint(&buf2, "merge: {s}\n", .{trunk_tip})) != null); } } test "cli REGRESSION B5: a merge that deletes a file deregisters it instead of reporting it missing" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // The symmetric half of the same omission, on the same `applyMerge` entry // stream: `.delete` entries remove the file from disk but leave it in the // managed set, so `status` invents a "missing" file that the user never // deleted and `commit` refuses over it. try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "base.txt", "base\n"); try writeFile(io, tmp.dir, "gone.txt", "gone\n"); try addManaged(gpa, io, tmp.dir, &.{ "base.txt", "gone.txt" }); const root = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(root); { var rm = try runZit(gpa, io, tmp.dir, &.{ "rm", "--hard", "gone.txt" }); defer freeResult(gpa, &rm); try expectExit(rm.term, 0); } var branch = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "--branch", "feature", "-m", "delfeat" }); defer freeResult(gpa, &branch); try expectExit(branch.term, 0); const feature_tip = try gpa.dupe(u8, try checkinHash(branch.stdout)); defer gpa.free(feature_tip); { var up = try runZit(gpa, io, tmp.dir, &.{ "update", root }); defer freeResult(gpa, &up); try expectExit(up.term, 0); } { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", feature_tip }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // gone.txt is off disk, so it must be off the managed set too. try std.testing.expect(!fileExists(io, tmp.dir, "gone.txt")); try expectManaged(gpa, io, tmp.dir, &.{"base.txt"}); try expectNoExtras(gpa, io, tmp.dir); // No phantom "missing" — the user deleted nothing. { var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "missing gone.txt") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, feature_tip) != null); } // …and commit lands the merge rather than refusing over the phantom. const m = try commitHashDup(gpa, io, tmp.dir, "m"); defer gpa.free(m); { var res = try runZit(gpa, io, tmp.dir, &.{ "info", m }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var buf: [96]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, res.stdout, try std.fmt.bufPrint(&buf, "merge: {s}\n", .{feature_tip})) != null); } } test "cli REGRESSION B5: zit add of a merge-added file is idempotent" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // CONTRACT PARTNER: merge gains a write path into the same `ManagedSet` // that `add` owns. Once merge registers the file, `add` must see it as // already tracked — two mechanisms writing the same set must agree, or a // user typing the obvious recovery command double-registers it. var fx = try buildAddOnlyBranch(gpa, io, tmp.dir); defer fx.deinit(gpa); { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "feature" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } var res = try runZit(gpa, io, tmp.dir, &.{ "add", "feat.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); // The notice is a stderr diagnostic, not stdout output — the stream `add` // has always used for it (see the `zit add` idempotence tests above). try std.testing.expect(std.mem.indexOf(u8, res.stderr, "already tracked") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "added feat.txt") == null); try expectManaged(gpa, io, tmp.dir, &.{ "base.txt", "feat.txt" }); } test "cli REGRESSION B5: cherrypick of an add-only revision registers the added file" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // CONTRACT PARTNER: cherrypick shares `applyMerge`, so an add-only revision // cherry-picked onto trunk loses the file exactly the same way. var fx = try buildAddOnlyBranch(gpa, io, tmp.dir); defer fx.deinit(gpa); { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "--cherrypick", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 file(s) changed") != null); } try expectManaged(gpa, io, tmp.dir, &.{ "base.txt", "feat.txt" }); try expectNoExtras(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.expect(std.mem.indexOf(u8, res.stdout, "added feat.txt") != null); } // A cherrypick is NOT a merge: it commits with one parent and a Q card, so // only the registration and the commit path are asserted here. const m = try commitHashDup(gpa, io, tmp.dir, "cp"); defer gpa.free(m); } test "cli REGRESSION B5: a convergent-content merge still commits its merge parent" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Both sides made the SAME edit, so the merge changes zero files but is // still a real merge with a real parent to record. commit's "nothing to // commit (no changes)" refusal must not fire over a pending merge: the // merge parent IS the change, and refusing wedges the tree with `undo` as // the only escape. var fx = try buildMergeDiamond(gpa, io, tmp.dir, "A\nB\nC\n", "A\nB\nC1\n", "A\nB\nC1\n"); defer fx.deinit(gpa); { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", fx.other }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "up to date") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "0 file(s) changed") != null); } try expectMergeState(io, tmp.dir, fx.current, fx.other); const m = try commitHashDup(gpa, io, tmp.dir, "m"); defer gpa.free(m); { var res = try runZit(gpa, io, tmp.dir, &.{ "info", m }); defer freeResult(gpa, &res); try expectExit(res.term, 0); var buf: [96]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, res.stdout, try std.fmt.bufPrint(&buf, "parent: {s}\n", .{fx.current})) != null); var buf2: [96]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, res.stdout, try std.fmt.bufPrint(&buf2, "merge: {s}\n", .{fx.other})) != null); } try expectMergeState(io, tmp.dir, m, ""); } test "cli REGRESSION B5: a fresh open materializes the merge-added file" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); var fx = try buildAddOnlyBranch(gpa, io, tmp_a.dir); defer fx.deinit(gpa); { var res = try runZit(gpa, io, tmp_a.dir, &.{ "merge", "feature" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // The end-to-end proof the blocker is about: the merged file must reach the // REPOSITORY, not just this working tree. If commit refuses, feat.txt only // ever existed on one disk and the branch is lost. const m = try commitHashDup(gpa, io, tmp_a.dir, "m"); defer gpa.free(m); const b5_open_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, b5_open_port); defer server.kill(io); try waitForListen(io, b5_open_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{b5_open_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); { var res = try runZit(gpa, io, b_dir, &.{"open"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); } const opened = try readFile(gpa, io, b_dir, "feat.txt"); defer gpa.free(opened); try std.testing.expectEqualStrings("feat\n", opened); try expectManaged(gpa, io, b_dir, &.{ "base.txt", "feat.txt" }); } test "cli REGRESSION B5: undo of a pending add-only merge leaves nothing registered" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // CONTRACT-PARTNER GUARD — structurally incapable of being RED pre-fix. // The abort path's job is to UNDO merge's managed-set mutation. Pre-fix // merge performs no such mutation, so "the set is back to {base.txt}" is // vacuously true and this scenario cannot exercise the defect. It becomes // load-bearing only once the fix introduces the write, and it must stay // green through the fix: a registration that undo does not reverse leaves a // phantom managed entry for a file undo just deleted. var fx = try buildAddOnlyBranch(gpa, io, tmp.dir); defer fx.deinit(gpa); { var res = try runZit(gpa, io, tmp.dir, &.{ "merge", "feature" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } { var un = try runZit(gpa, io, tmp.dir, &.{"undo"}); defer freeResult(gpa, &un); try expectExit(un.term, 0); } try std.testing.expect(!fileExists(io, tmp.dir, "feat.txt")); try expectManaged(gpa, io, tmp.dir, &.{"base.txt"}); try expectNoExtras(gpa, io, tmp.dir); try expectMergeState(io, tmp.dir, fx.current, ""); } // --- REGRESSION B6 -------------------------------------------------------- // // `tag` is read-only: only `tag list` exists, so a repository cannot be given // a name for a check-in at all (blocker B6, docs/plans/CLI-VERB-BREAKDOWN.md // line 27 / line 132: "tag | PARTIAL | read-only — `tag list` only, no // create"). The fix mints a CONTROL artifact carrying `T +sym-NAME <hash>` — // a SINGLETON (`+`), not a propagating (`*`) tag: repostate's `resolveName` // (src/repostate.zig:891) picks the max-event.mtime check-in among ALL active // sym rows, so a propagating sym would be inherited by every descendant and // silently resolve to the newest one — a B1-class wrong answer. `applyTagOps` // (src/repostate.zig:727) gates propagation on `op.type != .singleton`, so a // singleton card flows through the existing machinery unchanged. // // Plan §4 requires a ROUND TRIP, not just local visibility: create, see it in // the tag renderings, then confirm the T-card survives `pack` and a clone — a // synced artifact that does not survive sync is a worse lie than a missing // verb. Note for the implementer: neither `info` nor `timeline` renders tags // today (src/main.zig:4980 renderInfo prints id/branch/leaf/parents only; // runTimeline never calls `tagsOf`), so `tag list CHECKIN` IS the per-check-in // render surface these tests assert against — extending info/timeline is not // in B6's minimal scope. /// Mints a one-commit trunk and returns that check-in's 64-hex hash (caller /// frees). `a.txt` holds "root\n"; the checkout is left on it. fn b6BuildRoot(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]const u8 { try initRepo(gpa, io, dir); try writeFile(io, dir, "a.txt", "root\n"); try addManaged(gpa, io, dir, &.{"a.txt"}); return commitHashDup(gpa, io, dir, "root"); } test "cli REGRESSION B6: tag add creates a tag that tag list and tag list CHECKIN report" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try b6BuildRoot(gpa, io, tmp.dir); defer gpa.free(root); // THE MISSING CAPABILITY. Pre-fix this is `bad_subcommand` -> exit 2. { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); } // §5.1/§2 no-arg listing: the new symbolic name appears alongside `trunk`, // ascending, `sym-` stripped. { var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("trunk\nv1\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // §5.2 CHECKIN listing: the tag is ON the named check-in. { var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("branch trunk\ntrunk\nv1\n", res.stdout); } // §8.1/§8.2 resolution: the name now names that check-in everywhere. { var res = try runZit(gpa, io, tmp.dir, &.{ "info", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.startsWith(u8, res.stdout, root)); } { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "a.txt", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("root\n", res.stdout); } } test "cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (tag before commit)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try b6BuildRoot(gpa, io, tmp.dir); defer gpa.free(root); // ORDERING A: tag first, then mint the descendant. A propagating (`*`) // sym card would be inherited by everything committed AFTER it. { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } try writeFile(io, tmp.dir, "a.txt", "child\n"); const child = try commitHashDup(gpa, io, tmp.dir, "child"); defer gpa.free(child); try std.testing.expect(!std.mem.eql(u8, root, child)); try b6ExpectPinned(gpa, io, tmp.dir, root, child); } test "cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (commit before tag)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try b6BuildRoot(gpa, io, tmp.dir); defer gpa.free(root); // ORDERING B: mint the descendant FIRST, then tag the ancestor. The // propagation walk in `applyTagOps` runs over already-crosslinked // descendants here, a different code path from ordering A's inheritance // at commit time. Identical behavior in both orderings is what proves the // singleton mechanism rather than luck. try writeFile(io, tmp.dir, "a.txt", "child\n"); const child = try commitHashDup(gpa, io, tmp.dir, "child"); defer gpa.free(child); try std.testing.expect(!std.mem.eql(u8, root, child)); { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } try b6ExpectPinned(gpa, io, tmp.dir, root, child); } /// The pinning oracle shared by both orderings: `v1` is on `root` only, the /// descendant does NOT list it, and the name still resolves to `root` even /// though `child` is strictly newer (max-mtime would pick `child` if the row /// had propagated). fn b6ExpectPinned( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, root: []const u8, child: []const u8, ) !void { { var res = try runZit(gpa, io, dir, &.{ "tag", "list", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "v1\n") != null); } { var res = try runZit(gpa, io, dir, &.{ "tag", "list", child }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "v1\n") == null); } { var res = try runZit(gpa, io, dir, &.{ "info", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.startsWith(u8, res.stdout, root)); } // The content oracle: the tagged revision's bytes, not the descendant's. { var res = try runZit(gpa, io, dir, &.{ "cat", "a.txt", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("root\n", res.stdout); } } test "cli REGRESSION B6: a created tag survives pack" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try b6BuildRoot(gpa, io, tmp.dir); defer gpa.free(root); { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } { var res = try runZit(gpa, io, tmp.dir, &.{"pack"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // The T-card is permanent repository content: packing must not drop it. { var res = try runZit(gpa, io, tmp.dir, &.{ "tag", "list" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("trunk\nv1\n", res.stdout); } { var res = try runZit(gpa, io, tmp.dir, &.{ "info", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.startsWith(u8, res.stdout, root)); } } test "cli REGRESSION B6: a created tag survives a clone" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const root = try b6BuildRoot(gpa, io, tmp_a.dir); defer gpa.free(root); { var res = try runCommitEnv(gpa, io, tmp_a.dir, .{ .user = "u" }, &.{ "tag", "add", "v1", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } const b6_clone_port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, b6_clone_port); defer server.kill(io); try waitForListen(io, b6_clone_port); const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{b6_clone_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 expectExitDiag(clone.term, 0, clone); } var b_dir = try tmp_b.dir.openDir(io, "repo", .{}); defer b_dir.close(io); // A tag that does not survive sync is a worse lie than a missing verb. { var res = try runZit(gpa, io, b_dir, &.{ "tag", "list" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("trunk\nv1\n", res.stdout); } { var res = try runZit(gpa, io, b_dir, &.{ "tag", "list", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "v1\n") != null); } { var res = try runZit(gpa, io, b_dir, &.{ "info", "v1" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.startsWith(u8, res.stdout, root)); } } // --- REGRESSION B3 -------------------------------------------------------- // // A symlink anywhere in the tree permanently wedges `commit` (blocker B3, // docs/plans/CLI-VERB-BREAKDOWN.md §B3). `extras.zig` classifies symlinks as // extras and the recursive `add`/`addremove` sweep reuses that walk without // re-applying the exclusion, so `add .` TRACKS exactly what explicit `zit add // sym.txt` and `commit` both REFUSE. `status` then calls a file that is on // disk "missing", and the recovery (`zit rm sym.txt`) is undiscoverable. // // Contract of record (BLOCKER-FIX-PLAN.md §2 F-B3): consistent refusal — // 0.1.0 does not version symlinks. The recursive paths skip them (add reports // each skip on stderr, addremove withholds them silently), explicit `add` // keeps refusing with the symlink wording, and `commit`'s refusal for an // already-wedged repo names `zit rm PATH`. test "cli REGRESSION B3: `add .` skips a symlink, so commit succeeds, status is clean and extras lists it" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // The breakdown doc's repro verbatim: // zit init -q && echo a > a.txt && ln -s a.txt sym.txt try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "a.txt", "a\n"); try tmp.dir.symLink(io, "a.txt", "sym.txt", .{}); // `zit add .` — must NOT track sym.txt, and must SAY it skipped it. { var res = try runZit(gpa, io, tmp.dir, &.{ "add", "." }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("added a.txt\n", res.stdout); try std.testing.expect(std.mem.indexOf( u8, res.stderr, "skipped 'sym.txt': symlink (not versioned in 0.1.0)", ) != null); } try expectManaged(gpa, io, tmp.dir, &.{"a.txt"}); // `zit commit -m t` — must succeed. Today it dies with // "cannot commit 'sym.txt': not a regular file" and the repo is stuck. { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "t" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } // `zit status` — must NOT report sym.txt missing; it is on disk. { var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "missing sym.txt") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "sym.txt") == null); } // `zit extras` — must list sym.txt: untracked is where a symlink belongs. { var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("sym.txt\n", res.stdout); } } test "cli REGRESSION B3: `addremove` withholds symlinks in both sweep orderings" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // BOTH orderings relative to the regular file the sweep also yields: the // extras walk is sorted by path, so `aaa_sym` is visited BEFORE `m.txt` // and `zzz_sym` AFTER it. A skip that only works on one side of a regular // entry (e.g. a filter that latches off the previous item's kind) fails // exactly one of these two assertions. try writeFile(io, tmp.dir, "m.txt", "m\n"); try tmp.dir.symLink(io, "m.txt", "aaa_sym", .{}); try tmp.dir.symLink(io, "m.txt", "zzz_sym", .{}); { var res = try runZit(gpa, io, tmp.dir, &.{"addremove"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); // Neither symlink is ADDED, in either ordering. try std.testing.expectEqualStrings("ADDED m.txt\n", res.stdout); } // Neither symlink is tracked, and both remain extras. try expectManaged(gpa, io, tmp.dir, &.{"m.txt"}); { var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("aaa_sym\nzzz_sym\n", res.stdout); } // The whole point: commit is no longer wedged. { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "t" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } } test "cli REGRESSION B3: explicit `add sym.txt` refuses with the symlink wording, tracking 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 writeFile(io, tmp.dir, "a.txt", "a\n"); try tmp.dir.symLink(io, "a.txt", "sym.txt", .{}); var res = try runZit(gpa, io, tmp.dir, &.{ "add", "sym.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); // Today: "zit add: 'sym.txt' is not a regular file" — a fifo and a symlink // are not the same refusal and must not read the same. try std.testing.expect(std.mem.indexOf( u8, res.stderr, "symlink (not versioned in 0.1.0)", ) != null); try std.testing.expect(!fileExists(io, tmp.dir, managed.filename)); } test "cli REGRESSION B3: a wedged repo recovers via the `zit rm` the commit error names" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Build the wedge the way an F4-era `add .` left real repos: `sym.txt` is // MANAGED and is a symlink on disk. Tracking it while it is still a // regular file is the only construction that survives the fix (post-fix // neither `add .` nor `add sym.txt` will register it) — the wedged repos // this recovery exists for were created before the fix. try writeFile(io, tmp.dir, "a.txt", "a\n"); try writeFile(io, tmp.dir, "sym.txt", "placeholder\n"); try addManaged(gpa, io, tmp.dir, &.{ "a.txt", "sym.txt" }); try tmp.dir.deleteFile(io, "sym.txt"); try tmp.dir.symLink(io, "a.txt", "sym.txt", .{}); // commit refuses — correctly — but must name the way out. Today the // message is "cannot commit 'sym.txt': not a regular file" and stops // there, which is why the blocker was near-undiagnosable in the field. { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "t" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "zit rm sym.txt") != null); } // Follow the recovery the message gives, verbatim. { var res = try runZit(gpa, io, tmp.dir, &.{ "rm", "sym.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } { var res = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "t" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } { var res = try runZit(gpa, io, tmp.dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to report") != null); } // The symlink is still on disk and is now, correctly, an extra. { var res = try runZit(gpa, io, tmp.dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("sym.txt\n", res.stdout); } } test "cli REGRESSION B3: `clean --dry-run` still lists a symlink as an extra" { // CONTRACT-PARTNER GUARD, not a repro. clean is a pure READER of the // extras sweep — it never inserts into the managed set — so it is // structurally incapable of exhibiting the B3 wedge and was green before // the fix. It is here because the fix widens `extras.Extras` entries to // carry an is_symlink tag and touches clean's call site: clean must keep // IGNORING that tag (symlinks stay deletable extras) while add/addremove // filter on it. It must stay green through the fix. const 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 tmp.dir.symLink(io, "a.txt", "sym.txt", .{}); try addManaged(gpa, io, tmp.dir, &.{"a.txt"}); var res = try runZit(gpa, io, tmp.dir, &.{ "clean", "--dry-run" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("sym.txt\n", res.stdout); } // ========================================================================== // REGRESSION B4 — non-atomic `mv` (records the rename before moving the file, // with no rollback when the move fails). // // Repro of record (docs/plans/CLI-VERB-BREAKDOWN.md "B4 — non-atomic mv"), // reproduced verbatim against a fresh trunk build before these tests were // written: // // $ zit mv sub/a2.txt nodir/x.txt // recorded 'sub/a2.txt' as 'nodir/x.txt' but could not move the working // file: input/output error // $ zit status # missing nodir/x.txt (file still at sub/a2.txt) // $ zit mv nodir/x.txt sub/a2.txt # 'sub/a2.txt' already exists on disk // // A refused mv must be a ZERO state change, and the refusal chain is not // observable through `status` alone: the managed set mv persists is the same // set `extras` subtracts from and `commit` walks, so each of the three is // asserted. The two failure causes below (missing dest parent → ENOENT, // unwritable dest parent → EACCES) are the same defect reached two ways; // identical wrong state in both is what shows the mechanism is the WRITE/MOVE // ORDERING and not one errno. // ========================================================================== /// Sets up a repository with a single committed file at `sub/a2.txt` — the /// baseline all four B4 tests start from. A committed baseline (rather than a /// merely-added one) is what makes `commit`'s "nothing to commit" the honest /// oracle for "nothing was recorded". fn b4Baseline(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { try initRepo(gpa, io, dir); try dir.createDir(io, "sub", .default_dir); try writeFile(io, dir, "sub/a2.txt", "hello\n"); try addManaged(gpa, io, dir, &.{"sub/a2.txt"}); var res = try runCommitEnv(gpa, io, dir, .{}, &.{ "commit", "-m", "base" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } /// Asserts the repository is byte-for-byte the b4Baseline state: the file is /// still at `sub/a2.txt` with its original bytes, the managed set still names /// exactly that path, `status` reports nothing, `extras` is empty (a stranded /// working file would show up here), and `commit` has nothing to record. fn expectB4Untouched(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { try std.testing.expect(fileExists(io, dir, "sub/a2.txt")); const bytes = try readFile(gpa, io, dir, "sub/a2.txt"); defer gpa.free(bytes); try std.testing.expectEqualStrings("hello\n", bytes); try expectManaged(gpa, io, dir, &.{"sub/a2.txt"}); { var res = try runZit(gpa, io, dir, &.{"status"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to report") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "missing") == null); } { var res = try runZit(gpa, io, dir, &.{"extras"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stdout); } { var res = try runCommitEnv(gpa, io, dir, .{ .epoch = "1234567891" }, &.{ "commit", "-m", "after" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("zit commit: nothing to commit (no changes)\n", res.stderr); } // The DIRECT oracle for "nothing was recorded". Everything above is // indirect: commit's "no changes" reflects CONTENT only, so a stranded // rename record passes all of it silently and then surfaces at the next // commit that does carry a change, as a §4.7.6 old-name F card claiming a // former name that no manifest ever held. Read the record itself. { var set = try managed.ManagedSet.read(gpa, io, dir); defer set.deinit(); try std.testing.expect(set.pendingRename() == null); } } test "cli REGRESSION B4: a failed mv into a missing directory records nothing" { // REGRESSION B4: the repro of record. `nodir` does not exist, so the // rename cannot succeed; today mv persists the rename FIRST and then // reports the failure, leaving the set naming a path that is not on disk. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try b4Baseline(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/a2.txt", "nodir/x.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); try std.testing.expectEqualStrings("", res.stdout); // A refusal, not a report of a half-done rename. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "recorded") == null); // SF3 (mv-scoped): ENOENT must not be rendered as "input/output error", // and the message must name the directory that is missing. try std.testing.expectEqualStrings( "zit mv: cannot move into 'nodir': no such directory\n", res.stderr, ); try std.testing.expect(!fileExists(io, tmp.dir, "nodir/x.txt")); try expectB4Untouched(gpa, io, tmp.dir); } test "cli REGRESSION B4: a failed mv into an unwritable directory records nothing" { // REGRESSION B4: the SAME defect reached by a different errno. `ro` exists // (so a dest-parent-exists pre-check cannot catch it) but is not writable, // so the rename itself fails with EACCES. Identical wrong state to the // ENOENT case is what proves the defect is the record-before-move ordering // rather than one specific failure cause — and this is the case that will // still reach the rename handler after the fix, so it is the one that // pins the rollback path. 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 b4Baseline(gpa, io, tmp.dir); try tmp.dir.createDir(io, "ro", .default_dir); // Restored before tmp.cleanup, which // needs the directory writable again. defer tmp.dir.setFilePermissions(io, "ro", File.Permissions.fromMode(0o700), .{ .follow_symlinks = false }) catch {}; try tmp.dir.setFilePermissions(io, "ro", File.Permissions.fromMode(0o500), .{ .follow_symlinks = false }); var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/a2.txt", "ro/x.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, "recorded") == null); // SF3 (mv-scoped): EACCES is neither "input/output error" nor a raw Zig // error name — it is the same human phrase `clean` and `init` already print. try std.testing.expectEqualStrings( "zit mv: cannot move 'sub/a2.txt' to 'ro/x.txt': permission denied\n", res.stderr, ); try std.testing.expect(!fileExists(io, tmp.dir, "ro/x.txt")); try expectB4Untouched(gpa, io, tmp.dir); } test "cli REGRESSION B4: a failed mv leaves the source tracked and still movable" { // REGRESSION B4: the user-visible cost of the stranded record. The // breakdown's documented recovery (`zit mv NEW OLD`) fails after a failed // mv because the record and the filesystem disagree; the honest fix is // that no recovery is needed at all — the source is untouched, so an // ordinary mv of it just works. Today it does not: the source is no // longer tracked, so mv refuses it. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try b4Baseline(gpa, io, tmp.dir); { var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/a2.txt", "nodir/x.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 1); } { var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/a2.txt", "sub/c.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("renamed sub/a2.txt -> sub/c.txt\n", res.stdout); try std.testing.expectEqualStrings("", res.stderr); } try expectManaged(gpa, io, tmp.dir, &.{"sub/c.txt"}); try std.testing.expect(fileExists(io, tmp.dir, "sub/c.txt")); try std.testing.expect(!fileExists(io, tmp.dir, "sub/a2.txt")); } test "cli REGRESSION B4: `zit mv NEW OLD` reverses a successful rename exactly" { // CONTRACT-PARTNER GUARD, not a repro. Both renames here SUCCEED, so no // rename-failure path is entered and the record/move ordering is // unobservable — this scenario is structurally incapable of exhibiting the // B4 defect and was green before the fix. It is here because the fix // reorders mvCommit around the same recordRename bookkeeping: `mv b a` // after `mv a b` must net the rename record back out, or the reversal // would leave a stray old-name F card and `commit` would see a change. // It must stay green through the fix — it is what makes `mv --help`'s // "to reverse a rename, run `zit mv NEW OLD`" promise true. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try b4Baseline(gpa, io, tmp.dir); { var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/a2.txt", "sub/b.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } { var res = try runZit(gpa, io, tmp.dir, &.{ "mv", "sub/b.txt", "sub/a2.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } try std.testing.expect(!fileExists(io, tmp.dir, "sub/b.txt")); // Exactly the baseline again — including a commit that finds no change, // which is the assertion that no stray rename record survived the round trip. try expectB4Untouched(gpa, io, tmp.dir); } // --------------------------------------------------------------------------- // `zit stat` — the store's own byte accounting and artifact roll (issue #4/#5). // --------------------------------------------------------------------------- /// Extracts the u64 value of a `key value\n` line from `zit stat` output. /// Fails the test if the key is absent, so a dropped field cannot pass as a /// zero. fn statField(out: []const u8, key: []const u8) !u64 { var lines = std.mem.splitScalar(u8, out, '\n'); while (lines.next()) |line| { var it = std.mem.splitScalar(u8, line, ' '); const k = it.next() orelse continue; if (!std.mem.eql(u8, k, key)) continue; const v = it.next() orelse return error.TestUnexpectedResult; return std.fmt.parseInt(u64, v, 10); } std.debug.print("`zit stat` output has no '{s}' field:\n{s}\n", .{ key, out }); return error.TestUnexpectedResult; } /// Independent oracles for the two measured byte fields, sourced from the /// store's own per-rid index rather than from `storestat.walk` — which is what /// the verb totals through. Sharing no arithmetic with the code under test is /// the whole point: a `content-bytes` the verb computed some other way has to /// match a number assembled here from `payloadLenOf`, frame by frame. const StatOracle = struct { /// Sum of the on-disk payload length of every non-config record. content: u64, /// Sum of each of those frames' header + padding (`frameSpanOf` less the /// payload), which is what `frame-overhead-bytes` must equal. overhead: u64, artifacts: u64, }; fn statOracle(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !StatOracle { 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); defer gpa.free(rids); var o = StatOracle{ .content = 0, .overhead = 0, .artifacts = 0 }; for (rids) |rid| { if (log.kindOf(rid) == .config) continue; const payload = log.payloadLenOf(rid); o.content += payload; o.overhead += log.frameSpanOf(rid) - payload; o.artifacts += 1; } return o; } /// Builds a repository with two committed generations of one file plus a /// second file, so the store holds manifests, full-form blobs, and at least /// one delta frame. fn statCorpus(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { try initRepo(gpa, io, dir); try writeFile(io, dir, "poem.txt", "the sea is calm tonight\n" ** 40); try writeFile(io, dir, "notes.txt", "alpha beta gamma\n" ** 20); try addManaged(gpa, io, dir, &.{ "poem.txt", "notes.txt" }); const h1 = try commitHashDup(gpa, io, dir, "first"); gpa.free(h1); try writeFile(io, dir, "poem.txt", "the sea is calm tonight\n" ** 39 ++ "the tide is full\n"); const h2 = try commitHashDup(gpa, io, dir, "second"); gpa.free(h2); } test "e2e stat: content bytes are measured by the frame walk and never the file size" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try statCorpus(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{"stat"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // store-bytes is the file; nothing else may claim to be it. var f = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_only }); defer f.close(io); const st = try f.stat(io); try std.testing.expectEqual(st.size, try statField(res.stdout, "store-bytes")); // THE honesty property, against an oracle the verb does not share code // with: content-bytes must equal the sum of the stored frames' payload // lengths, taken from the store's own index (`statOracle`). A figure // derived from the `.zit` size fails here — `st.size` itself, `st.size` // less the header, the total on-disk frame span, any of them — because the // payload total is assembled frame by frame and never touches the size. // // What this cannot distinguish, and no test on the printed value can: a // figure that subtracts the walk's OWN overhead and config totals from the // size. Exact accounting makes that arithmetically identical to the // measurement — it IS the measurement, routed through a subtraction. The // property under test is the number, not the expression that produced it. const oracle = try statOracle(gpa, io, tmp.dir); const content = try statField(res.stdout, "content-bytes"); try std.testing.expect(oracle.content > 0); // the corpus really has content try std.testing.expectEqual(oracle.content, content); try std.testing.expect(content < st.size); // frame-overhead-bytes is independently sourced the same way, so the // overhead cannot be back-computed as "whatever makes the file add up" // while content is wrong by the same amount. const overhead = try statField(res.stdout, "frame-overhead-bytes"); try std.testing.expect(oracle.overhead > 0); try std.testing.expectEqual(oracle.overhead, overhead); // Exact accounting, reproduced at the CLI surface: every byte of the file // is either the header, a frame payload, or frame overhead. With content // and overhead both pinned above, this pins config-bytes too. const header = try statField(res.stdout, "header-bytes"); const config = try statField(res.stdout, "config-bytes"); try std.testing.expectEqual(st.size, header + content + overhead + config); try std.testing.expect(config > 0); // the config record is a real frame // The artifact census comes from the same walk as the bytes. const digests = try collectArtifactDigests(gpa, io, tmp.dir); defer freeDigests(gpa, digests); try std.testing.expectEqual(@as(u64, digests.len), try statField(res.stdout, "artifacts")); try std.testing.expectEqual(oracle.artifacts, @as(u64, digests.len)); const deltas = try statField(res.stdout, "deltas"); const full = try statField(res.stdout, "full-form"); try std.testing.expectEqual(@as(u64, digests.len), deltas + full); try std.testing.expect(deltas > 0); // the second poem.txt generation deltas } test "e2e stat: --artifacts enumerates every stored hash, one per line, sorted" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try statCorpus(gpa, io, tmp.dir); var res = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); const digests = try collectArtifactDigests(gpa, io, tmp.dir); defer freeDigests(gpa, digests); var expected: std.ArrayListUnmanaged(u8) = .empty; defer expected.deinit(gpa); for (digests) |d| { var hex: [2 * logstore.max_hash_len + 1]u8 = undefined; const line = try std.fmt.bufPrint(&hex, "{x}\n", .{d}); try expected.appendSlice(gpa, line); } // Byte-for-byte: same set, same sorted order, one hash per line. try std.testing.expectEqualStrings(expected.items, res.stdout); } test "e2e stat: --type=delta selects the delta-form artifacts only" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try statCorpus(gpa, io, tmp.dir); var all = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts" }); defer freeResult(gpa, &all); try expectExit(all.term, 0); var only_delta = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts", "--type=delta" }); defer freeResult(gpa, &only_delta); try expectExit(only_delta.term, 0); var only_full = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts", "--type=full" }); defer freeResult(gpa, &only_full); try expectExit(only_full.term, 0); const n = std.mem.count(u8, all.stdout, "\n"); const nd = std.mem.count(u8, only_delta.stdout, "\n"); const nf = std.mem.count(u8, only_full.stdout, "\n"); try std.testing.expect(nd > 0); try std.testing.expect(nf > 0); try std.testing.expectEqual(n, nd + nf); // the filter partitions, it does not drop // The counts agree with the summary from the same walk. var summary = try runZit(gpa, io, tmp.dir, &.{"stat"}); defer freeResult(gpa, &summary); try std.testing.expectEqual(@as(u64, nd), try statField(summary.stdout, "deltas")); try std.testing.expectEqual(@as(u64, nf), try statField(summary.stdout, "full-form")); } /// The verbatim `zit stat` help — an independent transcription of `main.zig`'s /// `stat_help`, the same house convention every other verb's help is held to, /// so a drift between the two is caught here. The field list is the verb's /// documented contract; editing one side alone must not be silent. const stat_help_expected = \\usage: zit stat [--artifacts] [--type=full|delta] [-h|--help] \\ \\Report what the repository store actually holds. Every number is read \\from the frames in `.zit` — nothing is estimated, and nothing is derived \\from the file's size. \\ \\Output is one 'field value' line per fact, so it reads by machine: \\ \\ store-bytes the `.zit` file itself, in bytes \\ header-bytes the store header \\ content-bytes artifact payload bytes, summed frame by frame \\ frame-overhead-bytes frame headers and 8-byte padding for those frames \\ config-bytes the repository's config record \\ shun-bytes the repository's shun table \\ shun-rows rows in that table \\ artifacts stored artifacts (the config record is not one) \\ full-form artifacts stored whole \\ full-form-bytes content-bytes held by those artifacts \\ deltas artifacts stored against a base \\ delta-bytes content-bytes held by those artifacts \\ \\content-bytes and store-bytes are different measurements and must not be \\substituted for one another: the store also carries the header, a header \\per frame, and padding, so it is always the larger number. The five byte \\fields plus header-bytes account for the file exactly. \\ \\full-form-bytes and delta-bytes PARTITION content-bytes; they are not \\further components of the file and must not be added to the accounting \\above. Read them against full-form/deltas rather than instead of them: a \\store is routinely a minority full-form by artifact and a large majority \\full-form by byte, because an artifact is stored whole exactly when \\delta did not pay -- the large, poorly-compressible ones. \\ \\options: \\ -h, --help show this help and exit \\ --artifacts list the stored artifact hashes instead, one per line, \\ in ascending hash order \\ --type=KIND with --artifacts, list only 'full' or 'delta' artifacts \\ \\exit status: \\ 0 the report was written \\ 1 this is not a zit repository, or `.zit` could not be read \\ 2 usage error (an unknown option, a repeated --type, a --type without \\ --artifacts, or an unexpected argument) \\ ; test "e2e stat: --help prints the verbatim help to stdout, exit 0, outside a repository too" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Help precedes every other reading of the command line: before the // repository lookup, and ahead of a trailing bad token or a real option. const invocations = [_][]const []const u8{ &.{ "stat", "--help" }, &.{ "stat", "-h" }, &.{ "stat", "--help", "foo" }, &.{ "stat", "-h", "--all" }, &.{ "stat", "--artifacts", "--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(stat_help_expected, res.stdout); try std.testing.expectEqualStrings("", res.stderr); } // `zit help stat` routes to the same block, not a second copy of it. try initRepo(gpa, io, tmp.dir); var topic = try runZit(gpa, io, tmp.dir, &.{ "help", "stat" }); defer freeResult(gpa, &topic); try expectExit(topic.term, 0); try std.testing.expectEqualStrings(stat_help_expected, topic.stdout); } test "e2e stat: a repeated --type is a usage error, not a silent last-wins" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try statCorpus(gpa, io, tmp.dir); // Two --type values cannot both be honoured, and answering the second one // silently would report a roll the user did not ask for. var res = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts", "--type=full", "--type=delta" }); 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, "--type") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "more than once") != null); // The same value twice is still two --type options; it is not a special case. var same = try runZit(gpa, io, tmp.dir, &.{ "stat", "--artifacts", "--type=full", "--type=full" }); defer freeResult(gpa, &same); try expectExit(same.term, 2); try std.testing.expectEqualStrings("", same.stdout); } // --- `-n N` entry limit and `-R` repository override ------------------------- // // Two options over one shared set of read verbs. `-n` follows fossil, including // its deliberate per-verb overload (`timeline -n 20` limits, `add -n` is a dry // run); `-R` follows `fossil -R`, naming a repository that need not have a // working tree attached. /// Builds `n` check-ins in `dir`, each touching the same file. The commits land /// as fast as the machine allows, so several routinely share a one-second /// timestamp — which is the case the limit must still order correctly. fn seedHistory(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, n: usize) !void { try initRepo(gpa, io, dir); try writeFile(io, dir, "f.txt", "0\n"); { var res = try runZit(gpa, io, dir, &.{ "add", "-q", "f.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } var i: usize = 0; while (i < n) : (i += 1) { var buf: [64]u8 = undefined; const body = try std.fmt.bufPrint(&buf, "line {d}\n", .{i}); try writeFile(io, dir, "f.txt", body); var msg_buf: [32]u8 = undefined; const msg = try std.fmt.bufPrint(&msg_buf, "c{d}", .{i}); var res = try runCommitEnv(gpa, io, dir, .{ .user = "u" }, &.{ "commit", "-q", "-m", msg }); defer freeResult(gpa, &res); try expectExit(res.term, 0); } } fn countLines(s: []const u8) usize { return std.mem.count(u8, s, "\n"); } test "cli timeline -n: the limit is a strict PREFIX of the unlimited listing, ties included" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try seedHistory(gpa, io, tmp.dir, 12); var full = try runZit(gpa, io, tmp.dir, &.{"timeline"}); defer freeResult(gpa, &full); try expectExit(full.term, 0); try std.testing.expectEqual(@as(usize, 12), countLines(full.stdout)); // The load-bearing assertion. Comparing only the ROW COUNT would pass even // if the limit selected an arbitrary N — the defect the ordering fix // existed to remove. Requiring the output to be the first N BYTES of the // unlimited listing can only hold if the cap is applied after the total // order, so this line fails for any wrong-N-check-ins implementation. for ([_]usize{ 1, 5, 11, 12 }) |n| { var num_buf: [8]u8 = undefined; const num = try std.fmt.bufPrint(&num_buf, "{d}", .{n}); var res = try runZit(gpa, io, tmp.dir, &.{ "timeline", "-n", num }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqual(n, countLines(res.stdout)); var end: usize = 0; var seen: usize = 0; while (seen < n) : (seen += 1) end = std.mem.indexOfScalarPos(u8, full.stdout, end, '\n').? + 1; try std.testing.expectEqualStrings(full.stdout[0..end], res.stdout); } } test "cli timeline -n: 0 is unlimited, a cap past the history shows everything" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try seedHistory(gpa, io, tmp.dir, 4); inline for (.{ "0", "99" }) |n| { var res = try runZit(gpa, io, tmp.dir, &.{ "timeline", "-n", n }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqual(@as(usize, 4), countLines(res.stdout)); } // `--limit` is the long alias for the same option. var alias = try runZit(gpa, io, tmp.dir, &.{ "timeline", "--limit", "2" }); defer freeResult(gpa, &alias); try expectExit(alias.term, 0); try std.testing.expectEqual(@as(usize, 2), countLines(alias.stdout)); } test "cli timeline/log -n: a non-numeric or negative value is refused, never silently defaulted" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try seedHistory(gpa, io, tmp.dir, 2); inline for (.{ "abc", "-3" }) |bad| { var res = try runZit(gpa, io, tmp.dir, &.{ "timeline", "-n", bad }); defer freeResult(gpa, &res); try expectExit(res.term, 2); // a usage error, not a silent full listing try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "invalid -n value") != null); } } test "cli log -n: limits the ancestry walk to the N most recent check-ins" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try seedHistory(gpa, io, tmp.dir, 6); // The tip's full ID, read off `leaves` (hash column). var leaves = try runZit(gpa, io, tmp.dir, &.{"leaves"}); defer freeResult(gpa, &leaves); try expectExit(leaves.term, 0); const star = std.mem.indexOfScalar(u8, leaves.stdout, ' ').? + 1; const tip = leaves.stdout[star .. star + 64]; var full = try runZit(gpa, io, tmp.dir, &.{ "log", tip }); defer freeResult(gpa, &full); try expectExit(full.term, 0); try std.testing.expectEqual(@as(usize, 6), countLines(full.stdout)); var lim = try runZit(gpa, io, tmp.dir, &.{ "log", "-n", "3", tip }); defer freeResult(gpa, &lim); try expectExit(lim.term, 0); try std.testing.expectEqual(@as(usize, 3), countLines(lim.stdout)); // Again a prefix, not merely a count: the walk order must be unchanged. try std.testing.expect(std.mem.startsWith(u8, full.stdout, lim.stdout)); // `-n 0` walks the whole ancestry. var all = try runZit(gpa, io, tmp.dir, &.{ "log", "-n", "0", tip }); defer freeResult(gpa, &all); try expectExit(all.term, 0); try std.testing.expectEqualStrings(full.stdout, all.stdout); } test "cli -n stays --dry-run on the working-tree verbs: the overload is per-verb" { const 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.txt", "x\n"); // `add -n f.txt` is a DRY RUN: it must not consume "f.txt" as a limit // value, and must leave the managed set empty. var res = try runZit(gpa, io, tmp.dir, &.{ "add", "-n", "f.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try expectManaged(gpa, io, tmp.dir, &.{}); } test "cli -R: a read verb runs against a store from outside any checkout" { const gpa = std.testing.allocator; const io = std.testing.io; var store = std.testing.tmpDir(.{}); defer store.cleanup(); var away = std.testing.tmpDir(.{}); defer away.cleanup(); try seedHistory(gpa, io, store.dir, 5); // `away` is a plain directory with no `.zit` anywhere: today's discovery // walk finds nothing there, which is exactly what `-R` overrides. { var bare = try runZit(gpa, io, away.dir, &.{"timeline"}); defer freeResult(gpa, &bare); try expectExit(bare.term, 1); try std.testing.expect(std.mem.indexOf(u8, bare.stderr, "not a zit repository") != null); } const cwd = try std.process.currentPathAlloc(io, gpa); defer gpa.free(cwd); const store_abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &store.sub_path }); defer gpa.free(store_abs); var in_place = try runZit(gpa, io, store.dir, &.{"timeline"}); defer freeResult(gpa, &in_place); try expectExit(in_place.term, 0); // Same bytes from outside as from inside — the override changes WHERE the // store is found and nothing else. var via_r = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", store_abs }); defer freeResult(gpa, &via_r); try expectExit(via_r.term, 0); try std.testing.expectEqualStrings(in_place.stdout, via_r.stdout); try std.testing.expectEqual(@as(usize, 5), countLines(via_r.stdout)); // `-R` composes with `-n` in one invocation (the two options land together // on the same verbs, so they are exercised together). var both = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", store_abs, "-n", "2" }); defer freeResult(gpa, &both); try expectExit(both.term, 0); try std.testing.expectEqual(@as(usize, 2), countLines(both.stdout)); try std.testing.expect(std.mem.startsWith(u8, in_place.stdout, both.stdout)); } test "cli -R: the long spelling, the .zit file itself, and a path holding no store" { const gpa = std.testing.allocator; const io = std.testing.io; var store = std.testing.tmpDir(.{}); defer store.cleanup(); var away = std.testing.tmpDir(.{}); defer away.cleanup(); try seedHistory(gpa, io, store.dir, 3); const cwd = try std.process.currentPathAlloc(io, gpa); defer gpa.free(cwd); const store_abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &store.sub_path }); defer gpa.free(store_abs); const file_abs = try std.fs.path.join(gpa, &.{ store_abs, ".zit" }); defer gpa.free(file_abs); const away_abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &away.sub_path }); defer gpa.free(away_abs); // `--repo <dir>` and `-R <dir>/.zit` both name the same store. var long = try runZit(gpa, io, away.dir, &.{ "timeline", "--repo", store_abs }); defer freeResult(gpa, &long); try expectExit(long.term, 0); var byfile = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", file_abs }); defer freeResult(gpa, &byfile); try expectExit(byfile.term, 0); try std.testing.expectEqualStrings(long.stdout, byfile.stdout); try std.testing.expectEqual(@as(usize, 3), countLines(long.stdout)); // A directory with no `.zit` is reported, never silently resolved to the cwd. var miss = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", away_abs }); defer freeResult(gpa, &miss); try expectExit(miss.term, 1); try std.testing.expectEqualStrings("", miss.stdout); try std.testing.expect(std.mem.indexOf(u8, miss.stderr, "not a zit repository") != null); } test "cli -R: every read verb accepts it; a bad value is refused rather than guessed" { const gpa = std.testing.allocator; const io = std.testing.io; var store = std.testing.tmpDir(.{}); defer store.cleanup(); var away = std.testing.tmpDir(.{}); defer away.cleanup(); try seedHistory(gpa, io, store.dir, 3); const cwd = try std.process.currentPathAlloc(io, gpa); defer gpa.free(cwd); const store_abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &store.sub_path }); defer gpa.free(store_abs); // The operand-free read verbs all reach the store from outside a checkout. inline for (.{ "timeline", "leaves", "branch" }) |verb| { var res = try runZit(gpa, io, away.dir, &.{ verb, "-R", store_abs }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(res.stdout.len > 0); } // A dangling `-R`, an empty value, and a repeated option are all usage // errors: none may fall back to discovery. { var res = try runZit(gpa, io, away.dir, &.{ "timeline", "-R" }); defer freeResult(gpa, &res); try expectExit(res.term, 2); } { var res = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", "" }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "non-empty value") != null); } { var res = try runZit(gpa, io, away.dir, &.{ "timeline", "-R", store_abs, "--repo", store_abs }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "more than once") != null); } } test "cli -R: checkout-dependent verbs REJECT it by name rather than ignoring it" { const gpa = std.testing.allocator; const io = std.testing.io; var store = std.testing.tmpDir(.{}); defer store.cleanup(); var work = std.testing.tmpDir(.{}); defer work.cleanup(); try seedHistory(gpa, io, store.dir, 2); try initRepo(gpa, io, work.dir); try writeFile(io, work.dir, "w.txt", "w\n"); const cwd = try std.process.currentPathAlloc(io, gpa); defer gpa.free(cwd); const store_abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &store.sub_path }); defer gpa.free(store_abs); // Run these from a REAL checkout (`work`), so a verb that wrongly ignored // `-R` would succeed against the wrong repository instead of erroring for // an unrelated reason. That is what makes the exit-2 assertion meaningful. inline for (.{ "status", "diff", "extras", "clean", "addremove", "undo", "redo" }) |verb| { var res = try runZit(gpa, io, work.dir, &.{ verb, "-R", store_abs }); 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, "not supported here") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "-R/--repo") != null); } // And the ones that take operands, including the write path. { var res = try runZit(gpa, io, work.dir, &.{ "add", "-R", store_abs, "w.txt" }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not supported here") != null); } { var res = try runZit(gpa, io, work.dir, &.{ "commit", "-R", store_abs, "-m", "x" }); defer freeResult(gpa, &res); try expectExit(res.term, 2); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "not supported here") != null); } // `amend` writes only the repository, yet it still rewrites HISTORY, so `-R` // would mint a check-in in the named store from a directory whose checkout // the operator never named. The argv is otherwise VALID — `trunk` resolves // in the seeded store and `--comment` is a real override — so the only // reason it fails is the `-R` refusal, not a bad operand. { var res = try runZit(gpa, io, work.dir, &.{ "amend", "-R", store_abs, "trunk", "--comment", "x" }); 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, "not supported here") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "-R/--repo") != null); } // The rejection is a REFUSAL, not a no-op: nothing was added to the tree. try expectManaged(gpa, io, work.dir, &.{}); } // --- help audit ----------------------------------------------------------- /// Whether `help` lists `verb` as a command entry — an indented line whose /// first token is exactly `verb`. Column-anchored on purpose: a bare substring /// match would pass vacuously when a verb NAME shows up only inside another /// verb's prose (e.g. "sync" in serve's one-liner, or "stat" inside "status"), /// so a dropped command line would slip through unnoticed. Indent-width /// agnostic — it keys on "leading whitespace, then the verb, then a space". fn helpListsVerb(help: []const u8, verb: []const u8) bool { var lines = std.mem.splitScalar(u8, help, '\n'); while (lines.next()) |line| { const body = std.mem.trimStart(u8, line, " "); if (body.len == line.len) continue; // no indent → a heading, not an entry if (std.mem.startsWith(u8, body, verb) and body.len > verb.len and body[verb.len] == ' ') { return true; } } return false; } /// Converts a Zig enum tag into its public CLI spelling. Command words use /// hyphens, while Zig enum identifiers use underscores. fn publicVerbName(tag: []const u8, buffer: []u8) []const u8 { std.debug.assert(buffer.len >= tag.len); for (tag, 0..) |byte, i| { buffer[i] = if (byte == '_') '-' else byte; } return buffer[0..tag.len]; } test "cli help audit: 'zit help' names every dispatched verb (anti-rot guard)" { 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); // Structural pin: the candidate set is the dispatched `Verb` enum itself, // not a hand-copied list, so a verb added to the dispatcher without a help // line fails HERE rather than shipping undiscoverable. `.unknown` is the // catch-all sentinel — never a command — so it is the sole exclusion. inline for (comptime std.enums.values(cli.Verb)) |v| { if (v == .unknown) continue; var public_verb_buffer: [@tagName(v).len]u8 = undefined; const public_verb = publicVerbName(@tagName(v), &public_verb_buffer); if (!helpListsVerb(res.stdout, public_verb)) { std.debug.print( "dispatched verb '{s}' is absent from 'zit help' output\n", .{public_verb}, ); return error.VerbMissingFromHelp; } } } // --- sub-manifest convergence across pull rounds --------------------------- // // draft-fossil-submanifests-00 §7: two zit peers converge a tree whose closure // arrives in stages, over the REAL binaries and the REAL wire. §7.1 fixes the // shape this test drives: "An interior sub-manifest's own S references become // known only when it arrives, so a tree of depth d converges in at most d+1 // pull rounds." A's tree is completed between the two rounds, so B necessarily // sees the intermediate state — a check-in it holds whose file set it cannot // yet produce — and §5.2's "MUST NOT present a partially resolved tree as a // complete file set" is asserted there, not assumed. /// Appends the §6.2 Z self-checksum card to `body` and returns the whole /// artifact. The digest is the MD5 of every byte before the `Z`, hex-encoded — /// computed here rather than hard-coded, so the fixture stays correct if a /// card above it is ever edited. fn withZCard(gpa: std.mem.Allocator, body: []const u8) ![]u8 { var digest: [16]u8 = undefined; std.crypto.hash.Md5.hash(body, &digest, .{}); return std.fmt.allocPrint(gpa, "{s}Z {x}\n", .{ body, &digest }); } /// One leaf sub-manifest holding `names` as its F run (submanifests §4.2: a /// SUBMANIFEST carries F, S, and Z only, and never both F and S). Each F card /// carries a hash because §4.4 forbids a hashless F card in a SUBMANIFEST. fn leafSubManifest(gpa: std.mem.Allocator, names: []const []const u8) ![]u8 { var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); for (names) |name| { const blob = try sha3HexAlloc(gpa, name); defer gpa.free(blob); try body.print(gpa, "F {s} {s}\n", .{ name, blob }); } return withZCard(gpa, body.items); } test "cli pull submanifests §7.1/§7.3: two peers converge a tree across pull rounds" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: a real repository with a real check-in, so clone has a project // code and a checkout to work from. 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", "seed\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); // The tree: two leaves holding contiguous slices of one sorted namespace // (submanifests §3.6), and the check-in manifest that references them. Each // S card's filename is the first filename of its subtree (§5.1 steps 4/5) // and the run strictly ascends (§3.4). const leaf_ab = try leafSubManifest(gpa, &.{ "a.txt", "b.txt" }); defer gpa.free(leaf_ab); const leaf_mn = try leafSubManifest(gpa, &.{ "m.txt", "n.txt" }); defer gpa.free(leaf_mn); var name_buf_ab: [64]u8 = undefined; var name_buf_mn: [64]u8 = undefined; const hash_ab = artifact.artifactName(.sha3_256, leaf_ab, &name_buf_ab); const hash_mn = artifact.artifactName(.sha3_256, leaf_mn, &name_buf_mn); const tree_body = try std.fmt.allocPrint( gpa, "C tree\nD 2026-07-27T12:00:00.000\nS a.txt {s}\nS m.txt {s}\nU u\n", .{ hash_ab, hash_mn }, ); defer gpa.free(tree_body); const tree = try withZCard(gpa, tree_body); defer gpa.free(tree); // ROUND 0: A holds the tree manifest and NEITHER leaf — the §7.1 phantom // state, staged deliberately so B must converge across two pulls. const tree_hash = try appendArtifact(gpa, io, tmp_a.dir, tree); defer gpa.free(tree_hash); const port0 = try freshLoopbackPort(io); var server0 = try spawnServe(gpa, io, tmp_a.dir, port0); defer server0.kill(io); try waitForListen(io, port0); const url0 = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{port0}); defer gpa.free(url0); var tmp_b = std.testing.tmpDir(.{}); defer tmp_b.cleanup(); var clone = try runZit(gpa, io, tmp_b.dir, &.{ "clone", url0, "repo" }); defer freeResult(gpa, &clone); // §7.1 ON THE CLONE PATH. A clone converges on the cfile cursor, so it // faithfully copies what A holds — INCLUDING A's own incomplete closure. // "Storing a structural artifact that carries S cards whose referenced // sub-manifests are absent MUST create a phantom for each missing // reference"; the reference the phantom stands for is precisely what B does // not have, so a clone that reports success here would hand the operator a // repository whose gap it recorded nowhere and would never ask for. The // repository is still written in full — a partial clone is progress — but // the exit status says the closure is open, exactly as `pull` already does // for artifacts it did not obtain. try std.testing.expect(clone.term != .exited or clone.term.exited != 0); try std.testing.expect(std.mem.indexOf(u8, clone.stderr, "sub-manifest") != null); try std.testing.expect(std.mem.indexOf(u8, clone.stderr, "2 ") != null); // both leaves, not one var b_repo = try tmp_b.dir.openDir(io, "repo", .{}); defer b_repo.close(io); // MID-CONVERGENCE, §5.2: B holds the check-in but not its sub-manifests, so // its file set MUST NOT be produced. `ls` fails rather than printing the // part of the tree B can already reach — a partial enumeration presented as // a file set is the exact failure the section forbids. var partial = try runZit(gpa, io, b_repo, &.{ "ls", tree_hash }); defer freeResult(gpa, &partial); try std.testing.expect(partial.term != .exited or partial.term.exited != 0); try std.testing.expectEqualStrings("", partial.stdout); try std.testing.expect(partial.stderr.len != 0); // The same holds on A, which is missing the same two artifacts: the gap is // a property of the repository's holdings, not of having synced. var partial_a = try runZit(gpa, io, tmp_a.dir, &.{ "ls", tree_hash }); defer freeResult(gpa, &partial_a); try std.testing.expectEqualStrings("", partial_a.stdout); // ROUND 1: the sub-manifests arrive at A, and B pulls again. const got_ab = try appendArtifact(gpa, io, tmp_a.dir, leaf_ab); defer gpa.free(got_ab); const got_mn = try appendArtifact(gpa, io, tmp_a.dir, leaf_mn); defer gpa.free(got_mn); try std.testing.expectEqualStrings(hash_ab, got_ab); // the S card names what arrived try std.testing.expectEqualStrings(hash_mn, got_mn); const port1 = try freshLoopbackPort(io); var server1 = try spawnServe(gpa, io, tmp_a.dir, port1); defer server1.kill(io); try waitForListen(io, port1); const url1 = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/xfer", .{port1}); defer gpa.free(url1); var pull = try runZit(gpa, io, b_repo, &.{ "pull", url1 }); defer freeResult(gpa, &pull); try expectExitDiag(pull.term, 0, pull); // THE PROOF: B's holdings equal A's, and the tree now enumerates to its // complete file set — the concatenation of the two leaves' F runs in S card // order (submanifests §5.1), which is also the sorted namespace. const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const b_digests = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_digests); try expectSameDigestSet(a_digests, b_digests); var listed = try runZit(gpa, io, b_repo, &.{ "ls", tree_hash }); defer freeResult(gpa, &listed); try expectExitDiag(listed.term, 0, listed); // Built from the same content hashes the leaves' F cards carry, so the // expectation is derived from the fixture rather than transcribed from a // passing run. var expected: std.ArrayList(u8) = .empty; defer expected.deinit(gpa); for ([_][]const u8{ "a.txt", "b.txt", "m.txt", "n.txt" }) |name| { const blob = try sha3HexAlloc(gpa, name); defer gpa.free(blob); try expected.print(gpa, "{s} {s}\n", .{ blob, name }); } try std.testing.expectEqualStrings(expected.items, listed.stdout); } // =========================================================================== // `zit submanifest` — the draft-fossil-submanifests-00 §6.3/§6.4/§8.1 operator // surface, end to end through the real binary. // =========================================================================== /// Reads the two sub-manifest settings out of a repository's `.zit` config /// frame — the durable state, not the verb's own report, so a claim that /// "enabling persisted" is proved against storage rather than against stdout. fn readSubmanifestSettings(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !submanifest.Settings { 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 .of(oc.config); } /// Reads the internal cache marker only to prove conservative invalidation. It /// is not an RFC setting; the surrounding enable/retune behavior is §6.3/§6.4. fn readSubmanifestSettledCostShape( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, ) !?[32]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 oc.config.submanifest_settled_cost_shape; } /// Overwrite the pinned k directly in the config frame, bypassing the verb. /// The ONLY way to reach §6.3's out-of-range state: `zit submanifest` refuses /// to pin one, so a repository can only get there by a hand-edited config or a /// writer from another implementation — which is exactly the case §6.3's /// fallback rule exists for. fn forcePinnedK(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, k: 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); var oc = (try log.readConfig(gpa)) orelse return error.TestUnexpectedResult; defer oc.deinit(gpa); var next = oc.config; next.submanifest_k = k; try log.putConfig(next); try log.commit(); } /// Overwrite `submanifest-auto-retune` directly in the config frame, bypassing /// the verb surface — there is no `zit submanifest` flag for this setting yet /// (that CLI surface, and its propagation to a clone or peer, is out of scope /// for the commit-path auto-dispatch this helper exists to test; §6.4's /// opt-in setting still needs to be reachable by SOME writer for a test /// to exercise it, the same reasoning `forcePinnedK` states for the /// out-of-range case above). fn forceAutoRetune(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, enabled: bool) !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); var oc = (try log.readConfig(gpa)) orelse return error.TestUnexpectedResult; defer oc.deinit(gpa); var next = oc.config; next.submanifest_auto_retune = enabled; try log.putConfig(next); try log.commit(); } /// Seeds `n` tracked files and commits them, returning the check-in hash. /// `n = 40` partitions into a real tree at the k = 3 the tests pin; the count /// is a TEST input, never a measured optimum. fn seedManyFiles(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, n: usize) !void { for (0..n) |i| { var name_buf: [32]u8 = undefined; const name = try std.fmt.bufPrint(&name_buf, "f{d:0>3}.txt", .{i}); try writeFile(io, dir, name, "x\n"); var add = try runZit(gpa, io, dir, &.{ "add", name }); defer freeResult(gpa, &add); try expectExitDiag(add.term, 0, add); } } test "cli commit submanifests §§5.1/5.4: a partial commit preserves unnamed tree-parent 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); try seedManyFiles(gpa, io, tmp.dir, 40); var enable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &enable); try expectExitDiag(enable.term, 0, enable); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &root); try expectExitDiag(root.term, 0, root); const root_hash = try checkinHash(root.stdout); var root_manifest = try parseCheckin(gpa, io, tmp.dir, root_hash); defer root_manifest.deinit(); try std.testing.expect(root_manifest.m.s.len != 0); try writeFile(io, tmp.dir, "f000.txt", "changed\n"); var child = try runCommitEnv( gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "partial", "f000.txt" }, ); defer freeResult(gpa, &child); try expectExitDiag(child.term, 0, child); var changed = try runZit(gpa, io, tmp.dir, &.{ "cat", "f000.txt" }); defer freeResult(gpa, &changed); try expectExit(changed.term, 0); try std.testing.expectEqualStrings("changed\n", changed.stdout); var unchanged = try runZit(gpa, io, tmp.dir, &.{ "cat", "f001.txt" }); defer freeResult(gpa, &unchanged); try expectExit(unchanged.term, 0); try std.testing.expectEqualStrings("x\n", unchanged.stdout); } test "cli commit submanifests §5.1 artifact §4.7.6: partial commit consumes carried rename" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); var enable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &enable); try expectExitDiag(enable.term, 0, enable); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &root); try expectExitDiag(root.term, 0, root); var move = try runZit(gpa, io, tmp.dir, &.{ "mv", "f000.txt", "renamed.txt" }); defer freeResult(gpa, &move); try expectExitDiag(move.term, 0, move); var rename = try runCommitEnv( gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "rename", "renamed.txt" }, ); defer freeResult(gpa, &rename); try expectExitDiag(rename.term, 0, rename); try writeFile(io, tmp.dir, "f001.txt", "changed\n"); var partial = try runCommitEnv( gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "other", "f001.txt" }, ); defer freeResult(gpa, &partial); try expectExitDiag(partial.term, 0, partial); var renamed = try runZit(gpa, io, tmp.dir, &.{ "cat", "renamed.txt" }); defer freeResult(gpa, &renamed); try expectExit(renamed.term, 0); try std.testing.expectEqualStrings("x\n", renamed.stdout); var old = try runZit(gpa, io, tmp.dir, &.{ "cat", "f000.txt" }); defer freeResult(gpa, &old); try expectExit(old.term, 1); } test "cli submanifests §6.3/§6.4: retune clears stale cache when checkout resolution fails" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 2); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExitDiag(root.term, 0, root); var enable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &enable); try expectExitDiag(enable.term, 0, enable); try std.testing.expect((try readSubmanifestSettledCostShape(gpa, io, tmp.dir)) != null); // Deliberately make the checkout unreadable after the cache was populated. // §6.3 permits retuning; failure to reconstruct its effective F-set must // conservatively invalidate the implementation-only cache. try writeFile(io, tmp.dir, ".zit-checkout", "corrupt\n"); var retune = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "retune", "--k", "4" }); defer freeResult(gpa, &retune); try expectExitDiag(retune.term, 0, retune); try std.testing.expect((try readSubmanifestSettledCostShape(gpa, io, tmp.dir)) == null); } test "cli submanifests §6.3: enable clears stale cache when checkout resolution fails" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 2); var root = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "root" }); defer freeResult(gpa, &root); try expectExitDiag(root.term, 0, root); var enable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &enable); try expectExitDiag(enable.term, 0, enable); try std.testing.expect((try readSubmanifestSettledCostShape(gpa, io, tmp.dir)) != null); var disable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "disable" }); defer freeResult(gpa, &disable); try expectExitDiag(disable.term, 0, disable); try writeFile(io, tmp.dir, ".zit-checkout", "corrupt\n"); var reenable = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &reenable); try expectExitDiag(reenable.term, 0, reenable); try std.testing.expect((try readSubmanifestSettledCostShape(gpa, io, tmp.dir)) == null); } test "cli submanifests §8.1: emission is OFF by default and a default repository mints no S card" { // THE default-off proof, at the only layer that can prove it: a repository // that nobody configured, committed through the real binary, whose minted // manifest carries a full flat F run and an EMPTY S 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); // The stored settings are off and unpinned before anything is asked of them. const settings = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!settings.enabled); try std.testing.expect(!settings.pinned()); try seedManyFiles(gpa, io, tmp.dir, 40); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "flat" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); const hash = try checkinHash(ci.stdout); var m = try parseCheckin(gpa, io, tmp.dir, hash); defer m.deinit(); try std.testing.expectEqual(@as(usize, 0), m.m.s.len); // §8.1 — no S card try std.testing.expectEqual(@as(usize, 40), m.m.f.len); // the whole flat run // …and the §8.1 seen-submanifest indicator is untouched, so this repository // is not treated as one that holds trees. var file = try tmp.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)).?; defer oc.deinit(gpa); try std.testing.expect(!oc.config.seen_submanifest); } test "cli submanifests §6.3/§8.1: a one-name check-in has no candidate, and enabling refuses atomically" { // The minimal no-candidate scenario: one name makes the height-0 scan flat // at every k (§6.2 step 2 needs at least two groups), so §6.3's automatic // selection "has no result" and §8.1's rule fires — "the writer MUST refuse // the request atomically: enable-submanifests and submanifest-k retain // their pre-request values, and no S card is emitted." Distinct from the // no-check-in case below: here the name set exists and is analyzed, and the // analysis is what comes back empty. const 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, "only.txt"); { var a = try runZit(gpa, io, tmp.dir, &.{ "add", "only.txt" }); defer freeResult(gpa, &a); try expectExitDiag(a.term, 0, a); } { var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "one" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); } // The analysis pass agrees there is no candidate, and says so without failing. { var a = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "analyze" }); defer freeResult(gpa, &a); try expectExitDiag(a.term, 0, a); try std.testing.expect(std.mem.indexOf(u8, a.stdout, "no k partitions") != null); } var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no k partitions") != null); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "--k") != null); // "retain their pre-request values" — atomically, so BOTH settings are as // they were, not just the one the refusal is about. const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!after.enabled); try std.testing.expect(!after.pinned()); // §8.1's escape hatch is still open: an explicit in-range k pins and enables // on the very same repository, which is what proves the refusal was about // the absent selection and not about the name set being unusable. var e = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "4" }); defer freeResult(gpa, &e); try expectExitDiag(e.term, 0, e); const pinned = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(pinned.enabled); try std.testing.expectEqual(@as(u8, 4), pinned.k); } test "cli submanifests §8.1: enabling is refused when no parameter can be selected" { // §6.3 has the writer select k on enabling, so the bare verb analyzes // rather than demanding a number. With no check-in open there is no name // set to select from — and §8.1's "MUST NOT enable emission on a repository // with no pinned parameter" then makes this a refusal, not a fallback to // some value of the implementation's own. 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 r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "no file set") != null); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "--k") != null); const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!after.enabled); // refused means NOT written try std.testing.expect(!after.pinned()); } test "cli submanifests §6.3: an out-of-range k is refused at the pin, at every value outside 1..12" { 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{ "0", "13", "200" }) |k| { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", k }); defer freeResult(gpa, &r); try std.testing.expect(r.term.exited != 0); const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!after.enabled); try std.testing.expect(!after.pinned()); } // Both ends of §6.3's supported range are accepted. for ([_][]const u8{ "1", "12" }) |k| { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "retune", "--k", k }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } } test "cli submanifests §8.1/§6.2: enabling warns, pins k, and the next commit mints a tree" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { // k = 3 is the Appendix A.5 conformance vector's value, pinned here for // the same reason: a test must never take its input from a measurement. var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); // §8.1/§8.2 — the consequence is shown, not implied. Both irreversible // halves must appear: the refusal of pre-2.29 peers, and the fact that // disabling later cannot retire what was written. try std.testing.expect(std.mem.indexOf(u8, r.stdout, "2.29") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "cannot retire") != null); // §6.3's project scope is stated to the operator, both halves: a clone // adopts these settings, an existing repository is not refreshed. try std.testing.expect(std.mem.indexOf(u8, r.stdout, "a CLONE adopts these settings") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "An EXISTING repository is not refreshed") != null); } const settings = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(settings.enabled); try std.testing.expectEqual(@as(u8, 3), settings.k); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); const hash = try checkinHash(ci.stdout); var m = try parseCheckin(gpa, io, tmp.dir, hash); defer m.deinit(); // §6.2 step 3 — the manifest carries its S run and NO F run: "the file set // is conveyed by the F run or the S run", never both. try std.testing.expect(m.m.s.len >= 2); try std.testing.expectEqual(@as(usize, 0), m.m.f.len); // §8.1 — "When the first S-bearing artifact is stored, an implementation // SHOULD record a local seen-submanifest indicator". The commit path is one // of the two places S-bearing artifacts enter a log; the indicator is what // keeps the §7.2 transfer gate armed after the setting is turned back off. { var file = try tmp.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)).?; defer oc.deinit(gpa); try std.testing.expect(oc.config.seen_submanifest); } // §5.1 — the tree enumerates back to the complete file set, so the check-in // is not merely well-formed but readable as the 40 files it was built from. var listed = try runZit(gpa, io, tmp.dir, &.{ "ls", hash }); defer freeResult(gpa, &listed); try expectExitDiag(listed.term, 0, listed); var lines: usize = 0; var it = std.mem.splitScalar(u8, listed.stdout, '\n'); while (it.next()) |l| { if (l.len != 0) lines += 1; } try std.testing.expectEqual(@as(usize, 40), lines); } test "cli submanifests §8.1: disabling stops new trees, keeps the pin, and says what it cannot undo" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } { var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); } { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "disable" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); // §8.1 — "Disabling the setting stops the emission of new tree // manifests; it cannot retire existing ones." try std.testing.expect(std.mem.indexOf(u8, r.stdout, "cannot retire") != null); } // The pin SURVIVES a disable: it is the repository's content-sharing // identity, and clearing it would silently make a later re-enable a §6.4 // re-tune (an every-leaf rewrite) instead of a resume. const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!after.enabled); try std.testing.expectEqual(@as(u8, 3), after.k); // …and the next commit is flat again. try writeFile(io, tmp.dir, "later.txt", "y\n"); { var add = try runZit(gpa, io, tmp.dir, &.{ "add", "later.txt" }); defer freeResult(gpa, &add); try expectExitDiag(add.term, 0, add); } var ci2 = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "flat again" }); defer freeResult(gpa, &ci2); try expectExitDiag(ci2.term, 0, ci2); var m = try parseCheckin(gpa, io, tmp.dir, try checkinHash(ci2.stdout)); defer m.deinit(); try std.testing.expectEqual(@as(usize, 0), m.m.s.len); try std.testing.expectEqual(@as(usize, 41), m.m.f.len); } test "cli submanifests §6.3: an out-of-range pinned k falls back to a flat manifest and reports it" { // "on receiving a value outside 1..12 it MUST fall back to emitting flat // manifests rather than substitute a parameter of its own, and SHOULD report // the fallback to the operator." Reached by writing the bad value straight // into the config, because the verb refuses to pin one. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } try forcePinnedK(gpa, io, tmp.dir, 13); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "fallback" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); // the commit SUCCEEDS — a fallback, not a failure try std.testing.expect(std.mem.indexOf(u8, ci.stderr, "out of range") != null); // the SHOULD-report var m = try parseCheckin(gpa, io, tmp.dir, try checkinHash(ci.stdout)); defer m.deinit(); try std.testing.expectEqual(@as(usize, 0), m.m.s.len); // flat, with no substituted parameter try std.testing.expectEqual(@as(usize, 40), m.m.f.len); // `show` reports the same fallback, from the same decision. var r = try runZit(gpa, io, tmp.dir, &.{"submanifest"}); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "out of range") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "commits emit: flat manifests") != null); } test "cli submanifests §6.4: a re-tune is manual, needs an explicit value, and warns about the leaf rewrite" { // "A re-tune MUST be manual and operator-initiated ... an implementation MAY // advise of drift from the analyzed optimum but MUST NOT re-tune // automatically." A bare `retune` therefore must NOT consult the analysis // pass — it must fail and ask for a value. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "retune" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "explicit value") != null); // Nothing moved. const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 3), after.k); } { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "retune", "--k", "4" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); // §6.4 — "The first commit after it shares no sub-manifest with its // parent's tree and rewrites every leaf, once." try std.testing.expect(std.mem.indexOf(u8, r.stdout, "rewrites every") != null); const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 4), after.k); try std.testing.expect(after.enabled); // a re-tune does not touch the gate } // `enable --k N` refuses to silently re-pin: that is what `retune` is for. var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "5" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "re-tune") != null); const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 4), after.k); } test "cli submanifests §6.3: the analysis pass reports every k, changes nothing, and never pins" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); } var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "analyze" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "names: 40") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "analyzed optimum: k=") != null); // §6.5's prediction is printed beside the measurement, so drift between the // two is visible rather than assumed away. try std.testing.expect(std.mem.indexOf(u8, r.stdout, "§6.5 prediction") != null); // §6.3/§6.4 — advisory, and it pins nothing: the settings are untouched. const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(!after.enabled); try std.testing.expect(!after.pinned()); } test "cli submanifests §6.3: enabling selects the parameter it analyzes, without an operator value" { // "The partition parameter k is selected once, when the enable-submanifests // setting is first turned on for a repository, and pinned" — by the writer, // minimizing §6.3's objective over the current name set. So the bare verb // succeeds, and the value it pins is the value `analyze` reports: both go // through one cost pass, and this is what holds them together. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "seed" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); } // What the advisory pass reports. var analyzed_k: u8 = 0; { var a = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "analyze" }); defer freeResult(gpa, &a); try expectExitDiag(a.term, 0, a); const marker = "analyzed optimum: k="; const at = std.mem.indexOf(u8, a.stdout, marker) orelse return error.TestUnexpectedResult; const rest = a.stdout[at + marker.len ..]; const end = std.mem.indexOfNone(u8, rest, "0123456789") orelse rest.len; analyzed_k = try std.fmt.parseInt(u8, rest[0..end], 10); try std.testing.expect(analyzed_k >= 1 and analyzed_k <= 12); } var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); // It says the value was selected rather than defaulted, and what from. try std.testing.expect(std.mem.indexOf(u8, r.stdout, "selected by the §6.3 analysis") != null); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "40 names") != null); const after = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expect(after.enabled); try std.testing.expect(after.pinned()); try std.testing.expectEqual(analyzed_k, after.k); // §6.3: "k is selected ONCE ... and pinned". A second enable re-measures // nothing and leaves the pin where it is — moving it is a re-tune (§6.4). var again = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable" }); defer freeResult(gpa, &again); try expectExitDiag(again.term, 0, again); try std.testing.expect(std.mem.indexOf(u8, again.stdout, "selected by the §6.3 analysis") == null); const twice = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(analyzed_k, twice.k); } test "cli submanifests §6.1: the analysis pass measures a name list, and rejects one out of canonical order" { // §6.1: "The predicate reads the filename only: content hash, permission, // and old-name never influence the partition" — so a name list is a complete // input, which is how a name set from a repository zit cannot open gets // measured. §6.2's precondition (strictly ascending canonical order) is // reported rather than silently repaired. 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 sorted: std.ArrayList(u8) = .empty; defer sorted.deinit(gpa); for (0..40) |i| try sorted.print(gpa, "src/f{d:0>3}.zig\n", .{i}); try writeFile(io, tmp.dir, "names.txt", sorted.items); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "analyze", "--names", "names.txt" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); try std.testing.expect(std.mem.indexOf(u8, r.stdout, "names: 40") != null); } try writeFile(io, tmp.dir, "unsorted.txt", "b.txt\na.txt\n"); var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "analyze", "--names", "unsorted.txt" }); defer freeResult(gpa, &r); try expectExit(r.term, 1); try std.testing.expect(std.mem.indexOf(u8, r.stderr, "canonical order") != null); } // =========================================================================== // `zit commit` submanifests §6.4 — the post-commit drift advisory. "An // implementation MAY advise of drift from the analyzed optimum at any time" // — unconditional, independent of the `submanifest-auto-retune` gate §6.4 // puts on the re-tune half. These run only through the real binary's // non-interactive path (test stdin is never a tty): the confirm-prompt's // accepted-'y' branch is deliberately NOT covered here, matching // `cmd/sync.zig`'s own // `readPasswordNoEcho` precedent, which states the same limit about itself. // =========================================================================== test "cli commit submanifests §6.4: a repository that never enabled sub-manifests gets no advisory" { // Requirement 1: zero added behavior on the ordinary case — a repository // that never turned submanifests on prints nothing new, ever. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "flat" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expect(std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") == null); } test "cli commit submanifests §6.4: no advisory when the pinned k is already the analyzed optimum" { // k = 3 is the Appendix A.5 conformance vector's value for this exact // 40-file synthetic set (the same fixture `seedManyFiles` builds elsewhere // in this file) — i.e. it already IS the analyzed optimum, so `driftCheck` // must find nothing to recommend. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expect(std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") == null); } test "cli commit submanifests §6.4: genuine margin-clearing drift prints the exact advisory line" { // k = 1 pins §6.3's coarsest supported partition against 200 names — a // fixed, deliberate mismatch (the same deterministic "x\n"-content fixture // every seed helper in this file uses, never a measured one) big enough // that the analyzed optimum clears the §6.4 margin gate by a wide margin. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 200); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "1" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); // Requirement 6: an advisory (present or not) never changes commit's own // exit code — every commit in this test block, drifted or not, exits 0. try expectExitDiag(ci.term, 0, ci); try std.testing.expectEqualStrings( "submanifest-k has drifted from 1 to 3 (65.3% read-cost improvement)\n", ci.stdout[std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") orelse return error.TestUnexpectedResult ..], ); } test "cli commit submanifests §6.4: --no-submanifest-advice suppresses the advisory even with real drift" { // Requirement 2: the flag skips step 3 onward entirely — same genuine-drift // fixture as the test above, but no line, no prompt, and the check-in's own // success line is untouched. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 200); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "1" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } var ci = try runCommitEnv( gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree", "--no-submanifest-advice" }, ); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expect(std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") == null); _ = try checkinHash(ci.stdout); // the ordinary success line still prints } // =========================================================================== // `zit commit` submanifests §6.4 — `submanifest-auto-retune` dispatch. §6.4's // opt-in shape: default false keeps the "manual and operator-initiated" // re-tune in force byte-for-byte (the test directly below is the regression // proof of that); true is the durable project-operator act §6.4 requires // before ANY automatic re-tune may fire, after which drift no longer needs a // fresh 'y' at every commit that crosses the margin gate. // =========================================================================== test "cli commit submanifests §6.4: auto-retune false (default) is byte-identical to the advisory-only path" { // Regression proof, not just a new-behavior test: explicitly forcing the // setting to its own default and re-running the exact genuine-drift // fixture from the advisory-only tests above must print the identical // advisory line, take no automatic action, and leave the pin untouched — // the auto-dispatch addition changes nothing on this path. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 200); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "1" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } try forceAutoRetune(gpa, io, tmp.dir, false); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expectEqualStrings( "submanifest-k has drifted from 1 to 3 (65.3% read-cost improvement)\n", ci.stdout[std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") orelse return error.TestUnexpectedResult ..], ); // No prompt is a tty-only concern (test stdin is never a tty, so the // existing advisory-only tests already prove the non-interactive prompt // path prints nothing further); what THIS test proves is the pin itself: // the non-interactive, non-auto-retune path never writes it. const settings = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 1), settings.k); } test "cli commit submanifests §6.4: auto-retune true dispatches the retune with no prompt" { // §6.4's core case: with the durable opt-in set, genuine drift is // applied directly — no prompt (works non-interactively, unlike the // manual-confirm path), the pin actually moves (proved by a config // readback, not just stdout), and the same "next commit rewrites every // leaf, once" note the manual path prints appears here too. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 200); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "1" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } try forceAutoRetune(gpa, io, tmp.dir, true); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expectEqualStrings( "submanifest-k has drifted from 1 to 3 (65.3% read-cost improvement)\n" ++ "submanifest-k: 1 -> 3\n" ++ "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++ "leaf, once. No history is rewritten and no reader behaves differently.\n", ci.stdout[std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") orelse return error.TestUnexpectedResult ..], ); // The durable proof: the pin actually moved in storage, not just on stdout. const settings = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 3), settings.k); } test "cli commit submanifests §6.4: auto-retune true with no drift is a no-op" { // The opt-in changes dispatch, not the drift gate: with the pin already at // the analyzed optimum, `driftCheck` recommends nothing, so there is // nothing for auto-retune to dispatch — same silence as the advisory-only // no-drift test above, and the pin stays exactly where it was. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); try seedManyFiles(gpa, io, tmp.dir, 40); { var r = try runZit(gpa, io, tmp.dir, &.{ "submanifest", "enable", "--k", "3" }); defer freeResult(gpa, &r); try expectExitDiag(r.term, 0, r); } try forceAutoRetune(gpa, io, tmp.dir, true); var ci = try runCommitEnv(gpa, io, tmp.dir, .{ .user = "u" }, &.{ "commit", "-m", "tree" }); defer freeResult(gpa, &ci); try expectExitDiag(ci.term, 0, ci); try std.testing.expect(std.mem.indexOf(u8, ci.stdout, "submanifest-k has drifted") == null); const settings = try readSubmanifestSettings(gpa, io, tmp.dir); try std.testing.expectEqual(@as(u8, 3), settings.k); } // --------------------------------------------------------------------------- // `zit pack` reclaims deferred shuns (the pack filter). No `zit shun` verb exists // yet, so these tests append the tombstone through the library — the same frame // that verb will write — and then drive the REAL binary over it. // --------------------------------------------------------------------------- /// Appends a shun row naming the artifact in `dir/.zit` whose reconstructed /// content equals `body`, and returns that artifact's raw digest (caller frees). /// Finds the artifact by scanning rather than by recomputing a hash, so the test /// does not have to know which hash algorithm the repository was initialized /// with. Fails if no artifact holds those bytes — the premise, not an assertion /// about pack. fn shunArtifactByContent( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, body: []const u8, reason: []const u8, ) ![]u8 { 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); const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); for (rids) |rid| { if (log.kindOf(rid) == .config) continue; const content = (try log.content(gpa, rid)) orelse continue; defer gpa.free(content); if (!std.mem.eql(u8, content, body)) continue; const digest = try gpa.dupe(u8, log.hashOf(rid)); errdefer gpa.free(digest); try log.putShun(digest, reason, 1753000010); try log.commit(); return digest; } return error.NoArtifactHoldsThoseBytes; } test "cli: zit pack reclaims a deferred shun, reports the count, and keeps the tombstone" { // A shun masks; a pack RECLAIMS. Between the two the bytes are still in the // store, which is the whole reason `zit pack` grew a filter — an operator who // was ordered to remove content needs one command that actually removes it, // and needs to be told how much it removed rather than inferring it from a // byte count that shrinks on an ordinary re-deltify too. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Two files so a pack that dropped everything cannot pass by accident. try initRepo(gpa, io, tmp.dir); try writeFile(io, tmp.dir, "doomed.txt", "content an operator was ordered to remove\n"); try writeFile(io, tmp.dir, "keep.txt", "content with nothing against it\n"); try addManaged(gpa, io, tmp.dir, &.{ "doomed.txt", "keep.txt" }); const root = try commitHashDup(gpa, io, tmp.dir, "root"); defer gpa.free(root); const doomed = try shunArtifactByContent( gpa, io, tmp.dir, "content an operator was ordered to remove\n", "takedown notice 4717", ); defer gpa.free(doomed); // The premise: masked but STILL STORED. `cat` refuses it (the shun mask), // while the frame is physically there for the pack to reclaim. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "doomed.txt", root }); defer freeResult(gpa, &res); try expectExit(res.term, 1); } { var res = try runZit(gpa, io, tmp.dir, &.{"pack"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("", res.stderr); // THE OPERATOR-VISIBLE PIN: the run says what it removed. Substring, not // the whole line — the artifact/byte counts are environment-dependent // and the exact wording is unit-tested in cmd/sync.zig. if (std.mem.indexOf(u8, res.stdout, "1 shunned reclaimed") == null) { std.debug.print("pack did not report the reclaim: {s}\n", .{res.stdout}); return error.ReclaimCountNotReported; } } // The on-disk oracle, read back through the library: the bytes are gone from // the packed store — not merely masked — and the tombstone that named them is // not, so the artifact still reads as absent and a sync will not fetch it back. { var file = try tmp.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); try std.testing.expect(log.lookup(doomed) == null); try std.testing.expect(log.isShunned(doomed)); const rows = log.shunList(); try std.testing.expectEqual(@as(usize, 1), rows.len); try std.testing.expectEqualStrings("takedown notice 4717", rows[0].reason); } // The unshunned neighbour is still readable through the real binary, so the // reclaim removed exactly what it was told to and nothing adjacent. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "keep.txt", root }); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expectEqualStrings("content with nothing against it\n", res.stdout); } // A second pack has nothing left to reclaim and must say nothing about it. { var res = try runZit(gpa, io, tmp.dir, &.{"pack"}); defer freeResult(gpa, &res); try expectExit(res.term, 0); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "shunned reclaimed") == null); } } // --------------------------------------------------------------------------- // `zit shun` through the REAL binary. // // NO DRAFT SPECIFIES THIS VERB: sync §3.3 (docs/rfcs/ // draft-fossil-sync-protocol-00.txt L518-519) defines the repository shun table // and L520-521 grants that local administration may add names to it; it names no command // and no policy. Every expectation below is therefore pinned against zit's own // stated policy — refuse by default, `--force` gates BOTH durable paths, the // current checkout's check-in has no override — not against a section number. // --------------------------------------------------------------------------- /// The full lowercase-hex name of the artifact in `dir/.zit` whose reconstructed /// content equals `body` (caller frees). Found by scanning rather than by /// recomputing a hash, so the test never has to know which hash algorithm the /// repository was initialized with. Fails if nothing holds those bytes — that is /// the fixture's premise, not an assertion about shun. fn artifactNameByContent( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, body: []const u8, ) ![]u8 { var file = try dir.openFile(io, ".zit", .{}); 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); defer gpa.free(rids); for (rids) |rid| { const content = (try log.content(gpa, rid)) orelse continue; defer gpa.free(content); if (!std.mem.eql(u8, content, body)) continue; const raw = log.hashOf(rid); const hex = try gpa.alloc(u8, raw.len * 2); const lower = "0123456789abcdef"; for (raw, 0..) |b, i| { hex[i * 2] = lower[b >> 4]; hex[i * 2 + 1] = lower[b & 0x0f]; } return hex; } return error.NoArtifactHoldsThoseBytes; } /// Reads `dir/.zit` whole (caller frees) — the byte-identical oracle a refused /// destructive run must satisfy. A byte comparison, not a size comparison: a run /// that appended a tombstone and then rewrote back to the same length would pass /// the latter. fn storeBytesAlloc(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]u8 { var file = try dir.openFile(io, ".zit", .{}); defer file.close(io); const st = try file.stat(io); const buf = try gpa.alloc(u8, @intCast(st.size)); errdefer gpa.free(buf); const n = try file.readPositionalAll(io, buf, 0); try std.testing.expectEqual(buf.len, n); return buf; } /// Whether `dir/.zit` still holds an artifact named by the hex `name` — the /// on-disk oracle for "the bytes are gone", read through the library. fn storeHoldsArtifact(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8) !bool { var file = try dir.openFile(io, ".zit", .{}); 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); return (try log.resolveHexPrefix(name)) != null; } /// The number of rows in `dir/.zit`'s shun table. fn storeShunRowCount(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !usize { var file = try dir.openFile(io, ".zit", .{}); 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); return log.shunList().len; } const doomed_body = "content an operator was ordered to remove\n"; const kept_body = "content with nothing against it\n"; /// Two-file repository, committed once. Returns the root check-in's 64-hex hash /// (caller frees). Two files so a shun that removed everything cannot pass by /// accident. fn shunFixture(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![]const u8 { try initRepo(gpa, io, dir); try writeFile(io, dir, "doomed.txt", doomed_body); try writeFile(io, dir, "keep.txt", kept_body); try addManaged(gpa, io, dir, &.{ "doomed.txt", "keep.txt" }); return commitHashDup(gpa, io, dir, "root"); } test "cli shun: refuses without --force leaving the store byte-identical; --force then removes the content" { // THE DEFAULT IS REFUSAL, and a refusal must be a no-op down to the byte — // not "no content removed" but "nothing written at all". A run that recorded // the tombstone while deciding would leave a durable, irreversible mark on a // repository whose operator explicitly did not say --force. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); // The refusal names what would go, says it is irreversible, and names the // flag that proceeds. Nothing lands on stdout — a refused destructive verb // has no success output to compose with. try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 hash would be shunned") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "irreversible") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "run 'zit shun --force <HASH>...' to proceed") != null); // The type diagnostic: a file blob names what the check-ins lose. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "file content; every check-in listing it loses that file") != null); } // THE BYTE-IDENTICAL PIN. { const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } // ...and the content is still readable, so the refusal did not mask anything // either. { var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "doomed.txt", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings(doomed_body, res.stdout); } // Now with --force: the bytes go and the tombstone stays. { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", "--reason", "takedown notice 4417", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings("", res.stderr); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "shunned: 1 hash recorded, 1 artifact removed") != null); } try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); { // The blob is gone: `cat` reports it missing rather than serving bytes. var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "doomed.txt", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); } { // The neighbour survived, so the rewrite removed exactly what it named. var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "keep.txt", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings(kept_body, res.stdout); } { // A repeat is a no-op: exit 0, no --force needed to say "nothing to do". var res = try runZit(gpa, io, tmp.dir, &.{ "shun", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to do; 1 hash already shunned") != null); } } test "cli shun: the check-in this checkout is on is refused EVEN WITH --force" { // The one refusal in this verb with no override. Removing the check-in the // working tree is standing on breaks the tree from under the operator, and // `zit update` is a cheap way out — so this is the single case where zit // overrides the operator's explicit --force rather than obeying it. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "is the check-in this checkout is on; refusing") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "run 'zit update'") != null); // No flag is offered, because none exists: an operator who reads one out of // this message would try it and be refused twice. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "--force") == null); // Byte-identical, and no tombstone: the guard runs before anything durable. { const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); } test "cli shun: an invocation naming the current checkout alongside a shunnable blob applies NOTHING" { // THE PARTIAL-APPLICATION CASE, and the test that pins WHERE the // current-checkout guard sits rather than merely that it exists. An // invocation is ONE operation: a run that removed the blob and then reported // a refusal would have destroyed content irreversibly while telling the // operator nothing happened, and there is no way back from that half. Its own // test (not a tail assertion on the single-target case) so the byte-identity // claim is reached even when the simpler refusal is broken too. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed, root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "is the check-in this checkout is on") != null); const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); } test "cli shun: a hash this repository does not hold is recorded and exits 0; a prefix that matches nothing is refused" { // The pre-emptive block. sync §3.3 L527-530 filters the phantom scan on the // shun table, so a name recorded before the artifact ever arrives is never // requested — recording one is meaningful and costs no rewrite. An // ABBREVIATED prefix is the opposite case: the table is keyed by complete // name (§3.3 L518-519), a prefix cannot be completed, and a row keyed on one // would read as a removal while naming nothing. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const absent = "dead" ++ "beef" ** 15; // 64 hex, held by nothing here { // Nothing is held, so NO REWRITE RUNS: the report says a row was recorded // and refuses to claim a removal. A whole-store rewrite here would cost // O(store) plus transient room for both generations to reclaim nothing, and // would end in a "shun" whose byte count had grown. var res = try runCommitEnv(gpa, io, tmp.dir, .{}, &.{ "shun", "--force", "--reason", "pre-emptive block", absent }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings( "shunned: 1 hash recorded; none is held here, so nothing was removed\n", res.stdout, ); } try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); { // `--list` shows it, with the recorded date and the reason. The date comes // from the pinned SOURCE_DATE_EPOCH (1234567890 = 2009-02-13). var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--list" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings( absent ++ " 2009-02-13 pre-emptive block\n", res.stdout, ); } { // A prefix matching nothing: refused, and it teaches the way through. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", "dead" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "no artifact matches 'dead'") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "give the complete name") != null); } // Still exactly one row: the refused prefix recorded nothing. try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); { // A token that is not a hash at all is refused the same way. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", "not-a-hash" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "'not-a-hash' is not an artifact name") != null); } { // An abbreviated prefix that DOES resolve is accepted — resolution to // exactly one complete name happens before any row is written. const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed[0..8] }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 hash recorded, 1 artifact removed") != null); try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); } } test "cli shun --defer: the content stays on disk, the mask is immediate, and a later zit pack reclaims" { // `--defer` is a name-level mask, not a byte-level one. The bytes remain and // stay reconstructible through any delta stored against them until a rewrite // runs — which is exactly why `shun_help` refuses to describe --defer as // having removed anything. // // NOTE on the pinned property: a tombstone is an APPENDED frame, so `.zit` // cannot come out the same SIZE — it necessarily grows by one frame. What // --defer promises is that the CONTENT was not reclaimed, and that is what is // asserted here. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); { // --defer still needs --force: the row it appends is just as irreversible // as the rewrite's, and the mask takes effect immediately. // // THE REFUSAL MUST DESCRIBE THE RUN THAT WAS ASKED FOR. Against the real // binary: a --defer run removes nothing now, so it must not claim a permanent // removal, and the command it suggests must carry --defer. An operator who // pastes a bare `zit shun --force` out of this block escalates a deferred // tombstone into a whole-store reclaim of every deferred row, named or not. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--defer", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 hash would be shunned") != null); try std.testing.expect(std.mem.indexOf( u8, res.stderr, "the bytes of 1 artifact stay on disk and stay reconstructible until 'zit pack'", ) != null); try std.testing.expect(std.mem.indexOf( u8, res.stderr, "run 'zit shun --defer --force <HASH>...' to proceed\n", ) != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "permanently removed") == null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "run 'zit shun --force") == null); } try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--defer", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings("", res.stderr); try std.testing.expectEqualStrings( "shunned: 1 hash recorded; run 'zit pack' to reclaim the space\n", res.stdout, ); } // The row is durable and the CONTENT IS STILL THERE — the whole point of the // deferred path, and the reason the help text says the bytes stay // reconstructible. try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); { // The mask, however, is immediate: reads answer identically either way. var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "doomed.txt", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); } { // ...and `zit pack` is what actually removes them. var res = try runZit(gpa, io, tmp.dir, &.{"pack"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 shunned reclaimed") != null); } try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); // The tombstone outlived the content, so a later sync will not re-fetch it. try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); } test "cli shun: a 40-hex ABBREVIATION of a 64-hex name resolves and removes, rather than tombstoning nothing" { // THE WIDTH TRAP. 40 hex is a COMPLETE sha1 name and also a legal prefix of a // 64-hex sha3 one. Deciding by width alone — "40 or 64 hex means complete name, // skip resolution" — makes this invocation record a row naming nothing, report // success, rewrite the store for no reason, and leave the content the operator // was ordered to remove still readable. The `unresolved` refusal exists to stop // exactly "a row that names nothing"; only asking the store FIRST keeps it in // the path. 39 and 41 hex always worked; this pins the two widths that did not. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); // The fixture is a default (sha3-256) repository, so the name is 64 hex and a // 40-hex slice of it is an abbreviation, not a name. try std.testing.expectEqual(@as(usize, 64), doomed.len); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed[0..40] }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 hash recorded, 1 artifact removed") != null); } try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); { // The row names the COMPLETE artifact, not the 40-hex token. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--list" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, doomed) != null); } { // ...and the content is actually gone, which is the claim that used to be // false while the exit code said 0. var res = try runZit(gpa, io, tmp.dir, &.{ "cat", "doomed.txt", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); } } test "cli shun: the current-checkout stop fires on a 40-hex abbreviation of the check-in too" { // The same width trap, aimed at the one refusal with no override: a token that // never resolves cannot be compared against the checkout's name, so a // width-first rule let `zit shun --force <40 hex of the current check-in>` // through with "0 artifacts removed" instead of refusing. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", root[0..40] }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "is the check-in this checkout is on; refusing") != null); const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); } test "cli shun: one artifact named twice in one invocation is ONE row, not a corruption claim" { // `packLog`'s row-count gate requires `extra_shuns` rows to be DISTINCT, so an // undeduplicated repeat used to abort with `zit shun: repository is corrupt` — // a false and alarming diagnostic on the one verb where an operator can least // afford to doubt their store. The prefix form is the dangerous one: an operator // pasting a takedown list beside an abbreviation from `zit log` cannot see that // the two name the same artifact. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); { // The refusal collapses the duplicate too: listing one artifact twice would // overstate what the operator is consenting to. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", doomed, doomed[0..12] }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 hash would be shunned") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "1 artifact would be permanently removed") != null); } { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed, doomed[0..12] }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "corrupt") == null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 hash recorded, 1 artifact removed") != null); } // ONE row, so `recorded` is the count of rows actually written. try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); } test "cli shun --force after --defer RECLAIMS rather than reporting nothing to do" { // A hash tombstoned by an earlier `--defer` still has its bytes on disk. Folding // that into "already shunned" made `zit shun --force H` print "nothing to do" // and exit 0 over content the operator had just been ordered to remove — with // the reclaim unreachable through this verb at all. So it is its own // disposition: no new row, and still a rewrite. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--defer", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); } try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); { // Still refused without --force: this run destroys content, so it is not the // no-op path even though it records nothing. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "no new hash would be recorded") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "already shunned; content still here and would be removed") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "nothing to do") == null); } try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "0 hashes recorded, 1 artifact removed") != null); } try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); // Exactly one row throughout: the reclaim added none. try std.testing.expectEqual(@as(usize, 1), try storeShunRowCount(gpa, io, tmp.dir)); { // NOW it is a no-op, and only now may it say so. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "nothing to do; 1 hash already shunned") != null); } } test "cli shun: a --force run turned away by the temp lock records NO tombstone" { // TWO PROPERTIES IN ONE, both about the fixed-name temp `pack` and `shun` share. // // (1) An overlapping rewrite is turned away with a diagnostic that names what is // happening. Unguarded, the two runs truncate each other's in-flight new // generation and both fail the §7.7 reconstruct-and-rehash pass, reported as // `error.Corrupt` — two healthy processes telling the operator their // repository is corrupt, while the shun silently does not happen. // // (2) THE ONE-DURABLE-TRANSITION RULE, pinned at the RUNNER rather than at // `rewriteAndSwap`. `runShun` must reach the store only through // `extra_shuns`; append the rows to `.zit` first and then rewrite — two // durable transitions — and this aborted run leaves a tombstone behind with // the content it names still present. Holding the lock from the test is a // deterministic abort seam inside the rewrite, no race required. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); // Stand in for the concurrent rewrite: hold the exclusive lock on the temp for // the whole of the child's run. var held = try tmp.dir.createFile(io, ".zit-pack-tmp", .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = true, }); defer held.close(io); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "another repository rewrite is in progress") != null); // Never the word that used to come out of this collision. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "corrupt") == null); } // NO TOMBSTONE, and `.zit` byte-identical: the rows never touched the live log. try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); } test "cli shun: the APPEND paths are turned away by the temp lock too, recording nothing" { // The append paths — `--defer`, and a `--force` run with nothing to reclaim — // reopen `.zit` and commit on the strength of a snapshot taken earlier. Without // the same exclusive lock the rewriting verbs take, a `zit pack` (or a // `zit shun --force`) that renames its new generation over `.zit` after that // reopen leaves the row landing on an ORPHANED INODE: it is lost with the old // generation while this run reports success. On the `--defer` path that means the // deferred shun silently does not exist, having just told the operator to run // `zit pack` to finish it — the one outcome this verb must never produce. // // Holding the lock from the test stands in for the concurrent rewrite, which // makes this deterministic rather than a race. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const absent = "dead" ++ "beef" ** 15; // 64 hex, held by nothing here const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var held = try tmp.dir.createFile(io, ".zit-pack-tmp", .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = true, }); defer held.close(io); { // `--defer`: content to leave behind, one row to append. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--defer", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "another repository rewrite is in progress") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "corrupt") == null); } { // The purely pre-emptive path: nothing to reclaim, so it appends too. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", absent }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "another repository rewrite is in progress") != null); } // Nothing recorded and `.zit` byte-identical on both paths. try std.testing.expectEqual(@as(usize, 0), try storeShunRowCount(gpa, io, tmp.dir)); const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp.dir, doomed)); } test "cli shun: a --reason carrying a newline is refused rather than forging a --list row" { // `--list` is the only view of a durable, irreversible, un-removable record and // it prints one row per line, so a newline inside a reason manufactures a row // there indistinguishable from a real one. Reasons arrive from scripts and pasted // takedown text, not only from typing. NO DRAFT SPEAKS TO THIS: refusing at input // is a local decision, taken the same way the length gate was — refuse rather // than mangle a note that may be a legal reference. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--defer", "--force", "--reason", "legit\ndeadbeefdeadbeef 2020-01-01 forged row", doomed, }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "--reason contains a control byte") != null); const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } test "cli shun: the usage surface — no hash, --list with a positional, an unknown option, and --help" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); const Case = struct { argv: []const []const u8, needle: []const u8 }; for ([_]Case{ .{ .argv = &.{"shun"}, .needle = "no hash given" }, .{ .argv = &.{ "shun", "--list", "aabb" }, .needle = "unexpected argument 'aabb'" }, .{ .argv = &.{ "shun", "--force", "-f", "aabb" }, .needle = "unknown option '-f'" }, }) |c| { var res = try runZit(gpa, io, tmp.dir, c.argv); defer freeResult(gpa, &res); try expectExitDiag(res.term, 2, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, c.needle) != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "try 'zit shun --help'") != null); } { // `-h` wins over `--force` and over a real hash: help never destroys. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", root, "-h" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "usage: zit shun") != null); // The two disclosures the help text is REQUIRED to carry: --defer leaves // the bytes reconstructible, and a running `zit serve` keeps serving them. try std.testing.expect(std.mem.indexOf(u8, res.stdout, "reconstructible") != null); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "zit serve") != null); } { // `-R` is refused by name: a whole-store rewrite operates on the // discovered checkout, never on a foreign repository. var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "-R", ".", "--force", "aabbccdd" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 2, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "is not supported here") != null); } // Not one of those runs touched the store. const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } test "cli shun: a check-in that is not the current checkout's names its own consequence and can be forced" { // The manifest arm of the per-type diagnostic, through the real binary: the // report says a check-in's descendants become unresolvable, and — unlike the // current checkout — the operator's --force is obeyed. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); try writeFile(io, tmp.dir, "keep.txt", "a second revision so the root is not the checkout\n"); const second = try commitHashDup(gpa, io, tmp.dir, "second"); defer gpa.free(second); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "checkin a check-in; its descendants become unresolvable") != null); } { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", root }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "1 hash recorded, 1 artifact removed") != null); } try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp.dir, root)); } // --- pull with a repository shun table (sync §3.3 L527-530) ---------------- // // The durable end of the phantom-scan shun filter, through the REAL binary: a // repository that has shunned a hash the peer holds must not fetch it back, and // must not fail for declining to. §3.3 filters the phantom SCAN, not phantom // creation, so B still mints the phantom from A's `igot` — it simply never asks, // on this pull or any later one. // // The shun rows are written straight into B's log through the library (the `zit // shun` verb is a separate slice); everything else is the real CLI over loopback // HTTP, so this is the guard that the built shun set actually reaches the pull // client. Replace `shun.shunSet()` with `.none` at `pullRun`'s call site and this // test fails. /// Add one shun row naming `digest` to `dir/.zit` (sync §3.3 L518-519 — keyed by /// the complete artifact name, so the whole digest, never a prefix). fn shunDigestInRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, digest: []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); // `open` re-reads the stored policy from the header, so the kind passed here // is only the empty-file seed value and never overrides B's own. var log = try logstore.LogStore.open(gpa, fs.storageFile(), @intFromEnum(artifact.defaultPolicy(false, false))); defer log.deinit(gpa); try log.putShun(digest, "e2e operator request", 1); try log.commit(); } fn digestSetContains(set: [][]u8, needle: []const u8) bool { for (set) |d| { if (std.mem.eql(u8, d, needle)) return true; } return false; } test "cli pull: an artifact in the repository shun table is not fetched from a peer that holds it (sync §3.3 L527-530)" { const gpa = std.testing.allocator; const io = std.testing.io; // Repo A: init + a first check-in. 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\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); // Clone A's seed state into B, then let A grow past it. const clone_port = try freshLoopbackPort(io); 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 expectExitDiag(clone.term, 0, clone); clone_server.kill(io); try writeFile(io, tmp_a.dir, "two.txt", "an artifact B will shun\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); var b_repo = try tmp_b.dir.openDir(io, "repo", .{}); defer b_repo.close(io); // Everything A gained past the clone — exactly what an unshunned pull would // fetch — is shunned in B. const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const b_before = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_before); var shunned: usize = 0; for (a_digests) |d| { if (digestSetContains(b_before, d)) continue; try shunDigestInRepo(gpa, io, b_repo, d); shunned += 1; } try std.testing.expect(shunned > 0); // the pull has something to decline // Pull from A. A announces every one of those hashes, B mints their phantoms // and asks for none of them. const pull_port = try freshLoopbackPort(io); 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 pull = try runZit(gpa, io, b_repo, &.{ "pull", pull_url }); defer freeResult(gpa, &pull); // Exit 0 and a silent stderr: a deliberately-suppressed phantom is not an // unmet expectation, so the §3.7 clean terminal is a SUCCESS. Were it counted, // `zit pull` would report "the peer holds N artifacts this pull did not // obtain" here and on every later pull, forever, for obeying the shun rows. try expectExitDiag(pull.term, 0, pull); try std.testing.expectEqualStrings("", pull.stderr); // THE PROOF: B's on-disk artifact set is unchanged — not one shunned artifact // came over the wire. const b_after = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_after); try expectSameDigestSet(b_before, b_after); // The same guard for `zit sync`, whose pull half runs the very same §3.3 scan // — the filter is a property of the operation, not of one verb's plumbing. var sync_run = try runZit(gpa, io, b_repo, &.{ "sync", pull_url }); defer freeResult(gpa, &sync_run); try expectExitDiag(sync_run.term, 0, sync_run); const b_after_sync = try collectArtifactDigests(gpa, io, b_repo); defer freeDigests(gpa, b_after_sync); try expectSameDigestSet(b_before, b_after_sync); } // --------------------------------------------------------------------------- // sync §9.5 L1988-1992 — the SEND side of the shun filter, through the REAL // binary over real loopback HTTP. // // "A subsequent read-authorized transfer path requests igot for every // eligible holding after the Section 3.3 shun, phantom, private, and // peer-has filters" // // `zit shun --force --defer` records the row WITHOUT reclaiming, so the bytes are // still in the served store: only the send-side filter can keep them off the wire. // That makes `--defer` the sharper fixture than `--force` alone — a reclaimed // artifact would be absent for a reason that has nothing to do with §9.5. // // THE CLONE TEST BELOW IS NOT §9.5, and says so in one place rather than in its // title: §9.5's sentence is scoped to the `send-catalog` pragma's igot inventory, // §3.4 L545-547 hands clone to §8.1-§8.4, and §8.2 L1504-1506 licenses only the // gap MECHANISM. Withholding a shunned holding from a clone batch is this // implementation's extension — see the erratum candidate on `LogIndexStore.masked` // — and the test pins the extension, not a clause. // --------------------------------------------------------------------------- /// Every digest in `all` except the one whose lowercase-hex spelling is `drop_hex` /// — `collectArtifactDigests` yields RAW binary digests while a shun names an /// artifact by its complete hex name, so the comparison happens in hex. Caller /// frees the outer slice only; the element slices are borrowed from `all`. fn digestsExcept(gpa: std.mem.Allocator, all: []const []const u8, drop_hex: []const u8) ![][]const u8 { var out: std.ArrayListUnmanaged([]const u8) = .empty; errdefer out.deinit(gpa); for (all) |d| { var hex_buf: [2 * 64]u8 = undefined; if (d.len * 2 <= hex_buf.len) { const hex = std.fmt.bufPrint(&hex_buf, "{x}", .{d}) catch unreachable; if (std.mem.eql(u8, hex, drop_hex)) continue; } try out.append(gpa, d); } return out.toOwnedSlice(gpa); } test "cli clone: a deferred-shun artifact is served to nobody — the clone converges without it and without error (extension; erratum candidate vs sync §8.1/§8.2)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const root = try shunFixture(gpa, io, tmp_a.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp_a.dir, doomed_body); defer gpa.free(doomed); // Record the shun WITHOUT reclaiming: the bytes stay in A's store. { var res = try runZit(gpa, io, tmp_a.dir, &.{ "shun", "--force", "--defer", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); } // The fixture's premise: masked but STILL STORED, so nothing but the send-side // filter can keep it off the wire. try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp_a.dir, doomed)); const a_digests = try collectArtifactDigests(gpa, io, tmp_a.dir); defer freeDigests(gpa, a_digests); const want = try digestsExcept(gpa, a_digests, doomed); defer gpa.free(want); // The shunned artifact really is one of A's holdings, and A holds more. try std.testing.expectEqual(a_digests.len - 1, want.len); try std.testing.expect(want.len >= 1); const port = try freshLoopbackPort(io); 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}/", .{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); // ABSENT, not an error: the clone completes clean. A protocol error or a // dangling delta would be the CORRUPT outcome this filter exists to avoid. try expectExitDiag(clone.term, 0, clone); try std.testing.expectEqualStrings("", clone.stderr); 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(want, b_digests); // …and stated the other way round, so a set comparison that somehow admitted // it could not pass: B does not hold the shunned artifact at all. try std.testing.expect(!try storeHoldsArtifact(gpa, io, b_dir, doomed)); } /// POST `plain` to the served repository as an `application/x-fossil` sync /// request and return the DECODED reply body (caller frees). Raw HTTP rather than /// a `zit` subcommand because no verb emits a bare `gimme` for an arbitrary hash — /// that card is what §3.3 L532-535's "If absent, silently skip" is about. fn postXferPlain(gpa: std.mem.Allocator, io: std.Io, port: u16, plain: []const u8) ![]u8 { const body = try container.encode(gpa, plain, .default); defer gpa.free(body); const stream = try dialServe(io, port); defer stream.close(io); { var wbuf: [512]u8 = undefined; var w = stream.writer(io, &wbuf); const head = try std.fmt.allocPrint( gpa, "POST /xfer HTTP/1.1\r\nHost: z\r\nContent-Type: application/x-fossil\r\n" ++ "Content-Length: {d}\r\nConnection: close\r\n\r\n", .{body.len}, ); defer gpa.free(head); try w.interface.writeAll(head); try w.interface.writeAll(body); try w.interface.flush(); } // Read to EOF (the server closed the connection), then split off the head. A // READ FAULT is not end-of-stream: both callers assert on a name being ABSENT // from the reply, which a truncated body satisfies for free, so a socket error // must fail the test rather than shorten the body. var rbuf: [4096]u8 = undefined; var r = stream.reader(io, &rbuf); const raw = try r.interface.allocRemaining(gpa, .limited(1 << 20)); defer gpa.free(raw); const sep = std.mem.indexOf(u8, raw, "\r\n\r\n") orelse return error.NoHttpHeadEnd; if (std.mem.indexOf(u8, raw[0..sep], " 200 ") == null) return error.NotOk; return container.decode(gpa, raw[sep + 4 ..], 1 << 20); } test "cli serve: a shunned holding is never igot-announced, and an inbound gimme for it is silently skipped (sync §9.5 L1988-1992, §3.3 L532-535)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const root = try shunFixture(gpa, io, tmp_a.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp_a.dir, doomed_body); defer gpa.free(doomed); const kept = try artifactNameByContent(gpa, io, tmp_a.dir, kept_body); defer gpa.free(kept); { var res = try runZit(gpa, io, tmp_a.dir, &.{ "shun", "--force", "--defer", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); } // Premise again: the server still HOLDS the bytes it must refuse to send. try std.testing.expect(try storeHoldsArtifact(gpa, io, tmp_a.dir, doomed)); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // ROUND 1 — the §3.2/§9.5 INVENTORY WALK. The client announces a hash this // repo does not hold, so the server answers with igot for "every eligible // holding after the Section 3.3 shun … filters" (§9.5 L1989-1991). The // unshunned neighbour proves the walk ran at all, so the shunned name's // absence is the filter and not an empty reply. { const absent = try gpa.alloc(u8, doomed.len); defer gpa.free(absent); @memset(absent, '0'); const plain = try std.fmt.allocPrint(gpa, "pragma client-version 20000\nigot {s}\n", .{absent}); defer gpa.free(plain); const reply = try postXferPlain(gpa, io, port, plain); defer gpa.free(reply); const kept_igot = try std.fmt.allocPrint(gpa, "igot {s}\n", .{kept}); defer gpa.free(kept_igot); try std.testing.expect(std.mem.indexOf(u8, reply, kept_igot) != null); // The shunned name appears on NO card — not igot (it is not an eligible // holding), not gimme (§3.3 L527-530 excludes it from the phantom scan), // and above all not `error`. try std.testing.expect(std.mem.indexOf(u8, reply, doomed) == null); try std.testing.expect(std.mem.indexOf(u8, reply, "error") == null); } // ROUND 2 — an inbound `gimme` for the shunned hash. §3.3 L532-535: "If // absent, silently skip." The mask is what makes it absent to the // enumeration, so this needs no code of its own on the gimme path. { const plain = try std.fmt.allocPrint( gpa, "pragma client-version 20000\ngimme {s}\ngimme {s}\n", .{ doomed, kept }, ); defer gpa.free(plain); const reply = try postXferPlain(gpa, io, port, plain); defer gpa.free(reply); // The neighbour comes back as a `file` card… const kept_card = try std.fmt.allocPrint(gpa, "file {s} ", .{kept}); defer gpa.free(kept_card); try std.testing.expect(std.mem.indexOf(u8, reply, kept_card) != null); // …and the shunned hash produces nothing at all, error card included. try std.testing.expect(std.mem.indexOf(u8, reply, doomed) == null); try std.testing.expect(std.mem.indexOf(u8, reply, "error") == null); } } // --- RECEIVE-side admission through the real binary (sync §7.7 L1434-1463) -- // // The send-side filter above keeps a shunned artifact off the wire when WE are // the sender. This is the other direction, and the one a shun is worthless // without: a PEER pushes the shunned artifact AT us. §7.7 L1437-1440 — "A // non-clone file whose target is shunned is consumed and discarded after its // payload and pending private marker; it emits no error, does not set // content-received, and makes no repository mutation." A server under // push-receive is always non-clone, so this is the discard arm, unconditionally. // // WHY A RAW POST AND NOT `zit push`. A zit-to-zit push cannot reach this code at // all: the receiving server's §3.3 L527-530 phantom scan already declines to // `gimme` a shunned name, and a zit push client only uploads what the peer asked // for — so the send-side filter masks the receive-side gap end to end, and a // `zit push` e2e passes with the whole admission check deleted (checked, not // assumed). A real Fossil push is UNSOLICITED: it sends the content its own // inventory says the peer lacks. `postXferPlain` is that shape, and it is the // only shape that puts the receive path under test. // // The shun is `--force`, so A does not hold the bytes: the ONLY thing that can // keep them out is admission at ingest. test "cli serve: an unsolicited push of a shunned artifact is discarded — nothing lands and no error card comes back (sync §7.7 L1437-1440)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); const root = try shunFixture(gpa, io, tmp_a.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp_a.dir, doomed_body); defer gpa.free(doomed); { var res = try runZit(gpa, io, tmp_a.dir, &.{ "shun", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); } // The premise: the operator's removal actually happened. try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp_a.dir, doomed)); const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, tmp_a.dir, port); defer server.kill(io); try waitForListen(io, port); // §9.1 — a `push SERVERCODE PROJECTCODE` card, then the artifact itself, sent // without ever having been asked for. const plain = try std.fmt.allocPrint( gpa, "pragma client-version 20000\npush {s} {s}\nfile {s} {d}\n{s}", .{ "1" ** 40, "2" ** 40, doomed, doomed_body.len, doomed_body }, ); defer gpa.free(plain); const reply = try postXferPlain(gpa, io, port, plain); defer gpa.free(reply); // "it emits no error" — no error card of any kind, and the shunned name is // not echoed back on one either. try std.testing.expect(std.mem.indexOf(u8, reply, "error") == null); try std.testing.expect(std.mem.indexOf(u8, reply, doomed) == null); // The SAME card in DELTA form (§7.1 `file HASH DELTASRC SIZE`), against a // basis this repository does not hold. That is the COMMON shape for a // shunned target — its neighbours are unlikely to still be here — and it is // the one that reaches the server's delta reconstruction. L1437-1440 draws no // distinction between the two forms: still no error card, still nothing // lands. The delta payload is deliberately junk; the basis lookup fails // before it is parsed, so the path is deterministic either way. const delta_plain = try std.fmt.allocPrint( gpa, "pragma client-version 20000\npush {s} {s}\nfile {s} {s} 4\njunk", .{ "1" ** 40, "2" ** 40, doomed, "3" ** 64 }, ); defer gpa.free(delta_plain); const delta_reply = try postXferPlain(gpa, io, port, delta_plain); defer gpa.free(delta_reply); try std.testing.expect(std.mem.indexOf(u8, delta_reply, "error") == null); try std.testing.expect(std.mem.indexOf(u8, delta_reply, doomed) == null); // "makes no repository mutation" — not even the DELTASRC phantom, because // admission is decided before DELTASRC is looked at (L1434-1435). try std.testing.expect(std.mem.indexOf(u8, delta_reply, "3" ** 64) == null); // THE PROOF: release the server's handles, then read A back off disk. The // repository the operator emptied is still empty of it. server.kill(io); try std.testing.expect(!try storeHoldsArtifact(gpa, io, tmp_a.dir, doomed)); } // --------------------------------------------------------------------------- // `zit verify` — zit's own fsck. No contract doc and no RFC section stand // behind this verb (it is a local design decision; repo-state §7.3 blesses a // full rebuild as a fallback but says nothing about a verb's shape), so these // titles cite neither a `§` nor a `B-` id. // --------------------------------------------------------------------------- /// The byte offset of the first PAYLOAD byte of the frame holding `body`. /// /// THE PITFALL THIS EXISTS TO CLOSE: the two older corruption e2e tests flip at /// `frame_offset + 10`, which lands inside the HEADER window (the hash), not the /// payload. A payload-fault fixture that used that offset would pass for the /// wrong reason — it would be exercising header-checksum detection. The payload /// begins after `frameHeaderSpan` = 22 + hash_len bytes. fn artifactPayloadOffset(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, body: []const u8) !u64 { var file = try dir.openFile(io, ".zit", .{}); 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); defer gpa.free(rids); for (rids) |rid| { const content = (try log.content(gpa, rid)) orelse continue; defer gpa.free(content); if (!std.mem.eql(u8, content, body)) continue; return log.offsetOf(rid) + 22 + @as(u64, log.hashOf(rid).len); } return error.NoArtifactHoldsThoseBytes; } /// Flips every bit of the byte at `at` in `dir/.zit`. fn flipStoreByte(io: std.Io, dir: std.Io.Dir, at: u64) !void { var file = try dir.openFile(io, ".zit", .{ .mode = .read_write }); defer file.close(io); var byte: [1]u8 = undefined; try std.testing.expectEqual(@as(usize, 1), try file.readPositionalAll(io, &byte, at)); byte[0] ^= 0xff; try file.writePositionalAll(io, &byte, at); } test "cli verify: a clean repository verifies and exits 0" { // Two files, so a verb that reported damage on everything could not pass // this by accident. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expectEqualStrings("", res.stderr); try std.testing.expect(std.mem.startsWith(u8, res.stdout, "verified: ")); try std.testing.expect(std.mem.indexOf(u8, res.stdout, "0 faults") != null); } test "cli verify: a bitflipped artifact payload is reported by name and exits 1" { // ACCEPTANCE 1. The fault line's job is to be pasted into `zit artifact` or // `zit shun`, so the FULL name has to appear — and the reason has to say // which check failed, or the assertion cannot tell the two verification // layers apart (see the mutation proof). const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); const at = try artifactPayloadOffset(gpa, io, tmp.dir, doomed_body); try flipStoreByte(io, tmp.dir, at); const before = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(before); var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); // Nothing on stdout: a run that found damage has no clean summary to // compose with. try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, doomed) != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "payload checksum mismatch") != null); // AN FSCK THAT MUTATES IS A BUG. Byte-identical, not same-length. const after = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } test "cli verify: a bitflipped config-record payload is reported and exits 1" { // THE SEMANTIC MUTATION TARGET. A config frame is never interned by `scan`, // so `ridsInPersistedOrder` never yields it and the per-artifact pass // structurally cannot see it. Only the whole-log frame pass catches this. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initRepo(gpa, io, tmp.dir); // Fixture premise, pinned rather than assumed: a fresh `zit init` store is // the file header followed by exactly one config frame, whose one-byte // sentinel hash puts its payload at header_len + 22 + 1. { const bytes = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(bytes); try std.testing.expectEqual(@intFromEnum(logstore.FrameKind.config), bytes[logstore.header_len + 4]); try std.testing.expectEqual(@as(u8, 1), bytes[logstore.header_len + 5]); } try flipStoreByte(io, tmp.dir, logstore.header_len + 23); var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "config record") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "payload checksum mismatch") != null); } /// The start offset of the first frame of `kind` in a raw store image, found by /// walking the frame geometry: payload_len at +0, flags at +4, hash_len at +5, /// and an 8-aligned span of 26 + hash_len + payload_len. fn frameStartOfKind(bytes: []const u8, kind: logstore.FrameKind) !usize { var off: usize = logstore.header_len; while (off + 22 <= bytes.len) { const pl: usize = std.mem.readInt(u32, bytes[off..][0..4], .little); const flags = bytes[off + 4]; const hl: usize = bytes[off + 5]; if (flags == @intFromEnum(kind)) return off; off += ((26 + hl + pl) + 7) / 8 * 8; } return error.NoFrameOfThatKind; } test "cli verify: a store the frame pass finds clean but no open will accept is a fault, not a clean run" { // THE ANSWER THIS VERB MUST NEVER GIVE. The frame pass walks raw bytes, so // it comes up clean on a shun row whose checksums are all valid and whose // payload `scan` still rejects — and the per-artifact pass then cannot open // the store at all. Reporting "0 faults" and exiting 0 on a repository // every other verb refuses to open would send an operator away satisfied // from a broken store, and contradicts verify's own exit-status table. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const doomed = try artifactNameByContent(gpa, io, tmp.dir, doomed_body); defer gpa.free(doomed); { var res = try runZit(gpa, io, tmp.dir, &.{ "shun", "--force", doomed }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); } { var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write }); defer file.close(io); const st = try file.stat(io); const bytes = try gpa.alloc(u8, @intCast(st.size)); defer gpa.free(bytes); try std.testing.expectEqual(bytes.len, try file.readPositionalAll(io, bytes, 0)); const start = try frameStartOfKind(bytes, .shun); const pl: usize = std.mem.readInt(u32, bytes[start..][0..4], .little); const hl: usize = bytes[start + 5]; // A shun payload is epoch(8) | reason_len(2) | reason. Declaring a // reason longer than the payload can hold makes `parseShun` reject it, // while leaving every length the framing itself depends on untouched. std.mem.writeInt(u16, bytes[start + 22 + hl + 8 ..][0..2], 0xffff, .little); // Recompute the whole-frame checksum so the frame pass has no complaint: // the header window is untouched, so only this one has moved. var fcrc = std.hash.Crc32.init(); fcrc.update(bytes[start .. start + 22 + hl + pl]); std.mem.writeInt(u32, bytes[start + 22 + hl + pl ..][0..4], fcrc.final(), .little); try file.writePositionalAll(io, bytes[start .. start + 22 + hl + pl + 4], @intCast(start)); } var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); // The frame pass genuinely had nothing to say — that is the premise, not an // accident — so this can only be the second pass refusing the store. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "payload checksum mismatch") == null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "could not be opened for per-artifact verification") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "bytes checked") != null); } test "cli verify: an incomplete final frame is reported but is not a fault (exit 0)" { // Guards against sending an operator to re-clone over an ordinary crash. A // torn tail is an interrupted write; the next write truncates it away. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); { var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write }); defer file.close(io); const st = try file.stat(io); try file.setLength(io, st.size - 4); } var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "incomplete final frame") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "this is not corruption") != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "fault") == null); // `openReadOnly` refuses a torn tail, so the per-artifact pass could not // run — and the summary has to say so rather than report "0 artifacts", // which would read as "this store holds none". try std.testing.expect(std.mem.indexOf(u8, res.stdout, "per-artifact verification did not run") != null); } test "cli verify: a corrupt frame header stops the walk and says so" { // A SHORT WALK MUST NOT READ AS A COMPLETE ONE. Past a damaged header // window there is no trustworthy frame boundary, so the byte accounting in // the verdict has to come up short of the file size. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const at = try artifactPayloadOffset(gpa, io, tmp.dir, doomed_body); const total = blk: { const bytes = try storeBytesAlloc(gpa, io, tmp.dir); defer gpa.free(bytes); break :blk bytes.len; }; // `at - 10` is past the 10-byte prefix window (so the prefix crc still // passes) and inside the header crc's coverage — with a 32-byte hash it // lands in `delta_of`. Which header field it hits does not matter; that it // is a header-window byte and not a payload byte is the whole point. try flipStoreByte(io, tmp.dir, at - 10); var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "cannot verify past this point") != null); var total_buf: [32]u8 = undefined; const total_str = try std.fmt.bufPrint(&total_buf, " of {d} bytes checked", .{total}); try std.testing.expect(std.mem.indexOf(u8, res.stderr, total_str) != null); // The bytes-checked figure is short of the total, stated as its own fact. var full_buf: [64]u8 = undefined; const full_str = try std.fmt.bufPrint(&full_buf, "{d} of {d} bytes checked", .{ total, total }); try std.testing.expect(std.mem.indexOf(u8, res.stderr, full_str) == null); } test "cli verify: --help prints its own help, and an unknown option is a usage error" { 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, &.{ "verify", "--help" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 0, res); try std.testing.expect(std.mem.startsWith(u8, res.stdout, "usage: zit verify")); try std.testing.expectEqualStrings("", res.stderr); } { var res = try runZit(gpa, io, tmp.dir, &.{ "verify", "--deep" }); defer freeResult(gpa, &res); try expectExitDiag(res.term, 2, res); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "--deep") != null); } } test "cli verify: a checksum-valid frame whose content does not match its name is caught by the per-artifact pass" { // THE SECOND LAYER'S OWN COVERAGE. Rewriting the frame's hash field AND // recomputing both checksums that cover it produces a frame the whole-log // pass has no complaint about — every checksum is valid — carrying content // that does not hash to the name the frame gives it. Only reconstructing the // artifact and re-hashing it finds this, which is why the two passes are not // redundant with each other. const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try shunFixture(gpa, io, tmp.dir); defer gpa.free(root); const payload_at = try artifactPayloadOffset(gpa, io, tmp.dir, doomed_body); var renamed_buf: [2 * logstore.max_hash_len]u8 = undefined; var renamed_len: usize = 0; { var file = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write }); defer file.close(io); const st = try file.stat(io); const bytes = try gpa.alloc(u8, @intCast(st.size)); defer gpa.free(bytes); try std.testing.expectEqual(bytes.len, try file.readPositionalAll(io, bytes, 0)); // Recover the frame start from the payload offset: the payload begins // 22 + hash_len bytes into the frame, and hash_len is the sixth byte of // the frame's prefix window — so the candidate start that agrees with // its own declared width is the right one. const start: usize = blk: { var hl: usize = logstore.min_hash_len; while (hl <= logstore.max_hash_len) : (hl += 1) { const cand: usize = @intCast(payload_at - 22 - hl); if (bytes[cand + 5] == hl) break :blk cand; } return error.FrameStartNotRecoverable; }; const hl: usize = bytes[start + 5]; const pl: usize = std.mem.readInt(u32, bytes[start..][0..4], .little); try std.testing.expectEqual(@as(usize, @intCast(payload_at)), start + 22 + hl); // Rename the frame, leaving its payload alone. The prefix window is // untouched (it does not cover the hash), so only the header checksum // and the whole-frame checksum have to be recomputed. bytes[start + 10] ^= 0xff; var hcrc = std.hash.Crc32.init(); hcrc.update(bytes[start .. start + 18 + hl]); std.mem.writeInt(u32, bytes[start + 18 + hl ..][0..4], hcrc.final(), .little); var fcrc = std.hash.Crc32.init(); fcrc.update(bytes[start .. start + 22 + hl + pl]); std.mem.writeInt(u32, bytes[start + 22 + hl + pl ..][0..4], fcrc.final(), .little); try file.writePositionalAll(io, bytes[start .. start + 22 + hl + pl + 4], @intCast(start)); const lower = "0123456789abcdef"; for (bytes[start + 10 ..][0..hl], 0..) |b, i| { renamed_buf[i * 2] = lower[b >> 4]; renamed_buf[i * 2 + 1] = lower[b & 0x0f]; } renamed_len = hl * 2; } const renamed = renamed_buf[0..renamed_len]; var res = try runZit(gpa, io, tmp.dir, &.{"verify"}); defer freeResult(gpa, &res); try expectExitDiag(res.term, 1, res); try std.testing.expectEqualStrings("", res.stdout); // No frame-pass complaint — every checksum is valid — so this line can only // have come from the per-artifact pass. try std.testing.expect(std.mem.indexOf(u8, res.stderr, "payload checksum mismatch") == null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, renamed) != null); try std.testing.expect(std.mem.indexOf(u8, res.stderr, "content does not hash to its name") != null); }