//! 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, .{});
}
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,
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")) return if (self.init_fails) 1 else 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")) 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: 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);
}
}