Skip to content
zit Artifact [4bff19b8c4]
Login

Artifact [4bff19b8c4]

Artifact 4bff19b8c41230bd46e2987002da7fd2adb84e327f895e794f14e21a08efb23a:


//! 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 "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 = "",

    /// 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);
        {
            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 = 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]);
    }

    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,
    });
}

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;
}