// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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 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 1234567890 +0100", "A U Thor 1234567890 -0130", "A U Thor ", "A U Thor ", "A U Thor 1234567890", "A U Thor 1234567890 ", "A U Thor 1234567890 +01", "A U Thor 1234567890 +2400", "A U Thor 1234567890 +9999", "A U Thor 1234567890 0100", "A U Thor 1234567890 +0100 extra", "A U Thor -5 +0000", "A U Thor 99999999999999999999 +0100", "A U Thor 18446744073709551615 +0000", "A U Thor 9223372036854775807 +0000", "A U Thor 9223372036854775808 -0700", "A U Thor 0x10 +0000", "A U Thor 00012 +0000", "A U Thor 1234567890 +0100", "A U Thor 1234567890+0100", "A U Thor a@x 1234567890 +0100", "A U Thor 1234567890 +0100", " 1234567890 +0100", " 1234567890 +0100", "A U Thor 1234567890 +0100", " A U Thor 1234567890 +0100", "A 1234567890 +0100", "A 1 +0000> 1234567890 +0100", "A > 1 +0000", "A <> 1234567890 +0100", "A 1234567890 +0100 5 +0000", "", "A", "A 1234567890 -0000", "A 1234567890 +12345", "A 1234567890 ++0100", "A 1234567890 +-100", "A 1234567890 + 0100", "A 1234567890 +", "A 1 +1x", "A 1 +2147483647", "A 1 +2147483646", "A 1 +2147483648", "A 1 -2147483647", "A 1 -2147483648", "A 1 -99999", "A 1 +0000000000000000000000", "A\tB 1234567890 +0100", "A 1234567890\t+0100", "A \r1 +0000", "A \x0c1 +0000", "A 1\x0b+0000", "A\r 1 +0000", "A\x0c 1 +0000", "A\x00B 1 +0000", "A 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 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 fuzz_input.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")}) }); }