Artifact 2963b51cafc80bdd52793aff8bdb2475d5066ad49d21a22b842b9294fee76d12:
- File
src/git_import_refs_test.zig
— part of check-in
[f0331d2f5a]
at
2026-07-31 19:25:02
on branch sub-manifest
— import-git: refs that differ only by case collide, and say so
sqlite carries refs/remotes/origin/EQP-improvements and .../eqp-improvements pointing at different commits. Both are legal, distinct git refs. Zit stores a ref by creating a file at its canonical path, so on APFS the second one landed on the first: bare PathAlreadyExists, raised by writeRefs during publication, after 49 minutes of completed import. No ref name, no cause.
The refusal already existed. finalizeRefs rejects two source refs that resolve to one canonical leaf -- 'refs/heads/./release' against 'refs/heads/release' is a tested case. It just compared bytes, and a filesystem does not. Keying the canonical index by an ASCII fold makes the existing rule see the collision it was written to catch, uniformly, instead of leaving it to whichever disk the import ran against.
ASCII rather than Unicode is deliberate and documented at the fold: APFS, HFS+ and NTFS each fold a different frozen table, so following them would make the importable set depend on the volume. A-Z is what they agree on.
The error now names both source refs, both canonical spellings, and whether case alone separates them -- a normalization clash and a case clash want different fixes from the operator. Retained on the importer and formatted by the CLI, the same split FileCommandOverrun uses.
This refuses on Linux what Linux tolerated, which is the point: the repository means the same thing everywhere or it is not portable. Making sqlite importable rather than merely diagnosable is a layout question -- one file per ref cannot represent it -- and stays open. (user: dmestas, size: 38828) [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; } 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)); try std.testing.expect(try task9HasSymbolicControl(gpa, &finalization, "sym-main", 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 refuses refs that differ only by case and names both" { // sqlite carries refs/remotes/origin/EQP-improvements and // .../eqp-improvements at different commits. Both are legal, distinct git // refs, so nothing upstream of zit objects; the canonical index compared // bytes and let them through, and the filesystem refused the second file // after the whole import had already run. The refusal belongs here, where // it is uniform across filesystems and can say which two refs clashed. 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 } } }); try std.testing.expectError(error.CanonicalRefCollision, importer.finalizeRefs(.{})); const collision = importer.ref_collision.?; try std.testing.expect(collision.case_only); try std.testing.expectEqualStrings("refs/remotes/origin/EQP-improvements", collision.first_source); try std.testing.expectEqualStrings("refs/remotes/origin/eqp-improvements", collision.second_source); // Both spellings are reported: neither alone shows the clash. try std.testing.expectEqualStrings("sym-refs/remotes/origin/EQP-improvements", collision.first_canonical); try std.testing.expectEqualStrings("sym-refs/remotes/origin/eqp-improvements", collision.second_canonical); } test "git import reports a normalization collision as distinct from a case one" { // The pre-existing arm. Same error, but the two sources canonicalize to a // byte-identical leaf, so `case_only` must stay false and the report must // not blame letter case for a path-normalization clash. 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.expect(!collision.case_only); try std.testing.expectEqualStrings("sym-heads/release", collision.first_canonical); try std.testing.expectEqualStrings("sym-heads/release", collision.second_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); 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}},"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}} \\ , .{ oid_a, checkin_hash, checkin_hash, checkin_hash, checkin_hash }, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, first.provenance_jsonl); 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. Both branch controls must survive. 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); try std.testing.expect(try task9HasPropagatingBranchProperty( gpa, &finalization, feature_child_hash, "feature", )); try std.testing.expect(try task9HasPropagatingBranchProperty( gpa, &finalization, main_child_hash, "main", )); } 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); }