Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions end-to-end-tests/features/steps/then-violations.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,12 @@ def assert_commit_title_too_long_violation(context):

# Then
assert_commits_linting_errors(result, [['CommitTitleTooLong']])


@then('has a scope which is not lowercase violation is detected.')
def assert_non_lowercase_scope_violation(context):
# When/Then
result = assert_linting_fails(context)

# Then
assert_commits_linting_errors(result, [['NonLowercaseScope']])
5 changes: 5 additions & 0 deletions end-to-end-tests/features/steps/when.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ def set_output(context, output):
@when('the argument --max-commit-title-length is set to "{max_length}".')
def set_max_commit_title_length(context, max_length):
context.arguments += f" --max-commit-title-length {max_length} "


@when('the argument --lowercase-scope is set.')
def set_lowercase_scope(context):
context.arguments += " --lowercase-scope "
58 changes: 58 additions & 0 deletions end-to-end-tests/features/violations/lowercase_scope.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
Feature: When the commit title has a scope which is not lowercase, it can be detected as a violation with the --lowercase-scope flag.


Scenario Outline:
Given the context and environment are reset.
When linting the "<commit_message>".
And the argument --output is set as "JSON".
And the argument --lowercase-scope is set.
Then has a scope which is not lowercase violation is detected.


Examples:
| commit_message |
| "feat(AUTH): add login" |
| "fix(API): handle errors" |
| "docs(README): update" |
| "feat(Auth): add login" |
| "fix(ApiClient): handle errors" |
| "docs(ReadMe): update" |
| "chore(Deps): update packages" |
| "feat(apiClient): add feature" |
| "fix(userService): bug fix" |
| "test(TestUtils): add test\n\nbody" |


Scenario Outline:
Given the context and environment are reset.
When linting the "<commit_message>".
And the argument --output is set as "JSON".
And the argument --lowercase-scope is set.
Then linting passes.


Examples:
| commit_message |
| "feat(auth): add login" |
| "fix(api): handle errors" |
| "docs(readme): update" |
| "chore(deps): update packages" |
| "test(user-service): add tests" |
| "feat(api-client): add retry logic" |
| "feat: add feature without scope" |
| "fix: bug fix without scope" |
| "feat(scope-with-hyphens): add feature\n\nwith body" |


Scenario Outline:
Given the context and environment are reset.
When linting the "<commit_message>".
And the argument --output is set as "JSON".
Then linting passes.


Examples:
| commit_message |
| "feat(AUTH): add login" |
| "fix(API): handle" |
| "docs(README): update" |
6 changes: 6 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ pub(crate) struct Arguments {
)]
pub(crate) max_commit_title_length: usize,

#[arg(
long,
help = "Enforce that the scope (if present) in conventional commit messages must be lowercase, otherwise linting will fail."
)]
pub(crate) lowercase_scope: bool,

#[arg(
help = "The Git reference from where to start taking the range of commits from till HEAD to lint. The range is inclusive of HEAD and exclusive of the provided reference. '-' indicates to read the standard input and lint the input as a Git commit message."
)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use super::*;

static SCOPE_EXTRACTION_REGEX: OnceLock<Regex> = OnceLock::new();

pub(crate) fn lint(commit_message: &str) -> Result<(), LintingError> {
let regex = SCOPE_EXTRACTION_REGEX.get_or_init(|| {
Regex::new(&format!(
r"^{OPTIONAL_PRECEDING_WHITESPACE}{TYPE}{OPTIONAL_EXCLAMATION}\(([^\)]+)\){OPTIONAL_EXCLAMATION}:",
))
.unwrap()
});

if let Some(captures) = regex.captures(commit_message) {
if let Some(scope) = captures.get(1) {
let scope_text = scope.as_str();
// Check if the scope contains any uppercase letters
if scope_text.chars().any(|c| c.is_uppercase()) {
return Err(LintingError::NonLowercaseScope);
}
}
}

Ok(())
}

#[cfg(test)]
mod tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use rstest::rstest;

use super::*;

#[rstest(
commit_message,
// Lowercase scopes - should pass
case("feat(auth): add login"),
case("fix(api): handle errors"),
case("docs(readme): update"),
case("chore(deps): update packages"),
case("test(user-service): add tests"),
case("feat(api-client): add retry logic"),
// No scope - should pass
case("feat: add feature"),
case("fix: bug fix"),
case("docs: update docs"),
)]
fn test_lowercase_scope_passes(commit_message: &str) {
let result = lint(commit_message);
assert!(result.is_ok(), "Expected OK for: {}", commit_message);
}

#[rstest(
commit_message,
// Uppercase scopes - should fail
case("feat(AUTH): add login"),
case("fix(API): handle errors"),
case("docs(README): update"),
// Mixed case scopes - should fail
case("feat(Auth): add login"),
case("fix(ApiClient): handle errors"),
case("docs(ReadMe): update"),
case("chore(Deps): update packages"),
// Scopes with uppercase letters in the middle
case("feat(apiClient): add feature"),
case("fix(userService): bug fix"),
)]
fn test_non_lowercase_scope_fails(commit_message: &str) {
let result = lint(commit_message);
assert_eq!(
result,
Err(LintingError::NonLowercaseScope),
"Expected NonLowercaseScope error for: {}",
commit_message
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::*;

pub(super) mod empty_scope;
pub(super) mod exclamation_mark_before_scope;
pub(crate) mod lowercase_scope;
pub mod message_length;
pub(super) mod no_description_after_type_and_scope;
pub(super) mod no_space_after_colon_preceding_type_and_scope;
Expand Down
10 changes: 10 additions & 0 deletions src/commits/commit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ impl Commit {
&self,
commit_type: &CommitType,
max_commit_title_length: usize,
lowercase_scope: bool,
) -> Vec<LintingError> {
info!("Linting the commit message {:?}.", self.message);
let mut linting_errors = vec![];
Expand Down Expand Up @@ -115,6 +116,15 @@ impl Commit {
}
}

if lowercase_scope {
match conventional_commits_specification::lowercase_scope::lint(&self.message) {
Ok(()) => {}
Err(linting_error) => {
linting_errors.push(linting_error);
}
}
}

// Check message length regardless of conventional commits compliance
let max_length = if max_commit_title_length == 0 {
None
Expand Down
10 changes: 6 additions & 4 deletions src/commits/commit/tests/generated_tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const DEFAULT_COMMIT_TYPE: &CommitType = &CommitType::Any;

const DEFAULT_COMMIT_TITLE_LENGTH: usize = 72;

const DEFAULT_LOWERCASE_SCOPE: bool = false;

mod generation;
#[macro_use]
mod macros;
Expand All @@ -19,7 +21,7 @@ fn test_non_angular_type_commits_with_no_angular_type_only_assertion() {
let commit = Commit::from_commit_message(&commit_message);

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message);
Expand All @@ -35,7 +37,7 @@ fn test_angular_type_commits_with_no_angular_type_only_assertion() {
let commit = Commit::from_commit_message(&commit_message);

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message);
Expand All @@ -55,7 +57,7 @@ fn test_non_angular_type_commits_with_angular_type_only_assertion() {
let commit = Commit::from_commit_message(&commit_message);

// When
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message);
Expand All @@ -71,7 +73,7 @@ fn test_angular_type_commits_with_angular_type_only_assertion() {
let commit = Commit::from_commit_message(&commit_message);

// When
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message);
Expand Down
18 changes: 10 additions & 8 deletions src/commits/commit/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const DEFAULT_COMMIT_TYPE: &CommitType = &CommitType::Any;

const DEFAULT_COMMIT_TITLE_LENGTH: usize = 72;

const DEFAULT_LOWERCASE_SCOPE: bool = false;

#[template]
#[rstest(
commit_message,
Expand Down Expand Up @@ -57,7 +59,7 @@ fn test_angular_type_conventional_commits_and_only_angular_type(commit_message:
let expected_linting_errors: Vec<LintingError> = vec![];

// When
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_eq!(
Expand All @@ -74,7 +76,7 @@ fn test_angular_type_conventional_commits(commit_message: &str) {
let expected_linting_errors: Vec<LintingError> = vec![];

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_eq!(
Expand Down Expand Up @@ -115,7 +117,7 @@ fn test_non_angular_type_conventional_commits_and_only_angular_type(commit_messa
let expected_linting_errors = vec![LintingError::NonAngularType];

// When
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_eq!(
Expand All @@ -132,7 +134,7 @@ fn test_non_angular_type_conventional_commits(commit_message: &str) {
let expected_linting_errors: Vec<LintingError> = vec![];

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_eq!(
Expand All @@ -156,7 +158,7 @@ fn test_non_conventional_commits_fail_linting(commit_message: &str) {
let commit = Commit::from_commit_message(commit_message.to_string());

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert!(
Expand Down Expand Up @@ -202,7 +204,7 @@ fn test_commit_title_too_long(commit_message: &str) {
let expected_linting_errors: Vec<LintingError> = vec![LintingError::CommitTitleTooLong];

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE);

// Then
assert_eq!(
Expand All @@ -218,7 +220,7 @@ fn test_max_commit_title_length_changeable(commit_message: &str) {
let commit = Commit::from_commit_message(commit_message.to_string());

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 120);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 120, DEFAULT_LOWERCASE_SCOPE);

// Then
assert!(
Expand All @@ -234,7 +236,7 @@ fn test_max_commit_title_length_disableable(commit_message: &str) {
let commit = Commit::from_commit_message(commit_message.to_string());

// When
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 0);
let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 0, DEFAULT_LOWERCASE_SCOPE);

// Then
assert!(
Expand Down
3 changes: 2 additions & 1 deletion src/commits/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ impl Commits {
self,
commit_type: &CommitType,
max_commit_title_length: usize,
lowercase_scope: bool,
) -> Option<LintingErrors> {
let mut errors: HashMap<Commit, Vec<LintingError>> = HashMap::new();

for commit in self.commits.iter().cloned() {
let commit_errors = commit.lint(commit_type, max_commit_title_length);
let commit_errors = commit.lint(commit_type, max_commit_title_length, lowercase_scope);

if !commit_errors.is_empty() {
warn!(
Expand Down
2 changes: 2 additions & 0 deletions src/linting_error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ pub enum LintingError {
NoDescriptionAfterTypeAndScope,
/// Commit message subject line exceeds the recommended maximum length.
CommitTitleTooLong,
/// Commit title has a scope which is not lowercase.
NonLowercaseScope,
}
8 changes: 5 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ fn run(arguments: Arguments) -> Result<i32> {
Commits::from_git(&repository, arguments.from, arguments.history_mode)
}?;

if let Some(linting_results) =
commits.lint(&arguments.commit_type, arguments.max_commit_title_length)
{
if let Some(linting_results) = commits.lint(
&arguments.commit_type,
arguments.max_commit_title_length,
arguments.lowercase_scope,
) {
match arguments.output {
Output::Quiet => {}
Output::Pretty => {
Expand Down
Loading