//! Pure `git fast-import` stream writer — the serialization inverse of
//! `git_fast_export.zig`'s parser, over the SAME typed records (`Event`,
//! `FileCommand`, `Identity`), so the two sides cannot drift structurally:
//! the parser is the writer's round-trip oracle in `git_fast_import_test.zig`.
//!
//! Pure means: no allocator retained across records, no subprocess, no
//! repository knowledge. The caller owns the `std.Io.Writer` and everything
//! referenced by the records it passes; nothing here outlives a call.
const std = @import("std");
const fast_export = @import("git_fast_export.zig");
pub const Event = fast_export.Event;
pub const Identity = fast_export.Identity;
pub const FileCommand = fast_export.FileCommand;
pub const FileMode = fast_export.FileMode;
pub const ObjectRef = fast_export.ObjectRef;
pub const Error = std.Io.Writer.Error;
/// The stream writer. Stateless beyond the `begin` latch — held only so a
/// stream that forgot its `feature done` preamble is a programmer error
/// caught at the first event, not a downstream fast-import failure.
pub const Writer = struct {
began: bool = false,
/// Emit the stream preamble. `feature done` makes the terminating `done`
/// record mandatory, so a truncated stream is a hard fast-import error
/// rather than a silently short import — the same posture the parser
/// takes on `MissingDone`.
pub fn begin(self: *Writer, out: *std.Io.Writer) Error!void {
std.debug.assert(!self.began);
self.began = true;
try out.writeAll("feature done\n");
}
pub fn writeEvent(self: *Writer, out: *std.Io.Writer, event: Event) Error!void {
std.debug.assert(self.began);
switch (event) {
.blob => |b| try writeBlob(out, b),
.commit => |c| try writeCommit(out, c),
.reset => |r| try writeReset(out, r),
.tag => |t| try writeTag(out, t),
.done => try out.writeAll("done\n"),
}
}
};
fn writeBlob(out: *std.Io.Writer, b: fast_export.Blob) Error!void {
try out.writeAll("blob\n");
try out.print("mark :{d}\n", .{b.mark});
try writeData(out, b.data);
}
fn writeCommit(out: *std.Io.Writer, c: fast_export.Commit) Error!void {
try out.print("commit {s}\n", .{c.ref});
try out.print("mark :{d}\n", .{c.mark});
try writeIdentity(out, "author", c.author);
try writeIdentity(out, "committer", c.committer);
try writeData(out, c.message);
if (c.from) |from| {
try out.writeAll("from ");
try writeRef(out, from);
try out.writeAll("\n");
}
for (c.merges) |merge| {
try out.writeAll("merge ");
try writeRef(out, merge);
try out.writeAll("\n");
}
for (c.commands) |command| try writeFileCommand(out, command);
try out.writeAll("\n");
}
fn writeReset(out: *std.Io.Writer, r: fast_export.Reset) Error!void {
try out.print("reset {s}\n", .{r.ref});
if (r.target) |target| {
try out.writeAll("from ");
try writeRef(out, target);
try out.writeAll("\n");
}
}
fn writeTag(out: *std.Io.Writer, t: fast_export.Tag) Error!void {
try out.print("tag {s}\n", .{t.name});
if (t.mark) |mark| try out.print("mark :{d}\n", .{mark});
try out.writeAll("from ");
try writeRef(out, t.target);
try out.writeAll("\n");
try writeIdentity(out, "tagger", t.tagger);
try writeData(out, t.message);
}
fn writeFileCommand(out: *std.Io.Writer, command: FileCommand) Error!void {
switch (command) {
.modify => |m| {
try out.print("M {s} ", .{modeText(m.mode)});
try writeRef(out, m.data);
try out.writeAll(" ");
// The final token runs to end of line, but a path holding a
// space, control byte, quote, or backslash still needs the
// quoted form to survive the parser's tokenizer.
try writePath(out, m.path);
try out.writeAll("\n");
},
.delete => |path| {
try out.writeAll("D ");
try writePath(out, path);
try out.writeAll("\n");
},
.rename => |r| {
try out.writeAll("R ");
try writePath(out, r.old);
try out.writeAll(" ");
try writePath(out, r.new);
try out.writeAll("\n");
},
.copy => |c| {
try out.writeAll("C ");
try writePath(out, c.old);
try out.writeAll(" ");
try writePath(out, c.new);
try out.writeAll("\n");
},
.delete_all => try out.writeAll("deleteall\n"),
}
}
fn modeText(mode: FileMode) []const u8 {
return switch (mode) {
.regular => "100644",
.executable => "100755",
.symlink => "120000",
.gitlink => "160000",
};
}
fn writeRef(out: *std.Io.Writer, ref: ObjectRef) Error!void {
switch (ref) {
.mark => |m| try out.print(":{d}", .{m}),
.oid => |oid| try out.writeAll(oid),
}
}
/// `<keyword> <name> <email> <epoch> <±HHMM>` — the identity line both
/// `author`/`committer` and `tagger` share. UTC-relative minute offsets are
/// rendered in Git's sign-hour-minute form exactly.
fn writeIdentity(out: *std.Io.Writer, keyword: []const u8, identity: Identity) Error!void {
const offset = identity.timezone_offset_minutes;
const abs: u16 = @intCast(@abs(offset));
try out.print("{s} {s} <{s}> {d} {c}{d:0>2}{d:0>2}\n", .{
keyword,
identity.name,
identity.email,
identity.timestamp,
@as(u8, if (offset < 0) '-' else '+'),
abs / 60,
abs % 60,
});
}
/// Length-delimited payload framing. The count is the payload's byte length
/// exactly; the trailing LF after the payload is stream framing (what git
/// itself emits), never counted.
fn writeData(out: *std.Io.Writer, data: []const u8) Error!void {
try out.print("data {d}\n", .{data.len});
try out.writeAll(data);
try out.writeAll("\n");
}
/// Whether `path` survives the parser's bare tokenizer unquoted: bare tokens
/// end at a space, lines end at LF, a leading quote flips the tokenizer into
/// quoted mode, and a backslash is only meaningful inside quotes — so any of
/// those forces the quoted form. Control bytes ride the quoted form too:
/// CR would be eaten by line handling, and octal escapes keep the stream
/// printable. High bytes (0x80+) pass through bare tokens untouched.
fn needsQuoting(path: []const u8) bool {
if (path.len == 0) return true;
if (path[0] == '"') return true;
for (path) |byte| {
if (byte == ' ' or byte == '"' or byte == '\\' or byte < 0x20) return true;
}
return false;
}
/// The exact inverse of the parser's `decodeQuotedPath`: quote and C-escape
/// when the bare form would misparse, else emit verbatim.
fn writePath(out: *std.Io.Writer, path: []const u8) Error!void {
if (!needsQuoting(path)) {
try out.writeAll(path);
return;
}
try out.writeAll("\"");
for (path) |byte| {
switch (byte) {
'"' => try out.writeAll("\\\""),
'\\' => try out.writeAll("\\\\"),
'\n' => try out.writeAll("\\n"),
'\r' => try out.writeAll("\\r"),
'\t' => try out.writeAll("\\t"),
else => if (byte < 0x20) {
try out.print("\\{o:0>3}", .{byte});
} else {
try out.writeByte(byte);
},
}
}
try out.writeAll("\"");
}