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