git_checks/
commit_subject.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use derive_builder::Builder;
10use git_checks_core::impl_prelude::*;
11use itertools::Itertools;
12use lazy_static::lazy_static;
13use regex::Regex;
14
15/// Check commit message subjects for invalid patterns.
16///
17/// Patterns which are checked for:
18///   - overly long or short summary lines;
19///   - work-in-progress messages;
20///   - `fixup!`, `squash!`, and `amend!` messages; and
21///   - custom prefixes.
22///
23/// Commit messages which appear to have been auto generated by actions such as merging or
24/// reverting commits will skip the summary line length limit (if enforced).
25#[derive(Builder, Debug, Clone)]
26#[builder(field(private))]
27pub struct CommitSubject {
28    /// The minimum length allowed for the summary line.
29    ///
30    /// Configuration: Optional
31    /// Default: `8`
32    #[builder(default = "8")]
33    min_summary: usize,
34    /// The maximum length allowed for the summary line.
35    ///
36    /// Configuration: Optional
37    /// Default: `78`
38    #[builder(default = "78")]
39    max_summary: usize,
40
41    /// Whether to deny work-in-progress commits or not.
42    ///
43    /// Commit messages which mention `WIP`, `wip`, or a few variants of `Draft` at the beginning
44    /// of their commit messages are rejected since they are (nominally) incomplete.
45    ///
46    /// Configuration: Optional
47    /// Default: `true`
48    #[builder(default = "true")]
49    check_work_in_progress: bool,
50
51    /// Check for rebase commands
52    ///
53    /// Rebase commands include commits which begin with `fixup! `, `squash! `, or `amend! `. These
54    /// subjects are used to indicate that the commit belongs somewhere else in the branch and
55    /// should be completed before merging.
56    ///
57    /// Configuration: Optional
58    /// Default: `true`
59    #[builder(default = "true")]
60    check_rebase_commands: bool,
61
62    /// Check for suggestions applied through a hosting facility
63    ///
64    /// Some hosting services support applying suggestions for changes to a topic as a new commit.
65    /// Enabling this option detects these subjects and rejects the commit as the code suggestion
66    /// should be squashed back into the relevant commit.
67    ///
68    /// Configuration: Optional
69    /// Default: `false`
70    #[builder(default = "false")]
71    check_suggestion_subjects: bool,
72
73    #[builder(private)]
74    #[builder(setter(name = "_tolerated_prefixes"))]
75    #[builder(default)]
76    tolerated_prefixes: Vec<Regex>,
77    #[builder(private)]
78    #[builder(setter(name = "_allowed_prefixes"))]
79    #[builder(default)]
80    allowed_prefixes: Vec<String>,
81    #[builder(private)]
82    #[builder(setter(name = "_disallowed_prefixes"))]
83    #[builder(default)]
84    disallowed_prefixes: Vec<String>,
85}
86
87lazy_static! {
88    static ref SUGGESTION_SUBJECTS: Vec<Regex> = vec![
89        // GitLab
90        Regex::new(r"Apply \d* suggestion\(s\) to \d* file\(s\)").unwrap(),
91        // GitHub. Developers are asked for commit information, but this is the default for a
92        // "batch" of suggestions.
93        Regex::new("Apply suggestions from code review").unwrap(),
94    ];
95}
96
97impl CommitSubjectBuilder {
98    /// Tolerated prefixes for commits.
99    ///
100    /// The specified prefix patterns will be tolerated regardless of any configured
101    /// allowed or disallowed prefixes.
102    ///
103    /// Configuration: Optional
104    /// Default: `Vec::new()`
105    pub fn tolerated_prefixes<I, P>(&mut self, patterns: I) -> &mut Self
106    where
107        I: IntoIterator<Item = P>,
108        P: Into<Regex>,
109    {
110        self.tolerated_prefixes = Some(patterns.into_iter().map(Into::into).collect());
111        self
112    }
113
114    /// Required prefixes for commits.
115    ///
116    /// The specified prefixes will be the only allowed prefixes on commit message subjects
117    /// that do not match a tolerated pattern.
118    ///
119    /// Configuration: Optional
120    /// Default: `Vec::new()`
121    pub fn allowed_prefixes<I, P>(&mut self, prefixes: I) -> &mut Self
122    where
123        I: IntoIterator<Item = P>,
124        P: Into<String>,
125    {
126        self.allowed_prefixes = Some(prefixes.into_iter().map(Into::into).collect());
127        self
128    }
129
130    /// Forbidden prefixes for commits.
131    ///
132    /// The specified prefixes will be rejected on commit message subjects that do not
133    /// also match a tolerated pattern.
134    ///
135    /// Configuration: Optional
136    /// Default: `Vec::new()`
137    pub fn disallowed_prefixes<I, P>(&mut self, prefixes: I) -> &mut Self
138    where
139        I: IntoIterator<Item = P>,
140        P: Into<String>,
141    {
142        self.disallowed_prefixes = Some(prefixes.into_iter().map(Into::into).collect());
143        self
144    }
145}
146
147impl CommitSubject {
148    /// Create a new builder.
149    pub fn builder() -> CommitSubjectBuilder {
150        Default::default()
151    }
152
153    /// Whether the summary is generated or not.
154    ///
155    /// The commit summaries generated by `git merge` and `git revert` can be long, but since they
156    /// are auto-generated, allow them.
157    fn is_generated_subject(summary: &str) -> bool {
158        summary.starts_with("Merge ") || summary.starts_with("Revert ")
159    }
160}
161
162impl Default for CommitSubject {
163    fn default() -> Self {
164        CommitSubject {
165            min_summary: 8,
166            max_summary: 78,
167            check_work_in_progress: true,
168            check_rebase_commands: true,
169            check_suggestion_subjects: false,
170            tolerated_prefixes: Vec::new(),
171            allowed_prefixes: Vec::new(),
172            disallowed_prefixes: Vec::new(),
173        }
174    }
175}
176
177impl Check for CommitSubject {
178    fn name(&self) -> &str {
179        "commit-subject"
180    }
181
182    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
183        let mut result = CheckResult::new();
184        let lines = commit.message.trim().lines().collect::<Vec<_>>();
185
186        if lines.is_empty() {
187            result.add_error(format!(
188                "commit {} has an invalid commit subject; it is empty.",
189                commit.sha1,
190            ));
191            return Ok(result);
192        }
193
194        let summary = &lines[0];
195        let summary_len = summary.len();
196
197        if summary_len < self.min_summary {
198            result.add_error(format!(
199                "commit {} has an invalid commit subject; the first line must be at least {} \
200                 characters.",
201                commit.sha1, self.min_summary,
202            ));
203        }
204
205        let is_generated = Self::is_generated_subject(summary);
206        if !is_generated && self.max_summary < summary_len {
207            result.add_error(format!(
208                "commit {} has an invalid commit subject; the first line must be no longer than \
209                 {} characters.",
210                commit.sha1, self.max_summary,
211            ));
212        }
213
214        if lines.len() >= 2 {
215            if lines.len() >= 2 && !lines[1].is_empty() {
216                result.add_error(format!(
217                    "commit {} has an invalid commit subject; the second line must be empty.",
218                    commit.sha1,
219                ));
220            }
221
222            if lines.len() == 2 {
223                result.add_error(format!(
224                    "commit {} has an invalid commit subject; it cannot be exactly two lines.",
225                    commit.sha1,
226                ));
227            } else if lines[2].is_empty() {
228                result.add_error(format!(
229                    "commit {} has an invalid commit subject; the third line must not be empty.",
230                    commit.sha1,
231                ));
232            }
233        }
234
235        const WIP_PREFIXES: &[&str] = &[
236            "WIP", "wip", "Draft:", "draft:", "[Draft]", "[draft]", "(Draft)", "(draft)",
237        ];
238
239        if self.check_work_in_progress
240            && WIP_PREFIXES
241                .iter()
242                .any(|prefix| summary.starts_with(prefix))
243        {
244            result.add_error(format!(
245                "commit {} cannot be merged; it is marked as a work-in-progress (WIP).",
246                commit.sha1,
247            ));
248        }
249
250        if self.check_rebase_commands {
251            if summary.starts_with("fixup! ") {
252                result.add_error(format!(
253                    "commit {} cannot be merged; it is marked as a fixup commit.",
254                    commit.sha1,
255                ));
256            } else if summary.starts_with("squash! ") {
257                result.add_error(format!(
258                    "commit {} cannot be merged; it is marked as a commit to be squashed.",
259                    commit.sha1,
260                ));
261            } else if summary.starts_with("amend! ") {
262                result.add_error(format!(
263                    "commit {} cannot be merged; it is marked as an amending commit.",
264                    commit.sha1,
265                ));
266            }
267        }
268
269        if self.check_suggestion_subjects {
270            for subject in SUGGESTION_SUBJECTS.iter() {
271                if subject.is_match(summary) {
272                    result.add_error(format!(
273                        "commit {} cannot be merged; its commit summary appears to have been \
274                         automatically generated by a suggestion application mechanism. Please \
275                         squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
276                        commit.sha1,
277                    ));
278                }
279            }
280        }
281
282        if !is_generated {
283            let is_tolerated = self.tolerated_prefixes.iter().any(|regex| {
284                regex
285                    .find(summary)
286                    .map(|found| found.start() == 0)
287                    .unwrap_or(false)
288            });
289            if !is_tolerated {
290                if !self.allowed_prefixes.is_empty() {
291                    let is_ok = self
292                        .allowed_prefixes
293                        .iter()
294                        .any(|prefix| summary.starts_with(prefix));
295                    if !is_ok {
296                        result.add_error(format!(
297                            "commit {} cannot be merged; it must start with one of the following \
298                             prefixes: `{}`.",
299                            commit.sha1,
300                            self.allowed_prefixes.iter().format("`, `"),
301                        ));
302                    }
303                }
304
305                let is_ok = self
306                    .disallowed_prefixes
307                    .iter()
308                    .all(|prefix| !summary.starts_with(prefix));
309                if !is_ok {
310                    result.add_error(format!(
311                        "commit {} cannot be merged; it cannot start with any of the following \
312                         prefixes: `{}`.",
313                        commit.sha1,
314                        self.disallowed_prefixes.iter().format("`, `"),
315                    ));
316                }
317            }
318        }
319
320        Ok(result)
321    }
322}
323
324#[cfg(feature = "config")]
325pub(crate) mod config {
326    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
327    use regex::Regex;
328    use serde::de::{Deserializer, Error as SerdeError};
329    use serde::Deserialize;
330    #[cfg(test)]
331    use serde_json::json;
332
333    use crate::CommitSubject;
334
335    #[derive(Debug)]
336    struct RegexConfig(Regex);
337
338    impl<'de> Deserialize<'de> for RegexConfig {
339        fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
340        where
341            D: Deserializer<'de>,
342        {
343            let regex_str = <String as Deserialize>::deserialize(deserializer)?;
344            let regex = Regex::new(&regex_str)
345                .map_err(|err| D::Error::custom(format!("'{}': {}", regex_str, err)))?;
346            Ok(RegexConfig(regex))
347        }
348    }
349
350    impl From<RegexConfig> for Regex {
351        fn from(regex_config: RegexConfig) -> Self {
352            regex_config.0
353        }
354    }
355
356    /// Configuration for the `CommitSubject` check.
357    ///
358    /// No configuration is necessary. The defaults are guided by common commit message guidelines.
359    ///
360    /// | Field | Type | Default |
361    /// | ----- | ---- | ------- |
362    /// | `min_summary` | positive integer | 8 |
363    /// | `max_summary` | positive integer | 78 |
364    /// | `check_work_in_progress` | boolean | true |
365    /// | `check_rebase_commands` | boolean | true |
366    /// | `check_suggestion_subjects` | boolean | false |
367    ///
368    /// The prefix configurations are lists of strings that are by default empty lists. The
369    /// `tolerated_prefixes` key is interpreted as a list of regular expressions.
370    ///
371    /// This check is registered as a commit check with the name `"commit_subject".
372    ///
373    /// # Example
374    ///
375    /// ```json
376    /// {
377    ///     "min_summary": 8,
378    ///     "max_summary": 78,
379    ///
380    ///     "check_work_in_progress": true,
381    ///     "check_rebase_commands": true,
382    ///     "check_suggestion_subjects": true,
383    ///
384    ///     "tolerated_prefixes": [
385    ///         "regex"
386    ///     ],
387    ///     "allowed_prefixes": [
388    ///         "literal"
389    ///     ],
390    ///     "disallowed_prefixes": [
391    ///         "literal"
392    ///     ]
393    /// }
394    /// ```
395    #[derive(Deserialize, Debug)]
396    pub struct CommitSubjectConfig {
397        #[serde(default)]
398        min_summary: Option<usize>,
399        #[serde(default)]
400        max_summary: Option<usize>,
401
402        #[serde(default)]
403        check_work_in_progress: Option<bool>,
404        #[serde(default)]
405        check_rebase_commands: Option<bool>,
406        #[serde(default)]
407        check_suggestion_subjects: Option<bool>,
408
409        #[serde(default)]
410        tolerated_prefixes: Option<Vec<RegexConfig>>,
411        #[serde(default)]
412        allowed_prefixes: Option<Vec<String>>,
413        #[serde(default)]
414        disallowed_prefixes: Option<Vec<String>>,
415    }
416
417    impl IntoCheck for CommitSubjectConfig {
418        type Check = CommitSubject;
419
420        fn into_check(self) -> Self::Check {
421            let mut builder = CommitSubject::builder();
422
423            if let Some(min_summary) = self.min_summary {
424                builder.min_summary(min_summary);
425            }
426
427            if let Some(max_summary) = self.max_summary {
428                builder.max_summary(max_summary);
429            }
430
431            if let Some(check_work_in_progress) = self.check_work_in_progress {
432                builder.check_work_in_progress(check_work_in_progress);
433            }
434
435            if let Some(check_rebase_commands) = self.check_rebase_commands {
436                builder.check_rebase_commands(check_rebase_commands);
437            }
438
439            if let Some(check_suggestion_subjects) = self.check_suggestion_subjects {
440                builder.check_suggestion_subjects(check_suggestion_subjects);
441            }
442
443            if let Some(tolerated_prefixes) = self.tolerated_prefixes {
444                builder.tolerated_prefixes(tolerated_prefixes);
445            }
446
447            if let Some(allowed_prefixes) = self.allowed_prefixes {
448                builder.allowed_prefixes(allowed_prefixes);
449            }
450
451            if let Some(disallowed_prefixes) = self.disallowed_prefixes {
452                builder.disallowed_prefixes(disallowed_prefixes);
453            }
454
455            builder
456                .build()
457                .expect("configuration mismatch for `CommitSubject`")
458        }
459    }
460
461    register_checks! {
462        CommitSubjectConfig {
463            "commit_subject" => CommitCheckConfig,
464        },
465    }
466
467    #[test]
468    fn test_commit_subject_config_empty() {
469        let json = json!({});
470        let check: CommitSubjectConfig = serde_json::from_value(json).unwrap();
471
472        assert_eq!(check.min_summary, None);
473        assert_eq!(check.max_summary, None);
474        assert_eq!(check.check_work_in_progress, None);
475        assert_eq!(check.check_rebase_commands, None);
476        assert_eq!(check.check_suggestion_subjects, None);
477        assert!(check.tolerated_prefixes.is_none());
478        assert_eq!(check.allowed_prefixes, None);
479        assert_eq!(check.disallowed_prefixes, None);
480
481        let check = check.into_check();
482
483        assert_eq!(check.min_summary, 8);
484        assert_eq!(check.max_summary, 78);
485        assert!(check.check_work_in_progress);
486        assert!(check.check_rebase_commands);
487        assert!(!check.check_suggestion_subjects);
488        assert!(check.tolerated_prefixes.is_empty());
489        itertools::assert_equal(&check.allowed_prefixes, &[] as &[&str]);
490        itertools::assert_equal(&check.disallowed_prefixes, &[] as &[&str]);
491    }
492
493    #[test]
494    fn test_commit_subject_config_all_fields() {
495        let exp_tprefix: String = "tolerated".into();
496        let exp_aprefix: String = "allowed".into();
497        let exp_dprefix: String = "disallowed".into();
498        let json = json!({
499            "min_summary": 1,
500            "max_summary": 100,
501            "check_work_in_progress": false,
502            "check_rebase_commands": false,
503            "check_suggestion_subjects": true,
504            "tolerated_prefixes": [exp_tprefix],
505            "allowed_prefixes": [exp_aprefix],
506            "disallowed_prefixes": [exp_dprefix],
507        });
508        let check: CommitSubjectConfig = serde_json::from_value(json).unwrap();
509
510        assert_eq!(check.min_summary, Some(1));
511        assert_eq!(check.max_summary, Some(100));
512        assert_eq!(check.check_work_in_progress, Some(false));
513        assert_eq!(check.check_rebase_commands, Some(false));
514        assert_eq!(check.check_suggestion_subjects, Some(true));
515        itertools::assert_equal(
516            check
517                .tolerated_prefixes
518                .as_ref()
519                .unwrap()
520                .iter()
521                .map(|re| re.0.as_str()),
522            &[exp_tprefix.clone()],
523        );
524        itertools::assert_equal(&check.allowed_prefixes, &Some([exp_aprefix.clone()]));
525        itertools::assert_equal(&check.disallowed_prefixes, &Some([exp_dprefix.clone()]));
526
527        let check = check.into_check();
528
529        assert_eq!(check.min_summary, 1);
530        assert_eq!(check.max_summary, 100);
531        assert!(!check.check_work_in_progress);
532        assert!(!check.check_rebase_commands);
533        assert!(check.check_suggestion_subjects);
534        itertools::assert_equal(
535            check.tolerated_prefixes.iter().map(|re| re.as_str()),
536            &[exp_tprefix],
537        );
538        itertools::assert_equal(&check.allowed_prefixes, &[exp_aprefix]);
539        itertools::assert_equal(&check.disallowed_prefixes, &[exp_dprefix]);
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use git_checks_core::Check;
546    use regex::Regex;
547
548    use crate::test::*;
549    use crate::CommitSubject;
550
551    const BAD_TOPIC: &str = "891db15952303d4f18ca23070c1bf054bc51a15c";
552
553    #[test]
554    fn test_commit_subject_builder_default() {
555        assert!(CommitSubject::builder().build().is_ok());
556    }
557
558    #[test]
559    fn test_commit_subject_name_commit() {
560        let check = CommitSubject::default();
561        assert_eq!(Check::name(&check), "commit-subject");
562    }
563
564    #[test]
565    fn test_commit_subject() {
566        let check = CommitSubject::default();
567        let result = run_check("test_commit_subject", BAD_TOPIC, check);
568        test_result_errors(result, &[
569            "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
570             first line must be at least 8 characters.",
571            "commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
572             first line must be no longer than 78 characters.",
573            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
574             second line must be empty.",
575            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
576             cannot be exactly two lines.",
577            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
578             second line must be empty.",
579            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
580             third line must not be empty.",
581            "commit e478f630b586e331753477eba88059d644927be8 cannot be merged; it is marked as a \
582             work-in-progress (WIP).",
583            "commit 9039b9a4813fa019229e960033fe1ae8514a0c8e cannot be merged; it is marked as a \
584             work-in-progress (WIP).",
585            "commit 11dbbbff3f32445d74d1a8d96df0a49381c81ba0 cannot be merged; it is marked as a \
586             work-in-progress (WIP).",
587            "commit 54d673ff559a72ce6343bd9526a950d79034b24e cannot be merged; it is marked as a \
588             fixup commit.",
589            "commit 5f7284fe1599265c90550b681a4bf0763bc1de21 cannot be merged; it is marked as a \
590             commit to be squashed.",
591            "commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
592             empty.",
593            "commit 12b564db852a80190950caf5b363a4939b822730 cannot be merged; it is marked as an \
594             amending commit.",
595        ]);
596    }
597
598    #[test]
599    fn test_commit_subject_with_suggestions() {
600        let check = CommitSubject::builder()
601            .check_work_in_progress(false)
602            .check_rebase_commands(false)
603            .check_suggestion_subjects(true)
604            .build()
605            .unwrap();
606        let result = run_check("test_commit_subject_with_suggestions", BAD_TOPIC, check);
607        test_result_errors(result, &[
608            "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
609             first line must be at least 8 characters.",
610            "commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
611             first line must be no longer than 78 characters.",
612            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
613             second line must be empty.",
614            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
615             cannot be exactly two lines.",
616            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
617             second line must be empty.",
618            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
619             third line must not be empty.",
620            "commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
621             empty.",
622            "commit c52a84631f35561043cb7cca6c136f94bd9fc757 cannot be merged; its commit summary \
623             appears to have been automatically generated by a suggestion application mechanism. \
624             Please squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
625            "commit 891db15952303d4f18ca23070c1bf054bc51a15c cannot be merged; its commit summary \
626             appears to have been automatically generated by a suggestion application mechanism. \
627             Please squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
628        ]);
629    }
630
631    #[test]
632    fn test_commit_subject_allowed_prefixes() {
633        let check = CommitSubject::builder()
634            .check_work_in_progress(false)
635            .check_rebase_commands(false)
636            .allowed_prefixes(["commit message "].iter().cloned())
637            .build()
638            .unwrap();
639        let result = run_check("test_commit_subject_allowed_prefixes", BAD_TOPIC, check);
640        test_result_errors(result, &[
641            "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
642             first line must be at least 8 characters.",
643            "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c cannot be merged; it must start with one of the following prefixes: `commit message `.",
644            "commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
645             first line must be no longer than 78 characters.",
646            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
647             second line must be empty.",
648            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
649             cannot be exactly two lines.",
650            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
651             second line must be empty.",
652            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
653             third line must not be empty.",
654            "commit e478f630b586e331753477eba88059d644927be8 cannot be merged; it must start with \
655             one of the following prefixes: `commit message `.",
656            "commit 9039b9a4813fa019229e960033fe1ae8514a0c8e cannot be merged; it must start with \
657             one of the following prefixes: `commit message `.",
658            "commit 11dbbbff3f32445d74d1a8d96df0a49381c81ba0 cannot be merged; it must start with \
659             one of the following prefixes: `commit message `.",
660            "commit 54d673ff559a72ce6343bd9526a950d79034b24e cannot be merged; it must start with \
661             one of the following prefixes: `commit message `.",
662            "commit 5f7284fe1599265c90550b681a4bf0763bc1de21 cannot be merged; it must start with \
663             one of the following prefixes: `commit message `.",
664            "commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
665             empty.",
666            "commit 12b564db852a80190950caf5b363a4939b822730 cannot be merged; it must start with \
667             one of the following prefixes: `commit message `.",
668            "commit c52a84631f35561043cb7cca6c136f94bd9fc757 cannot be merged; it must start with \
669             one of the following prefixes: `commit message `.",
670            "commit 891db15952303d4f18ca23070c1bf054bc51a15c cannot be merged; it must start with \
671             one of the following prefixes: `commit message `.",
672        ]);
673    }
674
675    #[test]
676    fn test_commit_subject_disallowed_prefixes() {
677        let check = CommitSubject::builder()
678            .check_work_in_progress(false)
679            .check_rebase_commands(false)
680            .disallowed_prefixes(["commit message "].iter().cloned())
681            .build()
682            .unwrap();
683        let result = run_check("test_commit_subject_disallowed_prefixes", BAD_TOPIC, check);
684        test_result_errors(result, &[
685            "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
686             first line must be at least 8 characters.",
687            "commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
688             first line must be no longer than 78 characters.",
689            "commit 1afc6b3584580488917fc61aa5e5298e98583805 cannot be merged; it cannot start \
690             with any of the following prefixes: `commit message `.",
691            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
692             second line must be empty.",
693            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
694             cannot be exactly two lines.",
695            "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 cannot be merged; it cannot start \
696             with any of the following prefixes: `commit message `.",
697            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
698             second line must be empty.",
699            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
700             third line must not be empty.",
701            "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 cannot be merged; it cannot start \
702             with any of the following prefixes: `commit message `.",
703            "commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
704             empty.",
705        ]);
706    }
707
708    #[test]
709    fn test_commit_subject_tolerated_prefixes() {
710        let check = CommitSubject::builder()
711            .check_work_in_progress(false)
712            .check_rebase_commands(false)
713            .tolerated_prefixes(
714                [
715                    "^(commit message )",
716                    "^([Ww][Ii][Pp]|fixup|squash|amend)",
717                    "Apply",
718                    // Intentionally match the middle of commit 234de3c3f's subject line
719                    // to verify that the line "short" is not a tolerated prefix.
720                    "hort",
721                ]
722                .iter()
723                .map(|patt| Regex::new(patt).unwrap()),
724            )
725            .allowed_prefixes(["allowed prefix "].iter().cloned())
726            .disallowed_prefixes(["commit message "].iter().cloned())
727            .build()
728            .unwrap();
729        let result = run_check("test_commit_subject_tolerated_prefixes", BAD_TOPIC, check);
730        test_result_errors(
731            result,
732            &[
733                "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; \
734                 the first line must be at least 8 characters.",
735                "commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c cannot be merged; it must start \
736                 with one of the following prefixes: `allowed prefix `.",
737                "commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; \
738                 the first line must be no longer than 78 characters.",
739                "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; \
740                 the second line must be empty.",
741                "commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; \
742                 it cannot be exactly two lines.",
743                "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; \
744                 the second line must be empty.",
745                "commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; \
746                 the third line must not be empty.",
747                "commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; \
748                 it is empty.",
749            ],
750        );
751    }
752}