Artifact bcbea61950a14d5911eeede43a5929af8f297635fcada38bb0a9fd051c470054:
- File
src/git_import_test.zig
— part of check-in
[9b96469ae6]
at
2026-08-02 01:29:11
on branch sub-manifest
— import-git: add --no-submanifests
Imports with sub-manifests off: flat manifests throughout and the spec-default config frame (k=0, enable off). Flat is §8.1's default shape; it is this verb's analyze-and-pin that is the deviation.
TreeFormat.Shape becomes flat | tree{k, height_max}, so flat-with-a-k and tree-without-one stop being representable and every tree-only site traps on the wrong tag. buildFlat drains the same MergedCursor, so ordering, dedup and rename/copy/delete stay single-sourced. SubmanifestChoice replaces submanifest_k from Request down to UserOptions.
Publication refuses if the store holds a SUBMANIFEST anyway, derived from work done rather than from the flag. Preflight analysis still runs under flat -- skipping it would put a second divergence inside the region the benchmark times.
Closes #23. (user: dmestas, size: 61425) [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 = .{ .tree = 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(), .{ .hash_algo = .sha1, .shape = .{ .tree = .{ .k = 5, .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.shape.tree.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); }