Artifact 31734cfb765bd21e7522784821f5673c08cfe7ebd5ceda71f4a6dd2f5beb91bd:
- File
src/git/git_export_e2e.zig
— part of check-in
[5fce7e867d]
at
2026-09-28 16:44:53
on branch trunk
— zit git export/status: a remembered Git mirror with autopush
git export exports into MIRROR and remembers it, with an autopush URL, push branch and email domain, in repository config; a bare re-run does the round trip. Authors' addresses come from their user-table contact, set with the new users contact. Autopush URLs carrying a password are refused. The config payload bound is now sized from its key count. Release 0.3.34. (user: dmestas, size: 19770) [annotate] [blame] [check-ins using]
//! 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 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("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); } }