//! `ExportSession` — the subprocess half of `zit export-git`: preflight the
//! destination, drive one `git fast-import` child over the translated
//! stream, read the marks back, finalize refs and HEAD, and only then
//! rewrite `MirrorState` — the commit point. Every earlier failure leaves
//! the old state authoritative, so a re-run converges instead of
//! over-claiming. Git interaction goes through the `GitOps` seam: production
//! spawns the real binary; tests script every answer (the import side's
//! `CommandScript`/`dispatchForTest` shape).
const std = @import("std");
// Every dependency through the `zit` module: this file belongs to the
// executable and the scripted-test module, never the library — the
// `git_import_cli.zig` layout, which is what keeps every library file
// single-module across all compilations.
const zit = @import("zit");
const logstore = zit.logstore;
const export_impl = zit.git_export;
const export_state = zit.git_export;
const fast_import_writer = zit.git_export.StreamWriter;
pub const RunError = error{ GitMissing, CommandFailed, OutOfMemory };
/// One bounded git invocation's observable result.
pub const RunResult = struct {
exit_code: u8,
stdout: []const u8,
};
/// The seam every git interaction crosses. `run` executes a bounded query
/// (`git --version`, `rev-parse`, `update-ref`, ...) and captures stdout;
/// `fastImport` accepts the whole serialized stream and returns the child's
/// exit code, leaving the marks file at `marks_path` on success. Production
/// backs this with real subprocesses; the scripted double cans every answer.
pub const GitOps = struct {
context: *anyopaque,
runFn: *const fn (context: *anyopaque, gpa: std.mem.Allocator, argv: []const []const u8, out: *std.ArrayList(u8)) RunError!u8,
fastImportFn: *const fn (context: *anyopaque, gpa: std.mem.Allocator, dest: []const u8, marks_out: []const u8, marks_in: ?[]const u8, stream: []const u8, stderr_tail: *std.ArrayList(u8)) RunError!u8,
pub fn run(self: GitOps, gpa: std.mem.Allocator, argv: []const []const u8, out: *std.ArrayList(u8)) RunError!u8 {
return self.runFn(self.context, gpa, argv, out);
}
};
/// Failure-point injection around the publish tail, the import side's
/// `PublicationHooks` shape: each named point may veto, and the tests prove
/// the state file survives a veto at every one of them.
pub const PublishHooks = struct {
context: ?*anyopaque = null,
checkFn: ?*const fn (context: ?*anyopaque, point: Point) bool = null,
pub const Point = enum { marks_readback, finalize_refs, state_rewrite };
fn check(self: PublishHooks, point: Point) bool {
const f = self.checkFn orelse return true;
return f(self.context, point);
}
};
pub const SessionError = error{
GitMissing,
DestNotARepository,
ForeignRefMoved,
FastImportFailed,
MarkUnresolved,
MarksMalformed,
TranslationFailed,
RefFinalizeFailed,
StatePublishFailed,
SourceUnreadable,
OutOfMemory,
};
pub const Outcome = struct {
commits_emitted: usize,
/// DESIGN §9: the source held S-bearing artifacts — the disposable-mirror
/// notice applies (printed by the CLI unless --quiet).
provisional_fixture: bool,
};
/// Everything one export run needs. `dest_path` is the DEST directory as the
/// operator named it (argv for git -C); `dest_dir` is the same directory
/// opened for the state sidecar.
pub const Session = struct {
gpa: std.mem.Allocator,
io: std.Io,
repo: *zit.Repository,
source_log_key: logstore.LogStore.LogKey,
dest_path: []const u8,
dest_dir: std.Io.Dir,
ops: GitOps,
hooks: PublishHooks = .{},
/// `--rebuild`: ignore any existing state and start a fresh full export.
rebuild: bool = false,
/// The synthesized-identity email domain, threaded to the translator.
email_domain: []const u8 = "zit.invalid",
/// The refused detail for error returns, CLI-formatted.
diagnostic: std.ArrayList(u8) = .empty,
pub fn deinit(self: *Session) void {
self.diagnostic.deinit(self.gpa);
}
fn fail(self: *Session, comptime err: SessionError, comptime fmt: []const u8, args: anytype) SessionError {
self.diagnostic.clearRetainingCapacity();
self.diagnostic.print(self.gpa, fmt, args) catch {};
return err;
}
pub fn run(self: *Session) SessionError!Outcome {
const gpa = self.gpa;
// --- Preflight -----------------------------------------------------
{
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
const code = self.ops.run(gpa, &.{ "git", "--version" }, &out) catch
return self.fail(error.GitMissing, "git is not runnable on this system", .{});
if (code != 0) return self.fail(error.GitMissing, "git --version exited {d}", .{code});
}
{
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
// The probe must establish that DEST ITSELF is a repository, not
// merely that git can find one: `rev-parse --git-dir` resolves
// UPWARD, so from a plain directory inside any enclosing repo it
// succeeds and names that repo — and every later `git -C DEST`
// (fast-import included) would then operate on the ENCLOSING
// repository's refs. That is how a CI workspace checkout got its
// trunk ref targeted by an export into a fresh subdirectory. The
// answer is DEST-local exactly when it prints `.git` (worktree
// root) or `.` (bare); an absolute path means discovery walked
// up, which is the failure, so DEST gets initialized instead.
const probe = self.ops.run(gpa, &.{ "git", "-C", self.dest_path, "rev-parse", "--git-dir" }, &out) catch 1;
const dest_is_repo = probe == 0 and blk: {
const answer = std.mem.trim(u8, out.items, " \r\n");
break :blk std.mem.eql(u8, answer, ".git") or std.mem.eql(u8, answer, ".");
};
if (!dest_is_repo) {
out.clearRetainingCapacity();
const init_code = self.ops.run(gpa, &.{ "git", "init", "--quiet", self.dest_path }, &out) catch
return self.fail(error.DestNotARepository, "cannot initialize '{s}' as a git repository", .{self.dest_path});
if (init_code != 0) return self.fail(error.DestNotARepository, "'{s}' is not a git repository and git init failed", .{self.dest_path});
}
}
if (self.rebuild) {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
const code = self.ops.run(gpa, &.{ "git", "-C", self.dest_path, "for-each-ref", "--count=1" }, &out) catch 1;
if (code == 0 and std.mem.trim(u8, out.items, " \n\r\t").len != 0) {
return self.fail(error.ForeignRefMoved, "--rebuild refuses: '{s}' already carries refs; a rebuild would overwrite them — point it at an absent or empty destination", .{self.dest_path});
}
}
// --- Prior state ---------------------------------------------------
var prior: ?export_state.MirrorState = null;
defer if (prior) |*p| p.deinit();
if (!self.rebuild) {
prior = export_state.loadState(gpa, self.io, self.dest_dir) catch |err| switch (err) {
error.Absent => null,
error.OutOfMemory => return error.OutOfMemory,
// Damaged state: disposable by design — a full export follows.
else => null,
};
}
if (prior) |*p| {
if (!export_state.MirrorState.isPrefixExtensionOf(p.source_key, self.source_log_key)) {
// The source was rewritten under the recorded key: marks are
// stale. Full export.
p.deinit();
prior = null;
}
}
// Foreign-ref movement: every recorded ref must still sit where this
// exporter left it, else someone else owns DEST now.
if (prior) |*p| {
for (p.refs) |ref| {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
const code = self.ops.run(gpa, &.{ "git", "-C", self.dest_path, "rev-parse", "--verify", ref.name }, &out) catch 1;
const trimmed = std.mem.trim(u8, out.items, " \n\r\t");
if (code != 0 or !std.mem.eql(u8, trimmed, ref.oid)) {
return self.fail(error.ForeignRefMoved, "ref '{s}' moved since the last export (recorded {s}); refusing to overwrite foreign work", .{ ref.name, ref.oid });
}
}
}
// --- Plan and translate --------------------------------------------
var already = StateExported{ .state = if (prior) |*p| p else null };
const planned = export_impl.plan(gpa, self.repo.state(), already.predicate()) catch return error.OutOfMemory;
defer gpa.free(planned);
var translator = export_impl.Translator.init(gpa);
translator.email_domain = self.email_domain;
defer translator.deinit();
if (prior) |*p| {
for (p.records) |record| {
if (record.kind != .commit) continue;
translator.seedExported(record.hash, record.oid) catch return error.OutOfMemory;
}
}
var stream: std.Io.Writer.Allocating = .init(gpa);
defer stream.deinit();
var writer = fast_import_writer{};
writer.begin(&stream.writer) catch return error.OutOfMemory;
var emitted_marks: std.ArrayList(struct { hash: []const u8, mark: u64, kind: export_state.RecordKind }) = .empty;
defer {
for (emitted_marks.items) |entry| gpa.free(entry.hash);
emitted_marks.deinit(gpa);
}
for (planned) |rid| {
var translated = translator.translateCommit(self.repo, rid) catch |err| {
return self.fail(error.TranslationFailed, "check-in {s}: {s} ({s})", .{
translator.fault.hashText(),
@errorName(err),
translator.fault.pathText(),
});
};
defer translated.deinit();
for (translated.events) |event| {
writer.writeEvent(&stream.writer, event) catch return error.OutOfMemory;
}
const owned_hash = gpa.dupe(u8, self.repo.hashOf(rid)) catch return error.OutOfMemory;
emitted_marks.append(gpa, .{ .hash = owned_hash, .mark = translated.mark, .kind = .commit }) catch {
gpa.free(owned_hash);
return error.OutOfMemory;
};
}
writer.writeEvent(&stream.writer, .done) catch return error.OutOfMemory;
if (emitted_marks.items.len == 0) {
// Nothing to export: the state (if any) already covers the log.
return .{ .commits_emitted = 0, .provisional_fixture = if (prior) |*p| p.provisional_fixture else false };
}
// --- The child -----------------------------------------------------
const marks_out = export_state.state_dirname ++ "/marks";
var stderr_tail: std.ArrayList(u8) = .empty;
defer stderr_tail.deinit(gpa);
const exit_code = self.ops.fastImportFn(
self.ops.context,
gpa,
self.dest_path,
marks_out,
null,
stream.writer.buffered(),
&stderr_tail,
) catch return self.fail(error.FastImportFailed, "git fast-import could not run", .{});
if (exit_code != 0) {
return self.fail(error.FastImportFailed, "git fast-import exited {d}: {s}", .{ exit_code, stderr_tail.items });
}
// --- Marks read-back ------------------------------------------------
if (!self.hooks.check(.marks_readback)) return self.fail(error.MarksMalformed, "injected: marks readback", .{});
var mark_oids: std.AutoHashMapUnmanaged(u64, []const u8) = .empty;
defer {
var vals = mark_oids.valueIterator();
while (vals.next()) |oid| gpa.free(oid.*);
mark_oids.deinit(gpa);
}
{
const marks_bytes = self.dest_dir.readFileAlloc(self.io, marks_out, gpa, .limited(export_state.max_state_bytes)) catch
return self.fail(error.MarksMalformed, "the marks file did not come back from fast-import", .{});
defer gpa.free(marks_bytes);
var lines = std.mem.splitScalar(u8, marks_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
// `:<mark> <oid>`
if (line[0] != ':') return self.fail(error.MarksMalformed, "malformed marks line '{s}'", .{line});
const space = std.mem.indexOfScalar(u8, line, ' ') orelse
return self.fail(error.MarksMalformed, "malformed marks line '{s}'", .{line});
const mark = std.fmt.parseInt(u64, line[1..space], 10) catch
return self.fail(error.MarksMalformed, "malformed marks line '{s}'", .{line});
const oid = std.mem.trim(u8, line[space + 1 ..], " \r\t");
if (oid.len == 0 or oid.len > export_state.oid_hex_max)
return self.fail(error.MarksMalformed, "malformed marks line '{s}'", .{line});
const owned = gpa.dupe(u8, oid) catch return error.OutOfMemory;
mark_oids.put(gpa, mark, owned) catch {
gpa.free(owned);
return error.OutOfMemory;
};
}
}
for (emitted_marks.items) |entry| {
if (!mark_oids.contains(entry.mark)) {
return self.fail(error.MarkUnresolved, "mark :{d} for check-in {s} never resolved to an OID", .{ entry.mark, entry.hash });
}
}
// --- Refs and HEAD ---------------------------------------------------
if (!self.hooks.check(.finalize_refs)) return self.fail(error.RefFinalizeFailed, "injected: ref finalize", .{});
var final_refs: std.ArrayList(export_state.Ref) = .empty;
defer {
for (final_refs.items) |ref| {
gpa.free(ref.name);
gpa.free(ref.oid);
}
final_refs.deinit(gpa);
}
try self.finalizeRefs(&translator, &mark_oids, &final_refs);
// --- Commit point ----------------------------------------------------
if (!self.hooks.check(.state_rewrite)) return self.fail(error.StatePublishFailed, "injected: state rewrite", .{});
var records: std.ArrayList(export_state.Record) = .empty;
defer records.deinit(gpa);
if (prior) |*p| {
for (p.records) |record| records.append(gpa, record) catch return error.OutOfMemory;
}
for (emitted_marks.items) |entry| {
records.append(gpa, .{
.hash = entry.hash,
.mark = entry.mark,
.oid = mark_oids.get(entry.mark).?,
.kind = entry.kind,
}) catch return error.OutOfMemory;
}
const provisional = self.sourceIsProvisional() or (if (prior) |*p| p.provisional_fixture else false);
if (!export_state.storeState(gpa, self.io, self.dest_dir, self.source_log_key, provisional, records.items, final_refs.items)) {
return self.fail(error.StatePublishFailed, "the mirror state could not be rewritten; the export did not happen", .{});
}
return .{ .commits_emitted = emitted_marks.items.len, .provisional_fixture = provisional };
}
/// One `git update-ref` per open-leaf branch head (multi-open-leaf
/// branches are a DESIGN §8 refusal — Git has one tip per ref), then
/// HEAD to the default branch.
fn finalizeRefs(
self: *Session,
translator: *const export_impl.Translator,
mark_oids: *const std.AutoHashMapUnmanaged(u64, []const u8),
final_refs: *std.ArrayList(export_state.Ref),
) SessionError!void {
const gpa = self.gpa;
const rs = self.repo.state();
const branch_entries = rs.branches(gpa) catch return error.OutOfMemory;
defer gpa.free(branch_entries);
for (branch_entries) |entry| {
const branch = entry.name;
const leaves = rs.openLeavesOf(gpa, branch) catch return error.OutOfMemory;
defer gpa.free(leaves);
if (leaves.len == 0) continue;
if (leaves.len > 1) {
return self.fail(error.RefFinalizeFailed, "branch '{s}' has {d} open leaves; Git has one tip per ref — close or merge before exporting", .{ branch, leaves.len });
}
const tip_hash = self.repo.hashOf(leaves[0]);
const oid = self.oidOf(translator, mark_oids, tip_hash) orelse
return self.fail(error.RefFinalizeFailed, "branch '{s}' tip {s} has no exported OID", .{ branch, tip_hash });
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const ref_name = export_impl.refForBranch(arena_state.allocator(), branch) catch return error.OutOfMemory;
for (final_refs.items) |existing| {
if (std.mem.eql(u8, existing.name, ref_name)) {
return self.fail(error.RefFinalizeFailed, "branches collide on ref '{s}' after sanitization", .{ref_name});
}
}
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
const code = self.ops.run(gpa, &.{ "git", "-C", self.dest_path, "update-ref", ref_name, oid }, &out) catch
return self.fail(error.RefFinalizeFailed, "git update-ref {s} failed to run", .{ref_name});
if (code != 0) return self.fail(error.RefFinalizeFailed, "git update-ref {s} exited {d}", .{ ref_name, code });
const owned_name = gpa.dupe(u8, ref_name) catch return error.OutOfMemory;
const owned_oid = gpa.dupe(u8, oid) catch {
gpa.free(owned_name);
return error.OutOfMemory;
};
final_refs.append(gpa, .{ .name = owned_name, .oid = owned_oid }) catch {
gpa.free(owned_name);
gpa.free(owned_oid);
return error.OutOfMemory;
};
}
// HEAD follows the default branch.
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const head_ref = export_impl.refForBranch(arena_state.allocator(), self.repo.mainBranch()) catch return error.OutOfMemory;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
const code = self.ops.run(gpa, &.{ "git", "-C", self.dest_path, "symbolic-ref", "HEAD", head_ref }, &out) catch
return self.fail(error.RefFinalizeFailed, "git symbolic-ref HEAD failed to run", .{});
if (code != 0) return self.fail(error.RefFinalizeFailed, "git symbolic-ref HEAD exited {d}", .{code});
}
fn oidOf(
self: *Session,
translator: *const export_impl.Translator,
mark_oids: *const std.AutoHashMapUnmanaged(u64, []const u8),
hash: []const u8,
) ?[]const u8 {
_ = self;
const ref = translator.commit_refs.get(hash) orelse return null;
return switch (ref) {
.mark => |mark| mark_oids.get(mark),
.oid => |oid| oid,
};
}
/// DESIGN §9: whether the source holds S-bearing artifacts, read from the
/// repository's own durable indicator.
fn sourceIsProvisional(self: *Session) bool {
const log = self.repo.logStore(self.gpa) catch return false;
var oc = (log.readConfig(self.gpa) catch return false) orelse return false;
defer oc.deinit(self.gpa);
return oc.config.seen_submanifest;
}
};
/// The planner predicate over a loaded MirrorState.
const StateExported = struct {
state: ?*const export_state.MirrorState,
fn containsImpl(context: ?*const anyopaque, hash: []const u8) bool {
const self: *const StateExported = @ptrCast(@alignCast(context.?));
const st = self.state orelse return false;
return st.containsHash(hash) != null;
}
fn predicate(self: *StateExported) export_impl.AlreadyExported {
return .{ .context = self, .containsFn = containsImpl };
}
};
// ---------------------------------------------------------------------------
// The production GitOps: real subprocesses, shell-free argv.
// ---------------------------------------------------------------------------
pub const ProductionGit = struct {
io: std.Io,
/// DEST opened as a directory — where the child's stderr spool lives
/// (a FILE, not a pipe, so a stderr flood can never deadlock the
/// stream write; the tail is read back after the child exits).
dest_dir: std.Io.Dir,
const stderr_spool = export_state.state_dirname ++ "/fastimport.stderr";
const stderr_tail_bytes = 4096;
const run_output_cap = 1 << 20;
fn runImpl(context: *anyopaque, gpa: std.mem.Allocator, argv: []const []const u8, out: *std.ArrayList(u8)) RunError!u8 {
const self: *ProductionGit = @ptrCast(@alignCast(context));
const result = std.process.run(gpa, self.io, .{
.argv = argv,
.stdout_limit = .limited(run_output_cap),
.stderr_limit = .limited(run_output_cap),
}) catch |err| switch (err) {
error.FileNotFound => return error.GitMissing,
else => return error.CommandFailed,
};
defer gpa.free(result.stderr);
defer gpa.free(result.stdout);
out.appendSlice(gpa, result.stdout) catch return error.OutOfMemory;
return switch (result.term) {
.exited => |code| code,
else => 1,
};
}
fn fastImportImpl(
context: *anyopaque,
gpa: std.mem.Allocator,
dest: []const u8,
marks_out: []const u8,
marks_in: ?[]const u8,
stream: []const u8,
stderr_tail: *std.ArrayList(u8),
) RunError!u8 {
const self: *ProductionGit = @ptrCast(@alignCast(context));
const io = self.io;
self.dest_dir.createDir(io, export_state.state_dirname, .default_dir) catch {};
const spool = self.dest_dir.createFile(io, stderr_spool, .{ .truncate = true, .read = true }) catch return error.CommandFailed;
// fast-import resolves marks paths from ITS cwd (git -C dest), so
// these stay DEST-relative — never prefixed with dest again.
var marks_arg_buf: [512]u8 = undefined;
const marks_arg = std.fmt.bufPrint(&marks_arg_buf, "--export-marks={s}", .{marks_out}) catch return error.CommandFailed;
var marks_in_buf: [512]u8 = undefined;
var argv_buf: [10][]const u8 = undefined;
var argc: usize = 0;
for ([_][]const u8{ "git", "-C", dest, "fast-import", "--done", "--quiet", marks_arg }) |a| {
argv_buf[argc] = a;
argc += 1;
}
if (marks_in) |prior| {
argv_buf[argc] = std.fmt.bufPrint(&marks_in_buf, "--import-marks={s}", .{prior}) catch return error.CommandFailed;
argc += 1;
}
var child = std.process.spawn(io, .{
.argv = argv_buf[0..argc],
.stdin = .pipe,
.stdout = .ignore,
.stderr = .{ .file = spool },
}) catch |err| switch (err) {
error.FileNotFound => return error.GitMissing,
else => return error.CommandFailed,
};
// The spawn duplicated the spool handle into the child; the child's
// exit is what makes the spool complete, so close ours after reading.
if (child.stdin) |stdin| {
var write_buf: [64 * 1024]u8 = undefined;
var w = stdin.writer(io, &write_buf);
w.interface.writeAll(stream) catch {};
w.interface.flush() catch {};
stdin.close(io);
child.stdin = null;
}
const term = child.wait(io) catch {
spool.close(io);
return error.CommandFailed;
};
// The tail of the spool is the diagnostic a refusal quotes.
blk: {
const st = spool.stat(io) catch break :blk;
const from: u64 = if (st.size > stderr_tail_bytes) st.size - stderr_tail_bytes else 0;
var tail_buf: [stderr_tail_bytes]u8 = undefined;
const n = spool.readPositionalAll(io, tail_buf[0..@min(stderr_tail_bytes, @as(usize, @intCast(st.size - from)))], from) catch break :blk;
stderr_tail.appendSlice(gpa, tail_buf[0..n]) catch {};
}
spool.close(io);
return switch (term) {
.exited => |code| code,
else => 1,
};
}
pub fn ops(self: *ProductionGit) GitOps {
return .{ .context = self, .runFn = runImpl, .fastImportFn = fastImportImpl };
}
};