Artifact a0951354e8330fbf3cdb5ad0ab0f979b40111b01a6c848ae227560c43b749886:
- File
src/git/git_import_refs_test.zig
— part of check-in
[5c445bb3f3]
at
2026-09-28 02:39:03
on branch trunk
— Take the writer lock inside LogStore.open and test the lock class
LogStore.open now takes the exclusive lock before it reads the file's length and scans for the end, and a read-only handle refuses to append. purge undo opens the store through the shared writer path.
A new DST sweep runs two to four writers interleaved against one log through a file that models the lock. It flags unlocked or misplaced appends as they happen, checks each writer sees everything committed before it locked and reads back its own writes, including deltas. A source check fails on any write-mode open of the store outside openStoreAppend. An e2e test races commits, tags and user-table writes on one store and counts what landed. (user: dmestas, size: 42975) [annotate] [blame] [check-ins using]
const std = @import("std"); const zit = @import("zit"); const artifact = zit.artifact; const editor = zit.submanifest_editor; const fast_export = zit.git_fast_export; const git_import = zit.git_import; const logstore = zit.logstore; const storage = zit.storage; const 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, .lock = lock, }; /// One writer by construction, so there is no other writer to exclude and /// no other writer's appends to re-read the length for. fn lock(_: *anyopaque) storage.StorageError!void {} 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)); // 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); } }; // 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 = "610425e0bc25194023dded7e429b4cf0aea349b0d5e5a352750e131bf3c8863a"; const repository_provenance_digest = "053a31f8c3e52a2198d588858c9eb8fe85ce79257abd8841a72ea440b820e272"; 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. 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); }