//! `zit import-fossil` — bring a fossil repository's history into zit, with
//! the compatibility consequence CHOSEN AT IMPORT TIME (#61). 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 #41 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` (the #61 step-1 extraction).
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");
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,
force: bool,
streams: cli.Streams,
) cli.Exit {
const root = (cli.resolveRoot(arena, io, source) catch return oom(streams)) orelse {
streams.err.print("zit import-fossil: '{s}' is not a zit repository (clone the fossil origin first: 'zit clone URL DIR')\n", .{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, .{}) catch {
streams.err.writeAll("zit import-fossil: cannot read the source repository\n") catch {};
return .failure;
};
defer repo.deinit(gpa);
const verdict = gate(gpa, &repo, k_override, streams) orelse return .failure;
if (!verdict.passes and !force) {
streams.err.writeAll(
"zit import-fossil: the full-history replay says a sub-manifest conversion\n" ++
"would cost more than flat manifests for this history; refusing. Pass --force\n" ++
"to convert anyway, or import with --compatible instead.\n",
) catch {};
return .failure;
}
const corpus = convert.census(gpa, &repo) catch {
streams.err.writeAll("zit import-fossil: walking the source corpus failed\n") catch {};
return .failure;
};
convert.renderCensus(&corpus, streams);
if (corpus.phantoms != 0) {
streams.err.writeAll("zit import-fossil: the source holds phantoms — an incomplete corpus cannot re-mint; pull to convergence first\n") catch {};
return .failure;
}
return runConversion(gpa, io, &repo, destination, verdict.k, &corpus, streams);
}
/// The conversion itself, under the staged-publication discipline (#61):
/// 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,
corpus: *const convert.Census,
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 import-fossil: cannot stage beside '{s}'\n", .{destination}) catch {};
return .failure;
};
var live = true;
defer if (live) bundle.abort();
bundle.advance(.raw_frozen) catch return convFail(streams);
bundle.advance(.packing) catch return convFail(streams);
var file = bundle.createPackedGeneration() catch return convFail(streams);
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);
defer dest.deinit(gpa);
// The NEW project code -- the non-negotiable identity break (#61):
// 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);
const result = convert.remint(gpa, repo, &dest, k, &project_code, streams) catch |e| {
switch (e) {
error.Diverged => {}, // already reported with the path
error.BrokenAncestry => streams.err.writeAll("zit import-fossil: the source corpus has holes; pull to convergence first\n") catch {},
error.OutOfMemory => streams.err.writeAll("zit import-fossil: out of memory\n") catch {},
error.Storage, error.Corrupt => streams.err.writeAll("zit import-fossil: reading the source failed\n") catch {},
}
return .failure;
};
// Count reconciliation against the census: re-minted + carried +
// dropped must account for every source artifact, or the conversion
// lost something silently.
const accounted = result.checkins + result.blobs + result.carried +
result.controls_remapped + result.clusters_dropped;
if (accounted != corpus.total - corpus.phantoms) {
streams.err.print(
"zit import-fossil: accounting mismatch — {d} artifacts in, {d} accounted; nothing published\n",
.{ corpus.total - corpus.phantoms, accounted },
) catch {};
return .failure;
}
dest.commit() catch return convFail(streams);
bundle.advance(.packed_frozen) catch return convFail(streams);
// 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);
bundle.discardRawGeneration() catch return convFail(streams); // 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);
bundle.verifyStagingManifest() catch return convFail(streams);
bundle.publish(destination, .{}) catch {
streams.err.print("zit import-fossil: publishing '{s}' failed (it may already exist)\n", .{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 {};
return .ok;
}
fn convFail(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit import-fossil: the conversion could not complete; nothing was published\n") catch {};
return .failure;
}
const Verdict = struct {
passes: bool,
k: u4,
};
/// The #41 evidence gate: static k sweep over the tip file set names the
/// candidate, a WHOLE-history replay through the real tree builder and
/// delta encoder prices it, and the verdict compares steady-state full-form
/// cost against the flat arm. Prints what it measures; null = could not
/// measure (already diagnosed).
fn gate(
gpa: std.mem.Allocator,
repo: *Repository,
k_override: ?u8,
streams: cli.Streams,
) ?Verdict {
const tip = repo.resolve("tip", .checkin) catch {
streams.err.writeAll("zit import-fossil: the source repository has no check-in to measure\n") catch {};
return null;
};
var fl = (repo.files(gpa, tip) catch null) orelse {
streams.err.writeAll("zit import-fossil: cannot read the source tip check-in\n") 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.writeAll("zit import-fossil: the source tip has no files to measure\n") catch {};
return null;
}
streams.out.print("gate: measuring {d} names at the source tip\n", .{entries.items.len}) catch {};
// Static sweep names the candidate k (the operator's --k overrides it).
// 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 candidate: u4 = if (k_override) |kk| @intCast(kk) else blk: {
const w = zit.submanifest.best(costs[0..n]) orelse {
streams.out.writeAll("gate: no k partitions this file set into a tree — every commit would stay flat\n") catch {};
return .{ .passes = false, .k = 0 };
};
break :blk w.k;
};
// Whole-history replay of the candidate and its in-range neighbours.
var ks_buf: [3]u4 = undefined;
var n_ks: usize = 0;
if (candidate > artifact.min_k) {
ks_buf[n_ks] = candidate - 1;
n_ks += 1;
}
ks_buf[n_ks] = candidate;
n_ks += 1;
if (candidate < artifact.max_k) {
ks_buf[n_ks] = candidate + 1;
n_ks += 1;
}
var rep = zit.submanifest.replay(gpa, repo, std.math.maxInt(usize), ks_buf[0..n_ks]) catch {
streams.err.writeAll("zit import-fossil: replaying the source history failed\n") catch {};
return null;
};
defer rep.deinit(gpa);
if (rep.commits < 2) {
streams.out.print("gate: only {d} replayable commit(s) — too little history to refuse on; proceeding is --force territory either way\n", .{rep.commits}) catch {};
return .{ .passes = false, .k = candidate };
}
streams.out.print(
"gate: replayed all {d} commits — flat store {d} bytes, flat full-form {d} bytes\n",
.{ rep.commits, rep.flat_store, rep.flat_full },
) catch {};
for (rep.arms) |arm| {
if (arm.all_flat) continue;
streams.out.print(
"gate: k={d} steady-state full-form {d} bytes (flat {d}), store {d} bytes (flat {d}), bootstrap {d}\n",
.{ arm.k, arm.steady_full, rep.flat_full_steady, arm.steady_store, rep.flat_store_steady, arm.bootstrap_store },
) catch {};
}
const winner = rep.bestArm() orelse {
streams.out.writeAll("gate: every replayed k built flat — this history gives sub-manifests nothing to share\n") catch {};
return .{ .passes = false, .k = candidate };
};
const passes = winner.steady_full < rep.flat_full_steady;
streams.out.print(
"gate verdict: k={d} {s}\n",
.{ winner.k, if (passes) "beats flat on steady-state full-form cost over the whole history" else "does not beat flat over the whole history" },
) catch {};
return .{ .passes = passes, .k = winner.k };
}
fn oom(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit import-fossil: out of memory\n") catch {};
return .failure;
}