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
//! An identity line: who, and when, as a commit's `author` and `committer`//! lines and a tag's `tagger` line record it —//!//! A U Thor <author@example.com> 1234567890 +0100//!//! read as forgivingly as git reads it, which is very. The rules here are//! git's observed ones (the tests make commits with `git hash-object//! --literally` and compare with what `git log` shows of them)://!//! - The line ends at its first NUL, if it has one.//! - The email address is what lies between the first `<` and the first//! `>` after it; without both, git shows nothing of the line at all.//! - The name is everything before the `<`, less trailing whitespace.//! - The date is read after the *last* `>`: whitespace, decimal seconds,//! optional whitespace, then a time zone of a sign and at least one//! digit. Anything after the time zone's digits is ignored. A line//! without a readable date still has a name and an email address.//! - Seconds past the largest signed 64-bit value are shown as the epoch//! in UTC, and a time zone not strictly between the smallest and largest//! signed 32-bit values as `+0000`.//!//! Whitespace is git's: space, tab, carriage return and line feed.//!//! Nothing is allocated: the fields are slices of the line.
const std = @import("std");
const Identity = @This();
/// The name, as written.name: []const u8,/// The email address, without its angle brackets.email: []const u8,/// The digits of the date's seconds as written, leading zeros and all, or/// empty if the line has no readable date. `git log --format=%at` prints/// these rather than the value they stand for.timestamp: []const u8,date_value: ?Date,
/// When something happened, and the time zone it happened in.pub const Date = struct { /// Seconds since the epoch, in UTC. seconds: i64, /// The time zone as written, read as a decimal number: hours times a /// hundred plus minutes, so `-0130` is -130. git does not check that /// the minutes are under 60, nor the hours under 24. zone: i32,
/// Prints the date in git's raw form, `1234567890 +0100`, as /// `--date=raw` shows it. pub fn format(d: Date, w: *std.Io.Writer) std.Io.Writer.Error!void { const sign: u8 = if (d.zone < 0) '-' else '+'; try w.print("{d} {c}{d:0>4}", .{ d.seconds, sign, @abs(d.zone) }); }};
/// Reads an identity line, without the `author ` or similar that/// introduces it. Returns null for a line git shows nothing of: one/// without a `<` and a `>` after it.pub fn parse(line: []const u8) ?Identity { const text = line[0 .. std.mem.findScalar(u8, line, 0) orelse line.len]; const lt = std.mem.findScalar(u8, text, '<') orelse return null; const gt = lt + 1 + (std.mem.findScalar(u8, text[lt + 1 ..], '>') orelse return null); const last_gt = std.mem.findScalarLast(u8, text, '>').?; var id: Identity = .{ .name = std.mem.trimEnd(u8, text[0..lt], whitespace), .email = text[lt + 1 .. gt], .timestamp = "", .date_value = null, }; parseDate(text[last_gt + 1 ..], &id); return id;}
/// The date, or null if the line has none git can read.pub fn date(id: Identity) ?Date { return id.date_value;}
const whitespace = " \t\r\n";
fn isSpace(c: u8) bool { return std.mem.findScalar(u8, whitespace, c) != null;}
fn parseDate(rest: []const u8, id: *Identity) void { var i: usize = 0; while (i < rest.len and isSpace(rest[i])) i += 1; const digits_start = i; while (i < rest.len and std.ascii.isDigit(rest[i])) i += 1; if (i == digits_start) return; const digits = rest[digits_start..i]; while (i < rest.len and isSpace(rest[i])) i += 1; if (i == rest.len or (rest[i] != '+' and rest[i] != '-')) return; const negative = rest[i] == '-'; i += 1; const zone_start = i; while (i < rest.len and std.ascii.isDigit(rest[i])) i += 1; if (i == zone_start) return;
id.timestamp = digits; const seconds = decimal(digits); const zone_magnitude = decimal(rest[zone_start..i]); if (seconds > std.math.maxInt(i64)) { id.date_value = .{ .seconds = 0, .zone = 0 }; return; } // git keeps a zone strictly between the smallest and largest 32-bit // values, and shows anything else as +0000. const zone: i32 = if (zone_magnitude >= @as(u64, if (negative) 1 << 31 else (1 << 31) - 1)) 0 else if (negative) -@as(i32, @intCast(zone_magnitude)) else @intCast(zone_magnitude); id.date_value = .{ .seconds = @intCast(seconds), .zone = zone };}
/// The value of a string of decimal digits, or the largest u64 if it is/// bigger than that.fn decimal(digits: []const u8) u64 { var v: u64 = 0; for (digits) |c| { v = std.math.mul(u64, v, 10) catch return std.math.maxInt(u64); v = std.math.add(u64, v, c - '0') catch return std.math.maxInt(u64); } return v;}
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
/// Identity lines git reads in surprising ways, every one of them/// compared with what `git log` makes of it.const odd_lines = [_][]const u8{ "A U Thor <a@x> 1234567890 +0100", "A U Thor <a@x> 1234567890 -0130", "A U Thor <a@x>", "A U Thor <a@x> ", "A U Thor <a@x> 1234567890", "A U Thor <a@x> 1234567890 ", "A U Thor <a@x> 1234567890 +01", "A U Thor <a@x> 1234567890 +2400", "A U Thor <a@x> 1234567890 +9999", "A U Thor <a@x> 1234567890 0100", "A U Thor <a@x> 1234567890 +0100 extra", "A U Thor <a@x> -5 +0000", "A U Thor <a@x> 99999999999999999999 +0100", "A U Thor <a@x> 18446744073709551615 +0000", "A U Thor <a@x> 9223372036854775807 +0000", "A U Thor <a@x> 9223372036854775808 -0700", "A U Thor <a@x> 0x10 +0000", "A U Thor <a@x> 00012 +0000", "A U Thor <a@x> 1234567890 +0100", "A U Thor <a@x>1234567890+0100", "A U Thor a@x 1234567890 +0100", "A U Thor <a@x 1234567890 +0100", "A U Thor a@x> 1234567890 +0100", "<a@x> 1234567890 +0100", " <a@x> 1234567890 +0100", "A U Thor <a@x> 1234567890 +0100", " A U Thor <a@x> 1234567890 +0100", "A <b> <c@x> 1234567890 +0100", "A <b@x> 1 +0000> 1234567890 +0100", "A > <a@x> 1 +0000", "A <> 1234567890 +0100", "A <a@x> 1234567890 +0100 <evil> 5 +0000", "", "A", "A <a@x> 1234567890 -0000", "A <a@x> 1234567890 +12345", "A <a@x> 1234567890 ++0100", "A <a@x> 1234567890 +-100", "A <a@x> 1234567890 + 0100", "A <a@x> 1234567890 +", "A <a@x> 1 +1x", "A <a@x> 1 +2147483647", "A <a@x> 1 +2147483646", "A <a@x> 1 +2147483648", "A <a@x> 1 -2147483647", "A <a@x> 1 -2147483648", "A <a@x> 1 -99999", "A <a@x> 1 +0000000000000000000000", "A\tB <a\tx> 1234567890 +0100", "A <a@x> 1234567890\t+0100", "A <a@x> \r1 +0000", "A <a@x> \x0c1 +0000", "A <a@x> 1\x0b+0000", "A\r <a@x> 1 +0000", "A\x0c<a@x> 1 +0000", "A\x00B <a@x> 1 +0000", "A <a@x> 1 +0000\x00junk",};
test "an identity line is read as git log reads it" { 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 (odd_lines) |line| { // The line is the committer's: git trims leading whitespace from // an author's name for `%an`, though not for `%cn` or `git show`. const commit = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A <a> 1 +0000\ncommitter {s}\n\nm\n", .{ std.mem.trimEnd(u8, empty_tree, "\n"), line }); defer gpa.free(commit); const id = try repo.hashObject("commit", commit); defer gpa.free(id); const shown = try repo.output(&.{ "log", "-1", "--date=raw", "--format=%cn%x00%ce%x00%ct%x00%cd", id }); defer gpa.free(shown); var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, shown, "\n"), 0); const want_name = it.next().?; const want_email = it.next().?; const want_timestamp = it.next().?; const want_date = it.next().?;
errdefer std.debug.print("identity line: \"{f}\"\n", .{std.zig.fmtString(line)}); const got = parse(line) orelse { try testing.expectEqualStrings("", want_name); try testing.expectEqualStrings("", want_email); try testing.expectEqualStrings("", want_date); continue; }; try testing.expectEqualStrings(want_name, got.name); try testing.expectEqualStrings(want_email, got.email); try testing.expectEqualStrings(want_timestamp, got.timestamp); var buffer: [64]u8 = undefined; const got_date = if (got.date()) |d| try std.fmt.bufPrint(&buffer, "{f}", .{d}) else ""; try testing.expectEqualStrings(want_date, got_date); }}
test "fuzz: identity lines" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const line = fuzz_input.bytes(smith, &buffer); const id = parse(line) orelse return; try testing.expect(std.mem.findScalar(u8, id.email, '>') == null); if (id.date()) |d| { var out: [64]u8 = undefined; _ = try std.fmt.bufPrint(&out, "{f}", .{d}); } } }.one, .{ .corpus = &(odd_lines ++ [_][]const u8{@embedFile("fuzz/identity")}) });}