Artifact 2717ab140648f77e80fc955ba2f261ec6b6b23455a894cf54d79a9077b22959c:
- File
src/git_import_test.zig
— part of check-in
[dc0aa2f858]
at
2026-07-31 02:56:07
on branch sub-manifest
— git-import: translate a Git fast-export stream into a zit history
`zit import-git SOURCE DEST` reads a Git repository through `git fast- export` and builds a zit repository from it. The destination appears only after the export, artifact creation and publication have all completed, so a failure anywhere leaves no half-published repository behind; this is the first caller of the logstore artifact batch added earlier on this branch, which is what makes a check-in's blobs arrive as a unit.
Mode 160000 now parses as a gitlink instead of error.UnsupportedMode. That completes the split begun in the previous check-in: reporting what the stream said is the parser's job, and deciding that zit has no submodule concept is the importer's. The refusal still happens, but it now explains itself instead of failing as a parse error on input git legitimately emits.
Signed commits are refused by default rather than imported with the signature silently dropped. Fossil has no signature card, so importing one is a loss the operator should choose; --drop-signatures accepts it explicitly.
The sub-manifest parameter is analyzed, not guessed. Preflight measures the source's own name set and pins its measured optimum, and --submanifest-k overrides that only to match another repository's existing pin, which is what lets two repositories share sub-manifests by content address. On a 100,000 file corpus the analyzed value cuts the objective by roughly 3.4x against the fixed default it replaces.
Correctness is checked differentially against real Git rather than against fixtures we wrote: every materialized tree is compared to `git ls-tree` for the corresponding commit, refs and annotated tags are compared to the source, and both comparators now assert that a gitlink is absent rather than merely tolerating it, so the omission is a contract instead of an accident.
The rest is wiring. extras learns the import provenance names, root.zig exports the importer modules across the executable and library boundary, tidy and the CI step guard learn the new test roots and build steps, and the e2e help check learns that a verb spelled import_git in Zig is import-git on the command line. (user: dmestas, size: 61396) [annotate] [blame] [check-ins using]
const std = @import("std"); const artifact = @import("artifact.zig"); const editor = @import("submanifest_editor.zig"); const fast_export = @import("git_fast_export.zig"); const git_import = @import("git_import.zig"); const logstore = @import("logstore.zig"); const storage = @import("storage.zig"); const update = @import("update.zig"); const submanifest = @import("repository.zig").submanifest; const oid_a = "1111111111111111111111111111111111111111"; const oid_b = "2222222222222222222222222222222222222222"; const oid_c = "3333333333333333333333333333333333333333"; const identity = fast_export.Identity{ .name = "Import Test", .email = "[email protected]", .timestamp = 1_700_000_000, .timezone_offset_minutes = 0, }; /// A real append-only LogStore needs only this memory-backed StorageFile in /// tests. TreeStore then uses the same log, so blobs, submanifests, and /// check-ins exercise their production persistence boundary together. const MemoryFile = struct { gpa: std.mem.Allocator, bytes: std.ArrayList(u8) = .empty, append_count: usize = 0, fail_append_ordinal: ?usize = null, fn init(gpa: std.mem.Allocator) MemoryFile { return .{ .gpa = gpa }; } fn deinit(self: *MemoryFile) void { self.bytes.deinit(self.gpa); self.* = undefined; } fn storageFile(self: *MemoryFile) storage.StorageFile { return .{ .context = self, .vtable = &vtable }; } const vtable: storage.StorageFile.VTable = .{ .append = append, .pread = pread, .sync = sync, .size = size, .truncate = truncate, }; fn append(ctx: *anyopaque, data: []const u8) storage.StorageError!void { const self: *MemoryFile = @ptrCast(@alignCast(ctx)); self.append_count += 1; if (self.fail_append_ordinal) |ordinal| { if (self.append_count == ordinal) return error.Io; } self.bytes.appendSlice(self.gpa, data) catch return error.Io; } fn pread(ctx: *anyopaque, offset: u64, out: []u8) storage.StorageError!usize { const self: *MemoryFile = @ptrCast(@alignCast(ctx)); if (offset >= self.bytes.items.len) return 0; const start: usize = @intCast(offset); const n = @min(out.len, self.bytes.items.len - start); @memcpy(out[0..n], self.bytes.items[start..][0..n]); return n; } fn sync(_: *anyopaque) storage.StorageError!void {} fn size(ctx: *anyopaque) storage.StorageError!u64 { const self: *MemoryFile = @ptrCast(@alignCast(ctx)); return self.bytes.items.len; } fn truncate(ctx: *anyopaque, length: u64) storage.StorageError!void { const self: *MemoryFile = @ptrCast(@alignCast(ctx)); const end: usize = @intCast(length); if (end > self.bytes.items.len) return error.Io; self.bytes.shrinkRetainingCapacity(end); } }; fn rawHashName(name: []const u8, buffer: *[32]u8) ?[]const u8 { if (name.len != 40 and name.len != 64) return null; const raw = buffer[0 .. name.len / 2]; _ = std.fmt.hexToBytes(raw, name) catch return null; return raw; } const Fixture = struct { gpa: std.mem.Allocator, file: *MemoryFile, log: logstore.LogStore, tree_loads: usize = 0, fn init(gpa: std.mem.Allocator) !Fixture { const file = try gpa.create(MemoryFile); errdefer gpa.destroy(file); file.* = MemoryFile.init(gpa); errdefer file.deinit(); const log = try logstore.LogStore.open(gpa, file.storageFile(), 0); return .{ .gpa = gpa, .file = file, .log = log }; } fn deinit(self: *Fixture) void { self.log.deinit(self.gpa); self.file.deinit(); self.gpa.destroy(self.file); self.* = undefined; } fn treeStore(self: *Fixture) editor.TreeStore { return .{ .ctx = self, .load_fn = loadTreeNode, .put_fn = putTreeNode, .put_batch_fn = putTreeNodes }; } fn loadTreeNode(ctx: *anyopaque, allocator: std.mem.Allocator, name: []const u8) editor.StoreError!editor.LoadedBytes { const self: *Fixture = @ptrCast(@alignCast(ctx)); self.tree_loads += 1; const rid = self.lookupName(name) orelse return error.MissingNode; const bytes = self.log.content(allocator, rid) catch return error.StorageFailure; return .{ .bytes = bytes orelse return error.MissingNode, .allocator = allocator }; } fn putTreeNode(ctx: *anyopaque, name: []const u8, bytes: []const u8) editor.StoreError!void { const self: *Fixture = @ptrCast(@alignCast(ctx)); 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); } }; const OwnedManifest = struct { bytes: []u8, manifest: artifact.Manifest, fn deinit(self: *OwnedManifest, gpa: std.mem.Allocator) void { self.manifest.deinit(); gpa.free(self.bytes); self.* = undefined; } }; fn loadManifest(fixture: *Fixture, gpa: std.mem.Allocator, hash: []const u8) !OwnedManifest { const rid = fixture.lookupName(hash) orelse return error.TestUnexpectedResult; const bytes = (try fixture.log.content(gpa, rid)) orelse return error.TestUnexpectedResult; errdefer gpa.free(bytes); const manifest = try artifact.parse(gpa, bytes); return .{ .bytes = bytes, .manifest = manifest }; } fn blob(mark: u64, original_oid: ?[]const u8, data: []const u8) fast_export.Event { return .{ .blob = .{ .mark = mark, .original_oid = original_oid, .data = data } }; } fn commit( mark: u64, original_oid: ?[]const u8, from: ?fast_export.ObjectRef, merges: []const fast_export.ObjectRef, commands: []const fast_export.FileCommand, message: []const u8, ) fast_export.Event { return .{ .commit = .{ .ref = "refs/heads/main", .mark = mark, .original_oid = original_oid, .author = identity, .committer = identity, .message = message, .from = from, .merges = merges, .commands = commands, } }; } const CommandSet = struct { gpa: std.mem.Allocator, paths: std.ArrayList([]u8) = .empty, commands: std.ArrayList(fast_export.FileCommand) = .empty, fn deinit(self: *CommandSet) void { for (self.paths.items) |path| self.gpa.free(path); self.paths.deinit(self.gpa); self.commands.deinit(self.gpa); self.* = undefined; } }; /// k=5 groups at least eight files. The ninth selected pathname has five zero /// bits, therefore forms a stable leaf boundary; a one-file child change in /// the first leaf must preserve the second leaf's artifact name. fn twoLeafCommands(gpa: std.mem.Allocator, first_mark: u64, second_mark: u64) !CommandSet { var set = CommandSet{ .gpa = gpa }; errdefer set.deinit(); var candidate: usize = 0; while (set.paths.items.len < 17) : (candidate += 1) { const count = set.paths.items.len; try set.paths.ensureUnusedCapacity(gpa, 1); try set.commands.ensureUnusedCapacity(gpa, 1); const path = try std.fmt.allocPrint(gpa, "p{d:0>8}.txt", .{candidate}); if (count == 8 and artifact.zbits(path) < 5) { gpa.free(path); continue; } set.paths.appendAssumeCapacity(path); set.commands.appendAssumeCapacity(.{ .modify = .{ .mode = .regular, .data = .{ .mark = if (count % 2 == 0) first_mark else second_mark }, .path = path, } }); } return set; } fn nameFor(bytes: []const u8, buffer: *[64]u8) []const u8 { return artifact.artifactName(.sha1, bytes, buffer); } fn rootView(root: editor.RootRun) editor.RootView { return switch (root) { .flat => |cards| .{ .flat = cards }, .tree => |cards| .{ .tree = cards }, }; } fn expectRootEqual(expected: editor.RootRun, actual: *const artifact.Manifest) !void { switch (expected) { .flat => |cards| { try std.testing.expectEqual(@as(usize, 0), actual.s.len); try std.testing.expectEqual(cards.len, actual.f.len); for (cards, actual.f) |want, got| { try std.testing.expectEqualStrings(want.filename, got.filename); try std.testing.expectEqualStrings(want.hash.?, got.hash.?); try std.testing.expectEqual(want.mode, got.mode); } }, .tree => |cards| { try std.testing.expectEqual(@as(usize, 0), actual.f.len); try std.testing.expectEqual(cards.len, actual.s.len); for (cards, actual.s) |want, got| { try std.testing.expectEqualStrings(want.filename, got.filename); try std.testing.expectEqualStrings(want.hash, got.hash); } }, } } fn expectSharedSubmanifest(before: *const artifact.Manifest, after: *const artifact.Manifest) !void { try std.testing.expect(before.s.len >= 2); try std.testing.expect(after.s.len >= 2); for (before.s) |old| for (after.s) |new| { if (std.mem.eql(u8, old.hash, new.hash)) return; }; return error.TestUnexpectedResult; } fn expectParents(manifest: *const artifact.Manifest, expected: []const []const u8) !void { try std.testing.expectEqual(@as(usize, 1), manifest.p.len); const parents = manifest.p[0]; try std.testing.expectEqual(expected.len, parents.count); var it = parents.hashes(); for (expected) |want| { const got = it.next() orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings(want, got); } try std.testing.expect(it.next() == null); } fn findFile(manifest: *const artifact.Manifest, path: []const u8) !artifact.FCard { for (manifest.f) |card| { if (std.mem.eql(u8, card.filename, path)) return card; } return error.TestUnexpectedResult; } /// Keeps every resolver result alive for the duration of a `flattenTree` call: /// the artifact API borrows the slices it is handed. const SubTrees = struct { gpa: std.mem.Allocator, fixture: *Fixture, loaded: std.ArrayList(OwnedManifest) = .empty, fn deinit(self: *SubTrees) void { for (self.loaded.items) |*item| item.deinit(self.gpa); self.loaded.deinit(self.gpa); self.* = undefined; } fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView { const self: *SubTrees = @ptrCast(@alignCast(ctx)); var loaded = loadManifest(self.fixture, self.gpa, hash) catch return null; self.loaded.append(self.gpa, loaded) catch { loaded.deinit(self.gpa); return null; }; const item = &self.loaded.items[self.loaded.items.len - 1]; return .{ .files = item.manifest.f, .subs = item.manifest.s }; } }; /// Every file in a check-in, whatever root shape the importer chose. `subs` /// must outlive the returned slice. fn flatFiles(manifest: *artifact.Manifest, subs: *SubTrees) ![]const artifact.FCard { if (manifest.s.len == 0) return manifest.f; try manifest.flattenTree(.{ .ctx = subs, .resolveFn = SubTrees.resolve }); return manifest.flattened.?; } fn expectNoFile(files: []const artifact.FCard, path: []const u8) !void { for (files) |card| { if (std.mem.eql(u8, card.filename, path)) return error.TestUnexpectedResult; } } test "git import and update share reserved path admission" { const Case = struct { path: []const u8, reserved: bool }; const cases = [_]Case{ .{ .path = ".zit-import-map.jsonl", .reserved = true }, .{ .path = ".ZiT-Checkout", .reserved = true }, .{ .path = "ReFs/heads/main", .reserved = true }, .{ .path = ".zit-update-txn-user/file", .reserved = true }, .{ .path = ".ZiT-UpDaTe-TxN-user/file", .reserved = true }, .{ .path = ".zit-checkout-user/file", .reserved = false }, .{ .path = ".zit-update-txn", .reserved = false }, .{ .path = ".zit-update-txns/user", .reserved = false }, }; for (cases) |case_| { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "reserved")); const before = fixture.log.entries.items.len; const commands = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = case_.path, } }}; const update_rejected = blk: { update.validateWorktreePath(case_.path) catch |err| switch (err) { error.ReservedPath => break :blk true, else => return err, }; break :blk false; }; const importer_rejected = blk: { importer.ingest(commit(10, null, null, &.{}, &commands, "reserved")) catch |err| { try std.testing.expectEqual(error.ReservedPath, err); break :blk true; }; break :blk false; }; try std.testing.expectEqual(case_.reserved, update_rejected); try std.testing.expectEqual(update_rejected, importer_rejected); if (importer_rejected) { try std.testing.expectEqual(before, fixture.log.entries.items.len); } else { try std.testing.expect(fixture.log.entries.items.len > before); } } } test "git import rejects every malformed update worktree path before artifacts or mappings" { const cases = [_][]const u8{ ".", "..", "a//b", "a/./b", "a/../b", "a\\b", "/absolute", }; for (cases) |path| { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "malformed")); const before = fixture.log.entries.items.len; const commands = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = path, } }}; try std.testing.expectError(error.InvalidPath, update.validateWorktreePath(path)); try std.testing.expectError(error.InvalidPath, importer.ingest(commit(10, null, null, &.{}, &commands, "malformed"))); try std.testing.expectEqual(before, fixture.log.entries.items.len); try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10)); } } test "git import work counters report observed tree height" { const gpa = std.testing.allocator; { var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(commit(10, null, null, &.{}, &.{}, "flat")); try std.testing.expectEqual(@as(u8, 0), importer.workCounters().max_tree_height); } var heights: [2]u8 = undefined; for ([_]u4{ 5, 1 }, &heights) |k, *height| { var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{ .submanifest_k = k, }); defer importer.deinit(); try importer.ingest(blob(1, null, "first")); try importer.ingest(blob(2, null, "second")); var commands = try twoLeafCommands(gpa, 1, 2); defer commands.deinit(); try importer.ingest(commit(10, null, null, &.{}, commands.commands.items, "tree")); height.* = importer.workCounters().max_tree_height; try std.testing.expect(height.* != 0); try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &.{}, "unchanged tree")); try std.testing.expectEqual(height.*, importer.workCounters().max_tree_height); } try std.testing.expect(heights[1] > heights[0]); } test "git import stores blobs before check-ins and canonically reuses an unchanged submanifest" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); var commands = try twoLeafCommands(gpa, 1, 2); defer commands.deinit(); try importer.ingest(blob(1, oid_a, "first blob bytes")); try importer.ingest(blob(2, oid_b, "second blob bytes")); try importer.ingest(commit(10, oid_c, null, &.{}, commands.commands.items, "root")); const root_hash = try importer.objectForMark(10); var root = try loadManifest(&fixture, gpa, root_hash); defer root.deinit(gpa); try std.testing.expectEqual(artifact.ArtifactType.manifest, root.manifest.kind); try std.testing.expect(root.manifest.z != null); try std.testing.expect(root.manifest.s.len >= 2); var first_blob_name_buf: [64]u8 = undefined; var second_blob_name_buf: [64]u8 = undefined; const first_blob_hash = nameFor("first blob bytes", &first_blob_name_buf); const second_blob_hash = nameFor("second blob bytes", &second_blob_name_buf); try std.testing.expectEqualStrings(first_blob_hash, try importer.objectForMark(1)); try std.testing.expectEqualStrings(second_blob_hash, try importer.objectForOid(oid_b)); try std.testing.expect((fixture.lookupName(first_blob_hash) orelse return error.TestUnexpectedResult) < (fixture.lookupName(root_hash) orelse return error.TestUnexpectedResult)); for (root.manifest.s) |sub| { try std.testing.expect((fixture.lookupName(sub.hash) orelse return error.TestUnexpectedResult) < (fixture.lookupName(root_hash) orelse return error.TestUnexpectedResult)); } var expected_fixture = try Fixture.init(gpa); defer expected_fixture.deinit(); var expected_editor = try editor.TreeEditor.init(gpa, expected_fixture.treeStore(), .{ .k = 5, .hash_algo = .sha1, .height_max = 16, }, 1 << 20); defer expected_editor.deinit(); var expected_commands: std.ArrayList(editor.TreeCommand) = .empty; defer expected_commands.deinit(gpa); for (commands.paths.items, 0..) |path, index| { try expected_commands.append(gpa, .{ .upsert = .{ .filename = path, .hash = if (index % 2 == 0) first_blob_hash else second_blob_hash, .perm = null, .old_name = null, .mode = .regular, } }); } var expected_root = try expected_editor.apply(.{ .flat = &.{} }, expected_commands.items); defer expected_root.deinit(); try expectRootEqual(expected_root.root, &root.manifest); const child_command = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = commands.paths.items[0], } }}; try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &child_command, "child changes one file")); const child_hash = try importer.objectForMark(11); var child = try loadManifest(&fixture, gpa, child_hash); defer child.deinit(gpa); try expectParents(&child.manifest, &.{root_hash}); try expectSharedSubmanifest(&root.manifest, &child.manifest); var expected_child = try expected_editor.apply(rootView(expected_root.root), &.{.{ .upsert = .{ .filename = commands.paths.items[0], .hash = second_blob_hash, .perm = null, .old_name = null, .mode = .regular, } }}); defer expected_child.deinit(); try expectRootEqual(expected_child.root, &child.manifest); } test "git import de-duplicates matching blobs and rejects conflicting or unresolved identities" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, oid_a, "identical bytes")); try importer.ingest(blob(2, oid_b, "identical bytes")); const hash_one = try importer.objectForMark(1); try std.testing.expectEqualStrings(hash_one, try importer.objectForMark(2)); try std.testing.expectEqualStrings(hash_one, try importer.objectForOid(oid_a)); try std.testing.expectEqualStrings(hash_one, try importer.objectForOid(oid_b)); try std.testing.expectEqual(@as(usize, 1), fixture.log.entries.items.len); try importer.ingest(blob(3, oid_c, "first OID body")); try std.testing.expectError(error.ConflictingOid, importer.ingest(blob(4, oid_c, "different OID body"))); try std.testing.expectError(error.DuplicateMark, importer.ingest(blob(1, null, "duplicate mark"))); try importer.ingest(commit(10, null, null, &.{}, &.{}, "root")); try std.testing.expectError(error.DuplicateMark, importer.ingest(commit(10, null, null, &.{}, &.{}, "duplicate commit mark"))); const unresolved_blob = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 999 }, .path = "missing.txt", } }}; try std.testing.expectError(error.UnresolvedMark, importer.ingest(commit(11, null, null, &.{}, &unresolved_blob, "missing blob"))); try std.testing.expectError(error.UnresolvedMark, importer.ingest(commit(12, null, .{ .mark = 998 }, &.{}, &.{}, "missing parent"))); } test "git import preserves ordered parents while empty commits retain the canonical root" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(commit(100, null, null, &.{}, &.{}, "root metadata")); try importer.ingest(commit(101, null, .{ .mark = 100 }, &.{}, &.{}, "empty child metadata")); try importer.ingest(commit(102, null, .{ .mark = 100 }, &.{}, &.{}, "other parent metadata")); try importer.ingest(commit(103, null, .{ .mark = 101 }, &.{.{ .mark = 102 }}, &.{}, "merge metadata")); try importer.ingest(commit(104, null, .{ .mark = 103 }, &.{ .{ .mark = 101 }, .{ .mark = 102 }, .{ .mark = 100 } }, &.{}, "octopus metadata")); const root_hash = try importer.objectForMark(100); const child_hash = try importer.objectForMark(101); const other_hash = try importer.objectForMark(102); const merge_hash = try importer.objectForMark(103); const octopus_hash = try importer.objectForMark(104); var root = try loadManifest(&fixture, gpa, root_hash); defer root.deinit(gpa); var child = try loadManifest(&fixture, gpa, child_hash); defer child.deinit(gpa); var merge = try loadManifest(&fixture, gpa, merge_hash); defer merge.deinit(gpa); var octopus = try loadManifest(&fixture, gpa, octopus_hash); defer octopus.deinit(gpa); try std.testing.expect(!std.mem.eql(u8, root.bytes, child.bytes)); try std.testing.expect(!std.mem.eql(u8, child.bytes, merge.bytes)); try expectRootEqual(.{ .flat = root.manifest.f }, &child.manifest); try expectParents(&child.manifest, &.{root_hash}); try expectParents(&merge.manifest, &.{ child_hash, other_hash }); try expectParents(&octopus.manifest, &.{ merge_hash, child_hash, other_hash, root_hash }); } test "git import translates Git file modes and classifies a submodule as a gitlink" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try std.testing.expectEqual(fast_export.FileMode.regular, try git_import.modeFromGit(0o100644)); try std.testing.expectEqual(fast_export.FileMode.executable, try git_import.modeFromGit(0o100755)); try std.testing.expectEqual(fast_export.FileMode.symlink, try git_import.modeFromGit(0o120000)); // A submodule is classified, not refused. Dropping it is import policy and // lives in the translator; only modes Git never emits are errors here. try std.testing.expectEqual(fast_export.FileMode.gitlink, try git_import.modeFromGit(0o160000)); try std.testing.expectError(error.UnknownMode, git_import.modeFromGit(0o040000)); try importer.ingest(blob(1, null, "regular")); try importer.ingest(blob(2, null, "executable")); try importer.ingest(blob(3, null, "symlink")); const commands = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "regular.txt" } }, .{ .modify = .{ .mode = .executable, .data = .{ .mark = 2 }, .path = "run" } }, .{ .modify = .{ .mode = .symlink, .data = .{ .mark = 3 }, .path = "link" } }, // No blob is ever declared for this OID: a submodule entry must be // omitted without its target being resolved at all. .{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } }, }; try importer.ingest(commit(10, null, null, &.{}, &commands, "modes")); var manifest = try loadManifest(&fixture, gpa, try importer.objectForMark(10)); defer manifest.deinit(gpa); try std.testing.expectEqual(artifact.FileMode.regular, (try findFile(&manifest.manifest, "regular.txt")).mode); try std.testing.expectEqual(artifact.FileMode.executable, (try findFile(&manifest.manifest, "run")).mode); try std.testing.expectEqual(artifact.FileMode.symlink, (try findFile(&manifest.manifest, "link")).mode); try expectNoFile(manifest.manifest.f, "nested"); try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries); } test "git import accepts a later delete, rename, or copy of an omitted submodule path" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "kept")); const seeded = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } }, .{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } }, }; try importer.ingest(commit(10, null, null, &.{}, &seeded, "submodule added")); // Git dropping the submodule names a path the zit tree never had. That is // not a missing path; it is the omission this import already made. const removed = [_]fast_export.FileCommand{.{ .delete = "nested" }}; try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &removed, "submodule removed")); // Same for a move or a copy whose source is the omitted path: the // destination inherits the omission instead of failing. const moved = [_]fast_export.FileCommand{.{ .rename = .{ .old = "nested", .new = "moved" } }}; try importer.ingest(commit(12, null, .{ .mark = 10 }, &.{}, &moved, "submodule moved")); const copied = [_]fast_export.FileCommand{.{ .copy = .{ .old = "nested", .new = "copied" } }}; try importer.ingest(commit(13, null, .{ .mark = 10 }, &.{}, &copied, "submodule copied")); for ([_]u64{ 11, 12, 13 }) |mark| { var manifest = try loadManifest(&fixture, gpa, try importer.objectForMark(mark)); defer manifest.deinit(gpa); try std.testing.expectEqual(@as(usize, 1), manifest.manifest.f.len); _ = try findFile(&manifest.manifest, "kept.txt"); } // The tolerance reaches submodule paths only. A delete of a path nothing // ever declared still fails, so a genuine missing-path bug still surfaces. const bogus = [_]fast_export.FileCommand{.{ .delete = "never-existed" }}; try std.testing.expectError( error.MissingPath, importer.ingest(commit(20, null, .{ .mark = 10 }, &.{}, &bogus, "bogus delete")), ); // One entry omitted at :10, one re-omitted at each of the move and copy // destinations. try std.testing.expectEqual(@as(u64, 3), importer.workCounters().skipped_submodule_entries); } test "git import writes a path from the commit where it stops being a submodule" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "kept")); try importer.ingest(blob(2, null, "no longer a submodule")); const seeded = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } }, .{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } }, }; try importer.ingest(commit(10, null, null, &.{}, &seeded, "submodule added")); const replaced = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "nested" } }, }; try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &replaced, "submodule became a file")); var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11)); defer after.deinit(gpa); var name_buf: [64]u8 = undefined; try std.testing.expectEqualStrings( nameFor("no longer a submodule", &name_buf), (try findFile(&after.manifest, "nested")).hash.?, ); // And once it is a real file, deleting it is a real delete: the recorded // submodule path must not swallow a delete of the file that replaced it. const deleted = [_]fast_export.FileCommand{.{ .delete = "nested" }}; try importer.ingest(commit(12, null, .{ .mark = 11 }, &.{}, &deleted, "file removed")); var last = try loadManifest(&fixture, gpa, try importer.objectForMark(12)); defer last.deinit(gpa); try std.testing.expectEqual(@as(usize, 1), last.manifest.f.len); _ = try findFile(&last.manifest, "kept.txt"); } test "git import removes a file that a later commit turns into a submodule" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "kept")); try importer.ingest(blob(2, null, "a real file for now")); const seeded = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "kept.txt" } }, .{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "nested" } }, }; try importer.ingest(commit(10, null, null, &.{}, &seeded, "file added")); const submoduled = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = "nested" } }, }; try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &submoduled, "file became a submodule")); var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11)); defer after.deinit(gpa); try expectNoFile(after.manifest.f, "nested"); _ = try findFile(&after.manifest, "kept.txt"); try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries); } test "git import removes a submodule path out of a tree-shaped parent root" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); // A tree-shaped root is the case where the parent lookup has to read stored // sub-manifests, so it is the one that proves the lookup is bound to a live // store rather than the previous commit's staging store. try importer.ingest(blob(1, null, "first")); try importer.ingest(blob(2, null, "second")); var set = try twoLeafCommands(gpa, 1, 2); defer set.deinit(); try importer.ingest(commit(10, null, null, &.{}, set.commands.items, "wide root")); var seeded = try loadManifest(&fixture, gpa, try importer.objectForMark(10)); defer seeded.deinit(gpa); try std.testing.expect(seeded.manifest.s.len != 0); const target = set.paths.items[0]; const submoduled = [_]fast_export.FileCommand{ .{ .modify = .{ .mode = .gitlink, .data = .{ .oid = oid_a }, .path = target } }, }; try importer.ingest(commit(11, null, .{ .mark = 10 }, &.{}, &submoduled, "one path became a submodule")); var after = try loadManifest(&fixture, gpa, try importer.objectForMark(11)); defer after.deinit(gpa); var subs = SubTrees{ .gpa = gpa, .fixture = &fixture }; defer subs.deinit(); const files = try flatFiles(&after.manifest, &subs); try std.testing.expectEqual(set.paths.items.len - 1, files.len); try expectNoFile(files, target); // Deleting the now-omitted path out of the same tree-shaped root still has // to be accepted. const deleted = [_]fast_export.FileCommand{.{ .delete = target }}; try importer.ingest(commit(12, null, .{ .mark = 11 }, &.{}, &deleted, "submodule removed")); try std.testing.expectEqual(@as(u64, 1), importer.workCounters().skipped_submodule_entries); } test "git import accepts a real Git no-from reset before its first commit" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); // `git fast-export --all` emits this reset without a blank separator before // the first commit. Reader must replay that commit, rather than consume it // as reset metadata, and the importer must accept the resulting null reset. const stream = "feature done\n" ++ "blob\nmark :1\noriginal-oid debade3ceb7f902612ff347334ca7dc4357c7292\ndata 12\nlinear root\n\n" ++ "blob\nmark :2\noriginal-oid 100b93820ade4c16225673b4ca62bb3ade63c313\ndata 6\nREADME\n" ++ "blob\nmark :3\noriginal-oid 9aaec01376f8c39683df3f230b972ff6871ad293\ndata 19\n#!/bin/sh\necho zit\n\n" ++ "blob\nmark :4\noriginal-oid fa48ac16de6179989a8ab8fa2b82d4d10cba9cb9\ndata 13\nunusual path\n\n" ++ "reset refs/heads/main\n" ++ "commit refs/heads/main\nmark :5\noriginal-oid ed9b38a10070b504cecfacd892507af78fcb24a4\n" ++ "author Git Author <[email protected]> 981173106 +0000\n" ++ "committer Git Committer <[email protected]> 981173106 +0000\n" ++ "data 12\nlinear root\n" ++ "M 120000 :2 README.link\nM 100644 :1 README\nM 100755 :3 bin/run\nM 100644 :4 \"odd/name with space\\tand-tab\"\n\n" ++ "done\n"; try importer.ingestFastExport(stream, .{ .max_line = 1024, .max_data = 1024, .max_path = 1024 }); _ = try importer.objectForMark(5); } test "git import retains exact commit identity while blob marks cannot become parents" { const gpa = std.testing.allocator; const resolved = try git_import.resolveOptions(.{}); try std.testing.expectEqual(@as(u4, 5), resolved.submanifest_k); var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "a blob is not a commit parent")); try std.testing.expectError( error.UnresolvedMark, importer.ingest(commit(2, null, .{ .mark = 1 }, &.{}, &.{}, "blob parent")), ); const same_commit = fast_export.Commit{ .ref = "refs/heads/main", .mark = 10, .original_oid = null, .author = identity, .committer = identity, .message = "same check-in on two source refs", .from = null, .merges = &.{}, .commands = &.{}, }; try importer.ingest(.{ .commit = same_commit }); var feature_commit = same_commit; feature_commit.ref = "refs/heads/feature"; feature_commit.mark = 11; try importer.ingest(.{ .commit = feature_commit }); try std.testing.expectEqualStrings( try importer.objectForMark(10), try importer.objectForMark(11), ); try importer.ingest(.{ .commit = .{ .ref = "refs/heads/main", .mark = 12, .original_oid = null, .author = identity, .committer = identity, .message = "main follows the feature identity", .from = .{ .mark = 11 }, .merges = &.{}, .commands = &.{}, } }); const child_hash = try importer.objectForMark(12); var refs = try importer.finalizeRefs(.{}); defer refs.deinit(gpa); var found_main_branch = false; for (refs.controls) |bytes| { var control = try artifact.parse(gpa, bytes); defer control.deinit(); for (control.t) |card| { if (std.mem.eql(u8, card.name, "branch") and std.mem.eql(u8, card.value.?, "main") and std.mem.eql(u8, card.target.?, child_hash)) { found_main_branch = true; } } } try std.testing.expect(found_main_branch); } // Task 8 blockers: attribution, identity canonicalization, tag-mark // reservation, pre-epoch dates, and failure atomicity at the persistence edge. test "git import attributes D and U cards to the selected identity" { const gpa = std.testing.allocator; const author = fast_export.Identity{ .name = "Author", .email = "[email protected]", .timestamp = 111, .timezone_offset_minutes = 0, }; const committer = fast_export.Identity{ .name = "Committer", .email = "[email protected]", .timestamp = 222, .timezone_offset_minutes = 0, }; var author_fixture = try Fixture.init(gpa); defer author_fixture.deinit(); var author_importer = try git_import.Importer.init(gpa, &author_fixture.log, author_fixture.treeStore(), .{ .attribution = .author, }); defer author_importer.deinit(); try author_importer.ingest(.{ .commit = .{ .ref = "refs/heads/main", .mark = 10, .original_oid = null, .author = author, .committer = committer, .message = "author attribution", .from = null, .merges = &.{}, .commands = &.{}, } }); var authored = try loadManifest(&author_fixture, gpa, try author_importer.objectForMark(10)); defer authored.deinit(gpa); var default_fixture = try Fixture.init(gpa); defer default_fixture.deinit(); var default_importer = try git_import.Importer.init(gpa, &default_fixture.log, default_fixture.treeStore(), .{}); defer default_importer.deinit(); try default_importer.ingest(.{ .commit = .{ .ref = "refs/heads/main", .mark = 10, .original_oid = null, .author = author, .committer = committer, .message = "committer attribution", .from = null, .merges = &.{}, .commands = &.{}, } }); var committed = try loadManifest(&default_fixture, gpa, try default_importer.objectForMark(10)); defer committed.deinit(gpa); try std.testing.expectEqualStrings("Author", authored.manifest.u.?.login); try std.testing.expectEqualStrings("Committer", committed.manifest.u.?.login); try std.testing.expect(!std.mem.eql(u8, authored.manifest.d.?.timestamp, committed.manifest.d.?.timestamp)); } test "git import canonicalizes OID spelling before deduplication and conflict detection" { const gpa = std.testing.allocator; const lower = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const upper = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, lower, "same OID bytes")); try std.testing.expectEqualStrings(try importer.objectForOid(lower), try importer.objectForOid(upper)); try std.testing.expectError(error.ConflictingOid, importer.ingest(blob(2, upper, "different bytes"))); } test "git import reserves annotated tag marks in the shared object namespace" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(commit(10, null, null, &.{}, &.{}, "tag target")); try importer.ingest(.{ .tag = .{ .name = "v1", .mark = 20, .target = .{ .mark = 10 }, .original_oid = null, .tagger = identity, .message = "release", } }); try std.testing.expectError(error.DuplicateMark, importer.ingest(blob(20, null, "tag mark collision"))); try std.testing.expectError(error.DuplicateMark, importer.ingest(commit(20, null, null, &.{}, &.{}, "tag mark collision"))); } test "git import serializes pre-epoch timestamps distinctly from the epoch" { const gpa = std.testing.allocator; const before_epoch = fast_export.Identity{ .name = "Before Epoch", .email = "[email protected]", .timestamp = -1, .timezone_offset_minutes = 0, }; const at_epoch = fast_export.Identity{ .name = "At Epoch", .email = "[email protected]", .timestamp = 0, .timezone_offset_minutes = 0, }; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(.{ .commit = .{ .ref = "refs/heads/main", .mark = 10, .original_oid = null, .author = before_epoch, .committer = before_epoch, .message = "before epoch", .from = null, .merges = &.{}, .commands = &.{}, } }); try importer.ingest(.{ .commit = .{ .ref = "refs/heads/main", .mark = 11, .original_oid = null, .author = at_epoch, .committer = at_epoch, .message = "at epoch", .from = null, .merges = &.{}, .commands = &.{}, } }); const before_hash = try importer.objectForMark(10); const epoch_hash = try importer.objectForMark(11); var before = try loadManifest(&fixture, gpa, before_hash); defer before.deinit(gpa); var epoch = try loadManifest(&fixture, gpa, epoch_hash); defer epoch.deinit(gpa); try std.testing.expectEqualStrings("1969-12-31T23:59:59", before.manifest.d.?.timestamp); try std.testing.expectEqualStrings("1970-01-01T00:00:00", epoch.manifest.d.?.timestamp); try std.testing.expect(!std.mem.eql(u8, before_hash, epoch_hash)); } test "git import rejects conflicting commit OIDs before persisting a candidate tree or check-in" { const gpa = std.testing.allocator; const shared_oid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "first tree body")); try importer.ingest(blob(2, null, "conflicting tree body")); var root_commands = try twoLeafCommands(gpa, 1, 2); defer root_commands.deinit(); try importer.ingest(commit(10, shared_oid, null, &.{}, root_commands.commands.items, "original commit")); const entries_before = fixture.log.entries.items.len; const end_before = fixture.log.end; const frames_before = fixture.log.frame_count; const tail_before = fixture.log.last_frame_offset; const conflicting_commands = [_]fast_export.FileCommand{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = root_commands.paths.items[0], } }}; try std.testing.expectError(error.ConflictingOid, importer.ingest(commit( 11, shared_oid, null, &.{}, &conflicting_commands, "conflicting commit", ))); try std.testing.expectEqual(entries_before, fixture.log.entries.items.len); try std.testing.expectEqual(end_before, fixture.log.end); try std.testing.expectEqual(frames_before, fixture.log.frame_count); try std.testing.expectEqual(tail_before, fixture.log.last_frame_offset); } fn expectMainRef(importer: *git_import.Importer, gpa: std.mem.Allocator, expected: []const u8) !void { var refs = try importer.finalizeRefs(.{}); defer refs.deinit(gpa); for (refs.refs) |mapping| { if (std.mem.eql(u8, mapping.source, "refs/heads/main")) { try std.testing.expectEqualStrings(expected, mapping.target); return; } } return error.TestUnexpectedResult; } fn expectNoPublishedMainRef(importer: *git_import.Importer, gpa: std.mem.Allocator) !void { var refs = importer.finalizeRefs(.{}) catch |err| { // A failed first commit has no branch to choose as primary. This // intentional finalization precondition proves no main ref published. try std.testing.expectEqual(error.InvalidPrimaryBranch, err); return; }; defer refs.deinit(gpa); try std.testing.expectEqual(@as(usize, 0), refs.refs.len); return error.TestUnexpectedResult; } fn visibleCheckinCount(fixture: *Fixture, gpa: std.mem.Allocator) !usize { var count: usize = 0; for (1..fixture.log.entries.items.len + 1) |ordinal| { const rid: logstore.Rid = @intCast(ordinal); const bytes = (try fixture.log.content(gpa, rid)) orelse continue; defer gpa.free(bytes); var manifest = artifact.parse(gpa, bytes) catch continue; defer manifest.deinit(); if (manifest.kind == .manifest) count += 1; } return count; } test "git import does not consume a tag mark when its target is unresolved" { const gpa = std.testing.allocator; var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try std.testing.expectError(error.UnresolvedMark, importer.ingest(.{ .tag = .{ .name = "unresolved", .mark = 20, .target = .{ .mark = 999 }, .original_oid = null, .tagger = identity, .message = "must not reserve", } })); try importer.ingest(blob(20, null, "the failed tag mark remains available")); _ = try importer.objectForMark(20); } test "git import tag-mark allocation failures leave the mark reusable" { var fail_index: usize = 0; var saw_tag_failure = false; while (true) : (fail_index += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index }); const gpa = failing.allocator(); var fixture = try Fixture.init(std.testing.allocator); defer fixture.deinit(); var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; }; defer importer.deinit(); if (importer.ingest(commit(10, null, null, &.{}, &.{}, "tag target"))) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } const tag = fast_export.Event{ .tag = .{ .name = "v1", .mark = 20, .target = .{ .mark = 10 }, .original_oid = null, .tagger = identity, .message = "release", } }; if (importer.ingest(tag)) |_| { if (!failing.has_induced_failure) break; return error.TestUnexpectedResult; } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); failing.fail_index = std.math.maxInt(usize); saw_tag_failure = true; try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(20)); try importer.ingest(blob(20, null, "the OOM tag mark remains available")); } } try std.testing.expect(saw_tag_failure); } test "git import setRef replacement OOM retains the old target and retry replaces it" { var fail_index: usize = 0; var saw_replacement_failure = false; while (true) : (fail_index += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index }); const gpa = failing.allocator(); var fixture = try Fixture.init(std.testing.allocator); defer fixture.deinit(); var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; }; defer importer.deinit(); if (importer.ingest(commit(10, null, null, &.{}, &.{}, "old main"))) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } if (importer.ingest(.{ .commit = .{ .ref = "refs/heads/other", .mark = 11, .original_oid = null, .author = identity, .committer = identity, .message = "new target", .from = null, .merges = &.{}, .commands = &.{}, } })) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } const old_hash = try importer.objectForMark(10); const new_hash = try importer.objectForMark(11); const replacement = fast_export.Event{ .reset = .{ .ref = "refs/heads/main", .target = .{ .mark = 11 }, } }; if (importer.ingest(replacement)) |_| { if (!failing.has_induced_failure) break; return error.TestUnexpectedResult; } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); failing.fail_index = std.math.maxInt(usize); saw_replacement_failure = true; try expectMainRef(&importer, gpa, old_hash); try importer.ingest(replacement); try expectMainRef(&importer, gpa, new_hash); } } try std.testing.expect(saw_replacement_failure); } test "git import OOM before commit publication leaves the staged tree unpublished and retry succeeds" { const commit_oid = "cccccccccccccccccccccccccccccccccccccccc"; var fail_index: usize = 0; var saw_candidate_failure = false; while (true) : (fail_index += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index }); const gpa = failing.allocator(); var fixture = try Fixture.init(std.testing.allocator); defer fixture.deinit(); var importer = git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; }; defer importer.deinit(); if (importer.ingest(blob(1, null, "staged tree first body"))) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } if (importer.ingest(blob(2, null, "staged tree second body"))) |_| {} else |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; } const entries_before_candidate = fixture.log.entries.items.len; var commands = twoLeafCommands(gpa, 1, 2) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); continue; }; defer commands.deinit(); const candidate = commit(10, commit_oid, null, &.{}, commands.commands.items, "staged tree commit"); if (importer.ingest(candidate)) |_| { if (!failing.has_induced_failure) break; return error.TestUnexpectedResult; } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); failing.fail_index = std.math.maxInt(usize); try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10)); try std.testing.expectError(error.UnresolvedMark, importer.objectForOid(commit_oid)); try expectNoPublishedMainRef(&importer, gpa); const entries_after_failure = fixture.log.entries.items.len; try std.testing.expectEqual(entries_before_candidate, entries_after_failure); try std.testing.expectEqual(@as(usize, 0), try visibleCheckinCount(&fixture, gpa)); try importer.ingest(candidate); const committed_hash = try importer.objectForMark(10); try std.testing.expectEqualStrings(committed_hash, try importer.objectForOid(commit_oid)); try expectMainRef(&importer, gpa, committed_hash); var checkin = try loadManifest(&fixture, gpa, committed_hash); defer checkin.deinit(gpa); try std.testing.expect(checkin.manifest.s.len >= 2); try std.testing.expectEqual( entries_before_candidate + checkin.manifest.s.len + 1, fixture.log.entries.items.len, ); saw_candidate_failure = true; } } try std.testing.expect(saw_candidate_failure); } test "git import batch storage failures publish no candidate artifact or mapping before retry" { const gpa = std.testing.allocator; const commit_oid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; for (1..4) |failure_offset| { var fixture = try Fixture.init(gpa); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "batch storage first body")); try importer.ingest(blob(2, null, "batch storage second body")); var commands = try twoLeafCommands(gpa, 1, 2); defer commands.deinit(); const candidate = commit(10, commit_oid, null, &.{}, commands.commands.items, "batch storage commit"); const entries_before = fixture.log.entries.items.len; const end_before = fixture.log.end; const frames_before = fixture.log.frame_count; fixture.file.fail_append_ordinal = fixture.file.append_count + failure_offset; try std.testing.expectError(error.StorageFailure, importer.ingest(candidate)); fixture.file.fail_append_ordinal = null; try std.testing.expectError(error.UnresolvedMark, importer.objectForMark(10)); try std.testing.expectError(error.UnresolvedMark, importer.objectForOid(commit_oid)); try expectNoPublishedMainRef(&importer, gpa); try std.testing.expectEqual(entries_before, fixture.log.entries.items.len); try std.testing.expectEqual(end_before, fixture.log.end); try std.testing.expectEqual(frames_before, fixture.log.frame_count); try std.testing.expectEqual(@as(usize, 0), try visibleCheckinCount(&fixture, gpa)); try importer.ingest(candidate); const committed_hash = try importer.objectForMark(10); try std.testing.expectEqualStrings(committed_hash, try importer.objectForOid(commit_oid)); try expectMainRef(&importer, gpa, committed_hash); var checkin = try loadManifest(&fixture, gpa, committed_hash); defer checkin.deinit(gpa); try std.testing.expectEqual( entries_before + checkin.manifest.s.len + 1, fixture.log.entries.items.len, ); try std.testing.expectEqual(@as(usize, 1), try visibleCheckinCount(&fixture, gpa)); } } fn stagedCommitOomNoLeak(gpa: std.mem.Allocator) !void { var fixture = try Fixture.init(std.testing.allocator); defer fixture.deinit(); var importer = try git_import.Importer.init(gpa, &fixture.log, fixture.treeStore(), .{}); defer importer.deinit(); try importer.ingest(blob(1, null, "staged tree first body")); try importer.ingest(blob(2, null, "staged tree second body")); var commands = try twoLeafCommands(gpa, 1, 2); defer commands.deinit(); try importer.ingest(commit( 10, "dddddddddddddddddddddddddddddddddddddddd", null, &.{}, commands.commands.items, "staged tree leak check", )); } test "git import staged commit unwinds every deterministic allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, stagedCommitOomNoLeak, .{}); } /// `n` distinct paths spread over directories, so the name set resembles a /// real tree rather than one flat run of near-identical strings. fn syntheticNames(arena: std.mem.Allocator, n: usize) ![]const []const u8 { const names = try arena.alloc([]const u8, n); for (names, 0..) |*name, index| { name.* = try std.fmt.allocPrint(arena, "src/dir{d}/file{d}.zig", .{ index % 97, index }); } return names; } test "git import analyzes a sub-manifest pin that beats the former fixed default" { const gpa = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // Too small to partition at all: §6.2 step 2's flat outcome must surface // as "no opinion", never as a manufactured pin. try std.testing.expectEqual(@as(?u4, null), try git_import.analyzeSubmanifestK(gpa, &.{}, .sha1)); try std.testing.expectEqual(@as(?u4, null), try git_import.analyzeSubmanifestK(gpa, &.{"only"}, .sha1)); // The optimum grows with the file count — the property that makes a single // hardcoded default wrong at both ends of the range. const small = (try git_import.analyzeSubmanifestK(gpa, try syntheticNames(arena, 64), .sha1)).?; const large = (try git_import.analyzeSubmanifestK(gpa, try syntheticNames(arena, 16_384), .sha1)).?; try std.testing.expect(large > small); // And on a set far larger than the old constant was tuned for, the pin it // chooses is genuinely cheaper under §6.3's own objective than 5 is. const names = try syntheticNames(arena, 16_384); const entries = try arena.alloc(artifact.FCard, names.len); std.mem.sort([]const u8, @constCast(names), {}, struct { fn less(_: void, a: []const u8, b: []const u8) bool { return artifact.compareBytes(a, b) == .lt; } }.less); for (entries, names) |*entry, name| { var hash_buf: [64]u8 = undefined; entry.* = .{ .filename = name, .hash = try arena.dupe(u8, artifact.artifactName(.sha1, name, &hash_buf)), .perm = null, .old_name = null, .mode = .regular, }; } const analyzed = try submanifest.cost(gpa, entries, large, .sha1); const former_default = try submanifest.cost(gpa, entries, 5, .sha1); try std.testing.expect(analyzed.total <= former_default.total); }