Skip to content
zit Artifact [e47af5da4a]
Login

Artifact [e47af5da4a]

Artifact e47af5da4ad926e3c00ee0eb9cb24b19550d54803f7a7a91e41ae115314061bd:


const std = @import("std");
const fast_export = @import("git_fast_export.zig");

const Limits = fast_export.Limits;
const Reader = fast_export.Reader;
const Event = fast_export.Event;

const oid = "0123456789abcdef0123456789abcdef01234567";
const second_oid = "89abcdef0123456789abcdef0123456789abcdef";

var stream_bytes: []const u8 = &.{};
var stream_split: usize = 0;
var stream_offset: usize = 0;

fn splitStream(_: *std.Io.Reader, writer: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize {
    if (stream_offset == stream_bytes.len) return error.EndOfStream;

    const boundary = if (stream_offset < stream_split) stream_split else stream_bytes.len;
    const available = boundary - stream_offset;
    const count = @min(available, @intFromEnum(limit));
    if (count == 0) return error.EndOfStream;

    try writer.writeAll(stream_bytes[stream_offset..][0..count]);
    stream_offset += count;
    return count;
}

fn initSplitInput(bytes: []const u8, split: usize, buffer: *[1]u8) std.Io.Reader {
    stream_bytes = bytes;
    stream_split = split;
    stream_offset = 0;
    return .{
        .vtable = &.{ .stream = splitStream },
        .buffer = buffer,
        .seek = 0,
        .end = 0,
    };
}

fn defaultLimits() Limits {
    return .{
        .max_line = 4096,
        .max_data = 4096,
        .max_path = 4096,
    };
}

fn expectBlobThenDone(reader: *Reader) !void {
    var blob_lease = (try reader.next()).?;
    defer blob_lease.deinit();

    switch (blob_lease.value) {
        .blob => |blob| {
            try std.testing.expectEqual(@as(u64, 1), blob.mark);
            try std.testing.expectEqualStrings(oid, blob.original_oid.?);
            try std.testing.expectEqualSlices(u8, "a\n\x00bc", blob.data);
        },
        else => return error.TestUnexpectedResult,
    }

    blob_lease.deinit();
    var done_lease = (try reader.next()).?;
    defer done_lease.deinit();
    switch (done_lease.value) {
        .done => {},
        else => return error.TestUnexpectedResult,
    }
    done_lease.deinit();
    try std.testing.expect((try reader.next()) == null);
}

test "git fast-export blob framing survives every split position" {
    const stream = "feature done\n" ++
        "blob\n" ++
        "mark :1\n" ++
        "original-oid " ++ oid ++ "\n" ++
        "data 5\n" ++
        "a\n\x00bc\n" ++
        "done\n";

    for (0..stream.len + 1) |split| {
        var input_buffer: [1]u8 = undefined;
        var reader = Reader.init(std.testing.allocator, initSplitInput(stream, split, &input_buffer), defaultLimits());
        defer reader.deinit();
        try expectBlobThenDone(&reader);
    }
}

test "git fast-export leases block advancement and release event storage" {
    const stream = "feature done\nblob\nmark :1\ndata 1\nx\ndone\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var lease = (try reader.next()).?;
    try std.testing.expectError(error.ActiveLease, reader.next());
    lease.deinit();

    var done_lease = (try reader.next()).?;
    defer done_lease.deinit();
    switch (done_lease.value) {
        .done => {},
        else => return error.TestUnexpectedResult,
    }
}

test "git fast-export skips top-level progress directives" {
    const stream = "feature done\n" ++
        "progress before blob\n" ++
        "blob\nmark :1\ndata 0\n\n" ++
        "progress after blob\n" ++
        "done\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var blob_lease = (try reader.next()).?;
    switch (blob_lease.value) {
        .blob => {},
        else => return error.TestUnexpectedResult,
    }
    blob_lease.deinit();

    var done_lease = (try reader.next()).?;
    defer done_lease.deinit();
    switch (done_lease.value) {
        .done => {},
        else => return error.TestUnexpectedResult,
    }
    done_lease.deinit();
    try std.testing.expect((try reader.next()) == null);
}

test "git fast-export commit decodes identities modes paths and ordered commands" {
    const stream = "feature done\n" ++
        "commit refs/heads/main\n" ++
        "mark :2\n" ++
        "original-oid " ++ oid ++ "\n" ++
        "author Alice Example <[email protected]> 1700000000 -0230\n" ++
        "committer Bob Example <[email protected]> 1700000001 +0530\n" ++
        "data 4\n" ++
        "msg\n" ++
        "from :1\n" ++
        "merge " ++ second_oid ++ "\n" ++
        "R \"old\\\\path\" \"space path\\t\\377\"\n" ++
        "M 100755 :2 \"space path\\t\\377\"\n" ++
        "M 100644 :1 plain\n" ++
        "M 120000 :3 link\n" ++
        "D -leading-dash\n" ++
        "D \"line\\nfeed\"\n" ++
        "D \"bells\\a\\v\"\n" ++
        "C plain copy\n" ++
        "deleteall\n" ++
        "\n" ++
        "done\n";

    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var lease = (try reader.next()).?;
    defer lease.deinit();
    switch (lease.value) {
        .commit => |commit| {
            try std.testing.expectEqualStrings("refs/heads/main", commit.ref);
            try std.testing.expectEqual(@as(u64, 2), commit.mark);
            try std.testing.expectEqualStrings(oid, commit.original_oid.?);
            try std.testing.expectEqualStrings("Alice Example", commit.author.name);
            try std.testing.expectEqualStrings("[email protected]", commit.author.email);
            try std.testing.expectEqual(@as(i64, 1700000000), commit.author.timestamp);
            try std.testing.expectEqual(@as(i16, -150), commit.author.timezone_offset_minutes);
            try std.testing.expectEqualStrings("Bob Example", commit.committer.name);
            try std.testing.expectEqual(@as(i16, 330), commit.committer.timezone_offset_minutes);
            try std.testing.expectEqualSlices(u8, "msg\n", commit.message);
            try std.testing.expectEqual(@as(usize, 1), commit.merges.len);
            try std.testing.expectEqualStrings(second_oid, commit.merges[0].oid);
            try std.testing.expectEqual(@as(usize, 9), commit.commands.len);

            switch (commit.commands[0]) {
                .rename => |rename| {
                    try std.testing.expectEqualStrings("old\\path", rename.old);
                    try std.testing.expectEqualSlices(u8, "space path\t\xff", rename.new);
                },
                else => return error.TestUnexpectedResult,
            }
            switch (commit.commands[1]) {
                .modify => |modify| {
                    try std.testing.expectEqual(.executable, modify.mode);
                    try std.testing.expectEqual(@as(u64, 2), modify.data.mark);
                    try std.testing.expectEqualSlices(u8, "space path\t\xff", modify.path);
                },
                else => return error.TestUnexpectedResult,
            }
            switch (commit.commands[5]) {
                .delete => |path| try std.testing.expectEqualSlices(u8, "line\nfeed", path),
                else => return error.TestUnexpectedResult,
            }
            switch (commit.commands[6]) {
                .delete => |path| try std.testing.expectEqualSlices(u8, "bells\x07\x0b", path),
                else => return error.TestUnexpectedResult,
            }
            switch (commit.commands[8]) {
                .delete_all => {},
                else => return error.TestUnexpectedResult,
            }
        },
        else => return error.TestUnexpectedResult,
    }
}

test "git fast-export accepts an empty reset directly before a commit" {
    const stream = "feature done\n" ++
        "reset refs/heads/main\n" ++
        "commit refs/heads/main\n" ++
        "mark :2\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n" ++
        "data 0\n\n" ++
        "done\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var reset_lease = (try reader.next()).?;
    switch (reset_lease.value) {
        .reset => |reset| {
            try std.testing.expectEqualStrings("refs/heads/main", reset.ref);
            try std.testing.expect(reset.target == null);
        },
        else => return error.TestUnexpectedResult,
    }
    reset_lease.deinit();

    var commit_lease = (try reader.next()).?;
    defer commit_lease.deinit();
    switch (commit_lease.value) {
        .commit => |commit| try std.testing.expectEqual(@as(u64, 2), commit.mark),
        else => return error.TestUnexpectedResult,
    }
}
test "git fast-export accepts a reset target directly before a commit" {
    const stream = "feature done\n" ++
        "reset refs/heads/main\n" ++
        "from :2\n" ++
        "commit refs/heads/main\n" ++
        "mark :3\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n" ++
        "data 0\n\n" ++
        "done\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var reset_lease = (try reader.next()).?;
    switch (reset_lease.value) {
        .reset => |reset| try std.testing.expectEqual(@as(u64, 2), reset.target.?.mark),
        else => return error.TestUnexpectedResult,
    }
    reset_lease.deinit();

    var commit_lease = (try reader.next()).?;
    defer commit_lease.deinit();
    switch (commit_lease.value) {
        .commit => |commit| try std.testing.expectEqual(@as(u64, 3), commit.mark),
        else => return error.TestUnexpectedResult,
    }
}

test "git fast-export reset and tag retain metadata" {
    const stream = "feature done\n" ++
        "reset refs/heads/main\n" ++
        "from :2\n\n" ++
        "tag v1.0\n" ++
        "mark :3\n" ++
        "from :2\n" ++
        "original-oid " ++ oid ++ "\n" ++
        "tagger Release Bot <[email protected]> 1700000002 +0000\n" ++
        "data 3\n" ++
        "tag\n" ++
        "done\n";

    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var reset_lease = (try reader.next()).?;
    defer reset_lease.deinit();
    switch (reset_lease.value) {
        .reset => |reset| {
            try std.testing.expectEqualStrings("refs/heads/main", reset.ref);
            try std.testing.expectEqual(@as(u64, 2), reset.target.?.mark);
        },
        else => return error.TestUnexpectedResult,
    }
    reset_lease.deinit();

    var tag_lease = (try reader.next()).?;
    defer tag_lease.deinit();
    switch (tag_lease.value) {
        .tag => |tag| {
            try std.testing.expectEqualStrings("v1.0", tag.name);
            try std.testing.expectEqual(@as(u64, 3), tag.mark.?);
            try std.testing.expectEqual(@as(u64, 2), tag.target.mark);
            try std.testing.expectEqualStrings(oid, tag.original_oid.?);
            try std.testing.expectEqualStrings("Release Bot", tag.tagger.name);
            try std.testing.expectEqualSlices(u8, "tag", tag.message);
        },
        else => return error.TestUnexpectedResult,
    }
}

fn expectNextError(bytes: []const u8, limits: Limits, expected: anyerror) !void {
    const input = std.Io.Reader.fixed(bytes);
    var reader = Reader.init(std.testing.allocator, input, limits);
    defer reader.deinit();
    try std.testing.expectError(expected, reader.next());
}

test "git fast-export rejects malformed framing and configured bounds" {
    try expectNextError("feature done\nblob\nmark :1\ndata -1\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\nblob\nmark :1\ndata 18446744073709551616\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\nblob\nmark :1\ndata 1\nx", defaultLimits(), error.UnexpectedEof);
    try expectNextError("feature done\nblob\nmark :1\ndata 1\nxx", defaultLimits(), error.Malformed);
    try expectNextError("feature done\nblob\nmark :x\ndata 0\n\n", defaultLimits(), error.Malformed);
    try expectNextError("feature relative-marks\n", defaultLimits(), error.UnsupportedFeature);
    try expectNextError("feature done\nblob\nmark :1\ndata 2\nxx\n", .{ .max_line = 64, .max_data = 1, .max_path = 64 }, error.DataLimitExceeded);
    try expectNextError("feature done\n", .{ .max_line = 3, .max_data = 64, .max_path = 64 }, error.LineLimitExceeded);
    try expectNextError(
        "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nD \"\\400\"\n\n",
        defaultLimits(),
        error.Malformed,
    );
    try expectNextError(
        "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nD four\n\n",
        .{ .max_line = 128, .max_data = 64, .max_path = 3 },
        error.PathLimitExceeded,
    );

    const trailing = "feature done\ndone\ntrailing";
    const input = std.Io.Reader.fixed(trailing);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();
    var done_lease = (try reader.next()).?;
    done_lease.deinit();
    try std.testing.expectError(error.TrailingData, reader.next());
}

test "git fast-export requires a terminal done followed by eof" {
    const stream = "feature done\nblob\nmark :1\ndata 0\n\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var blob_lease = (try reader.next()).?;
    blob_lease.deinit();
    try std.testing.expectError(error.MissingDone, reader.next());
}

test "git fast-export rejects invalid commit semantics at the grammar boundary" {
    const prefix = "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\n";
    try expectNextError(prefix ++ "M 160000 :1 submodule\n\n", defaultLimits(), error.UnsupportedMode);
    try expectNextError(prefix ++ "M 100600 :1 unknown\n\n", defaultLimits(), error.UnknownMode);
    try expectNextError(prefix ++ "M 100644 nonsense file\n\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 999999999999999999999999 +0000\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +2460\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\n\n", defaultLimits(), error.Malformed);
    try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\n", defaultLimits(), error.UnexpectedEof);
    try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 4\nabc", defaultLimits(), error.UnexpectedEof);
}

test "git fast-export leaves mark declaration and reference resolution to the importer" {
    const stream = "feature done\n" ++
        "blob\nmark :1\ndata 0\n\n" ++
        "blob\nmark :1\ndata 0\n\n" ++
        "commit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nfrom :999\n\n" ++
        "done\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    for (0..2) |_| {
        var blob_lease = (try reader.next()).?;
        switch (blob_lease.value) {
            .blob => {},
            else => return error.TestUnexpectedResult,
        }
        blob_lease.deinit();
    }
    var commit_lease = (try reader.next()).?;
    defer commit_lease.deinit();
    switch (commit_lease.value) {
        .commit => |commit| try std.testing.expectEqual(@as(u64, 999), commit.from.?.mark),
        else => return error.TestUnexpectedResult,
    }
}

test "git fast-export accepts exact per-commit merge and derived file command limits" {
    const commit_prefix = "feature done\n" ++
        "commit refs/heads/main\n" ++
        "mark :1\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n" ++
        "data 0\n";

    var merge_limits = defaultLimits();
    merge_limits.max_merges_per_commit = 2;
    const merge_stream = commit_prefix ++ "merge :2\nmerge :3\n\n";
    {
        const input = std.Io.Reader.fixed(merge_stream);
        var reader = Reader.init(std.testing.allocator, input, merge_limits);
        defer reader.deinit();

        var lease = (try reader.next()).?;
        defer lease.deinit();
        switch (lease.value) {
            .commit => |commit| try std.testing.expectEqual(@as(usize, 2), commit.merges.len),
            else => return error.TestUnexpectedResult,
        }
    }
    try expectNextError(
        commit_prefix ++ "merge :2\nmerge :3\nmerge :4\n\n",
        merge_limits,
        error.MergeLimitExceeded,
    );

    // The file-command ceiling is derived, so the way to pin it at two is to
    // authorize exactly two commands' worth of minimum retention. No count
    // appears in the limits at all.
    var command_limits = defaultLimits();
    command_limits.max_total_data = 2 * fast_export.min_retained_bytes_per_file_command;
    try std.testing.expectEqual(
        @as(usize, 2),
        fast_export.fileCommandCeiling(command_limits),
    );
    const command_stream = commit_prefix ++ "D one\nD two\n\n";
    {
        const input = std.Io.Reader.fixed(command_stream);
        var reader = Reader.init(std.testing.allocator, input, command_limits);
        defer reader.deinit();

        var lease = (try reader.next()).?;
        defer lease.deinit();
        switch (lease.value) {
            .commit => |commit| try std.testing.expectEqual(@as(usize, 2), commit.commands.len),
            else => return error.TestUnexpectedResult,
        }
        try std.testing.expect(reader.file_command_overrun == null);
    }
    {
        const input = std.Io.Reader.fixed(commit_prefix ++ "D one\nD two\nD three\n\n");
        var reader = Reader.init(std.testing.allocator, input, command_limits);
        defer reader.deinit();
        try std.testing.expectError(error.FileCommandLimitExceeded, reader.next());
        // The refusal retains the numbers its diagnostic must name.
        const overrun = reader.file_command_overrun.?;
        try std.testing.expectEqual(@as(usize, 3), overrun.observed_at_least);
        try std.testing.expectEqual(@as(usize, 2), overrun.ceiling);
        try std.testing.expectEqual(command_limits.max_total_data, overrun.payload_budget_bytes);
        try std.testing.expectEqual(
            fast_export.min_retained_bytes_per_file_command,
            overrun.min_bytes_per_command,
        );
    }

    // Raising the byte budget raises the ceiling with it: nothing else does.
    var doubled = command_limits;
    doubled.max_total_data *= 2;
    try std.testing.expectEqual(@as(usize, 4), fast_export.fileCommandCeiling(doubled));
}

test "git fast-export imports a commit far above the retired 200,000 file wall" {
    // The retired ceiling was the benchmark corpus width promoted to policy.
    // A root commit that drops a whole monorepo in at once is an ordinary
    // source shape, and `git fast-export` lists the entire tree for it, so a
    // commit past that number must import rather than fail.
    const retired_ceiling: usize = 200_000;
    const command_count: usize = retired_ceiling + 1;

    var stream: std.ArrayList(u8) = .empty;
    defer stream.deinit(std.testing.allocator);
    try stream.appendSlice(std.testing.allocator, "feature done\n" ++
        "commit refs/heads/main\n" ++
        "mark :1\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n" ++
        "data 0\n");
    var line_buffer: [32]u8 = undefined;
    for (0..command_count) |index| {
        const line = try std.fmt.bufPrint(&line_buffer, "D f{d}\n", .{index});
        try stream.appendSlice(std.testing.allocator, line);
    }
    try stream.appendSlice(std.testing.allocator, "\ndone\n");

    var limits = defaultLimits();
    // The production payload budget, not a value picked to make this pass.
    limits.max_total_data = @as(u64, 4) * 1024 * 1024 * 1024;
    try std.testing.expect(fast_export.fileCommandCeiling(limits) > command_count);

    const input = std.Io.Reader.fixed(stream.items);
    var reader = Reader.init(std.testing.allocator, input, limits);
    defer reader.deinit();

    var lease = (try reader.next()).?;
    switch (lease.value) {
        .commit => |commit| try std.testing.expectEqual(command_count, commit.commands.len),
        else => return error.TestUnexpectedResult,
    }
    lease.deinit();
    try std.testing.expect(reader.file_command_overrun == null);

    var done_lease = (try reader.next()).?;
    defer done_lease.deinit();
    switch (done_lease.value) {
        .done => {},
        else => return error.TestUnexpectedResult,
    }
}

test "git fast-export bounds total events and payload bytes before allocation" {
    var event_limits = defaultLimits();
    event_limits.max_events = 4;
    // `feature done` configures the stream; progress, blobs, and done each
    // consume one event slot.
    const event_stream = "feature done\n" ++
        "progress before blobs\n" ++
        "blob\nmark :1\ndata 0\n\n" ++
        "blob\nmark :2\ndata 0\n\n" ++
        "done\n";
    {
        const input = std.Io.Reader.fixed(event_stream);
        var reader = Reader.init(std.testing.allocator, input, event_limits);
        defer reader.deinit();

        for (0..2) |_| {
            var lease = (try reader.next()).?;
            switch (lease.value) {
                .blob => {},
                else => return error.TestUnexpectedResult,
            }
            lease.deinit();
        }
        var done_lease = (try reader.next()).?;
        switch (done_lease.value) {
            .done => {},
            else => return error.TestUnexpectedResult,
        }
        done_lease.deinit();
        try std.testing.expect((try reader.next()) == null);
    }
    {
        const overflow_stream = "feature done\n" ++
            "progress before blobs\n" ++
            "blob\nmark :1\ndata 0\n\n" ++
            "blob\nmark :2\ndata 0\n\n" ++
            "blob\nmark :3\ndata 0\n\n" ++
            "done\n";
        const input = std.Io.Reader.fixed(overflow_stream);
        var reader = Reader.init(std.testing.allocator, input, event_limits);
        defer reader.deinit();

        for (0..3) |_| {
            var lease = (try reader.next()).?;
            lease.deinit();
        }
        try std.testing.expectError(error.EventLimitExceeded, reader.next());
    }

    var payload_limits = defaultLimits();
    payload_limits.max_events = 3;
    payload_limits.max_total_data = 3;
    const payload_stream = "feature done\n" ++
        "blob\nmark :1\ndata 2\nab\n" ++
        "blob\nmark :2\ndata 1\nc\n" ++
        "done\n";
    for (0..payload_stream.len + 1) |split| {
        var input_buffer: [1]u8 = undefined;
        var reader = Reader.init(
            std.testing.allocator,
            initSplitInput(payload_stream, split, &input_buffer),
            payload_limits,
        );
        defer reader.deinit();

        for (0..2) |_| {
            var lease = (try reader.next()).?;
            switch (lease.value) {
                .blob => {},
                else => return error.TestUnexpectedResult,
            }
            lease.deinit();
        }
        var done_lease = (try reader.next()).?;
        switch (done_lease.value) {
            .done => {},
            else => return error.TestUnexpectedResult,
        }
        done_lease.deinit();
        try std.testing.expect((try reader.next()) == null);
    }
    {
        const input = std.Io.Reader.fixed(
            "feature done\nblob\nmark :1\ndata 2\nab\nblob\nmark :2\ndata 2\ncd\n",
        );
        var reader = Reader.init(std.testing.allocator, input, payload_limits);
        defer reader.deinit();

        var first_blob = (try reader.next()).?;
        first_blob.deinit();
        try std.testing.expectError(error.DataLimitExceeded, reader.next());
    }
}

test "git fast-export commit data framing accepts both legal forms across every split" {
    const prefix = "feature done\n" ++
        "commit refs/heads/main\n" ++
        "mark :1\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n";
    const suffix = "from :2\n\ndone\n";
    const Form = struct {
        stream: []const u8,
        message: []const u8,
    };
    const forms = [_]Form{
        .{ .stream = prefix ++ "data 3\nabc\n" ++ suffix, .message = "abc" },
        .{ .stream = prefix ++ "data 4\nabc\n" ++ suffix, .message = "abc\n" },
        .{ .stream = prefix ++ "data 0\n" ++ suffix, .message = "" },
    };

    for (forms) |form| {
        const stream = form.stream;
        for (0..stream.len + 1) |split| {
            var input_buffer: [1]u8 = undefined;
            var reader = Reader.init(std.testing.allocator, initSplitInput(stream, split, &input_buffer), defaultLimits());
            defer reader.deinit();

            var commit_lease = (try reader.next()).?;
            switch (commit_lease.value) {
                .commit => |commit| {
                    try std.testing.expectEqualStrings(form.message, commit.message);
                    try std.testing.expectEqual(@as(u64, 2), commit.from.?.mark);
                },
                else => return error.TestUnexpectedResult,
            }
            commit_lease.deinit();

            var done_lease = (try reader.next()).?;
            defer done_lease.deinit();
            switch (done_lease.value) {
                .done => {},
                else => return error.TestUnexpectedResult,
            }
        }
    }
}

test "git fast-export commit data count permits an immediately following command" {
    const stream = "feature done\n" ++
        "commit refs/heads/main\n" ++
        "mark :1\n" ++
        "author A <[email protected]> 1 +0000\n" ++
        "committer C <[email protected]> 2 +0000\n" ++
        "data 1\nxfrom :2\n\n" ++
        "done\n";
    const input = std.Io.Reader.fixed(stream);
    var reader = Reader.init(std.testing.allocator, input, defaultLimits());
    defer reader.deinit();

    var lease = (try reader.next()).?;
    defer lease.deinit();
    switch (lease.value) {
        .commit => |commit| {
            try std.testing.expectEqualStrings("x", commit.message);
            try std.testing.expectEqual(@as(u64, 2), commit.from.?.mark);
        },
        else => return error.TestUnexpectedResult,
    }
}