Skip to content
zit Artifact [25330b1c5b]
Login

Artifact [25330b1c5b]

Artifact 25330b1c5ba0da824e7e3a225b458cc85cf918211904c71a9a36d2838bebd0ce:


//! Real-Git differential tests for `zit export-git`: drive the BUILT zit
//! binary and the real `git`, then verify commit by commit against the seam
//! — `git ls-tree`/`cat-file` rows compared to the source repository's own
//! enumeration, metadata against the manifest's P/C/D cards, refs and HEAD
//! against the derived state. Determinism and incrementality are proven by
//! OID equality across fresh and appended runs. Never compared against real
//! Fossil or `fossil git export` — the oracle is zit's seam plus Git's own
//! read-back of what we wrote (DESIGN §2, §11).

const std = @import("std");
const repeat = @import("zit").repeat;
const build_options = @import("build_options");
const zit = @import("zit");

const testing = std.testing;
const zit_exe: []const u8 = build_options.zit_exe;

fn runProgram(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, argv: []const []const u8) !std.process.RunResult {
    return std.process.run(gpa, io, .{
        .argv = argv,
        .cwd = .{ .dir = dir },
        .stdout_limit = .limited(1 << 24),
        .stderr_limit = .limited(1 << 20),
    });
}

fn runZit(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, tail: []const []const u8) !std.process.RunResult {
    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const abs_exe = try std.fs.path.resolve(gpa, &.{ cwd_abs, zit_exe });
    defer gpa.free(abs_exe);
    var argv_buf: [16][]const u8 = undefined;
    argv_buf[0] = abs_exe;
    std.debug.assert(tail.len <= argv_buf.len - 1);
    for (tail, argv_buf[1..][0..tail.len]) |src, *dst| dst.* = src;
    return runProgram(gpa, io, dir, argv_buf[0 .. 1 + tail.len]);
}

fn expectExit(result: std.process.RunResult, want: u8) !void {
    switch (result.term) {
        .exited => |code| {
            if (code != want) {
                std.debug.print("exit {d} (want {d})\nstdout: {s}\nstderr: {s}\n", .{ code, want, result.stdout, result.stderr });
                return error.TestUnexpectedExitCode;
            }
        },
        else => return error.TestUnexpectedExitCode,
    }
}

fn freeResult(gpa: std.mem.Allocator, result: std.process.RunResult) void {
    gpa.free(result.stdout);
    gpa.free(result.stderr);
}

fn writeFile(io: std.Io, dir: std.Io.Dir, sub: []const u8, bytes: []const u8) !void {
    if (std.fs.path.dirname(sub)) |parent| dir.createDir(io, parent, .default_dir) catch {};
    var f = try dir.createFile(io, sub, .{ .truncate = true });
    defer f.close(io);
    try f.writePositionalAll(io, bytes, 0);
}

fn commitAll(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, message: []const u8) !void {
    const add = try runZit(gpa, io, dir, &.{"addremove"});
    defer freeResult(gpa, add);
    try expectExit(add, 0);
    const ci = try runZit(gpa, io, dir, &.{ "commit", "-m", message, "--user", "e2e" });
    defer freeResult(gpa, ci);
    try expectExit(ci, 0);
}

/// The deterministic fixture: linear history + branch + delete + executable
/// file, built through the real zit binary. Returns nothing; the state is on
/// disk in `dir`.
fn buildFixture(gpa: std.mem.Allocator, io: std.Io, tmp_dir: std.Io.Dir) !std.Io.Dir {
    tmp_dir.createDir(io, "src", .default_dir) catch {};
    const dir = try tmp_dir.openDir(io, "src", .{});
    const init = try runZit(gpa, io, dir, &.{"init"});
    defer freeResult(gpa, init);
    try expectExit(init, 0);

    try writeFile(io, dir, "adir/alpha.txt", "alpha one\n");
    try writeFile(io, dir, "bdir/beta.txt", "beta one\n");
    try commitAll(gpa, io, dir, "root");

    try writeFile(io, dir, "adir/alpha.txt", "alpha two\n");
    try writeFile(io, dir, "cdir/gamma.txt", "gamma\n");
    try commitAll(gpa, io, dir, "grow");

    // A delete: addremove drops the vanished file.
    dir.deleteFile(io, "bdir/beta.txt") catch {};
    try commitAll(gpa, io, dir, "shrink");
    return dir;
}

fn gitOut(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, argv: []const []const u8) ![]u8 {
    const result = try runProgram(gpa, io, dir, argv);
    defer gpa.free(result.stderr);
    errdefer gpa.free(result.stdout);
    try expectExit(result, 0);
    return result.stdout;
}

test "export-git e2e: a real export fscks clean and every commit's tree matches the seam enumeration" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var src = try buildFixture(gpa, io, tmp.dir);
    defer src.close(io);

    const exported = try runZit(gpa, io, src, &.{ "export-git", ".", "../mirror" });
    defer freeResult(gpa, exported);
    try expectExit(exported, 0);

    {
        const fsck = try runProgram(gpa, io, tmp.dir, &.{ "git", "-C", "mirror", "fsck", "--strict" });
        defer freeResult(gpa, fsck);
        try expectExit(fsck, 0);
    }

    // Per-commit differential via the state file's hash/OID pairs.
    var repo: zit.Repository = undefined;
    try zit.Repository.open(&repo, gpa, io, src, .{ .mode = .full });
    defer repo.deinit(gpa);
    var state = try zit.git_export.loadState(gpa, io, try tmp.dir.openDir(io, "mirror", .{}));
    defer state.deinit();
    try testing.expectEqual(@as(usize, 3), state.records.len);

    for (state.records) |record| {
        const rid = repo.lookup(record.hash) orelse return error.TestUnexpectedResult;
        var fl = (try repo.files(gpa, rid)) orelse return error.TestUnexpectedResult;
        defer fl.deinit(gpa);

        // `git ls-tree -r` rows: path -> blob OID.
        var lstree_argv_buf: [8][]const u8 = undefined;
        lstree_argv_buf[0..6].* = .{ "git", "-C", "mirror", "ls-tree", "-r", record.oid };
        const listing = try gitOut(gpa, io, tmp.dir, lstree_argv_buf[0..6]);
        defer gpa.free(listing);

        var git_paths: std.StringHashMapUnmanaged([]const u8) = .empty;
        defer git_paths.deinit(gpa);
        var lines = std.mem.splitScalar(u8, listing, '\n');
        while (lines.next()) |line| {
            if (line.len == 0) continue;
            // `<mode> blob <oid>\t<path>`
            const tab = std.mem.indexOfScalar(u8, line, '\t') orelse return error.TestUnexpectedResult;
            const meta = line[0..tab];
            const path = line[tab + 1 ..];
            var fields = std.mem.tokenizeScalar(u8, meta, ' ');
            _ = fields.next(); // mode
            _ = fields.next(); // type
            const oid = fields.next() orelse return error.TestUnexpectedResult;
            try git_paths.put(gpa, path, oid);
        }

        // Every seam file appears with matching CONTENT (via cat-file).
        var count: usize = 0;
        var it = fl.iterator();
        while (it.next()) |card| {
            count += 1;
            const git_oid = git_paths.get(card.filename) orelse {
                std.debug.print("missing path in git tree: {s}\n", .{card.filename});
                return error.TestUnexpectedResult;
            };
            var cat_argv: [6][]const u8 = .{ "git", "-C", "mirror", "cat-file", "blob", git_oid };
            const git_bytes = try gitOut(gpa, io, tmp.dir, &cat_argv);
            defer gpa.free(git_bytes);
            const zit_bytes = (try repo.blob(gpa, card.hash.?)) orelse return error.TestUnexpectedResult;
            defer gpa.free(zit_bytes);
            try testing.expectEqualSlices(u8, zit_bytes, git_bytes);
        }
        try testing.expectEqual(count, git_paths.count());
    }
}

test "export-git e2e: two fresh exports are OID-identical, and incremental equals fresh" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var src = try buildFixture(gpa, io, tmp.dir);
    defer src.close(io);

    // Fresh export twice: identical refs.
    for ([_][]const u8{ "../m1", "../m2" }) |dest| {
        const exported = try runZit(gpa, io, src, &.{ "export-git", ".", dest });
        defer freeResult(gpa, exported);
        try expectExit(exported, 0);
    }
    const refs1 = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "m1", "for-each-ref" });
    defer gpa.free(refs1);
    const refs2 = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "m2", "for-each-ref" });
    defer gpa.free(refs2);
    try testing.expectEqualStrings(refs1, refs2);

    // Append a commit; incremental m1 must equal a fresh m3.
    try writeFile(io, src, "adir/alpha.txt", "alpha three\n");
    try commitAll(gpa, io, src, "append");
    {
        const incr = try runZit(gpa, io, src, &.{ "export-git", ".", "../m1" });
        defer freeResult(gpa, incr);
        try expectExit(incr, 0);
        try testing.expect(std.mem.indexOf(u8, incr.stdout, "exported 1 commits") != null);
    }
    {
        const fresh = try runZit(gpa, io, src, &.{ "export-git", ".", "../m3" });
        defer freeResult(gpa, fresh);
        try expectExit(fresh, 0);
    }
    const refs_incr = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "m1", "for-each-ref" });
    defer gpa.free(refs_incr);
    const refs_fresh = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "m3", "for-each-ref" });
    defer gpa.free(refs_fresh);
    try testing.expectEqualStrings(refs_incr, refs_fresh);
}

test "export-git e2e: a DEST inside an enclosing git repository gets its own repo; the enclosing refs never move" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    // The enclosing repository — the role the CI workspace checkout played
    // when this class of failure was caught: DEST is a fresh subdirectory
    // inside it, and `rev-parse --git-dir` resolves upward to HERE.
    for ([_][]const []const u8{
        &.{ "git", "init", "-q", "-b", "trunk", "." },
        &.{ "git", "-c", "user.email=e@x", "-c", "user.name=e", "commit", "--allow-empty", "-q", "-m", "enclosing" },
    }) |argv| {
        const r = try runProgram(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, r);
        try expectExit(r, 0);
    }
    const enclosing_tip = try gitOut(gpa, io, tmp.dir, &.{ "git", "rev-parse", "refs/heads/trunk" });
    defer gpa.free(enclosing_tip);

    var src = try buildFixture(gpa, io, tmp.dir);
    defer src.close(io);
    const exported = try runZit(gpa, io, src, &.{ "export-git", ".", "../mirror" });
    defer freeResult(gpa, exported);
    try expectExit(exported, 0);

    // The mirror is its own repository with the fixture's three commits…
    const mirror_gitdir = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "mirror", "rev-parse", "--git-dir" });
    defer gpa.free(mirror_gitdir);
    try testing.expectEqualStrings(".git", std.mem.trim(u8, mirror_gitdir, " \r\n"));

    // …and the enclosing repository's trunk did not move.
    const after = try gitOut(gpa, io, tmp.dir, &.{ "git", "rev-parse", "refs/heads/trunk" });
    defer gpa.free(after);
    try testing.expectEqualStrings(enclosing_tip, after);
}

test "export-git e2e: a foreign commit on a mirror ref refuses with exit 1 and unchanged state" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var src = try buildFixture(gpa, io, tmp.dir);
    defer src.close(io);

    {
        const exported = try runZit(gpa, io, src, &.{ "export-git", ".", "../mirror" });
        defer freeResult(gpa, exported);
        try expectExit(exported, 0);
    }
    // Foreign work: a commit made directly in the mirror moves trunk.
    for ([_][]const []const u8{
        &.{ "git", "-C", "mirror", "checkout", "-q", "trunk" },
        &.{ "git", "-C", "mirror", "-c", "user.email=f@x", "-c", "user.name=f", "commit", "--allow-empty", "-q", "-m", "foreign" },
    }) |argv| {
        const r = try runProgram(gpa, io, tmp.dir, argv);
        defer freeResult(gpa, r);
        try expectExit(r, 0);
    }
    const state_before = try tmp.dir.readFileAlloc(io, "mirror/" ++ zit.git_export.state_filename, gpa, .limited(1 << 20));
    defer gpa.free(state_before);

    try writeFile(io, src, "adir/alpha.txt", "post-foreign\n");
    try commitAll(gpa, io, src, "post-foreign");
    const refused = try runZit(gpa, io, src, &.{ "export-git", ".", "../mirror" });
    defer freeResult(gpa, refused);
    try expectExit(refused, 1);
    try testing.expect(std.mem.indexOf(u8, refused.stderr, "moved since the last export") != null);

    const state_after = try tmp.dir.readFileAlloc(io, "mirror/" ++ zit.git_export.state_filename, gpa, .limited(1 << 20));
    defer gpa.free(state_after);
    try testing.expectEqualSlices(u8, state_before, state_after);
}

test "export-git e2e: a .git path segment in the source refuses with exit 1 naming the path" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    // Author the hostile manifest through the library (the CLI itself refuses
    // to add such paths, which is the point — imported histories may not).
    {
        var raw = try tmp.dir.createFile(io, ".zit", .{ .read = true });
        var fs = zit.storage.FileStorage.init(io, raw, 0);
        var log = try zit.logstore.LogStore.open(gpa, fs.storageFile(), 0);
        try log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash("evil\n", &digest, .{});
        _ = try log.put(&digest, "evil\n");
        var blob_hex: [64]u8 = undefined;
        const digits = "0123456789abcdef";
        for (&digest, 0..) |b, i| {
            blob_hex[i * 2] = digits[b >> 4];
            blob_hex[i * 2 + 1] = digits[b & 0x0f];
        }
        var m: zit.artifact.Manifest = .{
            .arena_state = std.heap.ArenaAllocator.init(gpa),
            .kind = .manifest,
            .a = null,
            .b = null,
            .c = .{ .comment = "hostile" },
            .d = .{ .timestamp = "2020-01-01T00:00:00" },
            .e = null,
            .g = null,
            .h = null,
            .i = null,
            .k = null,
            .l = null,
            .n = null,
            .r = null,
            .u = .{ .login = "u" },
            .z = null,
            .w = null,
            .f = &.{.{ .filename = ".git/hooks/pwn", .hash = &blob_hex, .perm = null, .old_name = null, .mode = .regular }},
            .j = &.{},
            .m = &.{},
            .p = &.{},
            .q = &.{},
            .t = &.{.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }},
        };
        defer m.deinit();
        const bytes = try zit.artifact.create(&m, gpa);
        defer gpa.free(bytes);
        var mdigest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{});
        _ = try log.put(&mdigest, bytes);
        try log.commit();
        log.deinit(gpa);
        raw.close(io);
    }
    const refused = try runZit(gpa, io, tmp.dir, &.{ "export-git", ".", "mirror" });
    defer freeResult(gpa, refused);
    try expectExit(refused, 1);
    try testing.expect(std.mem.indexOf(u8, refused.stderr, ".git/hooks/pwn") != null);
}

test "git export e2e: a remembered mirror exports, autopushes with contact addresses, and re-runs bare" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var src = try buildFixture(gpa, io, tmp.dir);
    defer src.close(io);

    const cwd_abs = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(cwd_abs);
    const tmp_abs = try std.fs.path.join(gpa, &.{ cwd_abs, ".zig-cache", "tmp", &tmp.sub_path });
    defer gpa.free(tmp_abs);
    const remote = try std.fs.path.join(gpa, &.{ tmp_abs, "remote.git" });
    defer gpa.free(remote);
    const mirror = try std.fs.path.join(gpa, &.{ tmp_abs, "mirror" });
    defer gpa.free(mirror);
    {
        const bare = try runProgram(gpa, io, tmp.dir, &.{ "git", "init", "--quiet", "--bare", "remote.git" });
        defer freeResult(gpa, bare);
        try expectExit(bare, 0);
    }

    // The committer gets a contact, so the Git host can link their commits.
    const user_row = "1700000000 'e2e' pw '' cap 'v' info 'placeholder'";
    try writeFile(io, src, "users.txt", std.fmt.comptimePrint("config /user {d}\n{s}\n", .{ user_row.len, user_row }));
    {
        const merged = try runZit(gpa, io, src, &.{ "config", "merge", "users.txt" });
        defer freeResult(gpa, merged);
        try expectExit(merged, 0);
        const set = try runZit(gpa, io, src, &.{ "users", "contact", "e2e", "E2E Person <[email protected]>" });
        defer freeResult(gpa, set);
        try expectExit(set, 0);
        const shown = try runZit(gpa, io, src, &.{ "users", "contact", "e2e" });
        defer freeResult(gpa, shown);
        try expectExit(shown, 0);
        try testing.expectEqualStrings("E2E Person <[email protected]>\n", shown.stdout);
    }

    // A password in the autopush URL is refused and nothing is remembered.
    {
        const refused = try runZit(gpa, io, src, &.{ "git", "export", "../mirror", "--autopush", "https://u:[email protected]/x.git" });
        defer freeResult(gpa, refused);
        try expectExit(refused, 2);
        const status = try runZit(gpa, io, src, &.{ "git", "status" });
        defer freeResult(gpa, status);
        try expectExit(status, 0);
        try testing.expectEqualStrings("mirror        none\n", status.stdout);
    }

    {
        const first = try runZit(gpa, io, src, &.{ "git", "export", "../mirror", "--autopush", remote });
        defer freeResult(gpa, first);
        try expectExit(first, 0);
        const want = try std.fmt.allocPrint(gpa, "exported 3 commits into {s}\npushed trunk to {s} as main\n", .{ mirror, remote });
        defer gpa.free(want);
        try testing.expectEqualStrings(want, first.stdout);
    }
    {
        const authors = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "remote.git", "log", "--format=%an <%ae>", "main" });
        defer gpa.free(authors);
        try testing.expectEqualStrings(repeat("e2e <[email protected]>\n", 3), authors);
    }

    // Remembered: a bare re-run from any directory exports what is new and
    // pushes it, and status says the mirror is current.
    try writeFile(io, src, "adir/alpha.txt", "alpha three\n");
    try commitAll(gpa, io, src, "more");
    {
        const again = try runZit(gpa, io, tmp.dir, &.{ "git", "export", "-R", "src" });
        defer freeResult(gpa, again);
        try expectExit(again, 0);
        try testing.expect(std.mem.startsWith(u8, again.stdout, "exported 1 commits into "));
        const count = try gitOut(gpa, io, tmp.dir, &.{ "git", "-C", "remote.git", "rev-list", "--count", "main" });
        defer gpa.free(count);
        try testing.expectEqualStrings("4\n", count);
    }
    {
        const status = try runZit(gpa, io, src, &.{ "git", "status" });
        defer freeResult(gpa, status);
        try expectExit(status, 0);
        const want_mirror = try std.fmt.allocPrint(gpa, "mirror        {s}\nautopush      {s}\npush-branch   main\nemail-domain  zit.invalid\n", .{ mirror, remote });
        defer gpa.free(want_mirror);
        try testing.expect(std.mem.startsWith(u8, status.stdout, want_mirror));
        try testing.expect(std.mem.indexOf(u8, status.stdout, "exported      4 check-ins, last at ") != null);
        try testing.expect(std.mem.endsWith(u8, status.stdout, "up-to-date    yes\n"));
    }

    // Forgetting the autopush stops the pushing.
    {
        const off = try runZit(gpa, io, src, &.{ "git", "export", "--no-autopush" });
        defer freeResult(gpa, off);
        try expectExit(off, 0);
        try testing.expectEqualStrings("exported 0 commits into ", off.stdout[0..24]);
        try testing.expect(std.mem.indexOf(u8, off.stdout, "pushed") == null);
    }
}