// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The contents of a commit, read without allocating: a view whose fields //! are slices of the bytes it was given. //! //! A commit is a block of header lines, a blank line, and a message: //! //! tree //! parent (any number, including none) //! author //! committer //! //! //! //! //! git insists only on the beginning: a `tree` line first, then any //! `parent` lines, each exactly an object ID and a line feed. Everything //! after that it takes as it finds it, and this view does the same. These //! are git's observed rules, found by writing commits with `git //! hash-object --literally` and seeing what `git log` makes of them: //! //! - Without a well-formed `tree` line first, the commit is bogus //! (`MalformedCommit`), and with a malformed `parent` line straight //! after it, it has bad parents (`MalformedParent`). Each of those lines //! must have something after it, and a line is only taken for a parent //! line if the commit has room left for a whole one: a truncated //! `parent` line at the end is just another header. //! - A `parent` line anywhere else is just another header, not a parent. //! - `author` and `committer` may be missing, out of order or repeated; //! the last of each counts. //! - `encoding` is the first of its kind. //! - A header line beginning with a space continues the one before it, as //! a `gpgsig` signature or a `mergetag` does. //! - The headers end at the first empty line; without one there is no //! message. const std = @import("std"); const object = @import("object.zig"); const Format = object.Format; const Id = object.Id; const Identity = @import("Identity.zig"); /// A commit's contents, read. pub const Commit = struct { format: Format, /// The whole of the commit's contents. contents: []const u8, /// The tree the commit records. tree: Id, /// The `parent` lines, one after another. parent_lines: []const u8, /// The header block, up to but not including the blank line. headers: []const u8, /// Everything after the blank line, or empty if there is none. message: []const u8, /// Why `parse` refused a commit: git calls the first a "bogus commit /// object" and the second "bad parents". pub const Error = error{ MalformedCommit, MalformedParent }; /// Reads the contents of a commit in `format`. pub fn parse(format: Format, contents: []const u8) Error!Commit { const parents_start = "tree ".len + format.hexLength() + 1; // git wants at least a byte after the tree line, and after every // parent line, and looks for parent lines only where there is room // for one. if (contents.len <= parents_start) return error.MalformedCommit; if (!std.mem.startsWith(u8, contents, "tree ")) return error.MalformedCommit; const tree = idLine(format, contents["tree ".len..]) orelse return error.MalformedCommit; const parent_len = "parent ".len + format.hexLength() + 1; var pos = parents_start; while (contents.len - pos >= parent_len and std.mem.startsWith(u8, contents[pos..], "parent ")) { if (contents.len - pos == parent_len) return error.MalformedParent; _ = idLine(format, contents[pos + "parent ".len ..]) orelse return error.MalformedParent; pos += parent_len; } var c: Commit = .{ .format = format, .contents = contents, .tree = tree, .parent_lines = contents[parents_start..pos], .headers = contents, .message = "", }; if (std.mem.find(u8, contents, "\n\n")) |blank| { c.headers = contents[0 .. blank + 1]; c.message = contents[blank + 2 ..]; } return c; } /// The tree a commit's contents name at their start, or null if they /// do not start `tree `: all that `git cat-file tree` needs /// of a commit to go from it to its tree. Unlike `parse`, it does not /// mind what follows the object ID, or that nothing does. pub fn treeOf(format: Format, contents: []const u8) ?Id { if (!std.mem.startsWith(u8, contents, "tree ")) return null; const hex = contents["tree ".len..]; if (hex.len < format.hexLength()) return null; return Id.parseHex(format, hex[0..format.hexLength()]) catch null; } /// How many parents the commit has. pub fn parentCount(c: Commit) usize { return c.parent_lines.len / ("parent ".len + c.format.hexLength() + 1); } /// The parents, in order. pub fn parents(c: Commit) Parents { return .{ .format = c.format, .rest = c.parent_lines }; } /// Steps through a commit's parents. pub const Parents = struct { format: Format, rest: []const u8, /// The next parent, or null after the last. pub fn next(it: *Parents) ?Id { if (it.rest.len == 0) return null; const id = idLine(it.format, it.rest["parent ".len..]).?; it.rest = it.rest["parent ".len + it.format.hexLength() + 1 ..]; return id; } }; /// Every header, `tree` and `parent` included, in order. pub fn fields(c: Commit) Fields { return .{ .rest = c.headers }; } /// The author, from the last `author` line, or null if there is none /// or git can read nothing from it. pub fn author(c: Commit) ?Identity { return Identity.parse(lastValue(c.headers, "author") orelse return null); } /// The committer, from the last `committer` line, or null if there is /// none or git can read nothing from it. pub fn committer(c: Commit) ?Identity { return Identity.parse(lastValue(c.headers, "committer") orelse return null); } /// The encoding the message is declared to be in, from the first /// `encoding` line, or null if there is none. It is only a name: the /// message is not converted. pub fn encoding(c: Commit) ?[]const u8 { var it = c.fields(); while (it.next()) |f| { if (std.mem.eql(u8, f.name, "encoding")) if (f.value) |v| return firstLine(v); } return null; } }; /// One header of a commit or a tag. pub const Field = struct { /// Up to the first space. name: []const u8, /// After the first space, continuation lines included, each still /// introduced by its line feed and space; null for a line with no /// space at all. value: ?[]const u8, }; /// Steps through a header block. pub const Fields = struct { rest: []const u8, /// The next header, or null after the last. pub fn next(it: *Fields) ?Field { // A continuation line with nothing before it to continue belongs // to no field. while (it.rest.len != 0 and it.rest[0] == ' ') it.rest = afterLine(it.rest); if (it.rest.len == 0) return null; var end = lineEnd(it.rest); while (end + 1 < it.rest.len and it.rest[end + 1] == ' ') end = end + 1 + lineEnd(it.rest[end + 1 ..]); const text = it.rest[0..end]; it.rest = it.rest[@min(end + 1, it.rest.len)..]; const line = firstLine(text); if (std.mem.findScalar(u8, line, ' ')) |space| { return .{ .name = text[0..space], .value = text[space + 1 ..] }; } return .{ .name = line, .value = null }; } }; /// The value of the last header named `name` in `headers`, its first line /// only, or null if there is none. pub fn lastValue(headers: []const u8, name: []const u8) ?[]const u8 { var found: ?[]const u8 = null; var it: Fields = .{ .rest = headers }; while (it.next()) |f| { if (std.mem.eql(u8, f.name, name)) if (f.value) |v| { found = firstLine(v); }; } return found; } /// An object ID in hexadecimal followed by a line feed, at the start of /// `bytes`. pub fn idLine(format: Format, bytes: []const u8) ?Id { const n = format.hexLength(); if (bytes.len < n + 1 or bytes[n] != '\n') return null; return Id.parseHex(format, bytes[0..n]) catch null; } fn lineEnd(bytes: []const u8) usize { return std.mem.findScalar(u8, bytes, '\n') orelse bytes.len; } fn afterLine(bytes: []const u8) []const u8 { return bytes[@min(lineEnd(bytes) + 1, bytes.len)..]; } fn firstLine(bytes: []const u8) []const u8 { return bytes[0..lineEnd(bytes)]; } const testing = std.testing; const fixture = @import("testing/fixture.zig"); const fuzz_input = @import("testing/fuzz.zig"); test "a commit is read as git log reads it" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const format = repo.object_format; try repo.commitFile("f", "one\n", "first"); try repo.commitFile("f", "two\n", "second"); const t = try repo.output(&.{ "rev-parse", "HEAD^{tree}" }); defer gpa.free(t); const p1 = try repo.output(&.{ "rev-parse", "HEAD" }); defer gpa.free(p1); const p2 = try repo.output(&.{ "rev-parse", "HEAD~" }); defer gpa.free(p2); const tree = std.mem.trimEnd(u8, t, "\n"); const T = try std.fmt.allocPrint(gpa, "tree {s}\n", .{tree}); defer gpa.free(T); const P1 = try std.fmt.allocPrint(gpa, "parent {s}", .{p1}); defer gpa.free(P1); const P2 = try std.fmt.allocPrint(gpa, "parent {s}", .{p2}); defer gpa.free(P2); const missing = try gpa.alloc(u8, format.hexLength()); defer gpa.free(missing); @memset(missing, '1'); const A = "author A 1 +0000\n"; const C = "committer C 2 +0000\n"; const cases = [_][]const []const u8{ &.{ T, P1, A, C, "\nmsg\n" }, &.{ P1, A, C, "\nmsg\n" }, &.{ P1, T, A, C, "\nmsg\n" }, &.{ T, T, A, C, "\nmsg\n" }, &.{ T, C, A, "\nmsg\n" }, &.{ T, A, "author B 3 +0000\n", C, "\nmsg\n" }, &.{ T, A, C, "committer D 9 +0000\n\nm\n" }, &.{ T, A, "author garbage\n", C, "\nm\n" }, &.{ T, "author\n", C, "\nm\n" }, &.{ T, "authorx Y 1 +0000\n", C, "\nm\n" }, &.{ T, "gpgsig x\n author Z 5 +0000\n", C, "\nm\n" }, &.{ T, C, "\nauthor Z 5 +0000\n" }, &.{ T, C, "\nmsg\n" }, &.{ T, A, "\nmsg\n" }, &.{ T, "\nmsg\n" }, &.{ T, A, C }, &.{ T, A, C, "msg\n" }, &.{ "frob x\n", T, A, C, "\nmsg\n" }, &.{ T, "frob x\n", A, C, "\nmsg\n" }, &.{ T, "frob\n", A, C, "\nmsg\n" }, &.{ T, A, P1, C, "\nmsg\n" }, &.{ T, P1, P2, A, C, "\nmsg\n" }, &.{ T, "parent zzz\n", A, C, "\nmsg\n" }, &.{ T, P1[0 .. P1.len - 2], "\n", A, C, "\nmsg\n" }, &.{ T, std.mem.trimEnd(u8, P1, "\n"), " junk\n", A, C, "\nmsg\n" }, &.{ T[0 .. T.len - 1], " junk\n", A, C, "\nmsg\n" }, &.{ T[0 .. T.len - 1], "\r\n", A, C, "\nmsg\n" }, &.{T[0 .. T.len - 1]}, &.{T}, &.{ T, P1 }, &.{ T, P1, "x" }, &.{ T, P1, "\n" }, &.{ T, P1, P2 }, &.{ T, P1[0 .. P1.len - 1] }, &.{ T, P1[0 .. P1.len - 1], "x" }, &.{ T, "parent zzz\n" }, &.{ T, "parent zzz\n", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, &.{ "tree", tree, "\n", A, C, "\nmsg\n" }, &.{ "tree\t", tree, "\n", A, C, "\nmsg\n" }, &.{""}, &.{ "tree ", missing, "\n", A, C, "\nmsg\n" }, &.{ T, A, C, "encoding ISO-8859-1\n\nmsg\xe9\n" }, &.{ T, "encoding ISO-8859-1\n", A, C, "\nmsg\n" }, &.{ T, A, C, "encoding UTF-8\nencoding ISO-8859-1\n\nmsg\n" }, &.{ T, A, C, "encoding \n\nm\n" }, &.{ T, A, C, "encoding\n\nm\n" }, &.{ T, A, C, "encoding X\n Y\n\nm\n" }, &.{ T, A, C, "\nencoding X\n" }, &.{ T, A, C, "gpgsig -----BEGIN PGP SIGNATURE-----\n \n abc\n -----END PGP SIGNATURE-----\n\nmsg\n" }, &.{ T, P1, A, C, "mergetag object ", p1, " type commit\n tag x\n\nmsg\n" }, }; for (cases) |parts| { const contents = try std.mem.concat(gpa, u8, parts); defer gpa.free(contents); errdefer std.debug.print("commit: \"{f}\"\n", .{std.zig.fmtString(contents)}); const id = try repo.hashObject("commit", contents); defer gpa.free(id); const shown = repo.output(&.{ "log", "-1", "--date=raw", "--format=%T%x00%P%x00%e%x00%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd", id }) catch |err| switch (err) { error.GitFailed => { // git says "bogus commit object" or "bad parents", and // which it is decides the error. const parsed = Commit.parse(format, contents); try testing.expect(std.meta.isError(parsed)); continue; }, else => return err, }; defer gpa.free(shown); const c = try Commit.parse(format, contents); var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, shown, "\n"), 0); var buffer: [object.max_hex_length]u8 = undefined; try testing.expectEqualStrings(it.next().?, c.tree.toHex(&buffer)); var parents: std.ArrayList(u8) = .empty; defer parents.deinit(gpa); var ps = c.parents(); while (ps.next()) |p| { if (parents.items.len != 0) try parents.append(gpa, ' '); try parents.appendSlice(gpa, p.toHex(&buffer)); } try testing.expectEqualStrings(it.next().?, parents.items); try testing.expectEqualStrings(it.next().?, c.encoding() orelse ""); for ([_]?Identity{ c.author(), c.committer() }) |who| { const name = it.next().?; const email = it.next().?; const date = it.next().?; const i = who orelse { try testing.expectEqualStrings("", name); try testing.expectEqualStrings("", email); try testing.expectEqualStrings("", date); continue; }; try testing.expectEqualStrings(name, i.name); try testing.expectEqualStrings(email, i.email); var date_buffer: [64]u8 = undefined; try testing.expectEqualStrings(date, if (i.date()) |d| try std.fmt.bufPrint(&date_buffer, "{f}", .{d}) else ""); } } // Which of the two errors git means is in what it says. const bogus = try std.mem.concat(gpa, u8, &.{ P1, A, C }); defer gpa.free(bogus); try testing.expectError(error.MalformedCommit, Commit.parse(format, bogus)); const bad_parent = try std.mem.concat(gpa, u8, &.{ T, "parent zzz\n", A, C, "\nroom for a whole parent line\n" }); defer gpa.free(bad_parent); try testing.expectError(error.MalformedParent, Commit.parse(format, bad_parent)); } test "a commit's headers continue onto lines beginning with a space" { const contents = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" ++ " stray\n" ++ "gpgsig -----BEGIN-----\n abc\n -----END-----\n" ++ "frob\n\nmessage\n"; const c = try Commit.parse(.sha1, contents); var it = c.fields(); try testing.expectEqualStrings("tree", it.next().?.name); const sig = it.next().?; try testing.expectEqualStrings("gpgsig", sig.name); try testing.expectEqualStrings("-----BEGIN-----\n abc\n -----END-----", sig.value.?); const frob = it.next().?; try testing.expectEqualStrings("frob", frob.name); try testing.expectEqual(null, frob.value); try testing.expectEqual(null, it.next()); try testing.expectEqualStrings("message\n", c.message); try testing.expectEqual(0, c.parentCount()); } fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { var buffer: [1024]u8 = undefined; const contents = fuzz_input.bytes(smith, &buffer); for ([_]Format{ .sha1, .sha256 }) |format| { const c = Commit.parse(format, contents) catch continue; var n: usize = 0; var ps = c.parents(); while (ps.next()) |_| n += 1; try testing.expectEqual(c.parentCount(), n); var fs = c.fields(); while (fs.next()) |_| {} _ = c.author(); _ = c.committer(); _ = c.encoding(); } } test "fuzz: commits" { try testing.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/commit"), @embedFile("fuzz/commit-sha256"), @embedFile("fuzz/commit-signed"), } }); }