Skip to main content

grit_lib/revwalk/
filter.rs

1//! Commit message and identity filters for revision walks (`--author`, `--grep`, …).
2//!
3//! Patterns use Git's default POSIX basic regular expression (BRE) syntax unless
4//! [`CommitFilterBuilder::fixed_strings`] is set. Callers supply an optional
5//! [`MailmapTable`] loaded explicitly
6//! (for example via [`load_mailmap_table`](crate::config::mailmap::load_mailmap_table));
7//! the filter never reads repository config on its own.
8//!
9//! ```
10//! use grit_lib::config::mailmap::MailmapTable;
11//! use grit_lib::objects::ObjectId;
12//! use grit_lib::repo::{InitOptions, Repository};
13//! use grit_lib::revwalk::filter::{CommitFilter, CommitFilterBuilder};
14//!
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let dir = tempfile::tempdir()?;
17//! let repo = Repository::init(dir.path(), &InitOptions::default())?;
18//! let filter = CommitFilterBuilder::new()
19//!     .author("Alice")
20//!     .build()?;
21//! let _matches = filter.is_active();
22//! # let _ = (repo, filter);
23//! # Ok(())
24//! # }
25//! ```
26
27use std::borrow::Cow;
28use std::sync::Arc;
29
30use regex_automata::meta::{self, Regex};
31use regex_automata::nfa::thompson::WhichCaptures;
32use regex_automata::util::syntax;
33use regex_automata::MatchKind;
34
35use crate::config::mailmap::{map_contact_table, parse_contact, MailmapTable};
36use crate::error::{Error, Result};
37use crate::objects::{ObjectId, ObjectKind};
38use crate::repo::Repository;
39
40/// A single user-supplied match pattern (BRE or fixed string).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Pattern(String);
43
44impl Pattern {
45    /// Wrap a pattern string from the caller.
46    #[must_use]
47    pub fn new(raw: impl Into<String>) -> Self {
48        Self(raw.into())
49    }
50
51    /// Borrow the raw pattern text.
52    #[must_use]
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl From<String> for Pattern {
59    fn from(s: String) -> Self {
60        Self(s)
61    }
62}
63
64impl From<&str> for Pattern {
65    fn from(s: &str) -> Self {
66        Self(s.to_owned())
67    }
68}
69
70/// Invalid commit-filter pattern or compilation failure.
71#[derive(Debug, thiserror::Error)]
72pub enum CommitFilterError {
73    /// A pattern could not be compiled.
74    #[error("invalid commit filter pattern `{pattern}`: {detail}")]
75    InvalidPattern {
76        /// Original pattern text.
77        pattern: String,
78        /// Underlying regex error message.
79        detail: String,
80    },
81}
82
83/// Compiled commit filter applied during [`crate::rev_list::rev_list`].
84#[derive(Debug, Clone)]
85pub struct CommitFilter {
86    inner: Arc<CommitFilterInner>,
87}
88
89#[derive(Debug)]
90struct CommitFilterInner {
91    author: Option<Regex>,
92    committer: Option<Regex>,
93    grep: Vec<Regex>,
94    all_match: bool,
95    invert_grep: bool,
96    mailmap: Option<MailmapTable>,
97}
98
99impl CommitFilter {
100    /// Start building a filter.
101    #[must_use]
102    pub fn builder() -> CommitFilterBuilder {
103        CommitFilterBuilder::new()
104    }
105
106    /// Returns true when any author, committer, or grep pattern is configured.
107    #[must_use]
108    pub fn is_active(&self) -> bool {
109        let i = &self.inner;
110        i.author.is_some() || i.committer.is_some() || !i.grep.is_empty()
111    }
112
113    /// Test whether `oid` satisfies this filter (author/committer/grep/mailmap rules).
114    ///
115    /// # Errors
116    ///
117    /// Returns repository and object read errors from the ODB.
118    pub fn matches(&self, repo: &Repository, oid: ObjectId) -> Result<bool> {
119        let object = repo.odb().read(&oid)?;
120        if object.kind != ObjectKind::Commit {
121            return Err(Error::CorruptObject(format!(
122                "expected commit object for {oid}, found {:?}",
123                object.kind
124            )));
125        }
126        Ok(self.matches_bytes(&object.data))
127    }
128
129    /// Match bytes already read by a traversal, without a second ODB lookup.
130    pub(crate) fn matches_bytes(&self, raw: &[u8]) -> bool {
131        let inner = &self.inner;
132        let headers = scan_commit_headers(raw);
133
134        if let Some(re) = &inner.author {
135            let ident = ident_for_pattern_match(headers.author, inner.mailmap.as_ref());
136            if !re.is_match(ident.as_ref()) {
137                return false;
138            }
139        }
140
141        if let Some(re) = &inner.committer {
142            let ident = ident_for_pattern_match(headers.committer, inner.mailmap.as_ref());
143            if !re.is_match(ident.as_ref()) {
144                return false;
145            }
146        }
147
148        if !inner.grep.is_empty() {
149            let body = commit_message_body(raw);
150            let grep_ok = if inner.all_match {
151                inner.grep.iter().all(|re| re.is_match(body))
152            } else {
153                inner.grep.iter().any(|re| re.is_match(body))
154            };
155            let grep_ok = if inner.invert_grep { !grep_ok } else { grep_ok };
156            if !grep_ok {
157                return false;
158            }
159        }
160
161        true
162    }
163}
164
165/// Builder for [`CommitFilter`].
166#[derive(Debug, Clone, Default)]
167pub struct CommitFilterBuilder {
168    author: Vec<Pattern>,
169    committer: Vec<Pattern>,
170    grep: Vec<Pattern>,
171    ignore_case: bool,
172    all_match: bool,
173    invert_grep: bool,
174    fixed_strings: bool,
175    mailmap: Option<MailmapTable>,
176}
177
178impl CommitFilterBuilder {
179    /// Empty builder.
180    #[must_use]
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Add an `--author` pattern (multiple patterns OR together).
186    #[must_use]
187    pub fn author(mut self, pattern: impl Into<Pattern>) -> Self {
188        self.author.push(pattern.into());
189        self
190    }
191
192    /// Add a `--committer` pattern (multiple patterns OR together).
193    #[must_use]
194    pub fn committer(mut self, pattern: impl Into<Pattern>) -> Self {
195        self.committer.push(pattern.into());
196        self
197    }
198
199    /// Add a `--grep` pattern (OR unless [`Self::all_match`], then AND).
200    #[must_use]
201    pub fn grep(mut self, pattern: impl Into<Pattern>) -> Self {
202        self.grep.push(pattern.into());
203        self
204    }
205
206    /// Case-insensitive matching for all pattern groups (`-i`).
207    #[must_use]
208    pub fn ignore_case(mut self, yes: bool) -> Self {
209        self.ignore_case = yes;
210        self
211    }
212
213    /// Require every `--grep` pattern to match (`--all-match`).
214    #[must_use]
215    pub fn all_match(mut self, yes: bool) -> Self {
216        self.all_match = yes;
217        self
218    }
219
220    /// Invert the `--grep` group only (`--invert-grep`).
221    #[must_use]
222    pub fn invert_grep(mut self, yes: bool) -> Self {
223        self.invert_grep = yes;
224        self
225    }
226
227    /// Treat patterns as fixed strings (`--fixed-strings` / `-F`).
228    #[must_use]
229    pub fn fixed_strings(mut self, yes: bool) -> Self {
230        self.fixed_strings = yes;
231        self
232    }
233
234    /// Optional mailmap table for author/committer identity mapping.
235    #[must_use]
236    pub fn mailmap(mut self, table: MailmapTable) -> Self {
237        self.mailmap = Some(table);
238        self
239    }
240
241    /// Compile patterns into a [`CommitFilter`].
242    ///
243    /// # Errors
244    ///
245    /// Returns [`CommitFilterError::InvalidPattern`] when any pattern fails to compile.
246    pub fn build(self) -> std::result::Result<CommitFilter, CommitFilterError> {
247        let compile = |patterns: &[Pattern], group| {
248            compile_patterns(patterns, group, self.ignore_case, self.fixed_strings)
249        };
250        let author = compile(&self.author, PatternGroup::Identity)?;
251        let committer = compile(&self.committer, PatternGroup::Identity)?;
252        let mut grep = Vec::with_capacity(self.grep.len());
253        for pat in &self.grep {
254            grep.extend(compile(std::slice::from_ref(pat), PatternGroup::Message)?);
255        }
256        Ok(CommitFilter {
257            inner: Arc::new(CommitFilterInner {
258                author,
259                committer,
260                grep,
261                all_match: self.all_match,
262                invert_grep: self.invert_grep,
263                mailmap: self.mailmap,
264            }),
265        })
266    }
267}
268
269struct CommitHeaderFields<'a> {
270    author: Option<&'a [u8]>,
271    committer: Option<&'a [u8]>,
272}
273
274fn commit_message_body(raw: &[u8]) -> &[u8] {
275    raw.windows(2)
276        .position(|w| w == b"\n\n")
277        .map(|pos| &raw[pos + 2..])
278        .unwrap_or(&[])
279}
280
281fn scan_commit_headers(raw: &[u8]) -> CommitHeaderFields<'_> {
282    let mut author = None;
283    let mut committer = None;
284    let mut line_start = 0usize;
285    for (i, &b) in raw.iter().enumerate() {
286        if b == b'\n' {
287            if line_start == i {
288                break;
289            }
290            let line = &raw[line_start..i];
291            if let Some(rest) = line.strip_prefix(b"author ") {
292                author = Some(rest);
293            } else if let Some(rest) = line.strip_prefix(b"committer ") {
294                committer = Some(rest);
295            }
296            line_start = i + 1;
297        }
298    }
299    CommitHeaderFields { author, committer }
300}
301
302fn trim_end_ascii_ws(ident: &[u8]) -> &[u8] {
303    let end = ident
304        .iter()
305        .rposition(|&b| b != b' ' && b != b'\t')
306        .map_or(0, |i| i + 1);
307    &ident[..end]
308}
309
310fn ident_without_timestamp_bytes(ident: &[u8]) -> &[u8] {
311    let Some(gt) = ident.iter().rposition(|&b| b == b'>') else {
312        return trim_end_ascii_ws(ident);
313    };
314    trim_end_ascii_ws(&ident[..=gt])
315}
316
317/// Identity bytes matched against `--author` / `--committer` (timestamp stripped).
318fn ident_for_pattern_match<'a>(
319    ident: Option<&'a [u8]>,
320    mailmap: Option<&MailmapTable>,
321) -> Cow<'a, [u8]> {
322    let Some(ident) = ident else {
323        return Cow::Borrowed(b"");
324    };
325    let bare = ident_without_timestamp_bytes(ident);
326    let Some(table) = mailmap else {
327        return Cow::Borrowed(bare);
328    };
329    // Mailmap rules are text; non-UTF-8 idents keep raw bytes (same as matching without mailmap).
330    let Ok(bare_str) = std::str::from_utf8(bare) else {
331        return Cow::Borrowed(bare);
332    };
333    let (name, email) = parse_contact(bare_str);
334    let (name, email) = map_contact_table(name.as_deref(), email.as_deref(), table);
335    if email.is_empty() {
336        Cow::Owned(name.into_bytes())
337    } else if name.is_empty() {
338        Cow::Owned(format!("<{email}>").into_bytes())
339    } else {
340        Cow::Owned(format!("{name} <{email}>").into_bytes())
341    }
342}
343
344/// Cache slots for concurrent matching; an explicit value keeps the engine from querying the
345/// CPU count (cgroup files on Linux) every time a filter is built.
346const MATCHER_POOL_CAPACITY: usize = 8;
347
348/// What a compiled pattern group is matched against.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350enum PatternGroup {
351    /// One `Name <email>` identity line; any pattern of the set may match.
352    Identity,
353    /// A whole commit message, where `^` / `$` match at line boundaries.
354    Message,
355}
356
357/// Compile `patterns` into one byte matcher that reports whether any of them matches.
358///
359/// Limits and match semantics follow `regex::bytes` (`RegexSet` for identities, `Regex` for
360/// messages).
361fn compile_patterns(
362    patterns: &[Pattern],
363    group: PatternGroup,
364    ignore_case: bool,
365    fixed_strings: bool,
366) -> std::result::Result<Option<Regex>, CommitFilterError> {
367    if patterns.is_empty() {
368        return Ok(None);
369    }
370    let rust_patterns = patterns
371        .iter()
372        .map(|pat| pattern_to_rust_regex(pat.as_str(), ignore_case, fixed_strings))
373        .collect::<std::result::Result<Vec<_>, _>>()?;
374    meta::Builder::new()
375        .configure(
376            meta::Config::new()
377                .nfa_size_limit(Some(10 * (1 << 20)))
378                .hybrid_cache_capacity(2 * (1 << 20))
379                .match_kind(match group {
380                    PatternGroup::Identity => MatchKind::All,
381                    PatternGroup::Message => MatchKind::LeftmostFirst,
382                })
383                .utf8_empty(false)
384                .which_captures(WhichCaptures::None)
385                .pool_capacity(MATCHER_POOL_CAPACITY),
386        )
387        .syntax(
388            syntax::Config::new()
389                .utf8(false)
390                .case_insensitive(ignore_case)
391                .multi_line(group == PatternGroup::Message),
392        )
393        .build_many(&rust_patterns)
394        .map(Some)
395        .map_err(|e| {
396            let failed = e.pattern().map_or(0, |id| id.as_usize());
397            CommitFilterError::InvalidPattern {
398                pattern: patterns
399                    .get(failed)
400                    .map_or_else(String::new, |p| p.as_str().to_owned()),
401                detail: e.to_string(),
402            }
403        })
404}
405
406/// A BRE `.` outside brackets: one well-formed UTF-8 scalar other than `\n` (RFC 3629 byte
407/// ranges, surrogates excluded), so invalid bytes never match.
408///
409/// Matches the same inputs as Unicode `.`, but compiles to byte classes; Unicode `.` makes
410/// the regex compiler allocate a large UTF-8 suffix cache on every build.
411const BRE_DOT: &str = r"(?-u:[\x00-\x09\x0B-\x7F]|[\xC2-\xDF][\x80-\xBF]|\xE0[\xA0-\xBF][\x80-\xBF]|[\xE1-\xEC\xEE\xEF][\x80-\xBF][\x80-\xBF]|\xED[\x80-\x9F][\x80-\xBF]|\xF0[\x90-\xBF][\x80-\xBF][\x80-\xBF]|[\xF1-\xF3][\x80-\xBF][\x80-\xBF][\x80-\xBF]|\xF4[\x80-\x8F][\x80-\xBF][\x80-\xBF])";
412
413fn pattern_to_rust_regex(
414    pattern: &str,
415    _ignore_case: bool,
416    fixed_strings: bool,
417) -> std::result::Result<String, CommitFilterError> {
418    if fixed_strings {
419        return Ok(regex::escape(pattern));
420    }
421    bre_translate_to_rust_regex(pattern).map_err(|detail| CommitFilterError::InvalidPattern {
422        pattern: pattern.to_owned(),
423        detail,
424    })
425}
426
427/// Translate a Git BRE pattern into Rust `regex` syntax (not including `(?m)`).
428pub(crate) fn bre_translate_to_rust_regex(pattern: &str) -> std::result::Result<String, String> {
429    let mut out = String::with_capacity(pattern.len() + 8);
430    let chars: Vec<char> = pattern.chars().collect();
431    let mut i = 0usize;
432    let mut in_bracket = false;
433
434    while i < chars.len() {
435        if in_bracket {
436            if chars[i] == ']' && i > 0 {
437                out.push(']');
438                in_bracket = false;
439                i += 1;
440            } else if chars[i] == '[' {
441                out.push('[');
442                i += 1;
443            } else if chars[i] == '\\' {
444                if i + 1 < chars.len() {
445                    let next = chars[i + 1];
446                    out.push('\\');
447                    out.push(next);
448                    i += 2;
449                } else {
450                    out.push('\\');
451                    i += 1;
452                }
453            } else {
454                out.push(chars[i]);
455                i += 1;
456            }
457            continue;
458        }
459
460        if chars[i] == '[' {
461            out.push('[');
462            in_bracket = true;
463            i += 1;
464            if i < chars.len() && (chars[i] == '^' || chars[i] == '!') {
465                out.push(chars[i]);
466                i += 1;
467            }
468            if i < chars.len() && chars[i] == ']' {
469                out.push(']');
470                i += 1;
471            }
472            continue;
473        }
474
475        if chars[i] == '\\' && i + 1 < chars.len() {
476            match chars[i + 1] {
477                '(' => {
478                    out.push('(');
479                    i += 2;
480                }
481                ')' => {
482                    out.push(')');
483                    i += 2;
484                }
485                '|' => {
486                    out.push('|');
487                    i += 2;
488                }
489                '+' => {
490                    out.push('+');
491                    i += 2;
492                }
493                '?' => {
494                    out.push('?');
495                    i += 2;
496                }
497                '{' => {
498                    out.push('{');
499                    i += 2;
500                }
501                '}' => {
502                    out.push('}');
503                    i += 2;
504                }
505                _ => {
506                    out.push('\\');
507                    out.push(chars[i + 1]);
508                    i += 2;
509                }
510            }
511            continue;
512        }
513
514        if matches!(chars[i], '(' | ')' | '|' | '+' | '?' | '{' | '}') {
515            out.push('\\');
516            out.push(chars[i]);
517            i += 1;
518            continue;
519        }
520
521        if chars[i] == '.' {
522            out.push_str(BRE_DOT);
523            i += 1;
524            continue;
525        }
526
527        out.push(chars[i]);
528        i += 1;
529    }
530
531    if in_bracket {
532        return Err("unterminated bracket expression".to_owned());
533    }
534
535    Ok(out)
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn bre_metachars_become_literals_or_operators() {
544        assert_eq!(
545            bre_translate_to_rust_regex("fix(parser)").unwrap(),
546            r"fix\(parser\)"
547        );
548        assert_eq!(bre_translate_to_rust_regex("a+b").unwrap(), r"a\+b");
549        assert_eq!(bre_translate_to_rust_regex(r"\(x\|y\)").unwrap(), "(x|y)");
550    }
551
552    #[test]
553    fn bre_dot_matches_like_unicode_dot_on_any_bytes() {
554        let haystacks: &[&[u8]] = &[
555            b"",
556            b"a",
557            b"\n",
558            b"\n\n",
559            b"ab",
560            b"axb",
561            b"a\nb",
562            b"Jos\xc3\xa9",
563            b"a\xc3\xa9b",
564            b"a\xe2\x82\xacb",
565            b"a\xf0\x9f\x98\x80b",
566            b"a\xf4\x8f\xbf\xbfb",
567            b"a\xf4\x90\x80\x80b",
568            b"a\xed\x9f\xbfb",
569            b"a\xed\xa0\x80b",
570            b"a\xc0\x80b",
571            b"a\xc1\xbfb",
572            b"a\xe0\x80\xafb",
573            b"a\xf0\x80\x80\xafb",
574            b"a\xf5\x80\x80\x80b",
575            b"a\xffb",
576            b"a\x80b",
577            b"a\xc3b",
578            b"\xc3",
579            b"\xff\n\xfe",
580            b"x\x00y",
581            b"a\rb",
582        ];
583        let patterns = [
584            ".",
585            "..",
586            "a.b",
587            "^.$",
588            "^a.*b$",
589            ".\\{2\\}",
590            "a.\\{1,3\\}b",
591            "^J.s.$",
592            "[.]",
593            "a\\.b",
594        ];
595        for ignore_case in [false, true] {
596            for group in [PatternGroup::Identity, PatternGroup::Message] {
597                let multi_line = group == PatternGroup::Message;
598                for pat in patterns {
599                    let ours = compile_patterns(&[Pattern::new(pat)], group, ignore_case, false)
600                        .unwrap()
601                        .unwrap();
602                    let unicode_dot = bre_translate_to_rust_regex(pat)
603                        .unwrap()
604                        .replace(BRE_DOT, ".");
605                    let reference = regex::bytes::RegexBuilder::new(&unicode_dot)
606                        .case_insensitive(ignore_case)
607                        .multi_line(multi_line)
608                        .build()
609                        .unwrap();
610                    for hay in haystacks {
611                        assert_eq!(
612                            ours.is_match(*hay),
613                            reference.is_match(hay),
614                            "pattern {pat:?} on {hay:?} (ignore_case={ignore_case}, multi_line={multi_line})"
615                        );
616                    }
617                }
618            }
619        }
620    }
621
622    #[test]
623    fn dot_patterns_filter_identities_and_grep_lines() {
624        let filter = CommitFilterBuilder::new().author("^Jos.$").build().unwrap();
625        let named = |ident: &[u8]| {
626            let mut raw = b"tree t\nauthor ".to_vec();
627            raw.extend_from_slice(ident);
628            raw.extend_from_slice(b" 1 +0000\ncommitter C <c> 1 +0000\n\nm\n");
629            raw
630        };
631        assert!(!filter.matches_bytes(&named(b"Jos\xc3\xa9 <j@x>")));
632        let filter = CommitFilterBuilder::new()
633            .author("^Jos. <")
634            .build()
635            .unwrap();
636        assert!(filter.matches_bytes(&named(b"Jos\xc3\xa9 <j@x>")));
637        assert!(!filter.matches_bytes(&named(b"Jos\xff <j@x>")));
638        assert!(!filter.matches_bytes(&named(b"Jos <j@x>")));
639
640        let grep = CommitFilterBuilder::new()
641            .grep("^fix.$")
642            .ignore_case(true)
643            .build()
644            .unwrap();
645        let msg = |body: &[u8]| {
646            let mut raw = b"tree t\nauthor A <a> 1 +0000\ncommitter A <a> 1 +0000\n\n".to_vec();
647            raw.extend_from_slice(body);
648            raw
649        };
650        assert!(grep.matches_bytes(&msg(b"subject\n\nFIX!\n")));
651        assert!(!grep.matches_bytes(&msg(b"fix\n!\n")));
652    }
653
654    #[test]
655    fn invalid_pattern_names_the_failing_pattern() {
656        let err = CommitFilterBuilder::new()
657            .author("ok")
658            .author("a\\{2")
659            .build()
660            .unwrap_err();
661        let CommitFilterError::InvalidPattern { pattern, .. } = err;
662        assert_eq!(pattern, "a\\{2");
663    }
664
665    #[test]
666    fn bre_bracket_class_preserved() {
667        assert_eq!(bre_translate_to_rust_regex("[ab]").unwrap(), "[ab]");
668        assert_eq!(bre_translate_to_rust_regex("[^ab]").unwrap(), "[^ab]");
669    }
670
671    #[test]
672    fn matcher_author_or_and_grep() {
673        let filter = CommitFilterBuilder::new()
674            .author("Alice")
675            .grep("fix")
676            .build()
677            .unwrap();
678        let commit =
679            b"tree abc\nauthor Alice <a@b.c> 1 +0000\ncommitter Alice <a@b.c> 1 +0000\n\nfix bug\n";
680        assert!(filter.matches_bytes(commit));
681        let other =
682            b"tree abc\nauthor Bob <b@b.c> 1 +0000\ncommitter Bob <b@b.c> 1 +0000\n\nfix bug\n";
683        assert!(!filter.matches_bytes(other));
684    }
685
686    #[test]
687    fn grep_all_match_and_invert() {
688        let both = CommitFilterBuilder::new()
689            .grep("fix")
690            .grep("bug")
691            .all_match(true)
692            .build()
693            .unwrap();
694        let msg = b"tree t\nauthor A <a> 1 +0000\ncommitter A <a> 1 +0000\n\nfix bug\n";
695        assert!(both.matches_bytes(msg));
696        let partial = b"tree t\nauthor A <a> 1 +0000\ncommitter A <a> 1 +0000\n\nother only\n";
697        assert!(!both.matches_bytes(partial));
698
699        let invert = CommitFilterBuilder::new()
700            .grep("fix")
701            .invert_grep(true)
702            .build()
703            .unwrap();
704        assert!(!invert.matches_bytes(msg));
705        assert!(invert.matches_bytes(partial));
706    }
707
708    #[test]
709    fn fixed_strings_escape_regex() {
710        let filter = CommitFilterBuilder::new()
711            .grep("a+b")
712            .fixed_strings(true)
713            .build()
714            .unwrap();
715        let hit = b"tree t\nauthor A <a> 1 +0000\ncommitter A <a> 1 +0000\n\ncontains a+b\n";
716        assert!(filter.matches_bytes(hit));
717        let miss = b"tree t\nauthor A <a> 1 +0000\ncommitter A <a> 1 +0000\n\naaaab\n";
718        assert!(!filter.matches_bytes(miss));
719    }
720
721    #[test]
722    fn non_utf8_author_name_matches_email_pattern() {
723        let filter = CommitFilterBuilder::new()
724            .author("alice@example.com")
725            .build()
726            .unwrap();
727        let commit = b"tree abc\nauthor \xFFAlice <alice@example.com> 1 +0000\ncommitter C <c@x.com> 1 +0000\n\nm\n";
728        assert!(filter.matches_bytes(commit));
729    }
730
731    #[test]
732    fn mailmap_applied_to_author_match() {
733        let mut table = MailmapTable::default();
734        crate::config::mailmap::read_mailmap_string(
735            &mut table,
736            "Proper Name <proper@email.com> <old@email.com>",
737        );
738        let filter = CommitFilterBuilder::new()
739            .author("Proper Name")
740            .mailmap(table)
741            .build()
742            .unwrap();
743        let raw = b"tree t\nauthor Wrong <old@email.com> 1 +0000\ncommitter Wrong <old@email.com> 1 +0000\n\nm\n";
744        assert!(filter.matches_bytes(raw));
745    }
746}