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