mirror of
https://github.com/espressif/conventional-precommit-linter.git
synced 2025-08-06 15:00:04 +08:00
change(user-output): update user output marking all issues with message
- Dynamic messages in output report - Color input commit message same as message elements - Tests updated
This commit is contained in:

committed by
Tomas Sebestik

parent
3523578b39
commit
72404cfabb
2
.github/workflows/pytest.yml
vendored
2
.github/workflows/pytest.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Test hook script with Pytest
|
||||
name: Tests Pytest (multi-os)
|
||||
|
||||
on:
|
||||
push:
|
||||
|
32
CHANGELOG.md
32
CHANGELOG.md
@ -1,17 +1,41 @@
|
||||
## Unreleased
|
||||
|
||||
|
||||
- change(user-output): update user output marking all issues with message - Dynamic messages in output report - Color input commit message same as message elements - Tests updated
|
||||
- ci: update project settings configuration (pyproject.toml)
|
||||
- add CHANGELOG.md, commitizen, test packages definitions
|
||||
- GitHub action - testing on multiple OSes
|
||||
|
||||
## v1.2.1 (2023-07-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **scope-capitalization**: Update scope regex to be consistent with commitlint in DangerJS (#6)
|
||||
- fix(scope-capitalization): Update scope regex to be consistent with commitlint in DangerJS (#6)
|
||||
- docs(README) Update default max summary length
|
||||
|
||||
## v1.2.0 (2023-06-29)
|
||||
|
||||
|
||||
- Ignore comment lines from linted commit message (#5)
|
||||
- * fix: Ignore # lines from linted commit message
|
||||
|
||||
* feat: Add hint for preserving commit message to output report
|
||||
|
||||
* fix: Allow in scope special characters " _ / . , * -"
|
||||
- docs: Update hook install process guide (#4)
|
||||
|
||||
## v1.1.0 (2023-06-27)
|
||||
|
||||
|
||||
- Update default rules (#3)
|
||||
- * change(rules): Set maximum summary length to 72 characters
|
||||
|
||||
* change(rules): Summary uppercase letter as optional rules
|
||||
- docs: Update argument usage example in README.md
|
||||
|
||||
## v1.0.0 (2023-06-21)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add linter pre-commit hook logic
|
||||
- Merge pull request #1 from espressif/add_linter_hook
|
||||
- feat: Add linter pre-commit hook logic (RDT-471)
|
||||
- feat: Add linter pre-commit hook logic
|
||||
- Init
|
||||
|
187
README.md
187
README.md
@ -1,57 +1,96 @@
|
||||
# Conventional Precommit Linter
|
||||
<div align="center">
|
||||
<h1>Conventional Precommit Linter</h1>
|
||||
<img src="docs/conventional-precommit-linter.jpg" width="800">
|
||||
<br>
|
||||
<br>
|
||||
<!-- GitHub Badges -->
|
||||
<img alt="release" src="https://img.shields.io/github/v/release/espressif/conventional-precommit-linter" />
|
||||
<img alt="tests" src="https://github.com/espressif/conventional-precommit-linter/actions/workflows/pytest.yml/badge.svg" />
|
||||
</div>
|
||||
The Conventional Precommit Linter is a tool designed to ensure commit messages follow the Conventional Commits standard, enhancing the readability and traceability of your project's history.
|
||||
<hr>
|
||||
|
||||
The Conventional Precommit Linter hook is a Python script that checks the format of the commit messages, ensuring they adhere to the Conventional Commits standard with some additional parameters.
|
||||
|
||||
It is intended to be used as a pre-commit hook to verify the commit messages before they are committed, improving the overall quality and consistency of your commit history.
|
||||
## Table of Contents
|
||||
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Commit Message Structure](#commit-message-structure)
|
||||
- [Installation](#installation)
|
||||
- [Install Commit-msg Hooks](#install-commit-msg-hooks)
|
||||
- [Configuration](#configuration)
|
||||
- [Contributing and Development](#contributing-and-development)
|
||||
- [Credits](#credits)
|
||||
|
||||
***
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use the Conventional Precommit Linter Hook in your project, add the following configuration to your `.pre-commit-config.yaml` file:
|
||||
### Commit Message Structure
|
||||
Commit messages are validated against the following format:
|
||||
```
|
||||
<type>(<optional-scope>): <summary>
|
||||
< ... empty line ... >
|
||||
<optional body lines>
|
||||
<optional body lines>
|
||||
<optional body lines>
|
||||
```
|
||||
Each component is checked for compliance with the provided or default configuration.
|
||||
|
||||
**Example output for failed message:**
|
||||
<img src="docs/example-output-default-args.png" width="800">
|
||||
|
||||
**Example output for failed message (with custom arguments):**
|
||||
<img src="docs/example-output-custom-args.png" width="800">
|
||||
|
||||
### Installation
|
||||
|
||||
To integrate the **Conventional Precommit Linter** into your project, add to your `.pre-commit-config.yaml`:
|
||||
|
||||
```yaml
|
||||
- repo: https://github.com/espressif/conventional-precommit-linter
|
||||
rev: v1.0.0
|
||||
# FILE: .pre-commit-config.yaml
|
||||
repos:
|
||||
- repo: https://github.com/espressif/conventional-precommit-linter
|
||||
rev: v1.3.0 # The version tag you wish to use
|
||||
hooks:
|
||||
- id: conventional-precommit-linter
|
||||
stages: [commit-msg]
|
||||
```
|
||||
|
||||
### Install commit-msg hooks
|
||||
**IMPORTANT**: To install `commit-msg` type hooks, execute the command:
|
||||
### Install Commit-msg Hooks
|
||||
**IMPORTANT:** `commit-msg` hooks require a specific installation command:
|
||||
```sh
|
||||
pre-commit install -t pre-commit -t commit-msg
|
||||
```
|
||||
|
||||
Simple `pre-commit install` will not help here - The `pre-commit install` command in default is responsible ... for installing the pre-commit stage hooks only. This means that it sets up hooks which get triggered before the commit process really starts (that is, before you type a commit message). These hooks are typically used to run checks like linting or unit tests, which need to pass before the commit can be made.
|
||||
|
||||
However, the `commit-msg` stage hooks (as the `conventional-precommit-linter` is) are a separate set of hooks that run at a different stage in the commit process. These hooks get triggered after you've typed in your commit message but before the commit is finalized.
|
||||
|
||||
Since these two types of hooks run at different stages in the commit process and serve different purposes, the pre-commit framework treats them as distinct. Therefore, you need to use `pre-commit install` to set up the `pre-commit` hooks, and `pre-commit install -t commit-msg` to set up the `commit-msg` hooks.
|
||||
|
||||
The `pre-commit install -t pre-commit -t commit-msg` command combines these two commands above.
|
||||
|
||||
The developer only needs to execute this command once. Subsequent changes to the `.pre-commit-config.yaml` file do not require re-running it, but it is recommended to run it after each change to this file.
|
||||
|
||||
|
||||
## Parameters
|
||||
|
||||
The script supports additional parameters to customize its behavior:
|
||||
|
||||
- `--types`: Optional list of commit types to support. If not specified, the default types are `["change", "ci", "docs", "feat", "fix", "refactor", "remove", "revert"]`.
|
||||
|
||||
- `--subject-min-length`: Minimum length of the 'Summary' field in commit message. Defaults to `20`.
|
||||
|
||||
- `--subject-max-length`: Maximum length of the 'Summary' field in commit message. Defaults to `72`.
|
||||
|
||||
- `--body-max-line-length`: Maximum length of a line in the commit message body. Defaults to `100`.
|
||||
|
||||
- `--summary-uppercase`: Summary must start with an uppercase letter. If not specified, the default is `false` (uppercase not required).
|
||||
|
||||
You can modify these parameters by adding them to the `args` array in the `.pre-commit-config.yaml` file. For example, to change the `--subject-min-length` to `10` and add a new type `fox`, your configuration would look like this:
|
||||
**Note:** The `pre-commit install` command by default sets up only the `pre-commit` stage hooks. The additional flag `-t commit-msg` is necessary to set up `commit-msg` stage hooks.
|
||||
|
||||
For a simplified setup (just with `pre-commit install` without flags), ensure your `.pre-commit-config.yaml` contains the following:
|
||||
```yaml
|
||||
# FILE: .pre-commit-config.yaml
|
||||
---
|
||||
minimum_pre_commit_version: 3.3.0
|
||||
default_install_hook_types: [pre-commit,commit-msg]
|
||||
...
|
||||
```
|
||||
After modifying `.pre-commit-config.yaml`, re-run the install command (`pre-commit install`) for changes to take effect.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
The linter accepts several configurable parameters to tailor commit message validation:
|
||||
- `--types`: Define the types of commits allowed (default: [`change`, `ci`, `docs`, `feat`, `fix`, `refactor`, `remove`, `revert`]).
|
||||
- `--subject-min-length`: Set the minimum length for the summary (default: `20`).
|
||||
- `--subject-max-length`: Set the maximum length for the summary (default: `72`).
|
||||
- `--body-max-line-length`: Set the maximum line length for the body (default: `100`).
|
||||
- `--summary-uppercase`: Enforce the summary to start with an uppercase letter (default: `disabled`).
|
||||
|
||||
The **custom configuration** can be specified in `.pre-commit-config.yaml` like this:
|
||||
```yaml
|
||||
# FILE: .pre-commit-config.yaml
|
||||
...
|
||||
- repo: https://github.com/espressif/conventional-precommit-linter
|
||||
rev: v1.0.0
|
||||
rev: v1.3.0 # The version tag you wish to use
|
||||
hooks:
|
||||
- id: conventional-precommit-linter
|
||||
stages: [commit-msg]
|
||||
@ -60,68 +99,56 @@ You can modify these parameters by adding them to the `args` array in the `.pre-
|
||||
- --subject-min-length=10
|
||||
```
|
||||
|
||||
## Commit Message Structure
|
||||
***
|
||||
|
||||
The script checks commit messages for the following structure:
|
||||
## Contributing and Development
|
||||
|
||||
```text
|
||||
<type><(scope/component)>: <Summary>
|
||||
We welcome contributions! To contribute to this repository, please follow these steps:
|
||||
|
||||
<Body>
|
||||
```
|
||||
1. **Clone the Project**: Clone the repository to your local machine using:
|
||||
```sh
|
||||
git clone https://github.com/espressif/conventional-precommit-linter.git
|
||||
```
|
||||
|
||||
Where:
|
||||
2. **Set Up Development Environment:**
|
||||
|
||||
- `<type>`: a descriptor of the performed change, e.g., `feat`, `fix`, `refactor`, etc. Use one of the specified types (either default or provided using the `--types` parameter).
|
||||
- Create and activate a virtual environment:
|
||||
```sh
|
||||
virtualenv venv -p python3.8 && source ./venv/bin/activate
|
||||
```
|
||||
or
|
||||
```sh
|
||||
python -m venv venv && source ./venv/bin/activate
|
||||
```
|
||||
|
||||
- `<scope/component>` (optional): the scope or component that the commit pertains to. It should be written in lower case without whitespace, allowed special characters in `scope` are `_` `/` `.` `,` `*` `-` `.`
|
||||
- Install the project and development dependencies:
|
||||
```sh
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
- `<summary>`: a short, concise description of the change. It should not end with a period, and be between `subject_min_length` and `subject_max_length` characters long (as specified by script parameters). If the `--summary-uppercase` flag is used, then the summary must start with a uppercase letter.
|
||||
3. **Testing Your Changes:**
|
||||
|
||||
- `<body>` (optional): a detailed description of the change. Each line should be no longer than `body_max_line_length` characters (as specified by script parameters). There should be one blank line between the summary and the body.
|
||||
- Create a file named `test_message.txt` in the root of the repository (this file is git-ignored) and place an example commit message in it.
|
||||
|
||||
Examples:
|
||||
- Run the tool to lint the message:
|
||||
```sh
|
||||
python -m conventional_precommit_linter.hook test_message.txt
|
||||
```
|
||||
|
||||
```text
|
||||
fix(freertos): Fix startup timeout issue
|
||||
|
||||
This is a detailed description of the commit message body ...
|
||||
|
||||
...
|
||||
|
||||
ci: added target test job for ESP32-Wifi6
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
With the Conventional Precommit Linter hook, your project can maintain clean and understandable commit messages that follow the Conventional Commits standard.
|
||||
... or with arguments:
|
||||
```sh
|
||||
python -m conventional_precommit_linter.hook --subject-min-length 20 --subject-max-length 72 --body-max-line-length 100 test_message.txt
|
||||
```
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
Our Conventional Precommit Linter hook also includes a test suite to ensure the correct functioning of the script.
|
||||
|
||||
To run these tests, you will need to have `pytest` installed. Once `pytest` is installed, you can run the tests by navigating to the root directory of this project and executing:
|
||||
|
||||
Before submitting a pull request, ensure your changes pass all the tests. You can run the test suite with the following command:
|
||||
```sh
|
||||
pytest
|
||||
```
|
||||
... or create a content in file `test_message.txt` and run:
|
||||
```sh
|
||||
python -m conventional_precommit_linter.hook --subject-min-length 20 --subject-max-length 72 --body-max-line-length 100 test_message.txt
|
||||
```
|
||||
|
||||
.... or (with default arguments):
|
||||
```sh
|
||||
python -m conventional_precommit_linter.hook test_message.txt
|
||||
```
|
||||
|
||||
The test cases include a variety of commit message scenarios and check if the script correctly identifies valid and invalid messages. The test suite ensures the correct identification of message structure, format, length, and content, amongst other parameters.
|
||||
|
||||
This way, the integrity of the Conventional Precommit Linter hook is continuously checked. Regularly running these tests after modifications to the script is highly recommended to ensure its consistent performance.
|
||||
Please also adhere to the Conventional Commits standard when writing your commit messages for this project.
|
||||
|
||||
***
|
||||
|
||||
|
||||
## Credit
|
||||
## Credits
|
||||
Inspired by project: https://github.com/compilerla/conventional-pre-commit
|
||||
|
25
conventional_precommit_linter/helpers.py
Normal file
25
conventional_precommit_linter/helpers.py
Normal file
@ -0,0 +1,25 @@
|
||||
from colorama import Fore
|
||||
from colorama import init
|
||||
from colorama import Style
|
||||
|
||||
init(autoreset=True) # Automatically reset the style after each print
|
||||
|
||||
|
||||
def _color_bold_green(text: str) -> str:
|
||||
return f'{Style.BRIGHT}{Fore.GREEN}{text}'
|
||||
|
||||
|
||||
def _color_purple(text: str) -> str:
|
||||
return f'{Fore.MAGENTA}{text}'
|
||||
|
||||
|
||||
def _color_orange(text: str) -> str:
|
||||
return f'{Fore.YELLOW}{text}'
|
||||
|
||||
|
||||
def _color_blue(text: str) -> str:
|
||||
return f'{Fore.LIGHTBLUE_EX}{text}'
|
||||
|
||||
|
||||
def _color_grey(text: str) -> str:
|
||||
return f'{Fore.LIGHTBLACK_EX}{text}'
|
@ -3,18 +3,162 @@ import re
|
||||
import sys
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
DEFAULT_TYPES = ['change', 'ci', 'docs', 'feat', 'fix', 'refactor', 'remove', 'revert']
|
||||
from .helpers import _color_blue
|
||||
from .helpers import _color_bold_green
|
||||
from .helpers import _color_grey
|
||||
from .helpers import _color_orange
|
||||
from .helpers import _color_purple
|
||||
|
||||
ERROR_EMPTY_MESSAGE = 'Commit message seems to be empty.'
|
||||
ERROR_MISSING_COLON = "Missing colon after 'type' or 'scope'. Ensure the commit message has the format '<type><(scope/component)>: <summary>'."
|
||||
ERROR_TYPE = "Issue with 'type'. Ensure the type is one of [{}]."
|
||||
ERROR_SCOPE_CAPITALIZATION = "Issue with 'scope'. Ensure the 'scope' is written in lower case without whitespace. Allowed special characters in 'scope' are _ / . , * -"
|
||||
ERROR_SUMMARY_LENGTH = "Issue with 'summary'. Ensure the summary is between {} and {} characters long."
|
||||
ERROR_SUMMARY_CAPITALIZATION = "Issue with 'summary'. Ensure the summary starts with an uppercase letter."
|
||||
ERROR_SUMMARY_PERIOD = "Issue with 'summary'. Ensure the summary does not end with a period."
|
||||
ERROR_BODY_FORMAT = "Incorrectly formatted 'body'. There should be one blank line between 'summary' and 'body'."
|
||||
ERROR_BODY_LENGTH = "Issue with 'body' line length. {} line(s) exceeding line length limit."
|
||||
|
||||
rules_output_status = {
|
||||
'empty_message': False,
|
||||
'error_body_format': False,
|
||||
'error_body_length': False,
|
||||
'error_scope_capitalization': False,
|
||||
'error_scope_format': False,
|
||||
'error_summary_capitalization': False,
|
||||
'error_summary_length': False,
|
||||
'error_summary_period': False,
|
||||
'error_type': False,
|
||||
'missing_colon': False,
|
||||
}
|
||||
|
||||
|
||||
def allowed_types(args: argparse.Namespace) -> str:
|
||||
default_types = ['change', 'ci', 'docs', 'feat', 'fix', 'refactor', 'remove', 'revert']
|
||||
# Provided types take precedence over default types
|
||||
types = args.types[0].split(',') if args.types else default_types
|
||||
return ', '.join(types)
|
||||
|
||||
|
||||
def read_commit_message(file_path: str) -> str:
|
||||
with open(file_path, encoding='utf-8') as file:
|
||||
lines = file.readlines()
|
||||
lines = [line for line in lines if not line.startswith('#')] # Remove comment lines (starting with '#')
|
||||
content = ''.join(lines)
|
||||
if not content.strip():
|
||||
rules_output_status['empty_message'] = True
|
||||
return content
|
||||
|
||||
|
||||
def split_message_title(message_title: str) -> Tuple[str, Optional[str], str]:
|
||||
"""Split 'message title' into 'type/scope' and 'summary'"""
|
||||
type_and_scope, _, commit_summary = message_title.partition(': ')
|
||||
commit_type, _, scope_part = type_and_scope.partition('(')
|
||||
|
||||
# Check if both opening and closing parentheses are present
|
||||
if '(' in type_and_scope and ')' not in scope_part:
|
||||
rules_output_status['error_scope_format'] = True
|
||||
return commit_type, None, commit_summary # Return None for the scope due to the error
|
||||
|
||||
commit_scope: Optional[str] = scope_part.rstrip(')').strip() if scope_part else None
|
||||
commit_summary = commit_summary.strip()
|
||||
return commit_type, commit_scope, commit_summary
|
||||
|
||||
|
||||
def check_colon_after_type(message_title: str) -> bool:
|
||||
"""Check for missing column between type / type(scope) and summary."""
|
||||
message_parts = message_title.split(': ', 1) # split only on first occurrence
|
||||
if len(message_parts) != 2:
|
||||
rules_output_status['missing_colon'] = True
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_allowed_types(commit_type: str, args: argparse.Namespace) -> None:
|
||||
"""Check for allowed types."""
|
||||
types = allowed_types(args)
|
||||
if commit_type not in types:
|
||||
rules_output_status['error_type'] = True
|
||||
|
||||
|
||||
def check_scope(commit_scope: str) -> None:
|
||||
"""Check for scope capitalization and allowed characters"""
|
||||
regex_scope = r'^[a-z0-9_/.,*-]*$'
|
||||
if commit_scope and not re.match(regex_scope, commit_scope):
|
||||
rules_output_status['error_scope_capitalization'] = True
|
||||
|
||||
|
||||
def check_summary_length(commit_summary: str, args: argparse.Namespace) -> None:
|
||||
"""Check for summary length (between min and max allowed characters)"""
|
||||
summary_length = len(commit_summary)
|
||||
if summary_length < args.subject_min_length or summary_length > args.subject_max_length:
|
||||
rules_output_status['error_summary_length'] = True
|
||||
|
||||
|
||||
def check_summary_lowercase(commit_summary: str) -> None:
|
||||
"""Check for summary starting with an uppercase letter (rule disabled in default config)"""
|
||||
if commit_summary[0].islower():
|
||||
rules_output_status['error_summary_capitalization'] = True
|
||||
|
||||
|
||||
def check_summary_period(commit_summary: str) -> None:
|
||||
"""Check for summary ending with a period"""
|
||||
if commit_summary[-1] == '.':
|
||||
rules_output_status['error_summary_period'] = True
|
||||
|
||||
|
||||
def check_body_empty_lines(message_body: List[str]) -> None:
|
||||
"""Check for empty line between summary and body"""
|
||||
if not message_body[0].strip() == '':
|
||||
rules_output_status['error_body_format'] = True
|
||||
|
||||
|
||||
def check_body_lines_length(message_body: List[str], args: argparse.Namespace) -> None:
|
||||
"""Check for body lines length (shorter than max allowed characters)"""
|
||||
if not all(len(line) <= args.body_max_line_length for line in message_body):
|
||||
rules_output_status['error_body_length'] = True
|
||||
|
||||
|
||||
def _get_icon_for_rule(status: bool) -> str:
|
||||
"""Return a icon depending on the status of the rule (True = error found, False = success))"""
|
||||
return '❌' if status else '✔️ '
|
||||
|
||||
|
||||
def print_report(commit_type: str, commit_scope: Optional[str], commit_summary: str, args) -> None:
|
||||
# Color the input commit message with matching element colors
|
||||
commit_message = f'{_color_purple(commit_type)}: { _color_orange( commit_summary)}'
|
||||
if commit_scope:
|
||||
commit_message = (
|
||||
f'{_color_purple(commit_type)}({ _color_blue( commit_scope)}): { _color_orange( commit_summary)}'
|
||||
)
|
||||
|
||||
# Rule messages that are always included
|
||||
rule_messages = [
|
||||
f"{_get_icon_for_rule(rules_output_status['error_type'])} {_color_purple('<type>')} is mandatory, use one of the following: [{_color_purple(allowed_types(args))}]",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_scope_format'])} {_color_blue('(<optional-scope>)')} if used, must be enclosed in parentheses",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_scope_capitalization'])} {_color_blue('(<optional-scope>)')} if used, must be written in lower case without whitespace",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_summary_period'])} {_color_orange('<summary>')} must not end with a period",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_summary_length'])} {_color_orange('<summary>')} must be between {args.subject_min_length} and {args.subject_max_length} characters long",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_body_length'])} {_color_grey('<body>')} lines must be no longer than {args.body_max_line_length} characters",
|
||||
f"{_get_icon_for_rule(rules_output_status['error_body_format'])} {_color_grey('<body>')} must be separated from the 'summary' by a blank line",
|
||||
]
|
||||
|
||||
# Dynamically add the additional rules set by arguments
|
||||
if args.summary_uppercase:
|
||||
rule_messages.append(
|
||||
f"{_get_icon_for_rule(rules_output_status['error_summary_capitalization'])} {_color_orange('<summary>')} must start with an uppercase letter"
|
||||
)
|
||||
|
||||
# Combine the rule messages into the final report block
|
||||
message_rules_block = ' ' + '\n '.join(rule_messages)
|
||||
|
||||
full_guide_message = f"""\n❌ INVALID COMMIT MESSAGE: {commit_message}
|
||||
_______________________________________________________________
|
||||
Commit message structure: {_color_purple('<type>')}{_color_blue("(<optional-scope>)")}: {_color_orange('<summary>')}
|
||||
<... empty line ...>
|
||||
{_color_grey('<optional body lines>')}
|
||||
{_color_grey('<optional body lines>')}
|
||||
_______________________________________________________________
|
||||
Commit message rules:
|
||||
{message_rules_block}
|
||||
"""
|
||||
print(full_guide_message)
|
||||
print(
|
||||
f'👉 To preserve and correct a commit message, run: {_color_bold_green("git commit --edit --file=.git/COMMIT_EDITMSG")}'
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: List[str]) -> argparse.Namespace:
|
||||
@ -22,157 +166,59 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
|
||||
prog='conventional-pre-commit', description='Check a git commit message for Conventional Commits formatting.'
|
||||
)
|
||||
parser.add_argument('--types', type=str, nargs='*', help='Optional list of types to support')
|
||||
parser.add_argument(
|
||||
'--subject-min-length', type=int, default=20, help="Minimum length of the 'Summary' field in commit message"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--subject-max-length', type=int, default=72, help="Maximum length of the 'Summary' field in commit message"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--body-max-line-length', type=int, default=100, help='Maximum length of the line in message body'
|
||||
)
|
||||
parser.add_argument('--subject-min-length', type=int, default=20, help="Minimum length of the 'Summary'")
|
||||
parser.add_argument('--subject-max-length', type=int, default=72, help="Maximum length of the 'Summary'")
|
||||
parser.add_argument('--body-max-line-length', type=int, default=100, help='Maximum length of the body line')
|
||||
parser.add_argument('--summary-uppercase', action='store_true', help='Summary must start with an uppercase letter')
|
||||
parser.add_argument('input', type=str, help='A file containing a git commit message')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def get_types(args: argparse.Namespace) -> str:
|
||||
# Provided types take precedence over default types
|
||||
types = args.types[0].split(',') if args.types else DEFAULT_TYPES
|
||||
return ', '.join(types)
|
||||
|
||||
|
||||
def raise_error(message: str, error: str, types: str, args: argparse.Namespace) -> None:
|
||||
full_error_msg = f'❌ Invalid commit message: "{message}"\n{error}'
|
||||
|
||||
guide_good_message = f"""
|
||||
commit message structure: <type><(scope/component)>: <Summary>
|
||||
|
||||
commit message rules:
|
||||
- use one of the following types: [{types}]
|
||||
- 'scope/component' is optional, but if used, must be written in lower case without whitespace
|
||||
- 'summary' must not end with a period
|
||||
- 'summary' must be between {args.subject_min_length} and {args.subject_max_length} characters long
|
||||
- 'body' is optional, but if used, lines must be no longer than {args.body_max_line_length} characters
|
||||
- 'body' is optional, but if used, must be separated from the 'summary' by a blank line
|
||||
|
||||
Example of a good commit message (with scope and body):
|
||||
fix(freertos): Fix startup timeout issue
|
||||
|
||||
This is a text of commit message body ...
|
||||
|
||||
...
|
||||
|
||||
Example of a good commit message (without scope and body):
|
||||
ci: added target test job for ESP32-Wifi6
|
||||
|
||||
...
|
||||
"""
|
||||
|
||||
print(f'{full_error_msg}{guide_good_message}')
|
||||
print(
|
||||
'\n\033[93m 👉 To preserve and correct a commit message, run\033[92m git commit --edit --file=.git/COMMIT_EDITMSG \033[0m'
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def read_commit_message(file_path: str) -> str:
|
||||
try:
|
||||
with open(file_path, encoding='utf-8') as file:
|
||||
lines = file.readlines()
|
||||
# Remove lines starting with '#'
|
||||
lines = [line for line in lines if not line.startswith('#')]
|
||||
content = ''.join(lines)
|
||||
if not content.strip():
|
||||
print(f'❌ {ERROR_EMPTY_MESSAGE}')
|
||||
raise SystemExit(1)
|
||||
return content
|
||||
except UnicodeDecodeError as exc:
|
||||
print('❌ Problem with reading the commit message. Possible encoding issue.')
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def parse_commit_message(args: argparse.Namespace, input_commit_message: str) -> None:
|
||||
# Split the 'commit message' into the 'message title' (first line) and 'message body' (the rest)
|
||||
message_title, *message_body = input_commit_message.strip().split('\n', 1)
|
||||
|
||||
# First split 'message title' into potential 'type/scope' and 'summary'
|
||||
message_parts = message_title.split(': ', 1) # using 1 as second argument to split only on first occurrence
|
||||
if len(message_parts) != 2:
|
||||
types = get_types(args)
|
||||
raise_error(message_title, ERROR_MISSING_COLON, types, args)
|
||||
|
||||
# Check if a 'scope' is provided in the potential 'type/scope' part
|
||||
type_scope_parts = message_parts[0].split('(', 1) # using 1 as second argument to split only on first occurrence
|
||||
if len(type_scope_parts) == 1:
|
||||
# no 'scope' provided
|
||||
commit_type = type_scope_parts[0]
|
||||
commit_scope = None
|
||||
else:
|
||||
# 'scope' provided
|
||||
commit_type = type_scope_parts[0]
|
||||
commit_scope = type_scope_parts[1].rstrip(')')
|
||||
|
||||
commit_summary = message_parts[1]
|
||||
|
||||
# Check for invalid commit 'type'
|
||||
types = get_types(args)
|
||||
if commit_type not in types.split(', '):
|
||||
error = ERROR_TYPE.format(types)
|
||||
raise_error(message_title, error, types, args)
|
||||
|
||||
# If 'scope' is provided, check for valid 'scope'
|
||||
regex_scope = r'^[a-z0-9_/.,*-]*$'
|
||||
if commit_scope and not re.match(regex_scope, commit_scope):
|
||||
raise_error(message_title, ERROR_SCOPE_CAPITALIZATION, types, args)
|
||||
|
||||
# Check for valid length of 'summary'
|
||||
summary_length = len(commit_summary)
|
||||
if summary_length < args.subject_min_length or summary_length > args.subject_max_length:
|
||||
error = ERROR_SUMMARY_LENGTH.format(args.subject_min_length, args.subject_max_length)
|
||||
raise_error(message_title, error, types, args)
|
||||
|
||||
# Check if the 'summary' starts with a lowercase letter
|
||||
if args.summary_uppercase and commit_summary[0].islower():
|
||||
raise_error(message_title, ERROR_SUMMARY_CAPITALIZATION, types, args)
|
||||
|
||||
# Check if the 'summary' ends with a period (full stop)
|
||||
if commit_summary[-1] == '.':
|
||||
raise_error(message_title, ERROR_SUMMARY_PERIOD, types, args)
|
||||
|
||||
# Skip the rest if there is no message 'body' (as `body` is optional)
|
||||
if not message_body:
|
||||
return
|
||||
|
||||
# Parse commit message 'body' (when it exists)
|
||||
if re.match(r'\n', message_body[0]):
|
||||
body = message_body[0][1:] # Remove the first blank line
|
||||
|
||||
# Check if each line of the 'body' is no longer than 100 characters
|
||||
lines = body.split('\n')
|
||||
invalid_lines = [line for line in lines if len(line) > args.body_max_line_length]
|
||||
num_invalid_lines = len(invalid_lines)
|
||||
if num_invalid_lines:
|
||||
error = ERROR_BODY_LENGTH.format(num_invalid_lines)
|
||||
|
||||
for line in invalid_lines:
|
||||
print(f" Line: '{line[:30]}...' (length: {len(line)})")
|
||||
raise_error(message_title, error, types, args)
|
||||
else:
|
||||
raise_error(message_title, ERROR_BODY_FORMAT, types, args)
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> None:
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
argv = argv or sys.argv[1:]
|
||||
|
||||
args = parse_args(argv)
|
||||
|
||||
# Read commit message
|
||||
# Parse the commit message in to parts
|
||||
input_commit_message = read_commit_message(args.input)
|
||||
|
||||
# Parse the commit message
|
||||
parse_commit_message(args, input_commit_message)
|
||||
if not input_commit_message.strip():
|
||||
print('❌ Commit message seems to be empty.')
|
||||
return 1
|
||||
|
||||
message_lines = input_commit_message.strip().split('\n') # Split the commit message into lines
|
||||
message_title = message_lines[0] # The summary is the first line
|
||||
message_body = message_lines[1:] # The body is everything after the summary, if it exists
|
||||
commit_type, commit_scope, commit_summary = split_message_title(message_title)
|
||||
|
||||
if not check_colon_after_type(message_title):
|
||||
print(f'❌ Missing colon after {_color_purple("<type>")} or {_color_blue("(<optional-scope>)")}.')
|
||||
print(
|
||||
f'\nEnsure the commit message has the format \"{_color_purple("<type>")}{_color_blue("(<optional-scope>)")}: {_color_orange("<summary>")}\"'
|
||||
)
|
||||
return 1
|
||||
|
||||
# Commit message title (first line) checks
|
||||
check_allowed_types(commit_type, args)
|
||||
if commit_scope:
|
||||
check_scope(commit_scope)
|
||||
check_summary_length(commit_summary, args)
|
||||
check_summary_period(commit_summary)
|
||||
if args.summary_uppercase:
|
||||
check_summary_lowercase(commit_summary)
|
||||
|
||||
# Commit message body checks
|
||||
if message_body:
|
||||
check_body_empty_lines(message_body)
|
||||
check_body_lines_length(message_body, args)
|
||||
|
||||
# Create report if issues found
|
||||
if any(value for value in rules_output_status.values()):
|
||||
print_report(commit_type, commit_scope, commit_summary, args)
|
||||
return 1
|
||||
|
||||
# No output and exit RC 0 if no issues found
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
BIN
docs/conventional-precommit-linter.jpg
Normal file
BIN
docs/conventional-precommit-linter.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 65 KiB |
BIN
docs/example-output-custom-args.png
Normal file
BIN
docs/example-output-custom-args.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 104 KiB |
BIN
docs/example-output-default-args.png
Normal file
BIN
docs/example-output-default-args.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 98 KiB |
@ -3,7 +3,7 @@
|
||||
{ name = "Tomas Sebestik (Espressif Systems)", email = "tomas.sebestik@espressif.com" },
|
||||
]
|
||||
classifiers = ["Programming Language :: Python :: 3 :: Only"]
|
||||
dependencies = []
|
||||
dependencies = ["colorama==0.4.6"]
|
||||
description = "A pre-commit hook that checks commit messages for Conventional Commits formatting."
|
||||
dynamic = ["version"]
|
||||
keywords = ["conventional-commits", "git", "pre-commit"]
|
||||
@ -87,7 +87,7 @@
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-s --log-cli-level DEBUG --cov=conventional_precommit_linter --cov-report=term"
|
||||
addopts = "-ra -v -p no:print --cov=conventional_precommit_linter --cov-report=term"
|
||||
python_classes = ["Test*"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
|
@ -2,198 +2,219 @@ import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from conventional_precommit_linter.hook import ERROR_BODY_FORMAT
|
||||
from conventional_precommit_linter.hook import ERROR_BODY_LENGTH
|
||||
from conventional_precommit_linter.hook import ERROR_EMPTY_MESSAGE
|
||||
from conventional_precommit_linter.hook import ERROR_MISSING_COLON
|
||||
from conventional_precommit_linter.hook import ERROR_SCOPE_CAPITALIZATION
|
||||
from conventional_precommit_linter.hook import ERROR_SUMMARY_CAPITALIZATION
|
||||
from conventional_precommit_linter.hook import ERROR_SUMMARY_LENGTH
|
||||
from conventional_precommit_linter.hook import ERROR_SUMMARY_PERIOD
|
||||
from conventional_precommit_linter.hook import ERROR_TYPE
|
||||
from conventional_precommit_linter.hook import main
|
||||
from conventional_precommit_linter.hook import rules_output_status
|
||||
|
||||
# Input arguments
|
||||
# Default values for the commit message format
|
||||
TYPES = 'change, ci, docs, feat, fix, refactor, remove, revert, fox'
|
||||
SUBJECT_MIN_LENGTH = 21
|
||||
SUBJECT_MAX_LENGTH = 53
|
||||
BODY_MAX_LINE_LENGTH = 101
|
||||
BODY_MAX_LINE_LENGTH = 107
|
||||
SUMMARY_UPPERCASE = True
|
||||
|
||||
# Default dictionary with all values set to False
|
||||
default_rules_output_status = {
|
||||
'empty_message': False,
|
||||
'error_body_format': False,
|
||||
'error_body_length': False,
|
||||
'error_scope_capitalization': False,
|
||||
'error_scope_format': False,
|
||||
'error_summary_capitalization': False,
|
||||
'error_summary_length': False,
|
||||
'error_summary_period': False,
|
||||
'error_type': False,
|
||||
'missing_colon': False,
|
||||
}
|
||||
|
||||
|
||||
# Dynamic test naming based on the commit message
|
||||
def commit_message_id(commit_message): # pylint: disable=redefined-outer-name
|
||||
return commit_message[0] # Use the first line of the commit message as the test ID
|
||||
|
||||
|
||||
# Fixture for messages
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
(
|
||||
# Expected PASS: Message with scope and body
|
||||
'feat(bootloader): This is commit message with scope and body\n\nThis is a text of body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope, without body
|
||||
'change(wifi): This is commit message with scope without body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with hyphen in scope), without body
|
||||
'change(esp-rom): This is commit message with hyphen in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with asterisk in scope), without body
|
||||
'change(examples*storage): This is commit message with asterisk in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with comma in scope), without body
|
||||
'change(examples,storage): This is commit message with comma in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with slash in scope), without body
|
||||
'change(examples/storage): This is commit message with slash in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message without scope, with body
|
||||
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#', # noqa: E501
|
||||
True,
|
||||
None,
|
||||
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#',
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message without scope, without body
|
||||
'change: This is commit message without scope and body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Test of additional types
|
||||
'fox(esp32): Testing additional types\n\nThis is a text of body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: 'body' line longer (custom arg 107 chars)
|
||||
'fix(bt): Update database schemas\n\nUpdating the database schema to include fields and user profile preferences, cleaning up unnecessary calls',
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing colon between 'type' (and 'scope') and 'summary'
|
||||
'change this is commit message without body',
|
||||
False,
|
||||
ERROR_MISSING_COLON,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too short
|
||||
'fix: Fix bug',
|
||||
False,
|
||||
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too long
|
||||
'change(rom): Refactor authentication flow for enhanced security measures',
|
||||
False,
|
||||
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' ends with period
|
||||
'change(rom): Fixed the another bug.',
|
||||
False,
|
||||
ERROR_SUMMARY_PERIOD,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' starts with lowercase
|
||||
'change(rom): this message starts with lowercase',
|
||||
False,
|
||||
ERROR_SUMMARY_CAPITALIZATION,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope'
|
||||
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_SCOPE_CAPITALIZATION,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope'
|
||||
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
|
||||
False,
|
||||
ERROR_SCOPE_CAPITALIZATION,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' with scope and body
|
||||
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
# Adjusted expected error message
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' without scope and without body
|
||||
'wip: Added new feature with change',
|
||||
False,
|
||||
# Adjusted expected error message
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' (type starts with uppercase)
|
||||
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing blank line between 'summary' and 'body'
|
||||
'change: Added new feature with change\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_BODY_FORMAT,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'body' line too long
|
||||
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls', # noqa: E501
|
||||
False,
|
||||
ERROR_BODY_LENGTH.format(1), # 1 here means found one line that is too long
|
||||
{'missing_colon': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: empty commit message
|
||||
' \n\n \n',
|
||||
False,
|
||||
ERROR_EMPTY_MESSAGE,
|
||||
{'empty_message': True},
|
||||
),
|
||||
]
|
||||
(
|
||||
# Expected FAIL: 'summary' too short
|
||||
'fix: Fix bug',
|
||||
{'error_summary_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too long
|
||||
'change(rom): Refactor authentication flow for enhanced security measures',
|
||||
{'error_summary_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' ends with period
|
||||
'change(rom): Fixed the another bug.',
|
||||
{'error_summary_period': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' starts with lowercase
|
||||
'change(rom): this message starts with lowercase',
|
||||
{'error_summary_capitalization': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope', with body
|
||||
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_scope_capitalization': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope', no body
|
||||
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
|
||||
{'error_scope_capitalization': True, 'error_summary_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' with scope and body
|
||||
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' without scope and without body
|
||||
'delete: Added new feature with change',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' without scope and without body
|
||||
'wip: Added new feature with change',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' (type starts with uppercase)
|
||||
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing blank line between 'summary' and 'body'
|
||||
'change: Added new feature with change\nThis feature adds functionality',
|
||||
{'error_body_format': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'body' line too long
|
||||
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls',
|
||||
{'error_body_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'scope' missing parenthese
|
||||
'fix(bt: Update database schemas\n\nUpdating the database schema to include new fields.',
|
||||
{'error_scope_format': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: allowed special 'type', uppercase in 'scope' required, 'summary' too long, 'summary' ends with period
|
||||
'fox(BT): update database schemas. Updating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls.',
|
||||
{
|
||||
'error_summary_capitalization': True,
|
||||
'error_scope_capitalization': True,
|
||||
'error_summary_length': True,
|
||||
'error_summary_period': True,
|
||||
},
|
||||
),
|
||||
],
|
||||
# Use the commit message to generate IDs for each test case
|
||||
ids=commit_message_id,
|
||||
)
|
||||
def message(request):
|
||||
return request.param
|
||||
def commit_message(request):
|
||||
# Combine the default dictionary with the test-specific dictionary
|
||||
combined_output = {**default_rules_output_status, **request.param[1]}
|
||||
return request.param[0], combined_output
|
||||
|
||||
|
||||
def test_commit_message(message, capsys):
|
||||
# Unpack the message, the expectation, and the expected error message
|
||||
message_text, should_pass, expected_error = message
|
||||
def test_commit_message_with_args(commit_message): # pylint: disable=redefined-outer-name
|
||||
message_text, expected_output = commit_message
|
||||
|
||||
# Convert the constants into a list of command-line arguments
|
||||
# Reset rules_output_status to its default state before each test case
|
||||
rules_output_status.clear()
|
||||
rules_output_status.update(default_rules_output_status)
|
||||
|
||||
# Create a temporary file to mock a commit message file input
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp:
|
||||
temp.write(message_text)
|
||||
temp_file_name = temp.name
|
||||
|
||||
# Construct the argument list for main with the new constants
|
||||
argv = [
|
||||
'--types',
|
||||
TYPES.replace(', ', ','),
|
||||
TYPES,
|
||||
'--subject-min-length',
|
||||
str(SUBJECT_MIN_LENGTH),
|
||||
'--subject-max-length',
|
||||
str(SUBJECT_MAX_LENGTH),
|
||||
'--body-max-line-length',
|
||||
str(BODY_MAX_LINE_LENGTH),
|
||||
'--summary-uppercase',
|
||||
'--summary-uppercase' if SUMMARY_UPPERCASE else '--no-summary-uppercase',
|
||||
temp_file_name,
|
||||
]
|
||||
|
||||
# Create a temporary file and write the commit message to it
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp:
|
||||
temp.write(message_text.encode())
|
||||
temp_file_name = temp.name
|
||||
|
||||
# Add the path of the temp file to the arguments
|
||||
argv.append(temp_file_name)
|
||||
|
||||
# If the message is expected to be invalid, check that it raises SystemExit and that the error message matches
|
||||
if not should_pass:
|
||||
with pytest.raises(SystemExit):
|
||||
main(argv)
|
||||
captured = capsys.readouterr()
|
||||
assert expected_error in captured.out
|
||||
else:
|
||||
# Run the main function of your script with the temporary file and arguments
|
||||
try:
|
||||
main(argv)
|
||||
|
||||
finally:
|
||||
# Clean up the temporary file after the test
|
||||
temp.close()
|
||||
|
||||
# Retrieve the actual rules_output_status after running the main function
|
||||
actual_output = rules_output_status
|
||||
|
||||
# Assert that the actual rules_output_status matches the expected output
|
||||
assert actual_output == expected_output, f'Failed on commit message: {message_text}'
|
||||
|
@ -2,189 +2,187 @@ import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from conventional_precommit_linter.hook import ERROR_BODY_FORMAT
|
||||
from conventional_precommit_linter.hook import ERROR_BODY_LENGTH
|
||||
from conventional_precommit_linter.hook import ERROR_EMPTY_MESSAGE
|
||||
from conventional_precommit_linter.hook import ERROR_MISSING_COLON
|
||||
from conventional_precommit_linter.hook import ERROR_SCOPE_CAPITALIZATION
|
||||
from conventional_precommit_linter.hook import ERROR_SUMMARY_LENGTH
|
||||
from conventional_precommit_linter.hook import ERROR_SUMMARY_PERIOD
|
||||
from conventional_precommit_linter.hook import ERROR_TYPE
|
||||
from conventional_precommit_linter.hook import main
|
||||
from conventional_precommit_linter.hook import rules_output_status
|
||||
|
||||
# DEFAULT input arguments
|
||||
# Default values for the commit message format
|
||||
TYPES = 'change, ci, docs, feat, fix, refactor, remove, revert'
|
||||
SUBJECT_MIN_LENGTH = 20
|
||||
SUBJECT_MAX_LENGTH = 72
|
||||
BODY_MAX_LINE_LENGTH = 100
|
||||
|
||||
# Default dictionary with all values set to False
|
||||
default_rules_output_status = {
|
||||
'empty_message': False,
|
||||
'error_body_format': False,
|
||||
'error_body_length': False,
|
||||
'error_scope_capitalization': False,
|
||||
'error_scope_format': False,
|
||||
'error_summary_capitalization': False,
|
||||
'error_summary_length': False,
|
||||
'error_summary_period': False,
|
||||
'error_type': False,
|
||||
'missing_colon': False,
|
||||
}
|
||||
|
||||
|
||||
# Dynamic test naming based on the commit message
|
||||
def commit_message_id(commit_message): # pylint: disable=redefined-outer-name
|
||||
return commit_message[0] # Use the first line of the commit message as the test ID
|
||||
|
||||
|
||||
# Fixture for messages
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
(
|
||||
# Expected PASS: Message with scope and body
|
||||
'feat(bootloader): This is commit message with scope and body\n\nThis is a text of body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope, without body
|
||||
'change(wifi): This is commit message with scope without body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with hyphen in scope), without body
|
||||
'change(esp-rom): This is commit message with hyphen in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with asterisk in scope), without body
|
||||
'change(examples*storage): This is commit message with asterisk in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with comma in scope), without body
|
||||
'change(examples,storage): This is commit message with comma in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message with scope (with slash in scope), without body
|
||||
'change(examples/storage): This is commit message with slash in scope',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message without scope, with body
|
||||
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#', # noqa: E501
|
||||
True,
|
||||
None,
|
||||
'change: This is commit message without scope with body\n\nThis is a text of body\n# Please enter the commit message for your changes. Lines starting\n# with \'#\' will be ignored, and an empty message aborts the commit.\n#',
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: Message without scope, without body
|
||||
'change: This is commit message without scope and body',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected PASS: 'summary' starts with lowercase
|
||||
'change(rom): this message starts with lowercase',
|
||||
True,
|
||||
None,
|
||||
{},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing colon between 'type' (and 'scope') and 'summary'
|
||||
'change this is commit message without body',
|
||||
False,
|
||||
ERROR_MISSING_COLON,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too short
|
||||
'fix: Fix bug',
|
||||
False,
|
||||
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too long
|
||||
'change(rom): Refactor authentication flow for enhanced security measures and improved user experience', # noqa: E501
|
||||
False,
|
||||
ERROR_SUMMARY_LENGTH.format(SUBJECT_MIN_LENGTH, SUBJECT_MAX_LENGTH),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' ends with period
|
||||
'change(rom): Fixed the another bug.',
|
||||
False,
|
||||
ERROR_SUMMARY_PERIOD,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope'
|
||||
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_SCOPE_CAPITALIZATION,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope'
|
||||
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
|
||||
False,
|
||||
ERROR_SCOPE_CAPITALIZATION,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' with scope and body
|
||||
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
# Adjusted expected error message
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' without scope and without body
|
||||
'wip: Added new feature with change',
|
||||
False,
|
||||
# Adjusted expected error message
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' (type starts with uppercase)
|
||||
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_TYPE.format(TYPES),
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing blank line between 'summary' and 'body'
|
||||
'change: Added new feature with change\nThis feature adds functionality',
|
||||
False,
|
||||
ERROR_BODY_FORMAT,
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'body' line too long
|
||||
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls', # noqa: E501
|
||||
False,
|
||||
ERROR_BODY_LENGTH.format(1), # 1 here means found one line that is too long
|
||||
{'missing_colon': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: empty commit message
|
||||
' \n\n \n',
|
||||
False,
|
||||
ERROR_EMPTY_MESSAGE,
|
||||
{'empty_message': True},
|
||||
),
|
||||
]
|
||||
(
|
||||
# Expected FAIL: 'summary' too short
|
||||
'fix: Fix bug',
|
||||
{'error_summary_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' too long
|
||||
'change(rom): Refactor authentication flow for enhanced security measures and improved user experience',
|
||||
{'error_summary_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'summary' ends with period
|
||||
'change(rom): Fixed the another bug.',
|
||||
{'error_summary_period': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope', with body
|
||||
'change(Bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_scope_capitalization': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: uppercase in 'scope', no body
|
||||
'fix(dangerGH): Update token permissions - allow Danger to add comments to PR',
|
||||
{'error_scope_capitalization': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' with scope and body
|
||||
'delete(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' without scope and without body
|
||||
'fox: Added new feature with change',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: not allowed 'type' (type starts with uppercase)
|
||||
'Fix(bt): Added new feature with change\n\nThis feature adds functionality',
|
||||
{'error_type': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: missing blank line between 'summary' and 'body'
|
||||
'change: Added new feature with change\nThis feature adds functionality',
|
||||
{'error_body_format': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'body' line too long
|
||||
'fix(bt): Update database schemas\n\nUpdating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls',
|
||||
{'error_body_length': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: 'scope' missing parenthese
|
||||
'fix(bt: Update database schemas\n\nUpdating the database schema to include new fields.',
|
||||
{'error_scope_format': True},
|
||||
),
|
||||
(
|
||||
# Expected FAIL: wrong 'type', uppercase in 'scope', 'summary' too long, 'summary' ends with period
|
||||
'fox(BT): Update database schemas. Updating the database schema to include new fields and user profile preferences, cleaning up unnecessary calls.',
|
||||
{
|
||||
'error_scope_capitalization': True,
|
||||
'error_summary_length': True,
|
||||
'error_summary_period': True,
|
||||
'error_type': True,
|
||||
},
|
||||
),
|
||||
],
|
||||
# Use the commit message to generate IDs for each test case
|
||||
ids=commit_message_id,
|
||||
)
|
||||
def message(request):
|
||||
return request.param
|
||||
def commit_message(request):
|
||||
# Combine the default dictionary with the test-specific dictionary
|
||||
combined_output = {**default_rules_output_status, **request.param[1]}
|
||||
return request.param[0], combined_output
|
||||
|
||||
|
||||
def test_commit_message(message, capsys):
|
||||
# Unpack the message, the expectation, and the expected error message
|
||||
message_text, should_pass, expected_error = message
|
||||
def test_commit_message(commit_message): # pylint: disable=redefined-outer-name
|
||||
message_text, expected_output = commit_message
|
||||
|
||||
# Convert the constants into a list of command-line arguments
|
||||
argv = [
|
||||
'--types',
|
||||
TYPES.replace(', ', ','),
|
||||
'--subject-min-length',
|
||||
str(SUBJECT_MIN_LENGTH),
|
||||
'--subject-max-length',
|
||||
str(SUBJECT_MAX_LENGTH),
|
||||
'--body-max-line-length',
|
||||
str(BODY_MAX_LINE_LENGTH),
|
||||
]
|
||||
# Reset rules_output_status to its default state before each test case
|
||||
rules_output_status.clear()
|
||||
rules_output_status.update(default_rules_output_status)
|
||||
|
||||
# Create a temporary file and write the commit message to it
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp:
|
||||
temp.write(message_text.encode())
|
||||
# Create a temporary file to mock a commit message file input
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp:
|
||||
temp.write(message_text)
|
||||
temp_file_name = temp.name
|
||||
|
||||
# Add the path of the temp file to the arguments
|
||||
argv.append(temp_file_name)
|
||||
# Run the main function of your script with the temporary file
|
||||
try:
|
||||
main([temp_file_name]) # Pass the file name as a positional argument
|
||||
finally:
|
||||
temp.close() # Clean up the temporary file after the test
|
||||
|
||||
# If the message is expected to be invalid, check that it raises SystemExit and that the error message matches
|
||||
if not should_pass:
|
||||
with pytest.raises(SystemExit):
|
||||
main(argv)
|
||||
captured = capsys.readouterr()
|
||||
assert expected_error in captured.out
|
||||
else:
|
||||
main(argv)
|
||||
# Retrieve the actual rules_output_status after running the main function
|
||||
actual_output = rules_output_status
|
||||
|
||||
# Assert that the actual rules_output_status matches the expected output
|
||||
assert actual_output == expected_output, f'Failed on commit message: {message_text}'
|
||||
|
Reference in New Issue
Block a user