//! The submanifest conversion pass (#61): re-mint a fossil corpus as a new
//! project with S-card tree manifests from the first commit.
//!
//! The load-bearing observation is the RE-MINT CASCADE RULE: an artifact's
//! name changes only when its BYTES change, and (with content-blob names
//! kept, the phase-1 posture) bytes change only for an artifact that either
//! gains the S-tree structure (every check-in manifest) or references a
//! re-minted artifact in a hash-bearing card. So the remap surface is:
//!
//! check-in manifests all re-mint (S-trees + remapped P/Q/B; D/C/U and
//! the R card survive -- R is over the file SET, which
//! is preserved exactly)
//! control artifacts re-mint iff a T card targets a remapped check-in
//! events (technotes) re-mint iff their P chain or T target remaps
//! wiki / forum P/I/G reference their own kind's chain -- verbatim
//! unless an ancestor re-minted (transitively closed)
//! attachments target a wiki NAME or ticket uuid + a CONTENT hash
//! -- verbatim in phase 1
//! tickets K ids are uuids, not artifact hashes -- verbatim
//! clusters dropped (sync housekeeping, not history)
//! content blobs verbatim (phase 1 keeps their names)
//! PGP signatures cannot survive rehashing: stripped and counted
//!
//! The census pass below is the converter's front door: it classifies the
//! whole corpus, counts the remap surface and the casualties, and is the
//! baseline the count-reconciliation acceptance check audits against.
const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const artifact = zit.artifact;
const logstore = zit.logstore;
const Repository = zit.Repository;
const repostate = zit.repostate;
/// One corpus walk: every stored artifact classified by parsed type, with
/// the phase-1 remap surface and casualty counts. Phantoms hold no bytes
/// and are skipped (they are the sync layer's business, not history's).
pub const Census = struct {
total: u64 = 0,
phantoms: u64 = 0,
blobs: u64 = 0,
checkins: u64 = 0,
submanifests: u64 = 0,
controls: u64 = 0,
wikis: u64 = 0,
tickets: u64 = 0,
attachments: u64 = 0,
events: u64 = 0,
forums: u64 = 0,
clusters: u64 = 0,
/// Check-in manifests wrapped in a PGP signature: the signature is a
/// stated casualty of any conversion (stripped and counted).
signed: u64 = 0,
pub fn structural(self: *const Census) u64 {
return self.checkins + self.submanifests + self.controls + self.wikis +
self.tickets + self.attachments + self.events + self.forums + self.clusters;
}
};
const pgp_prefix = "-----BEGIN PGP SIGNED MESSAGE-----";
/// Walk the source store and classify every artifact. Bounded by the
/// store's artifact count; each artifact is parsed once with the Z-tolerant
/// window parser (a parse failure is a content blob, by construction --
/// fossil's artifact grammar is what separates structure from content).
pub fn census(
gpa: std.mem.Allocator,
repo: *Repository,
) error{ OutOfMemory, Storage, Corrupt }!Census {
const log = repo.logStore(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
};
var out: Census = .{};
const n: u64 = log.artifactCount();
var rid: u64 = 1;
while (rid <= n) : (rid += 1) {
out.total += 1;
const r: logstore.Rid = @intCast(rid);
if (log.isPhantom(r)) {
out.phantoms += 1;
continue;
}
const bytes = (log.content(gpa, r) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => return error.Storage,
error.Corrupt => return error.Corrupt,
}) orelse {
out.phantoms += 1; // masked (shunned) reads as absent: not history
continue;
};
defer gpa.free(bytes);
const signed = std.mem.startsWith(u8, bytes, pgp_prefix);
var m = artifact.parseWindow(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => {
out.blobs += 1;
continue;
},
};
defer m.deinit();
switch (m.kind) {
.manifest => {
out.checkins += 1;
if (signed) out.signed += 1;
},
.submanifest => out.submanifests += 1,
.cluster => out.clusters += 1,
.control => out.controls += 1,
.wiki => out.wikis += 1,
.ticket => out.tickets += 1,
.attachment => out.attachments += 1,
.event => out.events += 1,
.forum => out.forums += 1,
}
}
return out;
}
/// Render the census for the operator: what converts, what carries, what
/// dies. The converter's own accounting reconciles against these numbers.
pub fn renderCensus(c: *const Census, streams: cli.Streams) void {
streams.out.print(
"census: {d} artifacts — {d} check-ins to re-mint ({d} PGP-signed: signatures strip)," ++
" {d} content blobs carried verbatim\n",
.{ c.total, c.checkins, c.signed, c.blobs },
) catch {};
if (c.controls + c.wikis + c.tickets + c.attachments + c.events + c.forums != 0) {
streams.out.print(
"census: {d} control, {d} wiki, {d} ticket, {d} attachment, {d} technote, {d} forum" ++
" — carried, re-minting only where a reference remaps\n",
.{ c.controls, c.wikis, c.tickets, c.attachments, c.events, c.forums },
) catch {};
}
if (c.clusters != 0) {
streams.out.print("census: {d} cluster(s) dropped (sync housekeeping, not history)\n", .{c.clusters}) catch {};
}
if (c.submanifests != 0) {
streams.out.print("census: {d} sub-manifest slice(s) already present in the source\n", .{c.submanifests}) catch {};
}
if (c.phantoms != 0) {
streams.out.print("census: {d} phantom/masked name(s) skipped — clone the origin to completeness first\n", .{c.phantoms}) catch {};
}
}
test "census: an empty-ish census sums structurally" {
// Goal: the structural() partition is total over ArtifactType — a new
// member without a census bucket fails the exhaustive switch in census()
// at compile time, and this pin keeps the arithmetic honest.
var c: Census = .{ .checkins = 2, .controls = 1, .clusters = 3 };
try std.testing.expectEqual(@as(u64, 6), c.structural());
}
// ---------------------------------------------------------------------------
// The re-mint pass (#61): check-ins become S-tree manifests under new names,
// every hash-bearing reference chases the old->new map, everything else
// carries verbatim. Phase 1 keeps content-blob names; new structural
// artifacts mint under SHA3-256 (the new project's native policy).
// ---------------------------------------------------------------------------
pub const RemintError = error{
OutOfMemory,
Storage,
Corrupt,
/// A parent reference left the corpus (the census phantom refusal should
/// have caught this; a hole here is a source-store defect).
BrokenAncestry,
/// The differential verify found a (old,new) pair whose file sets differ
/// -- the one bug class this converter must never publish.
Diverged,
};
pub const Remint = struct {
provenance_notes: u64 = 0,
checkins: u64 = 0,
blobs: u64 = 0,
carried: u64 = 0,
controls_remapped: u64 = 0,
signatures_stripped: u64 = 0,
clusters_dropped: u64 = 0,
verified_pairs: u64 = 0,
};
/// Re-mint the whole corpus from `repo` into `dest`. `dest` is a fresh
/// writable LogStore (the caller owns staging and publication). On success
/// every source check-in has a converted counterpart whose EFFECTIVE FILE
/// SET is byte-equivalent, proven pair by pair before returning.
pub fn remint(
gpa: std.mem.Allocator,
repo: *Repository,
dest: *logstore.LogStore,
k: u4,
project_code: []const u8,
streams: cli.Streams,
) RemintError!Remint {
const log = repo.logStore(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
};
var out: Remint = .{};
// old full-hex -> new full-hex, both owned. Sized by the census's
// structural count in spirit; grown on demand in practice.
var map: std.StringHashMapUnmanaged([]const u8) = .empty;
defer {
var it = map.iterator();
while (it.next()) |e| {
gpa.free(e.key_ptr.*);
gpa.free(e.value_ptr.*);
}
map.deinit(gpa);
}
// Pass 1: content blobs carry verbatim (phase 1 keeps their names), and
// every artifact classifies once into the work lists.
var checkin_rids: std.ArrayList(repostate.Rid) = .empty;
defer checkin_rids.deinit(gpa);
var carry_rids: std.ArrayList(logstore.Rid) = .empty;
defer carry_rids.deinit(gpa);
{
const n: u64 = log.artifactCount();
var rid: u64 = 1;
while (rid <= n) : (rid += 1) {
const r: logstore.Rid = @intCast(rid);
if (log.isPhantom(r)) return error.BrokenAncestry;
const bytes = (log.content(gpa, r) catch |e| return contentErr(e)) orelse return error.BrokenAncestry;
defer gpa.free(bytes);
var m = artifact.parseWindow(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => {
_ = dest.put(log.hashOf(r), bytes) catch return error.Storage;
out.blobs += 1;
continue;
},
};
defer m.deinit();
switch (m.kind) {
.manifest => {
// Collected via the DERIVED state below (topo order needs
// the P DAG, which repostate already holds).
},
.cluster => out.clusters_dropped += 1,
else => carry_rids.append(gpa, r) catch return error.OutOfMemory,
}
}
}
// Pass 2: check-ins in parents-first order. `events` holds every
// check-in; Kahn over `parentsOf` gives the order without recursion.
{
const state = repo.state();
const all = state.events.keys();
var indegree: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty;
defer indegree.deinit(gpa);
var children: std.AutoHashMapUnmanaged(repostate.Rid, std.ArrayListUnmanaged(repostate.Rid)) = .empty;
defer {
var cit = children.valueIterator();
while (cit.next()) |l| l.deinit(gpa);
children.deinit(gpa);
}
for (all) |rid| {
var deg: u32 = 0;
var pit = state.parentsOf(rid);
while (pit.next()) |p| {
if (state.mtimeOf(p) == null) continue; // not a check-in here
deg += 1;
const gop = children.getOrPut(gpa, p) catch return error.OutOfMemory;
if (!gop.found_existing) gop.value_ptr.* = .empty;
gop.value_ptr.append(gpa, rid) catch return error.OutOfMemory;
}
indegree.put(gpa, rid, deg) catch return error.OutOfMemory;
}
var queue: std.ArrayList(repostate.Rid) = .empty;
defer queue.deinit(gpa);
for (all) |rid| {
if ((indegree.get(rid) orelse 0) == 0) queue.append(gpa, rid) catch return error.OutOfMemory;
}
var taken: usize = 0;
while (taken < queue.items.len) {
const rid = queue.items[taken];
taken += 1;
try remintCheckin(gpa, repo, log, dest, rid, k, &map, &out);
if (children.getPtr(rid)) |kids| {
for (kids.items) |kid| {
const d = indegree.getPtr(kid).?;
d.* -= 1;
if (d.* == 0) queue.append(gpa, kid) catch return error.OutOfMemory;
}
}
}
if (taken != all.len) return error.BrokenAncestry; // a cycle is corrupt history
out.checkins = @intCast(taken);
checkin_rids.appendSlice(gpa, queue.items) catch return error.OutOfMemory;
}
// Pass 3: the carried structural artifacts. Controls target check-ins
// and re-mint through the map; wiki/forum/ticket/attachment/event chains
// reference only their own kind or uuids and carry verbatim (the
// cascade rule in this module's doc).
for (carry_rids.items) |r| {
const bytes = (log.content(gpa, r) catch |e| return contentErr(e)) orelse return error.BrokenAncestry;
defer gpa.free(bytes);
const rewritten = remapReferences(gpa, bytes, &map) catch return error.OutOfMemory;
if (rewritten) |new_bytes| {
defer gpa.free(new_bytes);
var name_buf: [64]u8 = undefined;
const new_hex = artifact.artifactName(.sha3_256, new_bytes, &name_buf);
var raw: [32]u8 = undefined;
_ = std.fmt.hexToBytes(raw[0 .. new_hex.len / 2], new_hex) catch unreachable;
_ = dest.put(raw[0 .. new_hex.len / 2], new_bytes) catch return error.Storage;
out.controls_remapped += 1;
} else {
_ = dest.put(log.hashOf(r), bytes) catch return error.Storage;
out.carried += 1;
}
}
// The provenance technote (#61 non-negotiable): the old->new map as an
// event artifact INSIDE the converted repository -- it syncs with the
// repo, touches no check-in's file set, and keeps every external hash
// citation resolvable by lookup. Deterministic: the tip's own timestamp
// and an id derived from the project code, so one source converts to
// byte-identical output every time.
try mintProvenance(gpa, repo, dest, &map, project_code, &out);
// Pass 4: the differential proof, pair by pair, before anything is
// published: the converted manifest's flattened file set must equal the
// source's effective set exactly. The source side re-reads through the
// same seam every verb uses; the destination side flattens the new tree
// through a resolver over `dest`.
for (checkin_rids.items) |rid| {
try verifyPair(gpa, repo, dest, rid, &map, streams);
out.verified_pairs += 1;
}
return out;
}
fn contentErr(e: logstore.ContentError) RemintError {
return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
error.Corrupt => error.Corrupt,
};
}
/// Re-mint ONE check-in: effective file set through the enumeration seam,
/// S-tree build, text-level card rewrite (F run out, S run in at its sorted
/// position, B dropped, P/Q/T hash tokens chased through the map, PGP
/// armor stripped, Z resealed), new name recorded in the map.
fn remintCheckin(
gpa: std.mem.Allocator,
repo: *Repository,
log: *logstore.LogStore,
dest: *logstore.LogStore,
rid: repostate.Rid,
k: u4,
map: *std.StringHashMapUnmanaged([]const u8),
out: *Remint,
) RemintError!void {
_ = log;
var owned = (repo.manifest(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
}) orelse return error.BrokenAncestry;
defer owned.deinit(gpa);
var fl = (repo.files(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
}) orelse return error.BrokenAncestry;
defer fl.deinit(gpa);
var entries: std.ArrayList(artifact.FCard) = .empty;
defer entries.deinit(gpa);
var it = fl.iterator();
while (it.next()) |f| entries.append(gpa, f) catch return error.OutOfMemory;
var build = artifact.buildTree(gpa, entries.items, k, .sha3_256) catch return error.OutOfMemory;
defer build.deinit();
for (build.subs) |sub| {
var raw: [32]u8 = undefined;
_ = std.fmt.hexToBytes(raw[0 .. sub.name.len / 2], sub.name) catch return error.Corrupt;
_ = dest.put(raw[0 .. sub.name.len / 2], sub.bytes) catch return error.Storage;
}
const source_text = stripClearsign(owned.bytes, out);
const new_bytes = rewriteManifest(gpa, source_text, entries.items, &build, map) catch return error.OutOfMemory;
defer gpa.free(new_bytes);
var name_buf: [64]u8 = undefined;
const new_hex = artifact.artifactName(.sha3_256, new_bytes, &name_buf);
var raw: [32]u8 = undefined;
_ = std.fmt.hexToBytes(raw[0..32], new_hex) catch unreachable;
_ = dest.put(raw[0..32], new_bytes) catch return error.Storage;
const old_key = gpa.dupe(u8, repo.hashOf(rid)) catch return error.OutOfMemory;
errdefer gpa.free(old_key);
const new_val = gpa.dupe(u8, new_hex) catch return error.OutOfMemory;
map.put(gpa, old_key, new_val) catch return error.OutOfMemory;
}
/// Unwrap a PGP clearsign envelope, counting the casualty. Manifest card
/// lines never begin with '-', so fossil's dash-escaping never fires and
/// the payload is exactly the lines between the armor's blank separator and
/// the signature block.
fn stripClearsign(bytes: []const u8, out: *Remint) []const u8 {
if (!std.mem.startsWith(u8, bytes, "-----BEGIN PGP SIGNED MESSAGE-----")) return bytes;
const body_start = (std.mem.indexOf(u8, bytes, "\n\n") orelse return bytes) + 2;
const sig = std.mem.indexOfPos(u8, bytes, body_start, "-----BEGIN PGP SIGNATURE-----") orelse return bytes;
out.signatures_stripped += 1;
return bytes[body_start..sig];
}
/// The text-level manifest rewrite. Cards stay in their §4 sorted order:
/// F lines are replaced by the S run (which sorts exactly where F did not:
/// after R, before T), B lines drop (the effective set is fully
/// materialized in the tree), P/Q/T lines have every hash token chased
/// through the map, everything else carries byte-verbatim, and Z reseals
/// over the result.
fn rewriteManifest(
gpa: std.mem.Allocator,
text: []const u8,
entries: []const artifact.FCard,
build: *const artifact.TreeBuild,
map: *const std.StringHashMapUnmanaged([]const u8),
) error{OutOfMemory}![]u8 {
_ = entries;
var outb: std.ArrayList(u8) = .empty;
errdefer outb.deinit(gpa);
var emitted_s = false;
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
const card = line[0];
if (!emitted_s and card > 'S' and card != 'Z') {
try appendSRun(gpa, &outb, build);
emitted_s = true;
}
switch (card) {
'F' => continue,
'B' => continue,
'Z' => {
if (!emitted_s) {
try appendSRun(gpa, &outb, build);
emitted_s = true;
}
try appendZ(gpa, &outb);
},
'P', 'Q', 'T' => try appendRemapped(gpa, &outb, line, map),
else => {
try outb.appendSlice(gpa, line);
try outb.append(gpa, '\n');
},
}
}
if (!emitted_s) try appendSRun(gpa, &outb, build);
return outb.toOwnedSlice(gpa);
}
fn appendSRun(gpa: std.mem.Allocator, outb: *std.ArrayList(u8), build: *const artifact.TreeBuild) error{OutOfMemory}!void {
for (build.s) |s_card| {
var enc_buf: [2 * 4096]u8 = undefined;
const encoded = artifact.encodeEscapes(s_card.filename, &enc_buf);
try outb.appendSlice(gpa, "S ");
try outb.appendSlice(gpa, encoded);
try outb.append(gpa, ' ');
try outb.appendSlice(gpa, s_card.hash);
try outb.append(gpa, '\n');
}
}
/// One card line with every 40/64-hex token replaced by its mapped
/// counterpart when one exists. Tokens outside the map (content hashes,
/// '*', tag names) pass through untouched.
fn appendRemapped(
gpa: std.mem.Allocator,
outb: *std.ArrayList(u8),
line: []const u8,
map: *const std.StringHashMapUnmanaged([]const u8),
) error{OutOfMemory}!void {
var toks = std.mem.splitScalar(u8, line, ' ');
var first = true;
while (toks.next()) |tok| {
if (!first) try outb.append(gpa, ' ');
first = false;
if (map.get(tok)) |new| {
try outb.appendSlice(gpa, new);
} else if (tok.len > 1 and (tok[0] == '+' or tok[0] == '-' or tok[0] == '*')) {
// Q cards carry a sign prefix on the hash token.
if (map.get(tok[1..])) |new| {
try outb.append(gpa, tok[0]);
try outb.appendSlice(gpa, new);
} else {
try outb.appendSlice(gpa, tok);
}
} else {
try outb.appendSlice(gpa, tok);
}
}
try outb.append(gpa, '\n');
}
/// Seal the §6.2 Z card: md5 over every byte already emitted.
fn appendZ(gpa: std.mem.Allocator, outb: *std.ArrayList(u8)) error{OutOfMemory}!void {
var digest: [16]u8 = undefined;
std.crypto.hash.Md5.hash(outb.items, &digest, .{});
const hex = std.fmt.bytesToHex(digest, .lower);
try outb.appendSlice(gpa, "Z ");
try outb.appendSlice(gpa, &hex);
try outb.append(gpa, '\n');
}
/// Rewrite a carried structural artifact's references. Returns the new
/// bytes when anything changed (caller re-mints), null when nothing did
/// (caller carries verbatim). Only T lines can target a check-in here;
/// the map lookup is what decides, not the card letter.
fn remapReferences(
gpa: std.mem.Allocator,
bytes: []const u8,
map: *const std.StringHashMapUnmanaged([]const u8),
) error{OutOfMemory}!?[]u8 {
var outb: std.ArrayList(u8) = .empty;
errdefer outb.deinit(gpa);
var changed = false;
var lines = std.mem.splitScalar(u8, bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
if (line[0] == 'Z') {
try appendZ(gpa, &outb);
continue;
}
if (line[0] == 'T') {
const before = outb.items.len;
try appendRemapped(gpa, &outb, line, map);
const slice = outb.items[before..];
if (!std.mem.eql(u8, slice[0 .. slice.len - 1], line)) changed = true;
continue;
}
try outb.appendSlice(gpa, line);
try outb.append(gpa, '\n');
}
if (!changed) {
outb.deinit(gpa);
return null;
}
return try outb.toOwnedSlice(gpa);
}
/// The pair proof: flatten the converted tree manifest over the destination
/// store and compare its file set to the source's effective set, entry by
/// entry. The one bug class this converter must never publish is a
/// valid-looking manifest with the wrong content — this is the check that
/// makes that class loud.
fn verifyPair(
gpa: std.mem.Allocator,
repo: *Repository,
dest: *logstore.LogStore,
rid: repostate.Rid,
map: *const std.StringHashMapUnmanaged([]const u8),
streams: cli.Streams,
) RemintError!void {
const old_hex = repo.hashOf(rid);
const new_hex = map.get(old_hex) orelse return error.Diverged;
var raw: [32]u8 = undefined;
_ = std.fmt.hexToBytes(raw[0 .. new_hex.len / 2], new_hex) catch return error.Corrupt;
const new_rid = dest.lookup(raw[0 .. new_hex.len / 2]) orelse return error.Diverged;
const new_bytes = (dest.content(gpa, new_rid) catch |e| return contentErr(e)) orelse return error.Diverged;
defer gpa.free(new_bytes);
var m = artifact.parse(gpa, new_bytes) catch return error.Diverged;
defer m.deinit();
Repository.flattenTreeFromLog(gpa, dest, &m) catch return error.Diverged;
var fl = (repo.files(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
}) orelse return error.BrokenAncestry;
defer fl.deinit(gpa);
var it = fl.iterator();
var i: usize = 0;
const new_files = m.flattened orelse return divergedAt(streams, old_hex, "(flatten produced no file set)");
while (it.next()) |src_card| : (i += 1) {
if (i >= new_files.len) return divergedAt(streams, old_hex, src_card.filename);
const dst_card = new_files[i];
if (!std.mem.eql(u8, src_card.filename, dst_card.filename)) return divergedAt(streams, old_hex, src_card.filename);
const src_hash = src_card.hash orelse return divergedAt(streams, old_hex, src_card.filename);
const dst_hash = dst_card.hash orelse return divergedAt(streams, old_hex, src_card.filename);
if (!std.mem.eql(u8, src_hash, dst_hash)) return divergedAt(streams, old_hex, src_card.filename);
}
if (i != new_files.len) return divergedAt(streams, old_hex, "(extra converted entries)");
}
fn divergedAt(streams: cli.Streams, old_hex: []const u8, path: []const u8) RemintError {
streams.err.print(
"zit import-fossil: VERIFY DIVERGENCE at source check-in {s}, path '{s}' — nothing was published\n",
.{ old_hex, path },
) catch {};
return error.Diverged;
}
/// Compose and store the provenance technote(s). Pairs are emitted in
/// sorted-old-hash order (map iteration order is not deterministic; the
/// output must be). One note holds up to `pairs_per_note` pairs; a corpus
/// larger than that chains notes, each E-carded under an id derived from
/// the project code and its chunk index.
const pairs_per_note: usize = 16 * 1024;
fn mintProvenance(
gpa: std.mem.Allocator,
repo: *Repository,
dest: *logstore.LogStore,
map: *const std.StringHashMapUnmanaged([]const u8),
project_code: []const u8,
out: *Remint,
) RemintError!void {
if (map.count() == 0) return;
// The tip's D timestamp: the conversion's clock, without ambient time.
const tip = repo.resolve("tip", .checkin) catch return error.BrokenAncestry;
var tip_owned = (repo.manifest(gpa, tip) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
}) orelse return error.BrokenAncestry;
defer tip_owned.deinit(gpa);
const timestamp = if (tip_owned.manifest.d) |d| d.timestamp else "1970-01-01T00:00:00";
var olds: std.ArrayList([]const u8) = .empty;
defer olds.deinit(gpa);
var kit = map.keyIterator();
while (kit.next()) |k| olds.append(gpa, k.*) catch return error.OutOfMemory;
std.mem.sort([]const u8, olds.items, {}, sliceLess);
var chunk: usize = 0;
var offset: usize = 0;
while (offset < olds.items.len) : (chunk += 1) {
const end = @min(offset + pairs_per_note, olds.items.len);
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
body.appendSlice(gpa, "import-fossil provenance: OLD-check-in-hash NEW-check-in-hash, one pair per line.\n") catch return error.OutOfMemory;
for (olds.items[offset..end]) |old_hex| {
body.appendSlice(gpa, old_hex) catch return error.OutOfMemory;
body.append(gpa, ' ') catch return error.OutOfMemory;
body.appendSlice(gpa, map.get(old_hex).?) catch return error.OutOfMemory;
body.append(gpa, '\n') catch return error.OutOfMemory;
}
offset = end;
var id_material: std.ArrayList(u8) = .empty;
defer id_material.deinit(gpa);
id_material.appendSlice(gpa, "import-fossil-provenance:") catch return error.OutOfMemory;
id_material.appendSlice(gpa, project_code) catch return error.OutOfMemory;
id_material.append(gpa, ':') catch return error.OutOfMemory;
id_material.append(gpa, @intCast('0' + (chunk % 10))) catch return error.OutOfMemory;
var id_buf: [64]u8 = undefined;
const note_id = artifact.artifactName(.sha3_256, id_material.items, &id_buf);
var note: std.ArrayList(u8) = .empty;
defer note.deinit(gpa);
const head = std.fmt.allocPrint(
gpa,
"D {s}\nE {s} {s}\nU import-fossil\nW {d}\n{s}\n",
.{ timestamp, timestamp, note_id, body.items.len, body.items },
) catch return error.OutOfMemory;
defer gpa.free(head);
note.appendSlice(gpa, head) catch return error.OutOfMemory;
appendZ(gpa, ¬e) catch return error.OutOfMemory;
var name_buf: [64]u8 = undefined;
const note_hex = artifact.artifactName(.sha3_256, note.items, &name_buf);
var raw: [32]u8 = undefined;
_ = std.fmt.hexToBytes(raw[0..32], note_hex) catch unreachable;
_ = dest.put(raw[0..32], note.items) catch return error.Storage;
out.provenance_notes += 1;
}
}
fn sliceLess(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}