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