Skip to content
zit Artifact [2b9f9f6256]
Login

Artifact [2b9f9f6256]

Artifact 2b9f9f625646ada2bb73e65ce98b4935392fbf1f326ca517796c526be05dfae6:


//! `zit import-git SOURCE DEST --into`: add the upstream commits a destination
//! does not yet hold to the ones it has.
//!
//! The fresh import in `git_import_cli.zig` builds a whole store in a staging
//! directory and publishes it with one rename. That cannot apply here without
//! copying the destination. New frames are staged through a base-plus-tail
//! binding. A durable immutable prepared bundle precedes live append, after
//! which only exact-prefix roll-forward is allowed. Explicit recovery needs
//! neither the Git source nor a persistent operating-system file identity.
//!
//! What the destination already holds comes from the map (`git_import_map`):
//! every imported commit's OID, check-in name, branch and first-parent depth,
//! and every ref's tip. The export excludes those tips, names an excluded
//! parent by OID, and the importer resolves the name through the seeded map
//! rather than the stream.

const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const logstore = zit.logstore;
const storage = zit.storage;
const fast_export = zit.git_fast_export;
const git_import = zit.git_import;
const import_cli = @import("git_import_cli.zig");
const map_file = zit.git_import_map;
const LogTreeStore = zit.log_tree_store.LogTreeStore;
const PublicationHooks = zit.publication.PublicationHooks;
const transaction = zit.import_transaction;

pub fn run(
    gpa: std.mem.Allocator,
    io: std.Io,
    request: import_cli.Request,
    streams: cli.Streams,
    dependencies: import_cli.Dependencies,
) cli.Exit {
    var ignored_result: import_cli.Result = .{};
    const result = request.result orelse &ignored_result;
    if (request.provenance) {
        result.refuse("incompatible_settings");
        streams.err.writeAll("import-git: --provenance applies to a fresh import; an import that adds to a destination keeps no per-object record\n") catch {};
        return .failure;
    }
    var destination = request.destination_parent.openDir(io, request.destination_name, .{}) catch {
        streams.err.print("import-git: destination '{s}' does not exist; omit --into to create it\n", .{request.destination_name}) catch {};
        return .failure;
    };
    defer destination.close(io);
    if (cli.storeDirOf(io, destination) catch return .failure) |resolved| {
        destination.close(io);
        destination = resolved;
    }

    // Validation is authoritative only after exclusion. In particular, never
    // load an old map while another importer publishes a new log generation.
    var store_append = cli.openStoreAppend(io, destination) catch |err| {
        if (err == error.ImportRecoveryRequired) {
            result.pending("pending_import");
        } else if (err == error.StoreBusy) {
            result.* = .{ .outcome = .busy, .reason = "store_busy", .retry = .later };
        } else if (err == error.StoreLockAmbiguous) {
            result.refuse("lock_ownership_ambiguous");
        } else result.refuse("destination_unavailable");
        switch (err) {
            error.ImportRecoveryRequired => streams.err.writeAll("import-git: an incremental import is pending; run 'zit import-git --recover DEST'\n") catch {},
            error.StoreBusy => {}, // the lock already named its holder
            error.StoreLockAmbiguous => streams.err.writeAll("import-git: the store lock's owner cannot be established; inspect it before retrying\n") catch {},
            else => streams.err.print("import-git: cannot write destination ({s})\n", .{@errorName(err)}) catch {},
        }
        return .failure;
    };
    var store_live = true;
    defer if (store_live) store_append.close(io);

    var map = map_file.load(gpa, io, destination) catch |err| {
        result.refuse(if (err == error.Absent) "map_missing" else "map_invalid");
        switch (err) {
            error.Absent => streams.err.print(
                "import-git: '{s}' has no commit map, so nothing says which upstream commits it holds; it was not written by this verb, or by a zit older than the map. Import into a fresh destination instead\n",
                .{request.destination_name},
            ) catch {},
            else => streams.err.print("import-git: the commit map of '{s}' cannot be read ({s})\n", .{ request.destination_name, @errorName(err) }) catch {},
        }
        return .failure;
    };
    defer map.deinit();

    if (map.contents.mirror_classifier_fired) {
        result.refuse("unsupported_mirror_metadata");
        streams.err.writeAll("import-git: the first import of this destination met a Fossil mirror's own metadata; adding to such a destination is not supported, re-import it instead\n") catch {};
        return .failure;
    }
    if (settingsConflict(request, map.contents.settings)) |message| {
        result.refuse("incompatible_settings");
        streams.err.print("import-git: {s}\n", .{message}) catch {};
        return .failure;
    }

    // Refusal precedence is part of the machine contract. Inspect the locked
    // destination read-only before suggesting that more Git history can help.
    const log_stat = store_append.file.stat(io) catch {
        result.refuse("destination_unavailable");
        return .failure;
    };
    var file_storage = storage.FileStorage.initReadOnly(io, store_append.file, log_stat.size);
    var log = logstore.LogStore.openReadOnly(gpa, file_storage.storageFile()) catch |err| {
        result.refuse("store_invalid");
        streams.err.print("import-git: cannot validate destination: {s}\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    var log_live = true;
    defer if (log_live) log.deinit(gpa);
    if (log.artifactCount() != map.contents.artifact_count) {
        result.refuse("map_stale");
        streams.err.print("import-git: '{s}' has been written to since its last import; its commit map no longer describes it\n", .{request.destination_name}) catch {};
        return .failure;
    }
    validateMap(gpa, &map, &log) catch |err| {
        result.refuse("map_stale");
        streams.err.print("import-git: destination map is stale or incompatible: {s}\n", .{@errorName(err)}) catch {};
        return .failure;
    };

    if (request.migrate_default_branch != null) {
        const expected_default = request.expect_default_branch orelse {
            result.refuse("migration_expectation_missing");
            return .failure;
        };
        const expected_head = request.expect_imported_head orelse {
            result.refuse("migration_expectation_missing");
            return .failure;
        };
        const old_name = std.fmt.allocPrint(gpa, "refs/heads/{s}", .{map.contents.settings.default_branch}) catch return .failure;
        defer gpa.free(old_name);
        const old_ref = map.lookupRef(old_name) orelse {
            result.refuse("default_ref_missing");
            return .failure;
        };
        const old_head = map.lookupOid(old_ref.oid) orelse {
            result.refuse("map_invalid");
            return .failure;
        };
        if (!std.mem.eql(u8, expected_default, map.contents.settings.default_branch) or !std.mem.eql(u8, expected_head, old_head.checkin)) {
            result.refuse("migration_expectation_stale");
            streams.err.writeAll("import-git: default migration expectations no longer match the last successful import\n") catch {};
            return .failure;
        }
    }

    const describe = dependencies.git.vtable.describe_source orelse {
        streams.err.writeAll("import-git: cannot identify the source, so cannot tell whether it is the one this destination was imported from\n") catch {};
        return .failure;
    };
    var identity = describe(dependencies.git.context, gpa, request.source) catch |err| {
        if (dependencies.git.vtable.startup_diagnostics) |write_diagnostics| write_diagnostics(dependencies.git.context, streams.err);
        streams.err.print("import-git: cannot identify the source: {s}\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    defer identity.deinit(gpa);
    const expected_format = if (map.contents.object_format == .sha1) "sha1" else "sha256";
    if (!std.mem.eql(u8, identity.object_format, expected_format)) {
        result.refuse("object_format_mismatch");
        streams.err.writeAll("import-git: source object format differs from the destination import\n") catch {};
        return .failure;
    }
    if (request.migrate_default_branch) |new_default| {
        var present = false;
        for (identity.refs) |ref| {
            if (std.mem.startsWith(u8, ref.name, "refs/heads/") and std.mem.eql(u8, ref.name["refs/heads/".len..], new_default)) present = true;
        }
        if (!present) {
            result.refuse("migration_ref_missing");
            streams.err.writeAll("import-git: the requested replacement default branch is not in the captured source\n") catch {};
            return .failure;
        }
    }
    if (unknownCut(identity, &map)) |cut| {
        result.* = .{ .outcome = .insufficient_history, .reason = if (cut.none_known) "shallow_identity_unproven" else "unknown_shallow_boundary", .retry = .deepen };
        if (cut.none_known) {
            // Nothing in the source is a commit the destination holds, so
            // nothing ties it to that history: another repository, or this
            // one cut off above everything already imported. Git cannot tell
            // the two apart from what such a source holds.
            streams.err.print("import-git: '{s}' is not the repository '{s}' was imported from, or is a shallow clone of it cut off at commit {s}, above every commit it holds; fetch it deeper (git fetch --deepen) until the cut is at a commit already imported\n", .{ request.source, request.destination_name, cut.commit }) catch {};
        } else {
            streams.err.print("import-git: '{s}' is shallow and cut off at commit {s}, which '{s}' does not hold; fetch it deeper (git fetch --deepen) until the cut is at a commit already imported\n", .{ request.source, cut.commit, request.destination_name }) catch {};
        }
        return .failure;
    }
    if (!sameSource(identity, map.contents)) {
        result.refuse("wrong_source");
        streams.err.print("import-git: '{s}' is not the repository '{s}' was imported from: a root commit the destination holds is not in its history\n", .{ request.source, request.destination_name }) catch {};
        return .failure;
    }

    if (request.migrate_default_branch) |new_default| {
        const old_name = std.fmt.allocPrint(gpa, "refs/heads/{s}", .{map.contents.settings.default_branch}) catch return .failure;
        defer gpa.free(old_name);
        const old_ref = map.lookupRef(old_name).?; // validated before source inspection
        var replacement: ?[]const u8 = null;
        for (identity.refs) |ref| {
            if (std.mem.startsWith(u8, ref.name, "refs/heads/") and std.mem.eql(u8, ref.name["refs/heads/".len..], new_default)) replacement = ref.oid;
        }
        const target = replacement.?; // captured replacement was checked above
        const related: ?bool = if (std.mem.eql(u8, old_ref.oid, target)) true else blk: {
            const prove = dependencies.git.vtable.first_parent_contains orelse {
                result.refuse("migration_proof_unavailable");
                return .failure;
            };
            break :blk prove(dependencies.git.context, request.source, old_ref.oid, target) catch {
                result.refuse("migration_proof_failed");
                return .failure;
            };
        };
        if (related == null) {
            result.* = .{ .outcome = .insufficient_history, .reason = "migration_ancestry_incomplete", .retry = .deepen };
            streams.err.writeAll("import-git: shallow history cannot prove default migration continuity; fetch deeper\n") catch {};
            return .failure;
        }
        if (!related.?) {
            result.refuse("migration_not_first_parent_continuation");
            streams.err.writeAll("import-git: replacement default is not the old tip or its first-parent continuation\n") catch {};
            return .failure;
        }
    }

    var stage = transaction.begin(io, destination) catch |err| {
        streams.err.print("import-git: cannot stage import: {s}\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    defer stage.close(io);
    defer destination.deleteTree(io, transaction.staging_name) catch {};
    const tail = stage.createFile(io, "tail", .{ .read = true, .exclusive = true }) catch return .failure;
    defer tail.close(io);
    var tail_storage = storage.FileStorage.init(io, tail, 0);
    var overlay: transaction.Overlay = .{ .base = file_storage.storageFile(), .tail = tail_storage.storageFile(), .base_len = log_stat.size };
    // Reuse the fully validated scan, but authorize writes only to the private
    // appended tail. No repairing scanner ever touches the live destination.
    log.file = overlay.file();
    log.writable = true;
    var outcome = importMore(gpa, io, request, &map, &identity, &log, stage, streams, dependencies) catch |err| {
        result.refuse(if (err == error.InvalidPrimaryBranch) "default_ref_missing" else "import_failed");
        streams.err.print("import-git: import failed: {s}; the destination is unchanged\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    defer outcome.deinit(gpa);
    const default_changed = if (request.migrate_default_branch) |name| !std.mem.eql(u8, name, map.contents.settings.default_branch) else false;
    if (outcome.added_commits == 0 and sameRefs(&identity, &map) and !default_changed) {
        result.success(false, 0);
        if (!request.quiet) streams.err.print("import-git: added 0 check-in(s) to '{s}'\n", .{request.destination_name}) catch {};
        return .ok;
    }
    transaction.prepare(io, destination, stage, store_append.file, dependencies.publication) catch |err| {
        result.refuse("prepare_failed");
        transaction.requireIdle(io, destination) catch result.pending("prepare_uncertain");
        streams.err.print("import-git: preparation interrupted ({s}); if pending, run 'zit import-git --recover DEST'\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    _ = transaction.recover(io, destination, dependencies.publication) catch |err| {
        result.pending("publication_interrupted");
        if (map_file.load(gpa, io, destination)) |published_map| {
            var published = published_map;
            defer published.deinit();
            if (published.contents.publication_token) |token| {
                if (std.mem.eql(u8, &token, &outcome.publication_token)) {
                    result.* = .{ .outcome = .committed_uncertain, .reason = "durability_uncertain", .changed = true, .committed = true, .durability = .uncertain, .retry = .recover, .added_checkins = outcome.added_commits };
                }
            }
        } else |_| {}
        streams.err.print("import-git: recovery_required ({s}); run 'zit import-git --recover DEST'; no live bytes were rolled back\n", .{@errorName(err)}) catch {};
        return .failure;
    };

    log.deinit(gpa);
    log_live = false;
    store_append.close(io);
    store_live = false;
    // Pre-pay the derived-state cache against the published store, as the
    // fresh import does; best-effort like every cache write.
    zit.Repository.warmCache(gpa, io, destination);
    result.success(true, outcome.added_commits);

    if (!request.quiet) {
        streams.err.print("import-git: added {d} check-in(s) to '{s}'\n", .{ outcome.added_commits, request.destination_name }) catch {};
        for (outcome.notes.items) |note| streams.err.print("import-git: {s}\n", .{note}) catch {};
    }
    return .ok;
}

fn sameRefs(identity: *const import_cli.SourceIdentity, map: *const map_file.Map) bool {
    if (identity.refs.len != map.contents.refs.len) return false;
    for (identity.refs) |ref| {
        const held = map.lookupRef(ref.name) orelse return false;
        if (!std.mem.eql(u8, held.oid, ref.oid)) return false;
    }
    return true;
}

fn validateMap(gpa: std.mem.Allocator, map: *const map_file.Map, log: *logstore.LogStore) !void {
    if (map.contents.publication_token != null) {
        const hash = try map_file.inventoryHash(gpa, log);
        if (!std.mem.eql(u8, &hash, &map.contents.inventory_hash)) return error.ImportBaseMismatch;
    }
    for (map.contents.commits) |commit| {
        var bytes: [32]u8 = undefined;
        const hash = std.fmt.hexToBytes(&bytes, commit.checkin) catch return error.InvalidMappedHash;
        if (log.isShunned(hash)) return error.MappedArtifactShunned;
        const rid = log.lookup(hash) orelse return error.MappedArtifactMissing;
        const content = (try log.content(gpa, rid)) orelse return error.MappedArtifactMissing;
        defer gpa.free(content);
        if (!zit.artifact.verify(commit.checkin, content)) return error.MappedArtifactCorrupt;
        var manifest = try zit.artifact.parseForImport(gpa, content);
        defer manifest.deinit(gpa);
        if (manifest.manifest.kind != .manifest) return error.MappedArtifactNotCheckin;
    }
    for (map.contents.refs) |ref| {
        if (map.lookupOid(ref.oid) == null) return error.InvalidMappedRef;
    }
    var config = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
    defer config.deinit(gpa);
    if (config.config.default_branch.len == 0) return error.AmbiguousPrimaryBranch;
    if (map.contents.publication_token != null and !std.mem.eql(u8, config.config.default_branch, map.contents.primary_label)) return error.IncompatiblePrimaryBranch;
    for (map.contents.commits) |commit| {
        if (std.mem.eql(u8, commit.branch, config.config.default_branch)) return;
    }
    return error.IncompatiblePrimaryBranch;
}

pub fn recover(io: std.Io, dir: std.Io.Dir, streams: cli.Streams, hooks: PublicationHooks) cli.Exit {
    var result: import_cli.Result = .{};
    return recoverResult(io, dir, streams, hooks, &result);
}

pub fn recoverResult(io: std.Io, dir: std.Io.Dir, streams: cli.Streams, hooks: PublicationHooks, result: *import_cli.Result) cli.Exit {
    // A mistyped path must never read as a durable no-op success.
    const probe = cli.storeDirOf(io, dir) catch {
        result.refuse("destination_unavailable");
        return .failure;
    };
    const store_dir = probe orelse dir;
    defer if (probe) |store| store.close(io);
    _ = store_dir.statFile(io, ".zit", .{}) catch {
        result.refuse("not_a_repository");
        streams.err.writeAll("import-git: recovery destination holds no zit store\n") catch {};
        return .failure;
    };
    var lock = cli.lockStoreRecovery(io, dir) catch |err| {
        if (err == error.StoreBusy) result.* = .{ .outcome = .busy, .reason = "store_busy", .retry = .later } else result.refuse("destination_unavailable");
        if (err == error.StoreLockAmbiguous) result.reason = "lock_ownership_ambiguous";
        streams.err.print("import-git: recovery lock failed: {s}\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    defer lock.release(io);
    const resolved = cli.storeDirOf(io, dir) catch return .failure;
    defer if (resolved) |store| store.close(io);
    const changed = transaction.recover(io, resolved orelse dir, hooks) catch |err| {
        result.pending("recovery_interrupted");
        switch (err) {
            // Missing or malformed members arise only from outside interference;
            // repeating recovery cannot repair them.
            error.DamagedImportJournal, error.ForeignImportSuffix, error.ImportBaseMismatch, error.ImportMapMismatch, error.ConflictingImportState, error.FileNotFound, error.NotDir, error.IsDir => {
                result.outcome = .refused;
                result.reason = "recovery_conflict";
                result.retry = .none;
            },
            else => {},
        }
        streams.err.print("import-git: recovery refused: {s}; preserve the store, map and pending bundle\n", .{@errorName(err)}) catch {};
        return .failure;
    };
    result.success(changed, null);
    result.outcome = if (changed) .recovered_changed else .recovered_unchanged;
    streams.err.writeAll(if (changed) "import-git: recovered prepared import\n" else "import-git: no pending import\n") catch {};
    return .ok;
}

/// A flag that contradicts what the first import recorded, as a message, or
/// null when the request is compatible. An absent flag means "reuse".
fn settingsConflict(request: import_cli.Request, recorded: map_file.Settings) ?[]const u8 {
    switch (request.submanifest) {
        .flat => if (recorded.submanifest != .flat) return "--no-submanifests contradicts the destination, which was imported with sub-manifests; omit it",
        .tree => |pin| if (pin) |k| switch (recorded.submanifest) {
            .flat => return "--submanifest-k contradicts the destination, which was imported flat; omit it",
            .tree => |recorded_k| if (recorded_k != k) return "--submanifest-k contradicts the k the destination was imported with; omit it",
        },
    }
    const requested_attribution: map_file.Attribution = switch (request.attribution) {
        .committer => .committer,
        .author => .author,
    };
    if (requested_attribution != recorded.attribution) return "--attribution contradicts the identities the destination was imported with; omit it";
    if (request.refuse_signed != recorded.refuse_signed) {
        return if (request.refuse_signed)
            "--refuse-signed contradicts the destination, which was imported with signatures stripped; omit it"
        else
            "the destination was imported with --refuse-signed; pass it again";
    }
    if (request.default_branch) |requested| {
        if (!std.mem.eql(u8, requested, recorded.default_branch)) return "--default-branch contradicts the main branch the destination was imported with; omit it";
    }
    return null;
}

/// The first commit a shallow source is cut off at that the destination does
/// not hold, or null. Beneath a cut the source has nothing, so every commit
/// the export names by OID there must already be in the destination; a cut
/// at a commit the map does not know would turn that commit into a root.
///
/// `none_known` is set when no commit of the cut is one the destination
/// holds, which leaves the source with no evidence it is this history at all.
fn unknownCut(identity: import_cli.SourceIdentity, map: *const map_file.Map) ?struct { commit: []const u8, none_known: bool } {
    var unknown: ?[]const u8 = null;
    var known: usize = 0;
    for (identity.shallow) |commit| {
        if (map.lookupOid(commit) == null) {
            if (unknown == null) unknown = commit;
        } else known += 1;
    }
    const commit = unknown orelse return null;
    return .{ .commit = commit, .none_known = known == 0 };
}

/// The source is the one the destination came from when every root the
/// destination recorded is still a root of the source. The source may have
/// gained roots since (an orphan branch, a first notes commit); it cannot
/// have lost one and still be the history the destination holds.
///
/// A shallow source has lost its roots to the cut. For it the cut is the
/// evidence instead: `unknownCut` has proved every commit it is cut off at is
/// one the destination holds, and a commit's OID names its whole ancestry.
fn sameSource(identity: import_cli.SourceIdentity, recorded: map_file.Contents) bool {
    const format: map_file.ObjectFormat = if (std.mem.eql(u8, identity.object_format, "sha256")) .sha256 else .sha1;
    if (format != recorded.object_format) return false;
    if (identity.shallow.len != 0) return true;
    for (recorded.roots) |theirs| {
        const present = for (identity.roots) |mine| {
            if (std.mem.eql(u8, mine, theirs)) break true;
        } else false;
        if (!present) return false;
    }
    return true;
}

const Outcome = struct {
    added_commits: usize,
    publication_token: [32]u8 = @splat(0),
    notes: std.ArrayList([]u8),

    fn deinit(self: *Outcome, gpa: std.mem.Allocator) void {
        for (self.notes.items) |note| gpa.free(note);
        self.notes.deinit(gpa);
        self.* = undefined;
    }
};

/// Stage a complete new map and only the new log tail. Errors before PREPARED
/// discard staging, never mutate the live store.
fn importMore(
    gpa: std.mem.Allocator,
    io: std.Io,
    request: import_cli.Request,
    map: *const map_file.Map,
    identity: *const import_cli.SourceIdentity,
    log: *logstore.LogStore,
    destination: std.Io.Dir,
    streams: cli.Streams,
    dependencies: import_cli.Dependencies,
) !Outcome {
    const recorded = map.contents;
    const hooks = dependencies.publication;

    // The tips to exclude: every ref's peeled commit, each once.
    var tips: std.ArrayList([]const u8) = .empty;
    defer tips.deinit(gpa);
    var tip_set: std.StringHashMapUnmanaged(void) = .empty;
    defer tip_set.deinit(gpa);
    for (recorded.refs) |ref| {
        const seen = try tip_set.getOrPut(gpa, ref.oid);
        if (seen.found_existing) continue;
        try tips.append(gpa, ref.oid);
    }

    var pipes = try dependencies.git.vtable.start_export(dependencies.git.context, request.source, tips.items);
    var session_finished = false;
    defer if (!session_finished) {
        _ = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, true) catch false;
    };

    var tree_store = LogTreeStore{ .gpa = gpa, .log = log };
    defer tree_store.deinitCache();
    var importer = try git_import.Importer.init(gpa, log, tree_store.treeStore(), .{
        .submanifest = switch (recorded.settings.submanifest) {
            .flat => .flat,
            .tree => |k| .{ .tree = k },
        },
        .attribution = switch (recorded.settings.attribution) {
            .committer => .committer,
            .author => .author,
        },
    });
    defer importer.deinit();
    for (recorded.commits) |commit| {
        try importer.seedKnownCommit(commit.oid, commit.checkin, commit.branch, commit.depth, commit.height);
    }

    var reader = fast_export.Reader.init(gpa, pipes.stdout, .{
        .max_line = 1024 * 1024,
        .max_data = 1024 * 1024 * 1024,
        .max_path = 4096,
        .max_events = std.math.maxInt(u64),
        .max_total_data = dependencies.limits.max_export_payload_bytes,
        .max_command_arena_bytes = dependencies.limits.max_command_arena_bytes,
    });
    defer reader.deinit();
    var diagnostic: import_cli.LimitDiagnostic = .{};
    import_cli.consumeExport(&reader, &importer, dependencies.limits, &diagnostic, .{
        .streams = streams,
        .io = io,
        .quiet = request.quiet,
        .commit_total = pipes.commit_total,
    }) catch |err| {
        const scripted_child_failed = pipes.exit_code_ref == null and import_cli.terminalExitCode(&pipes) != 0;
        const child_finished = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, true) catch |lifecycle_err| {
            session_finished = true;
            return lifecycle_err;
        };
        session_finished = true;
        diagnostic.write(streams.err);
        if (scripted_child_failed or (child_finished and import_cli.terminalExitCode(&pipes) != 0)) return error.GitExportFailed;
        return err;
    };
    _ = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, false) catch |err| {
        session_finished = true;
        return err;
    };
    session_finished = true;
    if (import_cli.terminalExitCode(&pipes) != 0) return error.GitExportFailed;
    // Production reads come from the frozen private capture, so this compares
    // that capture with itself; it still guards factories that describe a
    // live source, and ensures every recorded ref resolves in staged artifacts.
    var after_source = try dependencies.git.vtable.describe_source.?(dependencies.git.context, gpa, request.source);
    defer after_source.deinit(gpa);
    if (after_source.refs.len != identity.refs.len or !std.mem.eql(u8, after_source.object_format, identity.object_format)) return error.SourceChanged;
    for (identity.refs) |before| {
        const matched = for (after_source.refs) |after| {
            if (std.mem.eql(u8, before.name, after.name) and std.mem.eql(u8, before.oid, after.oid)) break true;
        } else false;
        if (!matched) return error.SourceChanged;
    }

    // The classifier's per-commit state is inherited along first parents,
    // which the stream no longer carries, so its verdicts cannot be trusted
    // here; a fresh import of the whole history is the only honest answer.
    if (importer.mirrorCensus().commits_with_candidates != 0) return error.MirrorMetadataInNewCommits;

    // The primary label is pinned by validated destination configuration.
    // A default ref can move onto a held side-branch commit without changing
    // the destination's mainline label.
    const selected_default = request.migrate_default_branch orelse recorded.settings.default_branch;
    const primary_ref = try std.fmt.allocPrint(gpa, "refs/heads/{s}", .{selected_default});
    defer gpa.free(primary_ref);
    var destination_config = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
    defer destination_config.deinit(gpa);
    const primary_label = destination_config.config.default_branch;
    var finalized = try importer.finalizeRefs(.{
        .default_branch = selected_default,
        .primary_label = primary_label,
        .symbolic_controls = false,
    });
    defer finalized.deinit(gpa);
    if (recorded.settings.submanifest == .flat and finalized.stored_submanifest) return error.SubmanifestEmittedUnderFlatImport;

    const added = importer.commitRecordCount();

    var outcome: Outcome = .{ .added_commits = added, .notes = .empty };
    errdefer outcome.deinit(gpa);

    for (finalized.controls) |control| try import_cli.putSha1Artifact(log, control);

    // Names are reconciled against what the source publishes now, not the
    // stream: a ref on a commit the destination already holds never appears
    // in the stream, an annotated tag on one is dropped from it outright.
    // Every control written here is dated now, to the millisecond, so a name
    // that moves, or returns to a check-in it once left, outranks the control
    // before it even within one second; a name that did not change writes
    // nothing. The main branch's own `sym-` name is never touched: it
    // propagates from the branch control, and cancelling it on a tip the
    // head left would take the branch itself off that check-in.
    var now_buf: [23]u8 = undefined;
    const now_ns = std.Io.Timestamp.now(io, .real).nanoseconds;
    const now = cli.formatWriterTimestampMs(@intCast(@divFloor(now_ns, std.time.ns_per_s)), @intCast(@divFloor(@mod(now_ns, std.time.ns_per_s), std.time.ns_per_ms)), &now_buf);
    var refs: std.ArrayList(map_file.RefRecord) = .empty;
    defer refs.deinit(gpa);
    var upstream_names: std.StringHashMapUnmanaged(void) = .empty;
    defer upstream_names.deinit(gpa);
    for (identity.refs) |ref| {
        try upstream_names.put(gpa, ref.name, {});
        const target = checkinOf(map, &importer, ref.oid) orelse return error.CapturedRefNotStaged;
        try refs.append(gpa, .{ .name = ref.name, .oid = ref.oid });
        const old = map.lookupRef(ref.name);
        const ref_changed = if (old) |before| !std.mem.eql(u8, before.oid, ref.oid) else true;
        const selected_now = request.migrate_default_branch != null and
            !std.mem.eql(u8, selected_default, recorded.settings.default_branch) and
            std.mem.eql(u8, ref.name, primary_ref);
        if (!ref_changed and !selected_now) continue;
        if (ref_changed) {
            const canonical = try git_import.canonicalRefName(gpa, ref.name);
            defer gpa.free(canonical);
            if (old) |before| if (map.lookupOid(before.oid)) |left| {
                try putSymbolic(log, &importer, left.checkin, canonical, .cancel, now);
                if (std.mem.startsWith(u8, ref.name, "refs/heads/") and !firstParentLineReaches(&importer, target, left.checkin)) {
                    const note = try std.fmt.allocPrint(gpa, "head '{s}' moved to {s}; the check-ins it left behind stay", .{ ref.name["refs/heads/".len..], target[0..@min(target.len, 12)] });
                    errdefer gpa.free(note);
                    try outcome.notes.append(gpa, note);
                }
            };
            try putSymbolic(log, &importer, target, canonical, .add, now);
        }
        // Selecting an already-recorded ref still changes its primary role.
        // The main branch's own name propagates from its branch control, so
        // it needs no help unless the head landed on a check-in another
        // branch already labelled; then only a direct tag names it there.
        if (std.mem.eql(u8, ref.name, primary_ref)) if (map.lookupOid(ref.oid)) |held| if (!std.mem.eql(u8, held.branch, primary_label)) {
            const alias = try std.fmt.allocPrint(gpa, "sym-{s}", .{primary_label});
            defer gpa.free(alias);
            try putSymbolic(log, &importer, target, alias, .add, now);
        };
    }
    for (recorded.refs) |old| {
        if (upstream_names.contains(old.name)) continue;
        const left = map.lookupOid(old.oid) orelse continue;
        const canonical = try git_import.canonicalRefName(gpa, old.name);
        defer gpa.free(canonical);
        try putSymbolic(log, &importer, left.checkin, canonical, .cancel, now);
        if (std.mem.startsWith(u8, old.name, "refs/heads/")) {
            const note = try std.fmt.allocPrint(gpa, "head '{s}' is gone upstream; its check-ins stay", .{old.name["refs/heads/".len..]});
            errdefer gpa.free(note);
            try outcome.notes.append(gpa, note);
        }
    }

    // Trees were stored for the first time: the latch the fresh import sets
    // from its own emission has to follow, or the store lies about itself.
    if (finalized.stored_submanifest) {
        var current = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
        defer current.deinit(gpa);
        if (!current.config.seen_submanifest) {
            var updated = current.config;
            updated.seen_submanifest = true;
            try log.putConfig(updated);
        }
    }

    try hooks.check(.before_into_commit);
    try log.commit();
    try hooks.check(.before_into_map);

    // The new map: every commit recorded before plus the ones added, every
    // ref the source publishes now.
    var commits = try gpa.alloc(map_file.CommitRecord, recorded.commits.len + added);
    defer gpa.free(commits);
    @memcpy(commits[0..recorded.commits.len], recorded.commits);
    for (0..added) |index| {
        const record = try importer.commitRecord(index);
        commits[recorded.commits.len + index] = .{
            .oid = record.oid,
            .checkin = record.hash,
            .branch = record.branch,
            .depth = record.depth,
            .height = record.tree_height,
        };
    }
    var token: [32]u8 = undefined;
    io.random(&token);
    outcome.publication_token = token;
    var updated_settings = recorded.settings;
    updated_settings.default_branch = selected_default;
    try map_file.write(gpa, io, destination, .{
        .publication_token = token,
        .store_size = log.size(),
        .inventory_hash = try map_file.inventoryHash(gpa, log),
        .primary_label = primary_label,
        .object_format = recorded.object_format,
        .roots = recorded.roots,
        .settings = updated_settings,
        .mirror_classifier_fired = false,
        .artifact_count = log.artifactCount(),
        .commits = commits,
        .refs = refs.items,
    });
    return outcome;
}

/// The check-in a Git commit became: one this import added, or one the
/// destination recorded.
fn checkinOf(map: *const map_file.Map, importer: *const git_import.Importer, oid: []const u8) ?[]const u8 {
    if (map.lookupOid(oid)) |record| return record.checkin;
    for (0..importer.commitRecordCount()) |index| {
        const record = importer.commitRecord(index) catch continue;
        if (std.mem.eql(u8, record.oid, oid)) return record.hash;
    }
    return null;
}

fn putSymbolic(log: *logstore.LogStore, importer: *git_import.Importer, target: []const u8, canonical: []const u8, sign: enum { add, cancel }, now: []const u8) !void {
    const bytes = switch (sign) {
        .add => try importer.makeSymbolicAdd(target, canonical, now),
        .cancel => try importer.makeSymbolicCancel(target, canonical, now),
    };
    defer importer.gpa.free(bytes);
    try import_cli.putSha1Artifact(log, bytes);
}

/// Whether walking first parents from the new object `tip` reaches the
/// check-in `target` before leaving the objects this import added. A head
/// that advanced along its own line passes; one that was reset or rewritten
/// does not. A merge of the old tip into a new line reads as a move, which
/// is a safe over-report.
fn firstParentLineReaches(importer: *const git_import.Importer, tip: []const u8, target: []const u8) bool {
    var cursor = importer.objectIndexOfHash(tip) orelse return false;
    while (true) {
        const object = importer.objectAt(cursor);
        if (object.first_parent_index) |parent| {
            cursor = parent;
            continue;
        }
        const first = object.first_parent orelse return false;
        return std.mem.eql(u8, first, target);
    }
}