Artifact 14f05388fc92feaf13277a16447b79bd10b3ba70ca4726139efbf698572b28b1:
- File
src/git_import_e2e.zig
— part of check-in
[dc0aa2f858]
at
2026-07-31 02:56:07
on branch sub-manifest
— git-import: translate a Git fast-export stream into a zit history
`zit import-git SOURCE DEST` reads a Git repository through `git fast- export` and builds a zit repository from it. The destination appears only after the export, artifact creation and publication have all completed, so a failure anywhere leaves no half-published repository behind; this is the first caller of the logstore artifact batch added earlier on this branch, which is what makes a check-in's blobs arrive as a unit.
Mode 160000 now parses as a gitlink instead of error.UnsupportedMode. That completes the split begun in the previous check-in: reporting what the stream said is the parser's job, and deciding that zit has no submodule concept is the importer's. The refusal still happens, but it now explains itself instead of failing as a parse error on input git legitimately emits.
Signed commits are refused by default rather than imported with the signature silently dropped. Fossil has no signature card, so importing one is a loss the operator should choose; --drop-signatures accepts it explicitly.
The sub-manifest parameter is analyzed, not guessed. Preflight measures the source's own name set and pins its measured optimum, and --submanifest-k overrides that only to match another repository's existing pin, which is what lets two repositories share sub-manifests by content address. On a 100,000 file corpus the analyzed value cuts the objective by roughly 3.4x against the fixed default it replaces.
Correctness is checked differentially against real Git rather than against fixtures we wrote: every materialized tree is compared to `git ls-tree` for the corresponding commit, refs and annotated tags are compared to the source, and both comparators now assert that a gitlink is absent rather than merely tolerating it, so the omission is a contract instead of an accident.
The rest is wiring. extras learns the import provenance names, root.zig exports the importer modules across the executable and library boundary, tidy and the CI step guard learn the new test roots and build steps, and the e2e help check learns that a verb spelled import_git in Zig is import-git on the command line. (user: dmestas, size: 45686) [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"); }