Skip to content
zit Artifact [5315dfcd19]
Login

Artifact [5315dfcd19]

Artifact 5315dfcd19212219f9421989a585e50e705874db08778def05915a0f0acec3c5:


//! Scripted-session tests for `ExportSession`: a Git double cans every
//! subprocess answer, so each failure shape is pinned — exit path, precise
//! diagnostic, and an UNCHANGED `MirrorState` — without spawning anything.

const std = @import("std");
const zit = @import("zit");
const artifact = zit.artifact;
const logstore = zit.logstore;
const storage = zit.storage;
const Repository = zit.Repository;
const export_cli = @import("git_export_cli.zig");
const export_state = zit.git_export;

const testing = std.testing;

// --- fixture ----------------------------------------------------------------

const Fixture = struct {
    tmp: std.testing.TmpDir,
    io: std.Io,
    repo: Repository,
    root_hash: []u8,
    source_key: logstore.LogStore.LogKey,

    fn init(self: *Fixture, io: std.Io) !void {
        const gpa = testing.allocator;
        self.io = io;
        self.tmp = testing.tmpDir(.{});

        var raw = try self.tmp.dir.createFile(io, ".zit", .{ .read = true });
        var fs = storage.FileStorage.init(io, raw, 0);
        var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
        try log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });

        var blob_digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash("payload\n", &blob_digest, .{});
        _ = try log.put(&blob_digest, "payload\n");
        var blob_hex: [64]u8 = undefined;
        hexOf(&blob_digest, &blob_hex);

        var m: artifact.Manifest = .{
            .arena_state = std.heap.ArenaAllocator.init(gpa),
            .kind = .manifest,
            .a = null,
            .b = null,
            .c = .{ .comment = "root" },
            .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 = "a.txt", .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 artifact.create(&m, gpa);
        defer gpa.free(bytes);
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
        _ = try log.put(&digest, bytes);
        self.root_hash = try gpa.alloc(u8, 64);
        hexOf(&digest, self.root_hash[0..64]);

        try log.commit();
        self.source_key = try log.logKey();
        log.deinit(gpa);
        raw.close(io);

        try Repository.open(&self.repo, gpa, io, self.tmp.dir, .{ .mode = .full });
    }

    fn deinit(self: *Fixture) void {
        const gpa = testing.allocator;
        self.repo.deinit(gpa);
        gpa.free(self.root_hash);
        self.tmp.cleanup();
    }

    fn session(self: *Fixture, ops: export_cli.GitOps) export_cli.Session {
        return .{
            .gpa = testing.allocator,
            .io = self.io,
            .repo = &self.repo,
            .source_log_key = self.source_key,
            .dest_path = "dest",
            .dest_dir = self.tmp.dir,
            .ops = ops,
        };
    }
};

fn hexOf(digest: []const u8, out: []u8) void {
    const digits = "0123456789abcdef";
    for (digest, 0..) |b, i| {
        out[i * 2] = digits[b >> 4];
        out[i * 2 + 1] = digits[b & 0x0f];
    }
}

// --- the scripted double ------------------------------------------------------

const oid_a = "1111111111111111111111111111111111111111";

const ScriptedGit = struct {
    io: std.Io,
    dir: std.Io.Dir,
    /// Behaviour knobs, one per scenario.
    git_missing: bool = false,
    init_fails: bool = false,
    rev_parse_oid: ?[]const u8 = null,
    fast_import_exit: u8 = 0,
    fast_import_stderr: []const u8 = "",
    marks_lines: ?[]const u8 = null, // null = derive good marks from the stream
    /// Marks seen by fastImport: how many `mark :N` lines the stream carried.
    stream_marks: std.ArrayListUnmanaged(u64) = .empty,
    /// The `rev-parse --git-dir` probe answer. `.git` is a repo answering
    /// from its own root; an absolute path is upward discovery from a
    /// non-repo DEST inside an enclosing repository.
    git_dir_answer: []const u8 = ".git",
    /// `git init` invocations observed (the upward-discovery fix must init).
    init_calls: usize = 0,

    fn deinit(self: *ScriptedGit) void {
        self.stream_marks.deinit(testing.allocator);
    }

    fn runImpl(context: *anyopaque, gpa: std.mem.Allocator, argv: []const []const u8, out: *std.ArrayList(u8)) export_cli.RunError!u8 {
        const self: *ScriptedGit = @ptrCast(@alignCast(context));
        if (self.git_missing) return error.GitMissing;
        const sub = argv[if (std.mem.eql(u8, argv[1], "-C")) 3 else 1];
        if (std.mem.eql(u8, sub, "--version")) return 0;
        if (std.mem.eql(u8, sub, "rev-parse")) {
            if (std.mem.eql(u8, argv[argv.len - 1], "--git-dir")) {
                if (self.init_fails) return 1;
                // What a repo answers from its own root — or, scripted, what
                // upward discovery answers from a non-repo subdirectory.
                try out.appendSlice(gpa, self.git_dir_answer);
                try out.append(gpa, '\n');
                return 0;
            }
            // --verify <ref>: the canned answer — oid_a (where this
            // exporter leaves refs) unless a scenario moved it.
            const oid = self.rev_parse_oid orelse oid_a;
            try out.appendSlice(gpa, oid);
            try out.append(gpa, '\n');
            return 0;
        }
        if (std.mem.eql(u8, sub, "init")) {
            self.init_calls += 1;
            return if (self.init_fails) 1 else 0;
        }
        if (std.mem.eql(u8, sub, "update-ref")) return 0;
        if (std.mem.eql(u8, sub, "symbolic-ref")) return 0;
        return 0;
    }

    fn fastImportImpl(
        context: *anyopaque,
        gpa: std.mem.Allocator,
        dest: []const u8,
        marks_out: []const u8,
        marks_in: ?[]const u8,
        stream: []const u8,
        stderr_tail: *std.ArrayList(u8),
    ) export_cli.RunError!u8 {
        _ = dest;
        _ = marks_in;
        const self: *ScriptedGit = @ptrCast(@alignCast(context));
        if (self.fast_import_exit != 0) {
            try stderr_tail.appendSlice(gpa, self.fast_import_stderr);
            return self.fast_import_exit;
        }
        // Collect the stream's marks so good marks files can be derived.
        var lines = std.mem.splitScalar(u8, stream, '\n');
        while (lines.next()) |line| {
            if (!std.mem.startsWith(u8, line, "mark :")) continue;
            const mark = std.fmt.parseInt(u64, line["mark :".len..], 10) catch continue;
            try self.stream_marks.append(testing.allocator, mark);
        }
        var body: std.ArrayList(u8) = .empty;
        defer body.deinit(gpa);
        if (self.marks_lines) |canned| {
            try body.appendSlice(gpa, canned);
        } else {
            for (self.stream_marks.items) |mark| {
                try body.print(gpa, ":{d} {s}\n", .{ mark, oid_a });
            }
        }
        self.dir.createDir(self.io, export_state.state_dirname, .default_dir) catch {};
        var f = self.dir.createFile(self.io, marks_out, .{ .truncate = true }) catch return error.CommandFailed;
        defer f.close(self.io);
        f.writePositionalAll(self.io, body.items, 0) catch return error.CommandFailed;
        return 0;
    }

    fn ops(self: *ScriptedGit) export_cli.GitOps {
        return .{ .context = self, .runFn = runImpl, .fastImportFn = fastImportImpl };
    }
};

fn expectNoState(io: std.Io, dir: std.Io.Dir) !void {
    try testing.expectError(error.Absent, export_state.loadState(testing.allocator, io, dir));
}

// --- scenarios ----------------------------------------------------------------

test "export-git session: a healthy scripted run publishes marks, refs, and the state file" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
    defer git.deinit();

    var s = fx.session(git.ops());
    defer s.deinit();
    const outcome = try s.run();
    try testing.expectEqual(@as(usize, 1), outcome.commits_emitted);
    try testing.expect(!outcome.provisional_fixture);

    var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
    defer loaded.deinit();
    try testing.expectEqual(@as(usize, 1), loaded.records.len);
    try testing.expectEqualStrings(fx.root_hash, loaded.records[0].hash);
    try testing.expectEqualStrings(oid_a, loaded.records[0].oid);
    try testing.expectEqual(@as(usize, 1), loaded.refs.len);
    try testing.expectEqualStrings("refs/heads/trunk", loaded.refs[0].name);

    // Idempotence: a second run over the same log emits nothing.
    var git2 = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
    defer git2.deinit();
    var s2 = fx.session(git2.ops());
    defer s2.deinit();
    const again = try s2.run();
    try testing.expectEqual(@as(usize, 0), again.commits_emitted);
}

test "export-git session: a DEST inside an enclosing repository is initialized, never adopted" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    // Upward discovery: the probe succeeds but names a repository ABOVE DEST.
    // Adopting it aimed fast-import at that repository's refs (the CI
    // workspace checkout, in the run that caught this); the session must
    // initialize DEST as its own repository instead.
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .git_dir_answer = "/some/enclosing/checkout/.git" };
    defer git.deinit();

    var s = fx.session(git.ops());
    defer s.deinit();
    const outcome = try s.run();
    try testing.expectEqual(@as(usize, 1), outcome.commits_emitted);
    try testing.expectEqual(@as(usize, 1), git.init_calls);
}

test "export-git session: git missing refuses with a precise line and no state" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .git_missing = true };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.GitMissing, s.run());
    try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "not runnable") != null);
    try expectNoState(io, fx.tmp.dir);
}

test "export-git session: DEST neither a repository nor initializable refuses, state untouched" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .init_fails = true };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.DestNotARepository, s.run());
    try expectNoState(io, fx.tmp.dir);
}

test "export-git session: a foreign ref moved since the recorded state refuses rather than overwriting" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();

    // A recorded state claiming trunk sits at oid_a...
    const records = [_]export_state.Record{.{ .hash = fx.root_hash, .mark = 1, .oid = oid_a, .kind = .commit }};
    const refs = [_]export_state.Ref{.{ .name = "refs/heads/trunk", .oid = oid_a }};
    try testing.expect(export_state.storeState(testing.allocator, io, fx.tmp.dir, fx.source_key, false, &records, &refs));

    // ...while git answers a DIFFERENT OID for it.
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .rev_parse_oid = "2222222222222222222222222222222222222222" };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.ForeignRefMoved, s.run());
    try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "refs/heads/trunk") != null);

    // The state file is byte-identical to what was recorded.
    var loaded = try export_state.loadState(testing.allocator, io, fx.tmp.dir);
    defer loaded.deinit();
    try testing.expectEqualStrings(oid_a, loaded.refs[0].oid);
}

test "export-git session: nonzero fast-import exit refuses with the stderr tail, state untouched" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .fast_import_exit = 128, .fast_import_stderr = "fatal: mark :1 not a commit" };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.FastImportFailed, s.run());
    try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "exited 128") != null);
    try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "not a commit") != null);
    try expectNoState(io, fx.tmp.dir);
}

test "export-git session: a marks file missing an emitted mark refuses, state untouched" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .marks_lines = ":999 " ++ oid_a ++ "\n" };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.MarkUnresolved, s.run());
    try testing.expect(std.mem.indexOf(u8, s.diagnostic.items, "never resolved") != null);
    try expectNoState(io, fx.tmp.dir);
}

test "export-git session: malformed marks lines refuse, state untouched" {
    const io = testing.io;
    var fx: Fixture = undefined;
    try fx.init(io);
    defer fx.deinit();
    var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir, .marks_lines = "garbage without colon\n" };
    defer git.deinit();
    var s = fx.session(git.ops());
    defer s.deinit();
    try testing.expectError(error.MarksMalformed, s.run());
    try expectNoState(io, fx.tmp.dir);
}

test "export-git session: a veto at every publish point leaves the state unchanged" {
    const io = testing.io;
    const Veto = struct {
        var point: export_cli.PublishHooks.Point = .marks_readback;
        fn check(_: ?*anyopaque, at: export_cli.PublishHooks.Point) bool {
            return at != point;
        }
    };
    inline for (.{ .marks_readback, .finalize_refs, .state_rewrite }) |point| {
        var fx: Fixture = undefined;
        try fx.init(io);
        defer fx.deinit();
        var git = ScriptedGit{ .io = io, .dir = fx.tmp.dir };
        defer git.deinit();
        var s = fx.session(git.ops());
        defer s.deinit();
        Veto.point = point;
        s.hooks = .{ .checkFn = Veto.check };
        try testing.expect(std.meta.isError(s.run()));
        try expectNoState(io, fx.tmp.dir);
    }
}

test "export-git session: a branch with two open leaves refuses at ref finalize — Git has one tip per ref" {
    const io = testing.io;
    const gpa = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    var raw = try tmp.dir.createFile(io, ".zit", .{ .read = true });
    var fs = storage.FileStorage.init(io, raw, 0);
    var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
    try log.putConfig(.{ .hash_policy = 0, .default_branch = "trunk", .project_id = "p" });

    var blob_digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash("payload\n", &blob_digest, .{});
    _ = try log.put(&blob_digest, "payload\n");
    var blob_hex: [64]u8 = undefined;
    hexOf(&blob_digest, &blob_hex);

    var hashes: [3][64]u8 = undefined;
    const specs = [_]struct { ts: []const u8, parent: ?usize }{
        .{ .ts = "2020-01-01T00:00:00", .parent = null },
        .{ .ts = "2020-01-02T00:00:00", .parent = 0 },
        .{ .ts = "2020-01-03T00:00:00", .parent = 0 },
    };
    for (specs, 0..) |spec, i| {
        var m: artifact.Manifest = .{
            .arena_state = std.heap.ArenaAllocator.init(gpa),
            .kind = .manifest,
            .a = null,
            .b = null,
            .c = .{ .comment = "c" },
            .d = .{ .timestamp = spec.ts },
            .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 = "a.txt", .hash = &blob_hex, .perm = null, .old_name = null, .mode = .regular }},
            .j = &.{},
            .m = &.{},
            .p = &.{},
            .q = &.{},
            .t = if (spec.parent == null) &.{.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }} else &.{},
        };
        defer m.deinit();
        var pbuf: [1]artifact.PCard = undefined;
        if (spec.parent) |pi| {
            pbuf[0] = .{ .parents = &hashes[pi], .count = 1 };
            m.p = pbuf[0..1];
        }
        const bytes = try artifact.create(&m, gpa);
        defer gpa.free(bytes);
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
        _ = try log.put(&digest, bytes);
        hexOf(&digest, &hashes[i]);
    }
    try log.commit();
    const key = try log.logKey();
    log.deinit(gpa);
    raw.close(io);

    var repo: Repository = undefined;
    try Repository.open(&repo, gpa, io, tmp.dir, .{ .mode = .full });
    defer repo.deinit(gpa);
    var git = ScriptedGit{ .io = io, .dir = tmp.dir };
    defer git.deinit();
    var s2 = export_cli.Session{
        .gpa = gpa,
        .io = io,
        .repo = &repo,
        .source_log_key = key,
        .dest_path = "dest",
        .dest_dir = tmp.dir,
        .ops = git.ops(),
    };
    defer s2.deinit();
    try testing.expectError(error.RefFinalizeFailed, s2.run());
    try testing.expect(std.mem.indexOf(u8, s2.diagnostic.items, "open leaves") != null);
    try testing.expect(std.mem.indexOf(u8, s2.diagnostic.items, "trunk") != null);
    try expectNoState(io, tmp.dir);
}