Skip to content
zit Artifact [41159f86fc]
Login

Artifact [41159f86fc]

Artifact 41159f86fc95751662903a3e22caf336fd27b3e674a8ac29a860cf0ae12e7d64:


//! `zit import-fossil` — bring a fossil repository's history into zit, with
//! the compatibility consequence CHOSEN AT IMPORT TIME. Two modes, no
//! default: the difference is permanent and the operator must pick.
//!
//!   --compatible   The artifact corpus is preserved verbatim: same hashes,
//!                  same project code, flat manifests. The result stays a
//!                  first-class member of the origin's sync world. This is
//!                  the clone machinery wearing its import name — fossil's
//!                  sync protocol IS the import path; no SQLite is read.
//!                  (Dispatched in main.zig straight onto the clone runner.)
//!
//!   --submanifest  REBIRTH: every check-in re-mints as a sub-manifest tree
//!                  under a NEW project code — uniform S-tree history from
//!                  the first commit, and a permanent break with the origin
//!                  (never syncs with it again; signatures cannot survive
//!                  rehashing and are stripped and counted).
//!
//! This file owns the submanifest arm: the evidence gate first — replay
//! the WHOLE history through the real tree builder, print the costs, and
//! REFUSE a conversion the evidence says would be worse than flat unless
//! --force — then the conversion itself. The staged-publication discipline
//! comes from `publication.zig`.

const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const artifact = zit.artifact;
const Repository = zit.Repository;
const convert = @import("fossil_convert.zig");
const cmd_sync = @import("../cmd/sync.zig");

pub const Mode = enum { compatible, submanifest };

/// The submanifest arm: SOURCE is a local store (a directory holding `.zit`
/// or the `.zit` file itself) already populated by `zit clone` from the
/// fossil origin. The gate runs unconditionally; the conversion runs only
/// when the gate passes (or --force overrides its refusal).
pub fn runSubmanifestImport(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    source: []const u8,
    destination: []const u8,
    k_override: ?u8,
    verb: []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const cwd = std.Io.Dir.cwd();
    // A sync URL is the fossil itself: clone it into a transient
    // sibling of DIR, convert from that, and remove it whether or not the
    // conversion publishes. The flat intermediate never needs a name.
    var flat_source = source;
    var transient: ?[]const u8 = null;
    defer if (transient) |t| cwd.deleteTree(io, t) catch {};
    if (std.mem.startsWith(u8, source, "http://") or std.mem.startsWith(u8, source, "https://")) {
        const t = std.fmt.allocPrint(arena, "{s}.zit-tmp-flat", .{destination}) catch return oom(streams);
        cwd.deleteTree(io, t) catch {}; // a prior run's wreckage, if any
        streams.out.print("{s}: cloning the fossil into a transient flat store first\n", .{verb}) catch {};
        if (cmd_sync.runClone(gpa, io, arena, &.{ source, t }, streams, amb) != .ok) {
            cwd.deleteTree(io, t) catch {};
            streams.err.print("zit {s}: the clone of '{s}' did not complete; nothing was converted\n", .{ verb, source }) catch {};
            return .failure;
        }
        transient = t;
        flat_source = t;
    }
    const root = (cli.resolveRoot(arena, io, flat_source) catch return oom(streams)) orelse {
        streams.err.print("zit {s}: '{s}' is neither a sync URL nor a zit repository\n", .{ verb, flat_source }) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    var repo: Repository = undefined;
    Repository.open(&repo, gpa, io, root_dir, .{ .mode = .full }) catch {
        streams.err.print("zit {s}: cannot read the source repository\n", .{verb}) catch {};
        return .failure;
    };
    defer repo.deinit(gpa);

    var clock = convert.Phase.start(io, streams);
    const k = pickK(gpa, &repo, k_override, verb, streams) orelse return .failure;
    clock.done("k sweep");

    const phantoms = convert.phantomCount(gpa, &repo) catch {
        streams.err.print("zit {s}: walking the source corpus failed\n", .{verb}) catch {};
        return .failure;
    };
    if (phantoms != 0) {
        streams.err.print("zit {s}: the source holds phantoms — an incomplete corpus cannot re-mint; pull to convergence first\n", .{verb}) catch {};
        return .failure;
    }

    const source_bytes = storeBytes(io, root_dir);
    return runConversion(gpa, io, &repo, destination, k, source_bytes, verb, streams);
}

/// The conversion itself, under the staged-publication discipline:
/// everything is written into a nonce-named staging directory beside the
/// destination, the stage machine orders write -> freeze -> verify, and the
/// atomic rename is the ONLY commit point -- a crash at any earlier moment
/// leaves a sweepable `.zit-tmp-*` and an untouched destination. This
/// converter writes its store directly as the published generation (its
/// output is born packed-enough; `zit pack` owns later compaction), so the
/// raw-generation stages pass through empty and the idempotent discard is
/// a no-op.
fn runConversion(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    destination: []const u8,
    k: u4,
    source_bytes: ?u64,
    verb: []const u8,
    streams: cli.Streams,
) cli.Exit {
    const cwd = std.Io.Dir.cwd();
    var bundle = zit.publication.PublicationBundle.create(gpa, io, cwd, destination) catch {
        streams.err.print("zit {s}: cannot stage beside '{s}'\n", .{ verb, destination }) catch {};
        return .failure;
    };
    var live = true;
    defer if (live) bundle.abort();

    bundle.advance(.raw_frozen) catch return convFail(streams, verb);
    bundle.advance(.packing) catch return convFail(streams, verb);
    var file = bundle.createPackedGeneration() catch return convFail(streams, verb);
    defer file.close(io);
    const hash_kind: u8 = @intFromEnum(zit.artifact.HashPolicy.sha3);
    var fs = zit.storage.FileStorage.init(io, file, 0);
    var dest = zit.logstore.LogStore.open(gpa, fs.storageFile(), hash_kind) catch return convFail(streams, verb);
    defer dest.deinit(gpa);

    // The NEW project code -- the non-negotiable identity break:
    // derived from the destination path plus the mode marker, so the result
    // can never be mistaken for (or synced with) the origin.
    const project_code = zit.sync.deriveProjectCode(&.{ destination, "import-fossil-submanifest" });
    dest.putConfig(.{
        .hash_policy = hash_kind,
        .default_branch = "trunk",
        .project_id = "",
        .project_code = &project_code,
        .enable_submanifests = true,
        .submanifest_k = k,
        .seen_submanifest = true,
    }) catch return convFail(streams, verb);

    const result = convert.remint(gpa, io, repo, &dest, k, &project_code, verb, streams) catch |e| {
        switch (e) {
            error.Diverged => {}, // already reported with the path
            error.BrokenAncestry => streams.err.print("zit {s}: the source corpus has holes; pull to convergence first\n", .{verb}) catch {},
            error.OutOfMemory => streams.err.print("zit {s}: out of memory\n", .{verb}) catch {},
            error.Storage, error.Corrupt => streams.err.print("zit {s}: reading the source failed\n", .{verb}) catch {},
        }
        return .failure;
    };

    // Count reconciliation against the source store: re-minted + carried +
    // dropped must account for every source artifact (the phantom pre-scan
    // found none), or the conversion lost something silently.
    const accounted = result.checkins + result.blobs + result.carried +
        result.controls_remapped + result.clusters_dropped;
    if (accounted != result.census.total) {
        streams.err.print(
            "zit {s}: accounting mismatch — {d} artifacts in, {d} accounted; nothing published\n",
            .{ verb, result.census.total, accounted },
        ) catch {};
        return .failure;
    }

    dest.commit() catch return convFail(streams, verb);
    bundle.advance(.packed_frozen) catch return convFail(streams, verb);
    // The pair-by-pair differential proof ran inside `remint`, reading back
    // through the same store API every verb uses; the stage records it.
    bundle.advance(.verified) catch return convFail(streams, verb);
    bundle.discardRawGeneration() catch return convFail(streams, verb); // raw-less: a no-op by idempotence
    zit.checkout.write(io, bundle.temporary, .{ .version = "", .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }) catch return convFail(streams, verb);
    bundle.verifyStagingManifest() catch return convFail(streams, verb);
    bundle.publish(destination, .{}) catch {
        streams.err.print("zit {s}: publishing '{s}' failed (it may already exist)\n", .{ verb, destination }) catch {};
        return .failure;
    };
    live = false;
    bundle.abort(); // post-publish: releases handles; the rename already landed

    streams.out.print(
        "converted: {d} check-ins re-minted as sub-manifest trees (k={d}), {d} blobs and" ++
            " {d} artifacts carried verbatim, {d} control artifact(s) remapped, {d} cluster(s)" ++
            " dropped, {d} signature(s) stripped, {d} provenance note(s) minted; every pair" ++
            " verified file-set-equivalent\n" ++
            "the result is a NEW project: it cannot sync with the origin. open it with" ++
            " 'zit open' inside '{s}'\n",
        .{ result.checkins, k, result.blobs, result.carried, result.controls_remapped, result.clusters_dropped, result.signatures_stripped, result.provenance_notes, destination },
    ) catch {};
    // The evidence a conversion decision wants is the result itself:
    // the flat store's bytes against the converted store's, measured, not
    // modelled — with the reminder that pack is what makes the second number
    // final.
    var published = cwd.openDir(io, destination, .{}) catch return .ok;
    defer published.close(io);
    const converted_bytes = storeBytes(io, published);
    if (source_bytes != null and converted_bytes != null) {
        streams.out.print(
            "store: flat source {d} bytes, converted {d} bytes (delta-encoded as written against the source's own chains)\nnext: run 'zit pack' in '{s}' — it finds better bases and usually shrinks the store several-fold\n",
            .{ source_bytes.?, converted_bytes.?, destination },
        ) catch {};
    } else {
        streams.out.print("the store is delta-encoded as written against the source's own chains\nnext: run 'zit pack' in '{s}' — it finds better bases and usually shrinks the store several-fold\n", .{destination}) catch {};
    }
    return .ok;
}

fn convFail(streams: cli.Streams, verb: []const u8) cli.Exit {
    streams.err.print("zit {s}: the conversion could not complete; nothing was published\n", .{verb}) catch {};
    return .failure;
}

/// Bytes of the `.zit` store under `dir`, or null when it cannot be read.
fn storeBytes(io: std.Io, dir: std.Io.Dir) ?u64 {
    const st = dir.statFile(io, zit.repo_filename, .{}) catch return null;
    return st.size;
}

/// Name the partition parameter: the operator's --k, else the static §6.3
/// sweep over the source tip's name set. There is no whole-history replay
/// gate any more: the conversion writes a NEW project beside an
/// untouched source, so the honest evidence is the converted store itself,
/// reported by the summary — not a model of it that cost as much to run as
/// the conversion. `zit submanifest analyze --replay-all` remains for anyone
/// who wants the replay before spending the machine time. Null = refused
/// (already diagnosed).
fn pickK(
    gpa: std.mem.Allocator,
    repo: *Repository,
    k_override: ?u8,
    verb: []const u8,
    streams: cli.Streams,
) ?u4 {
    if (k_override) |kk| {
        if (kk < artifact.min_k or kk > artifact.max_k) {
            streams.err.print("zit {s}: --k wants 1..12 (got {d})\n", .{ verb, kk }) catch {};
            return null;
        }
        streams.out.print("k={d} pinned by --k\n", .{kk}) catch {};
        return @intCast(kk);
    }
    const tip = repo.resolve("tip", .checkin) catch {
        streams.err.print("zit {s}: the source repository has no check-in to measure\n", .{verb}) catch {};
        return null;
    };
    var fl = (repo.files(gpa, tip) catch null) orelse {
        streams.err.print("zit {s}: cannot read the source tip check-in\n", .{verb}) catch {};
        return null;
    };
    defer fl.deinit(gpa);

    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const aa = arena_state.allocator();
    var entries: std.ArrayList(artifact.FCard) = .empty;
    var it = fl.iterator();
    while (it.next()) |f| {
        entries.append(aa, .{
            .filename = aa.dupe(u8, f.filename) catch return null,
            .hash = if (f.hash) |h| aa.dupe(u8, h) catch return null else null,
            .perm = if (f.perm) |p| aa.dupe(u8, p) catch return null else null,
            .old_name = null,
            .mode = f.mode,
        }) catch return null;
    }
    if (entries.items.len == 0) {
        streams.err.print("zit {s}: the source tip has no files to measure\n", .{verb}) catch {};
        return null;
    }
    // The measurement algo follows the source identity space: 40-hex = SHA-1.
    const algo: artifact.HashAlgo = if (repo.hashOf(tip).len == 40) .sha1 else .sha3_256;
    var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined;
    var n: usize = 0;
    var k: u8 = artifact.min_k;
    while (k <= artifact.max_k) : (k += 1) {
        costs[n] = zit.submanifest.cost(gpa, entries.items, @intCast(k), algo) catch return null;
        n += 1;
    }
    const w = zit.submanifest.best(costs[0..n]) orelse {
        // Every k leaves this name set flat (§6.2 step 2): the same refusal
        // `zit submanifest enable` makes, for the same reason — nothing here
        // would become a tree, and inventing a k is not the converter's call.
        streams.err.print(
            "zit {s}: no k partitions the source tip's {d} names into a tree (§6.2 step 2),\n" ++
                "so there is nothing to convert into sub-manifests; pin one with --k N if you\n" ++
                "want the rebirth anyway, or import with --compatible instead.\n",
            .{ verb, entries.items.len },
        ) catch {};
        return null;
    };
    streams.out.print("k={d} minimizes the §6.3 objective over the source tip's {d} names\n", .{ w.k, entries.items.len }) catch {};
    return w.k;
}

fn oom(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit: out of memory\n") catch {};
    return .failure;
}