Skip to content
zit Artifact [2b858153de]
Login

Artifact [2b858153de]

Artifact 2b858153de29cda58b19bb7a34c78abcd97db2da831bd95105f38d94cc42093d:


//! Real-Git differential tests for `zit import-git`.
//!
//! This suite deliberately builds ordinary Git repositories rather than feeding a
//! hand-written fast-export stream to the importer.  Git is the oracle for tree
//! shape, blob bytes, parent order, identities, commit messages, and refs; Zit
//! is read back through its public storage and artifact APIs.  The suite is an
//! opt-in build step because it starts Git and the compiled Zit executable.

const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const zit = @import("zit");

const artifact = zit.artifact;
const checkout = zit.checkout;
const logstore = zit.logstore;
const storage = zit.storage;

const zit_exe: []const u8 = build_options.zit_exe;

const Identity = struct {
    name: []const u8,
    email: []const u8,
    epoch: i64,
    offset_minutes: i16,
};

const MapIdentity = struct {
    name: []const u8,
    email: []const u8,
    timestamp: i64,
    timezone_offset_minutes: i16,
};

const MapEntry = struct {
    kind: []const u8,
    oid: ?[]const u8 = null,
    zit: ?[]const u8 = null,
    source: ?[]const u8 = null,
    canonical: ?[]const u8 = null,
    author: ?MapIdentity = null,
    committer: ?MapIdentity = null,
    warning: ?[]const u8 = null,
    target_zit: ?[]const u8 = null,
};

const Checkin = struct {
    bytes: []u8,
    manifest: artifact.Manifest,

    fn deinit(self: *Checkin, gpa: std.mem.Allocator) void {
        self.manifest.deinit();
        gpa.free(self.bytes);
        self.* = undefined;
    }
};

const LoadedManifest = struct {
    hash: []u8,
    bytes: []u8,
    manifest: artifact.Manifest,

    fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void {
        gpa.free(self.hash);
        self.manifest.deinit();
        gpa.free(self.bytes);
    }
};

fn rawArtifactHash(name: []const u8, buffer: *[32]u8) ?[]const u8 {
    if (name.len != 40 and name.len != 64) return null;
    const raw = buffer[0 .. name.len / 2];
    _ = std.fmt.hexToBytes(raw, name) catch return null;
    return raw;
}

/// Keeps every resolver result alive until `flattenTree` completes.  The
/// artifact API intentionally borrows slices from its resolver, so freeing a
/// submanifest eagerly would make the differential oracle unsound.
const TreeCache = struct {
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    loaded: std.ArrayList(LoadedManifest) = .empty,

    fn deinit(self: *TreeCache) void {
        for (self.loaded.items) |*item| item.deinit(self.gpa);
        self.loaded.deinit(self.gpa);
        self.* = undefined;
    }

    fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.SubManifestView {
        const self: *TreeCache = @ptrCast(@alignCast(ctx));
        for (self.loaded.items) |*item| {
            if (std.mem.eql(u8, item.hash, hash)) {
                return .{ .files = item.manifest.f, .subs = item.manifest.s };
            }
        }

        var digest: [32]u8 = undefined;
        const raw_hash = rawArtifactHash(hash, &digest) orelse return null;
        const rid = self.log.lookup(raw_hash) orelse return null;
        const bytes = (self.log.content(self.gpa, rid) catch return null) orelse return null;
        errdefer self.gpa.free(bytes);
        var manifest = artifact.parse(self.gpa, bytes) catch return null;
        errdefer manifest.deinit();
        const owned_hash = self.gpa.dupe(u8, hash) catch return null;
        errdefer self.gpa.free(owned_hash);
        self.loaded.append(self.gpa, .{ .hash = owned_hash, .bytes = bytes, .manifest = manifest }) catch return null;
        const item = &self.loaded.items[self.loaded.items.len - 1];
        return .{ .files = item.manifest.f, .subs = item.manifest.s };
    }
};

fn freeResult(gpa: std.mem.Allocator, result: *std.process.RunResult) void {
    gpa.free(result.stdout);
    gpa.free(result.stderr);
}

fn expectExit(result: std.process.RunResult, expected: u8) !void {
    switch (result.term) {
        .exited => |actual| if (actual == expected) return else {
            std.debug.print("expected exit {d}, got {d}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, actual, result.stdout, result.stderr });
            return error.UnexpectedExit;
        },
        else => {
            std.debug.print("expected exit {d}, got {any}\nstdout:\n{s}\nstderr:\n{s}\n", .{ expected, result.term, result.stdout, result.stderr });
            return error.UnexpectedTermination;
        },
    }
}

fn expectFailure(result: std.process.RunResult) !void {
    try expectExit(result, 1);
}

fn expectNoDirectory(io: std.Io, parent: std.Io.Dir, path: []const u8) !void {
    var dir = parent.openDir(io, path, .{}) catch return;
    dir.close(io);
    return error.UnexpectedPublishedDestination;
}

fn runCommand(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    argv: []const []const u8,
    env: ?*std.process.Environ.Map,
) !std.process.RunResult {
    return std.process.run(gpa, io, .{
        .argv = argv,
        .cwd = .{ .dir = cwd },
        .environ_map = env,
    });
}

fn expectNoImportTemps(io: std.Io, parent: std.Io.Dir) !void {
    var entries = parent.iterate();
    while (try entries.next(io)) |entry| {
        try std.testing.expect(!std.mem.startsWith(u8, entry.name, ".zit-import-tmp-"));
    }
}

fn initGitEnv(gpa: std.mem.Allocator) !std.process.Environ.Map {
    var env = std.process.Environ.Map.init(gpa);
    errdefer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-git-import-e2e-no-home");
    try env.put("PATH", "/usr/bin:/bin");
    try env.put("LC_ALL", "C");
    try env.put("LANG", "C");
    try env.put("GIT_CONFIG_NOSYSTEM", "1");
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_AUTHOR_NAME", "Git Author");
    try env.put("GIT_AUTHOR_EMAIL", "[email protected]");
    try env.put("GIT_COMMITTER_NAME", "Git Committer");
    try env.put("GIT_COMMITTER_EMAIL", "[email protected]");
    try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000");
    try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000");
    return env;
}

fn runGit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult {
    var argv: [16][]const u8 = undefined;
    std.debug.assert(args.len + 1 <= argv.len);
    argv[0] = "git";
    for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg;
    var env = try initGitEnv(gpa);
    defer env.deinit();
    return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env);
}

fn gitOk(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !void {
    var result = try runGit(gpa, io, cwd, args);
    defer freeResult(gpa, &result);
    try expectExit(result, 0);
}

fn gitCommit(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    message: []const u8,
    epoch: []const u8,
    allow_empty: bool,
) !void {
    var env = try initGitEnv(gpa);
    defer env.deinit();
    try env.put("GIT_AUTHOR_DATE", epoch);
    try env.put("GIT_COMMITTER_DATE", epoch);

    var argv: [9][]const u8 = .{ "git", "-C", "source", "commit", "--no-gpg-sign", "-m", message, undefined, undefined };
    const len: usize = if (allow_empty) blk: {
        argv[7] = "--allow-empty";
        break :blk 8;
    } else 7;
    var result = try runCommand(gpa, io, cwd, argv[0..len], &env);
    defer freeResult(gpa, &result);
    try expectExit(result, 0);
}

fn gitMerge(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    branches: []const []const u8,
    message: []const u8,
    epoch: []const u8,
) !void {
    var env = try initGitEnv(gpa);
    defer env.deinit();
    try env.put("GIT_AUTHOR_DATE", epoch);
    try env.put("GIT_COMMITTER_DATE", epoch);
    var argv: [16][]const u8 = undefined;
    const prefix = [_][]const u8{ "git", "-C", "source", "merge", "--no-ff", "-m", message };
    for (prefix, argv[0..prefix.len]) |value, *slot| slot.* = value;
    std.debug.assert(prefix.len + branches.len <= argv.len);
    for (branches, argv[prefix.len..][0..branches.len]) |branch, *slot| slot.* = branch;
    var result = try runCommand(gpa, io, cwd, argv[0 .. prefix.len + branches.len], &env);
    defer freeResult(gpa, &result);
    try expectExit(result, 0);
}

fn runZit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) !std.process.RunResult {
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-git-import-e2e-no-home");
    try env.put("PATH", "/usr/bin:/bin");

    const base = try std.process.currentPathAlloc(io, gpa);
    defer gpa.free(base);
    const executable = try std.fs.path.resolve(gpa, &.{ base, zit_exe });
    defer gpa.free(executable);

    var argv: [16][]const u8 = undefined;
    std.debug.assert(args.len + 1 <= argv.len);
    argv[0] = executable;
    for (args, argv[1..][0..args.len]) |arg, *slot| slot.* = arg;
    return runCommand(gpa, io, cwd, argv[0 .. args.len + 1], &env);
}

fn writeFile(io: std.Io, dir: std.Io.Dir, path: []const u8, bytes: []const u8) !void {
    var file = try dir.createFile(io, path, .{});
    defer file.close(io);
    try file.writePositionalAll(io, bytes, 0);
}

fn gitOutput(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, args: []const []const u8) ![]u8 {
    var result = try runGit(gpa, io, cwd, args);
    defer freeResult(gpa, &result);
    try expectExit(result, 0);
    return gpa.dupe(u8, result.stdout);
}

fn parseIdentity(line: []const u8, prefix: []const u8) !Identity {
    if (!std.mem.startsWith(u8, line, prefix)) return error.MalformedGitIdentity;
    const rest = line[prefix.len..];
    const close = std.mem.lastIndexOfScalar(u8, rest, '>') orelse return error.MalformedGitIdentity;
    if (close + 2 >= rest.len or rest[close + 1] != ' ') return error.MalformedGitIdentity;
    const open = std.mem.lastIndexOfScalar(u8, rest[0..close], '<') orelse return error.MalformedGitIdentity;
    if (open == 0 or rest[open - 1] != ' ') return error.MalformedGitIdentity;
    var tail = std.mem.splitScalar(u8, rest[close + 2 ..], ' ');
    const epoch = try std.fmt.parseInt(i64, tail.next() orelse return error.MalformedGitIdentity, 10);
    const zone = tail.next() orelse return error.MalformedGitIdentity;
    if (zone.len != 5 or (zone[0] != '+' and zone[0] != '-')) return error.MalformedGitIdentity;
    const minutes = try std.fmt.parseInt(i16, zone[1..3], 10) * 60 + try std.fmt.parseInt(i16, zone[3..5], 10);
    return .{
        .name = rest[0 .. open - 1],
        .email = rest[open + 1 .. close],
        .epoch = epoch,
        .offset_minutes = if (zone[0] == '-') -minutes else minutes,
    };
}

const GitCommit = struct {
    bytes: []u8,
    parents: []u8,
    author: Identity,
    committer: Identity,
    message: []const u8,

    fn deinit(self: *GitCommit, gpa: std.mem.Allocator) void {
        gpa.free(self.parents);
        gpa.free(self.bytes);
        self.* = undefined;
    }
};

fn inspectGitCommit(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, oid: []const u8) !GitCommit {
    const bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "commit", oid });
    errdefer gpa.free(bytes);
    const header_end = std.mem.indexOf(u8, bytes, "\n\n") orelse return error.MalformedGitCommit;
    const headers = bytes[0..header_end];
    var parents: std.ArrayList(u8) = .empty;
    errdefer parents.deinit(gpa);
    var author: ?Identity = null;
    var committer: ?Identity = null;
    var lines = std.mem.splitScalar(u8, headers, '\n');
    while (lines.next()) |line| {
        if (std.mem.startsWith(u8, line, "parent ")) {
            if (parents.items.len != 0) try parents.append(gpa, ' ');
            try parents.appendSlice(gpa, line[7..]);
        } else if (std.mem.startsWith(u8, line, "author ")) author = try parseIdentity(line, "author ") else if (std.mem.startsWith(u8, line, "committer ")) committer = try parseIdentity(line, "committer ");
    }
    return .{
        .bytes = bytes,
        .parents = try parents.toOwnedSlice(gpa),
        .author = author orelse return error.MalformedGitCommit,
        .committer = committer orelse return error.MalformedGitCommit,
        .message = bytes[header_end + 2 ..],
    };
}

fn loadCheckin(gpa: std.mem.Allocator, log: *logstore.LogStore, hash: []const u8) !Checkin {
    var digest: [32]u8 = undefined;
    const raw_hash = rawArtifactHash(hash, &digest) orelse return error.InvalidZitHash;
    const rid = log.lookup(raw_hash) orelse return error.MissingZitCheckin;
    const bytes = (try log.content(gpa, rid)) orelse return error.MissingZitCheckin;
    errdefer gpa.free(bytes);
    return .{ .bytes = bytes, .manifest = try artifact.parse(gpa, bytes) };
}

fn flattenedFiles(manifest: *artifact.Manifest, cache: *TreeCache) ![]const artifact.FCard {
    if (manifest.s.len == 0) return manifest.f;
    try manifest.flattenTree(.{ .ctx = cache, .resolveFn = TreeCache.resolve });
    return manifest.flattened.?;
}

fn fileMode(mode: []const u8) !artifact.FileMode {
    if (std.mem.eql(u8, mode, "100644")) return .regular;
    if (std.mem.eql(u8, mode, "100755")) return .executable;
    if (std.mem.eql(u8, mode, "120000")) return .symlink;
    return error.UnrepresentableGitMode;
}

fn compareTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    log: *logstore.LogStore,
    commit: []const u8,
    checkin: *artifact.Manifest,
) !void {
    var cache = TreeCache{ .gpa = gpa, .log = log };
    defer cache.deinit();
    const files = try flattenedFiles(checkin, &cache);
    const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit });
    defer gpa.free(listing);

    var count: usize = 0;
    var records = std.mem.splitScalar(u8, listing, 0);
    while (records.next()) |record| {
        if (record.len == 0) continue;
        const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree;
        var header = std.mem.splitScalar(u8, record[0..tab], ' ');
        const mode = header.next() orelse return error.MalformedLsTree;
        const object_kind = header.next() orelse return error.MalformedLsTree;
        const blob_oid = header.next() orelse return error.MalformedLsTree;
        const path = record[tab + 1 ..];
        // A gitlink is a `commit` entry in Git's tree.  The importer omits it
        // deliberately, so the imported manifest must not name that path at
        // all.  Assert the absence rather than tolerating it: a silent
        // reappearance would mean the skip had stopped working.
        if (std.mem.eql(u8, object_kind, "commit")) {
            try std.testing.expectEqualStrings("160000", mode);
            for (files) |f| {
                if (std.mem.eql(u8, path, f.filename)) return error.UnexpectedImportedSubmodule;
            }
            continue;
        }
        try std.testing.expectEqualStrings("blob", object_kind);
        const found = for (files) |f| {
            if (std.mem.eql(u8, path, f.filename)) break f;
        } else return error.MissingImportedPath;
        try std.testing.expectEqual(try fileMode(mode), found.mode);
        const zit_hash = found.hash orelse return error.ImportedDeleteInGitTree;
        var digest: [32]u8 = undefined;
        const raw_hash = rawArtifactHash(zit_hash, &digest) orelse return error.InvalidZitHash;
        const rid = log.lookup(raw_hash) orelse return error.MissingImportedBlob;
        const zit_bytes = (try log.content(gpa, rid)) orelse return error.MissingImportedBlob;
        defer gpa.free(zit_bytes);
        const git_bytes = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid });
        defer gpa.free(git_bytes);
        try std.testing.expectEqualSlices(u8, git_bytes, zit_bytes);
        count += 1;
    }
    try std.testing.expectEqual(files.len, count);
}

fn compareMaterializedTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    destination: std.Io.Dir,
    commit: []const u8,
) !void {
    const listing = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "ls-tree", "-rz", "--full-tree", commit });
    defer gpa.free(listing);

    var records = std.mem.splitScalar(u8, listing, 0);
    while (records.next()) |record| {
        if (record.len == 0) continue;
        const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedLsTree;
        var header = std.mem.splitScalar(u8, record[0..tab], ' ');
        const mode = header.next() orelse return error.MalformedLsTree;
        const object_kind = header.next() orelse return error.MalformedLsTree;
        const blob_oid = header.next() orelse return error.MalformedLsTree;
        const path = record[tab + 1 ..];
        // Omitted gitlink: nothing may be materialized at that path.
        if (std.mem.eql(u8, object_kind, "commit")) {
            try std.testing.expectEqualStrings("160000", mode);
            if (destination.statFile(io, path, .{ .follow_symlinks = false })) |_| {
                return error.UnexpectedMaterializedSubmodule;
            } else |err| switch (err) {
                error.FileNotFound => continue,
                else => return err,
            }
        }
        try std.testing.expectEqualStrings("blob", object_kind);
        const expected = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "cat-file", "blob", blob_oid });
        defer gpa.free(expected);
        const stat = try destination.statFile(io, path, .{ .follow_symlinks = false });
        if (std.mem.eql(u8, mode, "120000")) {
            try std.testing.expectEqual(.sym_link, stat.kind);
            var target: [4096]u8 = undefined;
            const len = try destination.readLink(io, path, &target);
            try std.testing.expectEqualSlices(u8, expected, target[0..len]);
            continue;
        }
        try std.testing.expectEqual(.file, stat.kind);
        const actual = try destination.readFileAlloc(io, path, gpa, .limited(1 << 20));
        defer gpa.free(actual);
        try std.testing.expectEqualSlices(u8, expected, actual);
        if (std.mem.eql(u8, mode, "100755") and comptime std.Io.File.Permissions.has_executable_bit) {
            try std.testing.expect(stat.permissions.toMode() & 0o111 != 0);
        }
        if (std.mem.eql(u8, mode, "100644") and comptime std.Io.File.Permissions.has_executable_bit) {
            try std.testing.expect(stat.permissions.toMode() & 0o111 == 0);
        }
    }
}

fn expectIdentity(expected: Identity, actual: MapIdentity) !void {
    try std.testing.expectEqualStrings(expected.name, actual.name);
    try std.testing.expectEqualStrings(expected.email, actual.email);
    try std.testing.expectEqual(expected.epoch, actual.timestamp);
    try std.testing.expectEqual(expected.offset_minutes, actual.timezone_offset_minutes);
}

fn writerTimestamp(epoch: i64, out: *[19]u8) ![]const u8 {
    const days = @divFloor(epoch, @as(i64, 86400));
    const rem = @mod(epoch, @as(i64, 86400));
    const z = days + 719468;
    const era = @divFloor(z, @as(i64, 146097));
    const doe = z - era * 146097;
    const yoe = @divFloor(doe - @divFloor(doe, @as(i64, 1460)) + @divFloor(doe, @as(i64, 36524)) - @divFloor(doe, @as(i64, 146096)), @as(i64, 365));
    const doy = doe - (365 * yoe + @divFloor(yoe, @as(i64, 4)) - @divFloor(yoe, @as(i64, 100)));
    const mp = @divFloor(5 * doy + 2, @as(i64, 153));
    const day = doy - @divFloor(153 * mp + 2, @as(i64, 5)) + 1;
    const month = if (mp < 10) mp + 3 else mp - 9;
    const year = yoe + era * 400 + @as(i64, if (month <= 2) 1 else 0);
    if (year < 0 or year > 9999) return error.InvalidTimestamp;
    const put2 = struct {
        fn put(slice: []u8, value: u8) void {
            slice[0] = '0' + value / 10;
            slice[1] = '0' + value % 10;
        }
    }.put;
    put2(out[0..2], @intCast(@divFloor(year, @as(i64, 100))));
    put2(out[2..4], @intCast(@mod(year, @as(i64, 100))));
    out[4] = '-';
    put2(out[5..7], @intCast(month));
    out[7] = '-';
    put2(out[8..10], @intCast(day));
    out[10] = 'T';
    put2(out[11..13], @intCast(@divFloor(rem, @as(i64, 3600))));
    out[13] = ':';
    put2(out[14..16], @intCast(@divFloor(@mod(rem, @as(i64, 3600)), @as(i64, 60))));
    out[16] = ':';
    put2(out[17..19], @intCast(@mod(rem, @as(i64, 60))));
    return out;
}

fn findCommitMap(gpa: std.mem.Allocator, map_bytes: []const u8, oid: []const u8) ![]u8 {
    var lines = std.mem.splitScalar(u8, map_bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
        defer parsed.deinit();
        const entry = parsed.value;
        if (std.mem.eql(u8, entry.kind, "commit") and entry.oid != null and std.mem.eql(u8, entry.oid.?, oid)) {
            return gpa.dupe(u8, entry.zit orelse return error.MissingCommitMapHash);
        }
    }
    return error.MissingCommitMap;
}

fn mappedParents(gpa: std.mem.Allocator, map_bytes: []const u8, parents: []const u8) ![]u8 {
    var result: std.ArrayList(u8) = .empty;
    errdefer result.deinit(gpa);
    var source_parents = std.mem.splitScalar(u8, parents, ' ');
    while (source_parents.next()) |oid| {
        if (oid.len == 0) continue;
        const mapped = try findCommitMap(gpa, map_bytes, oid);
        defer gpa.free(mapped);
        if (result.items.len != 0) try result.append(gpa, ' ');
        try result.appendSlice(gpa, mapped);
    }
    return result.toOwnedSlice(gpa);
}
fn assertCommitDifferential(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    log: *logstore.LogStore,
    map_bytes: []const u8,
    oid: []const u8,
) !void {
    const mapped = try findCommitMap(gpa, map_bytes, oid);
    defer gpa.free(mapped);
    var git = try inspectGitCommit(gpa, io, cwd, oid);
    defer git.deinit(gpa);

    var checkin = try loadCheckin(gpa, log, mapped);
    defer checkin.deinit(gpa);
    try std.testing.expectEqualStrings(git.message, checkin.manifest.c.?.comment);
    try std.testing.expectEqualStrings(git.committer.name, checkin.manifest.u.?.login);
    var timestamp: [19]u8 = undefined;
    try std.testing.expectEqualStrings(try writerTimestamp(git.committer.epoch, &timestamp), checkin.manifest.d.?.timestamp);
    const expected_parents = try mappedParents(gpa, map_bytes, git.parents);
    defer gpa.free(expected_parents);
    try std.testing.expectEqualStrings(expected_parents, if (checkin.manifest.p.len == 0) "" else (checkin.manifest.p[0].parents orelse ""));
    try compareTree(gpa, io, cwd, log, oid, &checkin.manifest);

    var lines = std.mem.splitScalar(u8, map_bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
        defer parsed.deinit();
        const entry = parsed.value;
        if (!std.mem.eql(u8, entry.kind, "commit") or entry.oid == null or !std.mem.eql(u8, entry.oid.?, oid)) continue;
        try std.testing.expectEqualStrings(mapped, entry.zit orelse return error.MissingCommitMapHash);
        try expectIdentity(git.author, entry.author orelse return error.MissingAuthorProvenance);
        try expectIdentity(git.committer, entry.committer orelse return error.MissingCommitterProvenance);
        return;
    }
    return error.MissingCommitProvenance;
}

fn assertRefResolution(
    gpa: std.mem.Allocator,
    io: std.Io,
    cwd: std.Io.Dir,
    destination: std.Io.Dir,
    map_bytes: []const u8,
) !void {
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    var lines = std.mem.splitScalar(u8, map_bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var parsed = try std.json.parseFromSlice(MapEntry, gpa, line, .{ .ignore_unknown_fields = true });
        defer parsed.deinit();
        const entry = parsed.value;
        const target = if (std.mem.eql(u8, entry.kind, "ref"))
            entry.zit
        else if (std.mem.eql(u8, entry.kind, "tag"))
            entry.target_zit
        else
            continue;
        const source = entry.source orelse return error.MissingImportedRefSource;
        const canonical = entry.canonical orelse return error.MissingCanonicalRef;
        const mapped_target = target orelse return error.MissingImportedRefTarget;
        const rev = try std.fmt.allocPrint(gpa, "{s}^{{commit}}", .{source});
        defer gpa.free(rev);
        const git_oid = try gitOutput(gpa, io, cwd, &.{ "-C", "source", "rev-parse", rev });
        defer gpa.free(git_oid);
        const expected = try findCommitMap(gpa, map_bytes, std.mem.trimEnd(u8, git_oid, "\n"));
        defer gpa.free(expected);
        try std.testing.expectEqualStrings(expected, mapped_target);

        var update = try runZit(gpa, io, destination, &.{ "update", canonical });
        defer freeResult(gpa, &update);
        try expectExit(update, 0);
        const current = (try checkout.read(io, destination, &ckbuf)).?;
        try std.testing.expectEqualStrings(mapped_target, current.version);
    }
}

test "git import e2e: real Git histories preserve flattened trees, topology, identities, refs, and materialization" {
    if (builtin.os.tag == .windows) return error.SkipZigTest;
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "branch", "-M", "main" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.name", "Git Committer" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "config", "user.email", "[email protected]" });
    var source = try tmp.dir.openDir(io, "source", .{});
    defer source.close(io);
    try source.createDir(io, "bin", .default_dir);
    try source.createDir(io, "odd", .default_dir);
    try writeFile(io, source, "README", "linear root\n");
    try writeFile(io, source, "bin/run", "#!/bin/sh\necho zit\n");
    try writeFile(io, source, "odd/name with space\tand-tab", "unusual path\n");
    try source.symLink(io, "README", "README.link", .{});
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--chmod=+x", "bin/run" });
    try gitCommit(gpa, io, tmp.dir, "linear root", "2001-02-03T04:05:06+0000", false);

    try source.rename("README", source, "RENAMED", io);
    try writeFile(io, source, "COPY", "linear root\n");
    try source.deleteFile(io, "README.link");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "rename copy delete", "2001-02-03T04:06:06+0000", false);
    try gitCommit(gpa, io, tmp.dir, "empty commit", "2001-02-03T04:07:06+0000", true);

    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "feature" });
    try writeFile(io, source, "feature.txt", "feature\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "feature", "2001-02-03T04:08:06+0000", false);
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
    try writeFile(io, source, "main.txt", "main\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "main side", "2001-02-03T04:09:06+0000", false);
    try gitMerge(gpa, io, tmp.dir, &.{"feature"}, "merge feature", "2001-02-03T04:09:30+0000");

    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-a" });
    try writeFile(io, source, "octopus-a", "a\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "octopus a", "2001-02-03T04:10:06+0000", false);
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "-b", "octopus-b" });
    try writeFile(io, source, "octopus-b", "b\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "octopus b", "2001-02-03T04:11:06+0000", false);
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "checkout", "main" });
    try gitMerge(gpa, io, tmp.dir, &.{ "octopus-a", "octopus-b" }, "octopus", "2001-02-03T04:11:30+0000");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "-a", "v1", "-m", "annotated tag" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "tag", "lightweight" });

    var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "imported", "--submanifest-k", "2" });
    defer freeResult(gpa, &imported);
    try expectExit(imported, 0);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expectEqualStrings("imported\n", imported.stdout);
    try std.testing.expect(std.mem.indexOf(u8, imported.stderr, "import-git") != null);

    var destination = try tmp.dir.openDir(io, "imported", .{});
    defer destination.close(io);
    const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20));
    defer gpa.free(map_bytes);
    var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only });
    defer store_file.close(io);
    const stat = try store_file.stat(io);
    var fs = storage.FileStorage.init(io, store_file, stat.size);
    var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
    defer log.deinit(gpa);

    const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" });
    defer gpa.free(commits);
    var commit_lines = std.mem.splitScalar(u8, commits, '\n');
    while (commit_lines.next()) |oid| if (oid.len != 0) try assertCommitDifferential(gpa, io, tmp.dir, &log, map_bytes, oid);

    // Import leaves an unborn checkout: `.zit-checkout` makes root discovery
    // work, but only the first real update may claim the default-tip version.
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const unborn = (try checkout.read(io, destination, &ckbuf)).?;
    try std.testing.expectEqualStrings("", unborn.version);
    try std.testing.expectEqualStrings("main", unborn.branch);

    var first_update = try runZit(gpa, io, destination, &.{ "update", "sym-heads/main" });
    defer freeResult(gpa, &first_update);
    try expectExit(first_update, 0);
    const main_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "main" });
    defer gpa.free(main_oid);
    const materialized_default = (try checkout.read(io, destination, &ckbuf)).?;
    try std.testing.expect(materialized_default.version.len != 0);
    try compareMaterializedTree(gpa, io, tmp.dir, destination, std.mem.trim(u8, main_oid, " \t\r\n"));

    // The `seen_submanifest` latch is one-way and means "an S reference is
    // stored here" (logstore.zig), NOT "trees are enabled". The import used to
    // set `enable_submanifests` and leave the latch false, which stayed hidden
    // only because the §7.2 gate is the disjunction of the two: a later
    // `zit submanifest disable` would have left this repository advertising
    // avoid-submanifests while holding S-bearing artifacts. This history emits
    // trees, so the latch must be set.
    {
        var cfg_file = try destination.openFile(io, ".zit", .{});
        defer cfg_file.close(io);
        const cfg_st = try cfg_file.stat(io);
        var cfg_fs = storage.FileStorage.init(io, cfg_file, cfg_st.size);
        var cfg_log = try logstore.LogStore.openReadOnly(gpa, cfg_fs.storageFile());
        defer cfg_log.deinit(gpa);
        var oc = (try cfg_log.readConfig(gpa)).?;
        defer oc.deinit(gpa);
        try std.testing.expect(oc.config.enable_submanifests);
        try std.testing.expect(oc.config.seen_submanifest);
    }
    // Every persisted canonical name resolves through the public CLI to exactly
    // the commit reached by Git's corresponding source ref or tag.
    try assertRefResolution(gpa, io, tmp.dir, destination, map_bytes);

    var reopened = try runZit(gpa, io, destination, &.{"open"});
    defer freeResult(gpa, &reopened);
    try expectExit(reopened, 1);

    var materialized_lines = std.mem.splitScalar(u8, commits, '\n');
    while (materialized_lines.next()) |oid| {
        if (oid.len == 0) continue;
        const mapped = try findCommitMap(gpa, map_bytes, oid);
        defer gpa.free(mapped);
        var update = try runZit(gpa, io, destination, &.{ "update", mapped });
        defer freeResult(gpa, &update);
        try expectExit(update, 0);
        const current = (try checkout.read(io, destination, &ckbuf)).?;
        try std.testing.expectEqualStrings(mapped, current.version);
        try compareMaterializedTree(gpa, io, tmp.dir, destination, oid);
    }

    var timeline = try runZit(gpa, io, destination, &.{"timeline"});
    defer freeResult(gpa, &timeline);
    try expectExit(timeline, 0);
    try std.testing.expect(std.mem.indexOf(u8, timeline.stdout, "octopus") != null);
}

test "git import e2e: failures are fail-closed and diagnostics name the rejected boundary" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Missing required destination is rejected by the CLI parser, before any
    // filesystem publication can occur.
    var absent_dest = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source-only" });
    defer freeResult(gpa, &absent_dest);
    try expectExit(absent_dest, 2);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expectEqualStrings("zit import-git: invalid arguments (MissingDestination); see 'zit import-git --help'\n", absent_dest.stderr);

    try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
    var source = try tmp.dir.openDir(io, "source", .{});
    defer source.close(io);
    try writeFile(io, source, "tracked", "tracked\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false);

    // A shallow clone cannot establish complete parent topology; it must fail
    // before publishing a destination, not truncate history silently.
    try gitOk(gpa, io, tmp.dir, &.{ "clone", "--depth", "1", "--no-local", "source", "shallow" });
    var shallow = try runZit(gpa, io, tmp.dir, &.{ "import-git", "shallow", "shallow-dest" });
    defer freeResult(gpa, &shallow);
    try expectFailure(shallow);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expectEqualStrings("import-git: cannot start git fast-export: ShallowRepository\n", shallow.stderr);
    try expectNoDirectory(io, tmp.dir, "shallow-dest");
}

test "git import e2e: a submodule entry is omitted and the rest of the tree imports" {
    if (builtin.os.tag == .windows) return error.SkipZigTest;
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
    var source = try tmp.dir.openDir(io, "source", .{});
    defer source.close(io);
    try writeFile(io, source, "tracked", "tracked\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    try gitCommit(gpa, io, tmp.dir, "root", "2001-02-03T04:05:06+0000", false);

    // Fossil has no submodule concept, so the importer drops the gitlink and
    // imports the history around it.  Refusing the whole repository withheld
    // histories that are otherwise entirely representable, which is why the
    // `beside` file committed alongside the gitlink must still arrive.
    const head = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "HEAD" });
    defer gpa.free(head);
    const cacheinfo = try std.fmt.allocPrint(gpa, "160000,{s},nested", .{std.mem.trimEnd(u8, head, "\n")});
    defer gpa.free(cacheinfo);
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "update-index", "--add", "--cacheinfo", cacheinfo });
    try writeFile(io, source, "beside", "beside\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "beside" });
    try gitCommit(gpa, io, tmp.dir, "gitlink", "2001-02-03T04:06:06+0000", false);

    var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "gitlink-dest" });
    defer freeResult(gpa, &imported);
    try expectExit(imported, 0);
    try expectNoImportTemps(io, tmp.dir);

    var destination = try tmp.dir.openDir(io, "gitlink-dest", .{});
    defer destination.close(io);
    const map_bytes = try destination.readFileAlloc(io, ".zit-import-map.jsonl", gpa, .limited(1 << 20));
    defer gpa.free(map_bytes);
    var store_file = try destination.openFile(io, ".zit", .{ .mode = .read_only });
    defer store_file.close(io);
    const store_stat = try store_file.stat(io);
    var fs = storage.FileStorage.init(io, store_file, store_stat.size);
    var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
    defer log.deinit(gpa);

    // compareTree, reached through assertCommitDifferential, fails if the
    // manifest names `nested`; compareMaterializedTree fails if anything is
    // written there.  Both sides of the omission are asserted, not assumed.
    const commits = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-list", "--reverse", "--topo-order", "--all" });
    defer gpa.free(commits);
    var commit_lines = std.mem.splitScalar(u8, commits, '\n');
    while (commit_lines.next()) |oid| if (oid.len != 0) try assertCommitDifferential(gpa, io, tmp.dir, &log, map_bytes, oid);

    const branch_out = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "--abbrev-ref", "HEAD" });
    defer gpa.free(branch_out);
    const ref = try std.fmt.allocPrint(gpa, "sym-heads/{s}", .{std.mem.trim(u8, branch_out, " \t\r\n")});
    defer gpa.free(ref);
    var materialize = try runZit(gpa, io, destination, &.{ "update", ref });
    defer freeResult(gpa, &materialize);
    try expectExit(materialize, 0);

    const head_after = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "HEAD" });
    defer gpa.free(head_after);
    try compareMaterializedTree(gpa, io, tmp.dir, destination, std.mem.trim(u8, head_after, " \t\r\n"));
    const beside = try destination.readFileAlloc(io, "beside", gpa, .limited(64));
    defer gpa.free(beside);
    try std.testing.expectEqualStrings("beside\n", beside);
}

test "git import e2e: a missing Git object aborts before publication" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try gitOk(gpa, io, tmp.dir, &.{ "init", "missing" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "config", "user.name", "Git Committer" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "config", "user.email", "[email protected]" });
    var source = try tmp.dir.openDir(io, "missing", .{});
    defer source.close(io);
    try writeFile(io, source, "missing.txt", "this blob will disappear\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "missing", "add", "missing.txt" });

    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("GIT_AUTHOR_NAME", "Git Author");
    try env.put("GIT_AUTHOR_EMAIL", "[email protected]");
    try env.put("GIT_COMMITTER_NAME", "Git Committer");
    try env.put("GIT_COMMITTER_EMAIL", "[email protected]");
    try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000");
    try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000");
    var commit = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "missing", "commit", "--no-gpg-sign", "-m", "missing object" }, &env);
    defer freeResult(gpa, &commit);
    try expectExit(commit, 0);

    const object = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "missing", "rev-parse", "HEAD:missing.txt" });
    defer gpa.free(object);
    const oid = std.mem.trimEnd(u8, object, "\n");
    const object_path = try std.fmt.allocPrint(gpa, "missing/.git/objects/{s}/{s}", .{ oid[0..2], oid[2..] });
    defer gpa.free(object_path);
    try tmp.dir.deleteFile(io, object_path);

    var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "missing", "missing-dest" });
    defer freeResult(gpa, &imported);
    try expectFailure(imported);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expectEqualStrings("import-git: cannot start git fast-export: MissingObjects\n", imported.stderr);
    try expectNoDirectory(io, tmp.dir, "missing-dest");
}

test "git import e2e: signed metadata and canonical ref aliases never publish a partial destination" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Manufacture a structurally valid commit object with a gpgsig header using
    // Git's object writer.  This avoids relying on a local secret key while
    // preserving the exact object shape a signed commit carries in a real repo.
    try gitOk(gpa, io, tmp.dir, &.{ "init", "signed" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "config", "user.name", "Git Committer" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "config", "user.email", "[email protected]" });
    var signed_dir = try tmp.dir.openDir(io, "signed", .{});
    defer signed_dir.close(io);
    try writeFile(io, signed_dir, "payload", "signed fixture\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "add", "payload" });

    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    try env.put("TZ", "UTC");
    try env.put("GIT_AUTHOR_NAME", "Git Author");
    try env.put("GIT_AUTHOR_EMAIL", "[email protected]");
    try env.put("GIT_COMMITTER_NAME", "Git Committer");
    try env.put("GIT_COMMITTER_EMAIL", "[email protected]");
    try env.put("GIT_AUTHOR_DATE", "2001-02-03T04:05:06+0000");
    try env.put("GIT_COMMITTER_DATE", "2001-02-03T04:05:06+0000");
    var root = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "signed", "commit", "--no-gpg-sign", "-m", "root" }, &env);
    defer freeResult(gpa, &root);
    try expectExit(root, 0);

    const tree = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "rev-parse", "HEAD^{tree}" });
    defer gpa.free(tree);
    const parent = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "rev-parse", "HEAD" });
    defer gpa.free(parent);
    const signed_object = try std.fmt.allocPrint(
        gpa,
        "tree {s}\nparent {s}\nauthor Git Author <[email protected]> 981173106 +0000\ncommitter Git Committer <[email protected]> 981173106 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n fixture signature\n -----END PGP SIGNATURE-----\n\nsigned metadata\n",
        .{ std.mem.trimEnd(u8, tree, "\n"), std.mem.trimEnd(u8, parent, "\n") },
    );
    defer gpa.free(signed_object);
    try writeFile(io, signed_dir, "signed-commit", signed_object);
    const signed_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "signed", "hash-object", "-t", "commit", "-w", "signed-commit" });
    defer gpa.free(signed_oid);
    const signed_head = std.mem.trimEnd(u8, signed_oid, "\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "signed", "update-ref", "refs/heads/signed", signed_head });
    // Default policy strips the signature and imports.  Fossil has no signature
    // card, so refusing withheld the whole history for a loss no import can
    // avoid; the operator opts in to that refusal instead of inheriting it.
    var stripped = try runZit(gpa, io, tmp.dir, &.{ "import-git", "signed", "signed-stripped" });
    defer freeResult(gpa, &stripped);
    try expectExit(stripped, 0);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expectEqualStrings("signed-stripped\n", stripped.stdout);
    var stripped_dest = try tmp.dir.openDir(io, "signed-stripped", .{});
    stripped_dest.close(io);

    // --refuse-signed restores refusal, and it must still be fail-closed.
    var signed_import = try runZit(gpa, io, tmp.dir, &.{ "import-git", "signed", "signed-dest", "--refuse-signed" });
    defer freeResult(gpa, &signed_import);
    try expectFailure(signed_import);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expect(std.mem.indexOf(u8, signed_import.stderr, "encountered signed commit") != null);
    try std.testing.expect(std.mem.indexOf(u8, signed_import.stderr, "import-git: import failed: GitExportFailed") != null);
    try expectNoDirectory(io, tmp.dir, "signed-dest");

    // Git normally rejects this spelling through update-ref.  A damaged but
    // otherwise real repository can still carry it in packed-refs; import must
    // reject its canonical collision rather than choosing either target.
    try gitOk(gpa, io, tmp.dir, &.{ "init", "collision" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "config", "user.name", "Git Committer" });
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "config", "user.email", "[email protected]" });
    var collision_dir = try tmp.dir.openDir(io, "collision", .{});
    defer collision_dir.close(io);
    try writeFile(io, collision_dir, "payload", "collision fixture\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "collision", "add", "payload" });
    var collision_commit = try runCommand(gpa, io, tmp.dir, &.{ "git", "-C", "collision", "commit", "--no-gpg-sign", "-m", "root" }, &env);
    defer freeResult(gpa, &collision_commit);
    try expectExit(collision_commit, 0);
    const collision_oid = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "collision", "rev-parse", "HEAD" });
    defer gpa.free(collision_oid);
    const oid = std.mem.trimEnd(u8, collision_oid, "\n");
    const packed_refs = try std.fmt.allocPrint(
        gpa,
        "# pack-refs with: peeled fully-peeled sorted\n{s} refs/heads/collision\n{s} refs/heads/collision//\n",
        .{ oid, oid },
    );
    defer gpa.free(packed_refs);
    try writeFile(io, collision_dir, ".git/packed-refs", packed_refs);
    var collision_import = try runZit(gpa, io, tmp.dir, &.{ "import-git", "collision", "collision-dest" });
    defer freeResult(gpa, &collision_import);
    try expectFailure(collision_import);
    try expectNoImportTemps(io, tmp.dir);
    try std.testing.expect(std.mem.indexOf(u8, collision_import.stderr, "packed refname is dangerous: refs/heads/collision//") != null);
    try std.testing.expect(std.mem.indexOf(u8, collision_import.stderr, "import-git: cannot start git fast-export: MissingObjects") != null);
    try expectNoDirectory(io, tmp.dir, "collision-dest");
}

test "git import e2e: an empty author email imports and names the user card" {
    if (builtin.os.tag == .windows) return error.SkipZigTest;
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try gitOk(gpa, io, tmp.dir, &.{ "init", "source" });
    var source = try tmp.dir.openDir(io, "source", .{});
    defer source.close(io);
    try writeFile(io, source, "f.txt", "hi\n");
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "add", "-A" });
    // `git config user.email ""` is ordinary configuration, and git exports the
    // resulting identity as `mohye77 <>`.  The parser refused that stream, so a
    // repository carrying one such commit could not be imported at all.
    try gitOk(gpa, io, tmp.dir, &.{ "-C", "source", "commit", "--no-gpg-sign", "-m", "empty email commit", "--author", "mohye77 <>" });

    var imported = try runZit(gpa, io, tmp.dir, &.{ "import-git", "source", "empty-email-dest", "--attribution", "author" });
    defer freeResult(gpa, &imported);
    try expectExit(imported, 0);
    try expectNoImportTemps(io, tmp.dir);

    var destination = try tmp.dir.openDir(io, "empty-email-dest", .{});
    defer destination.close(io);

    const branch_line = try gitOutput(gpa, io, tmp.dir, &.{ "-C", "source", "rev-parse", "--abbrev-ref", "HEAD" });
    defer gpa.free(branch_line);
    const branch = std.mem.trimEnd(u8, branch_line, "\n");

    var tip = try runZit(gpa, io, destination, &.{ "info", branch });
    defer freeResult(gpa, &tip);
    try expectExit(tip, 0);
    const newline = std.mem.indexOfScalar(u8, tip.stdout, '\n') orelse tip.stdout.len;
    const hash = tip.stdout[0..newline];

    var card = try runZit(gpa, io, destination, &.{ "artifact", hash, "--raw" });
    defer freeResult(gpa, &card);
    try expectExit(card, 0);
    // The identity's NAME, not its email, becomes the U card login, so an empty
    // email must still yield a named user rather than an empty one.
    try std.testing.expect(std.mem.indexOf(u8, card.stdout, "\nU mohye77\n") != null);
}