//! The direct-commit core against the working-tree commit, byte for byte.
//!
//! Two copies of one seeded store evolve in lockstep. One is a checkout:
//! each step's edits are made to its files (`zit add`, `zit rm`, `zit mv`)
//! and recorded by the real `zit commit`. The other is a bare store: the same
//! edits go to `checkin.commit` in-process. Same user, comment and commit
//! time (SOURCE_DATE_EPOCH pins the checkout's), so the two check-ins must
//! hash the same: equal names are equal manifests. After the run the two
//! stores must also hold the same artifact set (every blob and sub-manifest),
//! the same `crosslink` stream and the same file-history answers.
//!
//! Imported by the e2e root, whose step builds the `zit` binary this spawns.
const std = @import("std");
const zit = @import("zit");
const build_options = @import("build_options");
const artifact = zit.artifact;
const logstore = zit.logstore;
const storage = zit.storage;
const checkin = zit.checkin;
const zit_exe: []const u8 = build_options.zit_exe;
const seed_epoch: i64 = 1_700_000_000;
/// One store shape the core must reproduce the checkout commit on.
const Shape = struct {
name: []const u8,
/// `zit init --submanifest K`; null for a flat store.
k: ?u4 = null,
/// The seed's blobs carry SHA-1 names, as Fossil's SHA-1-era history does.
sha1: bool = false,
/// The store has stored a delta manifest before: flat check-ins continue
/// the dialect.
delta: bool = false,
repo_cksum: bool = false,
/// Run a side branch, a trunk commit and a merge, then commit on the merge.
merge: bool = false,
files: usize,
steps: usize,
};
const shapes = [_]Shape{
.{ .name = "flat", .merge = true, .files = 40, .steps = 8 },
.{ .name = "delta dialect", .delta = true, .files = 40, .steps = 8 },
.{ .name = "sha1 names", .sha1 = true, .files = 40, .steps = 6 },
.{ .name = "sha1 names repo-cksum", .sha1 = true, .repo_cksum = true, .files = 40, .steps = 3 },
.{ .name = "submanifest k=1", .k = 1, .files = 40, .steps = 6 },
.{ .name = "submanifest k=3", .k = 3, .merge = true, .files = 60, .steps = 6 },
.{ .name = "submanifest k=3 repo-cksum", .k = 3, .repo_cksum = true, .files = 60, .steps = 4 },
.{ .name = "submanifest k=7", .k = 7, .files = 900, .steps = 4 },
};
test "checkin core: scripted and random edits mint the checkout commit's bytes on every store shape" {
const gpa = std.testing.allocator;
const io = std.testing.io;
for (shapes, 0..) |shape, i| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, shape);
defer fx.deinit();
try fx.runScripted();
var prng = std.Random.DefaultPrng.init(0x5eed_0000 + i);
for (0..shape.steps) |_| try fx.runRandom(prng.random());
try fx.expectSameStores();
}
}
test "checkin core: a one-line edit on a 1200-file submanifest-7 store grows it no more than the checkout commit" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, .{ .name = "growth", .k = 7, .files = 1200, .steps = 0 });
defer fx.deinit();
const path = fx.model.items[fx.model.items.len / 2].path;
const ck_before = try fx.storeSize("ck");
const core_before = try fx.storeSize("core");
const bytes = try std.fmt.allocPrint(gpa, "{s}\none more line\n", .{fx.contentOf(path)});
defer gpa.free(bytes);
try fx.step(&.{.{ .put = .{ .path = path, .bytes = bytes } }});
const ck_growth = try fx.storeSize("ck") - ck_before;
const core_growth = try fx.storeSize("core") - core_before;
std.debug.print("checkin core growth on a 1200-file submanifest-7 store: core {d} B, checkout commit {d} B\n", .{ core_growth, ck_growth });
try std.testing.expect(core_growth <= ck_growth);
try fx.expectSameStores();
}
test "commit -R --rename/--delete: the CLI mints the checkout commit's bytes, flat and k=7" {
const gpa = std.testing.allocator;
const io = std.testing.io;
const cases = [_]Shape{
.{ .name = "cli flat", .files = 40, .steps = 0 },
.{ .name = "cli k=7", .k = 7, .files = 900, .steps = 0 },
};
for (cases) |shape| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, shape);
defer fx.deinit();
fx.via_cli = true;
const a = fx.arena.allocator();
const m = fx.model.items;
const dir_of = struct {
fn f(path: []const u8) []const u8 {
return std.fs.path.dirname(path).?;
}
}.f;
// Rename only.
try fx.step(&.{.{ .rename = .{ .old = m[0].path, .new = try std.fmt.allocPrint(a, "{s}/one.txt", .{dir_of(m[0].path)}) } }});
// Delete only, two of them.
try fx.step(&.{ .{ .delete = m[2].path }, .{ .delete = m[3].path } });
// Mixed: a delete, a rename onto the deleted file, a rename that takes
// stdin as its new content, and the order the CLI applies them in.
const onto = fx.model.items[4].path;
const from = fx.model.items[5].path;
const moved = try std.fmt.allocPrint(a, "{s}/moved.txt", .{dir_of(fx.model.items[6].path)});
try fx.step(&.{
.{ .delete = onto },
.{ .rename = .{ .old = from, .new = onto } },
.{ .rename = .{ .old = fx.model.items[6].path, .new = moved, .bytes = try fx.freshContent(moved, 0) } },
});
// Mixed with a plain put beside the renames.
const put_path = fx.model.items[8].path;
try fx.step(&.{
.{ .delete = fx.model.items[7].path },
.{ .rename = .{ .old = fx.model.items[9].path, .new = try std.fmt.allocPrint(a, "{s}/two.txt", .{dir_of(fx.model.items[9].path)}) } },
.{ .put = .{ .path = put_path, .bytes = try fx.freshContent(put_path, 1) } },
});
try fx.expectSameStores();
if (shape.k != null) try fx.expectSameOutput(&.{ "log", fx.tip, try std.fmt.allocPrint(a, "{s}/one.txt", .{dir_of(m[0].path)}), "-R" });
}
}
test "commit -R --rename/--delete: refusals name the path, exit 1 and write nothing" {
const gpa = std.testing.allocator;
const io = std.testing.io;
const cases = [_]Shape{
.{ .name = "refuse flat", .files = 40, .steps = 0 },
.{ .name = "refuse k=7", .k = 7, .files = 900, .steps = 0 },
};
for (cases) |shape| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, shape);
defer fx.deinit();
const p0 = fx.model.items[0].path;
const p1 = fx.model.items[1].path;
const Case = struct { args: []const []const u8, says: []const u8 };
const refusals = [_]Case{
.{ .args = &.{ "--rename", "no/such.txt", "x.txt" }, .says = "'no/such.txt' is not in the parent" },
.{ .args = &.{ "--delete", "no/such.txt" }, .says = "'no/such.txt' is not in the parent" },
.{ .args = &.{ "--rename", p0, p1 }, .says = p1 },
.{ .args = &.{ "--delete", p0, "--delete", p0 }, .says = p0 },
.{ .args = &.{ "--rename", p0, "x.txt", "--delete", p0 }, .says = p0 },
.{ .args = &.{ "--delete", "d3" }, .says = "'d3' is a directory in the parent" },
.{ .args = &.{ "--rename", "d3", "e3" }, .says = "'d3' is a directory in the parent" },
.{ .args = &.{ "--rename", p0, p0 }, .says = p0 },
};
const before = try fx.storeSize("core");
for (refusals) |c| {
const res = try fx.cli(c.args, null, &.{});
defer freeResult(gpa, res);
try expectExit(res, 1);
try std.testing.expectEqualStrings("", res.stdout);
if (std.mem.indexOf(u8, res.stderr, c.says) == null) {
std.debug.print("expected '{s}' in: {s}\n", .{ c.says, res.stderr });
return error.TestUnexpectedResult;
}
try std.testing.expectEqual(before, try fx.storeSize("core"));
}
// Without -R: the same usage refusal --stdin gets.
{
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "commit", "-m", "x", "--user", "u", "--rename", p0, "y.txt" });
defer freeResult(gpa, res);
try expectExit(res, 2);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "--rename needs -R STORE") != null);
}
// Edits that change nothing: the parent is printed, exit 3.
{
const res = try fx.cli(&.{ "--stdin", p0 }, fx.model.items[0].content, &.{});
defer freeResult(gpa, res);
try expectExit(res, 3);
try std.testing.expectEqualStrings(fx.tip, std.mem.trimEnd(u8, res.stdout, "\n"));
}
}
}
test "commit -R --rename: log --follow and annotate follow it, and a resend is the same check-in" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, .{ .name = "follow", .k = 7, .files = 900, .steps = 0 });
defer fx.deinit();
const old = fx.model.items[0].path;
const seed = fx.tip;
const pinned = [_][2][]const u8{.{ "SOURCE_DATE_EPOCH", "1700000100" }};
const rename_args = [_][]const u8{ "--rename", old, "d0/followed.txt", "--parent", seed };
const first = try fx.cli(&rename_args, null, &pinned);
defer freeResult(gpa, first);
try expectExit(first, 0);
const name = std.mem.trimEnd(u8, first.stdout, "\n");
// The rename is the parent's history continued: both readers reach the
// seed check-in through it.
{
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "log", name, "d0/followed.txt", "-R", "core" });
defer freeResult(gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed[0..10]) != null);
}
{
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "annotate", "d0/followed.txt", name, "-R", "core" });
defer freeResult(gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed[0..12]) != null);
}
// The same request again: the same check-in, already stored.
const before = try fx.storeSize("core");
const again = try fx.cli(&rename_args, null, &pinned);
defer freeResult(gpa, again);
try expectExit(again, 0);
try std.testing.expectEqualStrings(first.stdout, again.stdout);
try std.testing.expectEqual(before, try fx.storeSize("core"));
}
test "checkin core: a put of a rename's new name is that rename's content, as commit -R --rename --stdin records it" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, .{ .name = "fold", .files = 40, .steps = 0 });
defer fx.deinit();
const seed = fx.tip;
for ([_][]const u8{ "twin", "flipped" }) |name| {
try tmp.dir.createDir(io, name, .default_dir);
try tmp.dir.copyFile("core/.zit", tmp.dir, try std.fmt.allocPrint(fx.arena.allocator(), "{s}/.zit", .{name}), io, .{});
}
// The seed's first file is executable: the folded rename keeps its mode,
// as the CLI's rename-with-content does.
const old = fx.model.items[0].path;
const new = "d0/renamed.md";
// The old content plus a line: annotate credits the first line to the
// seed through the rename, the second to the folded commit.
const bytes = try std.fmt.allocPrint(fx.arena.allocator(), "{s}added by the rename\n", .{fx.model.items[0].content});
const cli = try fx.cli(&.{ "--rename", old, new, "--stdin", new }, bytes, &.{.{ "SOURCE_DATE_EPOCH", "1700000100" }});
defer freeResult(gpa, cli);
try expectExit(cli, 0);
const want = std.mem.trimEnd(u8, cli.stdout, "\n");
const orders = [_][]const checkin.Edit{
&.{ .{ .rename = .{ .old = old, .new = new } }, .{ .put = .{ .path = new, .bytes = bytes } } },
&.{ .{ .put = .{ .path = new, .bytes = bytes } }, .{ .rename = .{ .old = old, .new = new } } },
};
for ([_][]const u8{ "twin", "flipped" }, orders) |store, edits| {
var dir = try tmp.dir.openDir(io, store, .{});
defer dir.close(io);
var diag: checkin.Diag = .{};
var ctx: checkin.Context = undefined;
try ctx.open(gpa, io, dir, .{}, &diag);
defer ctx.deinit();
const got = try Resident.recorded(checkin.commit(&ctx, .{
.user = "u",
.comment = "cli",
.now = .{ .epoch = 1_700_000_100 },
.edits = edits,
}, &diag), &diag);
try std.testing.expectEqualStrings(want, got.slice());
}
// The folded rename is followed like any other.
{
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "log", want, new, "-R", "twin" });
defer freeResult(gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed[0..10]) != null);
}
{
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "annotate", new, want, "-R", "twin" });
defer freeResult(gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed[0..12]) != null);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, want[0..12]) != null);
}
// Every other repeat of a path is still refused, and writes nothing.
var dir = try tmp.dir.openDir(io, "twin", .{});
defer dir.close(io);
var diag: checkin.Diag = .{};
var ctx: checkin.Context = undefined;
try ctx.open(gpa, io, dir, .{}, &diag);
defer ctx.deinit();
const a = fx.model.items[1].path;
const b = fx.model.items[2].path;
const refused = [_][]const checkin.Edit{
&.{ .{ .put = .{ .path = a, .bytes = "one\n" } }, .{ .put = .{ .path = a, .bytes = "two\n" } } },
&.{ .{ .rename = .{ .old = a, .new = "x.md" } }, .{ .put = .{ .path = a, .bytes = "old\n" } } },
&.{ .{ .rename = .{ .old = a, .new = "x.md" } }, .{ .delete = "x.md" } },
&.{ .{ .rename = .{ .old = a, .new = "x.md" } }, .{ .rename = .{ .old = b, .new = "x.md" } } },
&.{ .{ .rename = .{ .old = a, .new = "x.md" } }, .{ .rename = .{ .old = b, .new = "x.md" } }, .{ .put = .{ .path = "x.md", .bytes = "x\n" } } },
&.{ .{ .rename = .{ .old = a, .new = "x.md" } }, .{ .put = .{ .path = "x.md", .bytes = "1\n" } }, .{ .put = .{ .path = "x.md", .bytes = "2\n" } } },
&.{ .{ .rename = .{ .old = a, .new = "x.md", .bytes = "own\n" } }, .{ .put = .{ .path = "x.md", .bytes = "put\n" } } },
};
const before = try fx.storeSize("twin");
for (refused, 0..) |edits, i| {
const got = checkin.commit(&ctx, .{
.user = "u",
.comment = "refused",
.now = .{ .epoch = 1_700_000_200 + @as(i64, @intCast(i)) },
.edits = edits,
}, &diag);
try std.testing.expectError(error.InvalidRequest, got);
try std.testing.expect(std.mem.indexOf(u8, diag.text(), "more than one edit") != null);
try std.testing.expectEqual(before, try fx.storeSize("twin"));
}
}
test "commit --date-override: the D card is the given time, from a checkout and from -R, and a resend is the first check-in" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, .{ .name = "date", .files = 40, .steps = 0 });
defer fx.deinit();
const a = fx.arena.allocator();
var ck = try tmp.dir.openDir(io, "ck", .{});
defer ck.close(io);
const dCard = struct {
fn of(dir: std.Io.Dir, store: []const u8, name: []const u8) ![]const u8 {
const res = try runZit(std.testing.allocator, std.testing.io, dir, &.{}, &.{ "artifact", name, "--raw", "-R", store });
defer freeResult(std.testing.allocator, res);
try expectOk(res);
const at = std.mem.indexOf(u8, res.stdout, "\nD ") orelse return error.TestUnexpectedResult;
const end = std.mem.indexOfScalarPos(u8, res.stdout, at + 1, '\n').?;
return std.testing.allocator.dupe(u8, res.stdout[at + 1 .. end]);
}
}.of;
// From a checkout, in SQLite's space-separated spelling.
{
try writeFile(io, ck, fx.model.items[0].path, "dated in a checkout\n");
const res = try runZit(gpa, io, ck, &.{.{ "USER", "u" }}, &.{ "commit", "-m", "dated", "--date-override", "2026-11-05 10:12:03.117", "--no-submanifest-advice" });
defer freeResult(gpa, res);
try expectOk(res);
const at = std.mem.indexOf(u8, res.stdout, "check-in ") orelse return error.TestUnexpectedResult;
const name = try a.dupe(u8, res.stdout[at + "check-in ".len ..][0..64]);
const d = try dCard(tmp.dir, "ck", name);
defer gpa.free(d);
try std.testing.expectEqualStrings("D 2026-11-05T10:12:03.117", d);
}
// A date that does not parse: exit 1, nothing committed.
{
try writeFile(io, ck, fx.model.items[0].path, "never committed\n");
const before = try fx.storeSize("ck");
const res = try runZit(gpa, io, ck, &.{.{ "USER", "u" }}, &.{ "commit", "-m", "bad", "--date-override", "yesterday" });
defer freeResult(gpa, res);
try expectExit(res, 1);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unrecognized date format (yesterday)") != null);
try std.testing.expectEqual(before, try fx.storeSize("ck"));
}
// From commit -R, in RFC 3339 with milliseconds: the D card, and the same
// time back out of `log -F json`.
const path = fx.model.items[1].path;
const args = [_][]const u8{ "--stdin", path, "--date-override", "2026-11-05T10:12:03.117Z" };
const first = try fx.cli(&args, "dated through -R\n", &.{});
defer freeResult(gpa, first);
try expectExit(first, 0);
const name = std.mem.trimEnd(u8, first.stdout, "\n");
{
const d = try dCard(tmp.dir, "core", name);
defer gpa.free(d);
try std.testing.expectEqualStrings("D 2026-11-05T10:12:03.117", d);
const res = try runZit(gpa, io, tmp.dir, &.{}, &.{ "log", name, path, "-F", "json", "-R", "core" });
defer freeResult(gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\"2026-11-05T10:12:03.117Z\"") != null);
}
// The same request again is answered by the first check-in: exit 0, its
// name, and the store as it was. (Built on the new tip, the same bytes
// would otherwise change nothing and exit 3.)
{
const before = try fx.storeSize("core");
const again = try fx.cli(&args, "dated through -R\n", &.{});
defer freeResult(gpa, again);
try expectExit(again, 0);
try std.testing.expectEqualStrings(first.stdout, again.stdout);
try std.testing.expectEqual(before, try fx.storeSize("core"));
}
{
const before = try fx.storeSize("core");
const res = try fx.cli(&.{ "--stdin", path, "--date-override", "2026-11-05T10:12:03.117" ++ "X" }, "x\n", &.{});
defer freeResult(gpa, res);
try expectExit(res, 1);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "unrecognized date format") != null);
try std.testing.expectEqual(before, try fx.storeSize("core"));
}
}
test "checkin core: one resident context across commits mints what a fresh context mints" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var fx: Fixture = undefined;
try fx.seed(gpa, io, tmp.dir, .{ .name = "resident", .k = 7, .files = 900, .steps = 0 });
defer fx.deinit();
try tmp.dir.createDir(io, "fresh", .default_dir);
try tmp.dir.copyFile("core/.zit", tmp.dir, "fresh/.zit", io, .{});
const a = fx.arena.allocator();
var core = try tmp.dir.openDir(io, "core", .{});
defer core.close(io);
var fresh = try tmp.dir.openDir(io, "fresh", .{});
defer fresh.close(io);
var diag: checkin.Diag = .{};
var ctx: checkin.Context = undefined;
try ctx.open(gpa, io, core, .{}, &diag);
defer ctx.deinit();
const paths = [_][]const u8{ fx.model.items[10].path, fx.model.items[200].path, fx.model.items[400].path, fx.model.items[600].path };
const renamed = try std.fmt.allocPrint(a, "{s}/renamed.txt", .{std.fs.path.dirname(paths[0]).?});
var r: Resident = .{ .gpa = gpa, .a = a, .ctx = &ctx, .fresh = fresh, .diag = &diag };
// A rename, then a commit that only carries the renamed card: the memo
// knows the parent's renamed card without a tree walk.
const n1 = try r.both(1, &.{.{ .rename = .{ .old = paths[0], .new = renamed } }}, null);
try std.testing.expectEqualStrings(n1, ctx.renamed_memo.?.checkin.slice());
try std.testing.expectEqual(@as(usize, 1), ctx.renamed_memo.?.cards.len);
const n2 = try r.both(2, &.{.{ .put = .{ .path = paths[1], .bytes = "two\n" } }}, null);
try std.testing.expectEqual(@as(usize, 0), ctx.renamed_memo.?.cards.len);
// A commit the before-commit hook refuses writes nothing, and the next
// one on the same context is unaffected by what the refused one built.
const before = try fx.storeSize("core");
try std.testing.expectError(error.HookRefused, checkin.commit(&ctx, .{
.user = "u",
.comment = "refused",
.now = .{ .epoch = seed_epoch + 3 },
.edits = &.{.{ .put = .{ .path = paths[2], .bytes = "refused\n" } }},
.hooks = .{ .before_commit = Resident.refuse },
}, &diag));
try std.testing.expectEqual(before, try fx.storeSize("core"));
const n3 = try r.both(3, &.{.{ .put = .{ .path = paths[2], .bytes = "three\n" } }}, null);
// A parent the memo has not seen, which still carries the renamed card.
_ = try r.both(4, &.{.{ .put = .{ .path = paths[3], .bytes = "four\n" } }}, n1);
// Another writer lands between two resident commits, and moves k.
const n5 = try r.fresh_only(core, 5, &.{.{ .put = .{ .path = paths[3], .bytes = "five\n" } }}, n3);
try std.testing.expectEqualStrings(n5, try r.fresh_only(fresh, 5, &.{.{ .put = .{ .path = paths[3], .bytes = "five\n" } }}, n3));
try setK(gpa, io, core, 5);
try setK(gpa, io, fresh, 5);
const n6 = try r.both(6, &.{.{ .put = .{ .path = paths[1], .bytes = "six\n" } }}, n5);
_ = try r.both(7, &.{.{ .put = .{ .path = paths[2], .bytes = "seven\n" } }}, n6);
_ = n2;
}
/// One resident context against a fresh context per commit on a twin store.
const Resident = struct {
gpa: std.mem.Allocator,
a: std.mem.Allocator,
ctx: *checkin.Context,
fresh: std.Io.Dir,
diag: *checkin.Diag,
fn refuse(_: ?*anyopaque) error{Refused}!void {
return error.Refused;
}
fn request(n: usize, edits: []const checkin.Edit, parent: ?[]const u8, comment: []const u8) checkin.Request {
return .{
.parent = parent,
.user = "u",
.comment = comment,
.now = .{ .epoch = seed_epoch + @as(i64, @intCast(n)) },
.edits = edits,
.allow_fork = parent != null,
};
}
/// The same commit on the resident context and on a fresh one; the two
/// names must agree.
fn both(self: *Resident, n: usize, edits: []const checkin.Edit, parent: ?[]const u8) ![]const u8 {
const comment = try std.fmt.allocPrint(self.a, "resident {d}", .{n});
const resident = try recorded(checkin.commit(self.ctx, request(n, edits, parent, comment), self.diag), self.diag);
const once = try self.fresh_only(self.fresh, n, edits, parent);
try std.testing.expectEqualStrings(once, resident.slice());
return once;
}
fn fresh_only(self: *Resident, dir: std.Io.Dir, n: usize, edits: []const checkin.Edit, parent: ?[]const u8) ![]const u8 {
const comment = try std.fmt.allocPrint(self.a, "resident {d}", .{n});
var diag: checkin.Diag = .{};
var ctx: checkin.Context = undefined;
try ctx.open(self.gpa, self.ctx.io, dir, .{}, &diag);
defer ctx.deinit();
const name = try recorded(checkin.commit(&ctx, request(n, edits, parent, comment), &diag), &diag);
return self.a.dupe(u8, name.slice());
}
fn recorded(result: checkin.Error!checkin.Outcome, diag: *const checkin.Diag) !checkin.Name {
const outcome = result catch |e| {
std.debug.print("\ncore refused: {s}: {s}\n", .{ @errorName(e), diag.text() });
return e;
};
return switch (outcome) {
.recorded => |name| name,
else => error.TestUnexpectedResult,
};
}
};
/// Re-pin the store's sub-manifest k, as `zit submanifest retune` would: a
/// config frame appended under the writer lock by another writer.
fn setK(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, k: u8) !void {
var held = try zit.cli.openStoreAppend(io, dir);
defer held.close(io);
const st = try held.file.stat(io);
var fs = storage.FileStorage.init(io, held.file, st.size);
var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
defer log.deinit(gpa);
var oc = (try log.readConfig(gpa)).?;
defer oc.deinit(gpa);
var cfg = oc.config;
cfg.submanifest_k = k;
try log.putConfig(cfg);
try log.commit();
}
const ModelFile = struct {
path: []const u8,
content: []const u8,
};
const Fixture = struct {
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
shape: Shape,
arena: std.heap.ArenaAllocator,
/// The current file set, as both stores hold it.
model: std.ArrayList(ModelFile) = .empty,
step_no: usize = 0,
/// The check-in both stores last recorded.
tip: []const u8 = "",
/// Make the bare store's commits through `zit commit -R` rather than the
/// core in-process.
via_cli: bool = false,
/// Initializes in place: the model lives on the fixture's own arena.
fn seed(fx: *Fixture, gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, shape: Shape) !void {
fx.* = .{ .gpa = gpa, .io = io, .dir = dir, .shape = shape, .arena = .init(gpa) };
errdefer fx.deinit();
const a = fx.arena.allocator();
{
var k_buf: [4]u8 = undefined;
const res = if (shape.k) |k|
try runZit(gpa, io, dir, &.{}, &.{ "init", "--submanifest", try std.fmt.bufPrint(&k_buf, "{d}", .{k}), "ck" })
else
try runZit(gpa, io, dir, &.{}, &.{ "init", "ck" });
defer freeResult(gpa, res);
try expectOk(res);
}
var ck = try dir.openDir(io, "ck", .{});
defer ck.close(io);
if (shape.repo_cksum) {
const res = try runZit(gpa, io, ck, &.{}, &.{ "settings", "repo-cksum", "on" });
defer freeResult(gpa, res);
try expectOk(res);
}
// The seed check-in, written straight into the log: SHA-1 names and
// tree shapes no commit of this build would mint for a root.
var cards: std.ArrayList(artifact.FCard) = .empty;
for (0..shape.files) |i| {
const path = try std.fmt.allocPrint(a, "d{d}/s{d}/f{d}.txt", .{ i % 7, i % 3, i });
const content = try std.fmt.allocPrint(a, "seed {s}\n", .{path});
try fx.model.append(a, .{ .path = path, .content = content });
var name_buf: [64]u8 = undefined;
const name = try a.dupe(u8, artifact.artifactName(if (shape.sha1) .sha1 else .sha3_256, content, &name_buf));
const exec = i % 11 == 0;
try cards.append(a, .{ .filename = path, .hash = name, .perm = if (exec) "x" else null, .old_name = null, .mode = if (exec) .executable else .regular });
}
std.mem.sort(artifact.FCard, cards.items, {}, struct {
fn less(_: void, x: artifact.FCard, y: artifact.FCard) bool {
return artifact.compareBytes(x.filename, y.filename) == .lt;
}
}.less);
const root = try fx.writeSeed(cards.items);
fx.tip = root;
{
const res = try runZit(gpa, io, ck, &.{}, &.{ "update", root });
defer freeResult(gpa, res);
try expectOk(res);
}
try dir.createDir(io, "core", .default_dir);
try dir.copyFile("ck/.zit", dir, "core/.zit", io, .{});
}
fn deinit(self: *Fixture) void {
self.arena.deinit();
}
fn writeSeed(self: *Fixture, cards: []const artifact.FCard) ![]const u8 {
const a = self.arena.allocator();
var file = try self.dir.openFile(self.io, "ck/.zit", .{ .mode = .read_write });
defer file.close(self.io);
const st = try file.stat(self.io);
var fs = storage.FileStorage.init(self.io, file, st.size);
var log = try logstore.LogStore.open(self.gpa, fs.storageFile(), 0);
defer log.deinit(self.gpa);
for (self.model.items) |f| {
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(if (self.shape.sha1) .sha1 else .sha3_256, f.content, &name_buf);
var digest: [32]u8 = undefined;
const raw = digest[0 .. name.len / 2];
_ = try std.fmt.hexToBytes(raw, name);
_ = try log.put(raw, f.content);
}
var s_cards: []const artifact.SCard = &.{};
var f_cards: []const artifact.FCard = cards;
var tree: ?artifact.TreeBuild = null;
defer if (tree) |*t| t.deinit();
if (self.shape.k) |k| {
tree = try artifact.buildTree(self.gpa, cards, k, .sha3_256);
if (!tree.?.isFlat()) {
for (tree.?.subs) |sub| {
var sd: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(sub.bytes, &sd, .{});
_ = try log.put(&sd, sub.bytes);
}
s_cards = tree.?.s;
f_cards = &.{};
}
}
if (self.shape.delta) {
var oc = (try log.readConfig(self.gpa)).?;
defer oc.deinit(self.gpa);
var cfg = oc.config;
cfg.seen_delta_manifest = true;
try log.putConfig(cfg);
}
const tags = try artifact.composeCommitTags(a, .{ .branch = "trunk" });
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(self.gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "seed" },
.d = .{ .timestamp = "2023-01-01T00:00:00.000" },
.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_cards,
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = s_cards,
.t = tags,
};
defer m.deinit();
const bytes = try artifact.create(&m, self.gpa);
defer self.gpa.free(bytes);
var md: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &md, .{});
_ = try log.put(&md, bytes);
try log.commit();
var name_buf: [64]u8 = undefined;
return a.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));
}
fn storeSize(self: *Fixture, which: []const u8) !u64 {
var buf: [32]u8 = undefined;
return (try self.dir.statFile(self.io, try std.fmt.bufPrint(&buf, "{s}/.zit", .{which}), .{})).size;
}
fn contentOf(self: *const Fixture, path: []const u8) []const u8 {
for (self.model.items) |f| if (std.mem.eql(u8, f.path, path)) return f.content;
unreachable;
}
fn indexOf(self: *const Fixture, path: []const u8) ?usize {
for (self.model.items, 0..) |f, i| if (std.mem.eql(u8, f.path, path)) return i;
return null;
}
/// Content no earlier version of any file had, and longer than all of
/// them, so the checkout's stat cache can never mistake it for old bytes.
fn freshContent(self: *Fixture, path: []const u8, salt: usize) ![]const u8 {
const a = self.arena.allocator();
const pad = try a.alloc(u8, 32 + self.step_no * 9 + salt);
@memset(pad, 'x');
return std.fmt.allocPrint(a, "{s} at step {d}.{d}\n{s}\n", .{ path, self.step_no, salt, pad });
}
fn runScripted(self: *Fixture) !void {
const a = self.arena.allocator();
const first = self.model.items[0].path;
const second = self.model.items[1].path;
const third = self.model.items[2].path;
// One existing file's bytes.
try self.step(&.{.{ .put = .{ .path = first, .bytes = try self.freshContent(first, 0) } }});
// A new file in a new directory, and a second edit.
try self.step(&.{
.{ .put = .{ .path = "fresh/dir/new.txt", .bytes = try self.freshContent("fresh/dir/new.txt", 0) } },
.{ .put = .{ .path = second, .bytes = try self.freshContent(second, 1) } },
});
// A delete.
try self.step(&.{.{ .delete = third }});
// A rename, carried content.
const renamed = try std.fmt.allocPrint(a, "{s}/renamed.txt", .{std.fs.path.dirname(second).?});
try self.step(&.{.{ .rename = .{ .old = second, .new = renamed } }});
// The next commit sheds the rename's provenance from the carried card.
try self.step(&.{.{ .put = .{ .path = first, .bytes = try self.freshContent(first, 2) } }});
// A rename with new bytes, beside a put.
const moved = try std.fmt.allocPrint(a, "{s}/moved.txt", .{std.fs.path.dirname(first).?});
try self.step(&.{
.{ .rename = .{ .old = first, .new = moved, .bytes = try self.freshContent(moved, 3) } },
.{ .put = .{ .path = "fresh/dir/new.txt", .bytes = try self.freshContent("fresh/dir/new.txt", 4) } },
});
if (self.shape.k) |k| if (k <= 3) try self.runEmptyLeaf();
if (self.shape.merge) try self.runMerge();
}
/// Delete every file of one leaf of the parent's tree, so that
/// sub-manifest node loses every entry and leaves the tree.
fn runEmptyLeaf(self: *Fixture) !void {
const a = self.arena.allocator();
const leaves = try self.leavesOf("core/.zit", self.tip);
// A middle leaf: its neighbours on both sides take over its range.
const leaf = leaves[leaves.len / 2];
var edits: std.ArrayList(checkin.Edit) = .empty;
for (leaf) |path| {
try std.testing.expect(self.indexOf(path) != null);
try edits.append(a, .{ .delete = path });
}
try self.step(edits.items);
}
/// A side branch, a trunk commit beside it, the merge of the two (made
/// by the checkout and copied to the bare store, since the core does not
/// merge), and a commit of both whose parent is that merge.
fn runMerge(self: *Fixture) !void {
const gpa = self.gpa;
const io = self.io;
const fork_point = self.tip;
const x = self.model.items[3].path;
const y = self.model.items[4].path;
const z = self.model.items[5].path;
try self.stepOn(&.{.{ .put = .{ .path = x, .bytes = try self.freshContent(x, 0) } }}, "side");
var ck = try self.dir.openDir(io, "ck", .{});
defer ck.close(io);
{
const res = try runZit(gpa, io, ck, &.{}, &.{ "update", fork_point });
defer freeResult(gpa, res);
try expectOk(res);
}
// The checkout's copy of X is the fork point's again; this commit
// writes only Y, so both sides record the fork point's X.
try self.step(&.{.{ .put = .{ .path = y, .bytes = try self.freshContent(y, 1) } }});
{
const res = try runZit(gpa, io, ck, &.{}, &.{ "merge", "side" });
defer freeResult(gpa, res);
try expectOk(res);
}
self.step_no += 1;
var epoch_buf: [24]u8 = undefined;
const res = try runZit(gpa, io, ck, &.{
.{ "SOURCE_DATE_EPOCH", try std.fmt.bufPrint(&epoch_buf, "{d}", .{seed_epoch + @as(i64, @intCast(self.step_no))}) },
.{ "USER", "u" },
}, &.{ "commit", "-m", "merge side", "--no-submanifest-advice" });
defer freeResult(gpa, res);
try expectOk(res);
const at = std.mem.indexOf(u8, res.stdout, "check-in ") orelse return error.TestUnexpectedResult;
self.tip = try self.arena.allocator().dupe(u8, res.stdout[at + "check-in ".len ..][0..64]);
try self.dir.deleteFile(io, "core/.zit");
try self.dir.copyFile("ck/.zit", self.dir, "core/.zit", io, .{});
try self.step(&.{.{ .put = .{ .path = z, .bytes = try self.freshContent(z, 2) } }});
try self.expectMergeParent();
}
/// The last check-in's parent is a merge: two parents in its P card.
fn expectMergeParent(self: *Fixture) !void {
const res = try runZit(self.gpa, self.io, self.dir, &.{}, &.{ "artifact", self.tip, "--raw", "-R", "core" });
defer freeResult(self.gpa, res);
try expectOk(res);
const p_at = std.mem.indexOf(u8, res.stdout, "\nP ") orelse return error.TestUnexpectedResult;
const parent = res.stdout[p_at + 3 ..][0..64];
const pres = try runZit(self.gpa, self.io, self.dir, &.{}, &.{ "artifact", parent, "--raw", "-R", "core" });
defer freeResult(self.gpa, pres);
try expectOk(pres);
const pp_at = std.mem.indexOf(u8, pres.stdout, "\nP ") orelse return error.TestUnexpectedResult;
const line_end = std.mem.indexOfScalarPos(u8, pres.stdout, pp_at + 1, '\n').?;
try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, pres.stdout[pp_at + 3 .. line_end], " ") + 1);
}
/// The paths of every leaf of check-in `hash`'s tree, in order.
fn leavesOf(self: *Fixture, path: []const u8, hash: []const u8) ![]const []const []const u8 {
const a = self.arena.allocator();
var file = try self.dir.openFile(self.io, path, .{});
defer file.close(self.io);
const st = try file.stat(self.io);
var fs = storage.FileStorage.initReadOnly(self.io, file, st.size);
var log = try logstore.LogStore.openReadOnly(self.gpa, fs.storageFile());
defer log.deinit(self.gpa);
var out: std.ArrayList([]const []const u8) = .empty;
try self.collectLeaves(a, &log, hash, &out);
try std.testing.expect(out.items.len > 1);
return out.items;
}
fn collectLeaves(self: *Fixture, a: std.mem.Allocator, log: *logstore.LogStore, hash: []const u8, out: *std.ArrayList([]const []const u8)) !void {
var digest: [32]u8 = undefined;
_ = try std.fmt.hexToBytes(&digest, hash);
const bytes = (try log.content(self.gpa, log.lookup(&digest).?)).?;
defer self.gpa.free(bytes);
var m = try artifact.parse(self.gpa, bytes);
defer m.deinit();
if (m.s.len != 0) {
for (m.s) |card| try self.collectLeaves(a, log, card.hash, out);
return;
}
if (m.kind == .manifest) return; // a flat check-in has no leaves
const paths = try a.alloc([]const u8, m.f.len);
for (m.f, paths) |f, *dst| dst.* = try a.dupe(u8, f.filename);
try out.append(a, paths);
}
fn runRandom(self: *Fixture, random: std.Random) !void {
const a = self.arena.allocator();
var edits: std.ArrayList(checkin.Edit) = .empty;
var touched: std.StringHashMapUnmanaged(void) = .empty;
const want = 1 + random.uintLessThan(usize, 4);
var tries: usize = 0;
while (edits.items.len < want and tries < 32) : (tries += 1) {
const salt = edits.items.len;
const pick = self.model.items[random.uintLessThan(usize, self.model.items.len)].path;
const roll = random.uintLessThan(u8, 100);
if (roll < 55) {
if (touched.contains(pick)) continue;
try touched.put(a, pick, {});
try edits.append(a, .{ .put = .{ .path = pick, .bytes = try self.freshContent(pick, salt) } });
} else if (roll < 70) {
const path = try std.fmt.allocPrint(a, "d{d}/n{d}_{d}.txt", .{ random.uintLessThan(usize, 7), self.step_no, salt });
if (touched.contains(path) or self.indexOf(path) != null) continue;
try touched.put(a, path, {});
try edits.append(a, .{ .put = .{ .path = path, .bytes = try self.freshContent(path, salt) } });
} else if (roll < 85) {
if (touched.contains(pick) or self.model.items.len < 8) continue;
try touched.put(a, pick, {});
try edits.append(a, .{ .delete = pick });
} else {
const new = try std.fmt.allocPrint(a, "{s}/r{d}_{d}.txt", .{ std.fs.path.dirname(pick).?, self.step_no, salt });
if (touched.contains(pick) or touched.contains(new) or self.indexOf(new) != null) continue;
try touched.put(a, pick, {});
try touched.put(a, new, {});
const bytes: ?[]const u8 = if (random.boolean()) try self.freshContent(new, salt) else null;
try edits.append(a, .{ .rename = .{ .old = pick, .new = new, .bytes = bytes } });
}
}
try std.testing.expect(edits.items.len != 0);
try self.step(edits.items);
}
/// Make `edits` in both stores and require the same check-in from each.
fn step(self: *Fixture, edits: []const checkin.Edit) !void {
return self.stepOn(edits, null);
}
/// `step`, landing on `branch` when it names one.
fn stepOn(self: *Fixture, edits: []const checkin.Edit, branch: ?[]const u8) !void {
self.step_no += 1;
const epoch = seed_epoch + @as(i64, @intCast(self.step_no));
const comment = try std.fmt.allocPrint(self.arena.allocator(), "step {d}", .{self.step_no});
const ck_hash = try self.commitCheckout(edits, epoch, comment, branch);
const core_hash = if (self.via_cli)
try self.commitCli(edits, epoch, comment, branch)
else
try self.commitCore(edits, epoch, comment, branch);
if (!std.mem.eql(u8, ck_hash, core_hash)) {
std.debug.print("\n[{s}] step {d}: checkout minted {s}, core minted {s}\n", .{ self.shape.name, self.step_no, ck_hash, core_hash });
try self.dumpArtifact("ck", ck_hash);
try self.dumpArtifact("core", core_hash);
return error.TestUnexpectedResult;
}
try self.applyToModel(edits);
self.tip = core_hash;
}
fn dumpArtifact(self: *Fixture, store: []const u8, hash: []const u8) !void {
const res = try runZit(self.gpa, self.io, self.dir, &.{}, &.{ "artifact", hash, "-R", store });
defer freeResult(self.gpa, res);
std.debug.print("--- {s} {s}\n{s}{s}\n", .{ store, hash, res.stdout, res.stderr });
}
fn applyToModel(self: *Fixture, edits: []const checkin.Edit) !void {
const a = self.arena.allocator();
for (edits) |e| switch (e) {
.put => |p| if (self.indexOf(p.path)) |i| {
self.model.items[i].content = p.bytes;
} else try self.model.append(a, .{ .path = p.path, .content = p.bytes }),
.delete => |path| _ = self.model.orderedRemove(self.indexOf(path).?),
.rename => |r| {
const i = self.indexOf(r.old).?;
self.model.items[i] = .{ .path = r.new, .content = r.bytes orelse self.model.items[i].content };
},
};
}
fn commitCheckout(self: *Fixture, edits: []const checkin.Edit, epoch: i64, comment: []const u8, branch: ?[]const u8) ![]const u8 {
const gpa = self.gpa;
const io = self.io;
var ck = try self.dir.openDir(io, "ck", .{});
defer ck.close(io);
for (edits) |e| switch (e) {
.put => |p| {
const is_new = self.indexOf(p.path) == null;
if (std.fs.path.dirname(p.path)) |d| try ck.createDirPath(io, d);
try writeFile(io, ck, p.path, p.bytes);
if (is_new) {
const res = try runZit(gpa, io, ck, &.{}, &.{ "add", p.path });
defer freeResult(gpa, res);
try expectOk(res);
}
},
.delete => |path| {
const res = try runZit(gpa, io, ck, &.{}, &.{ "rm", "--hard", path });
defer freeResult(gpa, res);
try expectOk(res);
},
.rename => |r| {
const res = try runZit(gpa, io, ck, &.{}, &.{ "mv", r.old, r.new });
defer freeResult(gpa, res);
try expectOk(res);
if (r.bytes) |b| try writeFile(io, ck, r.new, b);
},
};
var epoch_buf: [24]u8 = undefined;
const res = try runZit(gpa, io, ck, &.{
.{ "SOURCE_DATE_EPOCH", try std.fmt.bufPrint(&epoch_buf, "{d}", .{epoch}) },
.{ "USER", "u" },
}, if (branch) |b| &.{ "commit", "-m", comment, "--no-submanifest-advice", "--branch", b } else &.{ "commit", "-m", comment, "--no-submanifest-advice" });
defer freeResult(gpa, res);
try expectOk(res);
const at = std.mem.indexOf(u8, res.stdout, "check-in ") orelse return error.TestUnexpectedResult;
return self.arena.allocator().dupe(u8, res.stdout[at + "check-in ".len ..][0..64]);
}
/// `zit commit -R core -m MSG --user u EXTRA...` from the fixture's
/// directory, `stdin` (when given) fed as its standard input.
fn cli(self: *Fixture, extra: []const []const u8, stdin: ?[]const u8, env: []const [2][]const u8) !std.process.RunResult {
const a = self.arena.allocator();
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(a, &.{ "commit", "-R", "core", "-m", "cli", "--user", "u" });
try argv.appendSlice(a, extra);
try writeFile(self.io, self.dir, "stdin.in", stdin orelse "");
return runZitShell(self.gpa, self.io, self.dir, env, argv.items, "stdin.in");
}
/// The bare store's half of `step` through the CLI: the edits as
/// `--delete`, `--rename` and at most one `--stdin`.
fn commitCli(self: *Fixture, edits: []const checkin.Edit, epoch: i64, comment: []const u8, branch: ?[]const u8) ![]const u8 {
const a = self.arena.allocator();
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(a, &.{ "commit", "-R", "core", "-m", comment, "--user", "u" });
if (branch) |b| try argv.appendSlice(a, &.{ "--branch", b });
var stdin: ?[]const u8 = null;
for (edits) |e| switch (e) {
.delete => |path| try argv.appendSlice(a, &.{ "--delete", path }),
.rename => |r| {
try argv.appendSlice(a, &.{ "--rename", r.old, r.new });
if (r.bytes) |b| {
std.debug.assert(stdin == null);
stdin = b;
try argv.appendSlice(a, &.{ "--stdin", r.new });
}
},
.put => |p| {
std.debug.assert(stdin == null);
stdin = p.bytes;
try argv.appendSlice(a, &.{ "--stdin", p.path });
},
};
try writeFile(self.io, self.dir, "stdin.in", stdin orelse "");
var epoch_buf: [24]u8 = undefined;
const res = try runZitShell(self.gpa, self.io, self.dir, &.{
.{ "SOURCE_DATE_EPOCH", try std.fmt.bufPrint(&epoch_buf, "{d}", .{epoch}) },
}, argv.items, "stdin.in");
defer freeResult(self.gpa, res);
try expectOk(res);
try std.testing.expectEqual(@as(usize, 65), res.stdout.len);
return a.dupe(u8, res.stdout[0..64]);
}
fn commitCore(self: *Fixture, edits: []const checkin.Edit, epoch: i64, comment: []const u8, branch: ?[]const u8) ![]const u8 {
var core = try self.dir.openDir(self.io, "core", .{});
defer core.close(self.io);
var diag: checkin.Diag = .{};
var ctx: checkin.Context = undefined;
try ctx.open(self.gpa, self.io, core, .{}, &diag);
defer ctx.deinit();
const outcome = checkin.commit(&ctx, .{
.user = "u",
.comment = comment,
.now = .{ .epoch = epoch },
.edits = edits,
.branch = branch,
.policy = .{ .repo_cksum = self.shape.repo_cksum },
}, &diag) catch |e| {
std.debug.print("\n[{s}] step {d}: core refused: {s}: {s}\n", .{ self.shape.name, self.step_no, @errorName(e), diag.text() });
return e;
};
const name = switch (outcome) {
.recorded => |n| n,
else => return error.TestUnexpectedResult,
};
return self.arena.allocator().dupe(u8, name.slice());
}
/// The two stores hold the same artifacts and answer the derived
/// queries the same.
fn expectSameStores(self: *Fixture) !void {
const ck_set = try self.artifactSet("ck/.zit");
defer freeSet(self.gpa, ck_set);
const core_set = try self.artifactSet("core/.zit");
defer freeSet(self.gpa, core_set);
try std.testing.expectEqual(ck_set.len, core_set.len);
for (ck_set, core_set) |x, y| try std.testing.expectEqualStrings(x, y);
if (self.shape.k != null) {
// A tree store the test means to exercise must actually mint S
// cards, or the comparison above proves nothing about trees.
const res = try runZit(self.gpa, self.io, self.dir, &.{}, &.{ "artifact", self.tip, "--raw", "-R", "core" });
defer freeResult(self.gpa, res);
try expectOk(res);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, "\nS ") != null);
}
try self.expectSameOutput(&.{ "crosslink", "-R" });
for (self.model.items[0..@min(self.model.items.len, 4)]) |f| {
try self.expectSameOutput(&.{ "log", self.tip, f.path, "-R" });
}
}
fn expectSameOutput(self: *Fixture, head: []const []const u8) !void {
var argv: [8][]const u8 = undefined;
@memcpy(argv[0..head.len], head);
argv[head.len] = "ck";
const ck = try runZit(self.gpa, self.io, self.dir, &.{}, argv[0 .. head.len + 1]);
defer freeResult(self.gpa, ck);
argv[head.len] = "core";
const core = try runZit(self.gpa, self.io, self.dir, &.{}, argv[0 .. head.len + 1]);
defer freeResult(self.gpa, core);
try expectOk(ck);
try expectOk(core);
try std.testing.expectEqualStrings(ck.stdout, core.stdout);
}
/// Every content artifact the store holds, by name, sorted.
fn artifactSet(self: *Fixture, path: []const u8) ![][]const u8 {
var file = try self.dir.openFile(self.io, path, .{});
defer file.close(self.io);
const st = try file.stat(self.io);
var fs = storage.FileStorage.initReadOnly(self.io, file, st.size);
var log = try logstore.LogStore.openReadOnly(self.gpa, fs.storageFile());
defer log.deinit(self.gpa);
var names: std.ArrayList([]const u8) = .empty;
errdefer freeSet(self.gpa, names.items);
var rid: logstore.Rid = 1;
while (rid <= log.entryCount()) : (rid += 1) {
if (log.isPhantom(rid) or log.kindOf(rid) == .config) continue;
var hex_buf: [64]u8 = undefined;
const raw = log.hashOf(rid);
const hex = std.fmt.bufPrint(&hex_buf, "{x}", .{raw}) catch unreachable;
try names.append(self.gpa, try self.gpa.dupe(u8, hex));
}
std.mem.sort([]const u8, names.items, {}, struct {
fn less(_: void, x: []const u8, y: []const u8) bool {
return std.mem.order(u8, x, y) == .lt;
}
}.less);
return names.toOwnedSlice(self.gpa);
}
};
fn freeSet(gpa: std.mem.Allocator, set: []const []const u8) void {
for (set) |s| gpa.free(s);
gpa.free(set);
}
fn writeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8) !void {
var f = try dir.createFile(io, name, .{ .truncate = true });
defer f.close(io);
try f.writePositionalAll(io, content, 0);
}
fn runZit(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
extra: []const [2][]const u8,
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");
for (extra) |pair| try env.put(pair[0], pair[1]);
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);
@memcpy(argv_buf[1..][0..tail.len], tail);
return std.process.run(gpa, io, .{
.argv = argv_buf[0 .. 1 + tail.len],
.cwd = .{ .dir = dir },
.environ_map = &env,
});
}
/// `runZit` through `sh -c` with stdin redirected from `stdin_file` (under
/// `dir`): `std.process.run` pins a child's stdin to nothing.
fn runZitShell(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
extra: []const [2][]const u8,
tail: []const []const u8,
stdin_file: []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");
for (extra) |pair| try env.put(pair[0], pair[1]);
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 cmd: std.ArrayList(u8) = .empty;
defer cmd.deinit(gpa);
try cmd.print(gpa, "exec '{s}'", .{abs_exe});
for (tail) |t| try cmd.print(gpa, " '{s}'", .{t});
try cmd.print(gpa, " < '{s}'", .{stdin_file});
return std.process.run(gpa, io, .{
.argv = &.{ "/bin/sh", "-c", cmd.items },
.cwd = .{ .dir = dir },
.environ_map = &env,
});
}
fn expectExit(res: std.process.RunResult, want: u8) !void {
switch (res.term) {
.exited => |code| if (code == want) return,
else => {},
}
std.debug.print("\nzit: expected exit {d}, got {any}:\n{s}{s}\n", .{ want, res.term, res.stdout, res.stderr });
return error.TestUnexpectedResult;
}
fn freeResult(gpa: std.mem.Allocator, res: std.process.RunResult) void {
gpa.free(res.stdout);
gpa.free(res.stderr);
}
fn expectOk(res: std.process.RunResult) !void {
switch (res.term) {
.exited => |code| if (code == 0) return,
else => {},
}
std.debug.print("\nzit failed ({any}):\n{s}{s}\n", .{ res.term, res.stdout, res.stderr });
return error.TestUnexpectedResult;
}