// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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..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..*`. pub const Configured = struct { /// ``. name: []const u8, /// `trailer..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:)` 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 \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 1 +0000\ncommitter 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 fuzz_input.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") }) }); }