//! Real-Git differential tests for `zit import-git`.
//!
//! This suite deliberately builds ordinary Git repositories rather than feeding a
//! hand-written fast-export stream to the importer. Git is the oracle for tree
//! shape, blob bytes, parent order, identities, commit messages, and refs; Zit
//! is read back through its public storage and artifact APIs. The suite is an
//! opt-in build step because it starts Git and the compiled Zit executable.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const zit = @import("zit");
const artifact = zit.artifact;
const git_import = zit.git_import;
const checkout = zit.checkout;
const logstore = zit.logstore;
const storage = zit.storage;
const zit_exe: []const u8 = build_options.zit_exe;
const Identity = struct {
name: []const u8,
email: []const u8,
epoch: i64,
offset_minutes: i16,
};
const MapIdentity = struct {
name: []const u8,
email: []const u8,
timestamp: i64,
timezone_offset_minutes: i16,
};
/// The `mirror` object a `kind:"commit"` map row carries: what the classifier
/// decided about that commit's Fossil-mirror candidates, and -- when it
/// retained them -- which gate said so.
const MapMirror = struct {
decision: []const u8,
reason: ?[]const u8 = null,
};
const MapEntry = struct {
kind: []const u8,
oid: ?[]const u8 = null,
zit: ?[]const u8 = null,
source: ?[]const u8 = null,
canonical: ?[]const u8 = null,
author: ?MapIdentity = null,
committer: ?MapIdentity = null,
warning: ?[]const u8 = null,
target_zit: ?[]const u8 = null,
mirror: ?MapMirror = null,
};
const Checkin = struct {
bytes: []u8,
manifest: artifact.Manifest,
fn deinit(self: *Checkin, gpa: std.mem.Allocator) void {
self.manifest.deinit();
gpa.free(self.bytes);
self.* = undefined;
}
};
const LoadedManifest = struct {
hash: []u8,
bytes: []u8,
manifest: artifact.Manifest,
fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void {
gpa.free(self.hash);
self.manifest.deinit();
gpa.free(self.bytes);
}
};
fn rawArtifactHash(name: []const u8, buffer: *[32]u8) ?[]const u8 {
if (name.len != 40 and name.len != 64) return null;
const raw = buffer[0 .. name.len / 2];
_ = std.fmt.hexToBytes(raw, name) catch return null;
return raw;
}
/// Keeps every resolver result alive until `flattenTree` completes. The
/// artifact API intentionally borrows slices from its resolver, so freeing a
/// submanifest eagerly would make the differential oracle unsound.
const TreeCache = struct {
gpa: std.mem.Allocator,
log: *logstore.LogStore,
loaded: std.ArrayList(LoadedManifest) = .empty,
fn deinit(self: *TreeCache) void {
for (self.loaded.items) |*item| item.deinit(self.gpa);
self.loaded.deinit(self.gpa);
self.* = undefined;
}
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView {
const self: *TreeCache = @ptrCast(@alignCast(ctx));
for (self.loaded.items) |*item| {
if (std.mem.eql(u8, item.hash, hash)) {
return .{ .files = item.manifest.f, .subs = item.manifest.s };
}
}
var digest: [32]u8 = undefined;
const raw_hash = rawArtifactHash(hash, &digest) orelse return null;
const rid = self.log.lookup(raw_hash) orelse return null;
const bytes = (self.log.content(self.gpa, rid) catch return null) orelse return null;
errdefer self.gpa.free(bytes);
var manifest = artifact.parse(self.gpa, bytes) catch return null;
errdefer manifest.deinit();
const owned_hash = self.gpa.dupe(u8, hash) catch return null;
errdefer self.gpa.free(owned_hash);
self.loaded.append(self.gpa, .{ .hash = owned_hash, .bytes = bytes, .manifest = manifest }) catch return null;
const item = &self.loaded.items[self.loaded.items.len - 1];
return .{ .files = item.manifest.f, .subs = item.manifest.s };
}
};
fn freeResult(gpa: std.mem.Allocator, result: *std.process.RunResult) void {
gpa.free(result.stdout);
gpa.free(result.stderr);
}
fn expectExit(result: std.process.RunResult, expected: u8) !void {
switch (result.term) {
.exited => |actual| if (actual == expected) return else {
std.debug.print("expected exit {d}, got {d}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, actual, result.stdout, result.stderr });
return error.UnexpectedExit;
},
else => {
std.debug.print("expected exit {d}, got {any}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, result.term, result.stdout, result.stderr });
return error.UnexpectedTermination;
},
}
}
fn expectFailure(result: std.process.RunResult) !void {
try expectExit(result, 1);
}
fn expectNoDirectory(io: std.Io, parent: std.Io.Dir, path: []const u8) !void {
var dir = parent.openDir(io, path, .{}) catch return;
dir.close(io);
return error.UnexpectedPublishedDestination;
}
fn runCommand(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
argv: []const []const u8,
env: ?*std.process.Environ.Map,
) !std.process.RunResult {
return std.process.run(gpa, io, .{
.argv = argv,
.cwd = .{ .dir = cwd },
.environ_map = env,
});
}
/// The `.zit-checkout` state of `dir`, or an error naming its absence.
///
/// Every caller here reads it straight after a `zit update` that exited 0, so a
/// null is a real defect -- but `.?` DIAGNOSES that only while safety checks are
/// on, and this suite's own lane rules run it under `--release=fast` for corpus
/// loops, where unwrapping a null is illegal behavior rather than a failed test.
/// The same argument applies to every optional in this file that a subprocess or
/// a repository populated.
fn readCheckoutState(io: std.Io, dir: std.Io.Dir, buf: []u8) !checkout.CheckoutState {
return (try checkout.read(io, dir, buf)) orelse error.MissingCheckoutState;
}
fn expectNoImportTemps(io: std.Io, parent: std.Io.Dir) !void {
// Reopened for iteration: the caller's handle may lack iterate rights,
// which Linux enforces at readdir time (macOS does not).
var it_dir = try parent.openDir(io, ".", .{ .iterate = true });
defer it_dir.close(io);
var entries = it_dir.iterate();
while (try entries.next(io)) |entry| {
try std.testing.expect(!std.mem.startsWith(u8, entry.name, ".zit-import-tmp-"));
}
}
fn initGitEnv(gpa: std.mem.Allocator) !std.process.Environ.Map {
var env = std.process.Environ.Map.init(gpa);
errdefer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-git-import-e2e-no-home");
try env.put("PATH", "/usr/bin:/bin");
try env.put("LC_ALL", "C");
try env.put("LANG", "C");
try env.put("GIT_CONFIG_NOSYSTEM", "1");
try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
try env.put("GIT_AUTHOR_NAME", "Git Author");
try env.put("GIT_AUTHOR_EMAIL", "[email protected]");
try env.put("GIT_COMMITTER_NAME", "Git Committer");
try env.put("GIT_COMMITTER_EMAIL", "[email protected]");
// The two instants DIFFER, and they have to. Git's own default makes them
// equal, and `--attribution` selects between exactly these two: with one
// value in both slots the D card is the same either way, so an importer
// that always wrote the committer instant would satisfy the author run too
// and `assertCommitDifferential`'s timestamp comparison would prove
// nothing. Only the login would discriminate, and a login is one field.
try env.put("GIT_AUTHOR_DATE", author_instant_date ++ "T04:05:06+0000");
try env.put("GIT_COMMITTER_DATE", committer_instant_date ++ "T04:05:06+0000");
return env;
}
/// The date every fixture instant in this file carries, and the earlier date
/// the matching author instant is moved to. Same width so the substitution is a
/// splice, and far enough apart that no rounding could collapse them.
const committer_instant_date = "2001-02-03";
const author_instant_date = "1999-01-02";
/// The author instant paired with `committer`: same time of day, earlier date.
///
/// Derived rather than passed so every call site states one instant and still
/// gets two distinct ones. Both attribution modes then read a different D card,
/// which is what makes the comparison against it load-bearing.
fn authorInstant(committer: []const u8, buffer: []u8) ![]const u8 {
if (committer.len <= committer_instant_date.len) return error.MalformedTestInstant;
if (committer[committer_instant_date.len] != 'T') return error.MalformedTestInstant;
return std.fmt.bufPrint(buffer, "{s}{s}", .{
author_instant_date,
committer[committer_instant_date.len..],
});
}
fn runGit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult {
var argv: [16][]const u8 = undefined;
std.debug.assert(args.len + 1 <= argv.len);
argv[0] = "git";
for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg;
var env = try initGitEnv(gpa);
defer env.deinit();
return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env);
}
fn gitOk(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !void {
var result = try runGit(gpa, io, cwd, args);
defer freeResult(gpa, &result);
try expectExit(result, 0);
}
fn gitCommit(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
message: []const u8,
epoch: []const u8,
allow_empty: bool,
) !void {
var env = try initGitEnv(gpa);
defer env.deinit();
var author_buffer: [64]u8 = undefined;
try env.put("GIT_AUTHOR_DATE", try authorInstant(epoch, &author_buffer));
try env.put("GIT_COMMITTER_DATE", epoch);
var argv: [9][]const u8 = .{ "git", "-C", "source", "commit", "--no-gpg-sign", "-m", message, undefined, undefined };
const len: usize = if (allow_empty) blk: {
argv[7] = "--allow-empty";
break :blk 8;
} else 7;
var result = try runCommand(gpa, io, cwd, argv[0..len], &env);
defer freeResult(gpa, &result);
try expectExit(result, 0);
}
fn gitMerge(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
branches: []const []const u8,
message: []const u8,
epoch: []const u8,
) !void {
var env = try initGitEnv(gpa);
defer env.deinit();
var author_buffer: [64]u8 = undefined;
try env.put("GIT_AUTHOR_DATE", try authorInstant(epoch, &author_buffer));
try env.put("GIT_COMMITTER_DATE", epoch);
var argv: [16][]const u8 = undefined;
const prefix = [_][]const u8{ "git", "-C", "source", "merge", "--no-ff", "-m", message };
for (prefix, argv[0..prefix.len]) |value, *slot| slot.* = value;
std.debug.assert(prefix.len + branches.len <= argv.len);
for (branches, argv[prefix.len..][0..branches.len]) |branch, *slot| slot.* = branch;
var result = try runCommand(gpa, io, cwd, argv[0 .. prefix.len + branches.len], &env);
defer freeResult(gpa, &result);
try expectExit(result, 0);
}
fn runZit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult {
var env = std.process.Environ.Map.init(gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-git-import-e2e-no-home");
try env.put("PATH", "/usr/bin:/bin");
const base = try std.process.currentPathAlloc(io, gpa);
defer gpa.free(base);
const executable = try std.fs.path.resolve(gpa, &.{ base, zit_exe });
defer gpa.free(executable);
var argv: [16][]const u8 = undefined;
std.debug.assert(args.len + 1 <= argv.len);
argv[0] = executable;
for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg;
return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env);
}
fn writeFile(io: std.Io, dir: std.Io.Dir, path: []const u8, bytes: []const u8) !void {
var file = try dir.createFile(io, path, .{});
defer file.close(io);
try file.writePositionalAll(io, bytes, 0);
}
fn gitOutput(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) ![]u8 {
var result = try runGit(gpa, io, cwd, args);
defer freeResult(gpa, &result);
try expectExit(result, 0);
return gpa.dupe(u8, result.stdout);
}
fn parseIdentity(line: []const u8, prefix: []const u8) !Identity {
if (!std.mem.startsWith(u8, line, prefix)) return error.MalformedGitIdentity;
const rest = line[prefix.len..];
const close = std.mem.lastIndexOfScalar(u8, rest, '>') orelse return error.MalformedGitIdentity;
if (close + 2 >= rest.len or rest[close + 1] != ' ') return error.MalformedGitIdentity;
const open = std.mem.lastIndexOfScalar(u8, rest[0..close], '<') orelse return error.MalformedGitIdentity;
if (open == 0 or rest[open - 1] != ' ') return error.MalformedGitIdentity;
var tail = std.mem.splitScalar(u8, rest[close + 2 ..], ' ');
const epoch = try std.fmt.parseInt(i64, tail.next() orelse return error.MalformedGitIdentity, 10);
const zone = tail.next() orelse return error.MalformedGitIdentity;
if (zone.len != 5 or (zone[0] != '+' and zone[0] != '-')) return error.MalformedGitIdentity;
const minutes = try std.fmt.parseInt(i16, zone[1..3], 10) * 60 + try std.fmt.parseInt(i16, zone[3..5], 10);
return .{
.name = rest[0 .. open - 1],
.email = rest[open + 1 .. close],
.epoch = epoch,
.offset_minutes = if (zone[0] == '-') -minutes else minutes,
};
}
const GitCommit = struct {
bytes: []u8,
parents: []u8,
author: Identity,
committer: Identity,
message: []const u8,
fn deinit(self: *GitCommit, gpa: std.mem.Allocator) void {
gpa.free(self.parents);
gpa.free(self.bytes);
self.* = undefined;
}
};
fn inspectGitCommit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, oid: []const u8) !GitCommit {
const bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "commit", oid });
errdefer gpa.free(bytes);
const header_end = std.mem.indexOf(u8, bytes, "\n\n") orelse return error.MalformedGitCommit;
const headers = bytes[0..header_end];
var parents: std.ArrayList(u8) = .empty;
errdefer parents.deinit(gpa);
var author: ?Identity = null;
var committer: ?Identity = null;
var lines = std.mem.splitScalar(u8, headers, '\n');
while (lines.next()) |line| {
if (std.mem.startsWith(u8, line, "parent ")) {
if (parents.items.len != 0) try parents.append(gpa, ' ');
try parents.appendSlice(gpa, line[7..]);
} else if (std.mem.startsWith(u8, line, "author ")) author = try parseIdentity(line, "author ") else if (std.mem.startsWith(u8, line, "committer ")) committer = try parseIdentity(line, "committer ");
}
return .{
.bytes = bytes,
.parents = try parents.toOwnedSlice(gpa),
.author = author orelse return error.MalformedGitCommit,
.committer = committer orelse return error.MalformedGitCommit,
.message = bytes[header_end + 2 ..],
};
}
fn loadCheckin(gpa: std.mem.Allocator, log: *logstore.LogStore, hash: []const u8) !Checkin {
var digest: [32]u8 = undefined;
const raw_hash = rawArtifactHash(hash, &digest) orelse return error.InvalidZitHash;
const rid = log.lookup(raw_hash) orelse return error.MissingZitCheckin;
const bytes = (try log.content(gpa, rid)) orelse return error.MissingZitCheckin;
errdefer gpa.free(bytes);
return .{ .bytes = bytes, .manifest = try artifact.parse(gpa, bytes) };
}
fn flattenedFiles(manifest: *artifact.Manifest, cache: *TreeCache) ![]const artifact.FCard {
if (manifest.s.len == 0) return manifest.f;
try manifest.flattenTree(.{ .ctx = cache, .resolveFn = TreeCache.resolve });
return manifest.flattened.?;
}
fn fileMode(mode: []const u8) !artifact.FileMode {
if (std.mem.eql(u8, mode, "100644")) return .regular;
if (std.mem.eql(u8, mode, "100755")) return .executable;
if (std.mem.eql(u8, mode, "120000")) return .symlink;
return error.UnrepresentableGitMode;
}
/// Root paths this commit's imported tree is expected NOT to carry, because a
/// validated Fossil mirror pair was omitted from it.
///
/// Passed in rather than derived: which commits validated is the thing under
/// test, so a comparator that worked it out from the store would agree with
/// whatever the importer did. The caller states the expectation and both
/// directions are then assertions -- present when it should be, absent when it
/// should not.
const no_omissions: []const []const u8 = &.{};
fn isOmitted(omitted: []const []const u8, path: []const u8) bool {
for (omitted) |name| {
if (std.mem.eql(u8, name, path)) return true;
}
return false;
}
fn compareTree(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
log: *logstore.LogStore,
commit: []const u8,
checkin: *artifact.Manifest,
omitted: []const []const u8,
) !void {
var cache = TreeCache{ .gpa = gpa, .log = log };
defer cache.deinit();
const files = try flattenedFiles(checkin, &cache);
const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit });
defer gpa.free(listing);
var count: usize = 0;
var records = std.mem.splitScalar(u8, listing, 0);
while (records.next()) |record| {
if (record.len == 0) continue;
const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree;
var header = std.mem.splitScalar(u8, record[0..tab], ' ');
const mode = header.next() orelse return error.MalformedLsTree;
const object_kind = header.next() orelse return error.MalformedLsTree;
const blob_oid = header.next() orelse return error.MalformedLsTree;
const path = record[tab + 1 ..];
// A gitlink is a `commit` entry in Git's tree. The importer omits it
// deliberately, so the imported manifest must not name that path at
// all. Assert the absence rather than tolerating it: a silent
// reappearance would mean the skip had stopped working.
if (std.mem.eql(u8, object_kind, "commit")) {
try std.testing.expectEqualStrings("160000", mode);
for (files) |f| {
if (std.mem.eql(u8, path, f.filename)) return error.UnexpectedImportedSubmodule;
}
continue;
}
try std.testing.expectEqualStrings("blob", object_kind);
// An omitted mirror candidate is in Git's tree and must be in no
// imported tree. Asserted rather than skipped: a candidate that
// reappeared would otherwise pass as an ordinary matching file.
if (isOmitted(omitted, path)) {
for (files) |f| {
if (std.mem.eql(u8, path, f.filename)) return error.RetainedOmittedMirrorCandidate;
}
continue;
}
const found = for (files) |f| {
if (std.mem.eql(u8, path, f.filename)) break f;
} else return error.MissingImportedPath;
try std.testing.expectEqual(try fileMode(mode), found.mode);
const zit_hash = found.hash orelse return error.ImportedDeleteInGitTree;
var digest: [32]u8 = undefined;
const raw_hash = rawArtifactHash(zit_hash, &digest) orelse return error.InvalidZitHash;
const rid = log.lookup(raw_hash) orelse return error.MissingImportedBlob;
const zit_bytes = (try log.content(gpa, rid)) orelse return error.MissingImportedBlob;
defer gpa.free(zit_bytes);
const git_bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid });
defer gpa.free(git_bytes);
try std.testing.expectEqualSlices(u8, git_bytes, zit_bytes);
count += 1;
}
try std.testing.expectEqual(files.len, count);
}
fn compareMaterializedTree(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
destination: std.Io.Dir,
commit: []const u8,
omitted: []const []const u8,
) !void {
const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit });
defer gpa.free(listing);
var records = std.mem.splitScalar(u8, listing, 0);
while (records.next()) |record| {
if (record.len == 0) continue;
const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree;
var header = std.mem.splitScalar(u8, record[0..tab], ' ');
const mode = header.next() orelse return error.MalformedLsTree;
const object_kind = header.next() orelse return error.MalformedLsTree;
const blob_oid = header.next() orelse return error.MalformedLsTree;
const path = record[tab + 1 ..];
// Omitted gitlink: nothing may be materialized at that path.
if (std.mem.eql(u8, object_kind, "commit")) {
try std.testing.expectEqualStrings("160000", mode);
if (destination.statFile(io, path, .{ .follow_symlinks = false })) |_| {
return error.UnexpectedMaterializedSubmodule;
} else |err| switch (err) {
error.FileNotFound => continue,
else => return err,
}
}
try std.testing.expectEqualStrings("blob", object_kind);
// Omitted mirror candidate: nothing may be materialized at that path.
if (isOmitted(omitted, path)) {
if (destination.statFile(io, path, .{ .follow_symlinks = false })) |_| {
return error.MaterializedOmittedMirrorCandidate;
} else |err| switch (err) {
error.FileNotFound => continue,
else => return err,
}
}
const expected = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid });
defer gpa.free(expected);
const stat = try destination.statFile(io, path, .{ .follow_symlinks = false });
if (std.mem.eql(u8, mode, "120000")) {
try std.testing.expectEqual(.sym_link, stat.kind);
var target: [4096]u8 = undefined;
const len = try destination.readLink(io, path, &target);
try std.testing.expectEqualSlices(u8, expected, target[0..len]);
continue;
}
try std.testing.expectEqual(.file, stat.kind);
const actual = try destination.readFileAlloc(io, path, gpa, .limited(1 << 20));
defer gpa.free(actual);
try std.testing.expectEqualSlices(u8, expected, actual);
if (std.mem.eql(u8, mode, "100755") and comptime std.Io.File.Permissions.has_executable_bit) {
try std.testing.expect(stat.permissions.toMode() & 0o111 != 0);
}
if (std.mem.eql(u8, mode, "100644") and comptime std.Io.File.Permissions.has_executable_bit) {
try std.testing.expect(stat.permissions.toMode() & 0o111 == 0);
}
}
}
fn expectIdentity(expected: Identity, actual: MapIdentity) !void {
try std.testing.expectEqualStrings(expected.name, actual.name);
try std.testing.expectEqualStrings(expected.email, actual.email);
try std.testing.expectEqual(expected.epoch, actual.timestamp);
try std.testing.expectEqual(expected.offset_minutes, actual.timezone_offset_minutes);
}
fn writerTimestamp(epoch: i64, out: *[19]u8) ![]const u8 {
const days = @divFloor(epoch, @as(i64, 86400));
const rem = @mod(epoch, @as(i64, 86400));
const z = days + 719468;
const era = @divFloor(z, @as(i64, 146097));
const doe = z - era * 146097;
const yoe = @divFloor(doe - @divFloor(doe, @as(i64, 1460)) + @divFloor(doe, @as(i64, 36524)) - @divFloor(doe, @as(i64, 146096)), @as(i64, 365));
const doy = doe - (365 * yoe + @divFloor(yoe, @as(i64, 4)) - @divFloor(yoe, @as(i64, 100)));
const mp = @divFloor(5 * doy + 2, @as(i64, 153));
const day = doy - @divFloor(153 * mp + 2, @as(i64, 5)) + 1;
const month = if (mp < 10) mp + 3 else mp - 9;
const year = yoe + era * 400 + @as(i64, if (month <= 2) 1 else 0);
if (year < 0 or year > 9999) return error.InvalidTimestamp;
const put2 = struct {
fn put(slice: []u8, value: u8) void {
slice[0] = '0' + value / 10;
slice[1] = '0' + value % 10;
}
}.put;
put2(out[0..2], @intCast(@divFloor(year, @as(i64, 100))));
put2(out[2..4], @intCast(@mod(year, @as(i64, 100))));
out[4] = '-';
put2(out[5..7], @intCast(month));
out[7] = '-';
put2(out[8..10], @intCast(day));
out[10] = 'T';
put2(out[11..13], @intCast(@divFloor(rem, @as(i64, 3600))));
out[13] = ':';
put2(out[14..16], @intCast(@divFloor(@mod(rem, @as(i64, 3600)), @as(i64, 60))));
out[16] = ':';
put2(out[17..19], @intCast(@mod(rem, @as(i64, 60))));
return out;
}
fn findCommitMap(gpa: std.mem.Allocator, map_bytes: []const u8, oid: []const u8) ![]u8 {
var lines = std.mem.splitScalar(u8, map_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
const entry = parsed.value;
if (std.mem.eql(u8, entry.kind, "commit") and entry.oid != null and std.mem.eql(u8, entry.oid.?, oid)) {
return gpa.dupe(u8, entry.zit orelse return error.MissingCommitMapHash);
}
}
return error.MissingCommitMap;
}
fn mappedParents(gpa: std.mem.Allocator, map_bytes: []const u8, parents: []const u8) ![]u8 {
var result: std.ArrayList(u8) = .empty;
errdefer result.deinit(gpa);
var source_parents = std.mem.splitScalar(u8, parents, ' ');
while (source_parents.next()) |oid| {
if (oid.len == 0) continue;
const mapped = try findCommitMap(gpa, map_bytes, oid);
defer gpa.free(mapped);
if (result.items.len != 0) try result.append(gpa, ' ');
try result.appendSlice(gpa, mapped);
}
return result.toOwnedSlice(gpa);
}
/// Which Git identity `--attribution` selected for the Zit U and D cards. The
/// map's `author` and `committer` provenance rows carry both regardless, so
/// only the check-in comparison varies.
const Attribution = enum { committer, author };
fn assertCommitDifferential(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
log: *logstore.LogStore,
map_bytes: []const u8,
oid: []const u8,
attribution: Attribution,
omitted: []const []const u8,
) !void {
const mapped = try findCommitMap(gpa, map_bytes, oid);
defer gpa.free(mapped);
var git = try inspectGitCommit(gpa, io, cwd, oid);
defer git.deinit(gpa);
const attributed = switch (attribution) {
.committer => git.committer,
.author => git.author,
};
var checkin = try loadCheckin(gpa, log, mapped);
defer checkin.deinit(gpa);
// Named errors rather than `.?`: these three cards are parsed out of an
// artifact the importer wrote, and a check-in that reached the store without
// one is a defect this comparison should REPORT. `.?` reports it only while
// safety checks are on, and says nothing at all under `--release=fast`.
const comment_card = checkin.manifest.c orelse return error.MissingCommentCard;
const user_card = checkin.manifest.u orelse return error.MissingUserCard;
const date_card = checkin.manifest.d orelse return error.MissingDateCard;
try std.testing.expectEqualStrings(git.message, comment_card.comment);
try std.testing.expectEqualStrings(attributed.name, user_card.login);
var timestamp: [19]u8 = undefined;
try std.testing.expectEqualStrings(try writerTimestamp(attributed.epoch, ×tamp), date_card.timestamp);
const expected_parents = try mappedParents(gpa, map_bytes, git.parents);
defer gpa.free(expected_parents);
try std.testing.expectEqualStrings(expected_parents, if (checkin.manifest.p.len == 0) "" else (checkin.manifest.p[0].parents orelse ""));
try compareTree(gpa, io, cwd, log, oid, &checkin.manifest, omitted);
var lines = std.mem.splitScalar(u8, map_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
const entry = parsed.value;
if (!std.mem.eql(u8, entry.kind, "commit") or entry.oid == null or !std.mem.eql(u8, entry.oid.?, oid)) continue;
try std.testing.expectEqualStrings(mapped, entry.zit orelse return error.MissingCommitMapHash);
try expectIdentity(git.author, entry.author orelse return error.MissingAuthorProvenance);
try expectIdentity(git.committer, entry.committer orelse return error.MissingCommitterProvenance);
return;
}
return error.MissingCommitProvenance;
}
/// Every `ref` and `tag` row of `map_bytes` resolves, in `destination`, to the
/// commit Git says it names.
///
/// `minimum` is a non-vacuity floor and not a cosmetic count. This walks rows and
/// asserts nothing when there are none, so an import that stopped emitting ref
/// rows -- or a map read from the wrong place -- would satisfy it in silence. In
/// the mirror fixture this is the only ref statement made at all, which is
/// exactly the situation where a loop over an empty list must be an error.
/// `main`, `feature`, `octopus-a`, `octopus-b`, `v1`, `lightweight`.
const main_fixture_refs = 6;
/// `main`, `side`, `lookalike`, `mirror-light`, `mirror-v1`.
const mirror_fixture_refs = 5;
fn assertRefResolution(
gpa: std.mem.Allocator,
io: std.Io,
cwd: std.Io.Dir,
destination: std.Io.Dir,
map_bytes: []const u8,
minimum: usize,
) !void {
var resolved: usize = 0;
var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
var lines = std.mem.splitScalar(u8, map_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
const entry = parsed.value;
const target = if (std.mem.eql(u8, entry.kind, "ref"))
entry.zit
else if (std.mem.eql(u8, entry.kind, "tag"))
entry.target_zit
else
continue;
const source = entry.source orelse return error.MissingImportedRefSource;
const canonical = entry.canonical orelse return error.MissingCanonicalRef;
const mapped_target = target orelse return error.MissingImportedRefTarget;
const rev = try std.fmt.allocPrint(gpa, "{s}^{{commit}}", .{source});
defer gpa.free(rev);
const git_oid = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-parse", rev });
defer gpa.free(git_oid);
const expected = try findCommitMap(gpa, map_bytes, std.mem.trimEnd(u8, git_oid, "\n"));
defer gpa.free(expected);
try std.testing.expectEqualStrings(expected, mapped_target);
var update = try runZit(gpa, io, destination, &.{ "update", canonical });
defer freeResult(gpa, &update);
try expectExit(update, 0);
const current = try readCheckoutState(io, destination, &ckbuf);
try std.testing.expectEqualStrings(mapped_target, current.version);
resolved += 1;
}
if (resolved < minimum) {
std.debug.print("resolved {d} refs and tags, expected at least {d}\n", .{ resolved, minimum });
return error.TooFewResolvedRefs;
}
}
// ---------------------------------------------------------------------------
// Published-generation shape
// ---------------------------------------------------------------------------
/// The entries a published import destination holds. Spelled as literals
/// because `git_import_cli.zig` is not exported through `root.zig` and this
/// build root cannot import it; the source of truth for the set is
/// `git_import_cli.publish_manifest`.
// The --provenance publication set. This whole suite imports with the flag,
// because nearly every assertion here reads the map or the refs manifest; the
// DEFAULT set is pinned separately by `expectDefaultPublication` below.
const published_entries = [_][]const u8{ ".zit", ".zit-checkout", ".zit-import-map.jsonl", "refs" };
/// The default publication is the repository and its checkout record --
/// nothing else but the advisory cache the completion-time warm may add.
fn expectDefaultPublication(io: std.Io, destination: std.Io.Dir) !void {
// Reopened for iteration: the caller's handle may lack iterate rights,
// which Linux enforces at readdir time (macOS does not).
var it_dir = try destination.openDir(io, ".", .{ .iterate = true });
defer it_dir.close(io);
var entries = it_dir.iterate();
while (try entries.next(io)) |entry| {
if (std.mem.eql(u8, entry.name, ".zit")) continue;
if (std.mem.eql(u8, entry.name, ".zit-checkout")) continue;
if (std.mem.eql(u8, entry.name, ".zit-cache")) continue;
std.debug.print("unexpected default-publication entry: {s}\n", .{entry.name});
return error.UnexpectedPublishedEntry;
}
}
test "git import e2e: without --provenance the destination is the repository and its record, nothing else" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" });
try writeFile(io, tmp.dir, "source/a.txt", "plain content\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "one", "2001-02-03T04:05:06+0000", false);
var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "bare-dest" });
defer freeResult(gpa, &imported);
try expectExit(imported, 0);
var destination = try tmp.dir.openDir(io, "bare-dest", .{});
defer destination.close(io);
try expectDefaultPublication(io, destination);
}
/// Written AFTER publication by the completion-time cache warm, so it is a
/// legitimate resident of an imported repository but never a required one --
/// the warm is best-effort and a failure only costs the first reader a derive.
const optional_published_entries = [_][]const u8{".zit-cache"};
const raw_generation_entry = ".zit-import-raw";
/// A published destination is generation 2 and nothing else.
///
/// The raw generation is a file inside the same staging directory the rename
/// ships, so "the packed store was published" and "the raw store was not" are
/// two different facts. Naming the whole expected set proves the second one:
/// an unexpected extra entry fails here rather than surviving as dead weight
/// in every imported repository.
fn expectPublishedGeneration(io: std.Io, destination: std.Io.Dir) !void {
var seen: [published_entries.len]bool = @splat(false);
// Reopened for iteration: the caller's handle may lack iterate rights,
// which Linux enforces at readdir time (macOS does not).
var it_dir = try destination.openDir(io, ".", .{ .iterate = true });
defer it_dir.close(io);
var entries = it_dir.iterate();
while (try entries.next(io)) |entry| {
if (std.mem.eql(u8, entry.name, raw_generation_entry)) return error.RawGenerationPublished;
const optional = for (optional_published_entries) |name| {
if (std.mem.eql(u8, entry.name, name)) break true;
} else false;
if (optional) continue;
const index = for (published_entries, 0..) |name, position| {
if (std.mem.eql(u8, entry.name, name)) break position;
} else {
std.debug.print("unexpected published entry: {s}\n", .{entry.name});
return error.UnexpectedPublishedEntry;
};
if (seen[index]) return error.DuplicatePublishedEntry;
seen[index] = true;
}
for (seen, published_entries) |present, name| {
if (!present) {
std.debug.print("missing published entry: {s}\n", .{name});
return error.MissingPublishedEntry;
}
}
}
/// Whether the published store holds an artifact named for `content`.
///
/// `sha1` because `import-git` opens its destination under Git's hash policy,
/// which the main fixture pins by requiring a 40-character parent version.
///
/// Errors rather than answering `false` on a name it cannot decode. `false` is
/// what every drop-set assertion reads as "correctly dropped", so a helper that
/// returned it for an unaskable question would let all twenty absence proofs in
/// this file pass while proving nothing.
fn storeHolds(log: *logstore.LogStore, content: []const u8) !bool {
var name_buf: [64]u8 = undefined;
// The question is whether the IMPORT stored these bytes, so the name is
// computed under the import's algorithm. A fixed one asks about an address
// the store never used and answers "absent" for everything.
const name = artifact.artifactName(git_import.import_hash_algo, content, &name_buf);
var digest: [32]u8 = undefined;
const raw = rawArtifactHash(name, &digest) orelse return error.MalformedArtifactName;
return log.lookup(raw) != null;
}
/// The classifier's own account of one commit, read back out of the map.
///
/// Asserted alongside the store contents rather than instead of them: the map
/// says what the importer decided and the store says what the pack did, and a
/// mirror fixture is only meaningful when those two agree. `reason` is null
/// for an omission.
fn assertMirrorDecision(
gpa: std.mem.Allocator,
map_bytes: []const u8,
oid: []const u8,
decision: []const u8,
reason: ?[]const u8,
) !void {
var lines = std.mem.splitScalar(u8, map_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
const entry = parsed.value;
if (!std.mem.eql(u8, entry.kind, "commit")) continue;
if (entry.oid == null or !std.mem.eql(u8, entry.oid.?, oid)) continue;
const mirror = entry.mirror orelse return error.MissingMirrorProvenance;
// The reason is compared as an OPTIONAL, not through a `orelse ""`
// flattening: an expected `null` must not be satisfiable by a
// serialized empty string, which is a different fact -- "the classifier
// recorded no reason" versus "it recorded a blank one".
const reason_matches = if (reason) |expected|
mirror.reason != null and std.mem.eql(u8, expected, mirror.reason.?)
else
mirror.reason == null;
if (!std.mem.eql(u8, decision, mirror.decision) or !reason_matches) {
std.debug.print(
"{s}: expected mirror {s}/{s}, got {s}/{s}\n",
.{ oid, decision, reason orelse "null", mirror.decision, mirror.reason orelse "null" },
);
return error.UnexpectedMirrorDecision;
}
return;
}
return error.MissingMirrorProvenance;
}
fn expectStoreHolds(log: *logstore.LogStore, label: []const u8, content: []const u8, present: bool) !void {
if ((try storeHolds(log, content)) == present) return;
std.debug.print(
"{s}: expected {s} in the published store\n",
.{ label, if (present) "present" else "absent" },
);
return if (present) error.MissingRetainedArtifact else error.RetainedDroppedArtifact;
}
// ---------------------------------------------------------------------------
// Synthesizing a real Fossil→Git mirror
// ---------------------------------------------------------------------------
//
// `fossil git export` writes three root files beside every commit's tree:
// `manifest` is the Fossil check-in artifact with a sterilizing line appended,
// `manifest.uuid` is that artifact's name, `manifest.tags` is its branch/tag
// summary. Nothing short of building those bytes for real exercises the
// classifier through the public CLI: the pair is validated cryptographically,
// so a fixture that only looks like a mirror is retained and proves nothing.
//
// The names and the sterilizing line are BORROWED from `update` rather than
// retyped, for the same reason `git_import_impl` borrows them: the set this
// fixture writes must be exactly the set `zit update` writes and the
// classifier accepts.
const mirror_candidate_names = zit.update.manifest_paths;
const mirror_sterilizer = zit.update.sterilizer;
/// Well-formed per `mirrorTagsGrammar`: one `branch` line then one `tag` line.
const mirror_tags_content = "branch trunk\ntag trunk\n";
/// One entry of the tree a synthesized mirror check-in describes.
const MirrorFile = struct {
path: []const u8,
content: []const u8,
mode: artifact.FileMode = .regular,
};
/// A Git instant in both spellings this fixture needs: the value Git parses
/// out of `GIT_COMMITTER_DATE`, and the D card gate 7 compares it against.
const MirrorInstant = struct {
git: []const u8,
card: []const u8,
};
/// The two blobs a mirror writes for one commit, plus the check-in name that
/// ties them together and that this commit's children must cite as a parent.
const MirrorCheckin = struct {
uuid: []u8,
manifest_blob: []u8,
uuid_blob: []u8,
fn deinit(self: *MirrorCheckin, gpa: std.mem.Allocator) void {
gpa.free(self.uuid);
gpa.free(self.manifest_blob);
gpa.free(self.uuid_blob);
self.* = undefined;
}
};
fn mirrorPerm(mode: artifact.FileMode) ?[]const u8 {
return switch (mode) {
.regular => null,
.executable => "x",
.symlink => "l",
};
}
fn mirrorFileLess(_: void, a: MirrorFile, b: MirrorFile) bool {
return artifact.compareBytes(a.path, b.path) == .lt;
}
/// The `manifest`/`manifest.uuid` bytes for one commit.
///
/// `files` is the commit's tree WITHOUT the three candidate paths, which is
/// exactly the comparand gate 5 uses; `parents` are the already-validated
/// mirror UUIDs of the commit's ordered Git parents, which is what gate 6
/// requires; `instant` is its committer second, which is what gate 7 compares.
/// Every one of those is a fact the caller already fixed, so nothing here
/// reads the importer's output back.
fn buildMirrorCheckin(
gpa: std.mem.Allocator,
files: []const MirrorFile,
parents: []const []const u8,
instant: []const u8,
comment: []const u8,
) !MirrorCheckin {
const sorted = try gpa.dupe(MirrorFile, files);
defer gpa.free(sorted);
std.mem.sort(MirrorFile, sorted, {}, mirrorFileLess);
var manifest: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = comment },
.d = .{ .timestamp = instant },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "mirror" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer manifest.deinit();
const arena = manifest.arena_state.allocator();
const cards = try arena.alloc(artifact.FCard, sorted.len);
for (sorted, cards) |file, *card| {
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(.sha1, file.content, &name_buf);
card.* = .{
.filename = file.path,
.hash = try arena.dupe(u8, name),
.perm = mirrorPerm(file.mode),
.old_name = null,
.mode = file.mode,
};
}
manifest.f = cards;
if (parents.len != 0) {
const joined = try std.mem.join(arena, " ", parents);
const p = try arena.alloc(artifact.PCard, 1);
p[0] = .{ .parents = joined, .count = parents.len };
manifest.p = p;
}
const bytes = try artifact.create(&manifest, gpa);
defer gpa.free(bytes);
var uuid_buf: [64]u8 = undefined;
const uuid = artifact.artifactName(.sha1, bytes, &uuid_buf);
const owned_uuid = try gpa.dupe(u8, uuid);
errdefer gpa.free(owned_uuid);
const manifest_blob = try std.mem.concat(gpa, u8, &.{ bytes, mirror_sterilizer });
errdefer gpa.free(manifest_blob);
const uuid_blob = try std.fmt.allocPrint(gpa, "{s}\n", .{uuid});
return .{ .uuid = owned_uuid, .manifest_blob = manifest_blob, .uuid_blob = uuid_blob };
}
/// Writes the three candidate root files into the source worktree.
fn writeMirrorFiles(io: std.Io, source: std.Io.Dir, checkin: MirrorCheckin) !void {
try writeFile(io, source, mirror_candidate_names[0], checkin.manifest_blob);
try writeFile(io, source, mirror_candidate_names[1], checkin.uuid_blob);
try writeFile(io, source, mirror_candidate_names[2], mirror_tags_content);
}
test "git import e2e: real Git histories preserve flattened trees, topology, identities, refs, and materialization" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" });
var source = try tmp.dir.openDir(io, "source", .{});
defer source.close(io);
try source.createDir(io, "bin", .default_dir);
try source.createDir(io, "odd", .default_dir);
try writeFile(io, source, "README", "linear root\n");
try writeFile(io, source, "bin/run", "#!/bin/sh\necho zit\n");
try writeFile(io, source, "odd/name with space\tand-tab", "unusual path\n");
try source.symLink(io, "README", "README.link", .{});
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--chmod=+x", "bin/run" });
try gitCommit(gpa, io, tmp.dir, "linear root", "2001-02-03T04:05:06+0000", false);
try source.rename("README", source, "RENAMED", io);
try writeFile(io, source, "COPY", "linear root\n");
try source.deleteFile(io, "README.link");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "rename copy delete", "2001-02-03T04:06:06+0000", false);
try gitCommit(gpa, io, tmp.dir, "empty commit", "2001-02-03T04:07:06+0000", true);
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "feature" });
try writeFile(io, source, "feature.txt", "feature\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "feature", "2001-02-03T04:08:06+0000", false);
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
try writeFile(io, source, "main.txt", "main\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "main side", "2001-02-03T04:09:06+0000", false);
try gitMerge(gpa, io, tmp.dir, &.{"feature"}, "merge feature", "2001-02-03T04:09:30+0000");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-a" });
try writeFile(io, source, "octopus-a", "a\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "octopus a", "2001-02-03T04:10:06+0000", false);
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-b" });
try writeFile(io, source, "octopus-b", "b\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "octopus b", "2001-02-03T04:11:06+0000", false);
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
try gitMerge(gpa, io, tmp.dir, &.{ "octopus-a", "octopus-b" }, "octopus", "2001-02-03T04:11:30+0000");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "-a", "v1", "-m", "annotated tag" });
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "lightweight" });
var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "imported", "--submanifest-k", "2", "--provenance" });
defer freeResult(gpa, &imported);
try expectExit(imported, 0);
try expectNoImportTemps(io, tmp.dir);
try std.testing.expectEqualStrings("imported\n", imported.stdout);
try std.testing.expect(std.mem.indexOf(u8, imported.stderr, "import-git") != null);
// No commit here carries a Fossil mirror pair, so the drop set is empty --
// and the import still publishes a packed generation. Asserting the zero
// is what keeps "nothing was dropped" distinguishable from "the classifier
// never ran": every ordinary Git repository must land on this line.
if (std.mem.indexOf(u8, imported.stderr, "(dropped 0 mirror artifacts)") == null) {
std.debug.print("expected an empty drop set, stderr:\n{s}\n", .{imported.stderr});
return error.UnexpectedMirrorDropCount;
}
var destination = try tmp.dir.openDir(io, "imported", .{});
defer destination.close(io);
try expectPublishedGeneration(io, destination);
const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20));
defer gpa.free(map_bytes);
var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only });
defer store_file.close(io);
const stat = try store_file.stat(io);
var fs = storage.FileStorage.init(io, store_file, stat.size);
var log = try logstore.LogStore.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 one path per line (zit#170); take the first.
var first_line = std.mem.splitScalar(u8, ls.stdout, '\n');
const tracked = std.mem.trim(u8, first_line.next() orelse "", " \t\r");
try std.testing.expect(tracked.len != 0);
var f = try destination.openFile(io, tracked, .{ .mode = .read_write });
defer f.close(io);
const st = try f.stat(io);
_ = try f.writePositionalAll(io, "\n// 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.)
{
// -F oneline: the default shape marks the checkout's check-in
// *CURRENT*, which only the origin has.
var origin_timeline = try runZit(gpa, io, origin, &.{ "timeline", "-F", "oneline" });
defer freeResult(gpa, &origin_timeline);
try expectExit(origin_timeline, 0);
var clone_timeline = try runZit(gpa, io, clone_dir, &.{ "timeline", "-F", "oneline" });
defer freeResult(gpa, &clone_timeline);
try expectExit(clone_timeline, 0);
try std.testing.expectEqualStrings(origin_timeline.stdout, clone_timeline.stdout);
}
{
var verified = try runZit(gpa, io, clone_dir, &.{"verify"});
defer freeResult(gpa, &verified);
try expectExit(verified, 0);
}
// Pack fixpoint on the clone.
//
// The plan asked for byte identity between the clone and its own first
// pack. That is FALSE and it is false for a good reason: sync transfers
// artifact content, not the sender's delta chains, so a fresh clone is a
// faithful copy of the ORIGIN'S CONTENT and not of its packing. The first
// pack re-deltas and shrinks it, which is correct behavior, not a defect.
//
// The property that is actually true, and the one that matters, is that the
// clone reaches a fixpoint: pack it and pack it again, and the second run
// changes nothing -- byte for byte -- while the artifact set it holds is
// still the origin's. That is the same statement Task 5 makes about the
// imported generation, made about a store that arrived over a socket.
const cloned_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24));
defer gpa.free(cloned_bytes);
{
var first = try runZit(gpa, io, clone_dir, &.{"pack"});
defer freeResult(gpa, &first);
try expectExit(first, 0);
}
const packed_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24));
defer gpa.free(packed_bytes);
// The refutation, checked rather than asserted in prose: the clone's own
// first pack rewrites it and shrinks it. If this ever stops being true the
// fixpoint below turns vacuous, so the two assertions are a pair.
try std.testing.expect(!std.mem.eql(u8, cloned_bytes, packed_bytes));
try std.testing.expect(packed_bytes.len < cloned_bytes.len);
{
var second = try runZit(gpa, io, clone_dir, &.{"pack"});
defer freeResult(gpa, &second);
try expectExit(second, 0);
}
const repacked_bytes = try clone_dir.readFileAlloc(io, ".zit", gpa, .limited(1 << 24));
defer gpa.free(repacked_bytes);
try std.testing.expectEqualSlices(u8, packed_bytes, repacked_bytes);
// Packing kept every artifact the origin published, and the packed store
// still verifies.
{
const repacked_digests = try collectArtifactDigests(gpa, io, clone_dir);
defer freeDigests(gpa, repacked_digests);
try expectSameDigestSet(origin_digests, repacked_digests);
}
{
var verified = try runZit(gpa, io, clone_dir, &.{"verify"});
defer freeResult(gpa, &verified);
try expectExit(verified, 0);
}
}
test "git import e2e: failures are fail-closed and diagnostics name the rejected boundary" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Missing required destination is rejected by the CLI parser, before any
// filesystem publication can occur.
var absent_dest = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source-only", "--provenance" });
defer freeResult(gpa, &absent_dest);
try expectExit(absent_dest, 2);
try expectNoImportTemps(io, tmp.dir);
try std.testing.expectEqualStrings("zit import-git: invalid arguments (MissingDestination); see 'zit import-git --help'\n", absent_dest.stderr);
try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
var source = try tmp.dir.openDir(io, "source", .{});
defer source.close(io);
try writeFile(io, source, "tracked", "tracked\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false);
// A shallow clone cannot establish complete parent topology; it must fail
// before publishing a destination, not truncate history silently.
try gitOk(gpa, io, tmp.dir, &.{ "clone", "--depth", "1", "--no-local", "source", "shallow" });
var shallow = try runZit(gpa, io, tmp.dir, &.{ "import-git", "shallow", "shallow-dest", "--provenance" });
defer freeResult(gpa, &shallow);
try expectFailure(shallow);
try expectNoImportTemps(io, tmp.dir);
try std.testing.expectEqualStrings("import-git: cannot start git fast-export: ShallowRepository\n", shallow.stderr);
try expectNoDirectory(io, tmp.dir, "shallow-dest");
}
test "git import e2e: a submodule entry is omitted and the rest of the tree imports" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
var source = try tmp.dir.openDir(io, "source", .{});
defer source.close(io);
try writeFile(io, source, "tracked", "tracked\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false);
// Fossil has no submodule concept, so the importer drops the gitlink and
// imports the history around it. Refusing the whole repository withheld
// histories that are otherwise entirely representable, which is why the
// `beside` file committed alongside the gitlink must still arrive.
const head = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "HEAD" });
defer gpa.free(head);
const cacheinfo = try std.fmt.allocPrint(gpa, "160000,{s},nested", .{std.mem.trimEnd(u8, head, "\n")});
defer gpa.free(cacheinfo);
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--add", "--cacheinfo", cacheinfo });
try writeFile(io, source, "beside", "beside\n");
try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "beside" });
try gitCommit(gpa, io, tmp.dir, "gitlink", "2001-02-03T04:06:06+0000", false);
var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "gitlink-dest", "--provenance" });
defer freeResult(gpa, &imported);
try expectExit(imported, 0);
try expectNoImportTemps(io, tmp.dir);
var destination = try tmp.dir.openDir(io, "gitlink-dest", .{});
defer destination.close(io);
const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20));
defer gpa.free(map_bytes);
var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only });
defer store_file.close(io);
const store_stat = try store_file.stat(io);
var fs = storage.FileStorage.init(io, store_file, store_stat.size);
var log = try logstore.LogStore.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 raw tag visible on one check-in, compared verbatim: `zit tag list
/// --raw CHECKIN` prints `NAME VALUE` for a valued tag and a bare `NAME` for a
/// valueless one, ascending by raw name. A whole-listing comparison is the
/// point — a cancelled tag is proved by the line that is NOT there, which a
/// contains-check would miss.
fn expectTagListing(
gpa: std.mem.Allocator,
io: std.Io,
destination: std.Io.Dir,
hash: []const u8,
expected: []const u8,
) !void {
var listing = try runZit(gpa, io, destination, &.{ "tag", "list", "--raw", hash });
defer freeResult(gpa, &listing);
try expectExit(listing, 0);
try std.testing.expectEqualStrings(expected, listing.stdout);
}
/// Opens a freshly imported destination and hands back its provenance map.
/// The caller frees the returned bytes and closes the directory.
fn importForTopology(
gpa: std.mem.Allocator,
io: std.Io,
tmp: std.Io.Dir,
destination: *std.Io.Dir,
) ![]u8 {
var imported = try runZit(gpa, io, tmp, &.{ "import-git", "source", "imported", "--provenance" });
defer freeResult(gpa, &imported);
try expectExit(imported, 0);
destination.* = try tmp.openDir(io, "imported", .{});
errdefer destination.close(io);
return destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20));
}
/// The four `git init` incantations every shape below starts from, pinned so a
/// developer's own Git configuration cannot change what the oracle reports.
fn initSourceRepo(gpa: std.mem.Allocator, io: std.Io, tmp: std.Io.Dir) !void {
try gitOk(gpa, io, tmp, &.{ "init", "source" });
try gitOk(gpa, io, tmp, &.{ "-C", "source", "branch", "-M", "main" });
try gitOk(gpa, io, tmp, &.{ "-C", "source", "config", "user.name", "Git Committer" });
try gitOk(gpa, io, tmp, &.{ "-C", "source", "config", "user.email", "[email protected]" });
}
test "git import e2e 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\nsym-trunk\n");
const main_tip = try mappedRevision(gpa, io, tmp.dir, map_bytes, "main");
defer gpa.free(main_tip);
try expectTagListing(gpa, io, destination, main_tip, "branch trunk\nsym-heads/main\nsym-trunk\n");
const feat1_root = try mappedRevision(gpa, io, tmp.dir, map_bytes, "feat1");
defer gpa.free(feat1_root);
try expectTagListing(gpa, io, destination, feat1_root, "branch feat1\nsym-feat1\nsym-heads/feat1\n");
}
test "git import e2e 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;
};
}
}