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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The contents of an annotated tag, read without allocating: a view whose//! fields are slices of the bytes it was given.//!//! object <object ID>//! type <kind>//! tag <name>//! tagger <identity line> (optional)//! <other headers>//!//! <message, and any signature after it>//!//! git is strict about the first three lines and forgiving about the rest.//! These are its observed rules, from tags written with `git hash-object//! --literally` and peeled with `git rev-parse`://!//! - `object`, `type` and `tag` must come first and in that order, each//! ending in a line feed, or git calls the tag a bad object//! (`MalformedTag`). The object ID may be in either case.//! - The `type` must be exactly one of the four kinds' names, or git//! reports "unknown tag type" (`UnknownTagType`).//! - The tag's name may be empty, but its line must have the space.//! - `tagger` is optional, and git reads the first of them; one it cannot//! read is as good as none.//! - The headers end at the first empty line; without one there is no//! message.//!//! Whether the object the tag names really is of the kind its `type` says//! is a question for whoever reads that object, not for this view.
const std = @import("std");
const object = @import("object.zig");const Format = object.Format;const Id = object.Id;const Kind = object.Kind;const Diagnostics = @import("Diagnostics.zig");const Identity = @import("Identity.zig");const commit = @import("commit.zig");
/// An annotated tag's contents, read.pub const Tag = struct { format: Format, /// The whole of the tag's contents. contents: []const u8, /// The object the tag names. target: Id, /// The kind the tag says that object is. target_kind: Kind, /// The tag's name, from its `tag` line. name: []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 tag. pub const Error = error{ MalformedTag, UnknownTagType };
/// Reads the contents of an annotated tag in `format`. For /// `UnknownTagType` the diagnostic's detail is the type as written. pub fn parse(format: Format, contents: []const u8, diag: ?*Diagnostics) Error!Tag { if (!std.mem.startsWith(u8, contents, "object ")) return error.MalformedTag; const target = commit.idLine(format, contents["object ".len..]) orelse return error.MalformedTag; var rest = contents["object ".len + format.hexLength() + 1 ..]; if (!std.mem.startsWith(u8, rest, "type ")) return error.MalformedTag; rest = rest["type ".len..]; const type_end = std.mem.findScalar(u8, rest, '\n') orelse return error.MalformedTag; const kind = Kind.fromName(rest[0..type_end]) orelse { if (diag) |d| d.setDetail("{s}", .{rest[0..type_end]}); return error.UnknownTagType; }; rest = rest[type_end + 1 ..]; if (!std.mem.startsWith(u8, rest, "tag ")) return error.MalformedTag; rest = rest["tag ".len..]; const name_end = std.mem.findScalar(u8, rest, '\n') orelse return error.MalformedTag; var t: Tag = .{ .format = format, .contents = contents, .target = target, .target_kind = kind, .name = rest[0..name_end], .headers = contents, .message = "", }; if (std.mem.find(u8, contents, "\n\n")) |blank| { t.headers = contents[0 .. blank + 1]; t.message = contents[blank + 2 ..]; } return t; }
/// The object a tag's contents name at their start, or null if they do /// not start `object <object ID>`: all that `git cat-file <kind>` needs /// of a tag to go from it to what it tags. Unlike `parse`, it does not /// mind what follows the object ID, or that nothing does. pub fn targetOf(format: Format, contents: []const u8) ?Id { if (!std.mem.startsWith(u8, contents, "object ")) return null; const hex = contents["object ".len..]; if (hex.len < format.hexLength()) return null; return Id.parseHex(format, hex[0..format.hexLength()]) catch null; }
/// Every header, `object`, `type` and `tag` included, in order. pub fn fields(t: Tag) commit.Fields { return .{ .rest = t.headers }; }
/// Who made the tag and when, from its first `tagger` line, or null if /// there is none or git can read nothing from it. pub fn tagger(t: Tag) ?Identity { var it = t.fields(); while (it.next()) |f| { if (std.mem.eql(u8, f.name, "tagger")) if (f.value) |v| { return Identity.parse(v[0 .. std.mem.findScalar(u8, v, '\n') orelse v.len]); }; } return null; }};
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
test "a tag is read as git 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.commit("first"); const head = try repo.output(&.{ "rev-parse", "HEAD" }); defer gpa.free(head); const O = try std.fmt.allocPrint(gpa, "object {s}", .{head}); defer gpa.free(O); var upper_hex: [80]u8 = undefined; var upper_buffer: [128]u8 = undefined; const O_upper = try std.fmt.bufPrint(&upper_buffer, "object {s}", .{std.ascii.upperString(upper_hex[0..head.len], head)}); const missing = try gpa.alloc(u8, format.hexLength()); defer gpa.free(missing); @memset(missing, '1'); const TY = "type commit\n"; const TG = "tag v1\n"; const TR = "tagger T <t@x> 3 +0000\n";
const cases = [_][]const []const u8{ &.{ O, TY, TG, TR, "\nmsg\n" }, &.{ O, TY, TG, "\nmsg\n" }, &.{ O, TY, TR, "\nmsg\n" }, &.{ O, TG, TR, "\nmsg\n" }, &.{ TY, TG, TR, "\nmsg\n" }, &.{ TY, O, TG, TR, "\nmsg\n" }, &.{ O, "type frob\n", TG, TR, "\nmsg\n" }, &.{ O, "type COMMIT\n", TG, TR, "\nmsg\n" }, &.{ O, "type commit\n", TG, TR, "\nmsg\n" }, &.{ O, "type commit \n", TG, TR, "\nmsg\n" }, &.{ O, "type \n", TG, TR, "\nmsg\n" }, &.{ O_upper, TY, TG, TR, "\nmsg\n" }, &.{ O[0 .. O.len - 2], "\n", TY, TG, TR, "\nmsg\n" }, &.{ O[0 .. O.len - 1], " x\n", TY, TG, TR, "\nmsg\n" }, &.{ O, TY, TG, TR, "frob x\n\nmsg\n" }, &.{ O, TY, TG, "frob x\n", TR, "\nmsg\n" }, &.{ O, TY, TG, TR }, &.{ O, TY, TG, TR, "msg\n" }, &.{ O, TY, "tag \n", TR, "\nmsg\n" }, &.{ O, TY, "tag\n", TR, "\nmsg\n" }, &.{ O, TY, "tag v1" }, &.{ O, "type commit" }, &.{ O, TY, TR, TG, "\nmsg\n" }, &.{ O, TY, TG, "tagger nobody\n\nmsg\n" }, &.{ O, TY, TG, TR, "tagger U <u@x> 4 +0000\n\nmsg\n" }, &.{ O, TY, TG, TR, "\nmsg\n-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----\n" }, &.{O}, }; for (cases) |parts| { const contents = try std.mem.concat(gpa, u8, parts); defer gpa.free(contents); errdefer std.debug.print("tag: \"{f}\"\n", .{std.zig.fmtString(contents)}); const id = try repo.hashObject("tag", contents); defer gpa.free(id); var diag: Diagnostics = .{}; const parsed = Tag.parse(format, contents, &diag); // git peels a tag it can read to the object it names, and refuses // one it cannot. const peel_arg = try std.fmt.allocPrint(gpa, "{s}^{{}}", .{id}); defer gpa.free(peel_arg); const peeled = repo.output(&.{ "rev-parse", "--verify", "-q", peel_arg }) catch |err| switch (err) { error.GitFailed => { try testing.expect(std.meta.isError(parsed)); continue; }, else => return err, }; defer gpa.free(peeled); const t = try parsed; var buffer: [object.max_hex_length]u8 = undefined; try testing.expectEqualStrings(std.mem.trimEnd(u8, peeled, "\n"), t.target.toHex(&buffer));
// And for one it can read, `for-each-ref` shows the rest. try repo.git(&.{ "update-ref", "refs/tags/x", id }); const shown = try repo.output(&.{ "for-each-ref", "--format=%(type)%00%(tag)%00%(taggername)%00%(taggeremail)%00%(taggerdate:raw)", "refs/tags/x" }); defer gpa.free(shown); try repo.git(&.{ "update-ref", "-d", "refs/tags/x" }); var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, shown, "\n"), 0); try testing.expectEqualStrings(it.next().?, t.target_kind.name()); try testing.expectEqualStrings(it.next().?, t.name); const name = it.next().?; const email = it.next().?; const date = it.next().?; if (t.tagger()) |who| { try testing.expectEqualStrings(name, who.name); var email_buffer: [256]u8 = undefined; try testing.expectEqualStrings(email, try std.fmt.bufPrint(&email_buffer, "<{s}>", .{who.email})); var date_buffer: [64]u8 = undefined; try testing.expectEqualStrings(date, if (who.date()) |d| try std.fmt.bufPrint(&date_buffer, "{f}", .{d}) else ""); } else { try testing.expectEqualStrings("", name); try testing.expectEqualStrings("", email); } }
var diag: Diagnostics = .{}; const frob = try std.mem.concat(gpa, u8, &.{ O, "type frob\n", TG }); defer gpa.free(frob); try testing.expectError(error.UnknownTagType, Tag.parse(format, frob, &diag)); try testing.expectEqualStrings("frob", diag.detail());}
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| { _ = Tag.targetOf(format, contents); const t = Tag.parse(format, contents, null) catch continue; try testing.expect(Id.eql(t.target, Tag.targetOf(format, contents).?)); var fs = t.fields(); while (fs.next()) |_| {} _ = t.tagger(); }}
test "fuzz: tags" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/tag"), @embedFile("fuzz/tag-sha256"), } });}