Skip to main content

grit_lib/
mailmap.rs

1//! Parse `.mailmap` and resolve author/committer identities (Git-compatible).
2//!
3//! Behaviour matches Git's `mailmap.c`: load order, `mailmap.blob` default in bare repos,
4//! nofollow for in-tree `.mailmap`, and case-insensitive email/name matching with
5//! per-email buckets (simple remap vs name-specific entries).
6
7use crate::error::Error;
8use crate::objects::ObjectKind;
9use crate::repo::Repository;
10use crate::rev_parse::resolve_revision;
11use std::borrow::Cow;
12use std::collections::BTreeMap;
13use std::fs;
14#[cfg(unix)]
15use std::io::Read;
16use std::path::{Path, PathBuf};
17
18type Result<T> = std::result::Result<T, Error>;
19
20/// Legacy line-shaped entry kept for API compatibility; prefer [`MailmapTable`].
21#[derive(Debug, Clone)]
22pub struct MailmapEntry {
23    /// Canonical name (`None` = keep original).
24    pub canonical_name: Option<String>,
25    /// Canonical email (`None` = keep original).
26    pub canonical_email: Option<String>,
27    /// Match on this name (`None` = any name with the email).
28    pub match_name: Option<String>,
29    /// Match on this email.
30    pub match_email: String,
31}
32
33#[derive(Debug, Default, Clone)]
34struct MailmapInfo {
35    name: Option<String>,
36    email: Option<String>,
37}
38
39#[derive(Debug, Default, Clone)]
40struct MailmapBucket {
41    /// Simple entry: remap any name with this email (`old_name == None` lines).
42    simple: MailmapInfo,
43    /// Name-specific remaps keyed by lowercased old name.
44    by_name: BTreeMap<String, MailmapInfo>,
45}
46
47/// Parsed mailmap as a lookup table (Git `string_list` + nested `namemap`).
48#[derive(Debug, Default, Clone)]
49pub struct MailmapTable {
50    /// Key: lowercased match email.
51    buckets: BTreeMap<String, MailmapBucket>,
52}
53
54impl MailmapTable {
55    /// Returns true when no mappings are configured.
56    #[must_use]
57    pub fn is_empty(&self) -> bool {
58        self.buckets.is_empty()
59    }
60
61    /// Apply mailmap to a name/email pair (both may be empty strings).
62    ///
63    /// Returns `(mapped_name, mapped_email)` after applying the same rules as Git's `map_user`.
64    #[must_use]
65    pub fn map_user(&self, name: String, email: String) -> (String, String) {
66        self.map_user_str(name.as_str(), email.as_str())
67    }
68
69    /// Like [`Self::map_user`] but takes string slices (avoids cloning when nothing matches).
70    #[must_use]
71    pub fn map_user_str(&self, name: &str, email: &str) -> (String, String) {
72        let (name, email) = self.map_user_cow(name, email);
73        (name.into_owned(), email.into_owned())
74    }
75
76    /// Borrowed name/email when the mailmap does not remap this identity.
77    #[must_use]
78    pub fn map_user_cow<'a>(&self, name: &'a str, email: &'a str) -> (Cow<'a, str>, Cow<'a, str>) {
79        let key = email_lookup_key(email);
80        let Some(bucket) = self.buckets.get(key.as_ref()) else {
81            return (Cow::Borrowed(name), Cow::Borrowed(email));
82        };
83
84        let info = if !bucket.by_name.is_empty() {
85            let nk = name.to_ascii_lowercase();
86            bucket.by_name.get(&nk).or_else(|| {
87                if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
88                    Some(&bucket.simple)
89                } else {
90                    None
91                }
92            })
93        } else if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
94            Some(&bucket.simple)
95        } else {
96            None
97        };
98
99        let Some(info) = info else {
100            return (Cow::Borrowed(name), Cow::Borrowed(email));
101        };
102        if info.name.is_none() && info.email.is_none() {
103            return (Cow::Borrowed(name), Cow::Borrowed(email));
104        }
105        let mut out_name = name.to_owned();
106        let mut out_email = email.to_owned();
107        if let Some(ref e) = info.email {
108            out_email.clone_from(e);
109        }
110        if let Some(ref n) = info.name {
111            out_name.clone_from(n);
112        }
113        (Cow::Owned(out_name), Cow::Owned(out_email))
114    }
115}
116
117fn email_lookup_key(email: &str) -> Cow<'_, str> {
118    if email.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
119        Cow::Owned(email.to_ascii_lowercase())
120    } else {
121        Cow::Borrowed(email)
122    }
123}
124
125fn ascii_lowercase_owned(s: &str) -> String {
126    s.chars().map(|c| c.to_ascii_lowercase()).collect()
127}
128
129fn add_mapping(
130    table: &mut MailmapTable,
131    new_name: Option<String>,
132    new_email: Option<String>,
133    old_name: Option<String>,
134    old_email: Option<String>,
135) {
136    // Match Git `add_mapping`: when the line has only one `<email>` pair, `old_email` is NULL and
137    // the canonical email is the lookup key (`old_email = new_email; new_email = NULL`).
138    let (old_email, new_email) = match (old_email, new_email) {
139        (None, Some(e)) => (e, None),
140        (Some(old), new) => (old, new),
141        (None, None) => return,
142    };
143
144    let key = ascii_lowercase_owned(&old_email);
145    let bucket = table.buckets.entry(key).or_default();
146
147    if let Some(old_n) = old_name {
148        let nk = ascii_lowercase_owned(&old_n);
149        let mi = MailmapInfo {
150            name: new_name,
151            email: new_email,
152        };
153        bucket.by_name.insert(nk, mi);
154    } else {
155        if let Some(n) = new_name {
156            bucket.simple.name = Some(n);
157        }
158        if let Some(e) = new_email {
159            bucket.simple.email = Some(e);
160        }
161    }
162}
163
164/// Parse `buffer` like Git's `parse_name_and_email` (second pair uses `allow_empty_email`).
165fn parse_name_and_email(
166    buffer: &str,
167    allow_empty_email: bool,
168) -> Option<(Option<String>, Option<String>, &str)> {
169    let left = buffer.find('<')?;
170    let rest = &buffer[left + 1..];
171    let right_rel = rest.find('>')?;
172    if !allow_empty_email && right_rel == 0 {
173        return None;
174    }
175    // Do not trim inside `<>` — Git keeps spaces as part of the map key so
176    // `< a@example.com >` does not match a commit's `a@example.com` (t4203).
177    let email = rest[..right_rel].to_string();
178    let right = left + 1 + right_rel;
179    let name_part = buffer[..left].trim_end_matches(|c: char| c.is_ascii_whitespace());
180    let name = if name_part.is_empty() {
181        None
182    } else {
183        Some(name_part.to_string())
184    };
185    let after = buffer.get(right + 1..).unwrap_or("");
186    Some((name, Some(email), after))
187}
188
189fn read_mailmap_line_into(table: &mut MailmapTable, line: &str) {
190    let line = line.trim_end_matches(['\r', '\n']);
191    let line = line.trim_start();
192    if line.is_empty() || line.starts_with('#') {
193        return;
194    }
195
196    // Match Git `read_mailmap_line`: the first pair uses `allow_empty_email=0`, so a line whose
197    // first `<>` is empty (e.g. `Cee <> <c@example.com>`) is ignored entirely — only the second
198    // pair is parsed with `allow_empty_email=1`.
199    let (name1, email1, rest1) = match parse_name_and_email(line, false) {
200        Some(x) => x,
201        None => return,
202    };
203
204    let (name2, email2) = if rest1.trim().is_empty() {
205        (None, None)
206    } else {
207        match parse_name_and_email(rest1.trim_start(), true) {
208            Some((n, e, tail)) if tail.trim().is_empty() => (n, e),
209            _ => return,
210        }
211    };
212
213    add_mapping(table, name1, email1, name2, email2);
214}
215
216/// Append mappings from a mailmap file body (Git `read_mailmap_string`).
217pub fn read_mailmap_string(table: &mut MailmapTable, buf: &str) {
218    let mut start = 0usize;
219    for (i, ch) in buf.char_indices() {
220        if ch == '\n' {
221            read_mailmap_line_into(table, &buf[start..i]);
222            start = i + 1;
223        }
224    }
225    if start < buf.len() {
226        read_mailmap_line_into(table, &buf[start..]);
227    }
228}
229
230/// Convert a legacy vector of line entries into a table (last-wins per Git order is already in vec order).
231#[must_use]
232pub fn table_from_entries(entries: &[MailmapEntry]) -> MailmapTable {
233    let mut table = MailmapTable::default();
234    for e in entries {
235        add_mapping(
236            &mut table,
237            e.canonical_name.clone(),
238            e.canonical_email.clone(),
239            e.match_name.clone(),
240            Some(e.match_email.clone()),
241        );
242    }
243    table
244}
245
246/// Parse a `.mailmap` file body into legacy line entries (for compatibility).
247#[must_use]
248pub fn parse_mailmap(content: &str) -> Vec<MailmapEntry> {
249    table_to_entries(&build_mailmap_table_from_str(content))
250}
251
252fn build_mailmap_table_from_str(content: &str) -> MailmapTable {
253    let mut table = MailmapTable::default();
254    read_mailmap_string(&mut table, content);
255    table
256}
257
258fn table_to_entries(table: &MailmapTable) -> Vec<MailmapEntry> {
259    let mut out = Vec::new();
260    for (email_lc, bucket) in &table.buckets {
261        if bucket.simple.name.is_some() || bucket.simple.email.is_some() {
262            out.push(MailmapEntry {
263                canonical_name: bucket.simple.name.clone(),
264                canonical_email: bucket.simple.email.clone(),
265                match_name: None,
266                match_email: email_lc.clone(),
267            });
268        }
269        for (name_lc, mi) in &bucket.by_name {
270            out.push(MailmapEntry {
271                canonical_name: mi.name.clone(),
272                canonical_email: mi.email.clone(),
273                match_name: Some(name_lc.clone()),
274                match_email: email_lc.clone(),
275            });
276        }
277    }
278    out
279}
280
281/// Parse a contact string `Name <email>` or `<email>`.
282#[must_use]
283pub fn parse_contact(contact: &str) -> (Option<String>, Option<String>) {
284    let contact = contact.trim();
285    if let Some(lt) = contact.find('<') {
286        if let Some(gt) = contact.find('>') {
287            let name = contact[..lt].trim();
288            let email = contact[lt + 1..gt].trim();
289            return (
290                if name.is_empty() {
291                    None
292                } else {
293                    Some(name.to_string())
294                },
295                if email.is_empty() {
296                    None
297                } else {
298                    Some(email.to_string())
299                },
300            );
301        }
302    }
303    if contact.contains('@') && !contact.chars().any(char::is_whitespace) {
304        return (None, Some(contact.to_string()));
305    }
306
307    (Some(contact.to_string()), None)
308}
309
310/// Map `(name, email)` through the mailmap; uses [`MailmapTable`] internally.
311#[must_use]
312pub fn map_contact(
313    name: Option<&str>,
314    email: Option<&str>,
315    mailmap: &[MailmapEntry],
316) -> (String, String) {
317    let mut table = MailmapTable::default();
318    for e in mailmap {
319        add_mapping(
320            &mut table,
321            e.canonical_name.clone(),
322            e.canonical_email.clone(),
323            e.match_name.clone(),
324            Some(e.match_email.clone()),
325        );
326    }
327    let n = name.unwrap_or("").to_string();
328    let e = email.unwrap_or("").to_string();
329    table.map_user(n, e)
330}
331
332/// Map using a pre-built table.
333#[must_use]
334pub fn map_contact_table(
335    name: Option<&str>,
336    email: Option<&str>,
337    table: &MailmapTable,
338) -> (String, String) {
339    let n = name.unwrap_or("").to_string();
340    let e = email.unwrap_or("").to_string();
341    table.map_user(n, e)
342}
343
344/// Format a contact for display (`check-mailmap` style).
345#[must_use]
346pub fn render_contact(name: &str, email: &str) -> String {
347    if email.is_empty() {
348        return name.to_string();
349    }
350    if name.is_empty() {
351        return format!("<{email}>");
352    }
353    format!("{name} <{email}>")
354}
355
356fn resolve_mailmap_path(base: &Path, value: &str) -> PathBuf {
357    let candidate = Path::new(value);
358    if candidate.is_absolute() {
359        candidate.to_path_buf()
360    } else {
361        base.join(candidate)
362    }
363}
364
365fn read_mailmap_file_nofollow(path: &Path) -> Result<String> {
366    #[cfg(unix)]
367    {
368        use std::os::unix::fs::OpenOptionsExt;
369
370        // `O_NOFOLLOW` makes `open` fail rather than traverse a final symlink.
371        let mut file = fs::OpenOptions::new()
372            .read(true)
373            .custom_flags(libc::O_NOFOLLOW)
374            .open(path)
375            .map_err(|_| {
376                Error::PathError(format!("unable to open mailmap at {}", path.display()))
377            })?;
378        let mut s = String::new();
379        file.read_to_string(&mut s)
380            .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))?;
381        Ok(s)
382    }
383    #[cfg(not(unix))]
384    {
385        fs::read_to_string(path)
386            .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))
387    }
388}
389
390fn read_optional_mailmap_file(path: &Path, nofollow: bool) -> Result<String> {
391    if !path.exists() {
392        return Ok(String::new());
393    }
394    if nofollow {
395        read_mailmap_file_nofollow(path)
396    } else {
397        fs::read_to_string(path)
398            .map_err(|e| Error::PathError(format!("reading {}: {e}", path.display())))
399    }
400}
401
402/// Read mailmap text from a blob revision (for `mailmap.blob` / CLI `--mailmap-blob`).
403///
404/// # Errors
405///
406/// Returns [`Error::PathError`] when `spec` does not resolve, the object cannot be read,
407/// or it names an object that is not a blob.
408pub fn read_mailmap_blob(repo: &Repository, spec: &str) -> Result<String> {
409    let oid = resolve_revision(repo, spec)
410        .map_err(|e| Error::PathError(format!("resolving mailmap blob '{spec}': {e}")))?;
411    match read_blob_text(repo, &oid, spec)? {
412        BlobText::Text(text) => Ok(text),
413        BlobText::NotABlob(kind) => Err(Error::PathError(format!(
414            "mailmap blob '{spec}' is a {kind}, not a blob"
415        ))),
416    }
417}
418
419/// Contents of the object a mailmap blob spec resolved to.
420enum BlobText {
421    Text(String),
422    NotABlob(ObjectKind),
423}
424
425fn read_blob_text(
426    repo: &Repository,
427    oid: &crate::objects::ObjectId,
428    spec: &str,
429) -> Result<BlobText> {
430    let obj = repo
431        .odb()
432        .read(oid)
433        .map_err(|e| Error::PathError(format!("reading mailmap blob '{spec}': {e}")))?;
434    if obj.kind != ObjectKind::Blob {
435        return Ok(BlobText::NotABlob(obj.kind));
436    }
437    Ok(BlobText::Text(
438        String::from_utf8_lossy(&obj.data).into_owned(),
439    ))
440}
441
442/// Read `mailmap.blob` the way Git's loader does: an unresolvable spec contributes nothing,
443/// and a non-blob object is reported as a warning rather than failing the load.
444fn read_configured_mailmap_blob(repo: &Repository, spec: &str) -> Result<Option<String>> {
445    let Ok(oid) = resolve_revision(repo, spec) else {
446        return Ok(None);
447    };
448    match read_blob_text(repo, &oid, spec)? {
449        BlobText::Text(text) => Ok(Some(text)),
450        BlobText::NotABlob(kind) => {
451            repo.warn(crate::diagnostics::Warning::MailmapUnreadable {
452                spec: spec.to_owned(),
453                kind,
454            });
455            Ok(None)
456        }
457    }
458}
459
460/// Load mailmap from the repository using Git's source order and merge rules.
461pub fn load_mailmap_table(repo: &Repository) -> Result<MailmapTable> {
462    let mut table = MailmapTable::default();
463    load_mailmap_into(repo, &mut table)?;
464    Ok(table)
465}
466
467/// Merge Git's configured mailmap sources into `table`.
468pub fn load_mailmap_into(repo: &Repository, table: &mut MailmapTable) -> Result<()> {
469    let config = repo.config()?;
470    let mut mailmap_blob = config.get("mailmap.blob");
471    let is_bare = repo.work_tree().is_none();
472    if mailmap_blob.is_none() && is_bare {
473        mailmap_blob = Some("HEAD:.mailmap".to_string());
474    }
475
476    let base_dir = repo.work_tree().unwrap_or(repo.git_dir()).to_path_buf();
477
478    if let Some(wt) = repo.work_tree() {
479        let in_tree = wt.join(".mailmap");
480        let body = read_optional_mailmap_file(&in_tree, true)?;
481        read_mailmap_string(table, &body);
482    }
483
484    if let Some(ref blob) = mailmap_blob {
485        if let Some(content) = read_configured_mailmap_blob(repo, blob)? {
486            read_mailmap_string(table, &content);
487        }
488    }
489
490    if let Some(file) = config.get("mailmap.file") {
491        read_mailmap_string(
492            table,
493            &read_optional_mailmap_file(&resolve_mailmap_path(&base_dir, &file), false)?,
494        );
495    }
496
497    Ok(())
498}
499
500/// Parsed mailmap for the repository (default `.mailmap` + config).
501pub fn load_mailmap(repo: &Repository) -> Result<Vec<MailmapEntry>> {
502    let table = load_mailmap_table(repo)?;
503    Ok(table_to_entries(&table))
504}
505
506/// Rewrite `author ` / `committer ` / `tagger ` header lines in a commit or tag object buffer.
507///
508/// Git applies mailmap only to the `Name <email>` prefix; the trailing ` <epoch> <tz>` is preserved.
509#[must_use]
510pub fn apply_mailmap_to_commit_or_tag_bytes(data: &[u8], mailmap: &MailmapTable) -> Vec<u8> {
511    if mailmap.is_empty() {
512        return data.to_vec();
513    }
514    let Some(pos) = data.windows(2).position(|w| w == b"\n\n") else {
515        return data.to_vec();
516    };
517    let (headers, rest) = data.split_at(pos + 1);
518    let header_text = String::from_utf8_lossy(headers);
519    let mut out = String::with_capacity(data.len() + 64);
520    for line in header_text.lines() {
521        let rewritten = rewrite_identity_header_line(line, mailmap);
522        out.push_str(&rewritten);
523        out.push('\n');
524    }
525    out.push('\n');
526    out.push_str(&String::from_utf8_lossy(&rest[1..]));
527    out.into_bytes()
528}
529
530fn rewrite_identity_header_line(line: &str, mailmap: &MailmapTable) -> String {
531    for pref in ["author ", "committer ", "tagger "] {
532        if let Some(rest) = line.strip_prefix(pref) {
533            let rest = rest.trim_end_matches('\r');
534            let Some(gt) = rest.rfind('>') else {
535                return line.to_string();
536            };
537            let ident = &rest[..=gt];
538            let tail = rest[gt + 1..].trim_start();
539            let (name, email) = parse_contact(ident);
540            let (n, e) = map_contact_table(name.as_deref(), email.as_deref(), mailmap);
541            let new_ident = render_contact(&n, &e);
542            if tail.is_empty() {
543                return format!("{pref}{new_ident}");
544            }
545            return format!("{pref}{new_ident} {tail}");
546        }
547    }
548    line.to_string()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn name_entry_after_email_merges() {
557        let mut t = MailmapTable::default();
558        read_mailmap_string(
559            &mut t,
560            "<bugs@company.xy> <bugs@company.xx>\nInternal Guy <bugs@company.xx>\n",
561        );
562        let (n, e) = t.map_user("nick1".into(), "bugs@company.xx".into());
563        assert_eq!(n, "Internal Guy");
564        assert_eq!(e, "bugs@company.xy");
565    }
566
567    #[test]
568    fn single_pair_line_maps_name_only() {
569        let mut t = MailmapTable::default();
570        read_mailmap_string(&mut t, "Committed <committer@example.com>\n");
571        let (n, e) = t.map_user("C O Mitter".into(), "committer@example.com".into());
572        assert_eq!(n, "Committed");
573        assert_eq!(e, "committer@example.com");
574    }
575
576    #[test]
577    fn whitespace_inside_angle_brackets_is_part_of_map_key() {
578        let mut t = MailmapTable::default();
579        read_mailmap_string(&mut t, "Ah <ah@example.com> < a@example.com >\n");
580        let (n, e) = t.map_user("A".into(), "a@example.com".into());
581        assert_eq!(n, "A");
582        assert_eq!(e, "a@example.com");
583        let (n2, e2) = t.map_user("A".into(), " a@example.com ".into());
584        assert_eq!(n2, "Ah");
585        assert_eq!(e2, "ah@example.com");
586    }
587}