//! Export planning and enumeration: the topological frontier over
//! `RepoState` (every parent before every child, the §8.3 timeline order as
//! the deterministic tiebreak among ready check-ins, mark-mapped prefixes
//! excluded), and the ONE seam through which the exporter consumes file
//! sets. Translation to fast-import commands builds on these (Task 5).
const std = @import("std");
const artifact = @import("../artifact.zig");
const repostate = @import("../repostate.zig");
const repository = @import("../repository.zig");
const fast_export = @import("git_fast_export.zig");
/// The "already exported" predicate the planner consults — a `MirrorState`
/// in production, a seeded set or nothing in tests. An interface rather than
/// the concrete state so planning stays pure over `RepoState`.
pub const AlreadyExported = struct {
context: ?*const anyopaque,
containsFn: *const fn (context: ?*const anyopaque, hash: []const u8) bool,
pub fn contains(self: AlreadyExported, hash: []const u8) bool {
return self.containsFn(self.context, hash);
}
};
/// Nothing exported yet — the full-export predicate.
pub const NoneExported = struct {
fn never(_: ?*const anyopaque, _: []const u8) bool {
return false;
}
pub fn predicate(_: *NoneExported) AlreadyExported {
return .{ .context = null, .containsFn = never };
}
};
/// A hash-set-backed predicate (tests, and the state adapter's shape).
pub const SetExported = struct {
hashes: std.StringHashMapUnmanaged(void) = .empty,
pub fn deinit(self: *SetExported, gpa: std.mem.Allocator) void {
self.hashes.deinit(gpa);
}
fn containsImpl(context: ?*const anyopaque, hash: []const u8) bool {
const self: *const SetExported = @ptrCast(@alignCast(context.?));
return self.hashes.contains(hash);
}
pub fn predicate(self: *SetExported) AlreadyExported {
return .{ .context = self, .containsFn = containsImpl };
}
};
/// Plan the export: every UNEXPORTED check-in, ordered so each parent
/// precedes each child (Kahn over `parentsOf` restricted to check-ins), with
/// the §8.3 timeline order (oldest first) breaking ties among ready nodes —
/// so two runs over one repository plan identically. A parent that is
/// already exported, or is a phantom the state never crosslinked, counts as
/// satisfied rather than blocking. Every structure is bounded by the
/// check-in count. Caller frees the returned slice.
pub fn plan(
gpa: std.mem.Allocator,
rs: *const repostate.RepoState,
already: AlreadyExported,
) error{OutOfMemory}![]repostate.Rid {
// Timeline rank: position in OLDEST-first order — the deterministic
// tiebreak. `checkinsByMtime` is newest-first, so rank reverses it.
const timeline = try rs.checkinsByMtime(gpa);
defer gpa.free(timeline);
var rank_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty;
defer rank_of.deinit(gpa);
try rank_of.ensureTotalCapacity(gpa, @intCast(timeline.len));
for (timeline, 0..) |rid, newest_first_index| {
const rank: u32 = @intCast(timeline.len - 1 - newest_first_index);
rank_of.putAssumeCapacity(rid, rank);
}
// Indegree over UNEXPORTED planned nodes, and a local children multimap
// (repostate's adjacency serves parent walks; the planner needs the
// other direction only transiently).
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 it = children.valueIterator();
while (it.next()) |list| list.deinit(gpa);
children.deinit(gpa);
}
for (timeline) |rid| {
if (already.contains(rs.store.hashOf(rid))) continue;
var degree: u32 = 0;
var it = rs.parentsOf(rid);
while (it.next()) |parent| {
if (rs.mtimeOf(parent) == null) continue; // phantom: never planned
if (already.contains(rs.store.hashOf(parent))) continue; // satisfied
degree += 1;
const gop = try children.getOrPut(gpa, parent);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(gpa, rid);
}
try indegree.put(gpa, rid, degree);
}
const Ready = struct {
fn lessThan(context: *const std.AutoHashMapUnmanaged(repostate.Rid, u32), a: repostate.Rid, b: repostate.Rid) std.math.Order {
return std.math.order(context.get(a).?, context.get(b).?);
}
};
var ready = std.PriorityQueue(repostate.Rid, *const std.AutoHashMapUnmanaged(repostate.Rid, u32), Ready.lessThan).initContext(&rank_of);
defer ready.deinit(gpa);
var degrees = indegree.iterator();
while (degrees.next()) |entry| {
if (entry.value_ptr.* == 0) try ready.push(gpa, entry.key_ptr.*);
}
var out: std.ArrayList(repostate.Rid) = .empty;
errdefer out.deinit(gpa);
while (ready.pop()) |rid| {
try out.append(gpa, rid);
const kids = children.getPtr(rid) orelse continue;
for (kids.items) |child| {
const degree = indegree.getPtr(child).?;
degree.* -= 1;
if (degree.* == 0) try ready.push(gpa, child);
}
}
// A hash DAG cannot cycle, so the plan covers every unexported check-in.
std.debug.assert(out.items.len == indegree.count());
return out.toOwnedSlice(gpa);
}
/// The §9.3 seam, named once: the exporter's ONLY file-set source is
/// `Repository.files` — the same flatten-behind-a-resolver,
/// memoized-sub-manifest, baseline-merging enumeration every verb reads
/// (flat, S-card tree, and delta manifests alike; §5.2 hard failures
/// propagate as the fail-closed export errors they are). Kept as a named
/// wrapper so the seam-discipline sweep has a single site to audit.
pub fn enumerate(
repo: *repository.Repository,
gpa: std.mem.Allocator,
rid: repostate.Rid,
) repository.Repository.FilesError!?repository.Repository.FileList {
return repo.files(gpa, rid);
}
// ---------------------------------------------------------------------------
// Translation: check-ins to fast-import events (Task 5).
// ---------------------------------------------------------------------------
pub const TranslateError = error{
OutOfMemory,
/// §5.2 hard failure or an unreadable artifact — the fail-closed export
/// posture: the error NAMES the check-in (see `Translator.fault`).
EnumerationFailed,
/// A file path no Git tree may carry: `.git`, `.`, `..`, or an empty
/// segment. Named in `Translator.fault`.
ForbiddenPath,
/// A parent this commit references was neither planned before it nor
/// recorded in the mark map — a planner/state invariant violation.
ParentUnmapped,
/// A file card whose blob content the store cannot produce.
BlobUnreadable,
};
/// What a refused translation names: the artifact and (when path-shaped) the
/// offending path. The translator has no output channel; the CLI formats.
pub const Fault = struct {
hash: [64]u8 = undefined,
hash_len: u8 = 0,
path: [512]u8 = undefined,
path_len: usize = 0,
fn setHash(self: *Fault, hash: []const u8) void {
const n = @min(hash.len, self.hash.len);
@memcpy(self.hash[0..n], hash[0..n]);
self.hash_len = @intCast(n);
}
fn setPath(self: *Fault, path: []const u8) void {
const n = @min(path.len, self.path.len);
@memcpy(self.path[0..n], path[0..n]);
self.path_len = n;
}
pub fn hashText(self: *const Fault) []const u8 {
return self.hash[0..self.hash_len];
}
pub fn pathText(self: *const Fault) []const u8 {
return self.path[0..self.path_len];
}
};
/// One translated commit: the blob and commit events to stream, arena-owned.
/// Draining and deiniting this is the caller's ONLY retention — the
/// translator itself keeps one (hash, mark) per commit and one mark per
/// distinct blob, never a file list or card slice (the importer's
/// `CommitMarkState` discipline, mirrored).
pub const TranslatedCommit = struct {
arena_state: std.heap.ArenaAllocator,
events: []const fast_export.Event,
/// The mark assigned to this commit — the caller records it against the
/// check-in hash once the session confirms the stream landed.
mark: u64,
pub fn deinit(self: *TranslatedCommit) void {
self.arena_state.deinit();
self.* = undefined;
}
};
pub const Translator = struct {
gpa: std.mem.Allocator,
/// The synthesized-identity email domain (`login@DOMAIN`).
email_domain: []const u8 = "zit.invalid",
/// Marks handed out, monotonically. Mark 0 is never used (fast-import
/// reserves the meaning "no mark").
next_mark: u64 = 1,
/// Distinct blob content hash -> mark. Global by content addressing —
/// exactly the reuse the stream needs to emit each blob once.
blob_marks: std.StringHashMapUnmanaged(u64) = .empty,
/// Exported check-in hash -> ObjectRef text: a mark from THIS run, or a
/// recorded OID from the state file on incremental runs.
commit_refs: std.StringHashMapUnmanaged(fast_export.ObjectRef) = .empty,
/// Owned copies backing the two maps' keys (and recorded OIDs).
owned: std.ArrayListUnmanaged([]const u8) = .empty,
/// The refusal detail for the most recent error return.
fault: Fault = .{},
pub fn init(gpa: std.mem.Allocator) Translator {
return .{ .gpa = gpa };
}
pub fn deinit(self: *Translator) void {
self.blob_marks.deinit(self.gpa);
self.commit_refs.deinit(self.gpa);
for (self.owned.items) |bytes| self.gpa.free(bytes);
self.owned.deinit(self.gpa);
self.* = undefined;
}
/// The constant-state observable: entries retained across commits. One
/// per exported commit plus one per distinct blob — never proportional
/// to file-set width.
pub fn retainedStateEntries(self: *const Translator) usize {
return self.blob_marks.count() + self.commit_refs.count();
}
fn retain(self: *Translator, bytes: []const u8) error{OutOfMemory}![]const u8 {
const copy = try self.gpa.dupe(u8, bytes);
errdefer self.gpa.free(copy);
try self.owned.append(self.gpa, copy);
return copy;
}
/// Seed a commit's ref from the state file (incremental runs): parents
/// exported by a PRIOR run resolve by recorded OID instead of a mark.
pub fn seedExported(self: *Translator, hash: []const u8, oid: []const u8) error{OutOfMemory}!void {
const owned_hash = try self.retain(hash);
const owned_oid = try self.retain(oid);
try self.commit_refs.put(self.gpa, owned_hash, .{ .oid = owned_oid });
}
/// Translate one planned check-in into its blob + commit events. The
/// file-set diff runs against the PRIMARY parent's seam enumeration
/// (re-read, never retained); a parentless check-in emits `deleteall`
/// plus its full set. Fail-closed: any §8 refusal names the check-in and
/// path in `fault` and returns its precise error.
pub fn translateCommit(
self: *Translator,
repo: *repository.Repository,
rid: repostate.Rid,
) TranslateError!TranslatedCommit {
const gpa = self.gpa;
const hash = repo.hashOf(rid);
self.fault = .{};
self.fault.setHash(hash);
var arena_state = std.heap.ArenaAllocator.init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
var events: std.ArrayList(fast_export.Event) = .empty;
var owned_manifest = (repo.manifest(gpa, rid) catch return error.EnumerationFailed) orelse return error.EnumerationFailed;
defer owned_manifest.deinit(gpa);
const m = &owned_manifest.manifest;
// Primary parent (first hash of the first P card), if any.
var parent_hash_buf: [64]u8 = undefined;
var primary_parent: ?[]const u8 = null;
if (m.p.len > 0) {
var it = m.p[0].hashes();
if (it.next()) |first| {
@memcpy(parent_hash_buf[0..first.len], first);
primary_parent = parent_hash_buf[0..first.len];
}
}
// Both enumerations, filename-ordered by §8.1; a single merge walk
// yields adds/changes/deletes.
var current = (enumerate(repo, gpa, rid) catch return error.EnumerationFailed) orelse return error.EnumerationFailed;
defer current.deinit(gpa);
var commands: std.ArrayList(fast_export.FileCommand) = .empty;
if (primary_parent) |parent_hex| {
const parent_rid = repo.lookup(parent_hex) orelse return error.ParentUnmapped;
var parent_files = (enumerate(repo, gpa, parent_rid) catch return error.EnumerationFailed) orelse return error.EnumerationFailed;
defer parent_files.deinit(gpa);
try self.diffInto(arena, repo, &events, &commands, &parent_files, ¤t);
} else {
try commands.append(arena, .delete_all);
var it = current.iterator();
while (it.next()) |card| {
try self.modifyFor(arena, repo, &events, &commands, card);
}
}
// Parent refs in P-run order: `from` is the primary, the rest merge.
var from: ?fast_export.ObjectRef = null;
var merges: std.ArrayList(fast_export.ObjectRef) = .empty;
for (m.p) |pcard| {
var it = pcard.hashes();
while (it.next()) |parent| {
const ref = self.commit_refs.get(parent) orelse {
self.fault.setPath(parent);
return error.ParentUnmapped;
};
const duped: fast_export.ObjectRef = switch (ref) {
.mark => |mk| .{ .mark = mk },
.oid => |oid| .{ .oid = try arena.dupe(u8, oid) },
};
if (from == null) from = duped else try merges.append(arena, duped);
}
}
// C/D/U -> message, timestamp, synthesized identity.
const login: []const u8 = if (m.u) |u| u.login else "unknown";
const email = try std.fmt.allocPrint(arena, "{s}@{s}", .{ login, self.email_domain });
const seconds: i64 = blk: {
const d = m.d orelse break :blk 0;
const ms = repostate.parseMtime(d.timestamp) orelse break :blk 0;
break :blk @intCast(@divTrunc(ms, 1000));
};
const identity: fast_export.Identity = .{
.name = try arena.dupe(u8, login),
.email = email,
.timestamp = seconds,
.timezone_offset_minutes = 0,
};
const message: []const u8 = if (m.c) |c| try arena.dupe(u8, c.comment) else "";
const branch = repo.state().branchOf(rid, repo.mainBranch());
const ref_name = try refForBranch(arena, branch);
const mark = self.next_mark;
self.next_mark += 1;
try events.append(arena, .{ .commit = .{
.ref = ref_name,
.mark = mark,
.original_oid = null,
.author = identity,
.committer = identity,
.message = message,
.from = from,
.merges = merges.items,
.commands = commands.items,
} });
// Retain exactly one (hash, mark) for this commit.
const owned_hash = try self.retain(hash);
try self.commit_refs.put(gpa, owned_hash, .{ .mark = mark });
return .{
.arena_state = arena_state,
.events = events.items,
.mark = mark,
};
}
fn diffInto(
self: *Translator,
arena: std.mem.Allocator,
repo: *repository.Repository,
events: *std.ArrayList(fast_export.Event),
commands: *std.ArrayList(fast_export.FileCommand),
parent_files: *const repository.Repository.FileList,
current: *const repository.Repository.FileList,
) TranslateError!void {
var old_it = parent_files.iterator();
var new_it = current.iterator();
var old = old_it.next();
var new = new_it.next();
while (old != null or new != null) {
const order: std.math.Order = if (old == null)
.gt
else if (new == null)
.lt
else
artifact.compareBytes(old.?.filename, new.?.filename);
switch (order) {
.lt => {
// Present in the parent only: deleted.
try commands.append(arena, .{ .delete = try arena.dupe(u8, old.?.filename) });
old = old_it.next();
},
.gt => {
try self.modifyFor(arena, repo, events, commands, new.?);
new = new_it.next();
},
.eq => {
const same_hash = std.mem.eql(u8, old.?.hash orelse "", new.?.hash orelse "");
const same_mode = old.?.mode == new.?.mode;
if (!same_hash or !same_mode) {
try self.modifyFor(arena, repo, events, commands, new.?);
}
old = old_it.next();
new = new_it.next();
},
}
}
}
fn modifyFor(
self: *Translator,
arena: std.mem.Allocator,
repo: *repository.Repository,
events: *std.ArrayList(fast_export.Event),
commands: *std.ArrayList(fast_export.FileCommand),
card: artifact.FCard,
) TranslateError!void {
try validatePathForGit(&self.fault, card.filename);
const content_hash = card.hash orelse {
self.fault.setPath(card.filename);
return error.BlobUnreadable;
};
const mark = try self.blobMarkFor(arena, repo, events, content_hash, card.filename);
try commands.append(arena, .{ .modify = .{
.mode = switch (card.mode) {
.regular => .regular,
.executable => .executable,
.symlink => .symlink,
},
.data = .{ .mark = mark },
.path = try arena.dupe(u8, card.filename),
} });
}
/// The blob's mark: existing on content-hash reuse, else read the bytes
/// through the store, emit the blob event, and record the new mark.
fn blobMarkFor(
self: *Translator,
arena: std.mem.Allocator,
repo: *repository.Repository,
events: *std.ArrayList(fast_export.Event),
content_hash: []const u8,
path: []const u8,
) TranslateError!u64 {
if (self.blob_marks.get(content_hash)) |existing| return existing;
const bytes = (repo.blob(arena, content_hash) catch {
self.fault.setPath(path);
return error.BlobUnreadable;
}) orelse {
self.fault.setPath(path);
return error.BlobUnreadable;
};
const mark = self.next_mark;
self.next_mark += 1;
try events.append(arena, .{ .blob = .{ .mark = mark, .original_oid = null, .data = bytes } });
const owned_hash = try self.retain(content_hash);
try self.blob_marks.put(self.gpa, owned_hash, mark);
return mark;
}
};
/// §8 path policing: a Git tree may not carry `.git` (case-insensitive), `.`,
/// `..`, or an empty segment. The seam already excluded reserved Fossil
/// names; these are Git's own refusals, checked per exported path.
pub fn validatePathForGit(fault: *Fault, path: []const u8) TranslateError!void {
if (path.len == 0) {
fault.setPath(path);
return error.ForbiddenPath;
}
var it = std.mem.splitScalar(u8, path, '/');
while (it.next()) |segment| {
const forbidden = segment.len == 0 or
std.mem.eql(u8, segment, ".") or
std.mem.eql(u8, segment, "..") or
std.ascii.eqlIgnoreCase(segment, ".git");
if (forbidden) {
fault.setPath(path);
return error.ForbiddenPath;
}
}
}
/// `refs/heads/<sanitized branch>`, sanitized per git-check-ref-format's
/// component rules, locally (no `git` subprocess): control bytes, space,
/// `~ ^ : ? * [ \` and DEL become `-`; a leading `.` or `-`, a trailing
/// `.`, and the sequences `..` and `@{` are broken with `-`; `.lock` at the
/// end is suffixed. Deterministic, total, and never empty.
pub fn refForBranch(arena: std.mem.Allocator, branch: []const u8) error{OutOfMemory}![]const u8 {
var name: std.ArrayList(u8) = .empty;
try name.appendSlice(arena, "refs/heads/");
const base = name.items.len;
for (branch) |byte| {
const bad = byte < 0x20 or byte == 0x7f or byte == ' ' or byte == '~' or
byte == '^' or byte == ':' or byte == '?' or byte == '*' or byte == '[' or byte == '\\';
var next: u8 = if (bad) '-' else byte;
const n = name.items.len;
if (next == '.' and (n == base or name.items[n - 1] == '.' or name.items[n - 1] == '/')) next = '-';
if (next == '{' and n > base and name.items[n - 1] == '@') next = '-';
if (next == '-' and n == base) {
try name.append(arena, '_');
continue;
}
try name.append(arena, next);
}
if (name.items.len == base) try name.appendSlice(arena, "unnamed");
if (name.items[name.items.len - 1] == '.') name.items[name.items.len - 1] = '-';
if (std.mem.endsWith(u8, name.items, ".lock")) try name.append(arena, '-');
return name.toOwnedSlice(arena);
}