Artifact 02f143d1ca55855b18293a59189987b72f22020e559d5eccd750b70aac83aff9:
- File
src/git_import_test.zig
— part of check-in
[d189b0da1d]
at
2026-08-03 03:08:23
on branch sub-manifest
— import-git: classify Fossil mirror metadata as droppable
A Fossil->Git mirror carries root manifest/manifest.uuid/manifest.tags that are not project content: wapp has 252 distinct manifest blobs across 252 commits while its native history tracks no such file. The classifier proves per commit that those bytes are reconstructible from what the import already holds, so packing can drop them.
Fail-closed and cryptographic: stripped-manifest hash equals the uuid, F cards equal the tree, P cards equal validated parents, D card equals the committer instant. A real top-level file named manifest cannot satisfy that by chance, and any inconclusive signal retains every present candidate -- never a strict subset.
Three corrections to the design, all established against the corpus: presence is per-repository (fossil's manifest setting emits each of the three independently), gate 6 must exclude every present candidate since F cards list none of them, and gate 3 must strip the sterilizer line the export appends or the uuid never matches. update.zig's manifest_paths and sterilizer are now public rather than duplicated -- it already had all three names and the exact trailer.
Measured on the pinned wapp corpus: 252/252 commits validate, drop set 512 = 252 manifest + 252 manifest.uuid + 8 distinct manifest.tags.
test 3461/3461, gate, e2e 716/716, git-import-e2e 11/11, dst 6x1000. (user: dmestas, size: 137993) [annotate] [blame] [check-ins using]
const std = @import("std"); const artifact = @import("artifact.zig"); const editor = @import("submanifest_editor.zig"); const fast_export = @import("git_fast_export.zig"); const git_import = @import("git_import.zig"); const logstore = @import("logstore.zig"); const storage = @import("storage.zig"); const update = @import("update.zig"); const submanifest = @import("repository.zig").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)); var expected_buf: [64]u8 = undefined; const expected = artifact.artifactName(.sha1, bytes, &expected_buf); if (!std.mem.eql(u8, name, expected)) 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| { var expected_buf: [64]u8 = undefined; const expected = artifact.artifactName(.sha1, node.bytes, &expected_buf); if (!std.mem.eql(u8, node.name, expected)) 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; } fn nameFor(bytes: []const u8, buffer: *[64]u8) []const u8 { return artifact.artifactName(.sha1, 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-import-map.jsonl", .reserved = true }, .{ .path = ".ZiT-Checkout", .reserved = true }, .{ .path = "ReFs/heads/main", .reserved = true }, .{ .path = ".zit-update-txn-user/file", .reserved = true }, .{ .path = ".ZiT-UpDaTe-TxN-user/file", .reserved = true }, .{ .path = ".zit-checkout-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(), .{ .hash_algo = .sha1, .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 }); _ = try importer.objectForMark(5); } 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; if (!std.mem.eql(u8, card.value.?, "main")) 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 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"); 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"); 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"); 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"); 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"); 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"); 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"); defer gpa.free(forward_labels); try expectLabels(&expected, forward_labels); const reversed_labels = try git_import.labelBranches(gpa, &first_parents, &reversed, "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"); 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")); const nameless = [_]git_import.BranchHead{.{ .name = "", .commit_index = 0 }}; try std.testing.expectError(error.InvalidBranchHead, git_import.labelBranches(gpa, &first_parents, &nameless, "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")); // 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")); 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")); } 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")); // …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"); 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"); 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 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 }, .{ .name = "instant one second later", .spec = .{ .mutation = .timestamp_next_second }, .omit = false }, .{ .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(), .{}); 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 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(), .{}); 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 was // RETAINED, so its UUID is not proven and this commit is inconclusive. 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); try std.testing.expectEqual(@as(u64, 2), census.commits_validated); try std.testing.expectEqual(@as(u64, 1), census.reason(.tree_mismatch)); try std.testing.expectEqual(@as(u64, 1), 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(), .{}); 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 = artifact.artifactName(.sha1, full.tags.?, &tags_name_buf); var manifest_name_buf: [64]u8 = undefined; const manifest_name = artifact.artifactName(.sha1, full.manifest.?, &manifest_name_buf); var uuid_name_buf: [64]u8 = undefined; const uuid_name = artifact.artifactName(.sha1, 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); } 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); var oracle = try artifact.buildTree(gpa, flat_tree.checkin.manifest.f, 5, .sha1); 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. 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.expect(candidate.name.len == 40); } 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 = artifact.artifactName(.sha1, 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); } }