Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A mailmap: the names and email addresses people are to be shown under,//! in place of those their commits were made with, as gitmailmap(5)//! describes and `git check-mailmap` and `%aN` show.//!//! A mailmap is read from text with no I/O (`add`), as many times as there//! are sources, each later line taking precedence; `addRepository` reads a//! repository's own sources in git's order. The rules were found by asking//! `git check-mailmap`://!//! - A line beginning with `#` is a comment; a `#` anywhere else is part of//! the line, and a line holding no `<...>` is passed over.//! - The first `<...>` is the email address to show, and what comes before//! it, with blank space trimmed from both ends, the name; an empty email//! address there makes the line nothing at all. A second `<...>` after it//! is the email address to match, and what lies between the two the name//! to match, if any; without one, the first is matched and only the name//! is replaced. Anything after the last `>` is ignored.//! - Email addresses and names are matched without regard to the case of//! ASCII letters, and a name only as a whole, blank space inside it//! included.//! - For an email address alone, a later line replaces the name and the//! email address it gives and keeps the earlier line's for what it//! leaves out; for a name and an email address, a later line replaces//! the earlier one whole.//! - An identity whose name and email address a line matches takes that//! line's name and email address, or keeps its own for what the line//! leaves out, without looking at the lines for its email address alone.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const Repository = @import("Repository.zig");const Resolver = @import("Resolver.zig");const Diagnostics = @import("Diagnostics.zig");
const Mailmap = @This();
arena: std.heap.ArenaAllocator,/// Each email address matched, in lower case, and what it maps to.entries: std.StringHashMapUnmanaged(Entry) = .empty,
const Replacement = struct { name: ?[]const u8 = null, email: ?[]const u8 = null,};
const Entry = struct { /// What an identity with this email address alone becomes. replacement: Replacement = .{}, /// Names matched with this email address, in lower case, and what an /// identity with both becomes. by_name: std.StringHashMapUnmanaged(Replacement) = .empty,};
/// An empty mailmap.pub fn init(gpa: Allocator) Mailmap { return .{ .arena = .init(gpa) };}
/// Frees everything.pub fn deinit(m: *Mailmap) void { m.arena.deinit(); m.* = undefined;}
/// Whether the mailmap maps nothing.pub fn isEmpty(m: *const Mailmap) bool { return m.entries.count() == 0;}
/// What else to read besides a repository's own sources, as `git/// check-mailmap`'s options name them; each takes precedence over what is/// read before it.pub const Extra = struct { /// `--mailmap-blob`. blob: ?[]const u8 = null, /// `--mailmap-file`, relative to the directory git works from. file: ?[]const u8 = null, /// `$HOME`, for a `mailmap.file` beginning with `~`. home: ?[]const u8 = null,};
/// Something about a source git says on standard error and goes on past.pub const Warning = union(enum) { /// The work tree's `.mailmap` is a symbolic link, which git does not /// follow: "unable to open mailmap at .mailmap: Too many levels of /// symbolic links". symbolic_link,};
/// Reads `repo`'s mailmap sources, in the order git reads them, each/// taking precedence over the ones before: `.mailmap` at the top of the/// work tree, never through a symbolic link; `mailmap.blob`, or/// `HEAD:.mailmap` in a bare repository, resolved by `resolver`;/// `mailmap.file`, relative to the top of the work tree; and then/// `extra`'s blob and file. A source that is not there, or a blob/// revision that names no blob, is passed over without a word, as git/// passes it over.pub fn addRepository(m: *Mailmap, io: Io, repo: *Repository, resolver: *Resolver, extra: Extra, warnings: ?*std.ArrayList(Warning)) !void { const gpa = m.arena.child_allocator; if (repo.location.work_tree) |top| { const path = try std.fs.path.join(gpa, &.{ top, ".mailmap" }); defer gpa.free(path); try m.addFile(io, repo.work_dir, path, warnings); } const default_blob: ?[]const u8 = if (repo.location.work_tree == null) "HEAD:.mailmap" else null; const blob = (repo.config.getString("mailmap.blob") catch null) orelse default_blob; if (blob) |b| try m.addBlob(io, repo, resolver, b); if (repo.config.getPath(gpa, "mailmap.file", .{ .home = extra.home }) catch null) |p| { defer gpa.free(p.path); try m.addRelative(io, repo, p.path, warnings); } if (extra.blob) |b| try m.addBlob(io, repo, resolver, b); if (extra.file) |f| try m.addRelative(io, repo, f, warnings);}
fn addRelative(m: *Mailmap, io: Io, repo: *Repository, path: []const u8, warnings: ?*std.ArrayList(Warning)) !void { const gpa = m.arena.child_allocator; const full = if (std.fs.path.isAbsolute(path) or repo.location.work_tree == null) try gpa.dupe(u8, path) else try std.fs.path.join(gpa, &.{ repo.location.work_tree.?, path }); defer gpa.free(full); _ = warnings; const contents = repo.work_dir.readFileAlloc(io, full, gpa, .limited(64 << 20)) catch return; defer gpa.free(contents); try m.add(contents);}
fn addFile(m: *Mailmap, io: Io, dir: Io.Dir, path: []const u8, warnings: ?*std.ArrayList(Warning)) !void { const gpa = m.arena.child_allocator; const st = dir.statFile(io, path, .{ .follow_symlinks = false }) catch return; if (st.kind == .sym_link) { if (warnings) |w| try w.append(gpa, .symbolic_link); return; } const contents = dir.readFileAlloc(io, path, gpa, .limited(64 << 20)) catch return; defer gpa.free(contents); try m.add(contents);}
fn addBlob(m: *Mailmap, io: Io, repo: *Repository, resolver: *Resolver, revision: []const u8) !void { const gpa = m.arena.child_allocator; var diag: Diagnostics = .{}; const found = resolver.resolve(revision, &diag) catch return; resolver.clearMessages(); var obj = repo.objects().read(io, gpa, found.id, &diag) catch return; defer obj.deinit(gpa); if (obj.kind != .blob) return; try m.add(obj.data);}
/// One line of a mailmap, read.pub const Line = struct { /// The name to show, if any. name: ?[]const u8, /// The email address to show, if the line changes it. email: ?[]const u8, /// The name matched, if the line matches one. match_name: ?[]const u8, /// The email address matched. match_email: []const u8,};
/// git's blank space, which leaves out the vertical tab and form feed.const blank = " \t\n\r";
fn trimmed(text: []const u8) ?[]const u8 { const t = std.mem.trim(u8, text, blank); return if (t.len == 0) null else t;}
/// Reads one line, without its line feed; null for a comment or for a line/// that maps nothing.pub fn parseLine(line: []const u8) ?Line { if (line.len != 0 and line[0] == '#') return null; const lt = std.mem.findScalar(u8, line, '<') orelse return null; const gt = lt + 1 + (std.mem.findScalar(u8, line[lt + 1 ..], '>') orelse return null); const email = line[lt + 1 .. gt]; if (email.len == 0) return null; const name = trimmed(line[0..lt]); const rest = line[gt + 1 ..]; if (std.mem.findScalar(u8, rest, '<')) |lt2| { if (std.mem.findScalar(u8, rest[lt2 + 1 ..], '>')) |n| { return .{ .name = name, .email = email, .match_name = trimmed(rest[0..lt2]), .match_email = rest[lt2 + 1 ..][0..n], }; } } return .{ .name = name, .email = null, .match_name = null, .match_email = email };}
fn lower(a: Allocator, text: []const u8) Allocator.Error![]u8 { const out = try a.alloc(u8, text.len); _ = std.ascii.lowerString(out, text); return out;}
/// Adds the lines of one mailmap source, which later sources take/// precedence over.pub fn add(m: *Mailmap, text: []const u8) Allocator.Error!void { var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { const l = parseLine(line) orelse continue; try m.addLine(l); }}
/// Adds one line, read.pub fn addLine(m: *Mailmap, l: Line) Allocator.Error!void { const a = m.arena.allocator(); const key = try lower(a, l.match_email); const slot = try m.entries.getOrPut(a, key); if (!slot.found_existing) slot.value_ptr.* = .{}; const entry = slot.value_ptr; const name = if (l.name) |n| try a.dupe(u8, n) else null; const email = if (l.email) |e| try a.dupe(u8, e) else null; if (l.match_name) |match_name| { try entry.by_name.put(a, try lower(a, match_name), .{ .name = name, .email = email }); } else { if (name) |n| entry.replacement.name = n; if (email) |e| entry.replacement.email = e; }}
/// An identity's name and email address, as shown.pub const Mapped = struct { name: []const u8, email: []const u8,};
/// What `name` and `email` are shown as. The result borrows from the/// arguments and the mailmap.pub fn map(m: *const Mailmap, name: []const u8, email: []const u8) Mapped { var buffer: [256]u8 = undefined; const entry = m.lookup(&buffer, email) orelse return .{ .name = name, .email = email }; const r: Replacement = by_name: { if (entry.by_name.count() != 0) { var name_buffer: [256]u8 = undefined; if (lowerInto(&name_buffer, name)) |key| { if (entry.by_name.get(key)) |r| break :by_name r; } else { var it = entry.by_name.iterator(); while (it.next()) |e| if (std.ascii.eqlIgnoreCase(e.key_ptr.*, name)) break :by_name e.value_ptr.*; } } break :by_name entry.replacement; }; return .{ .name = r.name orelse name, .email = r.email orelse email };}
fn lowerInto(buffer: []u8, text: []const u8) ?[]const u8 { if (text.len > buffer.len) return null; return std.ascii.lowerString(buffer[0..text.len], text);}
fn lookup(m: *const Mailmap, buffer: []u8, email: []const u8) ?*const Entry { if (lowerInto(buffer, email)) |key| return m.entries.getPtr(key); var it = m.entries.iterator(); while (it.next()) |e| if (std.ascii.eqlIgnoreCase(e.key_ptr.*, email)) return e.value_ptr; return null;}
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
/// Mailmaps and the contacts asked of each, compared with `git/// check-mailmap`.const cases = [_]struct { []const u8, []const []const u8 }{ .{ "Proper Name <commit@email.xx>\n", &.{ "Old <commit@email.xx>", "Old <COMMIT@email.XX>", "<commit@email.xx>", "Old <other@x>" } }, .{ "<proper@x> <commit@x>\n", &.{ "Old <commit@x>", "<commit@x>" } }, .{ "Proper <proper@x> <commit@x>\n", &.{ "Old <commit@x>", "<commit@x>" } }, .{ "Proper <proper@x> Commit Name <commit@x>\n", &.{ "Commit Name <commit@x>", "commit name <COMMIT@x>", "Other <commit@x>", "<commit@x>" } }, .{ " Spaced Name <a@x> # comment\n", &.{"X <a@x>"} }, .{ "# full comment\n\nNoEmail\nName <unterminated\n<> <a@x>\nN <a@x> <b@x> trailing junk\n", &.{ "X <a@x>", "X <b@x>", "X <unterminated>" } }, .{ "A <a@x>\nB <a@x>\n", &.{"X <a@x>"} }, .{ "A <p@x> <a@x>\nB <q@x> <a@x>\n", &.{"X <a@x>"} }, .{ "A <p@x> X <a@x>\nB <q@x> <a@x>\n", &.{ "X <a@x>", "Y <a@x>" } }, .{ "B <q@x> <a@x>\nA <p@x> X <a@x>\n", &.{ "X <a@x>", "Y <a@x>" } }, .{ "A <a@x>\n<p@x> <a@x>\n", &.{"X <a@x>"} }, .{ "<p@x> <a@x>\nA <a@x>\n", &.{"X <a@x>"} }, .{ "<p@x> X <a@x>\nB <a@x>\n", &.{ "X <a@x>", "Y <a@x>" } }, .{ "\xc3\x89 <a@x> \xc3\xa9 <b@x>\n", &.{ "\xc3\x89 <b@x>", "\xc3\xa9 <b@x>" } }, .{ "A <a@x> X <B@X>\n", &.{"x <b@x>"} }, .{ "Name<a@x>\n", &.{"X <a@x>"} }, .{ "Name <a@x>junk<b@x>\n", &.{ "X <b@x>", "junk <b@x>" } }, .{ "Name <a@x> # c <b@x>\n", &.{"X <b@x>"} }, .{ "A <a@x>\r\n", &.{"X <a@x>"} }, .{ "\tA\t<a@x>\n", &.{"X <a@x>"} }, .{ "A <a@x>", &.{"X <a@x>"} }, .{ "A <a@x\n", &.{"X <a@x>"} }, .{ "A <a@x> B\n", &.{"B <a@x>"} }, .{ "A <a@x> <>\n", &.{ "X <>", "X <a@x>" } }, .{ "A <a#b@x>\n", &.{"X <a#b@x>"} }, .{ " # A <a@x>\n", &.{"X <a@x>"} }, .{ "A <>\n", &.{ "X <>", "X <a@x>" } }, .{ "B <> <a@x>\n", &.{ "X <>", "X <a@x>" } }, .{ "A <p@x> X <a@x>\n<q@x> X <a@x>\n", &.{"X <a@x>"} }, .{ "A <p@x> X Y <a@x>\n", &.{ "X Y <a@x>", "X Y <a@x>" } }, .{ "A <p@x> <a@x> <b@x>\n", &.{ "X <a@x>", "X <b@x>" } }, .{ " <a@x>\n", &.{"X <a@x>"} }, .{ "A\x0b<a@x>\n", &.{"X <a@x>"} }, .{ "A <a@x>\x00junk\nB <b@x>\n", &.{ "X <a@x>", "X <b@x>" } }, .{ "a <a@x> b <B@x>\nc <c@x> B <b@X>\n", &.{"b <b@x>"} },};
test "a mailmap maps identities as git check-mailmap does" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); for (cases) |case| { try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = ".mailmap", .data = case[0] }); var m: Mailmap = .init(gpa); defer m.deinit(); try m.add(case[0]); for (case[1]) |contact| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "check-mailmap", contact }); const want = try repo.output(args.items); defer gpa.free(want); const lt = std.mem.findScalar(u8, contact, '<').?; const name = std.mem.trimEnd(u8, contact[0..lt], " "); const email = contact[lt + 1 .. std.mem.findScalarLast(u8, contact, '>').?]; const got = m.map(name, email); const shown = if (got.name.len == 0) try std.fmt.allocPrint(gpa, "<{s}>\n", .{got.email}) else try std.fmt.allocPrint(gpa, "{s} <{s}>\n", .{ got.name, got.email }); defer gpa.free(shown); errdefer std.debug.print("mailmap \"{f}\", contact \"{s}\"\n", .{ std.zig.fmtString(case[0]), contact }); try testing.expectEqualStrings(want, shown); } }}
test "fuzz: mailmap lines" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [512]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); var m: Mailmap = .init(testing.allocator); defer m.deinit(); try m.add(text); var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { const l = parseLine(line) orelse continue; try testing.expect(l.email == null or l.email.?.len != 0); const got = m.map(l.match_name orelse "Someone", l.match_email); try testing.expect(std.mem.findScalar(u8, got.email, '>') == null); } } }.one, .{ .corpus = &.{ @embedFile("fuzz/mailmap"), cases[5][0], cases[8][0] } });}