Artifact 5315dfcd19212219f9421989a585e50e705874db08778def05915a0f0acec3c5:
- File
src/git/git_export_cli_test.zig
— part of check-in
[0910bc00f5]
at
2026-09-17 13:39:56
on branch trunk
— log: open once, on the zero-parse image
log reached past the open funnel and called Repository.open with the default options, which is the full record replay meant for write verbs, and did so twice for a symbolic name. On pkgsrc that was 0.94s before the first row; on the image path it is 0.05-0.10s, output byte-identical on every route. The open mode has no default any more: every direct caller declares it, so the next verb written past the funnel cannot inherit the expensive one silently. An e2e runs every log route under the derive-footprint seam and fails if any of them replays a record. (user: dmestas, size: 18291) [annotate] [blame] [check-ins using]
//! 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); }