Artifact 8e27984c99e2c40598f3d2687e15ad254bde3682b8dfba0afe585834bf07ba0a:
- File
src/git_import_e2e.zig
— part of check-in
[81b01a1157]
at
2026-08-02 01:29:18
on branch sub-manifest
— import-git: differential test for flat vs tree imports
Same source, same path, two imports: check-in manifests byte-identical once F/S/Z/P are stripped, blob provenance identical, P topology re-derived through each import's own map. (user: dmestas, size: 63993) [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 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, }; 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, }; 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, }); } fn expectNoImportTemps(io: std.Io, parent: std.Io.Dir) !void { var entries = parent.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]"); try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000"); try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000"); return env; } 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(); try env.put("GIT_AUTHOR_DATE", epoch); 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(); try env.put("GIT_AUTHOR_DATE", epoch); 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; } fn compareTree( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, log: *logstore.LogStore, commit: []const u8, checkin: *artifact.Manifest, ) !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); 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, ) !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); 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); } fn assertCommitDifferential( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, log: *logstore.LogStore, map_bytes: []const u8, oid: []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); var checkin = try loadCheckin(gpa, log, mapped); defer checkin.deinit(gpa); try std.testing.expectEqualStrings(git.message, checkin.manifest.c.?.comment); try std.testing.expectEqualStrings(git.committer.name, checkin.manifest.u.?.login); var timestamp: [19]u8 = undefined; try std.testing.expectEqualStrings(try writerTimestamp(git.committer.epoch, ×tamp), checkin.manifest.d.?.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); 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; } fn assertRefResolution( gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, destination: std.Io.Dir, map_bytes: []const u8, ) !void { 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 checkout.read(io, destination, &ckbuf)).?; try std.testing.expectEqualStrings(mapped_target, current.version); } } 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" }); 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); var destination = try tmp.dir.openDir(io, "imported", .{}); 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 stat = try store_file.stat(io); var fs = storage.FileStorage.init(io, store_file, stat.size); var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0); 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); // 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 checkout.read(io, destination, &ckbuf)).?; try std.testing.expectEqualStrings("", unborn.version); try std.testing.expectEqualStrings("main", 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 checkout.read(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")); // 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); 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 checkout.read(io, destination, &ckbuf)).?; try std.testing.expectEqualStrings(mapped, current.version); try compareMaterializedTree(gpa, io, tmp.dir, destination, oid); } 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); } 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" }); 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" }); 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" }); 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.open(gpa, fs.storageFile(), 0); 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); 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")); 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" }); 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" }); 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" }); 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" }); 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" }); 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"); var tip = try runZit(gpa, io, destination, &.{ "info", branch }); 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 (issue #23): 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" }); 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" }); 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.open(gpa, tree_fs.storageFile(), 0); 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.open(gpa, flat_fs.storageFile(), 0); 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); 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); 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")); } 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; }