├── tests ├── __init__.py ├── messages │ ├── bad_commit │ ├── custom_commit │ ├── conventional_commit │ ├── conventional_commit_utf-8 │ ├── merge_commit │ ├── conventional_commit_with_scope │ ├── fixup_commit │ ├── conventional_commit_bad_multi_line │ ├── conventional_commit_multi_line │ ├── conventional_commit_with_dots │ ├── conventional_commit_with_multiple_scopes │ └── conventional_commit_gbk ├── run.sh ├── conftest.py ├── test_output.py ├── test_hook.py └── test_format.py ├── .dockerignore ├── .flake8 ├── .gitignore ├── conventional_pre_commit ├── __init__.py ├── hook.py ├── output.py └── format.py ├── .pre-commit-hooks.yaml ├── compose.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .vscode └── settings.json ├── .github ├── dependabot.yaml └── workflows │ ├── tests.yml │ └── release.yml ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | -------------------------------------------------------------------------------- /tests/messages/bad_commit: -------------------------------------------------------------------------------- 1 | bad message 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 127 3 | -------------------------------------------------------------------------------- /tests/messages/custom_commit: -------------------------------------------------------------------------------- 1 | custom: message 2 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit: -------------------------------------------------------------------------------- 1 | feat: message 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[co] 3 | .coverage 4 | dist 5 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_utf-8: -------------------------------------------------------------------------------- 1 | feat: utf-8 test 测试 2 | -------------------------------------------------------------------------------- /tests/messages/merge_commit: -------------------------------------------------------------------------------- 1 | Merge branch '2.x.x' into '1.x.x' 2 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_with_scope: -------------------------------------------------------------------------------- 1 | feat(scope): message 2 | -------------------------------------------------------------------------------- /tests/messages/fixup_commit: -------------------------------------------------------------------------------- 1 | fixup! feature: implement something cool 2 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_bad_multi_line: -------------------------------------------------------------------------------- 1 | fix: message 2 | no blank line 3 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_multi_line: -------------------------------------------------------------------------------- 1 | fix: message 2 | 3 | A blank line is there 4 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_with_dots: -------------------------------------------------------------------------------- 1 | feat(customer.registration): adds support for oauth2 2 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_with_multiple_scopes: -------------------------------------------------------------------------------- 1 | feat(api,client): added new endpoint with client support 2 | -------------------------------------------------------------------------------- /tests/messages/conventional_commit_gbk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compilerla/conventional-pre-commit/HEAD/tests/messages/conventional_commit_gbk -------------------------------------------------------------------------------- /conventional_pre_commit/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version, PackageNotFoundError 2 | 3 | try: 4 | __version__ = version("conventional-pre-commit") 5 | except PackageNotFoundError: 6 | # package is not installed 7 | pass 8 | -------------------------------------------------------------------------------- /tests/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | # run normal pytests 5 | coverage run -m pytest 6 | 7 | # clean out old coverage results 8 | rm -rf ./tests/coverage 9 | 10 | # regenerate coverage report 11 | coverage html --directory ./tests/coverage 12 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: conventional-pre-commit 2 | name: Conventional Commit 3 | entry: conventional-pre-commit 4 | language: python 5 | description: Checks commit message for Conventional Commits formatting 6 | always_run: true 7 | stages: [commit-msg] 8 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dev: 3 | build: 4 | context: . 5 | dockerfile: .devcontainer/Dockerfile 6 | entrypoint: [] 7 | command: sleep infinity 8 | image: compilerla/conventional-pre-commit:dev 9 | volumes: 10 | - ./:/home/compiler/src 11 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ENV PYTHONDONTWRITEBYTECODE=1 \ 4 | PYTHONUNBUFFERED=1 \ 5 | USER=compiler 6 | 7 | # create $USER and home directory 8 | RUN useradd --create-home --shell /bin/bash $USER && \ 9 | chown -R $USER /home/$USER 10 | 11 | # switch to non-root $USER 12 | USER $USER 13 | 14 | # enter src directory 15 | WORKDIR /home/$USER/src 16 | 17 | # update PATH for local pip installs 18 | ENV PATH "$PATH:/home/$USER/.local/bin" 19 | 20 | # update pip 21 | RUN python -m pip install --upgrade pip 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "files.encoding": "utf8", 5 | "files.eol": "\n", 6 | "files.insertFinalNewline": true, 7 | "files.trimFinalNewlines": true, 8 | "files.trimTrailingWhitespace": true, 9 | "[python]": { 10 | "editor.defaultFormatter": "ms-python.black-formatter" 11 | }, 12 | "python.languageServer": "Pylance", 13 | "python.testing.pytestArgs": ["tests"], 14 | "python.testing.pytestEnabled": true, 15 | "python.testing.unittestEnabled": false 16 | } 17 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" # pyproject.toml 10 | schedule: 11 | interval: "daily" 12 | commit-message: 13 | prefix: "chore" 14 | include: "scope" 15 | labels: 16 | - "dependencies" 17 | - package-ecosystem: "github-actions" 18 | # Workflow files stored in the 19 | # default location of `.github/workflows` 20 | directory: "/" 21 | schedule: 22 | interval: "daily" 23 | commit-message: 24 | prefix: "chore" 25 | include: "scope" 26 | labels: 27 | - "dependencies" 28 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compilerla/conventional-pre-commit", 3 | "dockerComposeFile": ["../compose.yml"], 4 | "service": "dev", 5 | "runServices": ["dev"], 6 | "workspaceFolder": "/home/compiler/src", 7 | "postCreateCommand": "pip install -e .[dev]", 8 | "customizations": { 9 | "vscode": { 10 | // Set *default* container specific settings.json values on container create. 11 | "settings": { 12 | "terminal.integrated.defaultProfile.linux": "bash", 13 | "terminal.integrated.profiles.linux": { 14 | "bash": { 15 | "path": "/bin/bash" 16 | } 17 | } 18 | }, 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "eamodio.gitlens", 22 | "esbenp.prettier-vscode", 23 | "mhutchie.git-graph", 24 | "ms-python.python", 25 | "ms-python.black-formatter", 26 | "ms-python.flake8", 27 | "tamasfe.even-better-toml" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "chore(pre-commit): autofix run" 3 | autoupdate_commit_msg: "chore(pre-commit): autoupdate hooks" 4 | 5 | default_install_hook_types: 6 | - pre-commit 7 | - commit-msg 8 | 9 | repos: 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v6.0.0 12 | hooks: 13 | - id: trailing-whitespace 14 | - id: mixed-line-ending 15 | - id: end-of-file-fixer 16 | - id: requirements-txt-fixer 17 | - id: check-yaml 18 | - id: check-added-large-files 19 | 20 | - repo: https://github.com/psf/black-pre-commit-mirror 21 | rev: 25.11.0 22 | hooks: 23 | - id: black 24 | types: 25 | - python 26 | 27 | - repo: https://github.com/PyCQA/flake8 28 | rev: 7.3.0 29 | hooks: 30 | - id: flake8 31 | types: 32 | - python 33 | 34 | - repo: https://github.com/pycqa/bandit 35 | rev: 1.9.0 36 | hooks: 37 | - id: bandit 38 | args: ["-ll"] 39 | files: .py$ 40 | 41 | - repo: https://github.com/pre-commit/mirrors-prettier 42 | rev: v4.0.0-alpha.8 43 | hooks: 44 | - id: prettier 45 | types_or: [javascript] 46 | 47 | - repo: local 48 | hooks: 49 | - id: conventional-pre-commit 50 | name: Conventional Commit (local) 51 | entry: conventional-pre-commit 52 | language: python 53 | stages: [commit-msg] 54 | args: [--verbose] 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "main" 8 | workflow_call: 9 | 10 | jobs: 11 | tests: 12 | name: Tests 13 | runs-on: ubuntu-latest 14 | permissions: 15 | # Gives the action the necessary permissions for publishing new 16 | # comments in pull requests. 17 | pull-requests: write 18 | # Gives the action the necessary permissions for pushing data to the 19 | # python-coverage-comment-action branch, and for editing existing 20 | # comments (to avoid publishing multiple comments in the same PR) 21 | contents: write 22 | 23 | strategy: 24 | matrix: 25 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 26 | 27 | steps: 28 | - uses: actions/checkout@v5 29 | 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | cache: pip 35 | cache-dependency-path: "**/pyproject.toml" 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install -e .[dev] 41 | 42 | - name: Run tests 43 | run: ./tests/run.sh 44 | 45 | - name: Coverage comment 46 | uses: py-cov-action/python-coverage-comment-action@v3 47 | if: github.event_name != 'workflow_call' && matrix.python-version == '3.11' 48 | with: 49 | GITHUB_TOKEN: ${{ github.token }} 50 | MINIMUM_GREEN: 90 51 | MINIMUM_ORANGE: 80 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "conventional_pre_commit" 3 | dynamic = ["version"] 4 | description = "A pre-commit hook that checks commit messages for Conventional Commits formatting." 5 | readme = "README.md" 6 | license = { file = "LICENSE" } 7 | classifiers = ["Programming Language :: Python :: 3 :: Only"] 8 | keywords = ["git", "pre-commit", "conventional-commits"] 9 | authors = [ 10 | { name = "Compiler LLC", email = "dev@compiler.la" } 11 | ] 12 | requires-python = ">=3.8" 13 | dependencies = [] 14 | 15 | [project.urls] 16 | code = "https://github.com/compilerla/conventional-pre-commit" 17 | tracker = "https://github.com/compilerla/conventional-pre-commit/issues" 18 | 19 | [project.optional-dependencies] 20 | dev = [ 21 | "black", 22 | "build", 23 | "coverage", 24 | "flake8", 25 | "pre-commit", 26 | "pytest", 27 | "setuptools_scm", 28 | ] 29 | 30 | [project.scripts] 31 | conventional-pre-commit = "conventional_pre_commit.hook:main" 32 | 33 | [build-system] 34 | requires = ["setuptools>=65", "setuptools-scm>=8"] 35 | build-backend = "setuptools.build_meta" 36 | 37 | [tool.black] 38 | line-length = 127 39 | target-version = ['py311'] 40 | include = '\.pyi?$' 41 | 42 | [tool.coverage.run] 43 | branch = true 44 | relative_files = true 45 | source = ["conventional_pre_commit"] 46 | 47 | [tool.pyright] 48 | include = ["conventional_pre_commit", "tests"] 49 | 50 | [tool.pytest.ini_options] 51 | testpaths = ["tests"] 52 | norecursedirs = [ 53 | "*.egg-info", 54 | ".git", 55 | ".pytest_cache", 56 | ".vscode", 57 | ] 58 | 59 | [tool.setuptools] 60 | packages = ["conventional_pre_commit"] 61 | 62 | [tool.setuptools_scm] 63 | # intentionally left blank, but we need the section header to activate the tool 64 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import pytest 4 | 5 | TEST_DIR = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | 8 | def get_message_path(path): 9 | return os.path.join(TEST_DIR, "messages", path) 10 | 11 | 12 | @pytest.fixture 13 | def bad_commit_path(): 14 | return get_message_path("bad_commit") 15 | 16 | 17 | @pytest.fixture 18 | def conventional_commit_path(): 19 | return get_message_path("conventional_commit") 20 | 21 | 22 | @pytest.fixture 23 | def conventional_commit_with_scope_path(): 24 | return get_message_path("conventional_commit_with_scope") 25 | 26 | 27 | @pytest.fixture 28 | def custom_commit_path(): 29 | return get_message_path("custom_commit") 30 | 31 | 32 | @pytest.fixture 33 | def conventional_utf8_commit_path(): 34 | return get_message_path("conventional_commit_utf-8") 35 | 36 | 37 | @pytest.fixture 38 | def conventional_commit_with_dots_path(): 39 | return get_message_path("conventional_commit_with_dots") 40 | 41 | 42 | @pytest.fixture 43 | def conventional_gbk_commit_path(): 44 | return get_message_path("conventional_commit_gbk") 45 | 46 | 47 | @pytest.fixture 48 | def fixup_commit_path(): 49 | return get_message_path("fixup_commit") 50 | 51 | 52 | @pytest.fixture 53 | def merge_commit_path(): 54 | return get_message_path("merge_commit") 55 | 56 | 57 | @pytest.fixture 58 | def conventional_commit_bad_multi_line_path(): 59 | return get_message_path("conventional_commit_bad_multi_line") 60 | 61 | 62 | @pytest.fixture 63 | def conventional_commit_multi_line_path(): 64 | return get_message_path("conventional_commit_multi_line") 65 | 66 | 67 | @pytest.fixture 68 | def conventional_commit_with_multiple_scopes_path(): 69 | return get_message_path("conventional_commit_with_multiple_scopes") 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to GitHub and PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v[2-9].[0-9]+.[0-9]+" 8 | - "v[2-9].[0-9]+.[0-9]+-pre[0-9]?" 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/tests.yml 13 | 14 | release: 15 | runs-on: ubuntu-latest 16 | environment: release 17 | needs: test 18 | permissions: 19 | # https://github.com/softprops/action-gh-release#permissions 20 | contents: write 21 | # IMPORTANT: this permission is mandatory for trusted publishing 22 | id-token: write 23 | 24 | steps: 25 | - uses: actions/checkout@v5 26 | 27 | - uses: actions/setup-python@v6 28 | with: 29 | python-version: "3.11" 30 | cache: pip 31 | cache-dependency-path: "**/pyproject.toml" 32 | 33 | - name: Install build dependencies 34 | run: pip install build 35 | 36 | - name: Build package 37 | run: python -m build 38 | 39 | - name: Publish to Test PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | if: ${{ contains(github.ref, '-pre') }} 42 | with: 43 | repository-url: https://test.pypi.org/legacy/ 44 | print-hash: true 45 | skip-existing: true 46 | verbose: true 47 | 48 | - name: Publish to PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | if: ${{ !contains(github.ref, '-pre') }} 51 | with: 52 | print-hash: true 53 | 54 | - name: Release 55 | id: release 56 | uses: softprops/action-gh-release@v2 57 | with: 58 | files: | 59 | ./dist/*.whl 60 | ./dist/*.tar.gz 61 | prerelease: ${{ contains(github.ref, '-pre') }} 62 | generate_release_notes: ${{ !contains(github.ref, '-pre') }} 63 | -------------------------------------------------------------------------------- /conventional_pre_commit/hook.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from conventional_pre_commit import output 5 | from conventional_pre_commit.format import ConventionalCommit 6 | 7 | RESULT_SUCCESS = 0 8 | RESULT_FAIL = 1 9 | 10 | 11 | def main(argv=[]): 12 | parser = argparse.ArgumentParser( 13 | prog="conventional-pre-commit", description="Check a git commit message for Conventional Commits formatting." 14 | ) 15 | parser.add_argument( 16 | "types", type=str, nargs="*", default=ConventionalCommit.DEFAULT_TYPES, help="Optional list of types to support" 17 | ) 18 | parser.add_argument("input", type=str, help="A file containing a git commit message") 19 | parser.add_argument("--no-color", action="store_false", default=True, dest="color", help="Disable color in output.") 20 | parser.add_argument( 21 | "--force-scope", action="store_false", default=True, dest="optional_scope", help="Force commit to have scope defined." 22 | ) 23 | parser.add_argument( 24 | "--scopes", 25 | type=str, 26 | default=None, 27 | help="List of scopes to support. Scopes should be separated by commas with no spaces (e.g. api,client).", 28 | ) 29 | parser.add_argument( 30 | "--strict", 31 | action="store_true", 32 | help="Force commit to strictly follow Conventional Commits formatting. Disallows fixup! and merge commits.", 33 | ) 34 | parser.add_argument( 35 | "--verbose", 36 | action="store_true", 37 | dest="verbose", 38 | default=False, 39 | help="Print more verbose error output.", 40 | ) 41 | 42 | if len(argv) < 1: 43 | argv = sys.argv[1:] 44 | 45 | try: 46 | args = parser.parse_args(argv) 47 | except SystemExit: 48 | return RESULT_FAIL 49 | 50 | try: 51 | with open(args.input, encoding="utf-8") as f: 52 | commit_msg = f.read() 53 | except UnicodeDecodeError: 54 | print(output.unicode_decode_error(args.color)) 55 | return RESULT_FAIL 56 | if args.scopes: 57 | scopes = args.scopes.split(",") 58 | else: 59 | scopes = args.scopes 60 | 61 | commit = ConventionalCommit(commit_msg, args.types, args.optional_scope, scopes) 62 | 63 | if not args.strict: 64 | if commit.has_autosquash_prefix(): 65 | return RESULT_SUCCESS 66 | if commit.is_merge(): 67 | return RESULT_SUCCESS 68 | 69 | if commit.is_valid(): 70 | return RESULT_SUCCESS 71 | 72 | print(output.fail(commit, use_color=args.color)) 73 | 74 | if not args.verbose: 75 | print(output.verbose_arg(use_color=args.color)) 76 | else: 77 | print(output.fail_verbose(commit, use_color=args.color)) 78 | 79 | return RESULT_FAIL 80 | 81 | 82 | if __name__ == "__main__": 83 | raise SystemExit(main()) 84 | -------------------------------------------------------------------------------- /conventional_pre_commit/output.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from conventional_pre_commit.format import ConventionalCommit 4 | 5 | 6 | class Colors: 7 | LBLUE = "\033[00;34m" 8 | LRED = "\033[01;31m" 9 | RESTORE = "\033[0m" 10 | YELLOW = "\033[00;33m" 11 | 12 | def __init__(self, enabled=True): 13 | self.enabled = enabled 14 | 15 | @property 16 | def blue(self): 17 | return self.LBLUE if self.enabled else "" 18 | 19 | @property 20 | def red(self): 21 | return self.LRED if self.enabled else "" 22 | 23 | @property 24 | def restore(self): 25 | return self.RESTORE if self.enabled else "" 26 | 27 | @property 28 | def yellow(self): 29 | return self.YELLOW if self.enabled else "" 30 | 31 | 32 | def fail(commit: ConventionalCommit, use_color=True): 33 | c = Colors(use_color) 34 | lines = [ 35 | f"{c.red}[Bad commit message] >>{c.restore} {commit.message}" 36 | f"{c.yellow}Your commit message does not follow Conventional Commits formatting{c.restore}", 37 | f"{c.blue}https://www.conventionalcommits.org/{c.restore}", 38 | ] 39 | return os.linesep.join(lines) 40 | 41 | 42 | def verbose_arg(use_color=True): 43 | c = Colors(use_color) 44 | lines = [ 45 | "", 46 | f"{c.yellow}Use the {c.restore}--verbose{c.yellow} arg for more information{c.restore}", 47 | ] 48 | return os.linesep.join(lines) 49 | 50 | 51 | def fail_verbose(commit: ConventionalCommit, use_color=True): 52 | c = Colors(use_color) 53 | lines = [ 54 | "", 55 | f"{c.yellow}Conventional Commit messages follow a pattern like:", 56 | "", 57 | f"{c.restore} type(scope): subject", 58 | "", 59 | " extended body", 60 | "", 61 | ] 62 | 63 | def _options(opts): 64 | formatted_opts = f"{c.yellow}, {c.blue}".join(opts) 65 | return f"{c.blue}{formatted_opts}" 66 | 67 | errors = commit.errors() 68 | if errors: 69 | lines.append(f"{c.yellow}Please correct the following errors:{c.restore}") 70 | lines.append("") 71 | for group in errors: 72 | if group == "type": 73 | type_opts = _options(commit.types) 74 | lines.append(f"{c.yellow} - Expected value for {c.restore}type{c.yellow} from: {type_opts}") 75 | elif group == "scope": 76 | if commit.scopes: 77 | scopt_opts = _options(commit.scopes) 78 | lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} from: {scopt_opts}") 79 | else: 80 | lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} but found none.{c.restore}") 81 | else: 82 | lines.append(f"{c.yellow} - Expected value for {c.restore}{group}{c.yellow} but found none.{c.restore}") 83 | 84 | lines.extend( 85 | [ 86 | "", 87 | f"{c.yellow}Run:{c.restore}", 88 | "", 89 | " git commit --edit --file=.git/COMMIT_EDITMSG", 90 | "", 91 | f"{c.yellow}to edit the commit message and retry the commit.{c.restore}", 92 | ] 93 | ) 94 | return os.linesep.join(lines) 95 | 96 | 97 | def unicode_decode_error(use_color=True): 98 | c = Colors(use_color) 99 | return f""" 100 | {c.red}[Bad commit message encoding]{c.restore} 101 | 102 | {c.yellow}conventional-pre-commit couldn't decode your commit message. 103 | UTF-8 encoding is assumed, please configure git to write commit messages in UTF-8. 104 | See {c.blue}https://git-scm.com/docs/git-commit/#_discussion{c.yellow} for more.{c.restore} 105 | """ 106 | -------------------------------------------------------------------------------- /tests/test_output.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from conventional_pre_commit.format import ConventionalCommit 6 | from conventional_pre_commit.output import Colors, fail, fail_verbose, unicode_decode_error 7 | 8 | 9 | @pytest.fixture 10 | def commit(): 11 | return ConventionalCommit("commit msg") 12 | 13 | 14 | def test_colors(): 15 | colors = Colors() 16 | 17 | assert colors.blue == colors.LBLUE 18 | assert colors.red == colors.LRED 19 | assert colors.restore == colors.RESTORE 20 | assert colors.yellow == colors.YELLOW 21 | 22 | colors = Colors(enabled=False) 23 | 24 | assert colors.blue == "" 25 | assert colors.red == "" 26 | assert colors.restore == "" 27 | assert colors.yellow == "" 28 | 29 | 30 | def test_fail(commit): 31 | output = fail(commit) 32 | 33 | assert Colors.LRED in output 34 | assert Colors.YELLOW in output 35 | assert Colors.LBLUE in output 36 | assert Colors.RESTORE in output 37 | 38 | assert "Bad commit message" in output 39 | assert "commit msg" in output 40 | assert "Conventional Commits formatting" in output 41 | assert "https://www.conventionalcommits.org/" in output 42 | 43 | 44 | def test_fail__no_color(commit): 45 | output = fail(commit, use_color=False) 46 | 47 | assert Colors.LRED not in output 48 | assert Colors.YELLOW not in output 49 | assert Colors.LBLUE not in output 50 | assert Colors.RESTORE not in output 51 | 52 | 53 | def test_fail_verbose(commit): 54 | commit.scope_optional = False 55 | output = fail_verbose(commit) 56 | 57 | assert Colors.YELLOW in output 58 | assert Colors.RESTORE in output 59 | 60 | output = output.replace(Colors.YELLOW, Colors.RESTORE).replace(Colors.RESTORE, "") 61 | 62 | assert "Conventional Commit messages follow a pattern like" in output 63 | assert f"type(scope): subject{os.linesep}{os.linesep} extended body" in output 64 | assert "Expected value for type from:" in output 65 | for t in commit.types: 66 | assert t in output 67 | assert "Expected value for scope but found none." in output 68 | assert "git commit --edit --file=.git/COMMIT_EDITMSG" in output 69 | assert "edit the commit message and retry the commit" in output 70 | 71 | 72 | def test_fail_verbose__no_color(commit): 73 | output = fail_verbose(commit, use_color=False) 74 | 75 | assert Colors.LRED not in output 76 | assert Colors.YELLOW not in output 77 | assert Colors.LBLUE not in output 78 | assert Colors.RESTORE not in output 79 | 80 | 81 | def test_fail_verbose__optional_scope(commit): 82 | commit.scope_optional = True 83 | output = fail_verbose(commit, use_color=False) 84 | 85 | assert "Expected value for scope but found none." not in output 86 | 87 | 88 | def test_fail_verbose__missing_subject(): 89 | commit = ConventionalCommit("feat(scope):", scope_optional=False) 90 | output = fail_verbose(commit, use_color=False) 91 | 92 | assert "Expected value for subject but found none." in output 93 | assert "Expected value for type but found none." not in output 94 | assert "Expected value for scope but found none." not in output 95 | 96 | 97 | def test_fail_verbose__no_body_sep(): 98 | commit = ConventionalCommit( 99 | scope_optional=False, 100 | commit_msg="""feat(scope): subject 101 | body without blank line 102 | """, 103 | ) 104 | 105 | output = fail_verbose(commit, use_color=False) 106 | 107 | assert "Expected value for sep but found none." in output 108 | assert "Expected value for multi but found none." not in output 109 | 110 | assert "Expected value for subject but found none." not in output 111 | assert "Expected value for type but found none." not in output 112 | assert "Expected value for scope but found none." not in output 113 | 114 | 115 | def test_unicode_decode_error(): 116 | output = unicode_decode_error() 117 | 118 | assert Colors.LRED in output 119 | assert Colors.YELLOW in output 120 | assert Colors.LBLUE in output 121 | assert Colors.RESTORE in output 122 | 123 | assert "Bad commit message encoding" in output 124 | assert "UTF-8 encoding is assumed" in output 125 | assert "https://git-scm.com/docs/git-commit/#_discussion" in output 126 | 127 | 128 | def test_unicode_decode_error__no_color(): 129 | output = unicode_decode_error(use_color=False) 130 | 131 | assert Colors.LRED not in output 132 | assert Colors.YELLOW not in output 133 | assert Colors.LBLUE not in output 134 | assert Colors.RESTORE not in output 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # conventional-pre-commit 2 | 3 | A [`pre-commit`](https://pre-commit.com) hook to check commit messages for 4 | [Conventional Commits](https://conventionalcommits.org) formatting. 5 | 6 | Works with Python >= 3.8. 7 | 8 | ## Usage 9 | 10 | Make sure `pre-commit` is [installed](https://pre-commit.com#install). 11 | 12 | Create a blank configuration file at the root of your repo, if needed: 13 | 14 | ```console 15 | touch .pre-commit-config.yaml 16 | ``` 17 | 18 | Add/update `default_install_hook_types` and add a new repo entry in your configuration file: 19 | 20 | ```yaml 21 | default_install_hook_types: 22 | - pre-commit 23 | - commit-msg 24 | 25 | repos: 26 | # - repo: ... 27 | 28 | - repo: https://github.com/compilerla/conventional-pre-commit 29 | rev: 30 | hooks: 31 | - id: conventional-pre-commit 32 | stages: [commit-msg] 33 | args: [] 34 | ``` 35 | 36 | Install the `pre-commit` script: 37 | 38 | ```console 39 | pre-commit install --install-hooks 40 | ``` 41 | 42 | Make a (normal) commit :x:: 43 | 44 | ```console 45 | $ git commit -m "add a new feature" 46 | 47 | [INFO] Initializing environment for .... 48 | Conventional Commit......................................................Failed 49 | - hook id: conventional-pre-commit 50 | - duration: 0.07s 51 | - exit code: 1 52 | 53 | [Bad commit message] >> add a new feature 54 | Your commit message does not follow Conventional Commits formatting 55 | https://www.conventionalcommits.org/ 56 | ``` 57 | 58 | And with the `--verbose` arg: 59 | 60 | ```console 61 | $ git commit -m "add a new feature" 62 | 63 | [INFO] Initializing environment for .... 64 | Conventional Commit......................................................Failed 65 | - hook id: conventional-pre-commit 66 | - duration: 0.07s 67 | - exit code: 1 68 | 69 | [Bad commit message] >> add a new feature 70 | Your commit message does not follow Conventional Commits formatting 71 | https://www.conventionalcommits.org/ 72 | 73 | Conventional Commit messages follow a pattern like: 74 | 75 | type(scope): subject 76 | 77 | extended body 78 | 79 | Please correct the following errors: 80 | 81 | - Expected value for type from: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test 82 | 83 | Run: 84 | 85 | git commit --edit --file=.git/COMMIT_EDITMSG 86 | 87 | to edit the commit message and retry the commit. 88 | ``` 89 | 90 | Make a (conventional) commit :heavy_check_mark:: 91 | 92 | ```console 93 | $ git commit -m "feat: add a new feature" 94 | 95 | [INFO] Initializing environment for .... 96 | Conventional Commit......................................................Passed 97 | - hook id: conventional-pre-commit 98 | - duration: 0.05s 99 | ``` 100 | 101 | ## Install with pip 102 | 103 | `conventional-pre-commit` can also be installed and used from the command line: 104 | 105 | ```shell 106 | pip install conventional-pre-commit 107 | ``` 108 | 109 | Then run the command line script: 110 | 111 | ```shell 112 | conventional-pre-commit [types] input 113 | ``` 114 | 115 | - `[types]` is an optional list of Conventional Commit types to allow (e.g. `feat fix chore`) 116 | 117 | - `input` is a file containing the commit message to check: 118 | 119 | ```shell 120 | conventional-pre-commit feat fix chore ci test .git/COMMIT_MSG 121 | ``` 122 | 123 | Or from a Python program: 124 | 125 | ```python 126 | from conventional_pre_commit.format import is_conventional 127 | 128 | # prints True 129 | print(is_conventional("feat: this is a conventional commit")) 130 | 131 | # prints False 132 | print(is_conventional("nope: this is not a conventional commit")) 133 | 134 | # prints True 135 | print(is_conventional("custom: this is a conventional commit", types=["custom"])) 136 | ``` 137 | 138 | ## Passing `args` 139 | 140 | `conventional-pre-commit` supports a number of arguments to configure behavior: 141 | 142 | ```shell 143 | $ conventional-pre-commit -h 144 | usage: conventional-pre-commit [-h] [--no-color] [--force-scope] [--scopes SCOPES] [--strict] [--verbose] [types ...] input 145 | 146 | Check a git commit message for Conventional Commits formatting. 147 | 148 | positional arguments: 149 | types Optional list of types to support 150 | input A file containing a git commit message 151 | 152 | options: 153 | -h, --help show this help message and exit 154 | --no-color Disable color in output. 155 | --force-scope Force commit to have scope defined. 156 | --scopes SCOPES List of scopes to support. Scopes should be separated by commas with no spaces (e.g. api,client). 157 | --strict Force commit to strictly follow Conventional Commits formatting. Disallows fixup! and merge commits. 158 | --verbose Print more verbose error output. 159 | ``` 160 | 161 | Supply arguments on the command-line, or via the pre-commit `hooks.args` property: 162 | 163 | ```yaml 164 | repos: 165 | - repo: https://github.com/compilerla/conventional-pre-commit 166 | rev: 167 | hooks: 168 | - id: conventional-pre-commit 169 | stages: [commit-msg] 170 | args: [--strict, --force-scope, feat, fix, chore, test, custom] 171 | ``` 172 | 173 | **NOTE:** when using as a pre-commit hook, `input` is supplied automatically (with the current commit's message). 174 | 175 | ## Development 176 | 177 | `conventional-pre-commit` comes with a [VS Code devcontainer](https://code.visualstudio.com/learn/develop-cloud/containers) 178 | configuration to provide a consistent development environment. 179 | 180 | With the `Remote - Containers` extension enabled, open the folder containing this repository inside Visual Studio Code. 181 | 182 | You should receive a prompt in the Visual Studio Code window; click `Reopen in Container` to run the development environment 183 | inside the devcontainer. 184 | 185 | If you do not receive a prompt, or when you feel like starting from a fresh environment: 186 | 187 | 1. `Ctrl/Cmd+Shift+P` to bring up the command palette in Visual Studio Code 188 | 1. Type `Remote-Containers` to filter the commands 189 | 1. Select `Rebuild and Reopen in Container` to completely rebuild the devcontainer 190 | 1. Select `Reopen in Container` to reopen the most recent devcontainer build 191 | 192 | ## Versioning 193 | 194 | Versioning generally follows [Semantic Versioning](https://semver.org/). 195 | 196 | ## Making a release 197 | 198 | Releases to PyPI and GitHub are triggered by pushing a tag. 199 | 200 | 1. Ensure all changes for the release are present in the `main` branch 201 | 1. Tag with the new version: `git tag vX.Y.Z` for regular release, `git tag vX.Y.Z-preN` for pre-release 202 | 1. Push the new version tag: `git push origin vX.Y.Z` 203 | 204 | ## License 205 | 206 | [Apache 2.0](LICENSE) 207 | 208 | Inspired by matthorgan's [`pre-commit-conventional-commits`](https://github.com/matthorgan/pre-commit-conventional-commits). 209 | -------------------------------------------------------------------------------- /tests/test_hook.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import pytest 5 | 6 | from conventional_pre_commit.hook import RESULT_FAIL, RESULT_SUCCESS, main 7 | from conventional_pre_commit.output import Colors 8 | 9 | 10 | @pytest.fixture 11 | def cmd(): 12 | return "conventional-pre-commit" 13 | 14 | 15 | def test_main_fail__missing_args(): 16 | result = main() 17 | 18 | assert result == RESULT_FAIL 19 | 20 | 21 | def test_main_fail__bad(bad_commit_path): 22 | result = main([bad_commit_path]) 23 | 24 | assert result == RESULT_FAIL 25 | 26 | 27 | def test_main_fail__custom(bad_commit_path): 28 | result = main(["custom", bad_commit_path]) 29 | 30 | assert result == RESULT_FAIL 31 | 32 | 33 | def test_main_success__conventional(conventional_commit_path): 34 | result = main([conventional_commit_path]) 35 | 36 | assert result == RESULT_SUCCESS 37 | 38 | 39 | def test_main_success__custom(custom_commit_path): 40 | result = main(["custom", custom_commit_path]) 41 | 42 | assert result == RESULT_SUCCESS 43 | 44 | 45 | def test_main_success__custom_conventional(conventional_commit_path): 46 | result = main(["custom", conventional_commit_path]) 47 | 48 | assert result == RESULT_SUCCESS 49 | 50 | 51 | def test_main_success__conventional_utf8(conventional_utf8_commit_path): 52 | result = main([conventional_utf8_commit_path]) 53 | 54 | assert result == RESULT_SUCCESS 55 | 56 | 57 | def test_main_success__conventional_commit_with_dots_path(conventional_commit_with_dots_path): 58 | result = main([conventional_commit_with_dots_path]) 59 | 60 | assert result == RESULT_SUCCESS 61 | 62 | 63 | def test_main_fail__conventional_gbk(conventional_gbk_commit_path): 64 | result = main([conventional_gbk_commit_path]) 65 | 66 | assert result == RESULT_FAIL 67 | 68 | 69 | def test_main_fail__conventional_with_scope(conventional_commit_path): 70 | result = main(["--force-scope", conventional_commit_path]) 71 | 72 | assert result == RESULT_FAIL 73 | 74 | 75 | def test_main_success__conventional_with_scope(cmd, conventional_commit_with_scope_path): 76 | result = main(["--force-scope", conventional_commit_with_scope_path]) 77 | 78 | assert result == RESULT_SUCCESS 79 | 80 | 81 | def test_main_success__fixup_commit(fixup_commit_path): 82 | result = main([fixup_commit_path]) 83 | 84 | assert result == RESULT_SUCCESS 85 | 86 | 87 | def test_main_fail__fixup_commit(fixup_commit_path): 88 | result = main(["--strict", fixup_commit_path]) 89 | 90 | assert result == RESULT_FAIL 91 | 92 | 93 | def test_main_fail__merge_commit(merge_commit_path): 94 | result = main(["--strict", merge_commit_path]) 95 | 96 | assert result == RESULT_FAIL 97 | 98 | 99 | def test_main_success__merge_commit(merge_commit_path): 100 | result = main([merge_commit_path]) 101 | 102 | assert result == RESULT_SUCCESS 103 | 104 | 105 | def test_main_success__conventional_commit_multi_line(conventional_commit_multi_line_path): 106 | result = main([conventional_commit_multi_line_path]) 107 | 108 | assert result == RESULT_SUCCESS 109 | 110 | 111 | def test_main_fail__conventional_commit_bad_multi_line(conventional_commit_bad_multi_line_path): 112 | result = main([conventional_commit_bad_multi_line_path]) 113 | 114 | assert result == RESULT_FAIL 115 | 116 | 117 | def test_main_fail__verbose(bad_commit_path, capsys): 118 | result = main(["--verbose", "--force-scope", bad_commit_path]) 119 | 120 | assert result == RESULT_FAIL 121 | 122 | captured = capsys.readouterr() 123 | output = captured.out 124 | 125 | assert Colors.LBLUE in output 126 | assert Colors.LRED in output 127 | assert Colors.RESTORE in output 128 | assert Colors.YELLOW in output 129 | assert "Conventional Commit messages follow a pattern like" in output 130 | assert f"type(scope): subject{os.linesep}{os.linesep} extended body" in output 131 | assert "git commit --edit --file=.git/COMMIT_EDITMSG" in output 132 | assert "edit the commit message and retry the commit" in output 133 | 134 | 135 | def test_main_fail__no_color(bad_commit_path, capsys): 136 | result = main(["--verbose", "--no-color", bad_commit_path]) 137 | 138 | assert result == RESULT_FAIL 139 | 140 | captured = capsys.readouterr() 141 | output = captured.out 142 | 143 | assert Colors.LBLUE not in output 144 | assert Colors.LRED not in output 145 | assert Colors.RESTORE not in output 146 | assert Colors.YELLOW not in output 147 | 148 | 149 | def test_subprocess_fail__missing_args(cmd): 150 | result = subprocess.call(cmd) 151 | 152 | assert result == RESULT_FAIL 153 | 154 | 155 | def test_subprocess_fail__bad(cmd, bad_commit_path): 156 | result = subprocess.call((cmd, bad_commit_path)) 157 | 158 | assert result == RESULT_FAIL 159 | 160 | 161 | def test_subprocess_fail__custom(cmd, bad_commit_path): 162 | result = subprocess.call((cmd, "custom", bad_commit_path)) 163 | 164 | assert result == RESULT_FAIL 165 | 166 | 167 | def test_subprocess_success__conventional(cmd, conventional_commit_path): 168 | result = subprocess.call((cmd, conventional_commit_path)) 169 | 170 | assert result == RESULT_SUCCESS 171 | 172 | 173 | def test_subprocess_success__custom(cmd, custom_commit_path): 174 | result = subprocess.call((cmd, "custom", custom_commit_path)) 175 | 176 | assert result == RESULT_SUCCESS 177 | 178 | 179 | def test_subprocess_success__custom_conventional(cmd, conventional_commit_path): 180 | result = subprocess.call((cmd, "custom", conventional_commit_path)) 181 | 182 | assert result == RESULT_SUCCESS 183 | 184 | 185 | def test_subprocess_fail__conventional_with_scope(cmd, conventional_commit_path): 186 | result = subprocess.call((cmd, "--force-scope", conventional_commit_path)) 187 | 188 | assert result == RESULT_FAIL 189 | 190 | 191 | def test_subprocess_success__conventional_with_scope(cmd, conventional_commit_with_scope_path): 192 | result = subprocess.call((cmd, "--force-scope", conventional_commit_with_scope_path)) 193 | 194 | assert result == RESULT_SUCCESS 195 | 196 | 197 | def test_subprocess_success__conventional_with_multiple_scopes(cmd, conventional_commit_with_multiple_scopes_path): 198 | result = subprocess.call((cmd, "--scopes", "api,client", conventional_commit_with_multiple_scopes_path)) 199 | assert result == RESULT_SUCCESS 200 | 201 | 202 | def test_subprocess_fail__conventional_with_multiple_scopes(cmd, conventional_commit_with_multiple_scopes_path): 203 | result = subprocess.call((cmd, "--scopes", "api", conventional_commit_with_multiple_scopes_path)) 204 | assert result == RESULT_FAIL 205 | 206 | 207 | def test_main_success__custom_scopes_optional_scope(conventional_commit_path): 208 | result = main(["--scopes", "api,client", conventional_commit_path]) 209 | assert result == RESULT_SUCCESS 210 | 211 | 212 | def test_main_success__custom_scopes_with_allowed_scope(conventional_commit_with_multiple_scopes_path): 213 | result = main(["--scopes", "chore,api,client", conventional_commit_with_multiple_scopes_path]) 214 | assert result == RESULT_SUCCESS 215 | 216 | 217 | def test_main_fail__custom_scopes_with_disallowed_scope(conventional_commit_with_scope_path): 218 | result = main(["--scopes", "api,client", conventional_commit_with_scope_path]) 219 | assert result == RESULT_FAIL 220 | 221 | 222 | def test_main_fail__custom_scopes_require_scope_no_scope(conventional_commit_path): 223 | result = main(["--scopes", "chore,feat,fix,custom", "--force-scope", conventional_commit_path]) 224 | assert result == RESULT_FAIL 225 | 226 | 227 | def test_main_success__custom_scopes_require_scope_with_allowed_scope(conventional_commit_with_scope_path): 228 | result = main(["--scopes", "api,client,scope", "--force-scope", conventional_commit_with_scope_path]) 229 | assert result == RESULT_SUCCESS 230 | 231 | 232 | def test_main_fail__custom_scopes_require_scope_with_disallowed_scope(conventional_commit_with_scope_path): 233 | result = main(["--scopes", "api,client", "--force-scope", conventional_commit_with_scope_path]) 234 | assert result == RESULT_FAIL 235 | 236 | 237 | def test_subprocess_success__fixup_commit(cmd, fixup_commit_path): 238 | result = subprocess.call((cmd, fixup_commit_path)) 239 | 240 | assert result == RESULT_SUCCESS 241 | 242 | 243 | def test_subprocess_fail__fixup_commit(cmd, fixup_commit_path): 244 | result = subprocess.call((cmd, "--strict", fixup_commit_path)) 245 | 246 | assert result == RESULT_FAIL 247 | 248 | 249 | def test_subprocess_success__conventional_commit_multi_line(cmd, conventional_commit_multi_line_path): 250 | result = subprocess.call((cmd, conventional_commit_multi_line_path)) 251 | 252 | assert result == RESULT_SUCCESS 253 | 254 | 255 | def test_subprocess_fail__conventional_commit_bad_multi_line(cmd, conventional_commit_bad_multi_line_path): 256 | result = subprocess.call((cmd, conventional_commit_bad_multi_line_path)) 257 | 258 | assert result == RESULT_FAIL 259 | -------------------------------------------------------------------------------- /conventional_pre_commit/format.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | 5 | class Commit: 6 | """ 7 | Base class for inspecting commit message formatting. 8 | """ 9 | 10 | AUTOSQUASH_PREFIXES = sorted( 11 | [ 12 | "amend", 13 | "fixup", 14 | "squash", 15 | ] 16 | ) 17 | 18 | def __init__(self, commit_msg: str = ""): 19 | self.message = str(commit_msg) 20 | self.message = self.clean() 21 | 22 | @property 23 | def r_autosquash_prefixes(self): 24 | """Regex str for autosquash prefixes.""" 25 | return self._r_or(self.AUTOSQUASH_PREFIXES) 26 | 27 | @property 28 | def r_verbose_commit_ignored(self): 29 | """Regex str for the ignored part of a verbose commit message.""" 30 | return r"^# -{24} >8 -{24}\r?\n.*\Z" 31 | 32 | @property 33 | def r_comment(self): 34 | """Regex str for comments.""" 35 | return r"^#.*\r?\n?" 36 | 37 | def _r_or(self, items): 38 | """Join items with pipe "|" to form regex ORs.""" 39 | return "|".join(items) 40 | 41 | def _strip_comments(self, commit_msg: str = ""): 42 | """Strip comments from a commit message.""" 43 | commit_msg = commit_msg or self.message 44 | return re.sub(self.r_comment, "", commit_msg, flags=re.MULTILINE) 45 | 46 | def _strip_verbose_commit_ignored(self, commit_msg: str = ""): 47 | """Strip the ignored part of a verbose commit message.""" 48 | commit_msg = commit_msg or self.message 49 | return re.sub(self.r_verbose_commit_ignored, "", commit_msg, flags=re.DOTALL | re.MULTILINE) 50 | 51 | def clean(self, commit_msg: str = ""): 52 | """ 53 | Removes comments and ignored verbose commit segments from a commit message. 54 | """ 55 | commit_msg = commit_msg or self.message 56 | commit_msg = self._strip_verbose_commit_ignored(commit_msg) 57 | commit_msg = self._strip_comments(commit_msg) 58 | return commit_msg 59 | 60 | def has_autosquash_prefix(self, commit_msg: str = ""): 61 | """ 62 | Returns True if input starts with one of the autosquash prefixes used in git. 63 | See the documentation, please https://git-scm.com/docs/git-rebase. 64 | """ 65 | commit_msg = self.clean(commit_msg) 66 | pattern = f"^(({self.r_autosquash_prefixes})! ).*$" 67 | regex = re.compile(pattern, re.DOTALL) 68 | 69 | return bool(regex.match(commit_msg)) 70 | 71 | def is_merge(self, commit_msg: str = ""): 72 | """ 73 | Returns True if the commit message indicates a merge commit. 74 | Matches messages that start with "Merge", including: 75 | - Merge branch ... 76 | - Merge pull request ... 77 | - Merge remote-tracking branch ... 78 | - Merge tag ... 79 | See https://git-scm.com/docs/git-merge. 80 | """ 81 | commit_msg = self.clean(commit_msg) 82 | return bool(re.match(r"^merge\b", commit_msg.lower())) 83 | 84 | 85 | class ConventionalCommit(Commit): 86 | """ 87 | Impelements checks for Conventional Commits formatting. 88 | 89 | https://www.conventionalcommits.org 90 | """ 91 | 92 | CONVENTIONAL_TYPES = sorted(["feat", "fix"]) 93 | DEFAULT_TYPES = sorted( 94 | CONVENTIONAL_TYPES 95 | + [ 96 | "build", 97 | "chore", 98 | "ci", 99 | "docs", 100 | "perf", 101 | "refactor", 102 | "revert", 103 | "style", 104 | "test", 105 | ] 106 | ) 107 | 108 | def __init__( 109 | self, commit_msg: str = "", types: List[str] = DEFAULT_TYPES, scope_optional: bool = True, scopes: List[str] = [] 110 | ): 111 | super().__init__(commit_msg) 112 | 113 | if set(types) & set(self.CONVENTIONAL_TYPES) == set(): 114 | self.types = self.CONVENTIONAL_TYPES + types 115 | else: 116 | self.types = types 117 | self.types = sorted(self.types) if self.types else self.DEFAULT_TYPES 118 | self.scope_optional = scope_optional 119 | self.scopes = sorted(scopes) if scopes else [] 120 | 121 | @property 122 | def r_types(self): 123 | """Regex str for valid types.""" 124 | return f"(?i:{self._r_or(self.types)})" 125 | 126 | @property 127 | def r_scope(self): 128 | """Regex str for an optional (scope).""" 129 | escaped_delimiters = list(map(re.escape, [":", ",", "-", "/", "."])) # type: ignore 130 | if self.scopes: 131 | scopes = self._r_or(self.scopes) 132 | delimiters_pattern = self._r_or(escaped_delimiters) 133 | scope_pattern = rf"\(\s*(?:(?i:{scopes}))(?:\s*(?:{delimiters_pattern})\s*(?:(?i:{scopes})))*\s*\)" 134 | 135 | if self.scope_optional: 136 | return f"(?:{scope_pattern})?" 137 | else: 138 | return scope_pattern 139 | 140 | joined_delimiters = "".join(escaped_delimiters) 141 | if self.scope_optional: 142 | return rf"(\([\w {joined_delimiters}]+\))?" 143 | else: 144 | return rf"(\([\w {joined_delimiters}]+\))" 145 | 146 | @property 147 | def r_delim(self): 148 | """Regex str for optional breaking change indicator and colon delimiter.""" 149 | return r"!?:" 150 | 151 | @property 152 | def r_subject(self): 153 | """Regex str for subject line.""" 154 | return r" .+$" 155 | 156 | @property 157 | def r_body(self): 158 | """Regex str for the body, with multiline support.""" 159 | return r"(?P\r?\n(?P^$\r?\n)?.+)?" 160 | 161 | @property 162 | def regex(self): 163 | """`re.Pattern` for ConventionalCommits formatting.""" 164 | types_pattern = f"^(?P{self.r_types})?" 165 | scope_pattern = f"(?P{self.r_scope})?" 166 | delim_pattern = f"(?P{self.r_delim})?" 167 | subject_pattern = f"(?P{self.r_subject})?" 168 | body_pattern = f"(?P{self.r_body})?" 169 | pattern = types_pattern + scope_pattern + delim_pattern + subject_pattern + body_pattern 170 | 171 | return re.compile(pattern, re.MULTILINE) 172 | 173 | def errors(self, commit_msg: str = "") -> List[str]: 174 | """ 175 | Return a list of missing Conventional Commit components from a commit message. 176 | """ 177 | match = self.match(commit_msg) 178 | groups = match.groupdict() if match else {} 179 | 180 | # With a type error, the rest of the components will be unmatched 181 | # even if the overall structure of the commit is correct, 182 | # since a correct type must come first. 183 | # 184 | # E.g. with an invalid type: 185 | # 186 | # invalid: this is a commit 187 | # 188 | # The delim, subject, and body components would all be missing from the match 189 | # there's no need to notify on the other components when the type is invalid 190 | if not groups.get("type"): 191 | groups.pop("delim", None) 192 | groups.pop("subject", None) 193 | groups.pop("body", None) 194 | 195 | if self.scope_optional: 196 | groups.pop("scope", None) 197 | 198 | if not groups.get("body"): 199 | groups.pop("multi", None) 200 | groups.pop("sep", None) 201 | 202 | return [g for g, v in groups.items() if not v] 203 | 204 | def is_valid(self, commit_msg: str = "") -> bool: 205 | """ 206 | Returns True if commit_msg matches Conventional Commits formatting. 207 | https://www.conventionalcommits.org 208 | """ 209 | match = self.match(commit_msg) 210 | 211 | # match all the required components 212 | # 213 | # type(scope): subject 214 | # 215 | # extended body 216 | # 217 | return bool(match) and all( 218 | [ 219 | match.group("type"), 220 | self.scope_optional or match.group("scope"), 221 | match.group("delim"), 222 | match.group("subject"), 223 | any( 224 | [ 225 | # no extra body; OR 226 | not match.group("body"), 227 | # a multiline body with proper separator 228 | match.group("multi") and match.group("sep"), 229 | ] 230 | ), 231 | ] 232 | ) 233 | 234 | def match(self, commit_msg: str = ""): 235 | """ 236 | Returns an `re.Match` object for the input against the Conventional Commits format. 237 | """ 238 | commit_msg = self.clean(commit_msg) or self.message 239 | return self.regex.match(commit_msg) 240 | 241 | 242 | def is_conventional( 243 | input: str, types: List[str] = ConventionalCommit.DEFAULT_TYPES, optional_scope: bool = True, scopes: List[str] = [] 244 | ) -> bool: 245 | """ 246 | Returns True if input matches Conventional Commits formatting 247 | https://www.conventionalcommits.org 248 | 249 | Optionally provide a list of additional custom types. 250 | """ 251 | commit = ConventionalCommit(commit_msg=input, types=types, scope_optional=optional_scope, scopes=scopes) 252 | 253 | return commit.is_valid() 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/test_format.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from conventional_pre_commit.format import Commit, ConventionalCommit, is_conventional 6 | 7 | CUSTOM_TYPES = ["one", "two"] 8 | 9 | 10 | @pytest.fixture 11 | def commit() -> Commit: 12 | return Commit() 13 | 14 | 15 | @pytest.fixture 16 | def conventional_commit() -> ConventionalCommit: 17 | return ConventionalCommit() 18 | 19 | 20 | @pytest.fixture 21 | def conventional_commit_scope_required(conventional_commit) -> ConventionalCommit: 22 | conventional_commit.scope_optional = False 23 | return conventional_commit 24 | 25 | 26 | def test_commit_init(): 27 | input = ( 28 | """feat: some commit message 29 | # Please enter the commit message for your changes. Lines starting 30 | # with '#' will be ignored, and an empty message aborts the commit. 31 | # 32 | # On branch main 33 | # Your branch is up to date with 'origin/main'. 34 | # 35 | # Changes to be committed: 36 | # modified: README.md 37 | # 38 | # Changes not staged for commit: 39 | # modified: README.md 40 | # 41 | # ------------------------ >8 ------------------------ 42 | # Do not modify or remove the line above. 43 | # Everything below it will be ignored. 44 | diff --git c/README.md i/README.md 45 | index ea80a93..fe8a527 100644 46 | --- c/README.md 47 | +++ i/README.md 48 | @@ -20,3 +20,4 @@ Some hunk header 49 | Context 1 50 | """ 51 | + " " # This is on purpose to preserve the space from overly eager stripping. 52 | + """ 53 | Context 2 54 | +Added line 55 | """ 56 | ) 57 | 58 | expected = "feat: some commit message\n" 59 | 60 | assert Commit(input).message == expected 61 | 62 | 63 | def test_r_or(commit): 64 | result = commit._r_or(CUSTOM_TYPES) 65 | regex = re.compile(result) 66 | 67 | for item in CUSTOM_TYPES: 68 | assert regex.match(item) 69 | 70 | 71 | def test_r_autosquash_prefixes(commit): 72 | regex = re.compile(commit.r_autosquash_prefixes) 73 | 74 | for prefix in commit.AUTOSQUASH_PREFIXES: 75 | assert regex.match(prefix) 76 | 77 | 78 | def test_r_comment_single(commit): 79 | regex = re.compile(commit.r_comment) 80 | assert regex.match("# Some comment") 81 | assert not regex.match("Some comment") 82 | assert not regex.match(" # Some comment") 83 | 84 | 85 | def test_strip_comments__consecutive(commit): 86 | input = """feat(scope): message 87 | # Please enter the commit message for your changes. 88 | # These are comments usually added by editors, f.ex. with export EDITOR=vim 89 | """ 90 | result = commit._strip_comments(input) 91 | assert result.count("\n") == 1 92 | assert result.strip() == "feat(scope): message" 93 | 94 | 95 | def test_strip_comments__spaced(commit): 96 | input = """feat(scope): message 97 | # Please enter the commit message for your changes. 98 | 99 | # These are comments usually added by editors, f.ex. with export EDITOR=vim 100 | """ 101 | result = commit._strip_comments(input) 102 | assert result.count("\n") == 2 103 | assert result.strip() == "feat(scope): message" 104 | 105 | 106 | def test_r_verbose_commit_ignored__does_not_match_no_verbose(commit): 107 | regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) 108 | input = """feat: some commit message 109 | # Please enter the commit message for your changes. Lines starting 110 | # with '#' will be ignored, and an empty message aborts the commit. 111 | # 112 | # On branch main 113 | # Your branch is up to date with 'origin/main'. 114 | # 115 | # Changes to be committed: 116 | # modified: README.md 117 | # 118 | # Changes not staged for commit: 119 | # modified: README.md 120 | # 121 | """ 122 | 123 | assert not regex.search(input) 124 | 125 | 126 | def test_r_verbose_commit_ignored__matches_single_verbose_ignored(commit): 127 | regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) 128 | input = ( 129 | """feat: some commit message 130 | # Please enter the commit message for your changes. Lines starting 131 | # with '#' will be ignored, and an empty message aborts the commit. 132 | # 133 | # On branch main 134 | # Your branch is up to date with 'origin/main'. 135 | # 136 | # Changes to be committed: 137 | # modified: README.md 138 | # 139 | # Changes not staged for commit: 140 | # modified: README.md 141 | # 142 | # ------------------------ >8 ------------------------ 143 | # Do not modify or remove the line above. 144 | # Everything below it will be ignored. 145 | diff --git c/README.md i/README.md 146 | index ea80a93..fe8a527 100644 147 | --- c/README.md 148 | +++ i/README.md 149 | @@ -20,3 +20,4 @@ Some hunk header 150 | Context 1 151 | """ 152 | + " " # This is on purpose to preserve the space from overly eager stripping. 153 | + """ 154 | Context 2 155 | +Added line 156 | """ 157 | ) 158 | 159 | assert regex.search(input) 160 | 161 | 162 | def test_r_verbose_commit_ignored__matches_double_verbose_ignored(commit): 163 | regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) 164 | input = ( 165 | """feat: some commit message 166 | # Please enter the commit message for your changes. Lines starting 167 | # with '#' will be ignored, and an empty message aborts the commit. 168 | # 169 | # On branch main 170 | # Your branch is up to date with 'origin/main'. 171 | # 172 | # Changes to be committed: 173 | # modified: README.md 174 | # 175 | # Changes not staged for commit: 176 | # modified: README.md 177 | # 178 | # ------------------------ >8 ------------------------ 179 | # Do not modify or remove the line above. 180 | # Everything below it will be ignored. 181 | # 182 | # Changes to be committed: 183 | diff --git c/README.md i/README.md 184 | index ea80a93..fe8a527 100644 185 | --- c/README.md 186 | +++ i/README.md 187 | @@ -20,3 +20,4 @@ Some staged hunk header 188 | Staged Context 1 189 | """ 190 | + " " # This is on purpose to preserve the space from overly eager stripping. 191 | + """ 192 | Staged Context 2 193 | +Staged added line 194 | # -------------------------------------------------- 195 | # Changes not staged for commit: 196 | diff --git i/README.md w/README.md 197 | index fe8a527..1c00c14 100644 198 | --- i/README.md 199 | +++ w/README.md 200 | @@ -10,6 +10,7 @@ Some unstaged hunk header 201 | Context 1 202 | Context 2 203 | Context 3 204 | -Removed line 205 | +Added line 206 | """ 207 | + " " # This is on purpose to preserve the space from overly eager stripping. 208 | + """ 209 | Context 4 210 | """ 211 | + " " # This is on purpose to preserve the space from overly eager stripping. 212 | + """ 213 | """ 214 | ) 215 | 216 | assert regex.search(input) 217 | 218 | 219 | def test_strip_verbose_commit_ignored__does_not_strip_no_verbose(commit): 220 | input = """feat: some commit message 221 | # Please enter the commit message for your changes. Lines starting 222 | # with '#' will be ignored, and an empty message aborts the commit. 223 | # 224 | # On branch main 225 | # Your branch is up to date with 'origin/main'. 226 | # 227 | # Changes to be committed: 228 | # modified: README.md 229 | # 230 | # Changes not staged for commit: 231 | # modified: README.md 232 | # 233 | """ 234 | 235 | expected = """feat: some commit message 236 | # Please enter the commit message for your changes. Lines starting 237 | # with '#' will be ignored, and an empty message aborts the commit. 238 | # 239 | # On branch main 240 | # Your branch is up to date with 'origin/main'. 241 | # 242 | # Changes to be committed: 243 | # modified: README.md 244 | # 245 | # Changes not staged for commit: 246 | # modified: README.md 247 | # 248 | """ 249 | 250 | result = commit._strip_verbose_commit_ignored(input) 251 | assert result == expected 252 | 253 | 254 | def test_strip_verbose_commit_ignored__strips_single_verbose_ignored(commit): 255 | input = ( 256 | """feat: some commit message 257 | # Please enter the commit message for your changes. Lines starting 258 | # with '#' will be ignored, and an empty message aborts the commit. 259 | # 260 | # On branch main 261 | # Your branch is up to date with 'origin/main'. 262 | # 263 | # Changes to be committed: 264 | # modified: README.md 265 | # 266 | # Changes not staged for commit: 267 | # modified: README.md 268 | # 269 | # ------------------------ >8 ------------------------ 270 | # Do not modify or remove the line above. 271 | # Everything below it will be ignored. 272 | diff --git c/README.md i/README.md 273 | index ea80a93..fe8a527 100644 274 | --- c/README.md 275 | +++ i/README.md 276 | @@ -20,3 +20,4 @@ Some hunk header 277 | Context 1 278 | """ 279 | + " " # This is on purpose to preserve the space from overly eager stripping. 280 | + """ 281 | Context 2 282 | +Added line 283 | """ 284 | ) 285 | 286 | expected = """feat: some commit message 287 | # Please enter the commit message for your changes. Lines starting 288 | # with '#' will be ignored, and an empty message aborts the commit. 289 | # 290 | # On branch main 291 | # Your branch is up to date with 'origin/main'. 292 | # 293 | # Changes to be committed: 294 | # modified: README.md 295 | # 296 | # Changes not staged for commit: 297 | # modified: README.md 298 | # 299 | """ 300 | 301 | result = commit._strip_verbose_commit_ignored(input) 302 | assert result == expected 303 | 304 | 305 | def test_strip_verbose_commit_ignored__strips_double_verbose_ignored(commit): 306 | input = ( 307 | """feat: some commit message 308 | # Please enter the commit message for your changes. Lines starting 309 | # with '#' will be ignored, and an empty message aborts the commit. 310 | # 311 | # On branch main 312 | # Your branch is up to date with 'origin/main'. 313 | # 314 | # Changes to be committed: 315 | # modified: README.md 316 | # 317 | # Changes not staged for commit: 318 | # modified: README.md 319 | # 320 | # ------------------------ >8 ------------------------ 321 | # Do not modify or remove the line above. 322 | # Everything below it will be ignored. 323 | # 324 | # Changes to be committed: 325 | diff --git c/README.md i/README.md 326 | index ea80a93..fe8a527 100644 327 | --- c/README.md 328 | +++ i/README.md 329 | @@ -20,3 +20,4 @@ Some staged hunk header 330 | Staged Context 1 331 | """ 332 | + " " # This is on purpose to preserve the space from overly eager stripping. 333 | + """ 334 | Staged Context 2 335 | +Staged added line 336 | # -------------------------------------------------- 337 | # Changes not staged for commit: 338 | diff --git i/README.md w/README.md 339 | index fe8a527..1c00c14 100644 340 | --- i/README.md 341 | +++ w/README.md 342 | @@ -10,6 +10,7 @@ Some unstaged hunk header 343 | Context 1 344 | Context 2 345 | Context 3 346 | -Removed line 347 | +Added line 348 | """ 349 | + " " # This is on purpose to preserve the space from overly eager stripping. 350 | + """ 351 | Context 4 352 | """ 353 | + " " # This is on purpose to preserve the space from overly eager stripping. 354 | + """ 355 | """ 356 | ) 357 | 358 | expected = """feat: some commit message 359 | # Please enter the commit message for your changes. Lines starting 360 | # with '#' will be ignored, and an empty message aborts the commit. 361 | # 362 | # On branch main 363 | # Your branch is up to date with 'origin/main'. 364 | # 365 | # Changes to be committed: 366 | # modified: README.md 367 | # 368 | # Changes not staged for commit: 369 | # modified: README.md 370 | # 371 | """ 372 | 373 | result = commit._strip_verbose_commit_ignored(input) 374 | assert result == expected 375 | 376 | 377 | @pytest.mark.parametrize( 378 | "input,expected_result", 379 | [ 380 | ("amend! ", True), 381 | ("fixup! ", True), 382 | ("squash! ", True), 383 | ("squash! whatever .. $12 #", True), 384 | ("squash!", False), 385 | (" squash! ", False), 386 | ("squash!:", False), 387 | ("feat(foo):", False), 388 | ], 389 | ) 390 | def test_has_autosquash_prefix(commit, input, expected_result): 391 | assert commit.has_autosquash_prefix(input) is expected_result 392 | assert Commit(input).has_autosquash_prefix() is expected_result 393 | 394 | 395 | @pytest.mark.parametrize( 396 | "input,expected_result", 397 | [ 398 | ("Merge branch '2.x.x' into '1.x.x'", True), 399 | ("merge branch 'dev' into 'main'", True), 400 | ("Merge remote-tracking branch 'origin/master'", True), 401 | ("Merge pull request #123 from user/feature-branch", True), 402 | ("Merge tag 'v1.2.3' into main", True), 403 | ("Merge origin/master into develop", True), 404 | ("Merge refs/heads/main into develop", True), 405 | ("nope not a merge commit", False), 406 | ("type: subject", False), 407 | ("fix: merge bug in auth logic", False), 408 | ("chore: merged upstream changes", False), 409 | ("MergeSort implemented and tested", False), 410 | ], 411 | ) 412 | def test_is_merge_commit(input, expected_result): 413 | commit = Commit(input) 414 | assert commit.is_merge() is expected_result 415 | 416 | 417 | def test_r_scope__optional(conventional_commit): 418 | regex = re.compile(conventional_commit.r_scope) 419 | 420 | assert regex.match("") 421 | 422 | 423 | def test_r_scope__not_optional(conventional_commit_scope_required): 424 | regex = re.compile(conventional_commit_scope_required.r_scope) 425 | 426 | assert not regex.match("") 427 | assert not regex.match("scope") 428 | assert regex.match("(scope)") 429 | 430 | 431 | def test_r_scope__alphanumeric(conventional_commit_scope_required): 432 | regex = re.compile(conventional_commit_scope_required.r_scope) 433 | 434 | assert regex.match("(50m3t41N6)") 435 | 436 | 437 | def test_r_scope__special_chars(conventional_commit_scope_required): 438 | regex = re.compile(conventional_commit_scope_required.r_scope) 439 | 440 | assert regex.match("(some-thing)") 441 | assert regex.match("(some_thing)") 442 | assert regex.match("(some/thing)") 443 | assert regex.match("(some thing)") 444 | assert regex.match("(some:thing)") 445 | assert regex.match("(some,thing)") 446 | assert regex.match("(some.thing)") 447 | 448 | 449 | def test_r_scope__scopes(conventional_commit_scope_required): 450 | conventional_commit_scope_required.scopes = ["api", "client"] 451 | regex = re.compile(conventional_commit_scope_required.r_scope) 452 | 453 | assert regex.match("(api)") 454 | assert regex.match("(client)") 455 | assert regex.match("(api, client)") 456 | assert regex.match("(api: client)") 457 | assert regex.match("(api/client)") 458 | assert regex.match("(api-client)") 459 | assert regex.match("(api.client)") 460 | assert not regex.match("(test)") 461 | assert not regex.match("(api; client)") 462 | 463 | 464 | def test_r_scope__scopes_uppercase(conventional_commit_scope_required): 465 | conventional_commit_scope_required.scopes = ["api", "client"] 466 | regex = re.compile(conventional_commit_scope_required.r_scope) 467 | 468 | assert regex.match("(API)") 469 | assert regex.match("(CLIENT)") 470 | assert regex.match("(API, CLIENT)") 471 | assert regex.match("(API: CLIENT)") 472 | assert regex.match("(API/CLIENT)") 473 | assert regex.match("(API-CLIENT)") 474 | assert regex.match("(API.CLIENT)") 475 | assert not regex.match("(TEST)") 476 | assert not regex.match("(API; CLIENT)") 477 | 478 | 479 | def test_r_delim(conventional_commit): 480 | regex = re.compile(conventional_commit.r_delim) 481 | 482 | assert regex.match(":") 483 | assert not regex.match("") 484 | 485 | 486 | def test_r_delim__optional_breaking_indicator(conventional_commit): 487 | regex = re.compile(conventional_commit.r_delim) 488 | 489 | assert regex.match("!:") 490 | 491 | 492 | def test_r_subject__starts_with_space(conventional_commit): 493 | regex = re.compile(conventional_commit.r_subject) 494 | 495 | assert not regex.match("something") 496 | assert regex.match(" something") 497 | 498 | 499 | def test_r_subject__alphanumeric(conventional_commit): 500 | regex = re.compile(conventional_commit.r_subject) 501 | 502 | assert regex.match(" 50m3t41N6") 503 | 504 | 505 | def test_r_subject__special_chars(conventional_commit): 506 | regex = re.compile(conventional_commit.r_subject) 507 | 508 | assert regex.match(" some-thing") 509 | assert regex.match(" some_thing") 510 | assert regex.match(" some/thing") 511 | assert regex.match(" some thing") 512 | 513 | 514 | def test_types__default(): 515 | assert ConventionalCommit().types == ConventionalCommit.DEFAULT_TYPES 516 | 517 | 518 | def test_types__custom(): 519 | result = ConventionalCommit(types=["custom"]) 520 | 521 | assert set(["custom", *ConventionalCommit.CONVENTIONAL_TYPES]) == set(result.types) 522 | 523 | 524 | def test_regex(conventional_commit): 525 | regex = conventional_commit.regex 526 | 527 | assert isinstance(regex, re.Pattern) 528 | assert "type" in regex.groupindex 529 | assert "scope" in regex.groupindex 530 | assert "delim" in regex.groupindex 531 | assert "subject" in regex.groupindex 532 | assert "body" in regex.groupindex 533 | assert "multi" in regex.groupindex 534 | assert "sep" in regex.groupindex 535 | 536 | 537 | def test_match(conventional_commit): 538 | match = conventional_commit.match("test: subject line") 539 | 540 | assert isinstance(match, re.Match) 541 | assert match.group("type") == "test" 542 | assert match.group("scope") == "" 543 | assert match.group("delim") == ":" 544 | assert match.group("subject").strip() == "subject line" 545 | assert match.group("body") == "" 546 | 547 | 548 | def test_match_multiline(conventional_commit): 549 | match = conventional_commit.match( 550 | """test(scope): subject line 551 | 552 | body copy 553 | """ 554 | ) 555 | assert isinstance(match, re.Match) 556 | assert match.group("type") == "test" 557 | assert match.group("scope") == "(scope)" 558 | assert match.group("delim") == ":" 559 | assert match.group("subject").strip() == "subject line" 560 | assert match.group("body").strip() == "body copy" 561 | 562 | 563 | def test_match_dots(conventional_commit): 564 | match = conventional_commit.match("""feat(foo.bar): hello world""") 565 | assert isinstance(match, re.Match) 566 | assert match.group("type") == "feat" 567 | assert match.group("scope") == "(foo.bar)" 568 | assert match.group("delim") == ":" 569 | assert match.group("subject").strip() == "hello world" 570 | assert match.group("body").strip() == "" 571 | 572 | 573 | def test_match_invalid_type(conventional_commit): 574 | match = conventional_commit.match( 575 | """invalid(scope): subject line 576 | 577 | body copy 578 | """ 579 | ) 580 | assert isinstance(match, re.Match) 581 | assert match.group("type") is None 582 | assert match.group("scope") == "" 583 | assert match.group("delim") is None 584 | assert match.group("subject") is None 585 | assert match.group("body") == "" 586 | 587 | 588 | @pytest.mark.parametrize("type", ConventionalCommit.DEFAULT_TYPES) 589 | def test_is_valid__default_type(conventional_commit, type): 590 | input = f"{type}: message" 591 | 592 | assert conventional_commit.is_valid(input) 593 | 594 | 595 | @pytest.mark.parametrize("type", ConventionalCommit.DEFAULT_TYPES) 596 | def test_is_valid__default_type_uppercase(conventional_commit, type): 597 | input = f"{type.upper()}: message" 598 | 599 | assert conventional_commit.is_valid(input) 600 | 601 | 602 | @pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) 603 | def test_is_valid__conventional_type(conventional_commit, type): 604 | input = f"{type}: message" 605 | 606 | assert conventional_commit.is_valid(input) 607 | 608 | 609 | @pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) 610 | def test_is_valid__conventional_type_uppercase(conventional_commit, type): 611 | input = f"{type.upper()}: message" 612 | 613 | assert conventional_commit.is_valid(input) 614 | 615 | 616 | @pytest.mark.parametrize("type", CUSTOM_TYPES) 617 | def test_is_valid__custom_type(type): 618 | input = f"{type}: message" 619 | conventional_commits = ConventionalCommit(types=CUSTOM_TYPES) 620 | 621 | assert conventional_commits.is_valid(input) 622 | 623 | 624 | @pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) 625 | def test_is_valid__conventional_custom_type(type): 626 | input = f"{type}: message" 627 | conventional_commits = ConventionalCommit(types=CUSTOM_TYPES) 628 | 629 | assert conventional_commits.is_valid(input) 630 | 631 | 632 | @pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) 633 | def test_is_valid__conventional_custom_type_uppercase(type): 634 | input = f"{type.upper()}: message" 635 | conventional_commits = ConventionalCommit(types=CUSTOM_TYPES) 636 | 637 | assert conventional_commits.is_valid(input) 638 | 639 | 640 | def test_is_valid__breaking_change(conventional_commit): 641 | input = "fix!: message" 642 | 643 | assert conventional_commit.is_valid(input) 644 | 645 | 646 | def test_is_valid__with_scope(conventional_commit): 647 | input = "feat(scope): message" 648 | 649 | assert conventional_commit.is_valid(input) 650 | 651 | 652 | def test_is_valid__body_multiline_body_bad_type(conventional_commit): 653 | input = """wrong: message 654 | 655 | more_message 656 | """ 657 | 658 | assert not conventional_commit.is_valid(input) 659 | 660 | 661 | def test_is_valid__bad_body_multiline(conventional_commit): 662 | input = """feat(scope): message 663 | more message 664 | """ 665 | 666 | assert not conventional_commit.is_valid(input) 667 | 668 | 669 | def test_is_valid__body_multiline(conventional_commit): 670 | input = """feat(scope): message 671 | 672 | more message 673 | """ 674 | 675 | assert conventional_commit.is_valid(input) 676 | 677 | 678 | def test_is_valid__bad_body_multiline_paragraphs(conventional_commit): 679 | input = """feat(scope): message 680 | more message 681 | 682 | more body message 683 | """ 684 | 685 | assert not conventional_commit.is_valid(input) 686 | 687 | 688 | def test_is_valid__comment(conventional_commit): 689 | input = """feat(scope): message 690 | # Please enter the commit message for your changes. 691 | # These are comments usually added by editors, f.ex. with export EDITOR=vim 692 | """ 693 | assert conventional_commit.is_valid(input) 694 | 695 | 696 | @pytest.mark.parametrize("char", ['"', "'", "`", "#", "&"]) 697 | def test_is_valid__body_special_char(conventional_commit, char): 698 | input = f"feat: message with {char}" 699 | 700 | assert conventional_commit.is_valid(input) 701 | 702 | 703 | def test_is_valid__wrong_type(conventional_commit): 704 | input = "wrong: message" 705 | 706 | assert not conventional_commit.is_valid(input) 707 | 708 | 709 | def test_is_valid__scope_special_chars(conventional_commit): 710 | input = "feat(%&*@()): message" 711 | 712 | assert not conventional_commit.is_valid(input) 713 | 714 | 715 | def test_is_valid__space_scope(conventional_commit): 716 | input = "feat (scope): message" 717 | 718 | assert not conventional_commit.is_valid(input) 719 | 720 | 721 | def test_is_valid__scope_space(conventional_commit): 722 | input = "feat(scope) : message" 723 | 724 | assert not conventional_commit.is_valid(input) 725 | 726 | 727 | def test_is_valid__scope_not_optional(conventional_commit_scope_required): 728 | input = "feat: message" 729 | 730 | assert not conventional_commit_scope_required.is_valid(input) 731 | 732 | 733 | def test_is_valid__scope_not_optional_empty_parenthesis(conventional_commit_scope_required): 734 | input = "feat(): message" 735 | 736 | assert not conventional_commit_scope_required.is_valid(input) 737 | 738 | 739 | def test_is_valid__missing_delimiter(conventional_commit): 740 | input = "feat message" 741 | 742 | assert not conventional_commit.is_valid(input) 743 | 744 | 745 | @pytest.mark.parametrize( 746 | "input,expected_result", 747 | [ 748 | ("feat: subject", True), 749 | ("feat(scope): subject", True), 750 | ( 751 | """feat(scope): subject 752 | 753 | extended body 754 | """, 755 | True, 756 | ), 757 | ("feat", False), 758 | ("feat subject", False), 759 | ("feat(scope): ", False), 760 | (": subject", False), 761 | ("(scope): subject", False), 762 | ( 763 | """feat(scope): subject 764 | extended body no newline 765 | """, 766 | False, 767 | ), 768 | ], 769 | ) 770 | def test_is_conventional(input, expected_result): 771 | assert is_conventional(input) == expected_result 772 | --------------------------------------------------------------------------------