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 3\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", commit.message);
try std.testing.expectEqual(@as(u64, 1), commit.from.?.mark);
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 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.LimitExceeded);
try expectNextError("feature done\n", .{ .max_line = 3, .max_data = 64, .max_path = 64 }, error.LimitExceeded);
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\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\n\nD four\n\n",
.{ .max_line = 128, .max_data = 64, .max_path = 3 },
error.LimitExceeded,
);
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\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);
}
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\n\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,
}
}