//! Round-trip tests for the fast-import stream WRITER: serialize typed
//! records, feed the bytes to the shipped fast-export parser (the structural
//! oracle — the two sides share one type model, so drift is impossible by
//! construction), and require the parsed events to equal the input records.
const std = @import("std");
const fast_export = @import("git_fast_export.zig");
const fast_import = @import("git_fast_import.zig");
const Event = fast_export.Event;
const Identity = fast_export.Identity;
const FileCommand = fast_export.FileCommand;
const ObjectRef = fast_export.ObjectRef;
const testing = std.testing;
fn parseLimits() fast_export.Limits {
return .{
.max_line = 1 << 16,
.max_data = 1 << 24,
.max_path = 1 << 12,
.max_events = 1 << 20,
};
}
/// Serialize `events` with the writer under test and hand back the bytes.
fn serialize(gpa: std.mem.Allocator, events: []const Event) ![]u8 {
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
var w = fast_import.Writer{};
try w.begin(&out.writer);
for (events) |event| try w.writeEvent(&out.writer, event);
return out.toOwnedSlice();
}
fn expectIdentityEq(want: Identity, got: Identity) !void {
try testing.expectEqualStrings(want.name, got.name);
try testing.expectEqualStrings(want.email, got.email);
try testing.expectEqual(want.timestamp, got.timestamp);
try testing.expectEqual(want.timezone_offset_minutes, got.timezone_offset_minutes);
}
fn expectRefEq(want: ObjectRef, got: ObjectRef) !void {
try testing.expectEqual(std.meta.activeTag(want), std.meta.activeTag(got));
switch (want) {
.mark => |m| try testing.expectEqual(m, got.mark),
.oid => |o| try testing.expectEqualStrings(o, got.oid),
}
}
fn expectEventEq(want: Event, got: Event) !void {
try testing.expectEqual(std.meta.activeTag(want), std.meta.activeTag(got));
switch (want) {
.blob => |b| {
try testing.expectEqual(b.mark, got.blob.mark);
try testing.expectEqualSlices(u8, b.data, got.blob.data);
},
.commit => |c| {
try testing.expectEqualStrings(c.ref, got.commit.ref);
try testing.expectEqual(c.mark, got.commit.mark);
try expectIdentityEq(c.author, got.commit.author);
try expectIdentityEq(c.committer, got.commit.committer);
try testing.expectEqualStrings(c.message, got.commit.message);
try testing.expectEqual(c.from == null, got.commit.from == null);
if (c.from) |f| try expectRefEq(f, got.commit.from.?);
try testing.expectEqual(c.merges.len, got.commit.merges.len);
for (c.merges, got.commit.merges) |wm, gm| try expectRefEq(wm, gm);
try testing.expectEqual(c.commands.len, got.commit.commands.len);
for (c.commands, got.commit.commands) |wc, gc| {
try testing.expectEqual(std.meta.activeTag(wc), std.meta.activeTag(gc));
switch (wc) {
.modify => |m| {
try testing.expectEqual(m.mode, gc.modify.mode);
try expectRefEq(m.data, gc.modify.data);
try testing.expectEqualSlices(u8, m.path, gc.modify.path);
},
.delete => |p| try testing.expectEqualSlices(u8, p, gc.delete),
.delete_all => {},
.rename => |r| {
try testing.expectEqualSlices(u8, r.old, gc.rename.old);
try testing.expectEqualSlices(u8, r.new, gc.rename.new);
},
.copy => |cp| {
try testing.expectEqualSlices(u8, cp.old, gc.copy.old);
try testing.expectEqualSlices(u8, cp.new, gc.copy.new);
},
}
}
},
.reset => |r| {
try testing.expectEqualStrings(r.ref, got.reset.ref);
try testing.expectEqual(r.target == null, got.reset.target == null);
if (r.target) |t| try expectRefEq(t, got.reset.target.?);
},
.tag => |t| {
try testing.expectEqualStrings(t.name, got.tag.name);
try expectRefEq(t.target, got.tag.target);
try expectIdentityEq(t.tagger, got.tag.tagger);
try testing.expectEqualStrings(t.message, got.tag.message);
},
.done => {},
}
}
/// Round-trip `events` (which must end in `.done`) and assert equality.
fn expectRoundTrip(events: []const Event) !void {
const gpa = testing.allocator;
const bytes = try serialize(gpa, events);
defer gpa.free(bytes);
const input = std.Io.Reader.fixed(bytes);
var reader = fast_export.Reader.init(gpa, input, parseLimits());
defer reader.deinit();
var i: usize = 0;
while (try reader.next()) |lease| {
var l = lease;
defer l.deinit();
try testing.expect(i < events.len);
try expectEventEq(events[i], l.value);
i += 1;
}
try testing.expectEqual(events.len, i);
}
const alice: Identity = .{ .name = "Alice Author", .email = "[email protected]", .timestamp = 1_700_000_000, .timezone_offset_minutes = 0 };
const bob: Identity = .{ .name = "Bob Committer", .email = "[email protected]", .timestamp = 1_700_000_100, .timezone_offset_minutes = 0 };
test "git fast-import round-trip: blobs, root and child commits, merge, reset, annotated tag, done" {
const blob_lf: Event = .{ .blob = .{ .mark = 1, .original_oid = null, .data = "line one\nline two\n" } };
const blob_nul: Event = .{ .blob = .{ .mark = 2, .original_oid = null, .data = "bin\x00ary\x00" } };
const root: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 3,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "root commit\n\nwith a body line\n",
.from = null,
.merges = &.{},
.commands = &.{
.delete_all,
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "src/main.c" } },
.{ .modify = .{ .mode = .executable, .data = .{ .mark = 2 }, .path = "tools/run" } },
},
} };
const child: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 4,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "child\n",
.from = .{ .mark = 3 },
.merges = &.{},
.commands = &.{
.{ .modify = .{ .mode = .symlink, .data = .{ .mark = 1 }, .path = "link" } },
.{ .delete = "tools/run" },
},
} };
const side: Event = .{ .commit = .{
.ref = "refs/heads/side",
.mark = 5,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "side\n",
.from = .{ .mark = 3 },
.merges = &.{},
.commands = &.{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "side.txt" } }},
} };
const merge: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 6,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "merge side\n",
.from = .{ .mark = 4 },
.merges = &.{.{ .mark = 5 }},
.commands = &.{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 2 }, .path = "side.txt" } }},
} };
const reset: Event = .{ .reset = .{ .ref = "refs/heads/release", .target = .{ .mark = 6 } } };
const tag: Event = .{ .tag = .{
.name = "v1.0",
.mark = null,
.target = .{ .mark = 6 },
.original_oid = null,
.tagger = bob,
.message = "release one\n",
} };
try expectRoundTrip(&.{ blob_lf, blob_nul, root, child, side, merge, reset, tag, .done });
}
test "git fast-import round-trip: identity timezone offsets survive both signs" {
const east: Identity = .{ .name = "E", .email = "[email protected]", .timestamp = 42, .timezone_offset_minutes = 330 };
const west: Identity = .{ .name = "W", .email = "[email protected]", .timestamp = 43, .timezone_offset_minutes = -480 };
const commit: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 2,
.original_oid = null,
.author = east,
.committer = west,
.message = "tz\n",
.from = null,
.merges = &.{},
.commands = &.{.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = "a" } }},
} };
try expectRoundTrip(&.{ .{ .blob = .{ .mark = 1, .original_oid = null, .data = "x" } }, commit, .done });
}
test "git fast-import round-trip: quoting boundary — spaces, quotes, backslashes, LF, high bytes, leading dash" {
const paths = [_][]const u8{
"plain.txt",
"with space.txt",
"quote\"in\"name",
"back\\slash",
"line\nfeed",
"tab\tname",
"-leading-dash",
"\"leading-quote",
"high\xffbyte",
"control\x01byte",
};
var commands: [paths.len]FileCommand = undefined;
for (paths, 0..) |p, i| {
commands[i] = .{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = p } };
}
const commit: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 2,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "quoting\n",
.from = null,
.merges = &.{},
.commands = &commands,
} };
try expectRoundTrip(&.{ .{ .blob = .{ .mark = 1, .original_oid = null, .data = "x" } }, commit, .done });
}
test "git fast-import round-trip: deterministic sweep over every single-byte path character" {
// Every byte 0x01..0xFF except '/' (a directory separator changes the
// path's meaning, not its encoding) and NUL (never legal in a path):
// writer -> parser -> byte equality, one file command per byte.
var byte: usize = 1;
while (byte <= 0xFF) : (byte += 1) {
if (byte == '/') continue;
const b: u8 = @intCast(byte);
const path = [3]u8{ 'a', b, 'z' };
const commit: Event = .{ .commit = .{
.ref = "refs/heads/trunk",
.mark = 2,
.original_oid = null,
.author = alice,
.committer = bob,
.message = "sweep\n",
.from = null,
.merges = &.{},
.commands = &.{
.{ .modify = .{ .mode = .regular, .data = .{ .mark = 1 }, .path = &path } },
.{ .rename = .{ .old = &path, .new = "renamed" } },
},
} };
try expectRoundTrip(&.{ .{ .blob = .{ .mark = 1, .original_oid = null, .data = "x" } }, commit, .done });
}
}