Skip to content
zit Artifact [2717ab1406]
Login

Artifact [2717ab1406]

Artifact 2717ab140648f77e80fc955ba2f261ec6b6b23455a894cf54d79a9077b22959c:


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