// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Reflog entries, and the lines the files backend keeps them in under //! `logs/`, one line per change, oldest first: //! //! SP SP [TAB ] LF //! //! as git-update-ref(1) describes them. The identity is read with //! `Identity`, and the message is everything after the tab. git passes //! over a line it cannot read rather than failing — one with a malformed //! object ID, no email address or no date — and over a last line with no //! line feed, and so does this. const std = @import("std"); const Allocator = std.mem.Allocator; const object = @import("../object.zig"); const Id = object.Id; const Identity = @import("../Identity.zig"); /// One change to a ref, whichever backend recorded it. pub const Entry = struct { /// The value before; all zeros when the ref was created. old: Id, /// The value after; all zeros when the ref was deleted. new: Id, /// Who made the change. name: []const u8, /// Their email address, without angle brackets. email: []const u8, /// When, and in what time zone. date: Identity.Date, /// Why, without a trailing line feed; empty when none was given. message: []const u8, }; /// A ref's reflog, oldest entry first. pub const Reflog = struct { entries: []Entry, /// What the entries' strings live in. arena: std.heap.ArenaAllocator, /// Frees everything. pub fn deinit(r: *Reflog) void { r.arena.deinit(); r.* = undefined; } }; /// Reads one line of a reflog file, without its line feed, or returns /// null for a line git passes over. The entry's strings are slices of /// `line`. pub fn parseLine(format: object.Format, line: []const u8) ?Entry { const n = format.hexLength(); if (line.len < 2 * n + 2 or line[n] != ' ' or line[2 * n + 1] != ' ') return null; const old = Id.parseHex(format, line[0..n]) catch return null; const new = Id.parseHex(format, line[n + 1 .. 2 * n + 1]) catch return null; const rest = line[2 * n + 2 ..]; const tab = std.mem.findScalar(u8, rest, '\t'); const identity = Identity.parse(rest[0 .. tab orelse rest.len]) orelse return null; const date = identity.date() orelse return null; return .{ .old = old, .new = new, .name = identity.name, .email = identity.email, .date = date, .message = if (tab) |t| rest[t + 1 ..] else "", }; } /// Reads a whole reflog file, oldest entry first, into `entries`; the /// entries' strings are slices of `contents`. pub fn parseFile(gpa: Allocator, format: object.Format, contents: []const u8, entries: *std.ArrayList(Entry)) Allocator.Error!void { var rest = contents; while (std.mem.findScalar(u8, rest, '\n')) |nl| { if (parseLine(format, rest[0..nl])) |e| try entries.append(gpa, e); rest = rest[nl + 1 ..]; } } /// The time zone a reftable log record keeps in minutes east of UTC, as /// `Identity.Date` keeps it: hours times a hundred plus minutes. pub fn zoneFromMinutes(minutes: i16) i32 { const m: i32 = minutes; const a: i32 = @intCast(@abs(m)); const z = @divTrunc(a, 60) * 100 + @mod(a, 60); return if (m < 0) -z else z; } // -- tests ----------------------------------------------------------------------- const testing = std.testing; const fuzz_input = @import("../testing/fuzz.zig"); test "reflog lines are read, and the ones git passes over passed over" { const gpa = testing.allocator; var entries: std.ArrayList(Entry) = .empty; defer entries.deinit(gpa); try parseFile(gpa, .sha1, @embedFile("../fuzz/reflog"), &entries); try testing.expectEqual(2, entries.items.len); try testing.expectEqualStrings("branch: Created from main", entries.items[0].message); try testing.expectEqualStrings("C O Mitter", entries.items[1].name); try testing.expectEqual(-500, entries.items[1].date.zone); const z = "0000000000000000000000000000000000000000"; const h = "f262b5fdc70bf36bebcf70cc60cf788a294c8126"; try testing.expect(parseLine(.sha1, "garbage") == null); try testing.expect(parseLine(.sha1, z ++ " " ++ h ++ " A a@x 1790000000 +0100\tm") == null); try testing.expect(parseLine(.sha1, z ++ " " ++ h ++ " A \tm") == null); try testing.expect(parseLine(.sha1, z ++ " " ++ h ++ " A 1790000000 +0100\tm") == null); const e = parseLine(.sha1, z ++ " " ++ h ++ " A 1790000000 +0100\tmsg\twith tab\r").?; try testing.expectEqualStrings("msg\twith tab\r", e.message); try testing.expectEqualStrings("", parseLine(.sha1, z ++ " " ++ h ++ " A 1790000000 +0100").?.message); entries.clearRetainingCapacity(); try parseFile(gpa, .sha1, z ++ " " ++ h ++ " A 1 +0000\tno newline", &entries); try testing.expectEqual(0, entries.items.len); } test "reftable time zones become git's" { try testing.expectEqual(-800, zoneFromMinutes(-480)); try testing.expectEqual(230, zoneFromMinutes(150)); try testing.expectEqual(0, zoneFromMinutes(0)); } test "fuzz: reflog files" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [1024]u8 = undefined; const input = @import("../testing/fuzz.zig").bytes(smith, &buffer); var entries: std.ArrayList(Entry) = .empty; defer entries.deinit(testing.allocator); for ([_]object.Format{ .sha1, .sha256 }) |f| try parseFile(testing.allocator, f, input, &entries); } }.one, .{ .corpus = &.{@embedFile("../fuzz/reflog")} }); }