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