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,
};
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 (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);
}