Artifact fc263d4ba445686b643c2186917d1b7b1c860aa32f14758ed9cf38b837b02d6b:
- File
src/git_import_refs_test.zig
— part of check-in
[5caf866d86]
at
2026-08-07 15:29:07
on branch sub-manifest
— import-git: name the main line trunk, whatever git called it
A git default branch is master or main; this format's is trunk, and so is every place that must name the main line without being told -- a fresh checkout, zit open resolving a tip, a clone initialising its own state. A repository whose main line was called master satisfied none of them: it cloned and verified clean, then could not be opened, because the branch the destination looked for held no commits.
labelBranches now takes the name to MATCH on and the name to APPLY separately: the head is still found by its source name, the segment is labelled trunk. Side branches keep their source names, and the git-side refs mapping still records sym-heads/master, so nothing about round-trip identity moves.
If a side branch already publishes trunk, the source name is kept rather than colliding two segments under one label. (#42) (user: dmestas, size: 42724) [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 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, }; const MemoryFile = struct { gpa: std.mem.Allocator, bytes: std.ArrayList(u8) = .empty, 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.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, 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)); const rid = self.lookupName(name) orelse return error.MissingNode; const bytes = self.log.content(allocator, rid) catch return error.StorageFailure; return .{ .bytes = bytes orelse return error.MissingNode, .allocator = allocator }; } fn putTreeNode(ctx: *anyopaque, name: []const u8, bytes: []const u8) editor.StoreError!void { const self: *Fixture = @ptrCast(@alignCast(ctx)); var expected_buf: [64]u8 = undefined; const expected = artifact.artifactName(.sha1, bytes, &expected_buf); if (!std.mem.eql(u8, name, expected)) return error.StorageFailure; var raw_name_buf: [32]u8 = undefined; const raw_name = rawHashName(name, &raw_name_buf) orelse return error.StorageFailure; _ = self.log.putArtifact(raw_name, bytes, null) catch return error.StorageFailure; } fn putTreeNodes(ctx: *anyopaque, nodes: []const editor.StoreBatchNode) editor.StoreError!void { const self: *Fixture = @ptrCast(@alignCast(ctx)); var digests: std.ArrayList([32]u8) = .empty; defer digests.deinit(self.gpa); var items: std.ArrayList(logstore.LogStore.ArtifactBatchItem) = .empty; defer items.deinit(self.gpa); digests.ensureTotalCapacity(self.gpa, nodes.len) catch return error.OutOfMemory; items.ensureTotalCapacity(self.gpa, nodes.len) catch return error.OutOfMemory; for (nodes) |node| { var expected_buf: [64]u8 = undefined; const expected = artifact.artifactName(.sha1, node.bytes, &expected_buf); if (!std.mem.eql(u8, node.name, expected)) return error.StorageFailure; var digest: [32]u8 = undefined; const raw = rawHashName(node.name, &digest) orelse return error.StorageFailure; digests.appendAssumeCapacity(digest); items.appendAssumeCapacity(.{ .hash = digests.items[digests.items.len - 1][0..raw.len], .bytes = node.bytes, }); } self.log.putArtifactBatch(items.items) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.StorageFailure, }; } fn lookupName(self: *const Fixture, name: []const u8) ?logstore.Rid { var raw_name_buf: [32]u8 = undefined; const raw_name = rawHashName(name, &raw_name_buf) orelse return null; return self.log.lookup(raw_name); } }; // Wished API: `Importer.finalizeRefs(options) !FinalizedRefs` returns // caller-owned `controls: [][]u8`, `provenance_jsonl: []u8`, `refs` // ({ source, canonical, target }), and `primary_branch: []u8`. // `deinit(allocator)` frees all returned storage; finalization neither publishes // to a filesystem nor mutates the imported check-ins. fn task9Commit( ref: []const u8, mark: u64, original_oid: ?[]const u8, author: fast_export.Identity, committer: fast_export.Identity, from: ?fast_export.ObjectRef, merges: []const fast_export.ObjectRef, commands: []const fast_export.FileCommand, message: []const u8, ) fast_export.Event { return .{ .commit = .{ .ref = ref, .mark = mark, .original_oid = original_oid, .author = author, .committer = committer, .message = message, .from = from, .merges = merges, .commands = commands, } }; } fn task9ExpectRef(finalization: anytype, source: []const u8, canonical: []const u8, target: []const u8) !void { for (finalization.refs) |mapping| { if (!std.mem.eql(u8, source, mapping.source)) continue; try std.testing.expectEqualStrings(canonical, mapping.canonical); try std.testing.expectEqualStrings(target, mapping.target); return; } return error.TestUnexpectedResult; } fn task9HasPropagatingBranchProperty( gpa: std.mem.Allocator, finalization: anytype, target: []const u8, branch: []const u8, ) !bool { for (finalization.controls) |bytes| { var control = try artifact.parse(gpa, bytes); defer control.deinit(); try std.testing.expectEqual(artifact.ArtifactType.control, control.kind); for (control.t) |card| { const card_target = card.target orelse continue; const card_value = card.value orelse continue; if (card.sign == .propagate and std.mem.eql(u8, card.name, "branch") and std.mem.eql(u8, card_target, target) and std.mem.eql(u8, card_value, branch)) return true; } } return false; } /// Whether ANY control names `branch` at all — the check a head that claimed no /// commits has to fail, since it is represented by its symbolic control alone. fn task9HasBranchValueAnywhere( gpa: std.mem.Allocator, finalization: anytype, branch: []const u8, ) !bool { for (finalization.controls) |bytes| { var control = try artifact.parse(gpa, bytes); defer control.deinit(); for (control.t) |card| { const card_value = card.value orelse continue; if (std.mem.eql(u8, card.name, "branch") and std.mem.eql(u8, card_value, branch)) return true; } } return false; } fn task9HasSymbolicControl( gpa: std.mem.Allocator, finalization: anytype, name: []const u8, target: []const u8, ) !bool { for (finalization.controls) |bytes| { var control = try artifact.parse(gpa, bytes); defer control.deinit(); try std.testing.expectEqual(artifact.ArtifactType.control, control.kind); for (control.t) |card| { const card_target = card.target orelse continue; if (card.sign == .add and std.mem.eql(u8, card.name, name) and std.mem.eql(u8, card_target, target)) return true; } } return false; } test "git import finalizes fork merge refs into symbolic controls without duplicate check-ins" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); // Only this first feature commit changes branch context from its first // parent. Its feature child and the main merge must not gain another // propagating branch property. try importer.ingest(task9Commit("refs/heads/feature", 20, oid_b, identity, identity, .{ .mark = 10 }, &.{}, &.{}, "feature root")); try importer.ingest(task9Commit("refs/heads/feature", 21, null, identity, identity, .{ .mark = 20 }, &.{}, &.{}, "feature child")); try importer.ingest(task9Commit( "refs/heads/main", 30, oid_c, identity, identity, .{ .mark = 10 }, &.{.{ .mark = 21 }}, &.{}, "merge feature", )); try importer.ingest(.{ .reset = .{ .ref = "refs/heads/merge-alias", .target = .{ .mark = 30 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/heads/old-main", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/tags/lightweight", .target = .{ .mark = 30 } } }); try importer.ingest(.{ .tag = .{ .name = "annotated", .mark = null, .target = .{ .mark = 21 }, .original_oid = "4444444444444444444444444444444444444444", .tagger = identity, .message = "annotated release", } }); const root_hash = try importer.objectForMark(10); const feature_root_hash = try importer.objectForMark(20); const feature_tip_hash = try importer.objectForMark(21); const merge_hash = try importer.objectForMark(30); const imported_entries = fixture.log.entries.items.len; var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); try std.testing.expectEqual(imported_entries, fixture.log.entries.items.len); try task9ExpectRef(&finalization, "refs/heads/main", "sym-heads/main", merge_hash); try task9ExpectRef(&finalization, "refs/heads/feature", "sym-heads/feature", feature_tip_hash); try task9ExpectRef(&finalization, "refs/heads/merge-alias", "sym-heads/merge-alias", merge_hash); try task9ExpectRef(&finalization, "refs/heads/old-main", "sym-heads/old-main", root_hash); try task9ExpectRef(&finalization, "refs/tags/lightweight", "sym-tags/lightweight", merge_hash); try task9ExpectRef(&finalization, "refs/tags/annotated", "sym-tags/annotated", feature_tip_hash); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-heads/main", merge_hash)); // `sym-trunk`, not `sym-main`: the import adopts this format's name for the // main line, so the alias that makes it resolvable by name follows it. The // canonical `sym-heads/main` above is unchanged — that one records which // SOURCE ref this was, which the rename does not touch. try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-trunk", merge_hash)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-heads/feature", feature_tip_hash)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-heads/merge-alias", merge_hash)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-heads/old-main", root_hash)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-tags/lightweight", merge_hash)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-tags/annotated", feature_tip_hash)); try std.testing.expect(try task9HasPropagatingBranchProperty(gpa, &finalization, feature_root_hash, "feature")); try std.testing.expect(!(try task9HasPropagatingBranchProperty(gpa, &finalization, feature_tip_hash, "feature"))); try std.testing.expect(!(try task9HasPropagatingBranchProperty(gpa, &finalization, merge_hash, "main"))); } test "git import finalizes remote refs with symbolic controls and provenance" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/main", .target = .{ .mark = 10 } } }); const target = try importer.objectForMark(10); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); try task9ExpectRef(&finalization, "refs/remotes/origin/main", "sym-refs/remotes/origin/main", target); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-refs/remotes/origin/main", target)); try std.testing.expect(std.mem.indexOf( u8, finalization.provenance_jsonl, "\"source\":\"refs/remotes/origin/main\",\"canonical\":\"sym-refs/remotes/origin/main\"", ) != null); } test "git import rejects traversal and collisions in remote refs" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try std.testing.expectError( error.InvalidRef, importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/../main", .target = .{ .mark = 10 } } }), ); try importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/main", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/./main", .target = .{ .mark = 10 } } }); try std.testing.expectError(error.CanonicalRefCollision, importer.finalizeRefs(.{})); } test "git import keeps branch and tag leaves distinct while rejecting normalized branch collisions" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .reset = .{ .ref = "refs/heads/release", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/tags/release", .target = .{ .mark = 10 } } }); var distinct = try importer.finalizeRefs(.{}); defer distinct.deinit(gpa); const target = try importer.objectForMark(10); try task9ExpectRef(&distinct, "refs/heads/release", "sym-heads/release", target); try task9ExpectRef(&distinct, "refs/tags/release", "sym-tags/release", target); var collision_fixture = try Fixture.init(gpa); defer collision_fixture.deinit(); var collision_importer = try git_import.Importer.init(gpa, &collision_fixture.log, collision_fixture.treeStore(), .{}); defer collision_importer.deinit(); try collision_importer.ingest(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try collision_importer.ingest(.{ .reset = .{ .ref = "refs/heads/release", .target = .{ .mark = 10 } } }); try collision_importer.ingest(.{ .reset = .{ .ref = "refs/heads/./release", .target = .{ .mark = 10 } } }); try std.testing.expectError(error.CanonicalRefCollision, collision_importer.finalizeRefs(.{})); } test "git import keeps both refs when they differ only by case" { // sqlite carries refs/remotes/origin/EQP-improvements and // .../eqp-improvements at different commits. Both are legal, distinct git // refs, and nothing about them is ambiguous to zit: the canonical names // differ. Only the old one-file-per-ref layout could not hold both, and // the packed file removes that constraint, so this must import. 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/EQP-improvements", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/remotes/origin/eqp-improvements", .target = .{ .mark = 10 } } }); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); const target = try importer.objectForMark(10); try task9ExpectRef(&finalization, "refs/remotes/origin/EQP-improvements", "sym-refs/remotes/origin/EQP-improvements", target); try task9ExpectRef(&finalization, "refs/remotes/origin/eqp-improvements", "sym-refs/remotes/origin/eqp-improvements", target); try std.testing.expect(importer.ref_collision == null); } test "git import names both sources when two refs share one canonical name" { // The genuine collision: distinct sources normalizing to a byte-identical // canonical name. No layout can hold both, because there is only one name. // The error must still say which two refs produced it. 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .reset = .{ .ref = "refs/heads/release", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .reset = .{ .ref = "refs/heads/./release", .target = .{ .mark = 10 } } }); try std.testing.expectError(error.CanonicalRefCollision, importer.finalizeRefs(.{})); const collision = importer.ref_collision.?; try std.testing.expectEqualStrings("refs/heads/release", collision.first_source); try std.testing.expectEqualStrings("refs/heads/./release", collision.second_source); try std.testing.expectEqualStrings("sym-heads/release", collision.canonical); } test "git import finalization honors the default primary branch override" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(task9Commit("refs/heads/trunk", 20, oid_b, identity, identity, .{ .mark = 10 }, &.{}, &.{}, "trunk")); const trunk_hash = try importer.objectForMark(20); var finalization = try importer.finalizeRefs(.{ .default_branch = "trunk" }); defer finalization.deinit(gpa); try std.testing.expectEqualStrings("trunk", finalization.primary_branch); try task9ExpectRef(&finalization, "refs/heads/trunk", "sym-heads/trunk", trunk_hash); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-trunk", trunk_hash)); } test "git import finalization emits deterministic fixed-order valid provenance JSONL" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); const author = fast_export.Identity{ .name = "Alice \"Author\"", .email = "[email protected]", .timestamp = 1_700_000_001, .timezone_offset_minutes = 60, }; const committer = fast_export.Identity{ .name = "Bob Committer", .email = "[email protected]", .timestamp = 1_700_000_002, .timezone_offset_minutes = -120, }; const tagger = fast_export.Identity{ .name = "Cara Tagger", .email = "[email protected]", .timestamp = 1_700_000_003, .timezone_offset_minutes = 0, }; try importer.ingest(task9Commit("refs/heads/main", 10, oid_a, author, committer, null, &.{}, &.{}, "identity metadata")); try importer.ingest(.{ .reset = .{ .ref = "refs/tags/lightweight", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .tag = .{ .name = "annotated", .mark = 20, .target = .{ .mark = 10 }, .original_oid = "4444444444444444444444444444444444444444", .tagger = tagger, .message = "release\nnotes", } }); const checkin_hash = try importer.objectForMark(10); var first = try importer.finalizeRefs(.{}); defer first.deinit(gpa); var second = try importer.finalizeRefs(.{}); defer second.deinit(gpa); try std.testing.expectEqualStrings(first.provenance_jsonl, second.provenance_jsonl); // Pinned digest vectors. The empty tree's digest is SHA-256 of the tree // domain alone, independently reproducible as // `printf 'zit-import-git-tree-v1\0' | shasum -a 256`; the two provenance // values pin this fixture's exact commit and repository records. Every // digest a later task compares against passes through these three, so // none of them can change without a test saying so. All three were // recomputed from the schema outside this codebase before being written // down; the repository value covers one commit digest, an explicit commit // count, three ordered refs and one tag. const empty_tree_digest = "eedb9cfd37cfbbb728fd28f6b8ba2cf538b17a841a6189d157e8c7827cc7cece"; const commit_provenance_digest = "fabed19945f89a38557a3161f691f670a5c2af46eb7bba221dee4f21ce332d82"; const repository_provenance_digest = "5095d588c0eb2d84ebfb5667df75adfd67ce45407329c2175ced0adf536b7305"; const expected = try std.fmt.allocPrint( gpa, \\{{"kind":"commit","oid":"{s}","zit":"{s}","author":{{"name":"Alice \"Author\"","email":"[email protected]","timestamp":1700000001,"timezone_offset_minutes":60}},"committer":{{"name":"Bob Committer","email":"[email protected]","timestamp":1700000002,"timezone_offset_minutes":-120}},"tree_digest":"{s}","mirror":{{"decision":"none","reason":null,"candidates":[]}},"provenance_digest":"{s}","warning":null}} \\{{"kind":"ref","source":"refs/heads/main","canonical":"sym-heads/main","zit":"{s}","warning":null}} \\{{"kind":"ref","source":"refs/tags/lightweight","canonical":"sym-tags/lightweight","zit":"{s}","warning":"lightweight tag has no annotated metadata"}} \\{{"kind":"tag","source":"refs/tags/annotated","canonical":"sym-tags/annotated","name":"annotated","oid":"4444444444444444444444444444444444444444","target_zit":"{s}","tagger":{{"name":"Cara Tagger","email":"[email protected]","timestamp":1700000003,"timezone_offset_minutes":0}},"message":"release\nnotes","warning":null}} \\{{"kind":"summary","commits":1,"candidate_artifacts":0,"dropped_artifacts":0,"provenance_digest":"{s}","warning":null}} \\ , .{ oid_a, checkin_hash, empty_tree_digest, commit_provenance_digest, checkin_hash, checkin_hash, checkin_hash, repository_provenance_digest, }, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, first.provenance_jsonl); // The ledger carries the same three values the JSONL does, so a verifier // reading either sees one import, not two descriptions of one. try std.testing.expectEqual(@as(usize, 1), first.ledger.commits.len); var digest_buf: [64]u8 = undefined; try std.testing.expectEqualStrings( empty_tree_digest, git_import.formatDigest(first.ledger.commits[0].tree_digest, &digest_buf), ); try std.testing.expectEqualStrings( commit_provenance_digest, git_import.formatDigest(first.ledger.commits[0].provenance_digest, &digest_buf), ); try std.testing.expectEqualStrings( repository_provenance_digest, git_import.formatDigest(first.ledger.provenance_digest, &digest_buf), ); try std.testing.expectEqual(git_import.MirrorDecisionKind.none, first.ledger.commits[0].decision); try std.testing.expectEqual(@as(usize, 0), first.ledger.candidates.len); var lines = std.mem.splitScalar(u8, first.provenance_jsonl[0 .. first.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); } } fn task9Blob(mark: u64, original_oid: []const u8, data: []const u8) fast_export.Event { return .{ .blob = .{ .mark = mark, .original_oid = original_oid, .data = data } }; } fn task9ExpectControlsEqual(left: anytype, right: anytype) !void { try std.testing.expectEqual(left.controls.len, right.controls.len); for (left.controls, right.controls) |left_bytes, right_bytes| { try std.testing.expectEqualSlices(u8, left_bytes, right_bytes); } } test "git import provenance records concrete blob and commit mappings" { 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(task9Blob(1, oid_b, "blob provenance")); const commands = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "tracked.txt", } }}; try importer.ingest(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &commands, "commit provenance")); const blob_hash = try importer.objectForMark(1); const checkin_hash = try importer.objectForMark(10); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); const blob_record = try std.fmt.allocPrint(gpa, "\"kind\":\"blob\",\"oid\":\"{s}\",\"zit\":\"{s}\"", .{ oid_b, blob_hash }); defer gpa.free(blob_record); const commit_record = try std.fmt.allocPrint(gpa, "\"kind\":\"commit\",\"oid\":\"{s}\",\"zit\":\"{s}\"", .{ oid_a, checkin_hash }); defer gpa.free(commit_record); try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, blob_record) != null); try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, commit_record) != null); try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, "\"canonical\":\"sym-tags/annotated\"") == null); } test "git import finalization rejects invalid UTF-8 identity and message before output" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); const invalid = [_]u8{0xff}; const invalid_identity = fast_export.Identity{ .name = invalid[0..], .email = "[email protected]", .timestamp = 1_700_000_000, .timezone_offset_minutes = 0, }; try importer.ingest(task9Commit( "refs/heads/main", 10, oid_a, invalid_identity, invalid_identity, null, &.{}, &.{}, invalid[0..], )); try std.testing.expectError(error.InvalidUtf8, importer.finalizeRefs(.{})); } fn task9FinalizeOomNoLeak(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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .reset = .{ .ref = "refs/tags/lightweight", .target = .{ .mark = 10 } } }); try importer.ingest(.{ .tag = .{ .name = "annotated", .mark = 20, .target = .{ .mark = 10 }, .original_oid = "4444444444444444444444444444444444444444", .tagger = identity, .message = "release", } }); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); } test "git import finalization frees controls and ref tag ownership across allocation failures" { try std.testing.checkAllAllocationFailures(std.testing.allocator, task9FinalizeOomNoLeak, .{}); } test "git import finalization deterministically replays two independent fixtures" { const gpa = std.testing.allocator; var left_fixture = try Fixture.init(gpa); defer left_fixture.deinit(); var right_fixture = try Fixture.init(gpa); defer right_fixture.deinit(); var left = try git_import.Importer.init(gpa, &left_fixture.log, left_fixture.treeStore(), .{}); defer left.deinit(); var right = try git_import.Importer.init(gpa, &right_fixture.log, right_fixture.treeStore(), .{}); defer right.deinit(); const events = [_]fast_export.Event{ task9Blob(1, oid_b, "same blob"), task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "tracked.txt", } }}, "same commit"), .{ .reset = .{ .ref = "refs/tags/lightweight", .target = .{ .mark = 10 } } }, .{ .tag = .{ .name = "annotated", .mark = 20, .target = .{ .mark = 10 }, .original_oid = "4444444444444444444444444444444444444444", .tagger = identity, .message = "same release", } }, }; for (events) |event| { try left.ingest(event); try right.ingest(event); } var left_finalization = try left.finalizeRefs(.{}); defer left_finalization.deinit(gpa); var right_finalization = try right.finalizeRefs(.{}); defer right_finalization.deinit(gpa); try std.testing.expectEqualSlices(u8, left_fixture.file.bytes.items, right_fixture.file.bytes.items); try task9ExpectControlsEqual(&left_finalization, &right_finalization); try std.testing.expectEqualStrings(left_finalization.provenance_jsonl, right_finalization.provenance_jsonl); } test "git import finalizes refs from exact first-parent identities when translated commits deduplicate" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); const author_main: fast_export.Identity = .{ .name = "Main Author", .email = "[email protected]", .timestamp = 1_700_000_001, .timezone_offset_minutes = 0, }; const author_feature: fast_export.Identity = .{ .name = "Feature Author", .email = "[email protected]", .timestamp = 1_700_000_002, .timezone_offset_minutes = 0, }; // These parents differ in Git identity and source ref, but their chosen // committer attribution makes their Zit check-ins byte-identical. try importer.ingest(task9Commit("refs/heads/main", 10, oid_a, author_main, identity, null, &.{}, &.{}, "shared parent")); try importer.ingest(task9Commit("refs/heads/feature", 20, oid_b, author_feature, identity, null, &.{}, &.{}, "shared parent")); const main_parent_hash = try importer.objectForMark(10); const feature_parent_hash = try importer.objectForMark(20); try std.testing.expectEqualStrings(main_parent_hash, feature_parent_hash); // These distinct Git commits also deduplicate, yet their source refs and // exact first parents are crossed. try importer.ingest(task9Commit("refs/heads/feature", 30, oid_c, author_feature, identity, .{ .mark = 10 }, &.{}, &.{}, "shared child")); try importer.ingest(task9Commit( "refs/heads/main", 40, "dddddddddddddddddddddddddddddddddddddddd", author_main, identity, .{ .mark = 20 }, &.{}, &.{}, "shared child", )); const feature_child_hash = try importer.objectForMark(30); const main_child_hash = try importer.objectForMark(40); try std.testing.expectEqualStrings(feature_child_hash, main_child_hash); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); // Deduplication collapses both refs onto ONE check-in, so this is the // degenerate case: two heads on one commit. The primary claims the segment, // `feature` claims nothing, and the single control lands on the segment's // deepest check-in — the shared parent — with everything above it // inheriting by propagation (repo-state §5.4). Which source ref // fast-export emitted a commit under decides none of it (issue #26). try std.testing.expect(try task9HasPropagatingBranchProperty( gpa, &finalization, main_parent_hash, // The default branch is labelled `trunk` on import; `main` named the // source ref, which no longer names a branch in the destination. "trunk", )); try std.testing.expect(!(try task9HasPropagatingBranchProperty( gpa, &finalization, main_child_hash, "main", ))); try std.testing.expect(!(try task9HasBranchValueAnywhere(gpa, &finalization, "feature"))); // A head that claims nothing is still reachable: its symbolic control is // what represents it. try std.testing.expect(try task9HasSymbolicControl( gpa, &finalization, "sym-heads/feature", feature_child_hash, )); } test "git import commit OOM leaves exact-identity mappings unpublished before retry" { const retry_oid = "dddddddddddddddddddddddddddddddddddddddd"; var fail_index: usize = 0; var saw_commit_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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root"))) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } const entries_before = importer.retainedStateEntries(); const retry = task9Commit("refs/heads/feature", 20, retry_oid, identity, identity, .{ .mark = 10 }, &.{}, &.{}, "child"); if (importer.ingest(retry)) |_| { 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_commit_failure = true; try std.testing.expectEqual(entries_before, importer.retainedStateEntries()); try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(20)); try std.testing.expectError(error.UnresolvedMark, importer.objectForOid(retry_oid)); try importer.ingest(retry); _ = try importer.objectForMark(20); _ = try importer.objectForOid(retry_oid); } } try std.testing.expect(saw_commit_failure); } fn task9BlobOidOwnershipOom(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(); // The OID must be duplicated into importer-owned state before the // translated artifact hash is duplicated. Every failure point in that // hand-off must leave neither ownership path leaked. try importer.ingest(task9Blob(1, oid_b, "ownership after OID duplication")); } test "git import blob OID ingestion releases ownership across allocation failures" { try std.testing.checkAllAllocationFailures(std.testing.allocator, task9BlobOidOwnershipOom, .{}); } test "git import propagates branch when a feature's first commit is a merge" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(task9Commit("refs/heads/side", 11, oid_b, identity, identity, .{ .mark = 10 }, &.{}, &.{}, "side parent")); try importer.ingest(task9Commit( "refs/heads/feature", 20, oid_c, identity, identity, .{ .mark = 10 }, &.{.{ .mark = 11 }}, &.{}, "feature merge root", )); const feature_merge_hash = try importer.objectForMark(20); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); try std.testing.expect(try task9HasPropagatingBranchProperty( gpa, &finalization, feature_merge_hash, "feature", )); } test "git import finalization rejects a missing default branch selection" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try std.testing.expectError(error.InvalidPrimaryBranch, importer.finalizeRefs(.{ .default_branch = "trunk" })); } test "git import finalization retains a parentless non-primary branch root control" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "main root")); try importer.ingest(task9Commit("refs/heads/orphan", 20, oid_b, identity, identity, null, &.{}, &.{}, "orphan root")); const orphan_hash = try importer.objectForMark(20); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); try task9ExpectRef(&finalization, "refs/heads/orphan", "sym-heads/orphan", orphan_hash); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-heads/orphan", orphan_hash)); try std.testing.expect(try task9HasPropagatingBranchProperty(gpa, &finalization, orphan_hash, "orphan")); } test "git import finalization resolves nested annotated tags to their check-in" { 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(task9Commit("refs/heads/main", 10, oid_a, identity, identity, null, &.{}, &.{}, "root")); try importer.ingest(.{ .tag = .{ .name = "v1", .mark = 20, .target = .{ .mark = 10 }, .original_oid = "4444444444444444444444444444444444444444", .tagger = identity, .message = "first tag", } }); try importer.ingest(.{ .tag = .{ .name = "v2", .mark = 21, .target = .{ .mark = 20 }, .original_oid = "5555555555555555555555555555555555555555", .tagger = identity, .message = "nested tag", } }); const checkin_hash = try importer.objectForMark(10); var finalization = try importer.finalizeRefs(.{}); defer finalization.deinit(gpa); try task9ExpectRef(&finalization, "refs/tags/v1", "sym-tags/v1", checkin_hash); try task9ExpectRef(&finalization, "refs/tags/v2", "sym-tags/v2", checkin_hash); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-tags/v2", checkin_hash)); try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, "\"source\":\"refs/tags/v2\",\"canonical\":\"sym-tags/v2\"") != null); try std.testing.expect(std.mem.indexOf(u8, finalization.provenance_jsonl, "\"target_zit\":\"") != null); }