use derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use itertools::Itertools;
use lazy_static::lazy_static;
use regex::Regex;
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct CommitSubject {
#[builder(default = "8")]
min_summary: usize,
#[builder(default = "78")]
max_summary: usize,
#[builder(default = "true")]
check_work_in_progress: bool,
#[builder(default = "true")]
check_rebase_commands: bool,
#[builder(default = "false")]
check_suggestion_subjects: bool,
#[builder(private)]
#[builder(setter(name = "_tolerated_prefixes"))]
#[builder(default)]
tolerated_prefixes: Vec<Regex>,
#[builder(private)]
#[builder(setter(name = "_allowed_prefixes"))]
#[builder(default)]
allowed_prefixes: Vec<String>,
#[builder(private)]
#[builder(setter(name = "_disallowed_prefixes"))]
#[builder(default)]
disallowed_prefixes: Vec<String>,
}
lazy_static! {
static ref SUGGESTION_SUBJECTS: Vec<Regex> = vec![
Regex::new(r#"Apply \d* suggestion\(s\) to \d* file\(s\)"#).unwrap(),
Regex::new("Apply suggestions from code review").unwrap(),
];
}
impl CommitSubjectBuilder {
pub fn tolerated_prefixes<I, P>(&mut self, patterns: I) -> &mut Self
where
I: IntoIterator<Item = P>,
P: Into<Regex>,
{
self.tolerated_prefixes = Some(patterns.into_iter().map(Into::into).collect());
self
}
pub fn allowed_prefixes<I, P>(&mut self, prefixes: I) -> &mut Self
where
I: IntoIterator<Item = P>,
P: Into<String>,
{
self.allowed_prefixes = Some(prefixes.into_iter().map(Into::into).collect());
self
}
pub fn disallowed_prefixes<I, P>(&mut self, prefixes: I) -> &mut Self
where
I: IntoIterator<Item = P>,
P: Into<String>,
{
self.disallowed_prefixes = Some(prefixes.into_iter().map(Into::into).collect());
self
}
}
impl CommitSubject {
pub fn builder() -> CommitSubjectBuilder {
CommitSubjectBuilder::default()
}
fn is_generated_subject(summary: &str) -> bool {
summary.starts_with("Merge ") || summary.starts_with("Revert ")
}
}
impl Default for CommitSubject {
fn default() -> Self {
CommitSubject {
min_summary: 8,
max_summary: 78,
check_work_in_progress: true,
check_rebase_commands: true,
check_suggestion_subjects: false,
tolerated_prefixes: Vec::new(),
allowed_prefixes: Vec::new(),
disallowed_prefixes: Vec::new(),
}
}
}
impl Check for CommitSubject {
fn name(&self) -> &str {
"commit-subject"
}
fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
let mut result = CheckResult::new();
let lines = commit.message.trim().lines().collect::<Vec<_>>();
if lines.is_empty() {
result.add_error(format!(
"commit {} has an invalid commit subject; it is empty.",
commit.sha1,
));
return Ok(result);
}
let summary = &lines[0];
let summary_len = summary.len();
if summary_len < self.min_summary {
result.add_error(format!(
"commit {} has an invalid commit subject; the first line must be at least {} \
characters.",
commit.sha1, self.min_summary,
));
}
let is_generated = Self::is_generated_subject(summary);
if !is_generated && self.max_summary < summary_len {
result.add_error(format!(
"commit {} has an invalid commit subject; the first line must be no longer than \
{} characters.",
commit.sha1, self.max_summary,
));
}
if lines.len() >= 2 {
if lines.len() >= 2 && !lines[1].is_empty() {
result.add_error(format!(
"commit {} has an invalid commit subject; the second line must be empty.",
commit.sha1,
));
}
if lines.len() == 2 {
result.add_error(format!(
"commit {} has an invalid commit subject; it cannot be exactly two lines.",
commit.sha1,
));
} else if lines[2].is_empty() {
result.add_error(format!(
"commit {} has an invalid commit subject; the third line must not be empty.",
commit.sha1,
));
}
}
const WIP_PREFIXES: &[&str] = &[
"WIP", "wip", "Draft:", "draft:", "[Draft]", "[draft]", "(Draft)", "(draft)",
];
if self.check_work_in_progress
&& WIP_PREFIXES
.iter()
.any(|prefix| summary.starts_with(prefix))
{
result.add_error(format!(
"commit {} cannot be merged; it is marked as a work-in-progress (WIP).",
commit.sha1,
));
}
if self.check_rebase_commands {
if summary.starts_with("fixup! ") {
result.add_error(format!(
"commit {} cannot be merged; it is marked as a fixup commit.",
commit.sha1,
));
} else if summary.starts_with("squash! ") {
result.add_error(format!(
"commit {} cannot be merged; it is marked as a commit to be squashed.",
commit.sha1,
));
} else if summary.starts_with("amend! ") {
result.add_error(format!(
"commit {} cannot be merged; it is marked as an amending commit.",
commit.sha1,
));
}
}
if self.check_suggestion_subjects {
for subject in SUGGESTION_SUBJECTS.iter() {
if subject.is_match(summary) {
result.add_error(format!(
"commit {} cannot be merged; its commit summary appears to have been \
automatically generated by a suggestion application mechanism. Please \
squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
commit.sha1,
));
}
}
}
if !is_generated {
let is_tolerated = self.tolerated_prefixes.iter().any(|regex| {
regex
.find(summary)
.map(|found| found.start() == 0)
.unwrap_or(false)
});
if !is_tolerated {
if !self.allowed_prefixes.is_empty() {
let is_ok = self
.allowed_prefixes
.iter()
.any(|prefix| summary.starts_with(prefix));
if !is_ok {
result.add_error(format!(
"commit {} cannot be merged; it must start with one of the following \
prefixes: `{}`.",
commit.sha1,
self.allowed_prefixes.iter().format("`, `"),
));
}
}
let is_ok = self
.disallowed_prefixes
.iter()
.all(|prefix| !summary.starts_with(prefix));
if !is_ok {
result.add_error(format!(
"commit {} cannot be merged; it cannot start with any of the following \
prefixes: `{}`.",
commit.sha1,
self.disallowed_prefixes.iter().format("`, `"),
));
}
}
}
Ok(result)
}
}
#[cfg(feature = "config")]
pub(crate) mod config {
use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
use regex::Regex;
use serde::de::{Deserializer, Error as SerdeError};
use serde::Deserialize;
#[cfg(test)]
use serde_json::json;
use crate::CommitSubject;
#[derive(Debug)]
struct RegexConfig(Regex);
impl<'de> Deserialize<'de> for RegexConfig {
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let regex_str = <String as Deserialize>::deserialize(deserializer)?;
let regex = Regex::new(®ex_str)
.map_err(|err| D::Error::custom(format!("'{}': {}", regex_str, err)))?;
Ok(RegexConfig(regex))
}
}
impl From<RegexConfig> for Regex {
fn from(regex_config: RegexConfig) -> Self {
regex_config.0
}
}
#[derive(Deserialize, Debug)]
pub struct CommitSubjectConfig {
#[serde(default)]
min_summary: Option<usize>,
#[serde(default)]
max_summary: Option<usize>,
#[serde(default)]
check_work_in_progress: Option<bool>,
#[serde(default)]
check_rebase_commands: Option<bool>,
#[serde(default)]
check_suggestion_subjects: Option<bool>,
#[serde(default)]
tolerated_prefixes: Option<Vec<RegexConfig>>,
#[serde(default)]
allowed_prefixes: Option<Vec<String>>,
#[serde(default)]
disallowed_prefixes: Option<Vec<String>>,
}
impl IntoCheck for CommitSubjectConfig {
type Check = CommitSubject;
fn into_check(self) -> Self::Check {
let mut builder = CommitSubject::builder();
if let Some(min_summary) = self.min_summary {
builder.min_summary(min_summary);
}
if let Some(max_summary) = self.max_summary {
builder.max_summary(max_summary);
}
if let Some(check_work_in_progress) = self.check_work_in_progress {
builder.check_work_in_progress(check_work_in_progress);
}
if let Some(check_rebase_commands) = self.check_rebase_commands {
builder.check_rebase_commands(check_rebase_commands);
}
if let Some(check_suggestion_subjects) = self.check_suggestion_subjects {
builder.check_suggestion_subjects(check_suggestion_subjects);
}
if let Some(tolerated_prefixes) = self.tolerated_prefixes {
builder.tolerated_prefixes(tolerated_prefixes);
}
if let Some(allowed_prefixes) = self.allowed_prefixes {
builder.allowed_prefixes(allowed_prefixes);
}
if let Some(disallowed_prefixes) = self.disallowed_prefixes {
builder.disallowed_prefixes(disallowed_prefixes);
}
builder
.build()
.expect("configuration mismatch for `CommitSubject`")
}
}
register_checks! {
CommitSubjectConfig {
"commit_subject" => CommitCheckConfig,
},
}
#[test]
fn test_commit_subject_config_empty() {
let json = json!({});
let check: CommitSubjectConfig = serde_json::from_value(json).unwrap();
assert_eq!(check.min_summary, None);
assert_eq!(check.max_summary, None);
assert_eq!(check.check_work_in_progress, None);
assert_eq!(check.check_rebase_commands, None);
assert_eq!(check.check_suggestion_subjects, None);
assert!(check.tolerated_prefixes.is_none());
assert_eq!(check.allowed_prefixes, None);
assert_eq!(check.disallowed_prefixes, None);
let check = check.into_check();
assert_eq!(check.min_summary, 8);
assert_eq!(check.max_summary, 78);
assert!(check.check_work_in_progress);
assert!(check.check_rebase_commands);
assert!(!check.check_suggestion_subjects);
assert!(check.tolerated_prefixes.is_empty());
itertools::assert_equal(&check.allowed_prefixes, &[] as &[&str]);
itertools::assert_equal(&check.disallowed_prefixes, &[] as &[&str]);
}
#[test]
fn test_commit_subject_config_all_fields() {
let exp_tprefix: String = "tolerated".into();
let exp_aprefix: String = "allowed".into();
let exp_dprefix: String = "disallowed".into();
let json = json!({
"min_summary": 1,
"max_summary": 100,
"check_work_in_progress": false,
"check_rebase_commands": false,
"check_suggestion_subjects": true,
"tolerated_prefixes": [exp_tprefix],
"allowed_prefixes": [exp_aprefix],
"disallowed_prefixes": [exp_dprefix],
});
let check: CommitSubjectConfig = serde_json::from_value(json).unwrap();
assert_eq!(check.min_summary, Some(1));
assert_eq!(check.max_summary, Some(100));
assert_eq!(check.check_work_in_progress, Some(false));
assert_eq!(check.check_rebase_commands, Some(false));
assert_eq!(check.check_suggestion_subjects, Some(true));
itertools::assert_equal(
check
.tolerated_prefixes
.as_ref()
.unwrap()
.iter()
.map(|re| re.0.as_str()),
&[exp_tprefix.clone()],
);
itertools::assert_equal(&check.allowed_prefixes, &Some([exp_aprefix.clone()]));
itertools::assert_equal(&check.disallowed_prefixes, &Some([exp_dprefix.clone()]));
let check = check.into_check();
assert_eq!(check.min_summary, 1);
assert_eq!(check.max_summary, 100);
assert!(!check.check_work_in_progress);
assert!(!check.check_rebase_commands);
assert!(check.check_suggestion_subjects);
itertools::assert_equal(
check.tolerated_prefixes.iter().map(|re| re.as_str()),
&[exp_tprefix],
);
itertools::assert_equal(&check.allowed_prefixes, &[exp_aprefix]);
itertools::assert_equal(&check.disallowed_prefixes, &[exp_dprefix]);
}
}
#[cfg(test)]
mod tests {
use git_checks_core::Check;
use regex::Regex;
use crate::test::*;
use crate::CommitSubject;
const BAD_TOPIC: &str = "891db15952303d4f18ca23070c1bf054bc51a15c";
#[test]
fn test_commit_subject_builder_default() {
assert!(CommitSubject::builder().build().is_ok());
}
#[test]
fn test_commit_subject_name_commit() {
let check = CommitSubject::default();
assert_eq!(Check::name(&check), "commit-subject");
}
#[test]
fn test_commit_subject() {
let check = CommitSubject::default();
let result = run_check("test_commit_subject", BAD_TOPIC, check);
test_result_errors(result, &[
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
first line must be at least 8 characters.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
first line must be no longer than 78 characters.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
second line must be empty.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
cannot be exactly two lines.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
second line must be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
third line must not be empty.",
"commit e478f630b586e331753477eba88059d644927be8 cannot be merged; it is marked as a \
work-in-progress (WIP).",
"commit 9039b9a4813fa019229e960033fe1ae8514a0c8e cannot be merged; it is marked as a \
work-in-progress (WIP).",
"commit 11dbbbff3f32445d74d1a8d96df0a49381c81ba0 cannot be merged; it is marked as a \
work-in-progress (WIP).",
"commit 54d673ff559a72ce6343bd9526a950d79034b24e cannot be merged; it is marked as a \
fixup commit.",
"commit 5f7284fe1599265c90550b681a4bf0763bc1de21 cannot be merged; it is marked as a \
commit to be squashed.",
"commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
empty.",
"commit 12b564db852a80190950caf5b363a4939b822730 cannot be merged; it is marked as an \
amending commit.",
]);
}
#[test]
fn test_commit_subject_with_suggestions() {
let check = CommitSubject::builder()
.check_work_in_progress(false)
.check_rebase_commands(false)
.check_suggestion_subjects(true)
.build()
.unwrap();
let result = run_check("test_commit_subject_with_suggestions", BAD_TOPIC, check);
test_result_errors(result, &[
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
first line must be at least 8 characters.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
first line must be no longer than 78 characters.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
second line must be empty.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
cannot be exactly two lines.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
second line must be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
third line must not be empty.",
"commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
empty.",
"commit c52a84631f35561043cb7cca6c136f94bd9fc757 cannot be merged; its commit summary \
appears to have been automatically generated by a suggestion application mechanism. \
Please squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
"commit 891db15952303d4f18ca23070c1bf054bc51a15c cannot be merged; its commit summary \
appears to have been automatically generated by a suggestion application mechanism. \
Please squash the suggestion(s) into the relevant commit(s) using `git rebase`.",
]);
}
#[test]
fn test_commit_subject_allowed_prefixes() {
let check = CommitSubject::builder()
.check_work_in_progress(false)
.check_rebase_commands(false)
.allowed_prefixes(["commit message "].iter().cloned())
.build()
.unwrap();
let result = run_check("test_commit_subject_allowed_prefixes", BAD_TOPIC, check);
test_result_errors(result, &[
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
first line must be at least 8 characters.",
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c cannot be merged; it must start with one of the following prefixes: `commit message `.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
first line must be no longer than 78 characters.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
second line must be empty.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
cannot be exactly two lines.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
second line must be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
third line must not be empty.",
"commit e478f630b586e331753477eba88059d644927be8 cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit 9039b9a4813fa019229e960033fe1ae8514a0c8e cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit 11dbbbff3f32445d74d1a8d96df0a49381c81ba0 cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit 54d673ff559a72ce6343bd9526a950d79034b24e cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit 5f7284fe1599265c90550b681a4bf0763bc1de21 cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
empty.",
"commit 12b564db852a80190950caf5b363a4939b822730 cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit c52a84631f35561043cb7cca6c136f94bd9fc757 cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
"commit 891db15952303d4f18ca23070c1bf054bc51a15c cannot be merged; it must start with \
one of the following prefixes: `commit message `.",
]);
}
#[test]
fn test_commit_subject_disallowed_prefixes() {
let check = CommitSubject::builder()
.check_work_in_progress(false)
.check_rebase_commands(false)
.disallowed_prefixes(["commit message "].iter().cloned())
.build()
.unwrap();
let result = run_check("test_commit_subject_disallowed_prefixes", BAD_TOPIC, check);
test_result_errors(result, &[
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; the \
first line must be at least 8 characters.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; the \
first line must be no longer than 78 characters.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 cannot be merged; it cannot start \
with any of the following prefixes: `commit message `.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; the \
second line must be empty.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; it \
cannot be exactly two lines.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 cannot be merged; it cannot start \
with any of the following prefixes: `commit message `.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
second line must be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; the \
third line must not be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 cannot be merged; it cannot start \
with any of the following prefixes: `commit message `.",
"commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; it is \
empty.",
]);
}
#[test]
fn test_commit_subject_tolerated_prefixes() {
let check = CommitSubject::builder()
.check_work_in_progress(false)
.check_rebase_commands(false)
.tolerated_prefixes(
[
"^(commit message )",
"^([Ww][Ii][Pp]|fixup|squash|amend)",
"Apply",
"hort",
]
.iter()
.map(|patt| Regex::new(patt).unwrap()),
)
.allowed_prefixes(["allowed prefix "].iter().cloned())
.disallowed_prefixes(["commit message "].iter().cloned())
.build()
.unwrap();
let result = run_check("test_commit_subject_tolerated_prefixes", BAD_TOPIC, check);
test_result_errors(
result,
&[
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c has an invalid commit subject; \
the first line must be at least 8 characters.",
"commit 234de3c3f17ab29f0b7644ae96242e31a3dd634c cannot be merged; it must start \
with one of the following prefixes: `allowed prefix `.",
"commit 1afc6b3584580488917fc61aa5e5298e98583805 has an invalid commit subject; \
the first line must be no longer than 78 characters.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; \
the second line must be empty.",
"commit b1ca628043ed78625551420e2dbbd8cf74fde2c4 has an invalid commit subject; \
it cannot be exactly two lines.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; \
the second line must be empty.",
"commit 3a6fe6d56fbf11c667b6c88bdd7d851a8dcac0b1 has an invalid commit subject; \
the third line must not be empty.",
"commit c4a9d2f34a687134e0d5d47ff576d34966381f56 has an invalid commit subject; \
it is empty.",
],
);
}
}