Artifact 8e40de2d5396532b9c0692f700223e8e0214b4f06b450c29f1849b18cda2cb14:
- File
src/git_import_e2e.zig
— part of check-in
[1f21f96558]
at
2026-07-31 03:13:01
on branch sub-manifest
— fast-export: accept an author with an empty email
parseIdentity refused an identity carrying an empty address twice over, once as `open + 1 == close` and again as `email.len == 0`. That is not malformed input. Setting `user.email` to the empty string is ordinary git configuration; `git fast-export` writes the identity with an empty angle-bracket pair, and `git fast-import` reads that same stream back to the same commit. The construct is valid by git's own definition on both sides of its own tooling, and we rejected it.
The cost was not cosmetic. Two such commits, reachable only through a merge's second parent, aborted the import of an entire 437 commit repository with `import-git: import failed: Malformed`, naming neither the line nor the rule.
Only the email clauses are relaxed. The name clauses stay: git's own porcelain refuses an empty ident name, and the importer maps the identity's name onto the U card login, so a nameless identity would produce a nameless check-in. The two questions were conflated in a single condition, which is a large part of why this went unnoticed, so the guard now separates them and says why.
This is the third defect on this branch of the same shape: a property of our own fixtures promoted into a rule about the world. The first two were bounds tuned to the width of our benchmark corpus. This one is sharper, a refusal of input the tool we integrate with emits by default. Every identity fixture in the parser tests carried a non-empty email, so a uniform corpus could not falsify a rule that keys on emptiness.
Both tests are witnesses rather than decoration: each fails with Malformed against the previous parser and passes against this one. The unit test pins the construct and the empty-name refusal beside it; the differential test has git itself produce the commit and then asserts the imported check-in carries the login mohye77 rather than an empty user. (user: dmestas, size: 47792) [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")); // 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); }