1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Pattern(String);
43
44impl Pattern {
45 #[must_use]
47 pub fn new(raw: impl Into<String>) -> Self {
48 Self(raw.into())
49 }
50
51 #[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#[derive(Debug, thiserror::Error)]
72pub enum CommitFilterError {
73 #[error("invalid commit filter pattern `{pattern}`: {detail}")]
75 InvalidPattern {
76 pattern: String,
78 detail: String,
80 },
81}
82
83#[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 #[must_use]
102 pub fn builder() -> CommitFilterBuilder {
103 CommitFilterBuilder::new()
104 }
105
106 #[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 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 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#[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 #[must_use]
181 pub fn new() -> Self {
182 Self::default()
183 }
184
185 #[must_use]
187 pub fn author(mut self, pattern: impl Into<Pattern>) -> Self {
188 self.author.push(pattern.into());
189 self
190 }
191
192 #[must_use]
194 pub fn committer(mut self, pattern: impl Into<Pattern>) -> Self {
195 self.committer.push(pattern.into());
196 self
197 }
198
199 #[must_use]
201 pub fn grep(mut self, pattern: impl Into<Pattern>) -> Self {
202 self.grep.push(pattern.into());
203 self
204 }
205
206 #[must_use]
208 pub fn ignore_case(mut self, yes: bool) -> Self {
209 self.ignore_case = yes;
210 self
211 }
212
213 #[must_use]
215 pub fn all_match(mut self, yes: bool) -> Self {
216 self.all_match = yes;
217 self
218 }
219
220 #[must_use]
222 pub fn invert_grep(mut self, yes: bool) -> Self {
223 self.invert_grep = yes;
224 self
225 }
226
227 #[must_use]
229 pub fn fixed_strings(mut self, yes: bool) -> Self {
230 self.fixed_strings = yes;
231 self
232 }
233
234 #[must_use]
236 pub fn mailmap(mut self, table: MailmapTable) -> Self {
237 self.mailmap = Some(table);
238 self
239 }
240
241 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
317fn 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 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
344const MATCHER_POOL_CAPACITY: usize = 8;
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350enum PatternGroup {
351 Identity,
353 Message,
355}
356
357fn 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
406const 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
427pub(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}