From e01e1717b1d86de9accc038fff8195981c22936e Mon Sep 17 00:00:00 2001 From: David Paz Date: Fri, 15 Jul 2022 01:00:16 +0200 Subject: [PATCH] Add prepare-commit-msg hook Added new hook for templating prepare-commit-msg hook. Added 2 templates to use keepgin `prepare_commit_msg_append.j2`as default. The idea is that developers could create their own templates accordingly to teams commit conventions and override them in the hook arguments when configuring it. --- .pre-commit-hooks.yaml | 6 + pre_commit_hooks/prepare_commit_msg.py | 123 ++++++++++++++++++ .../templates/prepare_commit_msg_append.j2 | 3 + .../templates/prepare_commit_msg_prepend.j2 | 1 + requirements-dev.txt | 1 + setup.cfg | 2 + testing/util.py | 5 + tests/prepare_commit_msg_test.py | 95 ++++++++++++++ 8 files changed, 236 insertions(+) create mode 100644 pre_commit_hooks/prepare_commit_msg.py create mode 100644 pre_commit_hooks/templates/prepare_commit_msg_append.j2 create mode 100644 pre_commit_hooks/templates/prepare_commit_msg_prepend.j2 create mode 100644 tests/prepare_commit_msg_test.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 1a6056b..e5cf64b 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -174,6 +174,12 @@ language: python pass_filenames: false always_run: true +- id: prepare-commit-msg + name: prepare commit message + description: Formatter Helper for your commit message + entry: prepare-commit-msg + language: python + stages: [prepare-commit-msg] - id: requirements-txt-fixer name: fix requirements.txt description: sorts entries in requirements.txt. diff --git a/pre_commit_hooks/prepare_commit_msg.py b/pre_commit_hooks/prepare_commit_msg.py new file mode 100644 index 0000000..5134954 --- /dev/null +++ b/pre_commit_hooks/prepare_commit_msg.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import argparse +import re +from typing import Sequence + +from jinja2 import Environment +from jinja2 import PackageLoader +from jinja2 import select_autoescape + +from pre_commit_hooks.util import CalledProcessError +from pre_commit_hooks.util import cmd_output + + +def get_current_branch() -> str: + try: + ref_name = cmd_output('git', 'symbolic-ref', '--short', 'HEAD') + except CalledProcessError: + return '' + + return ref_name.strip() + + +def _configure_args( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + parser.add_argument( + '-t', '--template', default='prepare_commit_msg_append.j2', + help='Template to use for the commit message.', + ) + parser.add_argument( + '-b', '--branch', action='append', default=['main', 'master'], + help='Branch to skip, may be specified multiple times.', + ) + parser.add_argument( + '-p', '--pattern', action='append', default=['(?<=feature/).*'], + help='RegEx Pattern for recognising Ticket Numbers in branch, ' + 'may be specified multiple times.', + ) + parser.add_argument('COMMIT_MSG_FILE', nargs=argparse.REMAINDER) + + return parser + + +def get_jinja_env() -> Environment: + return Environment( + loader=PackageLoader('pre_commit_hooks'), + autoescape=select_autoescape(), + ) + + +def get_rendered_template( + jinja: Environment, + template_file: str, + variables: dict[str, str], +) -> str: + template = jinja.get_template(template_file) + return template.render(variables) + + +def update_commit_file( + jinja: Environment, + commit_msg_file: str, + template: str, + ticket: str, +) -> int: + try: + with open(commit_msg_file) as f: + data = f.readlines() + + data_as_str = ''.join([item for item in data]) + # if message already contain ticket number means + # it is under git commit --amend or rebase or alike + # where message was already set in the past + if ticket in data_as_str: + return 0 + + variables = { + 'ticket': ticket, + 'content': data_as_str, + } + + content = get_rendered_template( + jinja=jinja, + template_file=template, + variables=variables, + ) + with open(commit_msg_file, 'w') as f: + f.write(content) + + return 0 + except OSError as err: + print(f'OS error: {err}') + return 1 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _configure_args(argparse.ArgumentParser()) + args = parser.parse_args(argv) + + current = get_current_branch() + branches = frozenset(args.branch) + if current in branches: + # checked black listed branches + return 0 + + patterns = frozenset(args.pattern) + matches = [ + match.group(0) + for match in (re.search(pattern, current) for pattern in patterns) + if match + ] + if len(matches) == 0: + # checked white listed branches + return 0 + + jinja = get_jinja_env() + commit_file = args.COMMIT_MSG_FILE[0] + return update_commit_file(jinja, commit_file, args.template, matches[0]) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pre_commit_hooks/templates/prepare_commit_msg_append.j2 b/pre_commit_hooks/templates/prepare_commit_msg_append.j2 new file mode 100644 index 0000000..506bf53 --- /dev/null +++ b/pre_commit_hooks/templates/prepare_commit_msg_append.j2 @@ -0,0 +1,3 @@ +{{ content }} + +Relates: {{ ticket }} diff --git a/pre_commit_hooks/templates/prepare_commit_msg_prepend.j2 b/pre_commit_hooks/templates/prepare_commit_msg_prepend.j2 new file mode 100644 index 0000000..61c6614 --- /dev/null +++ b/pre_commit_hooks/templates/prepare_commit_msg_prepend.j2 @@ -0,0 +1 @@ +[{{ ticket }}] {{ content }} diff --git a/requirements-dev.txt b/requirements-dev.txt index 0c5a37e..3d12de4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ covdefaults coverage +Jinja2 pytest diff --git a/setup.cfg b/setup.cfg index f501571..0e69a51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ classifiers = [options] packages = find: install_requires = + Jinja2>=3.1.2 ruamel.yaml>=0.15 tomli>=1.1.0;python_version<"3.11" python_requires = >=3.7 @@ -62,6 +63,7 @@ console_scripts = mixed-line-ending = pre_commit_hooks.mixed_line_ending:main name-tests-test = pre_commit_hooks.tests_should_end_in_test:main no-commit-to-branch = pre_commit_hooks.no_commit_to_branch:main + prepare-commit-msg = pre_commit_hooks.prepare_commit_msg:main pre-commit-hooks-removed = pre_commit_hooks.removed:main pretty-format-json = pre_commit_hooks.pretty_format_json:main requirements-txt-fixer = pre_commit_hooks.requirements_txt_fixer:main diff --git a/testing/util.py b/testing/util.py index 2bbbe64..fb735b3 100644 --- a/testing/util.py +++ b/testing/util.py @@ -14,3 +14,8 @@ def get_resource_path(path): def git_commit(*args, **kwargs): cmd = ('git', 'commit', '--no-gpg-sign', '--no-verify', '--no-edit', *args) subprocess.check_call(cmd, **kwargs) + + +def get_template_path(path): + parent_dir = os.path.abspath(os.path.join(TESTING_DIR, os.pardir)) + return os.path.join(parent_dir, 'templates', path) diff --git a/tests/prepare_commit_msg_test.py b/tests/prepare_commit_msg_test.py new file mode 100644 index 0000000..51426b5 --- /dev/null +++ b/tests/prepare_commit_msg_test.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.prepare_commit_msg import get_current_branch +from pre_commit_hooks.prepare_commit_msg import get_jinja_env +from pre_commit_hooks.prepare_commit_msg import main +from pre_commit_hooks.prepare_commit_msg import update_commit_file +from pre_commit_hooks.util import cmd_output + + +def test_current_branch(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'feature') + assert get_current_branch() == 'feature' + + cmd_output('git', 'checkout', '-b', 'feature/branch') + assert get_current_branch() == 'feature/branch' + + +# Input, expected value, branch, template +TESTS = ( + ( + b'', + b'[TT-01] ', + 'feature/TT-01', + 'prepare_commit_msg_prepend.j2', + ), + ( + b'[TT-02] Some message', + b'[TT-02] Some message', + 'feature/TT-02', + 'prepare_commit_msg_prepend.j2', + ), + ( + b'Initial message', + b'[TT-03] Initial message', + 'feature/TT-03', + 'prepare_commit_msg_prepend.j2', + ), + ( + b'', + b'\n\nRelates: AA-01', + 'feature/AA-01', + 'prepare_commit_msg_append.j2', + ), + ( + b'Initial message', + b'Initial message\n\nRelates: AA-02', + 'feature/AA-02', + 'prepare_commit_msg_append.j2', + ), +) + + +@pytest.mark.parametrize( + ('input_s', 'expected_val', 'branch_name', 'template'), + TESTS, +) +def test_update_commit_file( + input_s, expected_val, branch_name, template, + temp_git_dir, +): + with temp_git_dir.as_cwd(): + path = temp_git_dir.join('COMMIT_EDITMSG') + path.write_binary(input_s) + ticket = branch_name.split('/')[1] + jinja = get_jinja_env() + update_commit_file(jinja, path, template, ticket) + + assert path.read_binary() == expected_val + + +@pytest.mark.parametrize( + ('input_s', 'expected_val', 'branch_name', 'template'), + TESTS, +) +def test_main( + input_s, expected_val, branch_name, template, + temp_git_dir, +): + with temp_git_dir.as_cwd(): + path = temp_git_dir.join('file.txt') + path.write_binary(input_s) + assert path.read_binary() == input_s + + cmd_output('git', 'checkout', '-b', branch_name) + assert main( + argv=[ + '-t', template, + '-p', '(?<=feature/).*', + str(path), + ], + ) == 0 + assert path.read_binary() == expected_val