const std = @import("std");
const zit = @import("zit");
const artifact = zit.artifact;
const editor = zit.submanifest_editor;
const fast_export = zit.git_fast_export;
const git_import = zit.git_import;
const logstore = zit.logstore;
const storage = zit.storage;
const update = zit.update;
const submanifest = zit.submanifest;
const oid_a = "1111111111111111111111111111111111111111";
const oid_b = "2222222222222222222222222222222222222222";
const oid_c = "3333333333333333333333333333333333333333";
const identity = fast_export.Identity{
.name = "Import Test",
.email = "[email protected]",
.timestamp = 1_700_000_000,
.timezone_offset_minutes = 0,
};
/// A real append-only LogStore needs only this memory-backed StorageFile in
/// tests. TreeStore then uses the same log, so blobs, submanifests, and
/// check-ins exercise their production persistence boundary together.
const MemoryFile = struct {
gpa: std.mem.Allocator,
bytes: std.ArrayList(u8) = .empty,
append_count: usize = 0,
fail_append_ordinal: ?usize = null,
fn init(gpa: std.mem.Allocator) MemoryFile {
return .{ .gpa = gpa };
}
fn deinit(self: *MemoryFile) void {
self.bytes.deinit(self.gpa);
self.* = undefined;
}
fn storageFile(self: *MemoryFile) storage.StorageFile {
return .{ .context = self, .vtable = &vtable };
}
const vtable: storage.StorageFile.VTable = .{
.append = append,
.pread = pread,
.sync = sync,
.size = size,
.truncate = truncate,
};
fn append(ctx: *anyopaque, data: []const u8) storage.StorageError!void {
const self: *MemoryFile = @ptrCast(@alignCast(ctx));
self.append_count += 1;
if (self.fail_append_ordinal) |ordinal| {
if (self.append_count == ordinal) return error.Io;
}
self.bytes.appendSlice(self.gpa, data) catch return error.Io;
}
fn pread(ctx: *anyopaque, offset: u64, out: []u8) storage.StorageError!usize {
const self: *MemoryFile = @ptrCast(@alignCast(ctx));
if (offset >= self.bytes.items.len) return 0;
const start: usize = @intCast(offset);
const n = @min(out.len, self.bytes.items.len - start);
@memcpy(out[0..n], self.bytes.items[start..][0..n]);
return n;
}
fn sync(_: *anyopaque) storage.StorageError!void {}
fn size(ctx: *anyopaque) storage.StorageError!u64 {
const self: *MemoryFile = @ptrCast(@alignCast(ctx));
return self.bytes.items.len;
}
fn truncate(ctx: *anyopaque, length: u64) storage.StorageError!void {
const self: *MemoryFile = @ptrCast(@alignCast(ctx));
const end: usize = @intCast(length);
if (end > self.bytes.items.len) return error.Io;
self.bytes.shrinkRetainingCapacity(end);
}
};
fn rawHashName(name: []const u8, buffer: *[32]u8) ?[]const u8 {
if (name.len != 40 and name.len != 64) return null;
const raw = buffer[0 .. name.len / 2];
_ = std.fmt.hexToBytes(raw, name) catch return null;
return raw;
}
const Fixture = struct {
gpa: std.mem.Allocator,
file: *MemoryFile,
log: logstore.LogStore,
tree_loads: usize = 0,
fn init(gpa: std.mem.Allocator) !Fixture {
const file = try gpa.create(MemoryFile);
errdefer gpa.destroy(file);
file.* = MemoryFile.init(gpa);
errdefer file.deinit();
const log = try logstore.LogStore.open(gpa, file.storageFile(), 0);
return .{ .gpa = gpa, .file = file, .log = log };
}
fn deinit(self: *Fixture) void {
self.log.deinit(self.gpa);
self.file.deinit();
self.gpa.destroy(self.file);
self.* = undefined;
}
fn treeStore(self: *Fixture) editor.TreeStore {
return .{ .ctx = self, .load_fn = loadTreeNode, .put_fn = putTreeNode, .put_batch_fn = putTreeNodes };
}
fn loadTreeNode(ctx: *anyopaque, allocator: std.mem.Allocator, name: []const u8) editor.StoreError!editor.LoadedBytes {
const self: *Fixture = @ptrCast(@alignCast(ctx));
self.tree_loads += 1;
const rid = self.lookupName(name) orelse return error.MissingNode;
const bytes = self.log.content(allocator, rid) catch return error.StorageFailure;
return .{ .bytes = bytes orelse return error.MissingNode, .allocator = allocator };
}
fn putTreeNode(ctx: *anyopaque, name: []const u8, bytes: []const u8) editor.StoreError!void {
const self: *Fixture = @ptrCast(@alignCast(ctx));
// Width-dispatched, so this stand-in store accepts whatever algorithm
// the import names artifacts under.
if (!artifact.verify(name, bytes)) return error.StorageFailure;
var raw_name_buf: [32]u8 = undefined;
const raw_name = rawHashName(name, &raw_name_buf) orelse return error.StorageFailure;
_ = self.log.putArtifact(raw_name, bytes, null) catch return error.StorageFailure;
}
fn putTreeNodes(ctx: *anyopaque, nodes: []const editor.StoreBatchNode) editor.StoreError!void {
const self: *Fixture = @ptrCast(@alignCast(ctx));
var digests: std.ArrayList([32]u8) = .empty;
defer digests.deinit(self.gpa);
var items: std.ArrayList(logstore.LogStore.ArtifactBatchItem) = .empty;
defer items.deinit(self.gpa);
digests.ensureTotalCapacity(self.gpa, nodes.len) catch return error.OutOfMemory;
items.ensureTotalCapacity(self.gpa, nodes.len) catch return error.OutOfMemory;
for (nodes) |node| {
// `verify` reads the algorithm off the name's own width, so this
// stand-in store follows whatever the import mints rather than
// pinning SHA-1 and rejecting every SHA3-256 name.
if (!artifact.verify(node.name, node.bytes)) return error.StorageFailure;
var digest: [32]u8 = undefined;
const raw = rawHashName(node.name, &digest) orelse return error.StorageFailure;
digests.appendAssumeCapacity(digest);
items.appendAssumeCapacity(.{
.hash = digests.items[digests.items.len - 1][0..raw.len],
.bytes = node.bytes,
});
}
self.log.putArtifactBatch(items.items) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.StorageFailure,
};
}
fn lookupName(self: *const Fixture, name: []const u8) ?logstore.Rid {
var raw_name_buf: [32]u8 = undefined;
const raw_name = rawHashName(name, &raw_name_buf) orelse return null;
return self.log.lookup(raw_name);
}
};
const OwnedManifest = struct {
bytes: []u8,
manifest: artifact.Manifest,
fn deinit(self: *OwnedManifest, gpa: std.mem.Allocator) void {
self.manifest.deinit();
gpa.free(self.bytes);
self.* = undefined;
}
};
fn loadManifest(fixture: *Fixture, gpa: std.mem.Allocator, hash: []const u8) !OwnedManifest {
const rid = fixture.lookupName(hash) orelse return error.TestUnexpectedResult;
const bytes = (try fixture.log.content(gpa, rid)) orelse return error.TestUnexpectedResult;
errdefer gpa.free(bytes);
const manifest = try artifact.parse(gpa, bytes);
return .{ .bytes = bytes, .manifest = manifest };
}
fn blob(mark: u64, original_oid: ?[]const u8, data: []const u8) fast_export.Event {
return .{ .blob = .{ .mark = mark, .original_oid = original_oid, .data = data } };
}
fn commit(
mark: u64,
original_oid: ?[]const u8,
from: ?fast_export.ObjectRef,
merges: []const fast_export.ObjectRef,
commands: []const fast_export.FileCommand,
message: []const u8,
) fast_export.Event {
return .{ .commit = .{
.ref = "refs/heads/main",
.mark = mark,
.original_oid = original_oid,
.author = identity,
.committer = identity,
.message = message,
.from = from,
.merges = merges,
.commands = commands,
} };
}
const CommandSet = struct {
gpa: std.mem.Allocator,
paths: std.ArrayList([]u8) = .empty,
commands: std.ArrayList(fast_export.FileCommand) = .empty,
fn deinit(self: *CommandSet) void {
for (self.paths.items) |path| self.gpa.free(path);
self.paths.deinit(self.gpa);
self.commands.deinit(self.gpa);
self.* = undefined;
}
};
/// k=5 groups at least eight files. The ninth selected pathname has five zero
/// bits, therefore forms a stable leaf boundary; a one-file child change in
/// the first leaf must preserve the second leaf's artifact name.
fn twoLeafCommands(gpa: std.mem.Allocator, first_mark: u64, second_mark: u64) !CommandSet {
var set = CommandSet{ .gpa = gpa };
errdefer set.deinit();
var candidate: usize = 0;
while (set.paths.items.len < 17) : (candidate += 1) {
const count = set.paths.items.len;
try set.paths.ensureUnusedCapacity(gpa, 1);
try set.commands.ensureUnusedCapacity(gpa, 1);
const path = try std.fmt.allocPrint(gpa, "p{d:0>8}.txt", .{candidate});
if (count == 8 and artifact.zbits(path) < 5) {
gpa.free(path);
continue;
}
set.paths.appendAssumeCapacity(path);
set.commands.appendAssumeCapacity(.{ .modify = .{
.mode = .regular,
.data = .{ .mark = if (count % 2 == 0) first_mark else second_mark },
.path = path,
} });
}
return set;
}
/// The name the IMPORT would give these bytes. Tests that predict a stored
/// artifact's name must follow the import's algorithm, not a fixed one.
fn nameFor(bytes: []const u8, buffer: *[64]u8) []const u8 {
return artifact.artifactName(git_import.import_hash_algo, bytes, buffer);
}
fn rootView(root: editor.RootRun) editor.RootView {
return switch (root) {
.flat => |cards| .{ .flat = cards },
.tree => |cards| .{ .tree = cards },
};
}
fn expectRootEqual(expected: editor.RootRun, actual: *const artifact.Manifest) !void {
switch (expected) {
.flat => |cards| {
try std.testing.expectEqual(@as(usize, 0), actual.s.len);
try std.testing.expectEqual(cards.len, actual.f.len);
for (cards, actual.f) |want, got| {
try std.testing.expectEqualStrings(want.filename, got.filename);
try std.testing.expectEqualStrings(want.hash.?, got.hash.?);
try std.testing.expectEqual(want.mode, got.mode);
}
},
.tree => |cards| {
try std.testing.expectEqual(@as(usize, 0), actual.f.len);
try std.testing.expectEqual(cards.len, actual.s.len);
for (cards, actual.s) |want, got| {
try std.testing.expectEqualStrings(want.filename, got.filename);
try std.testing.expectEqualStrings(want.hash, got.hash);
}
},
}
}
fn expectSharedSubmanifest(before: *const artifact.Manifest, after: *const artifact.Manifest) !void {
try std.testing.expect(before.s.len >= 2);
try std.testing.expect(after.s.len >= 2);
for (before.s) |old| for (after.s) |new| {
if (std.mem.eql(u8, old.hash, new.hash)) return;
};
return error.TestUnexpectedResult;
}
fn expectParents(manifest: *const artifact.Manifest, expected: []const []const u8) !void {
try std.testing.expectEqual(@as(usize, 1), manifest.p.len);
const parents = manifest.p[0];
try std.testing.expectEqual(expected.len, parents.count);
var it = parents.hashes();
for (expected) |want| {
const got = it.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(want, got);
}
try std.testing.expect(it.next() == null);
}
fn findFile(manifest: *const artifact.Manifest, path: []const u8) !artifact.FCard {
for (manifest.f) |card| {
if (std.mem.eql(u8, card.filename, path)) return card;
}
return error.TestUnexpectedResult;
}
/// Keeps every resolver result alive for the duration of a `flattenTree` call:
/// the artifact API borrows the slices it is handed.
const SubTrees = struct {
gpa: std.mem.Allocator,
fixture: *Fixture,
loaded: std.ArrayList(OwnedManifest) = .empty,
fn deinit(self: *SubTrees) void {
for (self.loaded.items) |*item| item.deinit(self.gpa);
self.loaded.deinit(self.gpa);
self.* = undefined;
}
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView {
const self: *SubTrees = @ptrCast(@alignCast(ctx));
var loaded = loadManifest(self.fixture, self.gpa, hash) catch return null;
self.loaded.append(self.gpa, loaded) catch {
loaded.deinit(self.gpa);
return null;
};
const item = &self.loaded.items[self.loaded.items.len - 1];
return .{ .files = item.manifest.f, .subs = item.manifest.s };
}
};
/// Every file in a check-in, whatever root shape the importer chose. `subs`
/// must outlive the returned slice.
fn flatFiles(manifest: *artifact.Manifest, subs: *SubTrees) ![]const artifact.FCard {
if (manifest.s.len == 0) return manifest.f;
try manifest.flattenTree(.{ .ctx = subs, .resolveFn = SubTrees.resolve });
return manifest.flattened.?;
}
fn expectNoFile(files: []const artifact.FCard, path: []const u8) !void {
for (files) |card| {
if (std.mem.eql(u8, card.filename, path)) return error.TestUnexpectedResult;
}
}
test "git import and update share reserved path admission" {
const Case = struct { path: []const u8, reserved: bool };
const cases = [_]Case{
.{ .path = ".ZiT-Checkout", .reserved = true },
.{ .path = ".ZiT-CaChE/repo", .reserved = true },
.{ .path = ".zit-tmp-import-user/file", .reserved = true },
.{ .path = ".zit-checkout-user/file", .reserved = false },
// The deleted legacy tier: ordinary admissible import content now.
.{ .path = ".zit-import-map.jsonl", .reserved = false },
.{ .path = "ReFs/heads/main", .reserved = false },
.{ .path = ".zit-update-txn-user/file", .reserved = false },
.{ .path = ".zit-update-txn", .reserved = false },
.{ .path = ".zit-update-txns/user", .reserved = false },
};
for (cases) |case_| {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "reserved"));
const before = fixture.log.entries.items.len;
const commands = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 1 },
.path = case_.path,
} }};
const update_rejected = blk: {
update.validateWorktreePath(case_.path) catch |err| switch (err) {
error.ReservedPath => break :blk true,
else => return err,
};
break :blk false;
};
const importer_rejected = blk: {
importer.ingest(commit(10, null, null, &.{}, &commands, "reserved")) catch |err| {
try std.testing.expectEqual(error.ReservedPath, err);
break :blk true;
};
break :blk false;
};
try std.testing.expectEqual(case_.reserved, update_rejected);
try std.testing.expectEqual(update_rejected, importer_rejected);
if (importer_rejected) {
try std.testing.expectEqual(before, fixture.log.entries.items.len);
} else {
try std.testing.expect(fixture.log.entries.items.len > before);
}
}
}
test "git import rejects every malformed update worktree path before artifacts or mappings" {
const cases = [_][]const u8{
".",
"..",
"a//b",
"a/./b",
"a/../b",
"a\\b",
"/absolute",
};
for (cases) |path| {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "malformed"));
const before = fixture.log.entries.items.len;
const commands = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 1 },
.path = path,
} }};
try std.testing.expectError(error.InvalidPath, update.validateWorktreePath(path));
try std.testing.expectError(error.InvalidPath, importer.ingest(commit(10, null, null, &.{}, &commands, "malformed")));
try std.testing.expectEqual(before, fixture.log.entries.items.len);
try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10));
}
}
test "git import work counters report observed tree height" {
const gpa = std.testing.allocator;
{
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(commit(10, null, null, &.{}, &.{}, "flat"));
try std.testing.expectEqual(@as(u8, 0), importer.workCounters().max_tree_height);
}
var heights: [2]u8 = undefined;
for ([_]u4{ 5, 1 }, &heights) |k, *height| {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{
.submanifest = .{ .tree = k },
});
defer importer.deinit();
try importer.ingest(blob(1, null, "first"));
try importer.ingest(blob(2, null, "second"));
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
try importer.ingest(commit(10, null, null, &.{}, commands.commands.items, "tree"));
height.* = importer.workCounters().max_tree_height;
try std.testing.expect(height.* != 0);
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &.{}, "unchanged tree"));
try std.testing.expectEqual(height.*, importer.workCounters().max_tree_height);
}
try std.testing.expect(heights[1] > heights[0]);
}
test "git import stores blobs before check-ins and canonically reuses an unchanged submanifest" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
try importer.ingest(blob(1, oid_a, "first blob bytes"));
try importer.ingest(blob(2, oid_b, "second blob bytes"));
try importer.ingest(commit(10, oid_c, null, &.{}, commands.commands.items, "root"));
const root_hash = try importer.objectForMark(10);
var root = try loadManifest(&fixture, gpa, root_hash);
defer root.deinit(gpa);
try std.testing.expectEqual(artifact.ArtifactType.manifest, root.manifest.kind);
try std.testing.expect(root.manifest.z != null);
try std.testing.expect(root.manifest.s.len >= 2);
var first_blob_name_buf: [64]u8 = undefined;
var second_blob_name_buf: [64]u8 = undefined;
const first_blob_hash = nameFor("first blob bytes", &first_blob_name_buf);
const second_blob_hash = nameFor("second blob bytes", &second_blob_name_buf);
try std.testing.expectEqualStrings(first_blob_hash, try importer.objectForMark(1));
try std.testing.expectEqualStrings(second_blob_hash, try importer.objectForOid(oid_b));
try std.testing.expect((fixture.lookupName(first_blob_hash) orelse return error.TestUnexpectedResult) <
(fixture.lookupName(root_hash) orelse return error.TestUnexpectedResult));
for (root.manifest.s) |sub| {
try std.testing.expect((fixture.lookupName(sub.hash) orelse return error.TestUnexpectedResult) <
(fixture.lookupName(root_hash) orelse return error.TestUnexpectedResult));
}
var expected_fixture = try Fixture.init(gpa);
defer expected_fixture.deinit();
var expected_editor = try editor.TreeEditor.init(gpa, expected_fixture.treeStore(), .{
// The oracle must name artifacts the way the import does, or it
// compares two different address spaces.
.hash_algo = git_import.import_hash_algo,
.shape = .{ .tree = .{ .k = 5, .height_max = 16 } },
}, 1 << 20);
defer expected_editor.deinit();
var expected_commands: std.ArrayList(editor.TreeCommand) = .empty;
defer expected_commands.deinit(gpa);
for (commands.paths.items, 0..) |path, index| {
try expected_commands.append(gpa, .{ .upsert = .{
.filename = path,
.hash = if (index % 2 == 0) first_blob_hash else second_blob_hash,
.perm = null,
.old_name = null,
.mode = .regular,
} });
}
var expected_root = try expected_editor.apply(.{ .flat = &.{} }, expected_commands.items);
defer expected_root.deinit();
try expectRootEqual(expected_root.root, &root.manifest);
const child_command = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 2 },
.path = commands.paths.items[0],
} }};
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &child_command, "child changes one file"));
const child_hash = try importer.objectForMark(11);
var child = try loadManifest(&fixture, gpa, child_hash);
defer child.deinit(gpa);
try expectParents(&child.manifest, &.{root_hash});
try expectSharedSubmanifest(&root.manifest, &child.manifest);
var expected_child = try expected_editor.apply(rootView(expected_root.root), &.{.{ .upsert = .{
.filename = commands.paths.items[0],
.hash = second_blob_hash,
.perm = null,
.old_name = null,
.mode = .regular,
} }});
defer expected_child.deinit();
try expectRootEqual(expected_child.root, &child.manifest);
}
test "git import de-duplicates matching blobs and rejects conflicting or unresolved identities" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, oid_a, "identical bytes"));
try importer.ingest(blob(2, oid_b, "identical bytes"));
const hash_one = try importer.objectForMark(1);
try std.testing.expectEqualStrings(hash_one, try importer.objectForMark(2));
try std.testing.expectEqualStrings(hash_one, try importer.objectForOid(oid_a));
try std.testing.expectEqualStrings(hash_one, try importer.objectForOid(oid_b));
try std.testing.expectEqual(@as(usize, 1), fixture.log.entries.items.len);
try importer.ingest(blob(3, oid_c, "first OID body"));
try std.testing.expectError(error.ConflictingOid, importer.ingest(blob(4, oid_c, "different OID body")));
try std.testing.expectError(error.DuplicateMark, importer.ingest(blob(1, null, "duplicate mark")));
try importer.ingest(commit(10, null, null, &.{}, &.{}, "root"));
try std.testing.expectError(error.DuplicateMark, importer.ingest(commit(10, null, null, &.{}, &.{}, "duplicate commit mark")));
const unresolved_blob = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 999 },
.path = "missing.txt",
} }};
try std.testing.expectError(error.UnresolvedMark, importer.ingest(commit(11, null, null, &.{}, &unresolved_blob, "missing blob")));
try std.testing.expectError(error.UnresolvedMark, importer.ingest(commit(12, null, .{ .mark = 998 }, &.{}, &.{}, "missing parent")));
}
test "git import preserves ordered parents while empty commits retain the canonical root" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(commit(100, null, null, &.{}, &.{}, "root metadata"));
try importer.ingest(commit(101, null, .{ .mark = 100 }, &.{}, &.{}, "empty child metadata"));
try importer.ingest(commit(102, null, .{ .mark = 100 }, &.{}, &.{}, "other parent metadata"));
try importer.ingest(commit(103, null, .{ .mark = 101 }, &.{.{ .mark = 102 }}, &.{}, "merge metadata"));
try importer.ingest(commit(104, null, .{ .mark = 103 }, &.{ .{ .mark = 101 }, .{ .mark = 102 }, .{ .mark = 100 } }, &.{}, "octopus metadata"));
const root_hash = try importer.objectForMark(100);
const child_hash = try importer.objectForMark(101);
const other_hash = try importer.objectForMark(102);
const merge_hash = try importer.objectForMark(103);
const octopus_hash = try importer.objectForMark(104);
var root = try loadManifest(&fixture, gpa, root_hash);
defer root.deinit(gpa);
var child = try loadManifest(&fixture, gpa, child_hash);
defer child.deinit(gpa);
var merge = try loadManifest(&fixture, gpa, merge_hash);
defer merge.deinit(gpa);
var octopus = try loadManifest(&fixture, gpa, octopus_hash);
defer octopus.deinit(gpa);
try std.testing.expect(!std.mem.eql(u8, root.bytes, child.bytes));
try std.testing.expect(!std.mem.eql(u8, child.bytes, merge.bytes));
try expectRootEqual(.{ .flat = root.manifest.f }, &child.manifest);
try expectParents(&child.manifest, &.{root_hash});
try expectParents(&merge.manifest, &.{ child_hash, other_hash });
try expectParents(&octopus.manifest, &.{ merge_hash, child_hash, other_hash, root_hash });
}
test "git import translates Git file modes and classifies a submodule as a gitlink" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try std.testing.expectEqual(fast_export.FileMode.regular, try git_import.modeFromGit(0o100644));
try std.testing.expectEqual(fast_export.FileMode.executable, try git_import.modeFromGit(0o100755));
try std.testing.expectEqual(fast_export.FileMode.symlink, try git_import.modeFromGit(0o120000));
// A submodule is classified, not refused. Dropping it is import policy and
// lives in the translator; only modes Git never emits are errors here.
try std.testing.expectEqual(fast_export.FileMode.gitlink, try git_import.modeFromGit(0o160000));
try std.testing.expectError(error.UnknownMode, git_import.modeFromGit(0o040000));
try importer.ingest(blob(1, null, "regular"));
try importer.ingest(blob(2, null, "executable"));
try importer.ingest(blob(3, null, "symlink"));
const commands = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "regular.txt" } },
.{ .modify = .{ .mode = .executable, .data = .{ .mark = 2 }, .path = "run" } },
.{ .modify = .{ .mode = .symlink, .data = .{ .mark = 3 }, .path = "link" } },
// No blob is ever declared for this OID: a submodule entry must be
// omitted without its target being resolved at all.
.{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } },
};
try importer.ingest(commit(10, null, null, &.{}, &commands, "modes"));
var manifest = try loadManifest(&fixture, gpa, try importer.objectForMark(10));
defer manifest.deinit(gpa);
try std.testing.expectEqual(artifact.FileMode.regular, (try findFile(&manifest.manifest, "regular.txt")).mode);
try std.testing.expectEqual(artifact.FileMode.executable, (try findFile(&manifest.manifest, "run")).mode);
try std.testing.expectEqual(artifact.FileMode.symlink, (try findFile(&manifest.manifest, "link")).mode);
try expectNoFile(manifest.manifest.f, "nested");
try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries);
}
test "git import accepts a later delete, rename, or copy of an omitted submodule path" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "kept"));
const seeded = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } },
.{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } },
};
try importer.ingest(commit(10, null, null, &.{}, &seeded, "submodule added"));
// Git dropping the submodule names a path the zit tree never had. That is
// not a missing path; it is the omission this import already made.
const removed = [_]fast_export.FileCommand{.{ .delete = "nested" }};
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &removed, "submodule removed"));
// Same for a move or a copy whose source is the omitted path: the
// destination inherits the omission instead of failing.
const moved = [_]fast_export.FileCommand{.{ .rename = .{ .old = "nested", .new = "moved" } }};
try importer.ingest(commit(12, null, .{ .mark = 10 }, &.{}, &moved, "submodule moved"));
const copied = [_]fast_export.FileCommand{.{ .copy = .{ .old = "nested", .new = "copied" } }};
try importer.ingest(commit(13, null, .{ .mark = 10 }, &.{}, &copied, "submodule copied"));
for ([_]u64{ 11, 12, 13 }) |mark| {
var manifest = try loadManifest(&fixture, gpa, try importer.objectForMark(mark));
defer manifest.deinit(gpa);
try std.testing.expectEqual(@as(usize, 1), manifest.manifest.f.len);
_ = try findFile(&manifest.manifest, "kept.txt");
}
// The tolerance reaches submodule paths only. A delete of a path nothing
// ever declared still fails, so a genuine missing-path bug still surfaces.
const bogus = [_]fast_export.FileCommand{.{ .delete = "never-existed" }};
try std.testing.expectError(
error.MissingPath,
importer.ingest(commit(20, null, .{ .mark = 10 }, &.{}, &bogus, "bogus delete")),
);
// One entry omitted at :10, one re-omitted at each of the move and copy
// destinations.
try std.testing.expectEqual(@as(u64, 3), importer.workCounters().skipped_submodule_entries);
}
test "git import writes a path from the commit where it stops being a submodule" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "kept"));
try importer.ingest(blob(2, null, "no longer a submodule"));
const seeded = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } },
.{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } },
};
try importer.ingest(commit(10, null, null, &.{}, &seeded, "submodule added"));
const replaced = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "nested" } },
};
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &replaced, "submodule became a file"));
var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11));
defer after.deinit(gpa);
var name_buf: [64]u8 = undefined;
try std.testing.expectEqualStrings(
nameFor("no longer a submodule", &name_buf),
(try findFile(&after.manifest, "nested")).hash.?,
);
// And once it is a real file, deleting it is a real delete: the recorded
// submodule path must not swallow a delete of the file that replaced it.
const deleted = [_]fast_export.FileCommand{.{ .delete = "nested" }};
try importer.ingest(commit(12, null, .{ .mark = 11 }, &.{}, &deleted, "file removed"));
var last = try loadManifest(&fixture, gpa, try importer.objectForMark(12));
defer last.deinit(gpa);
try std.testing.expectEqual(@as(usize, 1), last.manifest.f.len);
_ = try findFile(&last.manifest, "kept.txt");
}
test "git import removes a file that a later commit turns into a submodule" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "kept"));
try importer.ingest(blob(2, null, "a real file for now"));
const seeded = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } },
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "nested" } },
};
try importer.ingest(commit(10, null, null, &.{}, &seeded, "file added"));
const submoduled = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } },
};
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &submoduled, "file became a submodule"));
var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11));
defer after.deinit(gpa);
try expectNoFile(after.manifest.f, "nested");
_ = try findFile(&after.manifest, "kept.txt");
try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries);
}
test "git import removes a submodule path out of a tree-shaped parent root" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
// A tree-shaped root is the case where the parent lookup has to read stored
// sub-manifests, so it is the one that proves the lookup is bound to a live
// store rather than the previous commit's staging store.
try importer.ingest(blob(1, null, "first"));
try importer.ingest(blob(2, null, "second"));
var set = try twoLeafCommands(gpa, 1, 2);
defer set.deinit();
try importer.ingest(commit(10, null, null, &.{}, set.commands.items, "wide root"));
var seeded = try loadManifest(&fixture, gpa, try importer.objectForMark(10));
defer seeded.deinit(gpa);
try std.testing.expect(seeded.manifest.s.len != 0);
const target = set.paths.items[0];
const submoduled = [_]fast_export.FileCommand{
.{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = target } },
};
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &submoduled, "one path became a submodule"));
var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11));
defer after.deinit(gpa);
var subs = SubTrees{ .gpa = gpa, .fixture = &fixture };
defer subs.deinit();
const files = try flatFiles(&after.manifest, &subs);
try std.testing.expectEqual(set.paths.items.len - 1, files.len);
try expectNoFile(files, target);
// Deleting the now-omitted path out of the same tree-shaped root still has
// to be accepted.
const deleted = [_]fast_export.FileCommand{.{ .delete = target }};
try importer.ingest(commit(12, null, .{ .mark = 11 }, &.{}, &deleted, "submodule removed"));
try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries);
}
test "git import accepts a real Git no-from reset before its first commit" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
// `git fast-export --all` emits this reset without a blank separator before
// the first commit. Reader must replay that commit, rather than consume it
// as reset metadata, and the importer must accept the resulting null reset.
const stream =
"feature done\n" ++
"blob\nmark :1\noriginal-oid debade3ceb7f902612ff347334ca7dc4357c7292\ndata 12\nlinear root\n\n" ++
"blob\nmark :2\noriginal-oid 100b93820ade4c16225673b4ca62bb3ade63c313\ndata 6\nREADME\n" ++
"blob\nmark :3\noriginal-oid 9aaec01376f8c39683df3f230b972ff6871ad293\ndata 19\n#!/bin/sh\necho zit\n\n" ++
"blob\nmark :4\noriginal-oid fa48ac16de6179989a8ab8fa2b82d4d10cba9cb9\ndata 13\nunusual path\n\n" ++
"reset refs/heads/main\n" ++
"commit refs/heads/main\nmark :5\noriginal-oid ed9b38a10070b504cecfacd892507af78fcb24a4\n" ++
"author Git Author <[email protected]> 981173106 +0000\n" ++
"committer Git Committer <[email protected]> 981173106 +0000\n" ++
"data 12\nlinear root\n" ++
"M 120000 :2 README.link\nM 100644 :1 README\nM 100755 :3 bin/run\nM 100644 :4 \"odd/name with space\\tand-tab\"\n\n" ++
"done\n";
try importer.ingestFastExport(stream, .{ .max_line = 1024, .max_data = 1024, .max_path = 1024, .max_events = std.math.maxInt(u64) });
_ = try importer.objectForMark(5);
}
test "git import skips a worktree pseudo-ref and still refuses a malformed one" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
// `git fast-export --all` emits `reset worktrees/<name>/HEAD` for every
// detached-HEAD linked worktree in the source -- Git's own bookkeeping,
// not a ref of the history. Before this was skipped, one `git worktree
// add --detach` in the source aborted the entire import with InvalidRef
// after all the real work had already been done.
const stream =
"feature done\n" ++
"blob\nmark :1\ndata 12\nlinear root\n\n" ++
"commit refs/heads/main\nmark :2\n" ++
"author Git Author <[email protected]> 981173106 +0000\n" ++
"committer Git Committer <[email protected]> 981173106 +0000\n" ++
"data 4\nroot\n" ++
"M 100644 :1 README\n\n" ++
"reset worktrees/wt-test/HEAD\nfrom :2\n\n" ++
"done\n";
const limits = fast_export.Limits{ .max_line = 1024, .max_data = 1024, .max_path = 1024, .max_events = std.math.maxInt(u64) };
try importer.ingestFastExport(stream, limits);
_ = try importer.objectForMark(2);
try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_foreign_refs);
// The skip is on the `refs/` prefix alone. A well-formed ref that escapes
// its own namespace is malformed, not foreign, and must still abort --
// otherwise the skip would have widened `canonicalRef`'s traversal
// refusal into silence.
var strict = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer strict.deinit();
try std.testing.expectError(error.InvalidRef, strict.ingest(.{ .reset = .{
.ref = "refs/heads/../../evil",
.target = null,
} }));
try std.testing.expectEqual(@as(u64, 0), strict.workCounters().skipped_foreign_refs);
}
test "git import retains exact commit identity while blob marks cannot become parents" {
const gpa = std.testing.allocator;
const resolved = try git_import.resolveOptions(.{});
try std.testing.expectEqual(@as(u4, 5), resolved.shape.tree.k);
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "a blob is not a commit parent"));
try std.testing.expectError(
error.UnresolvedMark,
importer.ingest(commit(2, null, .{ .mark = 1 }, &.{}, &.{}, "blob parent")),
);
const same_commit = fast_export.Commit{
.ref = "refs/heads/main",
.mark = 10,
.original_oid = null,
.author = identity,
.committer = identity,
.message = "same check-in on two source refs",
.from = null,
.merges = &.{},
.commands = &.{},
};
try importer.ingest(.{ .commit = same_commit });
var feature_commit = same_commit;
feature_commit.ref = "refs/heads/feature";
feature_commit.mark = 11;
try importer.ingest(.{ .commit = feature_commit });
try std.testing.expectEqualStrings(
try importer.objectForMark(10),
try importer.objectForMark(11),
);
try importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 12,
.original_oid = null,
.author = identity,
.committer = identity,
.message = "main follows the feature identity",
.from = .{ .mark = 11 },
.merges = &.{},
.commands = &.{},
} });
const child_hash = try importer.objectForMark(12);
const root_hash = try importer.objectForMark(10);
var refs = try importer.finalizeRefs(.{});
defer refs.deinit(gpa);
// A branch control marks a segment's ROOT; every check-in above it inherits
// the branch by propagation (repo-state §5.4). `main` here runs from the
// parentless check-in the two source refs share up to the child, so its one
// control targets that root — not the child, which merely continued the
// branch under a ref fast-export happened to switch back to (issue #26).
var found_root_branch = false;
var found_child_branch = false;
for (refs.controls) |bytes| {
var control = try artifact.parse(gpa, bytes);
defer control.deinit();
for (control.t) |card| {
if (!std.mem.eql(u8, card.name, "branch")) continue;
// `trunk`, not `main`: the default branch is relabelled on import,
// so the branch card carries the destination's name. Which SOURCE
// ref it came from is recorded separately, by the canonical tag.
if (!std.mem.eql(u8, card.value.?, "trunk")) continue;
if (std.mem.eql(u8, card.target.?, root_hash)) found_root_branch = true;
if (std.mem.eql(u8, card.target.?, child_hash)) found_child_branch = true;
}
}
try std.testing.expect(found_root_branch);
try std.testing.expect(!found_child_branch);
}
// Task 8 blockers: attribution, identity canonicalization, tag-mark
// reservation, pre-epoch dates, and failure atomicity at the persistence edge.
test "git import attributes D and U cards to the selected identity" {
const gpa = std.testing.allocator;
const author = fast_export.Identity{
.name = "Author",
.email = "[email protected]",
.timestamp = 111,
.timezone_offset_minutes = 0,
};
const committer = fast_export.Identity{
.name = "Committer",
.email = "[email protected]",
.timestamp = 222,
.timezone_offset_minutes = 0,
};
var author_fixture = try Fixture.init(gpa);
defer author_fixture.deinit();
var author_importer = try git_import.Importer.init(gpa, &author_fixture.log, author_fixture.treeStore(), .{
.attribution = .author,
});
defer author_importer.deinit();
try author_importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 10,
.original_oid = null,
.author = author,
.committer = committer,
.message = "author attribution",
.from = null,
.merges = &.{},
.commands = &.{},
} });
var authored = try loadManifest(&author_fixture, gpa, try author_importer.objectForMark(10));
defer authored.deinit(gpa);
var default_fixture = try Fixture.init(gpa);
defer default_fixture.deinit();
var default_importer = try git_import.Importer.init(gpa, &default_fixture.log, default_fixture.treeStore(), .{});
defer default_importer.deinit();
try default_importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 10,
.original_oid = null,
.author = author,
.committer = committer,
.message = "committer attribution",
.from = null,
.merges = &.{},
.commands = &.{},
} });
var committed = try loadManifest(&default_fixture, gpa, try default_importer.objectForMark(10));
defer committed.deinit(gpa);
try std.testing.expectEqualStrings("Author", authored.manifest.u.?.login);
try std.testing.expectEqualStrings("Committer", committed.manifest.u.?.login);
try std.testing.expect(!std.mem.eql(u8, authored.manifest.d.?.timestamp, committed.manifest.d.?.timestamp));
}
test "git import canonicalizes OID spelling before deduplication and conflict detection" {
const gpa = std.testing.allocator;
const lower = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const upper = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, lower, "same OID bytes"));
try std.testing.expectEqualStrings(try importer.objectForOid(lower), try importer.objectForOid(upper));
try std.testing.expectError(error.ConflictingOid, importer.ingest(blob(2, upper, "different bytes")));
}
test "git import reserves annotated tag marks in the shared object namespace" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(commit(10, null, null, &.{}, &.{}, "tag target"));
try importer.ingest(.{ .tag = .{
.name = "v1",
.mark = 20,
.target = .{ .mark = 10 },
.original_oid = null,
.tagger = identity,
.message = "release",
} });
try std.testing.expectError(error.DuplicateMark, importer.ingest(blob(20, null, "tag mark collision")));
try std.testing.expectError(error.DuplicateMark, importer.ingest(commit(20, null, null, &.{}, &.{}, "tag mark collision")));
}
test "git import serializes pre-epoch timestamps distinctly from the epoch" {
const gpa = std.testing.allocator;
const before_epoch = fast_export.Identity{
.name = "Before Epoch",
.email = "[email protected]",
.timestamp = -1,
.timezone_offset_minutes = 0,
};
const at_epoch = fast_export.Identity{
.name = "At Epoch",
.email = "[email protected]",
.timestamp = 0,
.timezone_offset_minutes = 0,
};
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 10,
.original_oid = null,
.author = before_epoch,
.committer = before_epoch,
.message = "before epoch",
.from = null,
.merges = &.{},
.commands = &.{},
} });
try importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 11,
.original_oid = null,
.author = at_epoch,
.committer = at_epoch,
.message = "at epoch",
.from = null,
.merges = &.{},
.commands = &.{},
} });
const before_hash = try importer.objectForMark(10);
const epoch_hash = try importer.objectForMark(11);
var before = try loadManifest(&fixture, gpa, before_hash);
defer before.deinit(gpa);
var epoch = try loadManifest(&fixture, gpa, epoch_hash);
defer epoch.deinit(gpa);
try std.testing.expectEqualStrings("1969-12-31T23:59:59", before.manifest.d.?.timestamp);
try std.testing.expectEqualStrings("1970-01-01T00:00:00", epoch.manifest.d.?.timestamp);
try std.testing.expect(!std.mem.eql(u8, before_hash, epoch_hash));
}
test "git import rejects conflicting commit OIDs before persisting a candidate tree or check-in" {
const gpa = std.testing.allocator;
const shared_oid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "first tree body"));
try importer.ingest(blob(2, null, "conflicting tree body"));
var root_commands = try twoLeafCommands(gpa, 1, 2);
defer root_commands.deinit();
try importer.ingest(commit(10, shared_oid, null, &.{}, root_commands.commands.items, "original commit"));
const entries_before = fixture.log.entries.items.len;
const end_before = fixture.log.end;
const frames_before = fixture.log.frame_count;
const tail_before = fixture.log.last_frame_offset;
const conflicting_commands = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 2 },
.path = root_commands.paths.items[0],
} }};
try std.testing.expectError(error.ConflictingOid, importer.ingest(commit(
11,
shared_oid,
null,
&.{},
&conflicting_commands,
"conflicting commit",
)));
try std.testing.expectEqual(entries_before, fixture.log.entries.items.len);
try std.testing.expectEqual(end_before, fixture.log.end);
try std.testing.expectEqual(frames_before, fixture.log.frame_count);
try std.testing.expectEqual(tail_before, fixture.log.last_frame_offset);
}
fn expectMainRef(importer: *git_import.Importer, gpa: std.mem.Allocator, expected: []const u8) !void {
var refs = try importer.finalizeRefs(.{});
defer refs.deinit(gpa);
for (refs.refs) |mapping| {
if (std.mem.eql(u8, mapping.source, "refs/heads/main")) {
try std.testing.expectEqualStrings(expected, mapping.target);
return;
}
}
return error.TestUnexpectedResult;
}
fn expectNoPublishedMainRef(importer: *git_import.Importer, gpa: std.mem.Allocator) !void {
var refs = importer.finalizeRefs(.{}) catch |err| {
// A failed first commit has no branch to choose as primary. This
// intentional finalization precondition proves no main ref published.
try std.testing.expectEqual(error.InvalidPrimaryBranch, err);
return;
};
defer refs.deinit(gpa);
try std.testing.expectEqual(@as(usize, 0), refs.refs.len);
return error.TestUnexpectedResult;
}
fn visibleCheckinCount(fixture: *Fixture, gpa: std.mem.Allocator) !usize {
var count: usize = 0;
for (1..fixture.log.entries.items.len + 1) |ordinal| {
const rid: logstore.Rid = @intCast(ordinal);
const bytes = (try fixture.log.content(gpa, rid)) orelse continue;
defer gpa.free(bytes);
var manifest = artifact.parse(gpa, bytes) catch continue;
defer manifest.deinit();
if (manifest.kind == .manifest) count += 1;
}
return count;
}
test "git import does not consume a tag mark when its target is unresolved" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try std.testing.expectError(error.UnresolvedMark, importer.ingest(.{ .tag = .{
.name = "unresolved",
.mark = 20,
.target = .{ .mark = 999 },
.original_oid = null,
.tagger = identity,
.message = "must not reserve",
} }));
try importer.ingest(blob(20, null, "the failed tag mark remains available"));
_ = try importer.objectForMark(20);
}
test "git import tag-mark allocation failures leave the mark reusable" {
var fail_index: usize = 0;
var saw_tag_failure = false;
while (true) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index });
const gpa = failing.allocator();
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
};
defer importer.deinit();
if (importer.ingest(commit(10, null, null, &.{}, &.{}, "tag target"))) |_| {} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
}
const tag = fast_export.Event{ .tag = .{
.name = "v1",
.mark = 20,
.target = .{ .mark = 10 },
.original_oid = null,
.tagger = identity,
.message = "release",
} };
if (importer.ingest(tag)) |_| {
if (!failing.has_induced_failure) break;
return error.TestUnexpectedResult;
} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
failing.fail_index = std.math.maxInt(usize);
saw_tag_failure = true;
try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(20));
try importer.ingest(blob(20, null, "the OOM tag mark remains available"));
}
}
try std.testing.expect(saw_tag_failure);
}
test "git import setRef replacement OOM retains the old target and retry replaces it" {
var fail_index: usize = 0;
var saw_replacement_failure = false;
while (true) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index });
const gpa = failing.allocator();
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
};
defer importer.deinit();
if (importer.ingest(commit(10, null, null, &.{}, &.{}, "old main"))) |_| {} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
}
if (importer.ingest(.{ .commit = .{
.ref = "refs/heads/other",
.mark = 11,
.original_oid = null,
.author = identity,
.committer = identity,
.message = "new target",
.from = null,
.merges = &.{},
.commands = &.{},
} })) |_| {} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
}
const old_hash = try importer.objectForMark(10);
const new_hash = try importer.objectForMark(11);
const replacement = fast_export.Event{ .reset = .{
.ref = "refs/heads/main",
.target = .{ .mark = 11 },
} };
if (importer.ingest(replacement)) |_| {
if (!failing.has_induced_failure) break;
return error.TestUnexpectedResult;
} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
failing.fail_index = std.math.maxInt(usize);
saw_replacement_failure = true;
try expectMainRef(&importer, gpa, old_hash);
try importer.ingest(replacement);
try expectMainRef(&importer, gpa, new_hash);
}
}
try std.testing.expect(saw_replacement_failure);
}
test "git import OOM before commit publication leaves the staged tree unpublished and retry succeeds" {
const commit_oid = "cccccccccccccccccccccccccccccccccccccccc";
var fail_index: usize = 0;
var saw_candidate_failure = false;
while (true) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index });
const gpa = failing.allocator();
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
};
defer importer.deinit();
if (importer.ingest(blob(1, null, "staged tree first body"))) |_| {} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
}
if (importer.ingest(blob(2, null, "staged tree second body"))) |_| {} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
}
const entries_before_candidate = fixture.log.entries.items.len;
var commands = twoLeafCommands(gpa, 1, 2) catch |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
continue;
};
defer commands.deinit();
const candidate = commit(10, commit_oid, null, &.{}, commands.commands.items, "staged tree commit");
if (importer.ingest(candidate)) |_| {
if (!failing.has_induced_failure) break;
return error.TestUnexpectedResult;
} else |err| {
try std.testing.expectEqual(error.OutOfMemory, err);
failing.fail_index = std.math.maxInt(usize);
try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10));
try std.testing.expectError(error.UnresolvedMark, importer.objectForOid(commit_oid));
try expectNoPublishedMainRef(&importer, gpa);
const entries_after_failure = fixture.log.entries.items.len;
try std.testing.expectEqual(entries_before_candidate, entries_after_failure);
try std.testing.expectEqual(@as(usize, 0), try visibleCheckinCount(&fixture, gpa));
try importer.ingest(candidate);
const committed_hash = try importer.objectForMark(10);
try std.testing.expectEqualStrings(committed_hash, try importer.objectForOid(commit_oid));
try expectMainRef(&importer, gpa, committed_hash);
var checkin = try loadManifest(&fixture, gpa, committed_hash);
defer checkin.deinit(gpa);
try std.testing.expect(checkin.manifest.s.len >= 2);
try std.testing.expectEqual(
entries_before_candidate + checkin.manifest.s.len + 1,
fixture.log.entries.items.len,
);
saw_candidate_failure = true;
}
}
try std.testing.expect(saw_candidate_failure);
}
test "git import batch storage failures publish no candidate artifact or mapping before retry" {
const gpa = std.testing.allocator;
const commit_oid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
for (1..4) |failure_offset| {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "batch storage first body"));
try importer.ingest(blob(2, null, "batch storage second body"));
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
const candidate = commit(10, commit_oid, null, &.{}, commands.commands.items, "batch storage commit");
const entries_before = fixture.log.entries.items.len;
const end_before = fixture.log.end;
const frames_before = fixture.log.frame_count;
fixture.file.fail_append_ordinal = fixture.file.append_count + failure_offset;
try std.testing.expectError(error.StorageFailure, importer.ingest(candidate));
fixture.file.fail_append_ordinal = null;
try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10));
try std.testing.expectError(error.UnresolvedMark, importer.objectForOid(commit_oid));
try expectNoPublishedMainRef(&importer, gpa);
try std.testing.expectEqual(entries_before, fixture.log.entries.items.len);
try std.testing.expectEqual(end_before, fixture.log.end);
try std.testing.expectEqual(frames_before, fixture.log.frame_count);
try std.testing.expectEqual(@as(usize, 0), try visibleCheckinCount(&fixture, gpa));
try importer.ingest(candidate);
const committed_hash = try importer.objectForMark(10);
try std.testing.expectEqualStrings(committed_hash, try importer.objectForOid(commit_oid));
try expectMainRef(&importer, gpa, committed_hash);
var checkin = try loadManifest(&fixture, gpa, committed_hash);
defer checkin.deinit(gpa);
try std.testing.expectEqual(
entries_before + checkin.manifest.s.len + 1,
fixture.log.entries.items.len,
);
try std.testing.expectEqual(@as(usize, 1), try visibleCheckinCount(&fixture, gpa));
}
}
fn stagedCommitOomNoLeak(gpa: std.mem.Allocator) !void {
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "staged tree first body"));
try importer.ingest(blob(2, null, "staged tree second body"));
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
try importer.ingest(commit(
10,
"dddddddddddddddddddddddddddddddddddddddd",
null,
&.{},
commands.commands.items,
"staged tree leak check",
));
}
test "git import staged commit unwinds every deterministic allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, stagedCommitOomNoLeak, .{});
}
/// `n` distinct paths spread over directories, so the name set resembles a
/// real tree rather than one flat run of near-identical strings.
fn syntheticNames(arena: std.mem.Allocator, n: usize) ![]const []const u8 {
const names = try arena.alloc([]const u8, n);
for (names, 0..) |*name, index| {
name.* = try std.fmt.allocPrint(arena, "src/dir{d}/file{d}.zig", .{ index % 97, index });
}
return names;
}
test "git import analyzes a sub-manifest pin that beats the former fixed default" {
const gpa = std.testing.allocator;
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Too small to partition at all: §6.2 step 2's flat outcome must surface
// as "no opinion", never as a manufactured pin.
try std.testing.expectEqual(@as(?u4, null), try git_import.analyzeSubmanifestK(gpa, &.{}, .sha1));
try std.testing.expectEqual(@as(?u4, null), try git_import.analyzeSubmanifestK(gpa, &.{"only"}, .sha1));
// The optimum grows with the file count — the property that makes a single
// hardcoded default wrong at both ends of the range.
const small = (try git_import.analyzeSubmanifestK(gpa, try syntheticNames(arena, 64), .sha1)).?;
const large = (try git_import.analyzeSubmanifestK(gpa, try syntheticNames(arena, 16_384), .sha1)).?;
try std.testing.expect(large > small);
// And on a set far larger than the old constant was tuned for, the pin it
// chooses is genuinely cheaper under §6.3's own objective than 5 is.
const names = try syntheticNames(arena, 16_384);
const entries = try arena.alloc(artifact.FCard, names.len);
std.mem.sort([]const u8, @constCast(names), {}, struct {
fn less(_: void, a: []const u8, b: []const u8) bool {
return artifact.compareBytes(a, b) == .lt;
}
}.less);
for (entries, names) |*entry, name| {
var hash_buf: [64]u8 = undefined;
entry.* = .{
.filename = name,
.hash = try arena.dupe(u8, artifact.artifactName(.sha1, name, &hash_buf)),
.perm = null,
.old_name = null,
.mode = .regular,
};
}
const analyzed = try submanifest.cost(gpa, entries, large, .sha1);
const former_default = try submanifest.cost(gpa, entries, 5, .sha1);
try std.testing.expect(analyzed.total <= former_default.total);
}
/// The two facts `labelBranches` decides per commit, spelled out so a failing
/// expectation names the commit rather than an index into two parallel slices.
const ExpectedLabel = struct {
branch: []const u8,
is_segment_root: bool,
};
fn expectLabels(expected: []const ExpectedLabel, actual: []const git_import.BranchLabel) !void {
try std.testing.expectEqual(expected.len, actual.len);
for (expected, actual, 0..) |want, got, index| {
std.testing.expectEqualStrings(want.branch, got.branch) catch |err| {
std.debug.print("commit {d}: branch label mismatch\n", .{index});
return err;
};
std.testing.expectEqual(want.is_segment_root, got.is_segment_root) catch |err| {
std.debug.print("commit {d}: segment-root mismatch on branch {s}\n", .{ index, got.branch });
return err;
};
}
}
test "git import labels the primary segment with the DESTINATION name, matching on the source name" {
const gpa = std.testing.allocator;
// The rename that makes a Git import openable: the head still publishes
// `master`, which is how the primary segment is FOUND, but every check-in
// on it is labelled `trunk`, which is what this format calls its main line
// and therefore the only name a fresh checkout or `zit open` will look for.
const first_parents = [_]?usize{ null, 0, 1 };
const heads = [_]git_import.BranchHead{.{ .name = "master", .commit_index = 2 }};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "master", "trunk");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "trunk", .is_segment_root = true },
.{ .branch = "trunk", .is_segment_root = false },
.{ .branch = "trunk", .is_segment_root = false },
}, labels);
}
test "git import renames only the primary branch, never a side branch" {
const gpa = std.testing.allocator;
// A side branch keeps the name the source gave it. Only the main line is
// renamed, because only the main line has a name this format assumes.
const first_parents = [_]?usize{ null, 0, 0 };
const heads = [_]git_import.BranchHead{
.{ .name = "master", .commit_index = 1 },
.{ .name = "feature", .commit_index = 2 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "master", "trunk");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "trunk", .is_segment_root = true },
.{ .branch = "trunk", .is_segment_root = false },
.{ .branch = "feature", .is_segment_root = true },
}, labels);
}
test "git import refuses to rename the primary onto a name another head already holds" {
const gpa = std.testing.allocator;
// The collision `finalizeRefs` exists to avoid: if the caller relabelled the
// primary `trunk` while a SIDE branch already published `trunk`, two
// segments would claim one name. `labelBranches` catches it rather than
// producing a branch graph with two roots under one label — which is why
// `finalizeRefs` keeps the source's own name in that case instead.
const first_parents = [_]?usize{ null, 0, 0 };
const heads = [_]git_import.BranchHead{
.{ .name = "trunk", .commit_index = 1 },
.{ .name = "trunk", .commit_index = 2 },
};
try std.testing.expectError(
error.DuplicateBranchHead,
git_import.labelBranches(gpa, &first_parents, &heads, "trunk", "trunk"),
);
}
test "git import labels a branchless single-branch history with one segment root at its root" {
const gpa = std.testing.allocator;
// Shape A from issue #26: three commits, one head. The whole point is that
// the primary branch is labelled at all — before the claim pass this shape
// imported with zero branch controls and every check-in untagged.
const first_parents = [_]?usize{ null, 0, 1 };
const heads = [_]git_import.BranchHead{.{ .name = "main", .commit_index = 2 }};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "main", .is_segment_root = false },
}, labels);
}
test "git import labels every sibling branch of a merge-free fork with its own segment root" {
const gpa = std.testing.allocator;
// Shape B: one base commit, three feature branches off it, and a second
// commit on the primary. The base belongs to the primary because the
// primary claims before any other head, not because the export stream
// happened to emit it under some feature ref.
const first_parents = [_]?usize{ null, 0, 0, 0, 0 };
const heads = [_]git_import.BranchHead{
.{ .name = "feat2", .commit_index = 2 },
.{ .name = "main", .commit_index = 4 },
.{ .name = "feat1", .commit_index = 1 },
.{ .name = "feat3", .commit_index = 3 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "feat1", .is_segment_root = true },
.{ .branch = "feat2", .is_segment_root = true },
.{ .branch = "feat3", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
}, labels);
}
test "git import lands merge check-ins on their first parent's segment" {
const gpa = std.testing.allocator;
// Shape C: five topic branches, each merged back into the primary with a
// real merge commit. Every merge is a primary-chain commit reached through
// its first parent, so no topic may claim one and no merge may root a
// segment — that is what keeps the §7.2 same-branch leaf test from seeing
// a severed child chain.
const first_parents = [_]?usize{
null, // 0 base
0, // 1 topic1 work
0, // 2 merge topic1
2, // 3 topic2 work
2, // 4 merge topic2
4, // 5 topic3 work
4, // 6 merge topic3
6, // 7 topic4 work
6, // 8 merge topic4
8, // 9 topic5 work
8, // 10 merge topic5
};
const heads = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 10 },
.{ .name = "topic1", .commit_index = 1 },
.{ .name = "topic2", .commit_index = 3 },
.{ .name = "topic3", .commit_index = 5 },
.{ .name = "topic4", .commit_index = 7 },
.{ .name = "topic5", .commit_index = 9 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "topic1", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "topic2", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "topic3", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "topic4", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "topic5", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
}, labels);
}
test "git import roots an orphan branch at its own parentless commit" {
const gpa = std.testing.allocator;
// A head whose history shares no commit with the primary: its walk bottoms
// out at its own parentless root, which is where its control belongs.
const first_parents = [_]?usize{ null, 0, null, 2 };
const heads = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 1 },
.{ .name = "docs", .commit_index = 3 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "docs", .is_segment_root = true },
.{ .branch = "docs", .is_segment_root = false },
}, labels);
}
test "git import gives an unclaimed orphan root the primary label and its own control" {
const gpa = std.testing.allocator;
// Commit 2 roots a chain that was merged into the primary and whose branch
// git deleted, so no head reaches it by first parent. Left unlabelled the
// chain would carry no propagating tag at all; labelling it primary invents
// no branch name and makes its merged tip a non-leaf by the §7.2 merge-edge
// rule. Commit 4 is the merge (first parent 1, second parent 3).
const first_parents = [_]?usize{ null, 0, null, 2, 1 };
const heads = [_]git_import.BranchHead{.{ .name = "main", .commit_index = 4 }};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "main", .is_segment_root = false },
}, labels);
}
test "git import claims a shallower head's segment before a head built on top of it" {
const gpa = std.testing.allocator;
// `release` points into the middle of `feature`'s first-parent chain. Claim
// order is tip depth ascending, so `release` takes its own commit and
// `feature` takes only what sits above it — both keep a segment root.
// Claiming in stream order instead would let `feature` swallow commit 2 and
// leave `release` with no control at all.
const first_parents = [_]?usize{ null, 0, 0, 2, 3 };
const heads = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 1 },
.{ .name = "feature", .commit_index = 4 },
.{ .name = "release", .commit_index = 2 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "release", .is_segment_root = true },
.{ .branch = "feature", .is_segment_root = true },
.{ .branch = "feature", .is_segment_root = false },
}, labels);
}
test "git import breaks a two-heads-on-one-commit tie by name, independent of head order" {
const gpa = std.testing.allocator;
// Two non-primary heads on the same commit: exactly one segment exists to
// claim, so the lexicographically first name wins and the loser gets no
// branch control (it stays reachable through its own symbolic control).
// Feeding the heads in both orders pins that the outcome is a property of
// the names, not of export order.
const first_parents = [_]?usize{ null, 0 };
const forward = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 0 },
.{ .name = "beta", .commit_index = 1 },
.{ .name = "alpha", .commit_index = 1 },
};
const reversed = [_]git_import.BranchHead{
.{ .name = "alpha", .commit_index = 1 },
.{ .name = "beta", .commit_index = 1 },
.{ .name = "main", .commit_index = 0 },
};
const expected = [_]ExpectedLabel{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "alpha", .is_segment_root = true },
};
const forward_labels = try git_import.labelBranches(gpa, &first_parents, &forward, "main", "main");
defer gpa.free(forward_labels);
try expectLabels(&expected, forward_labels);
const reversed_labels = try git_import.labelBranches(gpa, &first_parents, &reversed, "main", "main");
defer gpa.free(reversed_labels);
try expectLabels(&expected, reversed_labels);
}
test "git import claims the primary chain ahead of a deeper head that shares it" {
const gpa = std.testing.allocator;
// The primary's tip is shallower than nothing here — `topic` is the shallow
// head — yet the base still belongs to the primary. Priority is primary
// first, then depth, and this pins the first half of that rule.
const first_parents = [_]?usize{ null, 0, 1 };
const heads = [_]git_import.BranchHead{
.{ .name = "topic", .commit_index = 1 },
.{ .name = "main", .commit_index = 2 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
defer gpa.free(labels);
// `topic` sits on the primary's own first-parent chain, so it claims
// nothing and contributes no branch control.
try expectLabels(&.{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "main", .is_segment_root = false },
.{ .branch = "main", .is_segment_root = false },
}, labels);
}
test "git import rejects unlabelable branch input instead of asserting about it" {
const gpa = std.testing.allocator;
// Every one of these facts is about data a foreign repository's export
// stream produced, so each is a rejection rather than an assert: under the
// shipped ReleaseSafe build an assert would panic on a malformed source,
// and under `--release=fast` it would vanish and let the bad index through.
const first_parents = [_]?usize{ null, 0 };
const heads = [_]git_import.BranchHead{.{ .name = "main", .commit_index = 1 }};
try std.testing.expectError(error.InvalidPrimaryBranch, git_import.labelBranches(gpa, &first_parents, &heads, "", ""));
try std.testing.expectError(error.InvalidPrimaryBranch, git_import.labelBranches(gpa, &first_parents, &heads, "trunk", "trunk"));
const nameless = [_]git_import.BranchHead{.{ .name = "", .commit_index = 0 }};
try std.testing.expectError(error.InvalidBranchHead, git_import.labelBranches(gpa, &first_parents, &nameless, "main", "main"));
const out_of_range = [_]git_import.BranchHead{.{ .name = "main", .commit_index = 2 }};
try std.testing.expectError(error.InvalidBranchHead, git_import.labelBranches(gpa, &first_parents, &out_of_range, "main", "main"));
// A child ahead of its own first parent cannot be walked at all.
const inverted = [_]?usize{ 1, null };
try std.testing.expectError(error.InvalidCommitOrder, git_import.labelBranches(gpa, &inverted, &heads, "main", "main"));
const duplicate = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 1 },
.{ .name = "main", .commit_index = 0 },
};
try std.testing.expectError(error.DuplicateBranchHead, git_import.labelBranches(gpa, &first_parents, &duplicate, "main", "main"));
}
test "git import refuses two heads publishing one branch name rather than emitting two roots for it" {
const gpa = std.testing.allocator;
// One chain with two heads on it both calling themselves "feat". Nothing
// rejects it and the labelling itself "works": main claims the root, the
// shallower "feat" claims 1-2, and the deeper "feat" claims 3-5 and stops
// where it meets that claim — so the segment root at 3 has a FIRST PARENT
// (2) that is also labelled "feat". `makeBranchControl` would then compose
// a `*branch feat` card whose cancel targets the very `*sym-feat` that same
// card establishes, which `artifact.composeCommitTags` asserts against: a
// ReleaseSafe panic on repository input, and with that assert compiled out
// under `--release=fast`, two `*branch feat` cards where repo-state §5.2's
// UNIQUE (rid, tag) admits one. One name, one segment: rejected here so no
// later function has to be right about it.
//
// 0 ← 1 ← 2 (feat) ← 3 ← 4 ← 5 (feat)
const first_parents = [_]?usize{ null, 0, 1, 2, 3, 4 };
const heads = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 0 },
.{ .name = "feat", .commit_index = 2 },
.{ .name = "feat", .commit_index = 5 },
};
try std.testing.expectError(error.DuplicateBranchHead, git_import.labelBranches(gpa, &first_parents, &heads, "main", "main"));
// …and the same shape with distinct names is accepted, so the rejection is
// about the collision and not about the topology.
const distinct = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 0 },
.{ .name = "feat", .commit_index = 2 },
.{ .name = "feat2", .commit_index = 5 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &distinct, "main", "main");
defer gpa.free(labels);
try std.testing.expectEqualSlices(git_import.BranchLabel, &[_]git_import.BranchLabel{
.{ .branch = "main", .is_segment_root = true },
.{ .branch = "feat", .is_segment_root = true },
.{ .branch = "feat", .is_segment_root = false },
.{ .branch = "feat2", .is_segment_root = true },
.{ .branch = "feat2", .is_segment_root = false },
.{ .branch = "feat2", .is_segment_root = false },
}, labels);
// …and the parent of each of those segment roots carries a DIFFERENT
// branch, which is precisely the fact `makeBranchControl` relies on and the
// duplicate above breaks.
try std.testing.expect(!std.mem.eql(u8, labels[1].branch, labels[0].branch));
try std.testing.expect(!std.mem.eql(u8, labels[3].branch, labels[2].branch));
}
/// Labelling allocates three times — the result, the depth scratch, and the
/// claim-order scratch — and only the first outlives a success.
fn labelBranchesOomNoLeak(gpa: std.mem.Allocator) !void {
const first_parents = [_]?usize{ null, 0, 0, 2 };
const heads = [_]git_import.BranchHead{
.{ .name = "main", .commit_index = 1 },
.{ .name = "feat", .commit_index = 3 },
};
const labels = try git_import.labelBranches(gpa, &first_parents, &heads, "main", "main");
gpa.free(labels);
}
test "git import branch labelling releases every allocation it makes under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, labelBranchesOomNoLeak, .{});
}
// ---------------------------------------------------------------------------
// Fossil Git-mirror metadata classification
// ---------------------------------------------------------------------------
//
// Every case below drives the real importer over a synthetic mirror commit and
// then asks one question of the check-in it produced: are the candidate root
// files in the tree or not? The classifier has no test-only entry point, so a
// gate that stops working shows up as a file appearing or disappearing from a
// real imported history — which is the only thing that actually matters.
/// The line fossil appends to the exported `manifest`. Spelled out here rather
/// than imported so the test pins the bytes independently of the writer.
const test_sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n";
/// `identity.timestamp` (1_700_000_000) as a Fossil D card.
const test_instant = "2023-11-14T22:13:20";
const MirrorFile = struct {
path: []const u8,
content: []const u8,
mode: artifact.FileMode = .regular,
};
/// One deliberate defect in an otherwise perfect mirror. Each one must retain
/// EVERY present candidate; none may retain a strict subset.
const MirrorMutation = enum {
none,
/// Fossil's sterilizing line is missing, so the exported bytes ARE the
/// artifact. `manifest.uuid` still names them, so this stays valid.
sterilizer_absent,
sterilizer_doubled,
sterilizer_leading,
uuid_uppercase,
uuid_extra_newline,
uuid_no_newline,
uuid_truncated,
uuid_names_other_artifact,
manifest_unparseable,
manifest_is_a_cluster,
manifest_extra_file,
manifest_missing_file,
manifest_wrong_perm,
manifest_wrong_content,
manifest_duplicate_file,
manifest_unsorted_files,
manifest_lists_candidate,
timestamp_next_second,
timestamp_fractional,
timestamp_trailing_junk,
parent_card_on_root,
tags_no_tag_line,
tags_unknown_keyword,
tags_missing_trailing_lf,
tags_bare_branch_name,
};
const MirrorSpec = struct {
algo: artifact.HashAlgo = .sha3_256,
/// The subset a mirror's `manifest` setting exports, as
/// `checkout.manifest_selectors` letters.
parts: []const u8 = "rut",
mode: artifact.FileMode = .regular,
mutation: MirrorMutation = .none,
};
/// The exported bytes of one mirror commit's metadata. Any member may be
/// absent: `manifest` is a per-repository setting, not a fixed pair.
const MirrorParts = struct {
gpa: std.mem.Allocator,
manifest: ?[]u8 = null,
uuid: ?[]u8 = null,
tags: ?[]u8 = null,
/// The artifact name the mirror's own `manifest.uuid` carries, so a child
/// commit can build a P card that refers to this one.
name: []u8,
fn deinit(self: *MirrorParts) void {
if (self.manifest) |bytes| self.gpa.free(bytes);
if (self.uuid) |bytes| self.gpa.free(bytes);
if (self.tags) |bytes| self.gpa.free(bytes);
self.gpa.free(self.name);
self.* = undefined;
}
};
fn mirrorPermToken(mode: artifact.FileMode) ?[]const u8 {
return switch (mode) {
.regular => null,
.executable => "x",
.symlink => "l",
};
}
fn mirrorExportMode(mode: artifact.FileMode) fast_export.FileMode {
return switch (mode) {
.regular => .regular,
.executable => .executable,
.symlink => .symlink,
};
}
fn mirrorFCard(arena: std.mem.Allocator, algo: artifact.HashAlgo, file: MirrorFile) !artifact.FCard {
var buf: [64]u8 = undefined;
return .{
.filename = file.path,
.hash = try arena.dupe(u8, artifact.artifactName(algo, file.content, &buf)),
.perm = mirrorPermToken(file.mode),
.old_name = null,
.mode = file.mode,
};
}
fn emptyMirrorManifest(gpa: std.mem.Allocator) artifact.Manifest {
return .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "mirror" },
.d = .{ .timestamp = test_instant },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "mirror" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
}
/// Builds the metadata a `fossil git export` mirror injects for a commit whose
/// tree is `files`, then applies `spec.mutation`. `files` must already be in
/// canonical order.
fn buildMirrorParts(
gpa: std.mem.Allocator,
spec: MirrorSpec,
files: []const MirrorFile,
parent_names: []const []const u8,
) !MirrorParts {
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var cards: std.ArrayList(artifact.FCard) = .empty;
for (files) |file| try cards.append(arena, try mirrorFCard(arena, spec.algo, file));
switch (spec.mutation) {
.manifest_extra_file => try cards.append(arena, try mirrorFCard(arena, spec.algo, .{
.path = "zz-phantom.txt",
.content = "phantom",
})),
.manifest_missing_file => _ = cards.pop(),
.manifest_wrong_perm => cards.items[0].perm = "x",
.manifest_wrong_content => cards.items[0].hash = (try mirrorFCard(arena, spec.algo, .{
.path = files[0].path,
.content = "not what the tree holds",
})).hash,
.manifest_duplicate_file => try cards.append(arena, cards.items[cards.items.len - 1]),
.manifest_unsorted_files => std.mem.swap(artifact.FCard, &cards.items[0], &cards.items[cards.items.len - 1]),
.manifest_lists_candidate => try cards.append(arena, try mirrorFCard(arena, spec.algo, .{
.path = "manifest.tags",
.content = "branch trunk\ntag trunk\n",
})),
else => {},
}
if (spec.mutation == .manifest_wrong_perm) cards.items[0].mode = .executable;
var manifest = emptyMirrorManifest(gpa);
defer manifest.deinit();
manifest.f = cards.items;
switch (spec.mutation) {
.timestamp_next_second => manifest.d = .{ .timestamp = "2023-11-14T22:13:21" },
.timestamp_fractional => manifest.d = .{ .timestamp = "2023-11-14T22:13:20.500" },
.timestamp_trailing_junk => manifest.d = .{ .timestamp = "2023-11-14T22:13:20 and a half" },
else => {},
}
var effective_parents = parent_names;
var synthetic: [1][]const u8 = undefined;
if (spec.mutation == .parent_card_on_root and parent_names.len == 0) {
var buf: [64]u8 = undefined;
synthetic[0] = try arena.dupe(u8, artifact.artifactName(spec.algo, "no such parent", &buf));
effective_parents = synthetic[0..1];
}
var parent_card: artifact.PCard = undefined;
if (effective_parents.len != 0) {
parent_card = .{
.parents = try std.mem.join(arena, " ", effective_parents),
.count = effective_parents.len,
};
manifest.p = (&parent_card)[0..1];
}
var cluster_card: artifact.MCard = undefined;
if (spec.mutation == .manifest_is_a_cluster) {
// A syntactically valid artifact that is not a check-in. The card set,
// not a field, is what makes it one, so the whole shape is replaced.
var buf: [64]u8 = undefined;
cluster_card = .{ .hash = try arena.dupe(u8, artifact.artifactName(.sha1, "clustered", &buf)) };
manifest.kind = .cluster;
manifest.c = null;
manifest.d = null;
manifest.u = null;
manifest.f = &.{};
manifest.p = &.{};
manifest.m = (&cluster_card)[0..1];
}
const artifact_bytes = try artifact.create(&manifest, gpa);
defer gpa.free(artifact_bytes);
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(spec.algo, artifact_bytes, &name_buf);
var parts = MirrorParts{ .gpa = gpa, .name = try gpa.dupe(u8, name) };
errdefer parts.deinit();
if (std.mem.indexOfScalar(u8, spec.parts, 'r') != null) {
parts.manifest = switch (spec.mutation) {
.sterilizer_absent => try gpa.dupe(u8, artifact_bytes),
.sterilizer_doubled => try std.mem.concat(gpa, u8, &.{ artifact_bytes, test_sterilizer, test_sterilizer }),
.sterilizer_leading => try std.mem.concat(gpa, u8, &.{ test_sterilizer, artifact_bytes }),
.manifest_unparseable => try std.mem.concat(gpa, u8, &.{ "not an artifact at all\n", test_sterilizer }),
else => try std.mem.concat(gpa, u8, &.{ artifact_bytes, test_sterilizer }),
};
}
if (std.mem.indexOfScalar(u8, spec.parts, 'u') != null) {
parts.uuid = switch (spec.mutation) {
.uuid_uppercase => blk: {
const upper = try gpa.dupe(u8, name);
defer gpa.free(upper);
for (upper) |*byte| byte.* = std.ascii.toUpper(byte.*);
break :blk try std.fmt.allocPrint(gpa, "{s}\n", .{upper});
},
.uuid_extra_newline => try std.fmt.allocPrint(gpa, "{s}\n\n", .{name}),
.uuid_no_newline => try gpa.dupe(u8, name),
.uuid_truncated => try std.fmt.allocPrint(gpa, "{s}\n", .{name[0 .. name.len - 1]}),
.uuid_names_other_artifact => blk: {
var other_buf: [64]u8 = undefined;
break :blk try std.fmt.allocPrint(gpa, "{s}\n", .{
artifact.artifactName(spec.algo, "a different artifact entirely", &other_buf),
});
},
else => try std.fmt.allocPrint(gpa, "{s}\n", .{name}),
};
}
if (std.mem.indexOfScalar(u8, spec.parts, 't') != null) {
parts.tags = try gpa.dupe(u8, switch (spec.mutation) {
.tags_no_tag_line => "branch trunk\n",
.tags_unknown_keyword => "branch trunk\nclosed trunk\n",
.tags_missing_trailing_lf => "branch trunk\ntag trunk",
.tags_bare_branch_name => "trunk\ntag trunk\n",
else => "branch trunk\ntag trunk\n",
});
}
return parts;
}
/// Ingests one mirror commit: a blob per tree file, a blob per exported
/// candidate, and the commit naming all of them.
fn ingestMirrorCommit(
gpa: std.mem.Allocator,
importer: *git_import.Importer,
mark_base: u64,
commit_mark: u64,
from: ?fast_export.ObjectRef,
merges: []const fast_export.ObjectRef,
files: []const MirrorFile,
parts: *const MirrorParts,
candidate_mode: artifact.FileMode,
) !void {
var commands: std.ArrayList(fast_export.FileCommand) = .empty;
defer commands.deinit(gpa);
var mark = mark_base;
for (files) |file| {
try importer.ingest(blob(mark, null, file.content));
try commands.append(gpa, .{ .modify = .{
.mode = mirrorExportMode(file.mode),
.data = .{ .mark = mark },
.path = file.path,
} });
mark += 1;
}
const candidates = [_]struct { path: []const u8, bytes: ?[]const u8 }{
.{ .path = "manifest", .bytes = parts.manifest },
.{ .path = "manifest.uuid", .bytes = parts.uuid },
.{ .path = "manifest.tags", .bytes = parts.tags },
};
for (candidates) |candidate| {
const bytes = candidate.bytes orelse continue;
try importer.ingest(blob(mark, null, bytes));
try commands.append(gpa, .{ .modify = .{
.mode = mirrorExportMode(candidate_mode),
.data = .{ .mark = mark },
.path = candidate.path,
} });
mark += 1;
}
// A deterministic original ID per commit, so a mirror fixture exercises
// the same provenance records a real `git fast-export --show-original-ids`
// stream produces.
var oid_buf: [40]u8 = undefined;
const commit_oid = std.fmt.bufPrint(&oid_buf, "{x:0>40}", .{commit_mark}) catch unreachable;
try importer.ingest(commit(commit_mark, commit_oid, from, merges, commands.items, "mirror commit"));
}
/// The complete file set of the check-in `mark` produced, whatever root shape
/// the builder chose.
const ImportedTree = struct {
gpa: std.mem.Allocator,
checkin: OwnedManifest,
subs: SubTrees,
files: []const artifact.FCard,
fn deinit(self: *ImportedTree) void {
self.subs.deinit();
self.checkin.deinit(self.gpa);
self.* = undefined;
}
fn has(self: *const ImportedTree, path: []const u8) bool {
for (self.files) |card| {
if (std.mem.eql(u8, card.filename, path)) return true;
}
return false;
}
};
fn importedTree(
gpa: std.mem.Allocator,
fixture: *Fixture,
importer: *git_import.Importer,
mark: u64,
) !ImportedTree {
const hash = try importer.objectForMark(mark);
var tree = ImportedTree{
.gpa = gpa,
.checkin = try loadManifest(fixture, gpa, hash),
.subs = .{ .gpa = gpa, .fixture = fixture },
.files = &.{},
};
errdefer tree.deinit();
tree.files = try flatFiles(&tree.checkin.manifest, &tree.subs);
return tree;
}
const mirror_tree_files = [_]MirrorFile{
.{ .path = "alpha.txt", .content = "alpha body" },
.{ .path = "bin/tool.sh", .content = "#!/bin/sh\n", .mode = .executable },
.{ .path = "docs/readme.md", .content = "readme body" },
};
const MirrorCase = struct {
name: []const u8,
spec: MirrorSpec,
omit: bool,
};
test "git import strips the sterilizer from both positions fossil writes it" {
const gpa = std.testing.allocator;
const marker = test_sterilizer;
const sig =
"-----BEGIN PGP SIGNATURE-----\n" ++
"iEYEARECAAYFAk507VMACgkQoxKgR168RlEAVgCfXvTVgVXWPGlRVI4ZDt0OzYbj\n" ++
"=LZzm\n" ++
"-----END PGP SIGNATURE-----\n";
const cards = "C probe\nD 2009-11-06T03:22:55\nU shaneh\n";
// 1. Appended at the end -- an ordinary manifest. A truncation.
{
const bytes = try std.mem.concat(gpa, u8, &.{ cards, marker });
defer gpa.free(bytes);
try std.testing.expectEqualStrings(cards, git_import.stripMirrorSterilizer(bytes));
}
// 2. Spliced before the signature block -- a PGP-clearsigned manifest.
// This is NOT a truncation, and a strip that only handled case 1 left
// the marker in place, so the bytes never hashed to the origin's name
// and the pair could never be proven. On a real sqlite mirror that was
// 1,076 check-ins whose mirror metadata was retained forever.
{
const bytes = try std.mem.concat(gpa, u8, &.{ cards, marker, sig });
defer gpa.free(bytes);
const want = try std.mem.concat(gpa, u8, &.{ cards, sig });
defer gpa.free(want);
try std.testing.expectEqualStrings(want, git_import.stripMirrorSterilizer(bytes));
}
// 3. Anywhere else is left alone: fossil never writes it there, and a blob
// that merely contains the line must not be reshaped into one that
// proves.
{
const bytes = try std.mem.concat(gpa, u8, &.{ marker, cards });
defer gpa.free(bytes);
const want = try gpa.dupe(u8, bytes);
defer gpa.free(want);
try std.testing.expectEqualStrings(want, git_import.stripMirrorSterilizer(bytes));
}
// 4. Absent: returned whole.
{
const bytes = try gpa.dupe(u8, cards);
defer gpa.free(bytes);
try std.testing.expectEqualStrings(cards, git_import.stripMirrorSterilizer(bytes));
}
}
test "git import mirror pair omits only a fully proven candidate set" {
const gpa = std.testing.allocator;
const cases = [_]MirrorCase{
// The two supported Fossil hash policies, and the subsets the
// `manifest` setting can export.
.{ .name = "sha3 rut", .spec = .{}, .omit = true },
.{ .name = "sha1 rut", .spec = .{ .algo = .sha1 }, .omit = true },
.{ .name = "ru", .spec = .{ .parts = "ru" }, .omit = true },
.{ .name = "sha1 ru", .spec = .{ .algo = .sha1, .parts = "ru" }, .omit = true },
// A dependent or lone member is never dropped on its own.
.{ .name = "r alone", .spec = .{ .parts = "r" }, .omit = false },
.{ .name = "u alone", .spec = .{ .parts = "u" }, .omit = false },
.{ .name = "t alone", .spec = .{ .parts = "t" }, .omit = false },
.{ .name = "rt without uuid", .spec = .{ .parts = "rt" }, .omit = false },
.{ .name = "ut without manifest", .spec = .{ .parts = "ut" }, .omit = false },
// Fossil's sterilizing line is accepted present or absent, and
// nowhere else.
.{ .name = "sterilizer absent", .spec = .{ .mutation = .sterilizer_absent }, .omit = true },
.{ .name = "sterilizer doubled", .spec = .{ .mutation = .sterilizer_doubled }, .omit = false },
.{ .name = "sterilizer leading", .spec = .{ .mutation = .sterilizer_leading }, .omit = false },
// UUID syntax.
.{ .name = "uuid uppercase", .spec = .{ .mutation = .uuid_uppercase }, .omit = false },
.{ .name = "uuid extra newline", .spec = .{ .mutation = .uuid_extra_newline }, .omit = false },
.{ .name = "uuid without newline", .spec = .{ .mutation = .uuid_no_newline }, .omit = true },
.{ .name = "uuid truncated", .spec = .{ .mutation = .uuid_truncated }, .omit = false },
.{ .name = "uuid names another artifact", .spec = .{ .mutation = .uuid_names_other_artifact }, .omit = false },
// Manifest shape.
.{ .name = "manifest unparseable", .spec = .{ .mutation = .manifest_unparseable }, .omit = false },
.{ .name = "manifest is a cluster artifact", .spec = .{ .mutation = .manifest_is_a_cluster }, .omit = false },
// Tree equality, in both directions and on every tuple member.
.{ .name = "manifest lists an extra file", .spec = .{ .mutation = .manifest_extra_file }, .omit = false },
.{ .name = "manifest omits a file", .spec = .{ .mutation = .manifest_missing_file }, .omit = false },
.{ .name = "manifest disagrees on permission", .spec = .{ .mutation = .manifest_wrong_perm }, .omit = false },
.{ .name = "manifest disagrees on content", .spec = .{ .mutation = .manifest_wrong_content }, .omit = false },
.{ .name = "manifest duplicates a path", .spec = .{ .mutation = .manifest_duplicate_file }, .omit = false },
.{ .name = "manifest is unsorted", .spec = .{ .mutation = .manifest_unsorted_files }, .omit = false },
.{ .name = "manifest lists a candidate", .spec = .{ .mutation = .manifest_lists_candidate }, .omit = false },
// Parents and instant.
.{ .name = "root commit with a P card", .spec = .{ .mutation = .parent_card_on_root }, .omit = false },
// Adopted, not refused. The origin's instant is proven authentic by
// gate 3, and Git's is the lossy carrier of it -- fossil's export
// truncates a floating-point Julian Day, so a whole-second origin
// timestamp lands a second EARLY about half the time, which is this
// case exactly. The import mints the origin's instant, so the manifest
// is redundant and drops. Before adoption this retained, and on a real
// sqlite mirror that was 4,538 commits.
.{ .name = "instant one second later", .spec = .{ .mutation = .timestamp_next_second }, .omit = true },
.{ .name = "instant with fossil's fraction", .spec = .{ .mutation = .timestamp_fractional }, .omit = true },
.{ .name = "instant with trailing text", .spec = .{ .mutation = .timestamp_trailing_junk }, .omit = false },
// The dependent member's own grammar.
.{ .name = "tags without a tag line", .spec = .{ .mutation = .tags_no_tag_line }, .omit = false },
.{ .name = "tags with an unknown keyword", .spec = .{ .mutation = .tags_unknown_keyword }, .omit = false },
.{ .name = "tags without a trailing LF", .spec = .{ .mutation = .tags_missing_trailing_lf }, .omit = false },
.{ .name = "tags with a bare branch name", .spec = .{ .mutation = .tags_bare_branch_name }, .omit = false },
// A candidate that is not an ordinary file is not a candidate.
.{ .name = "executable candidates", .spec = .{ .mode = .executable }, .omit = false },
.{ .name = "symlink candidates", .spec = .{ .mode = .symlink }, .omit = false },
};
for (cases) |case| {
errdefer std.debug.print("mirror case: {s}\n", .{case.name});
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{ .submanifest = .flat });
defer importer.deinit();
var parts = try buildMirrorParts(gpa, case.spec, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, case.spec.mode);
var tree = try importedTree(gpa, &fixture, &importer, 100);
defer tree.deinit();
// Project content is never touched by any outcome.
for (mirror_tree_files) |file| try std.testing.expect(tree.has(file.path));
// All-or-nothing: no outcome may drop a strict subset.
var present: usize = 0;
var expected: usize = 0;
inline for (.{ "manifest", "manifest.uuid", "manifest.tags" }, .{ 'r', 'u', 't' }) |path, letter| {
if (std.mem.indexOfScalar(u8, case.spec.parts, letter) != null) {
expected += 1;
if (tree.has(path)) present += 1;
} else {
try std.testing.expect(!tree.has(path));
}
}
try std.testing.expectEqual(if (case.omit) @as(usize, 0) else expected, present);
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 1), census.commits_with_candidates);
try std.testing.expectEqual(@as(u64, if (case.omit) 1 else 0), census.commits_validated);
try std.testing.expectEqual(@as(u64, if (case.omit) 0 else 1), census.commits_retained);
}
}
test "git import sub-manifest mode drops on the pair proof, not on reproduction" {
const gpa = std.testing.allocator;
// Sub-manifest mode restructures the file set behind S cards, so its
// check-in is a different artifact from the origin's by construction and
// reproduces nothing. Gates 5 to 7 prove reproduction, so requiring them
// here only strands metadata the mode has no use for -- 5,906 commits'
// worth on a real sqlite mirror, and every one of libfossil's 2,065.
//
// These four fail ONLY those gates. Each retains under flat mode above.
const reproduction_only = [_]MirrorCase{
.{ .name = "manifest disagrees on content", .spec = .{ .mutation = .manifest_wrong_content }, .omit = true },
.{ .name = "root commit with a P card", .spec = .{ .mutation = .parent_card_on_root }, .omit = true },
};
// ...while gate 3's cryptographic tie still governs, because it is what
// tells mirror metadata apart from a project file that merely happens to
// be called `manifest`. Relaxing the gates above must not reach it.
const proof_governs = [_]MirrorCase{
// Both rejected by the PARSE rather than by reproduction: a D card
// with trailing text is not a timestamp, and fossil's F run is
// sorted, so an unsorted one is not a manifest to begin with.
.{ .name = "instant with trailing text", .spec = .{ .mutation = .timestamp_trailing_junk }, .omit = false },
.{ .name = "manifest is unsorted", .spec = .{ .mutation = .manifest_unsorted_files }, .omit = false },
.{ .name = "uuid names another artifact", .spec = .{ .mutation = .uuid_names_other_artifact }, .omit = false },
.{ .name = "uuid truncated", .spec = .{ .mutation = .uuid_truncated }, .omit = false },
.{ .name = "sterilizer leading", .spec = .{ .mutation = .sterilizer_leading }, .omit = false },
.{ .name = "tags with a bare branch name", .spec = .{ .mutation = .tags_bare_branch_name }, .omit = false },
.{ .name = "manifest without a uuid", .spec = .{ .parts = "rt" }, .omit = false },
};
for (reproduction_only ++ proof_governs) |case| {
errdefer std.debug.print("sub-manifest mirror case: {s}\n", .{case.name});
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
// Default options: sub-manifest mode is the default.
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, case.spec, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, case.spec.mode);
var tree = try importedTree(gpa, &fixture, &importer, 100);
defer tree.deinit();
for (mirror_tree_files) |file| try std.testing.expect(tree.has(file.path));
var present: usize = 0;
var expected: usize = 0;
inline for (.{ "manifest", "manifest.uuid", "manifest.tags" }, .{ 'r', 'u', 't' }) |path, letter| {
if (std.mem.indexOfScalar(u8, case.spec.parts, letter) != null) {
expected += 1;
if (tree.has(path)) present += 1;
} else {
try std.testing.expect(!tree.has(path));
}
}
try std.testing.expectEqual(if (case.omit) @as(usize, 0) else expected, present);
}
}
test "git import mirror pair keeps a genuine project file named manifest" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
// A project whose own top-level `manifest` is ordinary text, alongside
// files whose names merely resemble the candidates.
const files = [_]MirrorFile{
.{ .path = "alpha.txt", .content = "alpha body" },
.{ .path = "manifest", .content = "name = demo\nversion = 1\n" },
.{ .path = "manifest.uuid", .content = "not a hash\n" },
.{ .path = "src/manifest", .content = "nested and never a candidate" },
};
var commands: std.ArrayList(fast_export.FileCommand) = .empty;
defer commands.deinit(gpa);
for (files, 1..) |file, mark| {
try importer.ingest(blob(@intCast(mark), null, file.content));
try commands.append(gpa, .{ .modify = .{
.mode = .regular,
.data = .{ .mark = @intCast(mark) },
.path = file.path,
} });
}
try importer.ingest(commit(100, null, null, &.{}, commands.items, "ordinary project"));
var tree = try importedTree(gpa, &fixture, &importer, 100);
defer tree.deinit();
for (files) |file| try std.testing.expect(tree.has(file.path));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 1), census.commits_with_candidates);
try std.testing.expectEqual(@as(u64, 0), census.commits_validated);
try std.testing.expectEqual(@as(u64, 1), census.commits_retained);
// The pair is present and syntactically plausible; only the cryptographic
// gate rejects it.
try std.testing.expectEqual(@as(u64, 1), census.reason(.uuid_syntax));
const drops = try importer.mirrorDropNames(gpa);
defer gpa.free(drops);
try std.testing.expectEqual(@as(usize, 0), drops.len);
}
test "git import mirror pair inherits an unchanged tags member and re-proves each commit" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{ .submanifest = .flat });
defer importer.deinit();
// Commit 1: a full export.
var first_parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer first_parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &first_parts, .regular);
// Commit 2: one file changes; `manifest.tags` is unchanged and therefore
// NOT re-exported, so its presence in the Git tree is inherited alone.
const second_files = [_]MirrorFile{
.{ .path = "alpha.txt", .content = "alpha body, revised" },
.{ .path = "bin/tool.sh", .content = "#!/bin/sh\n", .mode = .executable },
.{ .path = "docs/readme.md", .content = "readme body" },
};
var second_parts = try buildMirrorParts(gpa, .{ .parts = "ru" }, &second_files, &.{first_parts.name});
defer second_parts.deinit();
try ingestMirrorCommit(gpa, &importer, 20, 200, .{ .mark = 100 }, &.{}, &second_files, &second_parts, .regular);
var second = try importedTree(gpa, &fixture, &importer, 200);
defer second.deinit();
try std.testing.expect(!second.has("manifest"));
try std.testing.expect(!second.has("manifest.uuid"));
try std.testing.expect(!second.has("manifest.tags"));
try std.testing.expect(second.has("alpha.txt"));
// Commit 3: the mirror's own manifest lies about the tree, so everything
// present comes back — including the tags member inherited two commits
// earlier, which must be restored into the emitted tree.
const third_files = [_]MirrorFile{
.{ .path = "alpha.txt", .content = "alpha body, revised again" },
.{ .path = "bin/tool.sh", .content = "#!/bin/sh\n", .mode = .executable },
.{ .path = "docs/readme.md", .content = "readme body" },
};
var third_parts = try buildMirrorParts(
gpa,
.{ .parts = "ru", .mutation = .manifest_missing_file },
&third_files,
&.{second_parts.name},
);
defer third_parts.deinit();
try ingestMirrorCommit(gpa, &importer, 40, 300, .{ .mark = 200 }, &.{}, &third_files, &third_parts, .regular);
var third = try importedTree(gpa, &fixture, &importer, 300);
defer third.deinit();
try std.testing.expect(third.has("manifest"));
try std.testing.expect(third.has("manifest.uuid"));
try std.testing.expect(third.has("manifest.tags"));
// Commit 4: valid again, and its P card names commit 3 — which RETAINED,
// for a reason of its own (`tree_mismatch`) that says nothing about whether
// its UUID is correct. Gate 3 established commit 3's identity
// cryptographically before that later gate kept it, so commit 4's parent
// proof holds and commit 4 DROPS.
//
// This assertion used to read the other way, and its comment said so: "which
// was RETAINED, so its UUID is not proven and this commit is inconclusive."
// That is the defect of issue #43 written down as intent — it makes a
// child's proof depend on its parent's unrelated gates, so one retain
// anywhere poisons every descendant. On a real 36,432-commit sqlite mirror
// it produced zero drops and 36,252 `parent_unproven` retentions.
var fourth_parts = try buildMirrorParts(gpa, .{ .parts = "ru" }, &third_files, &.{third_parts.name});
defer fourth_parts.deinit();
try ingestMirrorCommit(gpa, &importer, 60, 400, .{ .mark = 300 }, &.{}, &third_files, &fourth_parts, .regular);
var fourth = try importedTree(gpa, &fixture, &importer, 400);
defer fourth.deinit();
try std.testing.expect(!fourth.has("manifest"));
try std.testing.expect(!fourth.has("manifest.tags"));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 4), census.commits_with_candidates);
// Three drop now, not two: commit 4 is no longer held back by commit 3's
// unrelated failure.
try std.testing.expectEqual(@as(u64, 3), census.commits_validated);
// Commit 3 still retains on its OWN merits — attestation changes what a
// child may rely on, never whether this commit was droppable.
try std.testing.expectEqual(@as(u64, 1), census.reason(.tree_mismatch));
try std.testing.expectEqual(@as(u64, 0), census.reason(.parent_unproven));
}
test "git import mirror pair proves ordered parents across a merge" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{ .submanifest = .flat });
defer importer.deinit();
const base_files = [_]MirrorFile{.{ .path = "alpha.txt", .content = "base" }};
var base = try buildMirrorParts(gpa, .{ .parts = "ru" }, &base_files, &.{});
defer base.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &base_files, &base, .regular);
const side_files = [_]MirrorFile{.{ .path = "alpha.txt", .content = "side" }};
var side = try buildMirrorParts(gpa, .{ .parts = "ru" }, &side_files, &.{base.name});
defer side.deinit();
try ingestMirrorCommit(gpa, &importer, 10, 200, .{ .mark = 100 }, &.{}, &side_files, &side, .regular);
const main_files = [_]MirrorFile{.{ .path = "alpha.txt", .content = "main" }};
var main = try buildMirrorParts(gpa, .{ .parts = "ru" }, &main_files, &.{base.name});
defer main.deinit();
try ingestMirrorCommit(gpa, &importer, 20, 300, .{ .mark = 100 }, &.{}, &main_files, &main, .regular);
const merged_files = [_]MirrorFile{.{ .path = "alpha.txt", .content = "merged" }};
// Correct order: first parent then merge parent.
var ordered = try buildMirrorParts(gpa, .{ .parts = "ru" }, &merged_files, &.{ main.name, side.name });
defer ordered.deinit();
try ingestMirrorCommit(gpa, &importer, 30, 400, .{ .mark = 300 }, &.{.{ .mark = 200 }}, &merged_files, &ordered, .regular);
var ordered_tree = try importedTree(gpa, &fixture, &importer, 400);
defer ordered_tree.deinit();
try std.testing.expect(!ordered_tree.has("manifest"));
// Reversed order: the same two proven UUIDs, in the wrong sequence.
var reversed = try buildMirrorParts(gpa, .{ .parts = "ru" }, &merged_files, &.{ side.name, main.name });
defer reversed.deinit();
try ingestMirrorCommit(gpa, &importer, 40, 500, .{ .mark = 300 }, &.{.{ .mark = 200 }}, &merged_files, &reversed, .regular);
var reversed_tree = try importedTree(gpa, &fixture, &importer, 500);
defer reversed_tree.deinit();
try std.testing.expect(reversed_tree.has("manifest"));
// One parent short.
var short = try buildMirrorParts(gpa, .{ .parts = "ru" }, &merged_files, &.{main.name});
defer short.deinit();
try ingestMirrorCommit(gpa, &importer, 50, 600, .{ .mark = 300 }, &.{.{ .mark = 200 }}, &merged_files, &short, .regular);
var short_tree = try importedTree(gpa, &fixture, &importer, 600);
defer short_tree.deinit();
try std.testing.expect(short_tree.has("manifest"));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 2), census.reason(.parent_unproven));
}
test "git import mirror pair drop set holds candidate-only artifacts and spares shared ones" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
// The same bytes fossil put in `manifest.tags` are also a real project
// file, so that artifact is referenced by a retained tree entry.
const files = mirror_tree_files ++ [_]MirrorFile{
.{ .path = "zz-copy-of-tags.txt", .content = "branch trunk\ntag trunk\n" },
};
var full = try buildMirrorParts(gpa, .{}, &files, &.{});
defer full.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &files, &full, .regular);
var tree = try importedTree(gpa, &fixture, &importer, 100);
defer tree.deinit();
try std.testing.expect(!tree.has("manifest"));
try std.testing.expect(tree.has("zz-copy-of-tags.txt"));
const drops = try importer.mirrorDropNames(gpa);
defer {
for (drops) |name| gpa.free(name);
gpa.free(drops);
}
var tags_name_buf: [64]u8 = undefined;
const tags_name = nameFor(full.tags.?, &tags_name_buf);
var manifest_name_buf: [64]u8 = undefined;
const manifest_name = nameFor(full.manifest.?, &manifest_name_buf);
var uuid_name_buf: [64]u8 = undefined;
const uuid_name = nameFor(full.uuid.?, &uuid_name_buf);
var saw_manifest = false;
var saw_uuid = false;
for (drops) |name| {
// The shared artifact is retained by the ordinary file that also
// names it, so it must not be droppable.
try std.testing.expect(!std.mem.eql(u8, name, tags_name));
if (std.mem.eql(u8, name, manifest_name)) saw_manifest = true;
if (std.mem.eql(u8, name, uuid_name)) saw_uuid = true;
}
try std.testing.expect(saw_manifest);
try std.testing.expect(saw_uuid);
try std.testing.expectEqual(@as(usize, 2), drops.len);
}
test "git import mirror pair retains a commit whose candidate arrived by rename" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, .regular);
var first = try importedTree(gpa, &fixture, &importer, 100);
defer first.deinit();
try std.testing.expect(!first.has("manifest"));
// A commit that renames a candidate out of the way. The bookkeeping does
// not model where the content went, so the whole set comes back — and the
// renamed destination has to hold the restored bytes.
try importer.ingest(commit(200, null, .{ .mark = 100 }, &.{}, &.{
.{ .rename = .{ .old = "manifest", .new = "kept-manifest" } },
}, "rename out"));
var second = try importedTree(gpa, &fixture, &importer, 200);
defer second.deinit();
try std.testing.expect(second.has("kept-manifest"));
try std.testing.expect(!second.has("manifest"));
try std.testing.expect(second.has("manifest.uuid"));
try std.testing.expect(second.has("manifest.tags"));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 1), census.reason(.unsupported_command));
// The safety property stated directly: commit 200's tree references all
// three artifacts, so none of them may be droppable. Without the retained
// path's reference accounting this stays green on every other assertion
// above while the drop set names three live artifacts.
const drops = try importer.mirrorDropNames(gpa);
defer {
for (drops) |name| gpa.free(name);
gpa.free(drops);
}
try std.testing.expectEqual(@as(usize, 0), drops.len);
}
test "git import mirror pair takes a candidate submodule from Git truth, not the emitted tree" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, .regular);
var first = try importedTree(gpa, &fixture, &importer, 100);
defer first.deinit();
try std.testing.expect(!first.has("manifest"));
// Git replaces the candidate file with a submodule. The validated parent
// emitted no `manifest` at all, so a delete decided by looking in the
// emitted tree would be skipped -- and the stale shadow entry would be
// restored into this retained commit as a regular file Git does not have.
try importer.ingest(commit(200, null, .{ .mark = 100 }, &.{}, &.{
.{ .modify = .{ .mode = .gitlink, .data = .{ .mark = 100 }, .path = "manifest" } },
}, "submodule at manifest"));
var second = try importedTree(gpa, &fixture, &importer, 200);
defer second.deinit();
try std.testing.expect(!second.has("manifest"));
try std.testing.expect(second.has("manifest.uuid"));
try std.testing.expect(second.has("manifest.tags"));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 1), census.commits_validated);
try std.testing.expectEqual(@as(u64, 1), census.reason(.incomplete_pair));
}
test "git import mirror pair names a commit that deleted every candidate no_candidates" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, .regular);
try importer.ingest(commit(200, null, .{ .mark = 100 }, &.{}, &.{
.{ .delete = "manifest" },
.{ .delete = "manifest.uuid" },
.{ .delete = "manifest.tags" },
}, "mirror turned off"));
var second = try importedTree(gpa, &fixture, &importer, 200);
defer second.deinit();
try std.testing.expect(!second.has("manifest"));
try std.testing.expect(!second.has("manifest.uuid"));
try std.testing.expect(!second.has("manifest.tags"));
// The reason is per commit, in the ledger: the census deliberately does not
// count this commit at all, so only the ledger can say what it decided.
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
const record = try ledgerCommit(&finalization, 1);
try std.testing.expectEqual(git_import.MirrorDecisionKind.retained, record.decision);
try std.testing.expectEqual(git_import.MirrorRetainReason.no_candidates, record.reason);
// A tree with no candidates has no pair to be incomplete, and it is not a
// commit "with candidates" either -- so the census still balances.
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 0), census.reason(.incomplete_pair));
try std.testing.expectEqual(@as(u64, 1), census.commits_with_candidates);
try std.testing.expectEqual(@as(u64, 1), census.commits_validated);
try std.testing.expectEqual(@as(u64, 0), census.commits_retained);
}
test "git import mirror pair leaves an ordinary import untouched" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(blob(1, null, "first body"));
try importer.ingest(blob(2, null, "second body"));
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
try importer.ingest(commit(10, null, null, &.{}, commands.commands.items, "ordinary"));
try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &.{}, "empty follow-up"));
const census = importer.mirrorCensus();
try std.testing.expectEqual(@as(u64, 0), census.commits_with_candidates);
try std.testing.expectEqual(@as(u64, 0), census.f_cards_compared);
const drops = try importer.mirrorDropNames(gpa);
defer gpa.free(drops);
try std.testing.expectEqual(@as(usize, 0), drops.len);
}
/// The drop-set capture, under allocation failure.
///
/// `capture_drop_set` is a publication boundary with its own fault test, but
/// the work that boundary names is `mirrorDropNames` -- one owned name per
/// droppable candidate plus the slice holding them -- and that had no
/// allocation coverage at all. It is the last allocating step before the pack
/// reads the filter, so a partial failure here would either leak the names or
/// hand packing a truncated set.
///
/// The fixture is deliberately one whose drop set is NON-EMPTY, asserted below:
/// a run that captured nothing would allocate nothing, leak nothing, and prove
/// nothing.
fn mirrorDropNamesOomNoLeak(gpa: std.mem.Allocator) !void {
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, .regular);
const drops = try importer.mirrorDropNames(gpa);
defer {
for (drops) |name| gpa.free(name);
gpa.free(drops);
}
if (drops.len == 0) return error.TestUnexpectedResult;
}
test "git import mirror drop-set capture releases every allocation it makes under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, mirrorDropNamesOomNoLeak, .{});
}
fn mirrorClassifierOomNoLeak(gpa: std.mem.Allocator) !void {
var fixture = try Fixture.init(std.testing.allocator);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, .{}, &mirror_tree_files, &.{});
defer parts.deinit();
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, .regular);
// A second commit whose classification fails, so the discarded speculative
// build and its replacement both unwind.
var broken = try buildMirrorParts(gpa, .{ .mutation = .manifest_missing_file }, &mirror_tree_files, &.{parts.name});
defer broken.deinit();
try ingestMirrorCommit(gpa, &importer, 20, 200, .{ .mark = 100 }, &.{}, &mirror_tree_files, &broken, .regular);
}
test "git import mirror classification releases every allocation it makes under allocation failure" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, mirrorClassifierOomNoLeak, .{});
}
// ---------------------------------------------------------------------------
// Normalized-tree and provenance digests
// ---------------------------------------------------------------------------
/// One commit's ledger entry, by position in stream order.
fn ledgerCommit(finalization: *const git_import.FinalizedRefs, index: usize) !git_import.LedgerCommit {
if (index >= finalization.ledger.commits.len) return error.TestUnexpectedResult;
return finalization.ledger.commits[index];
}
fn expectDigest(expected: []const u8, actual: git_import.Digest) !void {
var buffer: [64]u8 = undefined;
try std.testing.expectEqualStrings(expected, git_import.formatDigest(actual, &buffer));
}
const DigestFile = struct {
path: []const u8,
content: []const u8,
mode: fast_export.FileMode = .regular,
};
/// Imports one commit holding exactly `files` and returns its ledger entry.
/// The importer and its finalization stay with the caller, because every
/// digest in the ledger borrows from them.
fn digestCommands(
gpa: std.mem.Allocator,
importer: *git_import.Importer,
files: []const DigestFile,
) !std.ArrayList(fast_export.FileCommand) {
var commands: std.ArrayList(fast_export.FileCommand) = .empty;
errdefer commands.deinit(gpa);
for (files, 1..) |file, mark| {
try importer.ingest(blob(@intCast(mark), null, file.content));
try commands.append(gpa, .{ .modify = .{
.mode = file.mode,
.data = .{ .mark = @intCast(mark) },
.path = file.path,
} });
}
return commands;
}
/// The normalized-tree digest of a single-commit import of `files`.
fn treeDigestOf(gpa: std.mem.Allocator, files: []const DigestFile) !git_import.Digest {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var commands = try digestCommands(gpa, &importer, files);
defer commands.deinit(gpa);
try importer.ingest(commit(100, oid_a, null, &.{}, commands.items, "digest fixture"));
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
return (try ledgerCommit(&finalization, 0)).tree_digest;
}
test "git import normalized digest pins its domain-framed encoding" {
const gpa = std.testing.allocator;
// Independently reproducible vectors. The empty tree is the domain alone:
// printf 'zit-import-git-tree-v1\0' | shasum -a 256
// The three-entry vector is the same domain followed, per entry in
// canonical filename order, by a big-endian u64 path length, the path, one
// permission byte (regular=0, executable=1, symlink=2), a big-endian u64
// content length, and the content's raw SHA-256. Neither value may drift:
// Task 6 verifies a packed generation against them, and Task 8 compares
// flat and tree censuses by them.
const empty_digest = "eedb9cfd37cfbbb728fd28f6b8ba2cf538b17a841a6189d157e8c7827cc7cece";
const three_entry_digest = "fffd82fbea60f7408b84470f59e582e978aed06cc354a0e9cefc5bf2240d5115";
try expectDigest(empty_digest, try treeDigestOf(gpa, &.{}));
const files = [_]DigestFile{
.{ .path = "a.txt", .content = "alpha body" },
.{ .path = "b.sh", .content = "#!/bin/sh\n", .mode = .executable },
.{ .path = "c.lnk", .content = "a.txt", .mode = .symlink },
};
try expectDigest(three_entry_digest, try treeDigestOf(gpa, &files));
}
test "git import normalized digest separates path, content, and permission" {
const gpa = std.testing.allocator;
const base = [_]DigestFile{
.{ .path = "a.txt", .content = "alpha body" },
.{ .path = "b.sh", .content = "#!/bin/sh\n", .mode = .executable },
};
const renamed = [_]DigestFile{
.{ .path = "a2.txt", .content = "alpha body" },
.{ .path = "b.sh", .content = "#!/bin/sh\n", .mode = .executable },
};
const rewritten = [_]DigestFile{
.{ .path = "a.txt", .content = "alpha body." },
.{ .path = "b.sh", .content = "#!/bin/sh\n", .mode = .executable },
};
const rechmodded = [_]DigestFile{
.{ .path = "a.txt", .content = "alpha body" },
.{ .path = "b.sh", .content = "#!/bin/sh\n", .mode = .regular },
};
// Content that merely moves between paths is a different tree, so the
// digest cannot be a hash of the concatenated contents alone.
const swapped = [_]DigestFile{
.{ .path = "a.txt", .content = "#!/bin/sh\n" },
.{ .path = "b.sh", .content = "alpha body", .mode = .executable },
};
const want = try treeDigestOf(gpa, &base);
try std.testing.expectEqual(want, try treeDigestOf(gpa, &base));
for ([_][]const DigestFile{ &renamed, &rewritten, &rechmodded, &swapped }) |variant| {
try std.testing.expect(!std.mem.eql(u8, &want, &(try treeDigestOf(gpa, variant))));
}
}
test "git import normalized digest is one value for the flat and S-card renderings of one file set" {
const gpa = std.testing.allocator;
var digests: [2]git_import.Digest = undefined;
var had_subs: [2]bool = .{ false, false };
for ([_]git_import.SubmanifestChoice{ .flat, .{ .tree = 5 } }, 0..) |choice, index| {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{
.submanifest = choice,
});
defer importer.deinit();
try importer.ingest(blob(1, null, "first body"));
try importer.ingest(blob(2, null, "second body"));
var commands = try twoLeafCommands(gpa, 1, 2);
defer commands.deinit();
try importer.ingest(commit(100, oid_a, null, &.{}, commands.commands.items, "shape fixture"));
var tree = try importedTree(gpa, &fixture, &importer, 100);
defer tree.deinit();
had_subs[index] = tree.checkin.manifest.s.len != 0;
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
digests[index] = (try ledgerCommit(&finalization, 0)).tree_digest;
}
// The two runs really did choose different roots, so the equality below is
// a claim about the digest rather than about two identical manifests.
try std.testing.expect(!had_subs[0]);
try std.testing.expect(had_subs[1]);
try std.testing.expectEqual(digests[0], digests[1]);
}
test "git import buildTree oracle reproduces the production root and every reachable S node" {
const gpa = std.testing.allocator;
// The flat import supplies the canonical entry list the batch builder
// takes; the tree import supplies what the incremental writer produced
// from the same commit. Neither is derived from the other.
var flat_fixture = try Fixture.init(gpa);
defer flat_fixture.deinit();
var flat_importer = try git_import.Importer.init(gpa, &flat_fixture.log, flat_fixture.treeStore(), .{
.submanifest = .flat,
});
defer flat_importer.deinit();
try flat_importer.ingest(blob(1, null, "first body"));
try flat_importer.ingest(blob(2, null, "second body"));
var flat_commands = try twoLeafCommands(gpa, 1, 2);
defer flat_commands.deinit();
try flat_importer.ingest(commit(100, oid_a, null, &.{}, flat_commands.commands.items, "oracle fixture"));
var flat_tree = try importedTree(gpa, &flat_fixture, &flat_importer, 100);
defer flat_tree.deinit();
try std.testing.expectEqual(@as(usize, 0), flat_tree.checkin.manifest.s.len);
var tree_fixture = try Fixture.init(gpa);
defer tree_fixture.deinit();
var tree_importer = try git_import.Importer.init(gpa, &tree_fixture.log, tree_fixture.treeStore(), .{
.submanifest = .{ .tree = 5 },
});
defer tree_importer.deinit();
try tree_importer.ingest(blob(1, null, "first body"));
try tree_importer.ingest(blob(2, null, "second body"));
var tree_commands = try twoLeafCommands(gpa, 1, 2);
defer tree_commands.deinit();
try tree_importer.ingest(commit(100, oid_a, null, &.{}, tree_commands.commands.items, "oracle fixture"));
var produced = try loadManifest(&tree_fixture, gpa, try tree_importer.objectForMark(100));
defer produced.deinit(gpa);
// Same algorithm as the import, or the oracle names a different tree.
var oracle = try artifact.buildTree(gpa, flat_tree.checkin.manifest.f, 5, git_import.import_hash_algo);
defer oracle.deinit();
try std.testing.expect(!oracle.isFlat());
// The root's own S run, card for card.
try std.testing.expectEqual(oracle.s.len, produced.manifest.s.len);
for (oracle.s, produced.manifest.s) |want, got| {
try std.testing.expectEqualStrings(want.filename, got.filename);
try std.testing.expectEqualStrings(want.hash, got.hash);
}
// And every sub-manifest the root reaches, byte for byte out of the log.
try std.testing.expect(oracle.subs.len >= oracle.s.len);
for (oracle.subs) |sub| {
const rid = tree_fixture.lookupName(sub.name) orelse return error.TestUnexpectedResult;
const stored = (try tree_fixture.log.content(gpa, rid)) orelse return error.TestUnexpectedResult;
defer gpa.free(stored);
try std.testing.expectEqualSlices(u8, sub.bytes, stored);
}
}
const digest_oid_d = "4444444444444444444444444444444444444444";
const digest_author = fast_export.Identity{
.name = "Digest Author",
.email = "[email protected]",
.timestamp = 1_700_000_100,
.timezone_offset_minutes = 60,
};
const digest_committer = fast_export.Identity{
.name = "Digest Committer",
.email = "[email protected]",
.timestamp = 1_700_000_200,
.timezone_offset_minutes = -120,
};
const ProvenanceVariant = enum {
base,
extra_ref,
/// `extra_ref` with the same ref count and a different ref name, so the
/// digest has to cover what the refs ARE rather than how many there are.
renamed_ref,
annotated_tag,
/// `annotated_tag` with the same tag count and a different message, for
/// the same reason.
retagged_message,
author_attribution,
};
const ProvenanceSample = struct {
commit: git_import.Digest,
repository: git_import.Digest,
};
fn provenanceSample(gpa: std.mem.Allocator, variant: ProvenanceVariant) !ProvenanceSample {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{
.attribution = if (variant == .author_attribution) .author else .committer,
});
defer importer.deinit();
try importer.ingest(blob(1, oid_b, "provenance body"));
const commands = [_]fast_export.FileCommand{.{ .modify = .{
.mode = .regular,
.data = .{ .mark = 1 },
.path = "tracked.txt",
} }};
try importer.ingest(.{ .commit = .{
.ref = "refs/heads/main",
.mark = 10,
.original_oid = oid_a,
.author = digest_author,
.committer = digest_committer,
.message = "provenance fixture",
.from = null,
.merges = &.{},
.commands = &commands,
} });
if (variant == .extra_ref or variant == .renamed_ref) {
try importer.ingest(.{ .reset = .{
.ref = if (variant == .extra_ref) "refs/heads/release" else "refs/heads/staging",
.target = .{ .mark = 10 },
} });
}
if (variant == .annotated_tag or variant == .retagged_message) {
try importer.ingest(.{ .tag = .{
.name = "v1",
.mark = 20,
.target = .{ .mark = 10 },
.original_oid = digest_oid_d,
.tagger = digest_author,
.message = if (variant == .annotated_tag) "release" else "release candidate",
} });
}
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
return .{
.commit = (try ledgerCommit(&finalization, 0)).provenance_digest,
.repository = finalization.ledger.provenance_digest,
};
}
test "git import provenance digest separates attribution from refs and tags" {
const gpa = std.testing.allocator;
const base = try provenanceSample(gpa, .base);
try std.testing.expectEqual(base, try provenanceSample(gpa, .base));
// A ref or a tag is repository scope: the commit itself did not change,
// and its own digest must say so, while the import as a whole did.
for ([_]ProvenanceVariant{ .extra_ref, .renamed_ref, .annotated_tag, .retagged_message }) |variant| {
const sample = try provenanceSample(gpa, variant);
try std.testing.expectEqual(base.commit, sample.commit);
try std.testing.expect(!std.mem.eql(u8, &base.repository, &sample.repository));
}
// And the mappings themselves are covered, not merely counted: each pair
// below has the same number of refs and tags as the other and differs
// only in what they say.
const extra_ref = try provenanceSample(gpa, .extra_ref);
const renamed_ref = try provenanceSample(gpa, .renamed_ref);
try std.testing.expect(!std.mem.eql(u8, &extra_ref.repository, &renamed_ref.repository));
const tagged = try provenanceSample(gpa, .annotated_tag);
const retagged = try provenanceSample(gpa, .retagged_message);
try std.testing.expect(!std.mem.eql(u8, &tagged.repository, &retagged.repository));
// The selected attribution is commit scope, and reaches the repository
// digest only through the commit's.
const attributed = try provenanceSample(gpa, .author_attribution);
try std.testing.expect(!std.mem.eql(u8, &base.commit, &attributed.commit));
try std.testing.expect(!std.mem.eql(u8, &base.repository, &attributed.repository));
}
/// The provenance digest of the merge commit in a three-parent history whose
/// merge parents are listed in `order`.
fn mergeProvenanceDigest(gpa: std.mem.Allocator, order: [2]u64) !git_import.Digest {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
try importer.ingest(commit(10, oid_a, null, &.{}, &.{}, "first root"));
try importer.ingest(commit(11, oid_b, null, &.{}, &.{}, "second root"));
try importer.ingest(commit(12, oid_c, null, &.{}, &.{}, "third root"));
const merges = [_]fast_export.ObjectRef{ .{ .mark = order[0] }, .{ .mark = order[1] } };
try importer.ingest(commit(13, digest_oid_d, .{ .mark = 10 }, &merges, &.{}, "merge"));
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
return (try ledgerCommit(&finalization, 3)).provenance_digest;
}
test "git import provenance digest preserves ordered Git parents" {
const gpa = std.testing.allocator;
const forward = try mergeProvenanceDigest(gpa, .{ 11, 12 });
try std.testing.expectEqual(forward, try mergeProvenanceDigest(gpa, .{ 11, 12 }));
// Same parent set, different order: a different history, therefore a
// different digest.
const reversed = try mergeProvenanceDigest(gpa, .{ 12, 11 });
try std.testing.expect(!std.mem.eql(u8, &forward, &reversed));
}
const MirrorProvenance = struct {
tree: git_import.Digest,
provenance: git_import.Digest,
decision: git_import.MirrorDecisionKind,
reason: git_import.MirrorRetainReason,
candidates: [3]?git_import.LedgerCandidate,
candidate_artifacts: usize,
dropped: usize,
};
/// Imports one mirror commit and reports what the ledger says about it.
/// `tags_override`, when given, replaces the exported `manifest.tags` body
/// without touching the pair that proves the commit.
fn mirrorProvenance(
gpa: std.mem.Allocator,
spec: MirrorSpec,
tags_override: ?[]const u8,
) !MirrorProvenance {
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
var parts = try buildMirrorParts(gpa, spec, &mirror_tree_files, &.{});
defer parts.deinit();
if (tags_override) |body| {
if (parts.tags) |old| gpa.free(old);
parts.tags = try gpa.dupe(u8, body);
}
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &mirror_tree_files, &parts, spec.mode);
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
const entry = try ledgerCommit(&finalization, 0);
var dropped: usize = 0;
for (finalization.ledger.candidates) |candidate| {
if (candidate.dropped) dropped += 1;
}
return .{
.tree = entry.tree_digest,
.provenance = entry.provenance_digest,
.decision = entry.decision,
.reason = entry.reason,
.candidates = entry.candidates,
.candidate_artifacts = finalization.ledger.candidates.len,
.dropped = dropped,
};
}
test "git import provenance digest records the mirror decision and every candidate including tags" {
const gpa = std.testing.allocator;
const valid = try mirrorProvenance(gpa, .{}, null);
try std.testing.expectEqual(git_import.MirrorDecisionKind.omitted, valid.decision);
// All three exported candidates are recorded, the dependent tags member
// among them, and all three are droppable here because nothing else in
// the import names their content.
// Stored under the import's own algorithm, so the expected width follows
// it rather than a literal that only held while that algorithm was SHA-1.
var width_buf: [64]u8 = undefined;
const name_width = nameFor("", &width_buf).len;
for (valid.candidates, git_import.mirror_candidate_names) |slot, path| {
const candidate = slot orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(path, candidate.path);
try std.testing.expectEqual(name_width, candidate.name.len);
}
try std.testing.expectEqual(@as(usize, 3), valid.candidate_artifacts);
try std.testing.expectEqual(@as(usize, 3), valid.dropped);
// Changing only `manifest.tags` leaves the emitted tree byte-identical --
// the candidates are not in it -- so the tree digest cannot see it. The
// provenance digest must, or the tags member's decision would be
// unrecorded and a verifier could not tell the two imports apart.
const other_tags = try mirrorProvenance(gpa, .{}, "branch release\ntag release\n");
try std.testing.expectEqual(git_import.MirrorDecisionKind.omitted, other_tags.decision);
try std.testing.expectEqual(valid.tree, other_tags.tree);
try std.testing.expect(!std.mem.eql(u8, &valid.provenance, &other_tags.provenance));
// A retained commit records why, and its candidates stay in the tree.
const retained = try mirrorProvenance(gpa, .{ .mutation = .uuid_names_other_artifact }, null);
try std.testing.expectEqual(git_import.MirrorDecisionKind.retained, retained.decision);
try std.testing.expectEqual(git_import.MirrorRetainReason.uuid_mismatch, retained.reason);
try std.testing.expectEqual(@as(usize, 0), retained.candidate_artifacts);
try std.testing.expect(!std.mem.eql(u8, &valid.tree, &retained.tree));
try std.testing.expect(!std.mem.eql(u8, &valid.provenance, &retained.provenance));
}
test "git import ledger and provenance JSONL agree on candidates, references, and the drop set" {
const gpa = std.testing.allocator;
var fixture = try Fixture.init(gpa);
defer fixture.deinit();
var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{});
defer importer.deinit();
// The bytes fossil put in `manifest.tags` are also an ordinary project
// file in the same commit, so exactly one candidate artifact is shared
// with a retained tree entry and must survive the drop set.
const files = mirror_tree_files ++ [_]MirrorFile{
.{ .path = "zz-copy-of-tags.txt", .content = "branch trunk\ntag trunk\n" },
};
var parts = try buildMirrorParts(gpa, .{}, &files, &.{});
defer parts.deinit();
// Named once with an original ID, so the provenance JSONL carries a blob
// record for exactly this content.
try importer.ingest(blob(90, oid_b, "branch trunk\ntag trunk\n"));
try ingestMirrorCommit(gpa, &importer, 1, 100, null, &.{}, &files, &parts, .regular);
var finalization = try importer.finalizeRefs(.{});
defer finalization.deinit(gpa);
const drops = try importer.mirrorDropNames(gpa);
defer {
for (drops) |name| gpa.free(name);
gpa.free(drops);
}
// Every candidate artifact appears once in the ledger, and the ledger's
// dropped flags are exactly the drop set the packer will be handed.
var ledger_dropped: usize = 0;
for (finalization.ledger.candidates) |candidate| {
try std.testing.expectEqual(candidate.retained_references == 0, candidate.dropped);
if (!candidate.dropped) continue;
ledger_dropped += 1;
var found = false;
for (drops) |name| {
if (std.mem.eql(u8, name, candidate.name)) found = true;
}
try std.testing.expect(found);
}
try std.testing.expectEqual(drops.len, ledger_dropped);
// The shared `manifest.tags` content is a candidate that is not dropped.
try std.testing.expectEqual(@as(usize, 3), finalization.ledger.candidates.len);
try std.testing.expectEqual(@as(usize, 2), ledger_dropped);
// And the JSONL says the same thing about the blob that carries the
// shared bytes: a candidate, referenced once, and therefore not dropped.
var tags_name_buf: [64]u8 = undefined;
const tags_name = nameFor(parts.tags.?, &tags_name_buf);
const shared_record = try std.fmt.allocPrint(
gpa,
"\"zit\":\"{s}\",\"retained_references\":1,\"mirror_candidate\":true,\"dropped\":false",
.{tags_name},
);
defer gpa.free(shared_record);
try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, shared_record) != null);
try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, "\"decision\":\"omitted\"") != null);
try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, "\"kind\":\"summary\"") != null);
// Every line stays valid JSON with the new fields in place.
var lines = std.mem.splitScalar(u8, finalization.provenance_jsonl[0 .. finalization.provenance_jsonl.len - 1], '\n');
while (lines.next()) |line| {
var parsed = try std.json.parseFromSlice(std.json.Value, gpa, line, .{});
defer parsed.deinit();
try std.testing.expect(parsed.value == .object);
}
}