//! Export planner + seam-enumeration tests: strict topological order with
//! the timeline order as the ready tiebreak, mark-map exclusion, and the
//! §9.3 seam-equivalence proof — the SAME file set enumerated identically
//! through a flat manifest, an S-card tree, and a delta manifest, with the
//! tree case proven to have gone through `Manifest.flattenTree` via the
//! sub-manifest memo's own counters (zero bespoke S-card walks).
const std = @import("std");
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const storage = @import("../storage.zig");
const repostate = @import("../repostate.zig");
const Repository = @import("../repository.zig").Repository;
const export_impl = @import("git_export_impl.zig");
const testing = std.testing;
// ---------------------------------------------------------------------------
// Planner ordering over synthetic RepoState graphs.
// ---------------------------------------------------------------------------
const h = struct {
fn n(comptime c: u8) *const [64]u8 {
return &(.{c} ** 64);
}
};
fn crosslinkChain(rs: *repostate.RepoState) !void {
_ = try rs.crosslink(.{ .hash = h.n('a'), .mtime = 10, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
_ = try rs.crosslink(.{ .hash = h.n('b'), .mtime = 20, .parents = &.{h.n('a')} });
_ = try rs.crosslink(.{ .hash = h.n('c'), .mtime = 30, .parents = &.{h.n('b')} });
}
fn indexOf(order: []const repostate.Rid, rid: repostate.Rid) usize {
for (order, 0..) |r, i| {
if (r == rid) return i;
}
unreachable;
}
fn expectParentsFirst(rs: *const repostate.RepoState, order: []const repostate.Rid) !void {
for (order) |rid| {
var it = rs.parentsOf(rid);
while (it.next()) |parent| {
if (rs.mtimeOf(parent) == null) continue; // phantom parent: not planned
try testing.expect(indexOf(order, parent) < indexOf(order, rid));
}
}
}
test "git export planner: linear history plans oldest-first, every parent before its child" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var rs = repostate.RepoState.init(gpa, ms.store());
defer rs.deinit();
try crosslinkChain(&rs);
var nothing = export_impl.NoneExported{};
const order = try export_impl.plan(gpa, &rs, nothing.predicate());
defer gpa.free(order);
try testing.expectEqual(@as(usize, 3), order.len);
try expectParentsFirst(&rs, order);
// Deterministic and oldest-first on a linear chain.
try testing.expectEqualStrings(h.n('a'), ms.store().hashOf(order[0]));
try testing.expectEqualStrings(h.n('c'), ms.store().hashOf(order[2]));
}
test "git export planner: fork, merge, octopus, and an mtime-skewed child all stay topological" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var rs = repostate.RepoState.init(gpa, ms.store());
defer rs.deinit();
_ = try rs.crosslink(.{ .hash = h.n('a'), .mtime = 10, .tags = &.{
.{ .type = .propagate, .name = "sym-trunk", .target = null, .value = "trunk" },
} });
_ = try rs.crosslink(.{ .hash = h.n('b'), .mtime = 20, .parents = &.{h.n('a')} });
_ = try rs.crosslink(.{ .hash = h.n('c'), .mtime = 21, .parents = &.{h.n('a')} });
_ = try rs.crosslink(.{ .hash = h.n('d'), .mtime = 22, .parents = &.{h.n('a')} });
// Octopus-ish merge of three lines.
_ = try rs.crosslink(.{ .hash = h.n('e'), .mtime = 30, .parents = &.{ h.n('b'), h.n('c'), h.n('d') } });
// Mtime-skewed child: D card EARLIER than its parent's — must still plan
// after it (topology outranks the clock).
_ = try rs.crosslink(.{ .hash = h.n('f'), .mtime = 5, .parents = &.{h.n('e')} });
var nothing = export_impl.NoneExported{};
const order = try export_impl.plan(gpa, &rs, nothing.predicate());
defer gpa.free(order);
try testing.expectEqual(@as(usize, 6), order.len);
try expectParentsFirst(&rs, order);
}
test "git export planner: a pre-seeded mark map excludes exported prefixes from the frontier" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
var rs = repostate.RepoState.init(gpa, ms.store());
defer rs.deinit();
try crosslinkChain(&rs);
// 'a' and 'b' already exported: only 'c' plans, and its recorded parent
// counts as satisfied rather than blocking.
var seeded = export_impl.SetExported{};
defer seeded.deinit(gpa);
try seeded.hashes.put(gpa, h.n('a'), {});
try seeded.hashes.put(gpa, h.n('b'), {});
const order = try export_impl.plan(gpa, &rs, seeded.predicate());
defer gpa.free(order);
try testing.expectEqual(@as(usize, 1), order.len);
try testing.expectEqualStrings(h.n('c'), ms.store().hashOf(order[0]));
}
// ---------------------------------------------------------------------------
// Seam equivalence: flat, S-card tree, and delta manifests with ONE file set.
// ---------------------------------------------------------------------------
const FixtureRepo = struct {
tmp: std.testing.TmpDir,
fs: storage.FileStorage,
log: logstore.LogStore,
raw: std.Io.File,
io: std.Io,
fn init(self: *FixtureRepo, io: std.Io) !void {
self.io = io;
self.tmp = testing.tmpDir(.{});
self.raw = try self.tmp.dir.createFile(io, ".zit", .{ .read = true });
self.fs = storage.FileStorage.init(io, self.raw, 0);
self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0);
try self.log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });
}
fn seal(self: *FixtureRepo) !void {
try self.log.commit();
self.log.deinit(testing.allocator);
self.raw.close(self.io);
}
fn cleanup(self: *FixtureRepo) void {
self.tmp.cleanup();
}
fn putRaw(self: *FixtureRepo, bytes: []const u8) ![]u8 {
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
_ = try self.log.put(&digest, bytes);
const out = try testing.allocator.alloc(u8, 64);
const digits = "0123456789abcdef";
for (&digest, 0..) |b, i| {
out[i * 2] = digits[b >> 4];
out[i * 2 + 1] = digits[b & 0x0f];
}
return out;
}
};
fn fcard(name: []const u8, hash: []const u8) artifact.FCard {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}
fn baseManifest(ts: []const u8, parent: ?[]const u8) artifact.Manifest {
_ = parent;
return .{
.arena_state = std.heap.ArenaAllocator.init(testing.allocator),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "c" },
.d = .{ .timestamp = ts },
.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 = &.{.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }},
};
}
fn collect(gpa: std.mem.Allocator, files: artifact.ManifestFiles, out: *std.ArrayList(artifact.FCard)) !void {
var it = files.iterator();
while (it.next()) |f| try out.append(gpa, f);
}
test "git export seam: flat, S-card tree, and delta manifests enumerate one identical file set through flattenTree" {
const io = testing.io;
const gpa = testing.allocator;
var repo: FixtureRepo = undefined;
try repo.init(io);
defer repo.cleanup();
// The shared file set — names diverge on the first byte so k=1 partitions.
const b1 = try repo.putRaw("alpha\n");
defer gpa.free(b1);
const b2 = try repo.putRaw("beta\n");
defer gpa.free(b2);
const b3 = try repo.putRaw("gamma\n");
defer gpa.free(b3);
const cards = [_]artifact.FCard{
fcard("adir/a.txt", b1),
fcard("bdir/b.txt", b2),
fcard("cdir/c.txt", b3),
};
// 1. FLAT manifest.
var flat_m = baseManifest("2020-01-01T00:00:00", null);
defer flat_m.deinit();
flat_m.f = &cards;
const flat_bytes = try artifact.create(&flat_m, gpa);
defer gpa.free(flat_bytes);
const flat_hash = try repo.putRaw(flat_bytes);
defer gpa.free(flat_hash);
// 2. S-card TREE over the same set: store every emitted node, then a root
// whose S run replaces the F run.
var build = try artifact.buildTree(gpa, &cards, 1, .sha3_256);
defer build.deinit();
try testing.expect(!build.isFlat());
for (build.subs) |sub| {
const name = try repo.putRaw(sub.bytes);
gpa.free(name);
}
var tree_m = baseManifest("2020-01-02T00:00:00", null);
defer tree_m.deinit();
tree_m.s = build.s;
const tree_bytes = try artifact.create(&tree_m, gpa);
defer gpa.free(tree_bytes);
const tree_hash = try repo.putRaw(tree_bytes);
defer gpa.free(tree_hash);
// 3. DELTA manifest: baseline carries two of the cards, the delta patches
// in the third — same effective set.
var base_m = baseManifest("2020-01-03T00:00:00", null);
defer base_m.deinit();
base_m.f = cards[0..2];
const base_bytes = try artifact.create(&base_m, gpa);
defer gpa.free(base_bytes);
const base_hash = try repo.putRaw(base_bytes);
defer gpa.free(base_hash);
var delta_m = baseManifest("2020-01-04T00:00:00", null);
defer delta_m.deinit();
delta_m.b = .{ .hash = base_hash };
delta_m.f = cards[2..3];
const delta_bytes = try artifact.create(&delta_m, gpa);
defer gpa.free(delta_bytes);
const delta_hash = try repo.putRaw(delta_bytes);
defer gpa.free(delta_hash);
try repo.seal();
var r: Repository = undefined;
try Repository.open(&r, gpa, io, repo.tmp.dir, .{});
defer r.deinit(gpa);
var reference: std.ArrayList(artifact.FCard) = .empty;
defer reference.deinit(gpa);
for ([_][]const u8{ flat_hash, tree_hash, delta_hash }) |hash| {
const rid = r.lookup(hash) orelse return error.TestUnexpectedResult;
var fl = (try r.files(gpa, rid)) orelse return error.TestUnexpectedResult;
defer fl.deinit(gpa);
var got: std.ArrayList(artifact.FCard) = .empty;
defer got.deinit(gpa);
var it = fl.iterator();
while (it.next()) |f| try got.append(gpa, f);
try testing.expectEqual(@as(usize, 3), got.items.len);
if (reference.items.len == 0) {
try reference.appendSlice(gpa, got.items);
// Reference cards borrow fl — dupe names for later comparison.
for (reference.items) |*card| {
card.filename = try gpa.dupe(u8, card.filename);
card.hash = try gpa.dupe(u8, card.hash.?);
}
} else {
for (reference.items, got.items) |want, gotc| {
try testing.expectEqualStrings(want.filename, gotc.filename);
try testing.expectEqualStrings(want.hash.?, gotc.hash.?);
}
}
}
for (reference.items) |card| {
gpa.free(card.filename);
gpa.free(card.hash.?);
}
reference.clearRetainingCapacity();
// The seam counter: the TREE enumeration went through flattenTree's memo
// (misses were recorded for its sub-manifests) — zero bespoke S walks.
const memo = &r.sub_memo.?;
try testing.expect(memo.misses >= 1);
}