Artifact cb5a8ce12bb5f3290def6bcc395dfd8d43d517487e8a90b41185421fc977d369:
- File
src/fossil/fossil_import_cli.zig
— part of check-in
[75c8669553]
at
2026-08-28 01:00:42
on branch trunk
— import-fossil --submanifest: strip SSH signatures, verify per pair in the mint loop, fold the census into pass 1, skip the replay under --k --force
The converter only stripped PGP armor, so an SSH-signed check-in re-minted with its envelope inside and failed its own verify silently. Verification now runs as each pair is minted, the census counts come from the first pass instead of a second corpus read, and --k narrows the gate's replay to one arm (skipped with --force). Each phase reports its wall time on stderr; the summary points at 'zit pack'. Closes zit#155. (user: dmestas, size: 14576) [annotate] [blame] [check-ins using]
//! `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); var clock = convert.Phase.start(io, streams); const verdict = gate(gpa, &repo, k_override, force, streams) orelse return .failure; clock.done("gate"); 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 phantoms = convert.phantomCount(gpa, &repo) catch { streams.err.writeAll("zit import-fossil: walking the source corpus failed\n") catch {}; return .failure; }; if (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, 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, 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, io, 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 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 import-fossil: accounting mismatch — {d} artifacts in, {d} accounted; nothing published\n", .{ result.census.total, 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" ++ "the store is written full-form; run 'zit pack' there to delta-encode it\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, force: bool, 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; }; // --k with --force is a decision already taken: the replay would only // reach a verdict the operator has said to ignore, so it is skipped // (#155: on a 305k-commit history the replay is half the wall time). if (k_override != null and force) { streams.out.print("gate: skipped — --k {d} with --force decides without a replay\n", .{candidate}) catch {}; return .{ .passes = true, .k = candidate }; } // Whole-history replay of the candidate and, unless the operator pinned // it with --k, its in-range neighbours. var ks_buf: [3]u4 = undefined; var n_ks: usize = 0; if (k_override == null and candidate > artifact.min_k) { ks_buf[n_ks] = candidate - 1; n_ks += 1; } ks_buf[n_ks] = candidate; n_ks += 1; if (k_override == null and 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; }