Artifact 1798285556f7eeb6bf58cace6b8cb0689d13a9ba39598239308cc11bc4c00362:
- File
src/git/git_import_e2e.zig
— part of check-in
[5c445bb3f3]
at
2026-09-28 02:39:03
on branch trunk
— Take the writer lock inside LogStore.open and test the lock class
LogStore.open now takes the exclusive lock before it reads the file's length and scans for the end, and a read-only handle refuses to append. purge undo opens the store through the shared writer path.
A new DST sweep runs two to four writers interleaved against one log through a file that models the lock. It flags unlocked or misplaced appends as they happen, checks each writer sees everything committed before it locked and reads back its own writes, including deltas. A source check fails on any write-mode open of the store outside openStoreAppend. An e2e test races commits, tags and user-table writes on one store and counts what landed. (user: dmestas, size: 149214) [annotate] [blame] [check-ins using]
//! Real-Git differential tests for `zit import-git`. //! //! This suite deliberately builds ordinary Git repositories rather than feeding a //! hand-written fast-export stream to the importer. Git is the oracle for tree //! shape, blob bytes, parent order, identities, commit messages, and refs; Zit //! is read back through its public storage and artifact APIs. The suite is an //! opt-in build step because it starts Git and the compiled Zit executable. const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("build_options"); const zit = @import("zit"); const artifact = zit.artifact; const git_import = zit.git_import; const checkout = zit.checkout; const logstore = zit.logstore; const storage = zit.storage; const zit_exe: []const u8 = build_options.zit_exe; const Identity = struct { name: []const u8, email: []const u8, epoch: i64, offset_minutes: i16, }; const MapIdentity = struct { name: []const u8, email: []const u8, timestamp: i64, timezone_offset_minutes: i16, }; /// The `mirror` object a `kind:"commit"` map row carries: what the classifier /// decided about that commit's Fossil-mirror candidates, and -- when it /// retained them -- which gate said so. const MapMirror = struct { decision: []const u8, reason: ?[]const u8 = null, }; const MapEntry = struct { kind: []const u8, oid: ?[]const u8 = null, zit: ?[]const u8 = null, source: ?[]const u8 = null, canonical: ?[]const u8 = null, author: ?MapIdentity = null, committer: ?MapIdentity = null, warning: ?[]const u8 = null, target_zit: ?[]const u8 = null, mirror: ?MapMirror = null, }; const Checkin = struct { bytes: []u8, manifest: artifact.Manifest, fn deinit(self: *Checkin, gpa: std.mem.Allocator) void { self.manifest.deinit(); gpa.free(self.bytes); self.* = undefined; } }; const LoadedManifest = struct { hash: []u8, bytes: []u8, manifest: artifact.Manifest, fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void { gpa.free(self.hash); self.manifest.deinit(); gpa.free(self.bytes); } }; fn rawArtifactHash(name: []const u8, buffer: *[32]u8) ?[]const u8 { if (name.len != 40 and name.len != 64) return null; const raw = buffer[0 .. name.len / 2]; _ = std.fmt.hexToBytes(raw, name) catch return null; return raw; } /// Keeps every resolver result alive until `flattenTree` completes. The /// artifact API intentionally borrows slices from its resolver, so freeing a /// submanifest eagerly would make the differential oracle unsound. const TreeCache = struct { gpa: std.mem.Allocator, log: *logstore.LogStore, loaded: std.ArrayList(LoadedManifest) = .empty, fn deinit(self: *TreeCache) void { for (self.loaded.items) |*item| item.deinit(self.gpa); self.loaded.deinit(self.gpa); self.* = undefined; } fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView { const self: *TreeCache = @ptrCast(@alignCast(ctx)); for (self.loaded.items) |*item| { if (std.mem.eql(u8, item.hash, hash)) { return .{ .files = item.manifest.f, .subs = item.manifest.s }; } } var digest: [32]u8 = undefined; const raw_hash = rawArtifactHash(hash, &digest) orelse return null; const rid = self.log.lookup(raw_hash) orelse return null; const bytes = (self.log.content(self.gpa, rid) catch return null) orelse return null; errdefer self.gpa.free(bytes); var manifest = artifact.parse(self.gpa, bytes) catch return null; errdefer manifest.deinit(); const owned_hash = self.gpa.dupe(u8, hash) catch return null; errdefer self.gpa.free(owned_hash); self.loaded.append(self.gpa, .{ .hash = owned_hash, .bytes = bytes, .manifest = manifest }) catch return null; const item = &self.loaded.items[self.loaded.items.len - 1]; return .{ .files = item.manifest.f, .subs = item.manifest.s }; } }; fn freeResult(gpa: std.mem.Allocator, result: *std.process.RunResult) void { gpa.free(result.stdout); gpa.free(result.stderr); } fn expectExit(result: std.process.RunResult, expected: u8) !void { switch (result.term) { .exited => |actual| if (actual == expected) return else { std.debug.print("expected exit {d}, got {d}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, actual, result.stdout, result.stderr }); return error.UnexpectedExit; }, else => { std.debug.print("expected exit {d}, got {any}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, result.term, result.stdout, result.stderr }); return error.UnexpectedTermination; }, } } fn expectFailure(result: std.process.RunResult) !void { try expectExit(result, 1); } fn expectNoDirectory(io: std.Io, parent: std.Io.Dir, path: []const u8) !void { var dir = parent.openDir(io, path, .{}) catch return; dir.close(io); return error.UnexpectedPublishedDestination; } fn runCommand( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, argv: []const []const u8, env: ?*std.process.Environ.Map, ) !std.process.RunResult { return std.process.run(gpa, io, .{ .argv = argv, .cwd = .{ .dir = cwd }, .environ_map = env, }); } /// The `.zit-checkout` state of `dir`, or an error naming its absence. /// /// Every caller here reads it straight after a `zit update` that exited 0, so a /// null is a real defect -- but `.?` DIAGNOSES that only while safety checks are /// on, and this suite's own lane rules run it under `--release=fast` for corpus /// loops, where unwrapping a null is illegal behavior rather than a failed test. /// The same argument applies to every optional in this file that a subprocess or /// a repository populated. fn readCheckoutState(io: std.Io, dir: std.Io.Dir, buf: []u8) !checkout.CheckoutState { return (try checkout.read(io, dir, buf)) orelse error.MissingCheckoutState; } fn expectNoImportTemps(io: std.Io, parent: std.Io.Dir) !void { // Reopened for iteration: the caller's handle may lack iterate rights, // which Linux enforces at readdir time (macOS does not). var it_dir = try parent.openDir(io, ".", .{ .iterate = true }); defer it_dir.close(io); var entries = it_dir.iterate(); while (try entries.next(io)) |entry| { try std.testing.expect(!std.mem.startsWith(u8, entry.name, ".zit-import-tmp-")); } } fn initGitEnv(gpa: std.mem.Allocator) !std.process.Environ.Map { var env = std.process.Environ.Map.init(gpa); errdefer env.deinit(); try env.put("TZ", "UTC"); try env.put("HOME", "/zit-git-import-e2e-no-home"); try env.put("PATH", "/usr/bin:/bin"); try env.put("LC_ALL", "C"); try env.put("LANG", "C"); try env.put("GIT_CONFIG_NOSYSTEM", "1"); try env.put("GIT_CONFIG_GLOBAL", "/dev/null"); try env.put("GIT_AUTHOR_NAME", "Git Author"); try env.put("GIT_AUTHOR_EMAIL", "[email protected]"); try env.put("GIT_COMMITTER_NAME", "Git Committer"); try env.put("GIT_COMMITTER_EMAIL", "[email protected]"); // The two instants DIFFER, and they have to. Git's own default makes them // equal, and `--attribution` selects between exactly these two: with one // value in both slots the D card is the same either way, so an importer // that always wrote the committer instant would satisfy the author run too // and `assertCommitDifferential`'s timestamp comparison would prove // nothing. Only the login would discriminate, and a login is one field. try env.put("GIT_AUTHOR_DATE", author_instant_date ++ "T04:05:06+0000"); try env.put("GIT_COMMITTER_DATE", committer_instant_date ++ "T04:05:06+0000"); return env; } /// The date every fixture instant in this file carries, and the earlier date /// the matching author instant is moved to. Same width so the substitution is a /// splice, and far enough apart that no rounding could collapse them. const committer_instant_date = "2001-02-03"; const author_instant_date = "1999-01-02"; /// The author instant paired with `committer`: same time of day, earlier date. /// /// Derived rather than passed so every call site states one instant and still /// gets two distinct ones. Both attribution modes then read a different D card, /// which is what makes the comparison against it load-bearing. fn authorInstant(committer: []const u8, buffer: []u8) ![]const u8 { if (committer.len <= committer_instant_date.len) return error.MalformedTestInstant; if (committer[committer_instant_date.len] != 'T') return error.MalformedTestInstant; return std.fmt.bufPrint(buffer, "{s}{s}", .{ author_instant_date, committer[committer_instant_date.len..], }); } fn runGit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult { var argv: [16][]const u8 = undefined; std.debug.assert(args.len + 1 <= argv.len); argv[0] = "git"; for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg; var env = try initGitEnv(gpa); defer env.deinit(); return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env); } fn gitOk(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !void { var result = try runGit(gpa, io, cwd, args); defer freeResult(gpa, &result); try expectExit(result, 0); } fn gitCommit( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, message: []const u8, epoch: []const u8, allow_empty: bool, ) !void { var env = try initGitEnv(gpa); defer env.deinit(); var author_buffer: [64]u8 = undefined; try env.put("GIT_AUTHOR_DATE", try authorInstant(epoch, &author_buffer)); try env.put("GIT_COMMITTER_DATE", epoch); var argv: [9][]const u8 = .{ "git", "-C", "source", "commit", "--no-gpg-sign", "-m", message, undefined, undefined }; const len: usize = if (allow_empty) blk: { argv[7] = "--allow-empty"; break :blk 8; } else 7; var result = try runCommand(gpa, io, cwd, argv[0..len], &env); defer freeResult(gpa, &result); try expectExit(result, 0); } fn gitMerge( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, branches: []const []const u8, message: []const u8, epoch: []const u8, ) !void { var env = try initGitEnv(gpa); defer env.deinit(); var author_buffer: [64]u8 = undefined; try env.put("GIT_AUTHOR_DATE", try authorInstant(epoch, &author_buffer)); try env.put("GIT_COMMITTER_DATE", epoch); var argv: [16][]const u8 = undefined; const prefix = [_][]const u8{ "git", "-C", "source", "merge", "--no-ff", "-m", message }; for (prefix, argv[0..prefix.len]) |value, *slot| slot.* = value; std.debug.assert(prefix.len + branches.len <= argv.len); for (branches, argv[prefix.len..][0..branches.len]) |branch, *slot| slot.* = branch; var result = try runCommand(gpa, io, cwd, argv[0 .. prefix.len + branches.len], &env); defer freeResult(gpa, &result); try expectExit(result, 0); } fn runZit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult { var env = std.process.Environ.Map.init(gpa); defer env.deinit(); try env.put("TZ", "UTC"); try env.put("HOME", "/zit-git-import-e2e-no-home"); try env.put("PATH", "/usr/bin:/bin"); const base = try std.process.currentPathAlloc(io, gpa); defer gpa.free(base); const executable = try std.fs.path.resolve(gpa, &.{ base, zit_exe }); defer gpa.free(executable); var argv: [16][]const u8 = undefined; std.debug.assert(args.len + 1 <= argv.len); argv[0] = executable; for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg; return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env); } fn writeFile(io: std.Io, dir: std.Io.Dir, path: []const u8, bytes: []const u8) !void { var file = try dir.createFile(io, path, .{}); defer file.close(io); try file.writePositionalAll(io, bytes, 0); } fn gitOutput(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) ![]u8 { var result = try runGit(gpa, io, cwd, args); defer freeResult(gpa, &result); try expectExit(result, 0); return gpa.dupe(u8, result.stdout); } fn parseIdentity(line: []const u8, prefix: []const u8) !Identity { if (!std.mem.startsWith(u8, line, prefix)) return error.MalformedGitIdentity; const rest = line[prefix.len..]; const close = std.mem.lastIndexOfScalar(u8, rest, '>') orelse return error.MalformedGitIdentity; if (close + 2 >= rest.len or rest[close + 1] != ' ') return error.MalformedGitIdentity; const open = std.mem.lastIndexOfScalar(u8, rest[0..close], '<') orelse return error.MalformedGitIdentity; if (open == 0 or rest[open - 1] != ' ') return error.MalformedGitIdentity; var tail = std.mem.splitScalar(u8, rest[close + 2 ..], ' '); const epoch = try std.fmt.parseInt(i64, tail.next() orelse return error.MalformedGitIdentity, 10); const zone = tail.next() orelse return error.MalformedGitIdentity; if (zone.len != 5 or (zone[0] != '+' and zone[0] != '-')) return error.MalformedGitIdentity; const minutes = try std.fmt.parseInt(i16, zone[1..3], 10) * 60 + try std.fmt.parseInt(i16, zone[3..5], 10); return .{ .name = rest[0 .. open - 1], .email = rest[open + 1 .. close], .epoch = epoch, .offset_minutes = if (zone[0] == '-') -minutes else minutes, }; } const GitCommit = struct { bytes: []u8, parents: []u8, author: Identity, committer: Identity, message: []const u8, fn deinit(self: *GitCommit, gpa: std.mem.Allocator) void { gpa.free(self.parents); gpa.free(self.bytes); self.* = undefined; } }; fn inspectGitCommit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, oid: []const u8) !GitCommit { const bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "commit", oid }); errdefer gpa.free(bytes); const header_end = std.mem.indexOf(u8, bytes, "\n\n") orelse return error.MalformedGitCommit; const headers = bytes[0..header_end]; var parents: std.ArrayList(u8) = .empty; errdefer parents.deinit(gpa); var author: ?Identity = null; var committer: ?Identity = null; var lines = std.mem.splitScalar(u8, headers, '\n'); while (lines.next()) |line| { if (std.mem.startsWith(u8, line, "parent ")) { if (parents.items.len != 0) try parents.append(gpa, ' '); try parents.appendSlice(gpa, line[7..]); } else if (std.mem.startsWith(u8, line, "author ")) author = try parseIdentity(line, "author ") else if (std.mem.startsWith(u8, line, "committer ")) committer = try parseIdentity(line, "committer "); } return .{ .bytes = bytes, .parents = try parents.toOwnedSlice(gpa), .author = author orelse return error.MalformedGitCommit, .committer = committer orelse return error.MalformedGitCommit, .message = bytes[header_end + 2 ..], }; } fn loadCheckin(gpa: std.mem.Allocator, log: *logstore.LogStore, hash: []const u8) !Checkin { var digest: [32]u8 = undefined; const raw_hash = rawArtifactHash(hash, &digest) orelse return error.InvalidZitHash; const rid = log.lookup(raw_hash) orelse return error.MissingZitCheckin; const bytes = (try log.content(gpa, rid)) orelse return error.MissingZitCheckin; errdefer gpa.free(bytes); return .{ .bytes = bytes, .manifest = try artifact.parse(gpa, bytes) }; } fn flattenedFiles(manifest: *artifact.Manifest, cache: *TreeCache) ![]const artifact.FCard { if (manifest.s.len == 0) return manifest.f; try manifest.flattenTree(.{ .ctx = cache, .resolveFn = TreeCache.resolve }); return manifest.flattened.?; } fn fileMode(mode: []const u8) !artifact.FileMode { if (std.mem.eql(u8, mode, "100644")) return .regular; if (std.mem.eql(u8, mode, "100755")) return .executable; if (std.mem.eql(u8, mode, "120000")) return .symlink; return error.UnrepresentableGitMode; } /// Root paths this commit's imported tree is expected NOT to carry, because a /// validated Fossil mirror pair was omitted from it. /// /// Passed in rather than derived: which commits validated is the thing under /// test, so a comparator that worked it out from the store would agree with /// whatever the importer did. The caller states the expectation and both /// directions are then assertions -- present when it should be, absent when it /// should not. const no_omissions: []const []const u8 = &.{}; fn isOmitted(omitted: []const []const u8, path: []const u8) bool { for (omitted) |name| { if (std.mem.eql(u8, name, path)) return true; } return false; } fn compareTree( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, log: *logstore.LogStore, commit: []const u8, checkin: *artifact.Manifest, omitted: []const []const u8, ) !void { var cache = TreeCache{ .gpa = gpa, .log = log }; defer cache.deinit(); const files = try flattenedFiles(checkin, &cache); const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit }); defer gpa.free(listing); var count: usize = 0; var records = std.mem.splitScalar(u8, listing, 0); while (records.next()) |record| { if (record.len == 0) continue; const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree; var header = std.mem.splitScalar(u8, record[0..tab], ' '); const mode = header.next() orelse return error.MalformedLsTree; const object_kind = header.next() orelse return error.MalformedLsTree; const blob_oid = header.next() orelse return error.MalformedLsTree; const path = record[tab + 1 ..]; // A gitlink is a `commit` entry in Git's tree. The importer omits it // deliberately, so the imported manifest must not name that path at // all. Assert the absence rather than tolerating it: a silent // reappearance would mean the skip had stopped working. if (std.mem.eql(u8, object_kind, "commit")) { try std.testing.expectEqualStrings("160000", mode); for (files) |f| { if (std.mem.eql(u8, path, f.filename)) return error.UnexpectedImportedSubmodule; } continue; } try std.testing.expectEqualStrings("blob", object_kind); // An omitted mirror candidate is in Git's tree and must be in no // imported tree. Asserted rather than skipped: a candidate that // reappeared would otherwise pass as an ordinary matching file. if (isOmitted(omitted, path)) { for (files) |f| { if (std.mem.eql(u8, path, f.filename)) return error.RetainedOmittedMirrorCandidate; } continue; } const found = for (files) |f| { if (std.mem.eql(u8, path, f.filename)) break f; } else return error.MissingImportedPath; try std.testing.expectEqual(try fileMode(mode), found.mode); const zit_hash = found.hash orelse return error.ImportedDeleteInGitTree; var digest: [32]u8 = undefined; const raw_hash = rawArtifactHash(zit_hash, &digest) orelse return error.InvalidZitHash; const rid = log.lookup(raw_hash) orelse return error.MissingImportedBlob; const zit_bytes = (try log.content(gpa, rid)) orelse return error.MissingImportedBlob; defer gpa.free(zit_bytes); const git_bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid }); defer gpa.free(git_bytes); try std.testing.expectEqualSlices(u8, git_bytes, zit_bytes); count += 1; } try std.testing.expectEqual(files.len, count); } fn compareMaterializedTree( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, destination: std.Io.Dir, commit: []const u8, omitted: []const []const u8, ) !void { const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit }); defer gpa.free(listing); var records = std.mem.splitScalar(u8, listing, 0); while (records.next()) |record| { if (record.len == 0) continue; const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree; var header = std.mem.splitScalar(u8, record[0..tab], ' '); const mode = header.next() orelse return error.MalformedLsTree; const object_kind = header.next() orelse return error.MalformedLsTree; const blob_oid = header.next() orelse return error.MalformedLsTree; const path = record[tab + 1 ..]; // Omitted gitlink: nothing may be materialized at that path. if (std.mem.eql(u8, object_kind, "commit")) { try std.testing.expectEqualStrings("160000", mode); if (destination.statFile(io, path, .{ .follow_symlinks = false })) |_| { return error.UnexpectedMaterializedSubmodule; } else |err| switch (err) { error.FileNotFound => continue, else => return err, } } try std.testing.expectEqualStrings("blob", object_kind); // Omitted mirror candidate: nothing may be materialized at that path. if (isOmitted(omitted, path)) { if (destination.statFile(io, path, .{ .follow_symlinks = false })) |_| { return error.MaterializedOmittedMirrorCandidate; } else |err| switch (err) { error.FileNotFound => continue, else => return err, } } const expected = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid }); defer gpa.free(expected); const stat = try destination.statFile(io, path, .{ .follow_symlinks = false }); if (std.mem.eql(u8, mode, "120000")) { try std.testing.expectEqual(.sym_link, stat.kind); var target: [4096]u8 = undefined; const len = try destination.readLink(io, path, &target); try std.testing.expectEqualSlices(u8, expected, target[0..len]); continue; } try std.testing.expectEqual(.file, stat.kind); const actual = try destination.readFileAlloc(io, path, gpa, .limited(1 << 20)); defer gpa.free(actual); try std.testing.expectEqualSlices(u8, expected, actual); if (std.mem.eql(u8, mode, "100755") and comptime std.Io.File.Permissions.has_executable_bit) { try std.testing.expect(stat.permissions.toMode() & 0o111 != 0); } if (std.mem.eql(u8, mode, "100644") and comptime std.Io.File.Permissions.has_executable_bit) { try std.testing.expect(stat.permissions.toMode() & 0o111 == 0); } } } fn expectIdentity(expected: Identity, actual: MapIdentity) !void { try std.testing.expectEqualStrings(expected.name, actual.name); try std.testing.expectEqualStrings(expected.email, actual.email); try std.testing.expectEqual(expected.epoch, actual.timestamp); try std.testing.expectEqual(expected.offset_minutes, actual.timezone_offset_minutes); } fn writerTimestamp(epoch: i64, out: *[19]u8) ![]const u8 { const days = @divFloor(epoch, @as(i64, 86400)); const rem = @mod(epoch, @as(i64, 86400)); const z = days + 719468; const era = @divFloor(z, @as(i64, 146097)); const doe = z - era * 146097; const yoe = @divFloor(doe - @divFloor(doe, @as(i64, 1460)) + @divFloor(doe, @as(i64, 36524)) - @divFloor(doe, @as(i64, 146096)), @as(i64, 365)); const doy = doe - (365 * yoe + @divFloor(yoe, @as(i64, 4)) - @divFloor(yoe, @as(i64, 100))); const mp = @divFloor(5 * doy + 2, @as(i64, 153)); const day = doy - @divFloor(153 * mp + 2, @as(i64, 5)) + 1; const month = if (mp < 10) mp + 3 else mp - 9; const year = yoe + era * 400 + @as(i64, if (month <= 2) 1 else 0); if (year < 0 or year > 9999) return error.InvalidTimestamp; const put2 = struct { fn put(slice: []u8, value: u8) void { slice[0] = '0' + value / 10; slice[1] = '0' + value % 10; } }.put; put2(out[0..2], @intCast(@divFloor(year, @as(i64, 100)))); put2(out[2..4], @intCast(@mod(year, @as(i64, 100)))); out[4] = '-'; put2(out[5..7], @intCast(month)); out[7] = '-'; put2(out[8..10], @intCast(day)); out[10] = 'T'; put2(out[11..13], @intCast(@divFloor(rem, @as(i64, 3600)))); out[13] = ':'; put2(out[14..16], @intCast(@divFloor(@mod(rem, @as(i64, 3600)), @as(i64, 60)))); out[16] = ':'; put2(out[17..19], @intCast(@mod(rem, @as(i64, 60)))); return out; } fn findCommitMap(gpa: std.mem.Allocator, map_bytes: []const u8, oid: []const u8) ![]u8 { var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const entry = parsed.value; if (std.mem.eql(u8, entry.kind, "commit") and entry.oid != null and std.mem.eql(u8, entry.oid.?, oid)) { return gpa.dupe(u8, entry.zit orelse return error.MissingCommitMapHash); } } return error.MissingCommitMap; } fn mappedParents(gpa: std.mem.Allocator, map_bytes: []const u8, parents: []const u8) ![]u8 { var result: std.ArrayList(u8) = .empty; errdefer result.deinit(gpa); var source_parents = std.mem.splitScalar(u8, parents, ' '); while (source_parents.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); if (result.items.len != 0) try result.append(gpa, ' '); try result.appendSlice(gpa, mapped); } return result.toOwnedSlice(gpa); } /// Which Git identity `--attribution` selected for the Zit U and D cards. The /// map's `author` and `committer` provenance rows carry both regardless, so /// only the check-in comparison varies. const Attribution = enum { committer, author }; fn assertCommitDifferential( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, log: *logstore.LogStore, map_bytes: []const u8, oid: []const u8, attribution: Attribution, omitted: []const []const u8, ) !void { const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var git = try inspectGitCommit(gpa, io, cwd, oid); defer git.deinit(gpa); const attributed = switch (attribution) { .committer => git.committer, .author => git.author, }; var checkin = try loadCheckin(gpa, log, mapped); defer checkin.deinit(gpa); // Named errors rather than `.?`: these three cards are parsed out of an // artifact the importer wrote, and a check-in that reached the store without // one is a defect this comparison should REPORT. `.?` reports it only while // safety checks are on, and says nothing at all under `--release=fast`. const comment_card = checkin.manifest.c orelse return error.MissingCommentCard; const user_card = checkin.manifest.u orelse return error.MissingUserCard; const date_card = checkin.manifest.d orelse return error.MissingDateCard; try std.testing.expectEqualStrings(git.message, comment_card.comment); try std.testing.expectEqualStrings(attributed.name, user_card.login); var timestamp: [19]u8 = undefined; try std.testing.expectEqualStrings(try writerTimestamp(attributed.epoch, ×tamp), date_card.timestamp); const expected_parents = try mappedParents(gpa, map_bytes, git.parents); defer gpa.free(expected_parents); try std.testing.expectEqualStrings(expected_parents, if (checkin.manifest.p.len == 0) "" else (checkin.manifest.p[0].parents orelse "")); try compareTree(gpa, io, cwd, log, oid, &checkin.manifest, omitted); var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const entry = parsed.value; if (!std.mem.eql(u8, entry.kind, "commit") or entry.oid == null or !std.mem.eql(u8, entry.oid.?, oid)) continue; try std.testing.expectEqualStrings(mapped, entry.zit orelse return error.MissingCommitMapHash); try expectIdentity(git.author, entry.author orelse return error.MissingAuthorProvenance); try expectIdentity(git.committer, entry.committer orelse return error.MissingCommitterProvenance); return; } return error.MissingCommitProvenance; } /// Every `ref` and `tag` row of `map_bytes` resolves, in `destination`, to the /// commit Git says it names. /// /// `minimum` is a non-vacuity floor and not a cosmetic count. This walks rows and /// asserts nothing when there are none, so an import that stopped emitting ref /// rows -- or a map read from the wrong place -- would satisfy it in silence. In /// the mirror fixture this is the only ref statement made at all, which is /// exactly the situation where a loop over an empty list must be an error. /// `main`, `feature`, `octopus-a`, `octopus-b`, `v1`, `lightweight`. const main_fixture_refs = 6; /// `main`, `side`, `lookalike`, `mirror-light`, `mirror-v1`. const mirror_fixture_refs = 5; fn assertRefResolution( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, destination: std.Io.Dir, map_bytes: []const u8, minimum: usize, ) !void { var resolved: usize = 0; var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const entry = parsed.value; const target = if (std.mem.eql(u8, entry.kind, "ref")) entry.zit else if (std.mem.eql(u8, entry.kind, "tag")) entry.target_zit else continue; const source = entry.source orelse return error.MissingImportedRefSource; const canonical = entry.canonical orelse return error.MissingCanonicalRef; const mapped_target = target orelse return error.MissingImportedRefTarget; const rev = try std.fmt.allocPrint(gpa, "{s}^{{commit}}", .{source}); defer gpa.free(rev); const git_oid = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-parse", rev }); defer gpa.free(git_oid); const expected = try findCommitMap(gpa, map_bytes, std.mem.trimEnd(u8, git_oid, "\n")); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, mapped_target); var update = try runZit(gpa, io, destination, &.{ "update", canonical }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings(mapped_target, current.version); resolved += 1; } if (resolved < minimum) { std.debug.print("resolved {d} refs and tags, expected at least {d}\n", .{ resolved, minimum }); return error.TooFewResolvedRefs; } } // --------------------------------------------------------------------------- // Published-generation shape // --------------------------------------------------------------------------- /// The entries a published import destination holds. Spelled as literals /// because `git_import_cli.zig` is not exported through `root.zig` and this /// build root cannot import it; the source of truth for the set is /// `git_import_cli.publish_manifest`. // The --provenance publication set. This whole suite imports with the flag, // because nearly every assertion here reads the map or the refs manifest; the // DEFAULT set is pinned separately by `expectDefaultPublication` below. const published_entries = [_][]const u8{ ".zit", ".zit-checkout", ".zit-import-map.jsonl", "refs" }; /// The default publication is the repository and its checkout record -- /// nothing else but the advisory cache the completion-time warm may add. fn expectDefaultPublication(io: std.Io, destination: std.Io.Dir) !void { // Reopened for iteration: the caller's handle may lack iterate rights, // which Linux enforces at readdir time (macOS does not). var it_dir = try destination.openDir(io, ".", .{ .iterate = true }); defer it_dir.close(io); var entries = it_dir.iterate(); while (try entries.next(io)) |entry| { if (std.mem.eql(u8, entry.name, ".zit")) continue; if (std.mem.eql(u8, entry.name, ".zit-checkout")) continue; if (std.mem.eql(u8, entry.name, ".zit-cache")) continue; std.debug.print("unexpected default-publication entry: {s}\n", .{entry.name}); return error.UnexpectedPublishedEntry; } } test "git import e2e: without --provenance the destination is the repository and its record, nothing else" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" }); try writeFile(io, tmp.dir, "source/a.txt", "plain content\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "one", "2001-02-03T04:05:06+0000", false); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "bare-dest" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); var destination = try tmp.dir.openDir(io, "bare-dest", .{}); defer destination.close(io); try expectDefaultPublication(io, destination); } /// Written AFTER publication by the completion-time cache warm, so it is a /// legitimate resident of an imported repository but never a required one -- /// the warm is best-effort and a failure only costs the first reader a derive. const optional_published_entries = [_][]const u8{".zit-cache"}; const raw_generation_entry = ".zit-import-raw"; /// A published destination is generation 2 and nothing else. /// /// The raw generation is a file inside the same staging directory the rename /// ships, so "the packed store was published" and "the raw store was not" are /// two different facts. Naming the whole expected set proves the second one: /// an unexpected extra entry fails here rather than surviving as dead weight /// in every imported repository. fn expectPublishedGeneration(io: std.Io, destination: std.Io.Dir) !void { var seen: [published_entries.len]bool = @splat(false); // Reopened for iteration: the caller's handle may lack iterate rights, // which Linux enforces at readdir time (macOS does not). var it_dir = try destination.openDir(io, ".", .{ .iterate = true }); defer it_dir.close(io); var entries = it_dir.iterate(); while (try entries.next(io)) |entry| { if (std.mem.eql(u8, entry.name, raw_generation_entry)) return error.RawGenerationPublished; const optional = for (optional_published_entries) |name| { if (std.mem.eql(u8, entry.name, name)) break true; } else false; if (optional) continue; const index = for (published_entries, 0..) |name, position| { if (std.mem.eql(u8, entry.name, name)) break position; } else { std.debug.print("unexpected published entry: {s}\n", .{entry.name}); return error.UnexpectedPublishedEntry; }; if (seen[index]) return error.DuplicatePublishedEntry; seen[index] = true; } for (seen, published_entries) |present, name| { if (!present) { std.debug.print("missing published entry: {s}\n", .{name}); return error.MissingPublishedEntry; } } } /// Whether the published store holds an artifact named for `content`. /// /// `sha1` because `import-git` opens its destination under Git's hash policy, /// which the main fixture pins by requiring a 40-character parent version. /// /// Errors rather than answering `false` on a name it cannot decode. `false` is /// what every drop-set assertion reads as "correctly dropped", so a helper that /// returned it for an unaskable question would let all twenty absence proofs in /// this file pass while proving nothing. fn storeHolds(log: *logstore.LogStore, content: []const u8) !bool { var name_buf: [64]u8 = undefined; // The question is whether the IMPORT stored these bytes, so the name is // computed under the import's algorithm. A fixed one asks about an address // the store never used and answers "absent" for everything. const name = artifact.artifactName(git_import.import_hash_algo, content, &name_buf); var digest: [32]u8 = undefined; const raw = rawArtifactHash(name, &digest) orelse return error.MalformedArtifactName; return log.lookup(raw) != null; } /// The classifier's own account of one commit, read back out of the map. /// /// Asserted alongside the store contents rather than instead of them: the map /// says what the importer decided and the store says what the pack did, and a /// mirror fixture is only meaningful when those two agree. `reason` is null /// for an omission. fn assertMirrorDecision( gpa: std.mem.Allocator, map_bytes: []const u8, oid: []const u8, decision: []const u8, reason: ?[]const u8, ) !void { var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const entry = parsed.value; if (!std.mem.eql(u8, entry.kind, "commit")) continue; if (entry.oid == null or !std.mem.eql(u8, entry.oid.?, oid)) continue; const mirror = entry.mirror orelse return error.MissingMirrorProvenance; // The reason is compared as an OPTIONAL, not through a `orelse ""` // flattening: an expected `null` must not be satisfiable by a // serialized empty string, which is a different fact -- "the classifier // recorded no reason" versus "it recorded a blank one". const reason_matches = if (reason) |expected| mirror.reason != null and std.mem.eql(u8, expected, mirror.reason.?) else mirror.reason == null; if (!std.mem.eql(u8, decision, mirror.decision) or !reason_matches) { std.debug.print( "{s}: expected mirror {s}/{s}, got {s}/{s}\n", .{ oid, decision, reason orelse "null", mirror.decision, mirror.reason orelse "null" }, ); return error.UnexpectedMirrorDecision; } return; } return error.MissingMirrorProvenance; } fn expectStoreHolds(log: *logstore.LogStore, label: []const u8, content: []const u8, present: bool) !void { if ((try storeHolds(log, content)) == present) return; std.debug.print( "{s}: expected {s} in the published store\n", .{ label, if (present) "present" else "absent" }, ); return if (present) error.MissingRetainedArtifact else error.RetainedDroppedArtifact; } // --------------------------------------------------------------------------- // Synthesizing a real Fossil→Git mirror // --------------------------------------------------------------------------- // // `fossil git export` writes three root files beside every commit's tree: // `manifest` is the Fossil check-in artifact with a sterilizing line appended, // `manifest.uuid` is that artifact's name, `manifest.tags` is its branch/tag // summary. Nothing short of building those bytes for real exercises the // classifier through the public CLI: the pair is validated cryptographically, // so a fixture that only looks like a mirror is retained and proves nothing. // // The names and the sterilizing line are BORROWED from `update` rather than // retyped, for the same reason `git_import_impl` borrows them: the set this // fixture writes must be exactly the set `zit update` writes and the // classifier accepts. const mirror_candidate_names = zit.update.manifest_paths; const mirror_sterilizer = zit.update.sterilizer; /// Well-formed per `mirrorTagsGrammar`: one `branch` line then one `tag` line. const mirror_tags_content = "branch trunk\ntag trunk\n"; /// One entry of the tree a synthesized mirror check-in describes. const MirrorFile = struct { path: []const u8, content: []const u8, mode: artifact.FileMode = .regular, }; /// A Git instant in both spellings this fixture needs: the value Git parses /// out of `GIT_COMMITTER_DATE`, and the D card gate 7 compares it against. const MirrorInstant = struct { git: []const u8, card: []const u8, }; /// The two blobs a mirror writes for one commit, plus the check-in name that /// ties them together and that this commit's children must cite as a parent. const MirrorCheckin = struct { uuid: []u8, manifest_blob: []u8, uuid_blob: []u8, fn deinit(self: *MirrorCheckin, gpa: std.mem.Allocator) void { gpa.free(self.uuid); gpa.free(self.manifest_blob); gpa.free(self.uuid_blob); self.* = undefined; } }; fn mirrorPerm(mode: artifact.FileMode) ?[]const u8 { return switch (mode) { .regular => null, .executable => "x", .symlink => "l", }; } fn mirrorFileLess(_: void, a: MirrorFile, b: MirrorFile) bool { return artifact.compareBytes(a.path, b.path) == .lt; } /// The `manifest`/`manifest.uuid` bytes for one commit. /// /// `files` is the commit's tree WITHOUT the three candidate paths, which is /// exactly the comparand gate 5 uses; `parents` are the already-validated /// mirror UUIDs of the commit's ordered Git parents, which is what gate 6 /// requires; `instant` is its committer second, which is what gate 7 compares. /// Every one of those is a fact the caller already fixed, so nothing here /// reads the importer's output back. fn buildMirrorCheckin( gpa: std.mem.Allocator, files: []const MirrorFile, parents: []const []const u8, instant: []const u8, comment: []const u8, ) !MirrorCheckin { const sorted = try gpa.dupe(MirrorFile, files); defer gpa.free(sorted); std.mem.sort(MirrorFile, sorted, {}, mirrorFileLess); var manifest: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = comment }, .d = .{ .timestamp = instant }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = null, .u = .{ .login = "mirror" }, .z = null, .w = null, .f = &.{}, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .s = &.{}, .t = &.{}, }; defer manifest.deinit(); const arena = manifest.arena_state.allocator(); const cards = try arena.alloc(artifact.FCard, sorted.len); for (sorted, cards) |file, *card| { var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha1, file.content, &name_buf); card.* = .{ .filename = file.path, .hash = try arena.dupe(u8, name), .perm = mirrorPerm(file.mode), .old_name = null, .mode = file.mode, }; } manifest.f = cards; if (parents.len != 0) { const joined = try std.mem.join(arena, " ", parents); const p = try arena.alloc(artifact.PCard, 1); p[0] = .{ .parents = joined, .count = parents.len }; manifest.p = p; } const bytes = try artifact.create(&manifest, gpa); defer gpa.free(bytes); var uuid_buf: [64]u8 = undefined; const uuid = artifact.artifactName(.sha1, bytes, &uuid_buf); const owned_uuid = try gpa.dupe(u8, uuid); errdefer gpa.free(owned_uuid); const manifest_blob = try std.mem.concat(gpa, u8, &.{ bytes, mirror_sterilizer }); errdefer gpa.free(manifest_blob); const uuid_blob = try std.fmt.allocPrint(gpa, "{s}\n", .{uuid}); return .{ .uuid = owned_uuid, .manifest_blob = manifest_blob, .uuid_blob = uuid_blob }; } /// Writes the three candidate root files into the source worktree. fn writeMirrorFiles(io: std.Io, source: std.Io.Dir, checkin: MirrorCheckin) !void { try writeFile(io, source, mirror_candidate_names[0], checkin.manifest_blob); try writeFile(io, source, mirror_candidate_names[1], checkin.uuid_blob); try writeFile(io, source, mirror_candidate_names[2], mirror_tags_content); } test "git import e2e: real Git histories preserve flattened trees, topology, identities, refs, and materialization" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" }); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); try source.createDir(io, "bin", .default_dir); try source.createDir(io, "odd", .default_dir); try writeFile(io, source, "README", "linear root\n"); try writeFile(io, source, "bin/run", "#!/bin/sh\necho zit\n"); try writeFile(io, source, "odd/name with space\tand-tab", "unusual path\n"); try source.symLink(io, "README", "README.link", .{}); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--chmod=+x", "bin/run" }); try gitCommit(gpa, io, tmp.dir, "linear root", "2001-02-03T04:05:06+0000", false); try source.rename("README", source, "RENAMED", io); try writeFile(io, source, "COPY", "linear root\n"); try source.deleteFile(io, "README.link"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "rename copy delete", "2001-02-03T04:06:06+0000", false); try gitCommit(gpa, io, tmp.dir, "empty commit", "2001-02-03T04:07:06+0000", true); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "feature" }); try writeFile(io, source, "feature.txt", "feature\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "feature", "2001-02-03T04:08:06+0000", false); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" }); try writeFile(io, source, "main.txt", "main\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "main side", "2001-02-03T04:09:06+0000", false); try gitMerge(gpa, io, tmp.dir, &.{"feature"}, "merge feature", "2001-02-03T04:09:30+0000"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-a" }); try writeFile(io, source, "octopus-a", "a\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "octopus a", "2001-02-03T04:10:06+0000", false); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-b" }); try writeFile(io, source, "octopus-b", "b\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "octopus b", "2001-02-03T04:11:06+0000", false); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" }); try gitMerge(gpa, io, tmp.dir, &.{ "octopus-a", "octopus-b" }, "octopus", "2001-02-03T04:11:30+0000"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "-a", "v1", "-m", "annotated tag" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "lightweight" }); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "imported", "--submanifest-k", "2", "--provenance" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); try expectNoImportTemps(io, tmp.dir); try std.testing.expectEqualStrings("imported\n", imported.stdout); try std.testing.expect(std.mem.indexOf(u8, imported.stderr, "import-git") != null); // No commit here carries a Fossil mirror pair, so the drop set is empty -- // and the import still publishes a packed generation. Asserting the zero // is what keeps "nothing was dropped" distinguishable from "the classifier // never ran": every ordinary Git repository must land on this line. if (std.mem.indexOf(u8, imported.stderr, "(dropped 0 mirror artifacts)") == null) { std.debug.print("expected an empty drop set, stderr:\n{s}\n", .{imported.stderr}); return error.UnexpectedMirrorDropCount; } var destination = try tmp.dir.openDir(io, "imported", .{}); defer destination.close(io); try expectPublishedGeneration(io, destination); const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(map_bytes); var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only }); defer store_file.close(io); const stat = try store_file.stat(io); var fs = storage.FileStorage.init(io, store_file, stat.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" }); defer gpa.free(commits); var commit_lines = std.mem.splitScalar(u8, commits, '\n'); while (commit_lines.next()) |oid| if (oid.len != 0) try assertCommitDifferential(gpa, io, tmp.dir, &log, map_bytes, oid, .committer, no_omissions); // Import leaves an unborn checkout: `.zit-checkout` makes root discovery // work, but only the first real update may claim the default-tip version. var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const unborn = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings("", unborn.version); // The import relabels the main line `trunk`; the source ref name survives // separately in the ref mapping, which is what `sym-heads/main` below uses. try std.testing.expectEqualStrings("trunk", unborn.branch); var first_update = try runZit(gpa, io, destination, &.{ "update", "sym-heads/main" }); defer freeResult(gpa, &first_update); try expectExit(first_update, 0); const main_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "main" }); defer gpa.free(main_oid); const materialized_default = try readCheckoutState(io, destination, &ckbuf); try std.testing.expect(materialized_default.version.len != 0); try compareMaterializedTree(gpa, io, tmp.dir, destination, std.mem.trim(u8, main_oid, " \t\r\n"), no_omissions); // The `seen_submanifest` latch is one-way and means "an S reference is // stored here" (logstore.zig), NOT "trees are enabled". The import used to // set `enable_submanifests` and leave the latch false, which stayed hidden // only because the §7.2 gate is the disjunction of the two: a later // `zit submanifest disable` would have left this repository advertising // avoid-submanifests while holding S-bearing artifacts. This history emits // trees, so the latch must be set. { var cfg_file = try destination.openFile(io, ".zit", .{}); defer cfg_file.close(io); const cfg_st = try cfg_file.stat(io); var cfg_fs = storage.FileStorage.init(io, cfg_file, cfg_st.size); var cfg_log = try logstore.LogStore.openReadOnly(gpa, cfg_fs.storageFile()); defer cfg_log.deinit(gpa); var oc = (try cfg_log.readConfig(gpa)).?; defer oc.deinit(gpa); try std.testing.expect(oc.config.enable_submanifests); try std.testing.expect(oc.config.seen_submanifest); } // Every persisted canonical name resolves through the public CLI to exactly // the commit reached by Git's corresponding source ref or tag. try assertRefResolution(gpa, io, tmp.dir, destination, map_bytes, main_fixture_refs); var reopened = try runZit(gpa, io, destination, &.{"open"}); defer freeResult(gpa, &reopened); try expectExit(reopened, 1); var materialized_lines = std.mem.splitScalar(u8, commits, '\n'); while (materialized_lines.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var update = try runZit(gpa, io, destination, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings(mapped, current.version); try compareMaterializedTree(gpa, io, tmp.dir, destination, oid, no_omissions); } var timeline = try runZit(gpa, io, destination, &.{"timeline"}); defer freeResult(gpa, &timeline); try expectExit(timeline, 0); try std.testing.expect(std.mem.indexOf(u8, timeline.stdout, "octopus") != null); // An ordinary incremental commit on top of an imported history. // The parent here is named under git's sha1 (40 hex), because `import-git` // opens its destination under that policy, while `zit init` writes sha3-256 // (64 hex). `hexToDigest` used to require exactly the sha3 width, so this // commit failed with "the parent check-in is corrupt" on a store `zit verify` // called clean -- every commit on every imported repository, flat or tree. // // Deliberately the LAST step: it runs against the tree the update loop above // left materialized, which is the real operator sequence (import, open, // update, edit, commit) rather than a constructed one. { var head_update = try runZit(gpa, io, destination, &.{ "update", "sym-heads/main" }); defer freeResult(gpa, &head_update); try expectExit(head_update, 0); // A freshly materialized tree check-in is CLEAN. An // S-bearing manifest carries its file set in sub-manifests, so reading // its bare F run made every tracked path look absent from the current // check-in and `status`/`diff` called all of them `added` -- identical // output whether or not anything had changed. Asserted before the edit // below, because after it the interesting signal would be drowned. { var st = try runZit(gpa, io, destination, &.{"status"}); defer freeResult(gpa, &st); try expectExit(st, 0); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "nothing to report") != null); try std.testing.expect(std.mem.indexOf(u8, st.stdout, "added ") == null); var df = try runZit(gpa, io, destination, &.{"diff"}); defer freeResult(gpa, &df); try expectExit(df, 0); try std.testing.expectEqualStrings("", df.stdout); } const parent = try readCheckoutState(io, destination, &ckbuf); // The premise this test exists for: the parent is named under the // import's own algorithm, so the commit below really is stacking on an // imported check-in rather than on something this test made. The // original form pinned 40 hex, which stopped being reachable through // import once it minted SHA3-256; the guard is kept in the form that // still means something. var premise_buf: [64]u8 = undefined; const import_name_width = artifact.artifactName(git_import.import_hash_algo, "", &premise_buf).len; try std.testing.expectEqual(import_name_width, parent.version.len); // Copied out: `readCheckoutState` decodes into the caller's buffer, so // the `child` read below overwrites what `parent` points at. While the // two names differed in LENGTH this aliasing was invisible -- the // inequality assertion passed on the width alone -- and it would have // started comparing a slice against itself the moment both sides were // named the same way. var parent_version_buf: [64]u8 = undefined; const parent_version = blk: { @memcpy(parent_version_buf[0..parent.version.len], parent.version); break :blk parent_version_buf[0..parent.version.len]; }; var ls = try runZit(gpa, io, destination, &.{"ls"}); defer freeResult(gpa, &ls); try expectExit(ls, 0); // `zit ls` prints one path per line; take the first. var first_line = std.mem.splitScalar(u8, ls.stdout, '\n'); const tracked = std.mem.trim(u8, first_line.next() orelse "", " \t\r"); try std.testing.expect(tracked.len != 0); var f = try destination.openFile(io, tracked, .{ .mode = .read_write }); defer f.close(io); const st = try f.stat(io); _ = try f.writePositionalAll(io, "\n// first edit\n", st.size); var commit = try runZit(gpa, io, destination, &.{ "commit", "-m", "incremental commit over imported history", "--user", "e2e", tracked }); defer freeResult(gpa, &commit); try expectExit(commit, 0); // The child is named under this build's own policy. NOTE: the import // now mints under the same algorithm, so this fixture no longer // produces a store holding BOTH widths -- the mixed-width decode this // test used to cover incidentally needs its own fixture. const child = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqual(@as(usize, 64), child.version.len); try std.testing.expect(!std.mem.eql(u8, parent_version, child.version)); // Cleanliness again, on the far side of a real commit: and the fix is // not merely silencing output -- a genuine edit still registers. var st2 = try runZit(gpa, io, destination, &.{"status"}); defer freeResult(gpa, &st2); try expectExit(st2, 0); try std.testing.expect(std.mem.indexOf(u8, st2.stdout, "nothing to report") != null); var f2 = try destination.openFile(io, tracked, .{ .mode = .read_write }); defer f2.close(io); const st2sz = try f2.stat(io); _ = try f2.writePositionalAll(io, "\n// second edit\n", st2sz.size); var st3 = try runZit(gpa, io, destination, &.{"status"}); defer freeResult(gpa, &st3); try expectExit(st3, 0); try std.testing.expect(std.mem.indexOf(u8, st3.stdout, "EDITED ") != null); } } /// The synthesized mirror history, built once and imported under both /// attribution modes. const MirrorHistory = struct { /// M1..M5: the commits whose pair this fixture requires the classifier to /// validate. Their `manifest` and `manifest.uuid` blobs are the whole drop /// set -- ten artifacts, two per commit. validated: [5]MirrorCheckin, /// The look-alike commit's bytes. A genuine top-level file named /// `manifest` beside a syntactically canonical `manifest.uuid` that does /// not hash it. Gate 3 is what must refuse this, and these bytes surviving /// in the published store is the proof it did. lookalike_manifest: []const u8, lookalike_uuid_blob: []u8, lookalike_oid: []u8, fn deinit(self: *MirrorHistory, gpa: std.mem.Allocator) void { for (&self.validated) |*checkin| checkin.deinit(gpa); gpa.free(self.lookalike_uuid_blob); gpa.free(self.lookalike_oid); self.* = undefined; } }; const mirror_lookalike_manifest = "This repository ships a top-level file named `manifest`.\n" ++ "It is project documentation, not Fossil mirror metadata.\n"; /// Builds `source` as a repository a `fossil git export` mirror would have /// produced, then breaks the mirror on its last commit. /// /// Six commits: a root, a linear child, two divergent children, a two-parent /// merge of those, and finally one that replaces all three candidate paths /// with ordinary project content. The first five carry a pair that satisfies /// every gate -- including gate 6, whose ordered proven-parent requirement a /// root-only fixture cannot reach and a merge exercises with two. fn buildMirrorRepository(gpa: std.mem.Allocator, io: std.Io, tmp: std.Io.Dir) !MirrorHistory { const instants = [_]MirrorInstant{ .{ .git = "2010-01-01T00:00:00+0000", .card = "2010-01-01T00:00:00" }, .{ .git = "2010-01-02T00:00:00+0000", .card = "2010-01-02T00:00:00" }, .{ .git = "2010-01-03T00:00:00+0000", .card = "2010-01-03T00:00:00" }, .{ .git = "2010-01-04T00:00:00+0000", .card = "2010-01-04T00:00:00" }, .{ .git = "2010-01-05T00:00:00+0000", .card = "2010-01-05T00:00:00" }, .{ .git = "2010-01-06T00:00:00+0000", .card = "2010-01-06T00:00:00" }, }; const alpha_v1 = "alpha\n"; const alpha_v2 = "alpha\nbeta\n"; const runner = "#!/bin/sh\necho mirror\n"; // Git stores a symlink's target as the blob's whole content, so this is // both the link target and the bytes the F card names. const link_target = "a.txt"; const side_file = "side\n"; const main_file = "main\n"; const exec_run: MirrorFile = .{ .path = "bin/run", .content = runner, .mode = .executable }; const link: MirrorFile = .{ .path = "link.txt", .content = link_target, .mode = .symlink }; try initSourceRepo(gpa, io, tmp); // The candidate blobs are written with default permissions, and `bin/run` // is made executable through the index. With `core.fileMode` left on, the // next `git add -A` would notice the worktree bit and silently demote the // entry to 100644 -- which would not fail the import, it would just make // the executable F card this fixture claims to exercise stop existing. try gitOk(gpa, io, tmp, &.{ "-C", "source", "config", "core.fileMode", "false" }); var source = try tmp.openDir(io, "source", .{}); defer source.close(io); try source.createDir(io, "bin", .default_dir); var validated: [5]MirrorCheckin = undefined; var built: usize = 0; errdefer for (validated[0..built]) |*checkin| checkin.deinit(gpa); // M1 -- root. No P cards: gate 6 requires a root commit to carry none. try writeFile(io, source, "a.txt", alpha_v1); try writeFile(io, source, exec_run.path, runner); try source.symLink(io, link_target, link.path, .{}); validated[0] = try buildMirrorCheckin( gpa, &.{ .{ .path = "a.txt", .content = alpha_v1 }, exec_run, link }, &.{}, instants[0].card, "mirror root", ); built = 1; try writeMirrorFiles(io, source, validated[0]); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitOk(gpa, io, tmp, &.{ "-C", "source", "update-index", "--chmod=+x", exec_run.path }); try gitCommit(gpa, io, tmp, "mirror root", instants[0].git, false); // M2 -- linear child. Its P card cites M1's proven UUID. try writeFile(io, source, "a.txt", alpha_v2); validated[1] = try buildMirrorCheckin( gpa, &.{ .{ .path = "a.txt", .content = alpha_v2 }, exec_run, link }, &.{validated[0].uuid}, instants[1].card, "mirror second", ); built = 2; try writeMirrorFiles(io, source, validated[1]); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp, "mirror second", instants[1].git, false); try gitOk(gpa, io, tmp, &.{ "-C", "source", "tag", "mirror-light" }); // M3 -- side branch off M2. try gitOk(gpa, io, tmp, &.{ "-C", "source", "checkout", "-b", "side" }); try writeFile(io, source, "b.txt", side_file); validated[2] = try buildMirrorCheckin( gpa, &.{ .{ .path = "a.txt", .content = alpha_v2 }, .{ .path = "b.txt", .content = side_file }, exec_run, link }, &.{validated[1].uuid}, instants[2].card, "mirror side", ); built = 3; try writeMirrorFiles(io, source, validated[2]); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp, "mirror side", instants[2].git, false); // M4 -- main's own child of M2. try gitOk(gpa, io, tmp, &.{ "-C", "source", "checkout", "main" }); try writeFile(io, source, "c.txt", main_file); validated[3] = try buildMirrorCheckin( gpa, &.{ .{ .path = "a.txt", .content = alpha_v2 }, .{ .path = "c.txt", .content = main_file }, exec_run, link }, &.{validated[1].uuid}, instants[3].card, "mirror main", ); built = 4; try writeMirrorFiles(io, source, validated[3]); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp, "mirror main", instants[3].git, false); // M5 -- the two-parent merge. Its P card is "<M4> <M3>": Git's first // parent is the branch merged INTO, and gate 6 compares the P run against // the ordered Git parents position by position. // // Merged with `--no-commit` because both sides rewrote all three candidate // paths, so Git cannot resolve them and must not be asked to: the merge // commit's mirror bytes are a function of ITS tree and ITS parents, which // only this fixture knows. The merge's exit status is deliberately not // asserted -- a content conflict here is the expected outcome, not a // failure -- and `MERGE_HEAD` is what proves the merge is actually in // progress rather than a no-op that would commit one parent. { var merge = try runGit(gpa, io, tmp, &.{ "-C", "source", "merge", "--no-ff", "--no-commit", "side" }); freeResult(gpa, &merge); var merge_head = source.openFile(io, ".git/MERGE_HEAD", .{ .mode = .read_only }) catch return error.MergeNotInProgress; merge_head.close(io); } try writeFile(io, source, "a.txt", alpha_v2); try writeFile(io, source, "b.txt", side_file); try writeFile(io, source, "c.txt", main_file); validated[4] = try buildMirrorCheckin( gpa, &.{ .{ .path = "a.txt", .content = alpha_v2 }, .{ .path = "b.txt", .content = side_file }, .{ .path = "c.txt", .content = main_file }, exec_run, link, }, &.{ validated[3].uuid, validated[2].uuid }, instants[4].card, "mirror merge", ); built = 5; try writeMirrorFiles(io, source, validated[4]); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp, "mirror merge", instants[4].git, false); try gitOk(gpa, io, tmp, &.{ "-C", "source", "tag", "-a", "mirror-v1", "-m", "annotated mirror tag" }); // M6 -- the look-alike. Every structural signal a mirror gives is present: // three regular root files with the right names, a canonical lowercase // artifact name in `manifest.uuid`, and a well-formed `manifest.tags`. // Only the hash does not tie, and that alone must retain all three. // // Deliberately an UNRELATED root rather than a child of M5. A retained // commit whose parent validated re-materializes that parent's candidates // before applying its own commands, and `recordRetainedReferences` counts // every upsert in that list -- including ones the commit's own commands // immediately overwrite. That conservatism is correct (it can only keep an // artifact out of the drop set), but as a direct child it would take M5's // pair out of the drop set for a reason that has nothing to do with the // look-alike, and this fixture is about the classifier's decision, not // about the reference counter's safety margin. try gitOk(gpa, io, tmp, &.{ "-C", "source", "checkout", "--orphan", "lookalike" }); try gitOk(gpa, io, tmp, &.{ "-C", "source", "rm", "-r", "-f", "--quiet", "." }); var decoy_buf: [64]u8 = undefined; const decoy = artifact.artifactName(.sha1, "not the manifest\n", &decoy_buf); const lookalike_uuid_blob = try std.fmt.allocPrint(gpa, "{s}\n", .{decoy}); errdefer gpa.free(lookalike_uuid_blob); try writeFile(io, source, "a.txt", "documentation\n"); try writeFile(io, source, mirror_candidate_names[0], mirror_lookalike_manifest); try writeFile(io, source, mirror_candidate_names[1], lookalike_uuid_blob); // Byte-identical to the validated commits' tags file on purpose: that one // artifact is a candidate of five omitted commits AND a real file of a // retained one, so its retained-reference count is nonzero and it must // survive the pack. It is the only candidate blob here that does. try writeFile(io, source, mirror_candidate_names[2], mirror_tags_content); try gitOk(gpa, io, tmp, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp, "manifest is ours", instants[5].git, false); const head = try gitOutput(gpa, io, tmp, &.{ "-C", "source", "rev-parse", "lookalike" }); defer gpa.free(head); const lookalike_oid = try gpa.dupe(u8, std.mem.trim(u8, head, " \t\r\n")); errdefer gpa.free(lookalike_oid); // Leave HEAD where an operator would: on the mirror's own default branch. try gitOk(gpa, io, tmp, &.{ "-C", "source", "checkout", "main" }); return .{ .validated = validated, .lookalike_manifest = mirror_lookalike_manifest, .lookalike_uuid_blob = lookalike_uuid_blob, .lookalike_oid = lookalike_oid, }; } /// Imports the already-built `source` once and checks everything the published /// generation must say about it under `attribution`. fn assertMirrorImport( gpa: std.mem.Allocator, io: std.Io, tmp: std.Io.Dir, history: *const MirrorHistory, destination_name: []const u8, attribution: Attribution, ) !void { var imported = switch (attribution) { .committer => try runZit(gpa, io, tmp, &.{ "import-git", "source", destination_name, "--submanifest-k", "2", "--provenance" }), .author => try runZit(gpa, io, tmp, &.{ "import-git", "source", destination_name, "--submanifest-k", "2", "--attribution", "author", "--provenance" }), }; defer freeResult(gpa, &imported); try expectExit(imported, 0); try expectNoImportTemps(io, tmp); var destination = try tmp.openDir(io, destination_name, .{}); defer destination.close(io); try expectPublishedGeneration(io, destination); const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(map_bytes); var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only }); defer store_file.close(io); const stat = try store_file.stat(io); var fs = storage.FileStorage.init(io, store_file, stat.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); const commits = try gitOutput(gpa, io, tmp, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" }); defer gpa.free(commits); // What the classifier says it did, per commit, before anything about the // store is looked at. Five omissions and one named refusal: `uuid_mismatch` // is gate 3, the cryptographic tie, and it is the gate a genuine project // file called `manifest` must fail on. // This fixture imports in SUB-MANIFEST mode (`--submanifest-k 2`), which // makes no reproduction claim, so the drop turns on the pair proof alone // and `--attribution` does not reach it: both attributions omit. The // forfeit under `--attribution author` belongs to FLAT mode, where the // origin's instant is adopted and an explicit request for the author's // instants therefore conflicts with it. const adopts_origin_instant = true; { var decisions = std.mem.splitScalar(u8, commits, '\n'); while (decisions.next()) |oid| { if (oid.len == 0) continue; if (std.mem.eql(u8, oid, history.lookalike_oid)) { try assertMirrorDecision(gpa, map_bytes, oid, "retained", "uuid_mismatch"); } else if (adopts_origin_instant) { try assertMirrorDecision(gpa, map_bytes, oid, "omitted", null); } else { try assertMirrorDecision(gpa, map_bytes, oid, "retained", "timestamp_mismatch"); } } } // The drop set, artifact by artifact. for (&history.validated, 0..) |checkin, index| { // One buffer per label. Sharing one is correct only while the first // label happens to be consumed before the second `bufPrint` overwrites // it, which is an aliasing hazard a reordering would arm silently. var manifest_label_buf: [32]u8 = undefined; var uuid_label_buf: [32]u8 = undefined; const label = try std.fmt.bufPrint(&manifest_label_buf, "M{d} manifest", .{index + 1}); try expectStoreHolds(&log, label, checkin.manifest_blob, !adopts_origin_instant); const uuid_label = try std.fmt.bufPrint(&uuid_label_buf, "M{d} uuid", .{index + 1}); try expectStoreHolds(&log, uuid_label, checkin.uuid_blob, !adopts_origin_instant); } // Fail-closed, in the direction that matters: the look-alike's bytes are a // project file and they are still here. try expectStoreHolds(&log, "look-alike manifest", history.lookalike_manifest, true); try expectStoreHolds(&log, "look-alike manifest.uuid", history.lookalike_uuid_blob, true); try expectStoreHolds(&log, "shared manifest.tags", mirror_tags_content, true); // Five validated commits, two droppable artifacts each. The aggregate is // asserted after the artifact-by-artifact checks above, because a count // that disagrees is far easier to act on once the individual names have // already said which commit moved. Stated as the exact number rather than // "more than zero": an off-by-one here is the difference between omitting // a mirror's metadata and omitting somebody's file. const want_drop_line = if (adopts_origin_instant) "(dropped 10 mirror artifacts)" else "(dropped 0 mirror artifacts)"; if (std.mem.indexOf(u8, imported.stderr, want_drop_line) == null) { std.debug.print("expected '{s}', stderr:\n{s}\n", .{ want_drop_line, imported.stderr }); return error.UnexpectedMirrorDropCount; } var commit_lines = std.mem.splitScalar(u8, commits, '\n'); while (commit_lines.next()) |oid| { if (oid.len == 0) continue; const omitted: []const []const u8 = if (!adopts_origin_instant or std.mem.eql(u8, oid, history.lookalike_oid)) no_omissions else &mirror_candidate_names; try assertCommitDifferential(gpa, io, tmp, &log, map_bytes, oid, attribution, omitted); } try assertRefResolution(gpa, io, tmp, destination, map_bytes, mirror_fixture_refs); // Materialization is a function of the TREE, and `--attribution` cannot move // a tree: it selects which Git identity fills the U and D cards and touches // nothing else. Walking every commit's worktree a second time under // `.author` would re-prove a property the first pass already established, // for roughly a third of this test's wall time. The identity axis the flag // does move is compared per commit above, in both passes. if (attribution == .committer) { var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; var materialized = std.mem.splitScalar(u8, commits, '\n'); while (materialized.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var update = try runZit(gpa, io, destination, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings(mapped, current.version); const omitted: []const []const u8 = if (!adopts_origin_instant or std.mem.eql(u8, oid, history.lookalike_oid)) no_omissions else &mirror_candidate_names; try compareMaterializedTree(gpa, io, tmp, destination, oid, omitted); } } var verified = try runZit(gpa, io, destination, &.{"verify"}); defer freeResult(gpa, &verified); try expectExit(verified, 0); } test "git import e2e: a validated Fossil mirror pair is dropped and a look-alike is retained" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var history = try buildMirrorRepository(gpa, io, tmp.dir); defer history.deinit(gpa); // Both attribution modes over one history. The mirror decision is // deliberately NOT one of the things `--attribution` may move: gate 7 // compares the mirror's D card against the GIT COMMITTER instant whatever // identity Zit's own check-in ends up carrying. Two imports that disagree // about the check-in's U and D card and agree exactly about the drop set // is what states that. try assertMirrorImport(gpa, io, tmp.dir, &history, "imported-committer", .committer); try assertMirrorImport(gpa, io, tmp.dir, &history, "imported-author", .author); } // --------------------------------------------------------------------------- // Serving the published generation and cloning it // --------------------------------------------------------------------------- // // Everything above proves the published generation is a correct FILE. Nothing // above proves it is a REPOSITORY: a store can satisfy every reconstruction // invariant and still be unservable, and the only statement that rules that out // is a real server, a real socket, and a real client that converges. // // `freshLoopbackPort`, `spawnServe`, `waitForListen` and the digest-set helpers // are PORTED from `src/e2e.zig` rather than shared. `src/e2e.zig` already // imports this file, so importing it back would close a cycle, and this suite // is separately its own build root (`zig build git-import-e2e`). `src/e2e.zig` // duplicates the same three helpers out of `src/smoke.zig` for exactly that // reason, and the comment there says so. const net = std.Io.net; /// Binds an ephemeral loopback port, reads the number the kernel chose, and /// releases it. /// /// This NARROWS the collision window against a concurrent run of this suite; it /// does not close it. The port is released before `spawnServe` re-binds it, so /// a stranger can win the race in between. The consequence is bounded and /// legible rather than silent: `zit clone` then talks to something that is not /// a Zit server and fails, and `expectExit` prints its stderr. Closing the race /// properly needs `serve` to bind :0 and report the port it got, which is a /// production change this suite does not need. fn freshLoopbackPort(io: std.Io) !u16 { const addr = try net.IpAddress.parseIp4("127.0.0.1", 0); var server = try addr.listen(io, .{}); const port = server.socket.address.getPort(); server.deinit(io); if (port == 0) return error.NoLoopbackPort; return port; } /// Spawns `zit serve --port <port>` with `dir` as its cwd, so the server's own /// root discovery finds `dir/.zit` exactly as an operator's would. Caller kills /// the returned child. fn spawnServe(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, port: u16) !std.process.Child { var env = std.process.Environ.Map.init(gpa); defer env.deinit(); try env.put("TZ", "UTC"); try env.put("HOME", "/zit-git-import-e2e-no-home"); try env.put("PATH", "/usr/bin:/bin"); const base = try std.process.currentPathAlloc(io, gpa); defer gpa.free(base); const executable = try std.fs.path.resolve(gpa, &.{ base, zit_exe }); defer gpa.free(executable); const port_text = try std.fmt.allocPrint(gpa, "{d}", .{port}); defer gpa.free(port_text); return std.process.spawn(io, .{ .argv = &.{ executable, "serve", "--port", port_text }, .cwd = .{ .dir = dir }, .environ_map = &env, .stdout = .ignore, .stderr = .ignore, }); } /// Retry-connects until the server accepts or a wall-clock deadline elapses. /// /// The READINESS test is a successful TCP connect, never a fixed delay: a delay /// long enough to be reliable is long enough to dominate the lane, and one short /// enough not to is flaky. The pause BETWEEN refusals is a different thing and it /// is deliberate -- a refused connect returns immediately, so retrying with no /// pause burns a core against the very process that is trying to bind. First /// connect is still attempted with no delay, so the common case pays nothing. fn waitForListen(io: std.Io, port: u16) !void { const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress; const deadline_ns: i96 = 15_000_000_000; const retry_pause_ns = 1_000_000; // A structural bound alongside the clock one. The deadline alone makes // termination depend entirely on `Clock.now(.awake, ...)` advancing, and the // pause below is best-effort, so neither is trusted to end this loop alone. // At one attempt per millisecond the cap is three orders of magnitude above // fifteen seconds' worth and only ever fires if both of the others fail. const max_attempts = 10_000_000; const start = std.Io.Clock.now(.awake, io).nanoseconds; for (0..max_attempts) |_| { if (addr.connect(io, .{ .mode = .stream })) |stream| { stream.close(io); return; } else |_| {} if (std.Io.Clock.now(.awake, io).nanoseconds - start > deadline_ns) return error.ServerNeverBound; io.sleep(.{ .nanoseconds = retry_pause_ns }, .awake) catch {}; } return error.ServerNeverBound; } /// Every content-bearing artifact digest in `dir/.zit`, sorted, read through /// the library. /// /// `.config` records are repository metadata, not content, and do not cross the /// wire, so they are excluded from the comparison. This is a statement about /// what the set MEANS, not a workaround: an imported destination happens to hold /// zero of them today, so keeping them would change no result here -- but the /// origin writes its config through a different path than the clone does, and a /// set that included them would be comparing bookkeeping rather than content. /// The skip is a faithful port of the one in `src/e2e.zig`. fn collectArtifactDigests(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![][]u8 { var file = try dir.openFile(io, ".zit", .{ .mode = .read_only }); defer file.close(io); const st = try file.stat(io); var fs = storage.FileStorage.init(io, file, st.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); var list: std.ArrayList([]u8) = .empty; errdefer { for (list.items) |hash| gpa.free(hash); list.deinit(gpa); } for (rids) |rid| { if (log.kindOf(rid) == .config) continue; try list.append(gpa, try gpa.dupe(u8, log.hashOf(rid))); } const slice = try list.toOwnedSlice(gpa); std.mem.sort([]u8, slice, {}, struct { fn lt(_: void, a: []u8, b: []u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lt); return slice; } fn freeDigests(gpa: std.mem.Allocator, digests: [][]u8) void { for (digests) |hash| gpa.free(hash); gpa.free(digests); } /// Set equality over two sorted digest lists. fn expectSameDigestSet(expected: []const []const u8, actual: []const []const u8) !void { try std.testing.expectEqual(expected.len, actual.len); for (expected, actual) |a, b| try std.testing.expectEqualSlices(u8, a, b); } test "git import e2e: the published packed generation serves a clone that matches Git commit by commit" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // The mirror history rather than a fresh fixture: it is the only source // here that carries an executable, a symlink, a branch, a two-parent merge, // both tag spellings, an omitted mirror pair AND a retained look-alike. A // clone that reproduces it reproduces the drop decision too, which is the // one thing a packed generation could plausibly lose on the wire. var history = try buildMirrorRepository(gpa, io, tmp.dir); defer history.deinit(gpa); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "origin", "--submanifest-k", "2", "--provenance" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); // Every other import in this file asks this, and a leaked staging sibling // is invisible to everything the clone assertions below look at -- it is a // whole raw generation beside the destination, and `tmp.cleanup()` would // erase the evidence before anyone could see it. try expectNoImportTemps(io, tmp.dir); var origin = try tmp.dir.openDir(io, "origin", .{}); defer origin.close(io); try expectPublishedGeneration(io, origin); // The import map stays the ORIGIN's. It is not an artifact and never // crosses the wire, so every Git↔Zit correspondence the clone is checked // against is carried in by hand -- which is what makes the checks below // statements about the clone rather than about its own bookkeeping. const map_bytes = try origin.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(map_bytes); const origin_digests = try collectArtifactDigests(gpa, io, origin); defer freeDigests(gpa, origin_digests); // Non-vacuity for every digest-set comparison below: set equality holds // trivially between two empty stores, and a clone that arrived with // nothing would satisfy all three of them. Stated as a literal floor // rather than as arithmetic over the check-in count, which is an unrelated // quantity that would drift the moment the fixture gains a commit. const minimum_origin_artifacts = 7; try std.testing.expect(origin_digests.len >= minimum_origin_artifacts); // The server lives exactly as long as the clone needs it. Held open across // the rest of this test it would idle through two packs, two verifies and // every comparison below -- most of the test's wall time spent holding a // child process nothing is talking to. { const port = try freshLoopbackPort(io); var server = try spawnServe(gpa, io, origin, port); defer server.kill(io); try waitForListen(io, port); // The bare root path: sync §4 has the client send the repository URL // unchanged, and `zit serve` dispatches on method + content type. const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port}); defer gpa.free(url); var cloned = try runZit(gpa, io, tmp.dir, &.{ "clone", url, "clone" }); defer freeResult(gpa, &cloned); try expectExit(cloned, 0); // The server SURVIVED serving the clone. `kill` below is idempotent and // succeeds against an already-dead child, so without this a `zit serve` // that crashed on its way out of the transfer -- after the client had // everything it needed -- leaves this test entirely green. Asked by // reconnecting, which is the same question `waitForListen` answers and // the only one available from outside the process. waitForListen(io, port) catch return error.ServerDiedServingClone; } var clone_dir = try tmp.dir.openDir(io, "clone", .{}); defer clone_dir.close(io); // Artifact-set equality first: everything after it is a statement about // content the clone demonstrably holds. { const clone_digests = try collectArtifactDigests(gpa, io, clone_dir); defer freeDigests(gpa, clone_digests); try expectSameDigestSet(origin_digests, clone_digests); } const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" }); defer gpa.free(commits); { var store_file = try clone_dir.openFile(io, ".zit", .{ .mode = .read_only }); defer store_file.close(io); const stat = try store_file.stat(io); var fs = storage.FileStorage.init(io, store_file, stat.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); // The drop decision survived the wire in both directions. Asserted // against the CLONE's store: an omission the clone silently refilled // would make the packed generation's whole point untrue. for (&history.validated, 0..) |checkin, index| { var manifest_label_buf: [40]u8 = undefined; var uuid_label_buf: [40]u8 = undefined; const label = try std.fmt.bufPrint(&manifest_label_buf, "cloned M{d} manifest", .{index + 1}); try expectStoreHolds(&log, label, checkin.manifest_blob, false); const uuid_label = try std.fmt.bufPrint(&uuid_label_buf, "cloned M{d} uuid", .{index + 1}); try expectStoreHolds(&log, uuid_label, checkin.uuid_blob, false); } try expectStoreHolds(&log, "cloned look-alike manifest", history.lookalike_manifest, true); try expectStoreHolds(&log, "cloned look-alike manifest.uuid", history.lookalike_uuid_blob, true); // The one blob that is simultaneously a candidate of five OMITTED // commits and a real file of a RETAINED one. Its retained-reference // count is what keeps it out of the drop set, so it is the single most // interesting artifact on the wire and the one whose absence would be // hardest to notice: every other retained blob has only one role. try expectStoreHolds(&log, "cloned shared manifest.tags", mirror_tags_content, true); // Commit by commit, against Git, out of the clone's own store: comment, // selected identity, D card, ordered parents, flattened tree, and every // file's bytes and mode. var commit_lines = std.mem.splitScalar(u8, commits, '\n'); while (commit_lines.next()) |oid| { if (oid.len == 0) continue; const omitted: []const []const u8 = if (std.mem.eql(u8, oid, history.lookalike_oid)) no_omissions else &mirror_candidate_names; try assertCommitDifferential(gpa, io, tmp.dir, &log, map_bytes, oid, .committer, omitted); } } // Refs and tags are proven through the CLONE's control artifacts, not by // differencing files: `refs` is not an artifact and does not travel, so // `zit update <canonical>` resolving to the mapped commit is the only // statement available -- and it is the one an operator depends on. try assertRefResolution(gpa, io, tmp.dir, clone_dir, map_bytes, mirror_fixture_refs); // The branch view rebuilt from the clone's own control artifacts, against // Git: the same set of names, each name resolving to the same tip, and // every first-parent commit of `main` still claimed by `main`. Asserted // both cached and `--no-cache`, because a branch listing that only agreed // through a cache would agree for a reason the wire did not supply. The // orphan look-alike branch is in this set, so it also proves the clone did // not quietly collapse the history onto one branch. try assertBranchTopology(gpa, io, tmp.dir, clone_dir, map_bytes, "main"); // Tags, as whole listings differenced against the origin check-in by // check-in. `zit tag list` prints every tag visible on a check-in, so an // equal listing everywhere is the statement that the clone rebuilt the tag // view from artifacts that crossed the wire. The two spellings are looked // for by name: a clone holding no tags at all would otherwise satisfy an // all-empty differential. { var saw_annotated = false; var saw_lightweight = false; var tagged = std.mem.splitScalar(u8, commits, '\n'); while (tagged.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var origin_tags = try runZit(gpa, io, origin, &.{ "tag", "list", mapped }); defer freeResult(gpa, &origin_tags); try expectExit(origin_tags, 0); var clone_tags = try runZit(gpa, io, clone_dir, &.{ "tag", "list", mapped }); defer freeResult(gpa, &clone_tags); try expectExit(clone_tags, 0); try std.testing.expectEqualStrings(origin_tags.stdout, clone_tags.stdout); if (std.mem.indexOf(u8, clone_tags.stdout, "mirror-v1") != null) saw_annotated = true; if (std.mem.indexOf(u8, clone_tags.stdout, "mirror-light") != null) saw_lightweight = true; } try std.testing.expect(saw_annotated); try std.testing.expect(saw_lightweight); } var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; var materialized = std.mem.splitScalar(u8, commits, '\n'); while (materialized.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var update = try runZit(gpa, io, clone_dir, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, clone_dir, &ckbuf); try std.testing.expectEqualStrings(mapped, current.version); const omitted: []const []const u8 = if (std.mem.eql(u8, oid, history.lookalike_oid)) no_omissions else &mirror_candidate_names; try compareMaterializedTree(gpa, io, tmp.dir, clone_dir, oid, omitted); } // The repository view derived from scratch on both sides agrees exactly. // Compared as whole bytes: a timeline that lost a check-in, reordered two, // or renamed a branch fails here without needing a per-field comparator. // (The stored `default-branch` deliberately does NOT agree -- `clone` does // not carry it, so the clone keeps `init`'s `trunk` while the import wrote // `main`. Comparing configs would fail for that reason and prove nothing.) { // -F oneline: the default shape marks the checkout's check-in // *CURRENT*, which only the origin has. var origin_timeline = try runZit(gpa, io, origin, &.{ "timeline", "-F", "oneline" }); defer freeResult(gpa, &origin_timeline); try expectExit(origin_timeline, 0); var clone_timeline = try runZit(gpa, io, clone_dir, &.{ "timeline", "-F", "oneline" }); defer freeResult(gpa, &clone_timeline); try expectExit(clone_timeline, 0); try std.testing.expectEqualStrings(origin_timeline.stdout, clone_timeline.stdout); } { var verified = try runZit(gpa, io, clone_dir, &.{"verify"}); defer freeResult(gpa, &verified); try expectExit(verified, 0); } // Pack fixpoint on the clone. // // The plan asked for byte identity between the clone and its own first // pack. That is FALSE and it is false for a good reason: sync transfers // artifact content, not the sender's delta chains, so a fresh clone is a // faithful copy of the ORIGIN'S CONTENT and not of its packing. The first // pack re-deltas and shrinks it, which is correct behavior, not a defect. // // The property that is actually true, and the one that matters, is that the // clone reaches a fixpoint: pack it and pack it again, and the second run // changes nothing -- byte for byte -- while the artifact set it holds is // still the origin's. That is the same statement Task 5 makes about the // imported generation, made about a store that arrived over a socket. const cloned_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24)); defer gpa.free(cloned_bytes); { var first = try runZit(gpa, io, clone_dir, &.{"pack"}); defer freeResult(gpa, &first); try expectExit(first, 0); } const packed_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24)); defer gpa.free(packed_bytes); // The refutation, checked rather than asserted in prose: the clone's own // first pack rewrites it and shrinks it. If this ever stops being true the // fixpoint below turns vacuous, so the two assertions are a pair. try std.testing.expect(!std.mem.eql(u8, cloned_bytes, packed_bytes)); try std.testing.expect(packed_bytes.len < cloned_bytes.len); { var second = try runZit(gpa, io, clone_dir, &.{"pack"}); defer freeResult(gpa, &second); try expectExit(second, 0); } const repacked_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24)); defer gpa.free(repacked_bytes); try std.testing.expectEqualSlices(u8, packed_bytes, repacked_bytes); // Packing kept every artifact the origin published, and the packed store // still verifies. { const repacked_digests = try collectArtifactDigests(gpa, io, clone_dir); defer freeDigests(gpa, repacked_digests); try expectSameDigestSet(origin_digests, repacked_digests); } { var verified = try runZit(gpa, io, clone_dir, &.{"verify"}); defer freeResult(gpa, &verified); try expectExit(verified, 0); } } test "git import e2e: failures are fail-closed and diagnostics name the rejected boundary" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Missing required destination is rejected by the CLI parser, before any // filesystem publication can occur. var absent_dest = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source-only", "--provenance" }); defer freeResult(gpa, &absent_dest); try expectExit(absent_dest, 2); try expectNoImportTemps(io, tmp.dir); try std.testing.expectEqualStrings("zit import-git: invalid arguments (MissingDestination); see 'zit import-git --help'\n", absent_dest.stderr); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); try writeFile(io, source, "tracked", "tracked\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false); // A shallow clone cannot establish complete parent topology; it must fail // before publishing a destination, not truncate history silently. try gitOk(gpa, io, tmp.dir, &.{ "clone", "--depth", "1", "--no-local", "source", "shallow" }); var shallow = try runZit(gpa, io, tmp.dir, &.{ "import-git", "shallow", "shallow-dest", "--provenance" }); defer freeResult(gpa, &shallow); try expectFailure(shallow); try expectNoImportTemps(io, tmp.dir); try std.testing.expectEqualStrings("import-git: cannot start git fast-export: ShallowRepository\n", shallow.stderr); try expectNoDirectory(io, tmp.dir, "shallow-dest"); } test "git import e2e: a submodule entry is omitted and the rest of the tree imports" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); try writeFile(io, source, "tracked", "tracked\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false); // Fossil has no submodule concept, so the importer drops the gitlink and // imports the history around it. Refusing the whole repository withheld // histories that are otherwise entirely representable, which is why the // `beside` file committed alongside the gitlink must still arrive. const head = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "HEAD" }); defer gpa.free(head); const cacheinfo = try std.fmt.allocPrint(gpa, "160000,{s},nested", .{std.mem.trimEnd(u8, head, "\n")}); defer gpa.free(cacheinfo); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--add", "--cacheinfo", cacheinfo }); try writeFile(io, source, "beside", "beside\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "beside" }); try gitCommit(gpa, io, tmp.dir, "gitlink", "2001-02-03T04:06:06+0000", false); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "gitlink-dest", "--provenance" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); try expectNoImportTemps(io, tmp.dir); var destination = try tmp.dir.openDir(io, "gitlink-dest", .{}); defer destination.close(io); const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(map_bytes); var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only }); defer store_file.close(io); const store_stat = try store_file.stat(io); var fs = storage.FileStorage.init(io, store_file, store_stat.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); // compareTree, reached through assertCommitDifferential, fails if the // manifest names `nested`; compareMaterializedTree fails if anything is // written there. Both sides of the omission are asserted, not assumed. const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" }); defer gpa.free(commits); var commit_lines = std.mem.splitScalar(u8, commits, '\n'); while (commit_lines.next()) |oid| if (oid.len != 0) try assertCommitDifferential(gpa, io, tmp.dir, &log, map_bytes, oid, .committer, no_omissions); const branch_out = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "--abbrev-ref", "HEAD" }); defer gpa.free(branch_out); const ref = try std.fmt.allocPrint(gpa, "sym-heads/{s}", .{std.mem.trim(u8, branch_out, " \t\r\n")}); defer gpa.free(ref); var materialize = try runZit(gpa, io, destination, &.{ "update", ref }); defer freeResult(gpa, &materialize); try expectExit(materialize, 0); const head_after = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "HEAD" }); defer gpa.free(head_after); try compareMaterializedTree(gpa, io, tmp.dir, destination, std.mem.trim(u8, head_after, " \t\r\n"), no_omissions); const beside = try destination.readFileAlloc(io, "beside", gpa, .limited(64)); defer gpa.free(beside); try std.testing.expectEqualStrings("beside\n", beside); } test "git import e2e: a missing Git object aborts before publication" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "missing" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "config", "user.email", "[email protected]" }); var source = try tmp.dir.openDir(io, "missing", .{}); defer source.close(io); try writeFile(io, source, "missing.txt", "this blob will disappear\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "add", "missing.txt" }); var env = std.process.Environ.Map.init(gpa); defer env.deinit(); try env.put("TZ", "UTC"); try env.put("GIT_AUTHOR_NAME", "Git Author"); try env.put("GIT_AUTHOR_EMAIL", "[email protected]"); try env.put("GIT_COMMITTER_NAME", "Git Committer"); try env.put("GIT_COMMITTER_EMAIL", "[email protected]"); try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000"); try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000"); var commit = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "missing", "commit", "--no-gpg-sign", "-m", "missing object" }, &env); defer freeResult(gpa, &commit); try expectExit(commit, 0); const object = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "missing", "rev-parse", "HEAD:missing.txt" }); defer gpa.free(object); const oid = std.mem.trimEnd(u8, object, "\n"); const object_path = try std.fmt.allocPrint(gpa, "missing/.git/objects/{s}/{s}", .{ oid[0..2], oid[2..] }); defer gpa.free(object_path); try tmp.dir.deleteFile(io, object_path); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "missing", "missing-dest", "--provenance" }); defer freeResult(gpa, &imported); try expectFailure(imported); try expectNoImportTemps(io, tmp.dir); try std.testing.expectEqualStrings("import-git: cannot start git fast-export: MissingObjects\n", imported.stderr); try expectNoDirectory(io, tmp.dir, "missing-dest"); } test "git import e2e: signed metadata and canonical ref aliases never publish a partial destination" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Manufacture a structurally valid commit object with a gpgsig header using // Git's object writer. This avoids relying on a local secret key while // preserving the exact object shape a signed commit carries in a real repo. try gitOk(gpa, io, tmp.dir, &.{ "init", "signed" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "config", "user.email", "[email protected]" }); var signed_dir = try tmp.dir.openDir(io, "signed", .{}); defer signed_dir.close(io); try writeFile(io, signed_dir, "payload", "signed fixture\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "add", "payload" }); var env = std.process.Environ.Map.init(gpa); defer env.deinit(); try env.put("TZ", "UTC"); try env.put("GIT_AUTHOR_NAME", "Git Author"); try env.put("GIT_AUTHOR_EMAIL", "[email protected]"); try env.put("GIT_COMMITTER_NAME", "Git Committer"); try env.put("GIT_COMMITTER_EMAIL", "[email protected]"); try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000"); try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000"); var root = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "signed", "commit", "--no-gpg-sign", "-m", "root" }, &env); defer freeResult(gpa, &root); try expectExit(root, 0); const tree = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "rev-parse", "HEAD^{tree}" }); defer gpa.free(tree); const parent = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "rev-parse", "HEAD" }); defer gpa.free(parent); const signed_object = try std.fmt.allocPrint( gpa, "tree {s}\nparent {s}\nauthor Git Author <[email protected]> 981173106 +0000\ncommitter Git Committer <[email protected]> 981173106 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n fixture signature\n -----END PGP SIGNATURE-----\n\nsigned metadata\n", .{ std.mem.trimEnd(u8, tree, "\n"), std.mem.trimEnd(u8, parent, "\n") }, ); defer gpa.free(signed_object); try writeFile(io, signed_dir, "signed-commit", signed_object); const signed_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "hash-object", "-t", "commit", "-w", "signed-commit" }); defer gpa.free(signed_oid); const signed_head = std.mem.trimEnd(u8, signed_oid, "\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "update-ref", "refs/heads/signed", signed_head }); // Default policy strips the signature and imports. Fossil has no signature // card, so refusing withheld the whole history for a loss no import can // avoid; the operator opts in to that refusal instead of inheriting it. var stripped = try runZit(gpa, io, tmp.dir, &.{ "import-git", "signed", "signed-stripped", "--provenance" }); defer freeResult(gpa, &stripped); try expectExit(stripped, 0); try expectNoImportTemps(io, tmp.dir); try std.testing.expectEqualStrings("signed-stripped\n", stripped.stdout); var stripped_dest = try tmp.dir.openDir(io, "signed-stripped", .{}); stripped_dest.close(io); // --refuse-signed restores refusal, and it must still be fail-closed. var signed_import = try runZit(gpa, io, tmp.dir, &.{ "import-git", "signed", "signed-dest", "--refuse-signed", "--provenance" }); defer freeResult(gpa, &signed_import); try expectFailure(signed_import); try expectNoImportTemps(io, tmp.dir); try std.testing.expect(std.mem.indexOf(u8, signed_import.stderr, "encountered signed commit") != null); try std.testing.expect(std.mem.indexOf(u8, signed_import.stderr, "import-git: import failed: GitExportFailed") != null); try expectNoDirectory(io, tmp.dir, "signed-dest"); // Git normally rejects this spelling through update-ref. A damaged but // otherwise real repository can still carry it in packed-refs; import must // reject its canonical collision rather than choosing either target. try gitOk(gpa, io, tmp.dir, &.{ "init", "collision" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "config", "user.email", "[email protected]" }); var collision_dir = try tmp.dir.openDir(io, "collision", .{}); defer collision_dir.close(io); try writeFile(io, collision_dir, "payload", "collision fixture\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "add", "payload" }); var collision_commit = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "collision", "commit", "--no-gpg-sign", "-m", "root" }, &env); defer freeResult(gpa, &collision_commit); try expectExit(collision_commit, 0); const collision_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "collision", "rev-parse", "HEAD" }); defer gpa.free(collision_oid); const oid = std.mem.trimEnd(u8, collision_oid, "\n"); const packed_refs = try std.fmt.allocPrint( gpa, "# pack-refs with: peeled fully-peeled sorted\n{s} refs/heads/collision\n{s} refs/heads/collision//\n", .{ oid, oid }, ); defer gpa.free(packed_refs); try writeFile(io, collision_dir, ".git/packed-refs", packed_refs); var collision_import = try runZit(gpa, io, tmp.dir, &.{ "import-git", "collision", "collision-dest", "--provenance" }); defer freeResult(gpa, &collision_import); try expectFailure(collision_import); try expectNoImportTemps(io, tmp.dir); try std.testing.expect(std.mem.indexOf(u8, collision_import.stderr, "packed refname is dangerous: refs/heads/collision//") != null); try std.testing.expect(std.mem.indexOf(u8, collision_import.stderr, "import-git: cannot start git fast-export: MissingObjects") != null); try expectNoDirectory(io, tmp.dir, "collision-dest"); } test "git import e2e: an empty author email imports and names the user card" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); try writeFile(io, source, "f.txt", "hi\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); // `git config user.email ""` is ordinary configuration, and git exports the // resulting identity as `mohye77 <>`. The parser refused that stream, so a // repository carrying one such commit could not be imported at all. try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "commit", "--no-gpg-sign", "-m", "empty email commit", "--author", "mohye77 <>" }); var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "empty-email-dest", "--attribution", "author", "--provenance" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); try expectNoImportTemps(io, tmp.dir); var destination = try tmp.dir.openDir(io, "empty-email-dest", .{}); defer destination.close(io); const branch_line = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "--abbrev-ref", "HEAD" }); defer gpa.free(branch_line); const branch = std.mem.trimEnd(u8, branch_line, "\n"); // HEAD is the source's main line, so the import publishes it as `trunk`. // Reading the source's own name first still matters: it pins that this // fixture has one branch and that it is the one being relabelled. const label = zitLabelFor(branch, branch); var tip = try runZit(gpa, io, destination, &.{ "info", label }); defer freeResult(gpa, &tip); try expectExit(tip, 0); const newline = std.mem.indexOfScalar(u8, tip.stdout, '\n') orelse tip.stdout.len; const hash = tip.stdout[0..newline]; var card = try runZit(gpa, io, destination, &.{ "artifact", hash, "--raw" }); defer freeResult(gpa, &card); try expectExit(card, 0); // The identity's NAME, not its email, becomes the U card login, so an empty // email must still yield a named user rather than an empty one. try std.testing.expect(std.mem.indexOf(u8, card.stdout, "\nU mohye77\n") != null); } /// Every card line of `bytes` except the F, S, Z, and P cards, in order. /// /// F/S cards ARE the divergence: flat lists every file, a tree names its /// sub-manifests. Three cards are excluded with them because they are not /// independent content, they are consequences of it: /// /// - Z is computed by `artifact.create` over the artifact's own bytes, so it /// moves whenever the file-set cards move. /// - P names the parent CHECK-IN by content address, and the parent's address /// moved for the same reason. Topology is still asserted, one level up, by /// mapping each import's P card back through its own provenance map to the /// Git parent OIDs and requiring those to match. /// /// What is left -- C, D, U -- is produced by code this feature does not touch, /// so the two imports' manifests must agree byte for byte there. That is a much /// stronger statement than "the two file sets match", and it is the acceptance /// criterion stated directly: differ in tree emission and in nothing else. fn manifestWithoutFileCards(gpa: std.mem.Allocator, bytes: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var lines = std.mem.splitScalar(u8, bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (std.mem.startsWith(u8, line, "F ") or std.mem.startsWith(u8, line, "S ") or std.mem.startsWith(u8, line, "Z ") or std.mem.startsWith(u8, line, "P ")) continue; try out.appendSlice(gpa, line); try out.append(gpa, '\n'); } return out.toOwnedSlice(gpa); } const StoreShapeCensus = struct { submanifests: usize, checkins: usize, tree_checkins: usize, }; /// Read directly off the produced store, consulting no config field: how many /// SUBMANIFEST artifacts it holds and how many of its check-ins carry S cards. fn censusStoreShape(gpa: std.mem.Allocator, log: *logstore.LogStore) !StoreShapeCensus { const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); var census: StoreShapeCensus = .{ .submanifests = 0, .checkins = 0, .tree_checkins = 0 }; for (rids) |rid| { const bytes = (try log.content(gpa, rid)) orelse continue; defer gpa.free(bytes); var manifest = artifact.parse(gpa, bytes) catch continue; defer manifest.deinit(); switch (manifest.kind) { .submanifest => census.submanifests += 1, .manifest => { census.checkins += 1; if (manifest.s.len != 0) census.tree_checkins += 1; }, else => {}, } } return census; } /// Every `"kind":"blob"` provenance line of `map_bytes`, joined and sorted by /// Git OID so two maps can be compared without depending on emission order. fn blobProvenanceLines(gpa: std.mem.Allocator, map_bytes: []const u8) ![][]u8 { var out: std.ArrayList([]u8) = .empty; errdefer { for (out.items) |line| gpa.free(line); out.deinit(gpa); } var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (std.mem.indexOf(u8, line, "\"kind\":\"blob\"") == null) continue; try out.append(gpa, try gpa.dupe(u8, line)); } const owned = try out.toOwnedSlice(gpa); std.mem.sort([]u8, owned, {}, struct { fn lessThan(_: void, a: []u8, b: []u8) bool { return std.mem.lessThan(u8, a, b); } }.lessThan); return owned; } fn freeLines(gpa: std.mem.Allocator, lines: [][]u8) void { for (lines) |line| gpa.free(line); gpa.free(lines); } // The A/B this flag exists for: the SAME source through the SAME // ingestion path twice, differing in tree emission and in nothing else. The // existing flat comparison point -- a fossil-server clone -- takes a different // path entirely and reintroduces exactly the confound a benchmark built on it // would need to have removed. // // One caveat for whoever publishes numbers from this pair, because it is not // something the assertions below can express: the two stores do not hold the // same artifacts in the same ORDER. Tree mode interleaves each check-in's // SUBMANIFEST nodes ahead of the check-in itself, so the blob and check-in // sequence matches but the run of records between them does not. That is the // measurement rather than a confound, but it means store size and delta chains // compare like for like only at the repository level -- a per-artifact delta // comparison is reading the difference the flag was built to create. test "git import e2e: --no-submanifests imports the same history flat and differs in nothing else" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try gitOk(gpa, io, tmp.dir, &.{ "init", "source" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" }); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); // Wide enough at k=2 that the tree import really partitions -- asserted // below off the store, not assumed -- and deep enough that the second // commit exercises the retained-suffix path rather than a fresh build. try source.createDir(io, "pkg", .default_dir); for (0..64) |index| { const path = try std.fmt.allocPrint(gpa, "pkg/unit{d:0>4}.txt", .{index}); defer gpa.free(path); const body = try std.fmt.allocPrint(gpa, "unit {d}\n", .{index}); defer gpa.free(body); try writeFile(io, source, path, body); } try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "wide root", "2001-02-03T04:05:06+0000", false); try source.rename("pkg/unit0007.txt", source, "pkg/renamed0007.txt", io); try source.deleteFile(io, "pkg/unit0031.txt"); try writeFile(io, source, "pkg/unit0000.txt", "unit 0 revised\n"); try writeFile(io, source, "pkg/added.txt", "added\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "rename delete modify add", "2001-02-03T04:06:06+0000", false); // Identical requests but for the one field. Same source, same default // branch, same attribution, same everything the runner is given. var tree_run = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "imported-tree", "--submanifest-k", "2", "--provenance" }); defer freeResult(gpa, &tree_run); try expectExit(tree_run, 0); var flat_run = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "imported-flat", "--no-submanifests", "--provenance" }); defer freeResult(gpa, &flat_run); try expectExit(flat_run, 0); // "Do the default thing" says nothing about sub-manifests. try std.testing.expect(std.mem.indexOf(u8, flat_run.stderr, "sub-manifest") == null); var tree_dir = try tmp.dir.openDir(io, "imported-tree", .{}); defer tree_dir.close(io); var flat_dir = try tmp.dir.openDir(io, "imported-flat", .{}); defer flat_dir.close(io); var tree_store_file = try tree_dir.openFile(io, ".zit", .{ .mode = .read_only }); defer tree_store_file.close(io); const tree_stat = try tree_store_file.stat(io); var tree_fs = storage.FileStorage.init(io, tree_store_file, tree_stat.size); var tree_log = try logstore.LogStore.openReadOnly(gpa, tree_fs.storageFile()); defer tree_log.deinit(gpa); var flat_store_file = try flat_dir.openFile(io, ".zit", .{ .mode = .read_only }); defer flat_store_file.close(io); const flat_stat = try flat_store_file.stat(io); var flat_fs = storage.FileStorage.init(io, flat_store_file, flat_stat.size); var flat_log = try logstore.LogStore.openReadOnly(gpa, flat_fs.storageFile()); defer flat_log.deinit(gpa); // The tree half is asserted first because it is what makes the flat half // meaningful: this history at this k really does emit sub-manifests. const tree_census = try censusStoreShape(gpa, &tree_log); try std.testing.expect(tree_census.submanifests != 0); try std.testing.expect(tree_census.tree_checkins != 0); const flat_census = try censusStoreShape(gpa, &flat_log); try std.testing.expectEqual(@as(usize, 0), flat_census.submanifests); try std.testing.expectEqual(@as(usize, 0), flat_census.tree_checkins); try std.testing.expectEqual(tree_census.checkins, flat_census.checkins); const tree_map = try tree_dir.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(tree_map); const flat_map = try flat_dir.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); defer gpa.free(flat_map); // Blobs are content-addressed; tree emission cannot touch them, so these // provenance lines must be byte-identical between the two imports. const tree_blobs = try blobProvenanceLines(gpa, tree_map); defer freeLines(gpa, tree_blobs); const flat_blobs = try blobProvenanceLines(gpa, flat_map); defer freeLines(gpa, flat_blobs); try std.testing.expect(tree_blobs.len != 0); try std.testing.expectEqual(tree_blobs.len, flat_blobs.len); for (tree_blobs, flat_blobs) |expected, actual| try std.testing.expectEqualStrings(expected, actual); const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" }); defer gpa.free(commits); var seen_commits: usize = 0; var commit_lines = std.mem.splitScalar(u8, commits, '\n'); while (commit_lines.next()) |oid| { if (oid.len == 0) continue; seen_commits += 1; // Both imports reproduce the Git tree exactly. `compareTree` normalizes // the two shapes through `flattenedFiles`, so it is the same assertion // asked of both stores. const tree_hash = try findCommitMap(gpa, tree_map, oid); defer gpa.free(tree_hash); var tree_checkin = try loadCheckin(gpa, &tree_log, tree_hash); defer tree_checkin.deinit(gpa); try compareTree(gpa, io, tmp.dir, &tree_log, oid, &tree_checkin.manifest, no_omissions); const flat_hash = try findCommitMap(gpa, flat_map, oid); defer gpa.free(flat_hash); var flat_checkin = try loadCheckin(gpa, &flat_log, flat_hash); defer flat_checkin.deinit(gpa); try compareTree(gpa, io, tmp.dir, &flat_log, oid, &flat_checkin.manifest, no_omissions); try std.testing.expectEqual(@as(usize, 0), flat_checkin.manifest.s.len); // Strip the file-set cards and their consequences, and the two // artifacts must be the same bytes: same comment, same writer // timestamp, same U card. const tree_rest = try manifestWithoutFileCards(gpa, tree_checkin.bytes); defer gpa.free(tree_rest); const flat_rest = try manifestWithoutFileCards(gpa, flat_checkin.bytes); defer gpa.free(flat_rest); try std.testing.expectEqualStrings(tree_rest, flat_rest); // Topology, asserted through the renaming rather than around it: each // import's P card must name exactly the check-ins ITS OWN map assigns // to this commit's Git parents, in Git's order. var git_commit = try inspectGitCommit(gpa, io, tmp.dir, oid); defer git_commit.deinit(gpa); const tree_parents = try mappedParents(gpa, tree_map, git_commit.parents); defer gpa.free(tree_parents); const flat_parents = try mappedParents(gpa, flat_map, git_commit.parents); defer gpa.free(flat_parents); try std.testing.expectEqualStrings( tree_parents, if (tree_checkin.manifest.p.len == 0) "" else (tree_checkin.manifest.p[0].parents orelse ""), ); try std.testing.expectEqualStrings( flat_parents, if (flat_checkin.manifest.p.len == 0) "" else (flat_checkin.manifest.p[0].parents orelse ""), ); // Provenance: only the `zit` hash may differ. Author and committer are // recorded by code upstream of the divergence point. var tree_entry = try findCommitProvenance(gpa, tree_map, oid); defer tree_entry.deinit(); var flat_entry = try findCommitProvenance(gpa, flat_map, oid); defer flat_entry.deinit(); try expectIdentity(.{ .name = tree_entry.value.author.?.name, .email = tree_entry.value.author.?.email, .epoch = tree_entry.value.author.?.timestamp, .offset_minutes = tree_entry.value.author.?.timezone_offset_minutes, }, flat_entry.value.author.?); try expectIdentity(.{ .name = tree_entry.value.committer.?.name, .email = tree_entry.value.committer.?.email, .epoch = tree_entry.value.committer.?.timestamp, .offset_minutes = tree_entry.value.committer.?.timezone_offset_minutes, }, flat_entry.value.committer.?); } try std.testing.expect(seen_commits >= 2); // The operator-visible statement: the flat destination is a repository in // the §8.1 default state, not one that merely happens to hold flat history. var shown = try runZit(gpa, io, flat_dir, &.{ "submanifest", "show" }); defer freeResult(gpa, &shown); try expectExit(shown, 0); try std.testing.expect(std.mem.indexOf(u8, shown.stdout, "enable-submanifests: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, shown.stdout, "submanifest-k: (not pinned)\n") != null); try std.testing.expect(std.mem.indexOf(u8, shown.stdout, "seen-submanifest: no\n") != null); try std.testing.expect(std.mem.indexOf(u8, shown.stdout, "commits emit: flat manifests\n") != null); // And it is a working repository, not just a well-shaped store. var updated = try runZit(gpa, io, flat_dir, &.{ "update", "sym-heads/main" }); defer freeResult(gpa, &updated); try expectExit(updated, 0); const main_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "main" }); defer gpa.free(main_oid); try compareMaterializedTree(gpa, io, tmp.dir, flat_dir, std.mem.trim(u8, main_oid, " \t\r\n"), no_omissions); } fn findCommitProvenance( gpa: std.mem.Allocator, map_bytes: []const u8, oid: []const u8, ) !std.json.Parsed(MapEntry) { var lines = std.mem.splitScalar(u8, map_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true }); const entry = parsed.value; if (std.mem.eql(u8, entry.kind, "commit") and entry.oid != null and std.mem.eql(u8, entry.oid.?, oid) and entry.author != null and entry.committer != null) { return parsed; } parsed.deinit(); } return error.MissingCommitProvenance; } /// Strips the two-column marker gutter `zit branch` prints in front of every /// name, leaving exactly the listing Git's `--format=%(refname:short)` reports: /// one branch per line, ascending by name on both sides. /// The label the import gives a Git branch. Every other branch keeps its /// source name; only the main line is renamed, because `trunk` is the name the /// format's own conventions assume and the one a fresh checkout, `open` and a /// clone all reach for without being told. fn zitLabelFor(name: []const u8, primary: []const u8) []const u8 { return if (std.mem.eql(u8, name, primary)) "trunk" else name; } /// Git's branch listing rewritten to the labels the import publishes, then /// re-sorted: substituting `trunk` for the primary can move it in the /// ordering, so comparing against zit's sorted listing requires sorting after /// the substitution rather than before it. fn expectedBranchNames(gpa: std.mem.Allocator, git_listing: []const u8, primary: []const u8) ![]u8 { var names: std.ArrayList([]const u8) = .empty; defer names.deinit(gpa); var lines = std.mem.splitScalar(u8, git_listing, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; try names.append(gpa, zitLabelFor(line, primary)); } std.mem.sort([]const u8, names.items, {}, struct { fn lessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lessThan); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); for (names.items) |name| { try out.appendSlice(gpa, name); try out.append(gpa, '\n'); } return out.toOwnedSlice(gpa); } fn zitBranchNames(gpa: std.mem.Allocator, listing: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var lines = std.mem.splitScalar(u8, listing, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (line.len < 3) return error.MalformedBranchListing; try out.appendSlice(gpa, line[2..]); try out.append(gpa, '\n'); } return out.toOwnedSlice(gpa); } /// The branch topology the importer reconstructs, checked against /// Git rather than against the importer's own idea of it. /// /// Three independent claims, because the bug broke all three differently: /// the branch *set* (a single-branch repository used to import with none at /// all), each branch's *tip* (a ref could end up naming history it never /// contained), and the *ownership of the primary branch's own chain* (a side /// ref reached a commit first in the export stream and claimed it, which is /// what severed the child chains and multiplied leaves). /// /// The branch of a check-in is read the way an operator reads it: `zit update` /// it and look at what the checkout says it is on. fn assertBranchTopology( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, destination: std.Io.Dir, map_bytes: []const u8, primary: []const u8, ) !void { const git_branches = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "branch", "--format=%(refname:short)" }); defer gpa.free(git_branches); // The destination's set is the source's with the main line relabelled; a // side branch keeps its source name. const expected_branches = try expectedBranchNames(gpa, git_branches, primary); defer gpa.free(expected_branches); // Once with the cache and once without: the branch set is derived state, // and a stale cache would be a second, silent source for it. for ([_]bool{ false, true }) |no_cache| { const argv: []const []const u8 = if (no_cache) &.{ "branch", "--no-cache" } else &.{"branch"}; var listing = try runZit(gpa, io, destination, argv); defer freeResult(gpa, &listing); try expectExit(listing, 0); const names = try zitBranchNames(gpa, listing.stdout); defer gpa.free(names); try std.testing.expectEqualStrings(expected_branches, names); } var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; var names = std.mem.splitScalar(u8, git_branches, '\n'); while (names.next()) |name| { if (name.len == 0) continue; const label = zitLabelFor(name, primary); const tip_oid = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-parse", name }); defer gpa.free(tip_oid); const mapped = try findCommitMap(gpa, map_bytes, std.mem.trim(u8, tip_oid, " \t\r\n")); defer gpa.free(mapped); var update = try runZit(gpa, io, destination, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings(mapped, current.version); try std.testing.expectEqualStrings(label, current.branch); // The branch NAME resolves to the branch's tip, exactly as // `git rev-parse <branch>` does. A branch root carries the repo-state // §6.2 tag PAIR — `*branch NAME` alongside a propagating `*sym-NAME` — // and it is the sym half that makes the name resolvable; a repository // that answered only to the importer's own `heads/NAME` alias would be // one no native commit could produce. `info` prints the resolved // check-in on its first line. var info = try runZit(gpa, io, destination, &.{ "info", label }); defer freeResult(gpa, &info); try expectExit(info, 0); try std.testing.expectEqualStrings(mapped, std.mem.sliceTo(info.stdout, '\n')); } // Every commit Git reaches from the primary branch by first parents belongs // to the primary branch — merges included, since a merge is on the branch it // was merged into. No other ref may have claimed one of them. const chain = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-list", "--first-parent", primary }); defer gpa.free(chain); var chain_lines = std.mem.splitScalar(u8, chain, '\n'); var walked: usize = 0; while (chain_lines.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var update = try runZit(gpa, io, destination, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings(zitLabelFor(primary, primary), current.branch); walked += 1; } try std.testing.expect(walked >= 2); } /// The imported check-in one Git revision translated to, resolved through the /// same provenance map an operator would read. fn mappedRevision( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, map_bytes: []const u8, revision: []const u8, ) ![]u8 { const oid = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-parse", revision }); defer gpa.free(oid); return findCommitMap(gpa, map_bytes, std.mem.trim(u8, oid, " \t\r\n")); } /// Every raw tag visible on one check-in, compared verbatim: `zit tag list /// --raw CHECKIN` prints `NAME VALUE` for a valued tag and a bare `NAME` for a /// valueless one, ascending by raw name. A whole-listing comparison is the /// point — a cancelled tag is proved by the line that is NOT there, which a /// contains-check would miss. fn expectTagListing( gpa: std.mem.Allocator, io: std.Io, destination: std.Io.Dir, hash: []const u8, expected: []const u8, ) !void { var listing = try runZit(gpa, io, destination, &.{ "tag", "list", "--raw", hash }); defer freeResult(gpa, &listing); try expectExit(listing, 0); try std.testing.expectEqualStrings(expected, listing.stdout); } /// Opens a freshly imported destination and hands back its provenance map. /// The caller frees the returned bytes and closes the directory. fn importForTopology( gpa: std.mem.Allocator, io: std.Io, tmp: std.Io.Dir, destination: *std.Io.Dir, ) ![]u8 { var imported = try runZit(gpa, io, tmp, &.{ "import-git", "source", "imported", "--provenance" }); defer freeResult(gpa, &imported); try expectExit(imported, 0); destination.* = try tmp.openDir(io, "imported", .{}); errdefer destination.close(io); return destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20)); } /// The four `git init` incantations every shape below starts from, pinned so a /// developer's own Git configuration cannot change what the oracle reports. fn initSourceRepo(gpa: std.mem.Allocator, io: std.Io, tmp: std.Io.Dir) !void { try gitOk(gpa, io, tmp, &.{ "init", "source" }); try gitOk(gpa, io, tmp, &.{ "-C", "source", "branch", "-M", "main" }); try gitOk(gpa, io, tmp, &.{ "-C", "source", "config", "user.name", "Git Committer" }); try gitOk(gpa, io, tmp, &.{ "-C", "source", "config", "user.email", "[email protected]" }); } test "git import e2e shape A: a single-branch history imports as exactly that branch" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initSourceRepo(gpa, io, tmp.dir); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); const stamps = [_][]const u8{ "2001-02-03T04:05:06+0000", "2001-02-03T04:06:06+0000", "2001-02-03T04:07:06+0000", }; for (stamps, 0..) |stamp, index| { const body = try std.fmt.allocPrint(gpa, "revision {d}\n", .{index}); defer gpa.free(body); try writeFile(io, source, "a.txt", body); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, body, stamp, false); } var destination: std.Io.Dir = undefined; const map_bytes = try importForTopology(gpa, io, tmp.dir, &destination); defer gpa.free(map_bytes); defer destination.close(io); try assertBranchTopology(gpa, io, tmp.dir, destination, map_bytes, "main"); } test "git import e2e shape B: sibling branches claim only their own commits" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initSourceRepo(gpa, io, tmp.dir); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); // Every commit is stamped the same second, deliberately. Branch controls // are separate artifacts here, and repo-state §5.3 skips a direct tag // application when the row already present has an mtime >= the new one — so // if an ancestor's branch is allowed to propagate into a branch root before // that root's own control is applied, a root sharing its parent's timestamp // never gets its branch. Any two commits made within one second reproduce // it, which is not rare; a distinct-timestamp fixture would pass without // exercising the emission ordering that makes it safe. const stamp = "2001-02-03T04:05:06+0000"; try writeFile(io, source, "base.txt", "base\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "base", stamp, false); const feats = [_][]const u8{ "feat1", "feat2", "feat3" }; for (feats) |feat| { try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", feat, "main" }); const path = try std.fmt.allocPrint(gpa, "{s}.txt", .{feat}); defer gpa.free(path); try writeFile(io, source, path, feat); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, feat, stamp, false); } try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" }); try writeFile(io, source, "base.txt", "base and more\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "main tip", stamp, false); var destination: std.Io.Dir = undefined; const map_bytes = try importForTopology(gpa, io, tmp.dir, &destination); defer gpa.free(map_bytes); defer destination.close(io); try assertBranchTopology(gpa, io, tmp.dir, destination, map_bytes, "main"); // The branch-root card set itself (repo-state §6.2), read off the // check-ins it lands on. The primary root introduces `*branch main` with // its `*sym-main`, which then propagates along every primary-parent link — // including into each feature root, since a fork's first parent is a main // commit. Only the fork's own `-sym-main` cancel keeps it off that name. // Dropping the cancel would not merely mislabel: every commit in this // fixture shares one timestamp, so three uncancelled feature roots become // candidates tied with main's tip for the name `main`, and resolution // falls through to the least-hash concurrency tie-break. const main_root = try mappedRevision(gpa, io, tmp.dir, map_bytes, "main~1"); defer gpa.free(main_root); try expectTagListing(gpa, io, destination, main_root, "branch trunk\nsym-trunk\n"); const main_tip = try mappedRevision(gpa, io, tmp.dir, map_bytes, "main"); defer gpa.free(main_tip); try expectTagListing(gpa, io, destination, main_tip, "branch trunk\nsym-heads/main\nsym-trunk\n"); const feat1_root = try mappedRevision(gpa, io, tmp.dir, map_bytes, "feat1"); defer gpa.free(feat1_root); try expectTagListing(gpa, io, destination, feat1_root, "branch feat1\nsym-feat1\nsym-heads/feat1\n"); } test "git import e2e shape C: merged topic branches leave every merge on the primary branch" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try initSourceRepo(gpa, io, tmp.dir); var source = try tmp.dir.openDir(io, "source", .{}); defer source.close(io); try writeFile(io, source, "base.txt", "base\n"); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, "base", "2001-02-03T04:05:06+0000", false); const topics = [_][]const u8{ "topic1", "topic2", "topic3", "topic4", "topic5" }; const work_stamps = [_][]const u8{ "2001-02-03T04:06:06+0000", "2001-02-03T04:08:06+0000", "2001-02-03T04:10:06+0000", "2001-02-03T04:12:06+0000", "2001-02-03T04:14:06+0000", }; const merge_stamps = [_][]const u8{ "2001-02-03T04:07:06+0000", "2001-02-03T04:09:06+0000", "2001-02-03T04:11:06+0000", "2001-02-03T04:13:06+0000", "2001-02-03T04:15:06+0000", }; for (topics, work_stamps, merge_stamps) |topic, work_stamp, merge_stamp| { try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", topic, "main" }); const path = try std.fmt.allocPrint(gpa, "{s}.txt", .{topic}); defer gpa.free(path); try writeFile(io, source, path, topic); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" }); try gitCommit(gpa, io, tmp.dir, topic, work_stamp, false); try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" }); const message = try std.fmt.allocPrint(gpa, "merge {s}", .{topic}); defer gpa.free(message); try gitMerge(gpa, io, tmp.dir, &.{topic}, message, merge_stamp); } var destination: std.Io.Dir = undefined; const map_bytes = try importForTopology(gpa, io, tmp.dir, &destination); defer gpa.free(map_bytes); defer destination.close(io); try assertBranchTopology(gpa, io, tmp.dir, destination, map_bytes, "main"); // The merge check-ins specifically: a merge is on the branch it was merged // into, so none of them may be labelled with the topic it absorbed. const merges = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--merges", "main" }); defer gpa.free(merges); var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; var merge_lines = std.mem.splitScalar(u8, merges, '\n'); var seen: usize = 0; while (merge_lines.next()) |oid| { if (oid.len == 0) continue; const mapped = try findCommitMap(gpa, map_bytes, oid); defer gpa.free(mapped); var update = try runZit(gpa, io, destination, &.{ "update", mapped }); defer freeResult(gpa, &update); try expectExit(update, 0); const current = try readCheckoutState(io, destination, &ckbuf); try std.testing.expectEqualStrings("trunk", current.branch); seen += 1; } try std.testing.expectEqual(@as(usize, topics.len), seen); } test "git import e2e: import-git help states the branch rule, its degenerate case, and the repair path" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var flag = try runZit(gpa, io, tmp.dir, &.{ "import-git", "--help" }); defer freeResult(gpa, &flag); try expectExit(flag, 0); try std.testing.expectEqualStrings("", flag.stderr); // Two routes reach this text and neither is the other's summary, so pin // them equal rather than pinning one and trusting the other to follow. var routed = try runZit(gpa, io, tmp.dir, &.{ "help", "import-git" }); defer freeResult(gpa, &routed); try expectExit(routed, 0); try std.testing.expectEqualStrings(flag.stdout, routed.stdout); // The rule the three shapes above pin is not derivable from the source // repository: Git has no branch-of-a-commit, so an operator who cannot // read the claim order here cannot predict where a merge or a shared // ancestor lands, and has no way to learn that a destination imported // before the fix carries wrong branch state that a re-import repairs. for ([_][]const u8{ "reconstructed from the commit graph", "first-parent chain", "shallowest tip first", // Wrapped help lines cannot be matched across their newline, so every // phrase here is one the text keeps on a single line. "the branch it was merged into", "Two branches on the same commit", "Re-import such a destination", "check-in hashes survive", }) |phrase| { std.testing.expect(std.mem.indexOf(u8, flag.stdout, phrase) != null) catch |err| { std.debug.print("import-git help does not state: {s}\n", .{phrase}); return err; }; } }