Skip to content
zit Artifact [a80638503d]
Login

Artifact [a80638503d]

Artifact a80638503de54bcdc95e245bfbb9aaa6b0adf1b8e9a19371c9f5e3f8bd5b223a:


//! `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 };
    }
};