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