// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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 \n", &.{ "Old ", "Old ", "", "Old " } }, .{ " \n", &.{ "Old ", "" } }, .{ "Proper \n", &.{ "Old ", "" } }, .{ "Proper Commit Name \n", &.{ "Commit Name ", "commit name ", "Other ", "" } }, .{ " Spaced Name # comment\n", &.{"X "} }, .{ "# full comment\n\nNoEmail\nName \nN trailing junk\n", &.{ "X ", "X ", "X " } }, .{ "A \nB \n", &.{"X "} }, .{ "A \nB \n", &.{"X "} }, .{ "A X \nB \n", &.{ "X ", "Y " } }, .{ "B \nA X \n", &.{ "X ", "Y " } }, .{ "A \n \n", &.{"X "} }, .{ " \nA \n", &.{"X "} }, .{ " X \nB \n", &.{ "X ", "Y " } }, .{ "\xc3\x89 \xc3\xa9 \n", &.{ "\xc3\x89 ", "\xc3\xa9 " } }, .{ "A X \n", &.{"x "} }, .{ "Name\n", &.{"X "} }, .{ "Name junk\n", &.{ "X ", "junk " } }, .{ "Name # c \n", &.{"X "} }, .{ "A \r\n", &.{"X "} }, .{ "\tA\t\n", &.{"X "} }, .{ "A ", &.{"X "} }, .{ "A "} }, .{ "A B\n", &.{"B "} }, .{ "A <>\n", &.{ "X <>", "X " } }, .{ "A \n", &.{"X "} }, .{ " # A \n", &.{"X "} }, .{ "A <>\n", &.{ "X <>", "X " } }, .{ "B <> \n", &.{ "X <>", "X " } }, .{ "A X \n X \n", &.{"X "} }, .{ "A X Y \n", &.{ "X Y ", "X Y " } }, .{ "A \n", &.{ "X ", "X " } }, .{ " \n", &.{"X "} }, .{ "A\x0b\n", &.{"X "} }, .{ "A \x00junk\nB \n", &.{ "X ", "X " } }, .{ "a b \nc B \n", &.{"b "} }, }; 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] } }); }