Skip to content
zit Artifact [ce2a86863e]
Login

Artifact [ce2a86863e]

Artifact ce2a86863e48a6b5cbc70fed4e37bcecdb10acc4dbbf71d5bfc68f34cf3c5f29:


//! 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, &timestamp), 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;
        };
    }
}