Skip to content
zit Artifact [47ecdc08d4]
Login

Artifact [47ecdc08d4]

Artifact 47ecdc08d4693e9aff853b3c1b8d2ece959e2202d54e2135b6602b4f48d20c55:


//! 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("\"");
}