Skip to content
zit Artifact [5bc654344f]
Login

Artifact [5bc654344f]

Artifact 5bc654344f3c5bb7ea71e4594c3619364be6a9a28fb87ce72379db143e0ef075:


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