Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Trailers: the `Key: value` lines at the end of a commit message, found//! as git-interpret-trailers(1) finds them and written as `%(trailers)`//! writes them, read on bytes with no I/O.//!//! The documentation gives the outline — a block of lines at the end of//! the message, after a blank line, that is all trailers or a quarter//! trailers with at least one git wrote or the configuration names — and//! `git log --format=%(trailers)` gave the rest://!//! - **The message** ends at its last line that is not empty: empty lines//! at the end are no part of it, though lines holding only blank space//! are. Lines beginning with the comment string (`core.commentString`,//! `#` by default) are passed over wherever they are. `---` ends//! nothing: git reads a commit's message whole.//! - **The block** is the last paragraph, after the last line that is//! blank (empty, or blank space alone) before the message's last line//! that is not; the title — the first paragraph after any blank lines —//! is never part of it, so a message of one paragraph has no trailers.//! - **A trailer** is a line beginning with a key of ASCII letters, digits//! and hyphens, then any spaces and tabs, then one of the separators//! (`trailer.separators`, `:` by default) — but not `:` followed at once//! by `//`, as a URL is. A line beginning with blank space continues a//! trailer before it, and is a line of its own after anything else. A line beginning `Signed-off-by: ` or `(cherry picked//! from commit ` is one git writes, and counts as a trailer; so does a//! trailer whose key begins a configured trailer's key or name//! (`trailer.<name>.key`), without regard to case, and that trailer is//! shown under the configured key.//! - **The count**: the block is kept when every line that counts is a//! trailer, or when one is git's or configured and trailers are at least//! a quarter of them. A line continuing a trailer does not count, nor//! does a line of blank space alone; a line continuing anything else//! counts as not a trailer.//! - **Writing**: `%(trailers)` without options that change it writes the//! block as it is in the message. With them each trailer is written as//! its key, the first separator and a space, and its value with blank//! space trimmed from its ends — its continuation lines kept unless//! `unfold`, which joins them with single spaces — and every other line//! as it is; each followed by a line feed, or with `separator` between//! them.
const std = @import("std");const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;
/// What the configuration says about reading trailers.pub const Settings = struct { /// `trailer.separators`. separators: []const u8 = ":", /// The trailers the configuration names, in the order it first names /// them. configured: []const Configured = &.{}, /// The comment string, `core.commentString` or `core.commentChar`. comment: []const u8 = "#",};
/// A trailer the configuration names, as `trailer.<name>.*`.pub const Configured = struct { /// `<name>`. name: []const u8, /// `trailer.<name>.key`, if set. key: ?[]const u8 = null,};
/// One logical line of a trailer block: a line and the lines continuing/// it.pub const Item = struct { /// The line and its continuation lines, without the last line feed. text: []const u8, /// Whether it is a trailer, rather than any other line. trailer: bool = false, /// A trailer's key, as written, without the blank space before its /// separator. key: []const u8 = "", /// A trailer's value, as written: everything after its separator, /// continuation lines and all. value: []const u8 = "", /// The configured trailer it matched, if any. configured: ?usize = null,};
/// A message's trailer block.pub const Block = struct { /// The block as it is in the message, comments and all. text: []const u8 = "", /// Its logical lines, without comments. items: []Item = &.{},
/// Frees the items. pub fn deinit(b: *Block, gpa: Allocator) void { gpa.free(b.items); b.* = undefined; }};
fn isBlankLine(line: []const u8) bool { for (line) |c| if (!isBlank(c)) return false; return true;}
/// git's blank space.fn isBlank(c: u8) bool { return c == ' ' or c == '\t' or c == '\n' or c == '\r';}
/// The key of a trailer line and where its separator is, or null if the/// line is not a trailer.fn trailerKey(line: []const u8, separators: []const u8) ?struct { key: []const u8, separator: usize } { const at = std.mem.findAny(u8, line, separators) orelse return null; const key = std.mem.trimEnd(u8, line[0..at], " \t"); if (key.len == 0) return null; for (key) |c| if (!std.ascii.isAlphanumeric(c) and c != '-') return null; if (line[at] == ':' and std.mem.startsWith(u8, line[at + 1 ..], "//")) return null; return .{ .key = key, .separator = at };}
/// Which configured trailer `key` names: the first whose key or name/// begins with it, without regard to case.fn findConfigured(key: []const u8, settings: Settings) ?usize { for (settings.configured, 0..) |c, i| { if (c.key) |k| if (startsWithIgnoreCase(k, key)) return i; if (startsWithIgnoreCase(c.name, key)) return i; } return null;}
fn startsWithIgnoreCase(text: []const u8, prefix: []const u8) bool { return text.len >= prefix.len and std.ascii.eqlIgnoreCase(text[0..prefix.len], prefix);}
const git_prefixes = [_][]const u8{ "Signed-off-by: ", "(cherry picked from commit " };
/// Finds the trailer block of `message`, a commit's message; an empty/// block if it has none.pub fn parse(gpa: Allocator, message: []const u8, settings: Settings) Allocator.Error!Block { const isComment = struct { fn f(line: []const u8, comment: []const u8) bool { return comment.len != 0 and std.mem.startsWith(u8, line, comment); } }.f; var lines: std.ArrayList([]const u8) = .empty; defer lines.deinit(gpa); var starts: std.ArrayList(usize) = .empty; defer starts.deinit(gpa); { var at: usize = 0; while (at < message.len) { const nl = std.mem.findScalarPos(u8, message, at, '\n') orelse message.len; try lines.append(gpa, message[at..nl]); try starts.append(gpa, at); at = nl + 1; } } // The message less the empty lines and comments at its end. while (lines.items.len != 0) { const l = lines.items[lines.items.len - 1]; if (l.len != 0 and !isComment(l, settings.comment)) break; lines.items.len -= 1; starts.items.len -= 1; } const end = if (lines.items.len == 0) 0 else @min(message.len, starts.items[starts.items.len - 1] + lines.items[lines.items.len - 1].len + 1); const text = message[0..end]; // The title: past any blank lines, to the first blank line after it. var i: usize = 0; while (i < lines.items.len and (isBlankLine(lines.items[i]) or isComment(lines.items[i], settings.comment))) i += 1; while (i < lines.items.len and !isBlankLine(lines.items[i])) i += 1; const after_title = i;
// The last line that is not blank, and the blank line before it. var last = lines.items.len; while (last > after_title) { const l = lines.items[last - 1]; if (!isBlankLine(l) and !isComment(l, settings.comment)) break; last -= 1; } if (last <= after_title) return .{}; var first = last - 1; while (first > after_title and !isBlankLine(lines.items[first - 1])) first -= 1; if (first == after_title) { // The block must follow a blank line, which the title ends at. if (first == 0 or !isBlankLine(lines.items[first - 1])) return .{}; }
var items: std.ArrayList(Item) = .empty; errdefer items.deinit(gpa); var trailers: usize = 0; var others: usize = 0; var recognized = false; // Whether the line before was a trailer, for counting the lines that // continue it. var previous: enum { none, trailer, other } = .none; for (lines.items[first..], starts.items[first..]) |line, start| { if (isComment(line, settings.comment)) { previous = .other; continue; } if (isBlankLine(line) and previous != .trailer) { // Blank space alone counts for nothing, and stands as a line // of its own unless it continues a trailer. try items.append(gpa, .{ .text = line }); previous = .other; continue; } if (line.len == 0 or line[0] == ' ' or line[0] == '\t') { if (previous != .trailer and !isBlankLine(line)) others += 1; if (previous == .trailer) { const it = &items.items[items.items.len - 1]; const it_start = @intFromPtr(it.text.ptr) - @intFromPtr(text.ptr); it.text = text[it_start .. start + line.len]; if (it.trailer) it.value = text[@intFromPtr(it.value.ptr) - @intFromPtr(text.ptr) .. start + line.len]; continue; } try items.append(gpa, .{ .text = line }); previous = .other; continue; } for (git_prefixes) |p| if (std.mem.startsWith(u8, line, p)) { recognized = true; }; if (trailerKey(line, settings.separators)) |k| { const configured = findConfigured(k.key, settings); if (configured != null) recognized = true; try items.append(gpa, .{ .text = line, .trailer = true, .key = k.key, .value = line[k.separator + 1 ..], .configured = configured }); trailers += 1; previous = .trailer; continue; } if (std.mem.startsWith(u8, line, git_prefixes[1])) { // git's own line counts as a trailer, though it is written // as any other line. trailers += 1; } else { others += 1; } try items.append(gpa, .{ .text = line }); previous = .other; } const kept = trailers != 0 and (others == 0 or (recognized and trailers * 3 >= others)); if (!kept) { items.deinit(gpa); return .{}; } return .{ .text = text[starts.items[first]..], .items = try items.toOwnedSlice(gpa) };}
/// How `%(trailers:<options>)` writes a block.pub const Options = struct { /// `only`: leave out lines that are not trailers. only: bool = false, /// `unfold`: join each trailer's continuation lines. unfold: bool = false, /// `keyonly`. key_only: bool = false, /// `valueonly`. value_only: bool = false, /// `separator`: between lines, in place of a line feed after each. separator: ?[]const u8 = null, /// `key_value_separator`: in place of the separator and a space. key_value_separator: ?[]const u8 = null, /// `key`: the keys to show, any trailing colon removed. keys: []const []const u8 = &.{},
/// Whether these options write the block as it is. pub fn verbatim(o: Options) bool { return !o.only and !o.unfold and !o.key_only and !o.value_only and o.separator == null and o.key_value_separator == null and o.keys.len == 0; }};
/// The key a trailer is shown under, and the separator after it.fn shownKey(item: Item, settings: Settings) struct { key: []const u8, separator: u8 } { const default_separator: u8 = if (settings.separators.len != 0) settings.separators[0] else ':'; const c = item.configured orelse return .{ .key = item.key, .separator = default_separator }; const configured = settings.configured[c].key orelse return .{ .key = item.key, .separator = default_separator }; const key = std.mem.trimEnd(u8, configured, " \t"); if (key.len != 0 and std.mem.findScalar(u8, settings.separators, key[key.len - 1]) != null) return .{ .key = std.mem.trimEnd(u8, key[0 .. key.len - 1], " \t"), .separator = key[key.len - 1] }; return .{ .key = key, .separator = default_separator };}
fn keyWanted(key: []const u8, options: Options) bool { if (options.keys.len == 0) return true; for (options.keys) |k| if (std.ascii.eqlIgnoreCase(k, key)) return true; return false;}
/// Writes `block` as `%(trailers)` with `options` writes it.pub fn write(w: *Writer, block: Block, options: Options, settings: Settings) Writer.Error!void { if (options.verbatim()) return w.writeAll(block.text); var first = true; for (block.items) |item| { if (item.trailer) { const shown = shownKey(item, settings); if (!keyWanted(shown.key, options)) continue; if (options.separator) |s| if (!first) try w.writeAll(s); first = false; if (!options.value_only) try w.writeAll(shown.key); if (!options.key_only and !options.value_only) { if (options.key_value_separator) |kv| try w.writeAll(kv) else try w.print("{c} ", .{shown.separator}); } if (!options.key_only) { const value = std.mem.trim(u8, item.value, " \t\n\r"); if (options.unfold) try writeUnfolded(w, value) else try w.writeAll(value); } } else { if (options.only) continue; if (options.separator) |s| if (!first) try w.writeAll(s); first = false; // A line that is not a trailer is written as it is, unless // only values are, when git trims the blank space at its end. if (options.value_only) try w.writeAll(std.mem.trimEnd(u8, item.text, " \t\n\r")) else try w.writeAll(item.text); } if (options.separator == null) try w.writeByte('\n'); }}
/// Writes `value` with each line feed and the blank space after it made/// one space.fn writeUnfolded(w: *Writer, value: []const u8) Writer.Error!void { var i: usize = 0; while (i < value.len) { if (value[i] == '\n') { try w.writeByte(' '); i += 1; while (i < value.len and (value[i] == ' ' or value[i] == '\t')) i += 1; continue; } try w.writeByte(value[i]); i += 1; }}
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
/// Messages whose trailer blocks are found, or not, in each of the ways/// git was seen to find them.pub const messages = [_][]const u8{ "A: b\n", "subj\nA: b\n", "subj\n\nA: b\n", "subj\n\nA: b", "subj\n\nA: b\n\n", "subj\n\nA: b\n \n", "subj\n\nbody\nA: b\n", "subj\n\nA: b\nnot\n", "subj\n\nSigned-off-by: x\nnot\nnot2\nnot3\n", "subj\n\nSigned-off-by: x\nnot\nnot2\nnot3\nnot4\n", "subj\n\nA: b\n---\nC: d\n", "subj\n\nA: b\n# comment\n", "subj\n\n# c\nA: b\n", "subj\n\n(cherry picked from commit abc)\nnot\nnot2\nnot3\n", "subj\n\n(cherry picked from commit\nnot\n", "subj\n\n A: b\n", "subj\n\nA:b\n", "subj\n\nA :b\n", "subj\n\nA\t: b\n", "subj\n\n-A: b\n", "subj\n\nA:\n", "subj\n\n: b\n", "subj\n\nA://b\n", "subj\n\nA: //b\n", "subj\n\nA=b\n", "subj\n\nA: b\r\nC: d\r\n", "subj\n\nA: b\n\tcont\n", "subj\n\n cont\nA: b\n", "subj\n\n\xc3\x84: b\n", "subj\n\nA: b\n\nC: d\n", "\n\nA: b\n", "subj\n\nSigned-off-by: x\nA: b\n fold1\n fold2\n fold3\nnot\n", "subj\n\nSigned-off-by: x\n f1\n f2\nn1\nn2\nn3\n", "subj\n\nSigned-off-by: x\nn1\nn2\nn3\n f\n", "subj\n\nsigned-off-by: x\nn1\nn2\nn3\n", "subj\n\nSigned-off-by : x\nn1\nn2\nn3\n", "subj\n\nbody\n# c\nA: b\n", "subj\n\nbody\n\n# c\n\nA: b\n", "subj\n\nA: b \t\n", "subj\n\nA: b\n c d \n\te\n", "subj\n\nA:\n b\n", "subj\n\nA: b\n# c\nC: d\n", "subj\n\nA: b\n# c\n e\nC: d\n", "subj\n\nbody\n\nSigned-off-by: a\n \n \n", "subj\n\nSigned-off-by: x\nn1\nn2\nn3\n \n", "subj\n\nSigned-off-by: x\nn1\nn2\n \nn3\n", "subj\n \nA: b\n", "subj\n\nSigned-off-by: s\n c\nn\n d\n", "subj\n\nA: b\n\n \n", "subj\n\nA: b\n\n ", "subj\n\nbody\n\nAcked-by: B\n folded more\nnot a trailer line\nKey : spaced\nhttp://example.com\nX-Y_z: under\nSigned-off-by: A <a>\n",};
const formats = [_][]const u8{ "%(trailers)", "%(trailers:only)", "%(trailers:unfold)", "%(trailers:only=no)", "%(trailers:keyonly)", "%(trailers:valueonly,separator=%x2C)", "%(trailers:key=a,key=signed-off-by:)", "%(trailers:key_value_separator==,unfold)",};
/// Writes `message`'s trailers as `format`, one of `formats`, says.fn render(gpa: Allocator, message: []const u8, format: []const u8, settings: Settings) ![]u8 { var block = try parse(gpa, message, settings); defer block.deinit(gpa); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const options: Options = if (std.mem.eql(u8, format, "%(trailers)") or std.mem.eql(u8, format, "%(trailers:only=no)")) .{} else if (std.mem.eql(u8, format, "%(trailers:only)")) .{ .only = true } else if (std.mem.eql(u8, format, "%(trailers:unfold)")) .{ .unfold = true } else if (std.mem.eql(u8, format, "%(trailers:keyonly)")) .{ .key_only = true } else if (std.mem.eql(u8, format, "%(trailers:valueonly,separator=%x2C)")) .{ .value_only = true, .separator = "," } else if (std.mem.eql(u8, format, "%(trailers:key=a,key=signed-off-by:)")) .{ .only = true, .keys = &.{ "a", "signed-off-by" } } else .{ .key_value_separator = "=", .unfold = true }; try write(&out.writer, block, options, settings); return out.toOwnedSlice();}
test "trailer blocks are found and written as git log's %(trailers) does" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const empty_tree = try repo.output(&.{ "mktree", "--missing" }); defer gpa.free(empty_tree); for (messages) |message| { const commit = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A <a> 1 +0000\ncommitter C <c> 1 +0000\n\n{s}", .{ std.mem.trimEnd(u8, empty_tree, "\n"), message }); defer gpa.free(commit); const id = try repo.hashObject("commit", commit); defer gpa.free(id); for (formats) |format| { const arg = try std.fmt.allocPrint(gpa, "--format={s}", .{format}); defer gpa.free(arg); const want = try repo.output(&.{ "log", "-1", arg, id }); defer gpa.free(want); const got = try render(gpa, message, format, .{}); defer gpa.free(got); errdefer std.debug.print("message \"{f}\", {s}\n", .{ std.zig.fmtString(message), format }); // git ends each commit's output with a line feed. try testing.expectEqualStrings(want[0 .. want.len - 1], got); } }}
test "configured trailers and separators are read as git reads them" { const gpa = testing.allocator; const cases = [_]struct { []const u8, Settings, []const u8 }{ .{ "subj\n\nFoo: x\nn1\nn2\nn3\n", .{ .configured = &.{.{ .name = "f", .key = "Foo" }} }, "Foo: x\n" }, .{ "subj\n\nFoo: x\nn1\nn2\nn3\n", .{ .configured = &.{.{ .name = "f", .key = "Fo" }} }, "" }, .{ "subj\n\nFoo: x\nn1\nn2\nn3\n", .{ .configured = &.{.{ .name = "f", .key = "foo" }} }, "foo: x\n" }, .{ "subj\n\nA: b\n", .{ .configured = &.{.{ .name = "aa", .key = "a" }} }, "a: b\n" }, .{ "subj\n\nA: b\n", .{ .configured = &.{.{ .name = "a" }} }, "A: b\n" }, .{ "subj\n\nAck: b\n", .{ .configured = &.{.{ .name = "a", .key = "Acked" }} }, "Acked: b\n" }, .{ "subj\n\nF: x\n", .{ .configured = &.{.{ .name = "foo", .key = "Foo" }} }, "Foo: x\n" }, .{ "subj\n\nFoo: x\n", .{ .configured = &.{.{ .name = "foo", .key = "Bar" }} }, "Bar: x\n" }, .{ "subj\n\nA: x\n", .{ .configured = &.{.{ .name = "x", .key = "Abc: " }} }, "Abc: x\n" }, .{ "subj\n\nA: x\n", .{ .separators = ":#", .configured = &.{.{ .name = "x", .key = "Abc# " }} }, "Abc# x\n" }, .{ "subj\n\nA: x\n", .{ .configured = &.{ .{ .name = "x", .key = "Abc" }, .{ .name = "y", .key = "Axe" } } }, "Abc: x\n" }, .{ "subj\n\nFoo= x\nBar: y\n", .{ .separators = "=:" }, "Foo= x\nBar= y\n" }, .{ "subj\n\nFoo= x\nBar: y\n", .{ .separators = "=" }, "" }, .{ "subj\n\nA: x\n", .{ .separators = "" }, "" }, .{ "subj\n\n;c\nA: b\n", .{ .comment = ";" }, "A: b\n" }, .{ "subj\n\n# c\nA: b\n", .{ .comment = ";" }, "" }, .{ "subj\n\nA: b\n//c\n", .{ .comment = "//" }, "A: b\n" }, .{ "subj\n\nA: b\n", .{ .comment = "A" }, "" }, }; for (cases) |case| { const got = try render(gpa, case[0], "%(trailers:only)", case[1]); defer gpa.free(got); errdefer std.debug.print("message \"{f}\"\n", .{std.zig.fmtString(case[0])}); try testing.expectEqualStrings(case[2], got); }}
test "fuzz: trailer blocks" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [512]u8 = undefined; const message = fuzz_input.bytes(smith, &buffer); const settings: Settings = .{ .configured = &.{.{ .name = "ack", .key = "Acked-by" }} }; var block = try parse(testing.allocator, message, settings); defer block.deinit(testing.allocator); for (block.items) |item| { try testing.expect(item.text.len != 0); try testing.expect(!item.trailer or item.key.len != 0); } var discard: [256]u8 = undefined; var w: std.Io.Writer.Discarding = .init(&discard); try write(&w.writer, block, .{ .unfold = true }, settings); } }.one, .{ .corpus = &(messages ++ [_][]const u8{ @embedFile("fuzz/trailers"), @embedFile("fuzz/trailers-blank-end") }) });}