Artifact 648cb92057ff90361a8057ddc2d2e555a3b317d77ac6b9913587eb5b38128080:
- File
src/git/git_export_cli.zig
— part of check-in
[5fce7e867d]
at
2026-09-28 16:44:53
on branch trunk
— zit git export/status: a remembered Git mirror with autopush
git export exports into MIRROR and remembers it, with an autopush URL, push branch and email domain, in repository config; a bare re-run does the round trip. Authors' addresses come from their user-table contact, set with the new users contact. Autopush URLs carrying a password are refused. The config payload bound is now sized from its key count. Release 0.3.34. (user: dmestas, size: 25416) [annotate] [blame] [check-ins using]
//! `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", /// Login -> address, threaded to the translator; see `Translator.contacts`. contacts: ?*const std.StringHashMapUnmanaged([]const u8) = null, /// 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; translator.contacts = self.contacts; 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 }; } };