diff --git a/.commit-check.yml b/.commit-check.yml index ad339d0..d215d90 100644 --- a/.commit-check.yml +++ b/.commit-check.yml @@ -32,3 +32,8 @@ checks: regex: main # it can be master, develop, devel etc based on your project. error: Current branch is not rebased onto target branch suggest: Please ensure your branch is rebased with the target branch + + - check: imperative + regex: '' # Not used for imperative mood check + error: 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")' + suggest: 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4f08a45..37cffb1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @shenxianpeng +* @commit-check/developers diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 375a8f2..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Documentation for this file can be found at: -# https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository - -blank_issues_enabled: false -contact_links: - - name: "(maintainers only) Blank issue" - url: https://github.com/commit-check/commit-check/issues/new/ - about: For maintainers only. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95bb02f..755e981 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,10 +5,6 @@ version: 2 updates: - - package-ecosystem: docker - directory: / - schedule: - interval: "weekly" - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..f4bbb59 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,44 @@ +name: CodSpeed + +permissions: + contents: read + +on: + push: + branches: + - "main" + paths: + - "commit_check/**" + - "tests/**" + - ".github/workflows/codspeed.yml" + - "pyproject.toml" + pull_request: + branches: + - "main" + paths: + - "commit_check/**" + - "tests/**" + - ".github/workflows/codspeed.yml" + - "pyproject.toml" + # `workflow_dispatch` allows CodSpeed to trigger backtest + # performance analysis in order to generate initial data. + workflow_dispatch: + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[test] + + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest tests/ --codspeed diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 52689d6..13e989a 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,5 +1,9 @@ name: PR Autolabeler +permissions: + contents: write + pull-requests: write + on: # pull_request event is required for autolabeler pull_request: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6333198..3163c6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,8 @@ name: main +permissions: + contents: write + on: push: branches: @@ -21,7 +24,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.x' - - run: pip install -r requirements-dev.txt + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox - name: Run pre-commit run: | @@ -63,7 +70,7 @@ jobs: python-version: ${{ matrix.py }} - run: | pip install --upgrade pip - pip install -r requirements-dev.txt + pip install .[dev] - name: Download wheel artifact uses: actions/download-artifact@v4 @@ -85,7 +92,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.10" - - run: pip install -r requirements-dev.txt + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox - name: Build docs run: nox -s docs diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml deleted file mode 100644 index 39dd16e..0000000 --- a/.github/workflows/publish-image.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: publish image - -on: - push: - paths: - - 'Dockerfile' - workflow_dispatch: - inputs: - tag: - description: 'Which tag want to build' - default: '' - required: false - -jobs: - publish: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Build to check Dockerfile - if: github.event.inputs.tag == '' - run: | - docker build -f Dockerfile -t commit-check . - - name: Build and publish docker image - if: github.event.inputs.tag != '' - run: | - echo "tag = ${{ github.event.inputs.tag }}" - docker build -f Dockerfile --build-arg VERSION=${{ github.event.inputs.tag }} -t commit-check:${{ github.event.inputs.tag }} . - echo $CR_PAT | docker login ghcr.io -u shenxianpeng --password-stdin - docker tag commit-check:${{ github.event.inputs.tag }} ghcr.io/commit-check/commit-check:${{ github.event.inputs.tag }} - docker push ghcr.io/commit-check/commit-check:${{ github.event.inputs.tag }} - env: - CR_PAT: ${{ secrets.CR_PAT }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index d25c13e..182ecec 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -6,6 +6,10 @@ on: - "main" workflow_dispatch: +permissions: + contents: write + pull-requests: write + jobs: draft-release: uses: commit-check/.github/.github/workflows/release-drafter.yml@main diff --git a/.gitpod.yml b/.gitpod.yml index 8123636..4f2f475 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -3,6 +3,6 @@ # and commit this file to your remote git repository to share the goodness with others. tasks: - - before: pip install --upgrade pip && pip install -r requirements-dev.txt + - before: pip install --upgrade pip init: pre-commit install - command: pip install -e . + command: pip install -e .[dev] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc6ee16..58c47aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,13 +19,13 @@ repos: - id: requirements-txt-fixer - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.2 + rev: v0.12.2 hooks: # Run the linter. - id: ruff args: [ --fix ] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.16.1 hooks: - id: mypy additional_dependencies: [types-PyYAML] @@ -35,11 +35,12 @@ repos: hooks: - id: codespell - repo: https://github.com/commit-check/commit-check - rev: v0.9.4 + rev: v0.9.8 hooks: - id: check-message # - id: check-branch # uncomment if you need. - id: check-author-name # uncomment if you need. - id: check-author-email # uncomment if you need. - # - id: commit-signoff # uncomment if you need. + # - id: check-commit-signoff # uncomment if you need. # - id: check-merge-base # requires download all git history + # - id: check-imperative # uncomment if you need. diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 1fca51a..6a73596 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -41,3 +41,10 @@ args: [--merge-base] pass_filenames: false language: python +- id: check-imperative + name: check imperative mood + description: ensures commit message uses imperative mood + entry: commit-check + args: [--imperative] + pass_filenames: true + language: python diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 9bfb5c2..0000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Read the Docs configuration file for Sphinx projects -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -version: 2 - -build: - os: ubuntu-22.04 - tools: - python: "3.12" - -sphinx: - builder: "dirhtml" - configuration: docs/conf.py - -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -python: - install: - - requirements: docs/requirements.txt diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7776999..0000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:3.13-slim - -ARG VERSION - -LABEL com.github.actions.name="Commit Check" -LABEL com.github.actions.description="Check commit message formatting, branch naming, commit author, email, and more." -LABEL com.github.actions.icon="code" -LABEL com.github.actions.color="gray-dark" - -LABEL repository="https://github.com/commit-check/commit-check" -LABEL maintainer="shenxianpeng <20297606+shenxianpeng@users.noreply.github.com>" - -RUN if [ -z "$VERSION" ]; then \ - pip3 install commit-check; \ - else \ - pip3 install commit-check==$VERSION; \ - fi - -USER nobody - -ENTRYPOINT [ "commit-check" ] diff --git a/README.rst b/README.rst index 2f19257..bfda1a0 100644 --- a/README.rst +++ b/README.rst @@ -42,15 +42,14 @@ Configuration Use Default Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~ -- If you don't set ``.commit-check.yml``, Commit Check will use the `default configuration `_. +- **Commit Check** uses a `default configuration `_ if you do not provide a ``.commit-check.yml`` file. -- The commit message will follow the rules of `Conventional Commits `_, - branch naming follow the rules of `Conventional Branch `_. +- The default configuration enforces commit message rules based on the `Conventional Commits `_ specification and branch naming rules based on the `Conventional Branch `_ convention. Use Custom Configuration ~~~~~~~~~~~~~~~~~~~~~~~~ -Create a config file ``.commit-check.yml`` under your repository's root directory, e.g., `.commit-check.yml `_ +To customize the behavior, create a config file ``.commit-check.yml`` under your repository's root directory, e.g., `.commit-check.yml `_ Usage ----- @@ -78,6 +77,7 @@ Running as pre-commit hook - id: check-author-email - id: check-commit-signoff - id: check-merge-base # requires download all git history + - id: check-imperative Running as CLI ~~~~~~~~~~~~~~ @@ -110,7 +110,7 @@ To configure the hook, create a script file in the ``.git/hooks/`` directory. .. code-block:: bash #!/bin/sh - commit-check --message --branch --author-name --author-email --commit-signoff --merge-base + commit-check --message --branch --author-name --author-email --commit-signoff --merge-base --imperative Save the script file as ``pre-push`` and make it executable: @@ -157,14 +157,14 @@ Check Branch Naming Failed Commit rejected by Commit-Check. - (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) - / ._. \ / ._. \ / ._. \ / ._. \ / ._. \ - __\( C )/__ __\( H )/__ __\( E )/__ __\( C )/__ __\( K )/__ + (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) + / ._. \ / ._. \ / ._. \ / ._. \ / ._. \ + __\( C )/__ __\( H )/__ __\( E )/__ __\( C )/__ __\( K )/__ (_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._) - || E || || R || || R || || O || || R || - _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ + || E || || R || || R || || O || || R || + _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ (.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.) - `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ + `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ Commit rejected. @@ -180,14 +180,14 @@ Check Commit Signature Failed Commit rejected by Commit-Check. - (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) - / ._. \ / ._. \ / ._. \ / ._. \ / ._. \ - __\( C )/__ __\( H )/__ __\( E )/__ __\( C )/__ __\( K )/__ + (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) + / ._. \ / ._. \ / ._. \ / ._. \ / ._. \ + __\( C )/__ __\( H )/__ __\( E )/__ __\( C )/__ __\( K )/__ (_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._) - || E || || R || || R || || O || || R || - _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ + || E || || R || || R || || O || || R || + _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ (.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.) - `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ + `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ Commit rejected. @@ -197,6 +197,28 @@ Check Commit Signature Failed Suggest: run command `git commit -m "conventional commit message" --signoff` +Check Imperative Mood Failed + +.. code-block:: text + + Commit rejected by Commit-Check. + + (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c) + / ._. \ / ._. \ / ._. \ / ._. \ / ._. \ + __\( C )/__ __\( H )/__ __\( E )/__ __\( C )/__ __\( K )/__ + (_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._) + || E || || R || || R || || O || || R || + _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ _.' '-' '._ + (.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.) + `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ + + Commit rejected. + + Type imperative check failed => Added file + It doesn't match regex: imperative mood pattern + Commit message should use imperative mood (e.g., "Add feature" not "Added feature") + Suggest: Use imperative mood in commit message like "Add", "Fix", "Update", "Remove" + Badging your repository ----------------------- diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 521f68f..8b78762 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -54,6 +54,12 @@ 'error': 'Current branch is not rebased onto target branch', 'suggest': 'Please ensure your branch is rebased with the target branch', }, + { + 'check': 'imperative', + 'regex': r'', # Not used for imperative mood check + 'error': 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")', + 'suggest': 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"', + }, ], } diff --git a/commit_check/author.py b/commit_check/author.py index a68397e..e3cfd20 100644 --- a/commit_check/author.py +++ b/commit_check/author.py @@ -1,10 +1,13 @@ """Check git author name and email""" import re from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import get_commit_info, print_error_header, print_error_message, print_suggestion +from commit_check.util import get_commit_info, has_commits, print_error_header, print_error_message, print_suggestion def check_author(checks: list, check_type: str) -> int: + if has_commits() is False: + return PASS # pragma: no cover + for check in checks: if check['check'] == check_type: if check['regex'] == "": diff --git a/commit_check/branch.py b/commit_check/branch.py index 1cc8a35..b7446cc 100644 --- a/commit_check/branch.py +++ b/commit_check/branch.py @@ -1,7 +1,7 @@ """Check git branch naming convention.""" import re from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import get_branch_name, git_merge_base, print_error_header, print_error_message, print_suggestion +from commit_check.util import get_branch_name, git_merge_base, print_error_header, print_error_message, print_suggestion, has_commits def check_branch(checks: list) -> int: @@ -33,6 +33,9 @@ def check_merge_base(checks: list) -> int: :returns PASS(0) if merge base check succeeds, FAIL(1) otherwise """ + if has_commits() is False: + return PASS # pragma: no cover + for check in checks: if check['check'] == 'merge_base': if check['regex'] == "": diff --git a/commit_check/commit.py b/commit_check/commit.py index 60b8d2f..fccc5da 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -2,7 +2,13 @@ import re from pathlib import PurePath from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import cmd_output, get_commit_info, print_error_header, print_error_message, print_suggestion +from commit_check.util import cmd_output, get_commit_info, print_error_header, print_error_message, print_suggestion, has_commits +from commit_check.imperatives import IMPERATIVES + + +def _load_imperatives() -> set: + """Load imperative verbs from imperatives module.""" + return IMPERATIVES def get_default_commit_msg_file() -> str: @@ -22,19 +28,23 @@ def read_commit_msg(commit_msg_file) -> str: def check_commit_msg(checks: list, commit_msg_file: str = "") -> int: + """Check commit message against the provided checks.""" + if has_commits() is False: + return PASS # pragma: no cover + if commit_msg_file is None or commit_msg_file == "": commit_msg_file = get_default_commit_msg_file() commit_msg = read_commit_msg(commit_msg_file) for check in checks: - if check['regex'] == "": - print( - f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}", - ) - return PASS - if check['check'] == 'message': + if check['regex'] == "": + print( + f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}", + ) + return PASS + result = re.match(check['regex'], commit_msg) if result is None: if not print_error_header.has_been_called: @@ -51,6 +61,9 @@ def check_commit_msg(checks: list, commit_msg_file: str = "") -> int: def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int: + if has_commits() is False: + return PASS # pragma: no cover + if commit_msg_file is None or commit_msg_file == "": commit_msg_file = get_default_commit_msg_file() @@ -77,3 +90,92 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int: return FAIL return PASS + + +def check_imperative(checks: list, commit_msg_file: str = "") -> int: + """Check if commit message uses imperative mood.""" + if has_commits() is False: + return PASS # pragma: no cover + + if commit_msg_file is None or commit_msg_file == "": + commit_msg_file = get_default_commit_msg_file() + + for check in checks: + if check['check'] == 'imperative': + commit_msg = read_commit_msg(commit_msg_file) + + # Extract the subject line (first line of commit message) + subject = commit_msg.split('\n')[0].strip() + + # Skip if empty or merge commit + if not subject or subject.startswith('Merge'): + return PASS + + # For conventional commits, extract description after the colon + if ':' in subject: + description = subject.split(':', 1)[1].strip() + else: + description = subject + + # Check if the description uses imperative mood + if not _is_imperative(description): + if not print_error_header.has_been_called: + print_error_header() # pragma: no cover + print_error_message( + check['check'], 'imperative mood pattern', + check['error'], subject, + ) + if check['suggest']: + print_suggestion(check['suggest']) + return FAIL + + return PASS + + +def _is_imperative(description: str) -> bool: + """Check if a description uses imperative mood.""" + if not description: + return True + + # Get the first word of the description + first_word = description.split()[0].lower() + + # Load imperative verbs from file + imperatives = _load_imperatives() + + # Check for common past tense pattern (-ed ending) but be more specific + if (first_word.endswith('ed') and len(first_word) > 3 and + first_word not in {'red', 'bed', 'fed', 'led', 'wed', 'shed', 'fled'}): + return False + + # Check for present continuous pattern (-ing ending) but be more specific + if (first_word.endswith('ing') and len(first_word) > 4 and + first_word not in {'ring', 'sing', 'king', 'wing', 'thing', 'string', 'bring'}): + return False + + # Check for third person singular (-s ending) but be more specific + # Only flag if it's clearly a verb in third person singular form + if first_word.endswith('s') and len(first_word) > 3: + # Common nouns ending in 's' that should be allowed + common_nouns_ending_s = {'process', 'access', 'address', 'progress', 'express', 'stress', 'success', 'class', 'pass', 'mass', 'loss', 'cross', 'gross', 'boss', 'toss', 'less', 'mess', 'dress', 'press', 'bless', 'guess', 'chess', 'glass', 'grass', 'brass'} + + # Words ending in 'ss' or 'us' are usually not third person singular verbs + if first_word.endswith('ss') or first_word.endswith('us'): + return True # Allow these + + # If it's a common noun, allow it + if first_word in common_nouns_ending_s: + return True + + # Otherwise, it's likely a third person singular verb + return False + + # If we have imperatives loaded, check if the first word is imperative + if imperatives: + # Check if the first word is in our imperative list + if first_word in imperatives: + return True + + # If word is not in imperatives list, apply some heuristics + # If it passes all the negative checks above, it's likely imperative + return True diff --git a/commit_check/imperatives.py b/commit_check/imperatives.py new file mode 100644 index 0000000..0c3d091 --- /dev/null +++ b/commit_check/imperatives.py @@ -0,0 +1,237 @@ +# https://github.com/crate-ci/imperative/blob/master/assets/imperatives.txt +# Imperative forms of verbs +# +# This file contains the imperative form of frequently encountered +# docstring verbs. Some of these may be more commonly encountered as +# nouns, but blacklisting them for this may cause false positives. + +IMPERATIVES = { + 'accept', + 'access', + 'add', + 'adjust', + 'aggregate', + 'allow', + 'append', + 'apply', + 'archive', + 'assert', + 'assign', + 'attempt', + 'authenticate', + 'authorize', + 'break', + 'build', + 'cache', + 'calculate', + 'call', + 'cancel', + 'capture', + 'change', + 'check', + 'clean', + 'clear', + 'close', + 'collect', + 'combine', + 'commit', + 'compare', + 'compute', + 'configure', + 'confirm', + 'connect', + 'construct', + 'control', + 'convert', + 'copy', + 'count', + 'create', + 'customize', + 'declare', + 'decode', + 'decorate', + 'define', + 'delegate', + 'delete', + 'deprecate', + 'derive', + 'describe', + 'detect', + 'determine', + 'display', + 'download', + 'drop', + 'dump', + 'emit', + 'empty', + 'enable', + 'encapsulate', + 'encode', + 'end', + 'ensure', + 'enumerate', + 'establish', + 'evaluate', + 'examine', + 'execute', + 'exit', + 'expand', + 'expect', + 'export', + 'extend', + 'extract', + 'feed', + 'fetch', + 'fill', + 'filter', + 'finalize', + 'find', + 'fire', + 'fix', + 'flag', + 'force', + 'format', + 'forward', + 'generate', + 'get', + 'give', + 'go', + 'group', + 'handle', + 'help', + 'hold', + 'identify', + 'implement', + 'import', + 'indicate', + 'init', + 'initialise', + 'initialize', + 'initiate', + 'input', + 'insert', + 'instantiate', + 'intercept', + 'invoke', + 'iterate', + 'join', + 'keep', + 'launch', + 'list', + 'listen', + 'load', + 'log', + 'look', + 'make', + 'manage', + 'manipulate', + 'map', + 'mark', + 'match', + 'merge', + 'mock', + 'modify', + 'monitor', + 'move', + 'normalize', + 'note', + 'obtain', + 'open', + 'output', + 'override', + 'overwrite', + 'package', + 'pad', + 'parse', + 'partial', + 'pass', + 'perform', + 'persist', + 'pick', + 'plot', + 'poll', + 'populate', + 'post', + 'prepare', + 'print', + 'process', + 'produce', + 'provide', + 'publish', + 'pull', + 'put', + 'query', + 'raise', + 'read', + 'record', + 'refer', + 'refresh', + 'register', + 'reload', + 'remove', + 'rename', + 'render', + 'replace', + 'reply', + 'report', + 'represent', + 'request', + 'require', + 'reset', + 'resolve', + 'retrieve', + 'return', + 'roll', + 'rollback', + 'round', + 'run', + 'sample', + 'save', + 'scan', + 'search', + 'select', + 'send', + 'serialise', + 'serialize', + 'serve', + 'set', + 'show', + 'simulate', + 'source', + 'specify', + 'split', + 'start', + 'step', + 'stop', + 'store', + 'strip', + 'submit', + 'subscribe', + 'sum', + 'swap', + 'sync', + 'synchronise', + 'synchronize', + 'take', + 'tear', + 'test', + 'time', + 'transform', + 'translate', + 'transmit', + 'truncate', + 'try', + 'turn', + 'tweak', + 'update', + 'upload', + 'use', + 'validate', + 'verify', + 'view', + 'wait', + 'walk', + 'wrap', + 'write', + 'yield', +} diff --git a/commit_check/main.py b/commit_check/main.py index 6faa070..f81af48 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -92,6 +92,14 @@ def get_parser() -> argparse.ArgumentParser: required=False, ) + parser.add_argument( + '-i', + '--imperative', + help='check commit message uses imperative mood', + action="store_true", + required=False, + ) + return parser @@ -122,6 +130,8 @@ def main() -> int: check_results.append(commit.check_commit_signoff(checks)) if args.merge_base: check_results.append(branch.check_merge_base(checks)) + if args.imperative: + check_results.append(commit.check_imperative(checks, args.commit_msg_file)) return PASS if all(val == PASS for val in check_results) else FAIL diff --git a/commit_check/util.py b/commit_check/util.py index a63d6c1..6d3c33b 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -22,13 +22,29 @@ def get_branch_name() -> str: :returns: A `str` describing the current branch name. """ try: - commands = ['git', 'rev-parse', '--abbrev-ref', 'HEAD'] - branch_name = cmd_output(commands) + # Git 2.22 and above supports `git branch --show-current` + commands = ['git', 'branch', '--show-current'] + branch_name = cmd_output(commands) or "HEAD" except CalledProcessError: branch_name = '' return branch_name.strip() +def has_commits() -> bool: + """Check if there are any commits in the current branch. + :returns: `True` if there are commits, `False` otherwise. + """ + try: + subprocess.run( + ["git", "rev-parse", "--verify", "HEAD"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True + ) + return True + except subprocess.CalledProcessError: + return False + def get_commit_info(format_string: str, sha: str = "HEAD") -> str: """Get latest commits information :param format_string: could be diff --git a/docs/conf.py b/docs/conf.py index 53d5b56..2f3e1d9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,11 +25,6 @@ "sphinx.ext.viewcode", ] -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "requests": ("https://requests.readthedocs.io/en/latest/", None), -} - autodoc_member_order = "bysource" templates_path = ["_templates"] diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index af718d9..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ - -. -sphinx-immaterial diff --git a/noxfile.py b/noxfile.py index 079df8a..50599af 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,11 +4,6 @@ nox.options.reuse_existing_virtualenvs = True nox.options.sessions = ["lint"] -REQUIREMENTS = { - "dev": "requirements-dev.txt", - "docs": "docs/requirements.txt", -} - # ----------------------------------------------------------------------------- # Development Commands # ----------------------------------------------------------------------------- @@ -54,8 +49,7 @@ def commit_check(session): @nox.session() def coverage(session): - session.install(".") - session.install("-r", REQUIREMENTS["dev"]) + session.install('.[test]') session.run("coverage", "run", "--source", "commit_check", "-m", "pytest") session.run("coverage", "report") session.run("coverage", "xml") @@ -63,13 +57,11 @@ def coverage(session): @nox.session() def docs(session): - session.install(".") - session.install("-r", REQUIREMENTS["docs"]) + session.install('.[docs]') session.run("sphinx-build", "-E", "-W", "-b", "html", "docs", "_build/html") @nox.session(name="docs-live") def docs_live(session): - session.install(".") - session.install("-r", REQUIREMENTS["docs"], "sphinx-autobuild") + session.install('.[docs]') session.run("sphinx-autobuild", "-b", "html", "docs", "_build/html") diff --git a/pyproject.toml b/pyproject.toml index c84d181..8c0db4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,11 @@ tracker = "https://github.com/commit-check/commit-check/issues" # ... other project metadata fields as specified in: # https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +[project.optional-dependencies] +dev = ['nox'] +test = ['coverage', 'pytest', 'pytest-mock', 'pytest-codspeed'] +docs = ['sphinx-immaterial', 'sphinx-autobuild'] + [tool.setuptools] zip-safe = false packages = ["commit_check"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 120645f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -coverage -git+https://github.com/wntrblm/nox.git@main -pre-commit -pytest -pytest-mock diff --git a/tests/author_test.py b/tests/author_test.py index 8936223..fcc4939 100644 --- a/tests/author_test.py +++ b/tests/author_test.py @@ -1,3 +1,4 @@ +import pytest from commit_check import PASS, FAIL from commit_check.author import check_author @@ -11,6 +12,7 @@ class TestAuthorName: fake_author_value_an = "fake_author_name" fake_accented_author_value_an = "fáké_áúthór_námé" + @pytest.mark.benchmark def test_check_author(self, mocker): # Must call get_commit_info, re.match. checks = [{ @@ -30,6 +32,7 @@ def test_check_author(self, mocker): assert m_get_commit_info.call_count == 1 assert m_re_match.call_count == 1 + @pytest.mark.benchmark def test_check_author_with_accented_letters(self, mocker): # Must call get_commit_info, re.match. checks = [{ @@ -49,6 +52,7 @@ def test_check_author_with_accented_letters(self, mocker): assert m_get_commit_info.call_count == 1 assert m_re_match.call_count == 1 + @pytest.mark.benchmark def test_check_author_with_empty_checks(self, mocker): # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] @@ -65,6 +69,7 @@ def test_check_author_with_empty_checks(self, mocker): assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_author_with_different_check(self, mocker): # Must NOT call get_commit_info, re.match with not `author_name`. checks = [{ @@ -84,6 +89,7 @@ def test_check_author_with_different_check(self, mocker): assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_author_with_len0_regex(self, mocker, capfd): # Must NOT call get_commit_info, re.match with `regex` with length 0. checks = [ @@ -107,6 +113,7 @@ def test_check_author_with_len0_regex(self, mocker, capfd): out, _ = capfd.readouterr() assert "Not found regex for author_name." in out + @pytest.mark.benchmark def test_check_author_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. checks = [{ @@ -140,6 +147,7 @@ class TestAuthorEmail: # used by get_commit_info mock fake_author_value_ae = "fake_author_email" + @pytest.mark.benchmark def test_check_author(self, mocker): # Must call get_commit_info, re.match. checks = [{ @@ -159,6 +167,7 @@ def test_check_author(self, mocker): assert m_get_commit_info.call_count == 1 assert m_re_match.call_count == 1 + @pytest.mark.benchmark def test_check_author_with_empty_checks(self, mocker): # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] @@ -175,6 +184,7 @@ def test_check_author_with_empty_checks(self, mocker): assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_author_with_different_check(self, mocker): # Must NOT call get_commit_info, re.match with not `author_email`. checks = [{ @@ -194,6 +204,7 @@ def test_check_author_with_different_check(self, mocker): assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_author_with_len0_regex(self, mocker, capfd): # Must NOT call get_commit_info, re.match with `regex` with length 0. checks = [ @@ -217,6 +228,7 @@ def test_check_author_with_len0_regex(self, mocker, capfd): out, _ = capfd.readouterr() assert "Not found regex for author_email." in out + @pytest.mark.benchmark def test_check_author_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. checks = [{ diff --git a/tests/branch_test.py b/tests/branch_test.py index 083c5bd..34e3a3b 100644 --- a/tests/branch_test.py +++ b/tests/branch_test.py @@ -1,3 +1,4 @@ +import pytest from commit_check import PASS, FAIL from commit_check.branch import check_branch, check_merge_base @@ -7,6 +8,7 @@ class TestCheckBranch: + @pytest.mark.benchmark def test_check_branch(self, mocker): # Must call get_branch_name, re.match at once. checks = [{ @@ -26,6 +28,7 @@ def test_check_branch(self, mocker): assert m_get_branch_name.call_count == 1 assert m_re_match.call_count == 1 + @pytest.mark.benchmark def test_check_branch_with_empty_checks(self, mocker): # Must NOT call get_branch_name, re.match with `checks` param with length 0. checks = [] @@ -42,6 +45,7 @@ def test_check_branch_with_empty_checks(self, mocker): assert m_get_branch_name.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_branch_with_different_check(self, mocker): # Must NOT call get_branch_name, re.match with not `branch`. checks = [{ @@ -61,6 +65,7 @@ def test_check_branch_with_different_check(self, mocker): assert m_get_branch_name.call_count == 0 assert m_re_match.call_count == 0 + @pytest.mark.benchmark def test_check_branch_with_len0_regex(self, mocker, capfd): # Must NOT call get_branch_name, re.match with `regex` with length 0. checks = [ @@ -84,6 +89,7 @@ def test_check_branch_with_len0_regex(self, mocker, capfd): out, _ = capfd.readouterr() assert "Not found regex for branch naming." in out + @pytest.mark.benchmark def test_check_branch_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. checks = [{ @@ -115,6 +121,7 @@ def test_check_branch_with_result_none(self, mocker): class TestCheckMergeBase: + @pytest.mark.benchmark def test_check_merge_base_with_empty_checks(self, mocker): checks = [] m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") @@ -122,7 +129,7 @@ def test_check_merge_base_with_empty_checks(self, mocker): assert retval == PASS assert m_check_merge.call_count == 0 - + @pytest.mark.benchmark def test_check_merge_base_with_empty_regex(self, mocker): checks = [{ "check": "merge_base", @@ -133,6 +140,7 @@ def test_check_merge_base_with_empty_regex(self, mocker): assert retval == PASS assert m_check_merge.call_count == 0 + @pytest.mark.benchmark def test_check_merge_base_with_different_check(self, mocker): checks = [{ "check": "branch", @@ -143,6 +151,7 @@ def test_check_merge_base_with_different_check(self, mocker): assert retval == PASS assert m_check_merge.call_count == 0 + @pytest.mark.benchmark def test_check_merge_base_fail_with_messages(self, mocker, capfd): checks = [{ "check": "merge_base", diff --git a/tests/commit_test.py b/tests/commit_test.py index a458636..9a1235d 100644 --- a/tests/commit_test.py +++ b/tests/commit_test.py @@ -1,5 +1,6 @@ +import pytest from commit_check import PASS, FAIL -from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff +from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff, check_imperative # used by get_commit_info mock FAKE_BRANCH_NAME = "fake_commits_info" @@ -9,11 +10,13 @@ MSG_FILE = '.git/COMMIT_EDITMSG' +@pytest.mark.benchmark def test_get_default_commit_msg_file(mocker): retval = get_default_commit_msg_file() assert retval == ".git/COMMIT_EDITMSG" +@pytest.mark.benchmark def test_read_commit_msg_from_existing_file(tmp_path): # Create a temporary file with a known content commit_msg_content = "Test commit message content." @@ -24,12 +27,14 @@ def test_read_commit_msg_from_existing_file(tmp_path): assert result == commit_msg_content +@pytest.mark.benchmark def test_read_commit_msg_file_not_found(mocker): m_commits_info = mocker.patch('commit_check.util.get_commit_info', return_value='mocked_commits_info') read_commit_msg("non_existent_file.txt") assert m_commits_info.call_count == 0 +@pytest.mark.benchmark def test_check_commit_msg_no_commit_msg_file(mocker): mock_get_default_commit_msg_file = mocker.patch( "commit_check.commit.get_default_commit_msg_file", @@ -49,6 +54,7 @@ def test_check_commit_msg_no_commit_msg_file(mocker): assert result == 0 +@pytest.mark.benchmark def test_check_commit_with_empty_checks(mocker): checks = [] m_re_match = mocker.patch( @@ -60,6 +66,7 @@ def test_check_commit_with_empty_checks(mocker): assert m_re_match.call_count == 0 +@pytest.mark.benchmark def test_check_commit_with_different_check(mocker): checks = [{ "check": "branch", @@ -74,6 +81,7 @@ def test_check_commit_with_different_check(mocker): assert m_re_match.call_count == 0 +@pytest.mark.benchmark def test_check_commit_with_len0_regex(mocker, capfd): checks = [ { @@ -92,6 +100,7 @@ def test_check_commit_with_len0_regex(mocker, capfd): assert "Not found regex for commit message." in out +@pytest.mark.benchmark def test_check_commit_with_result_none(mocker): checks = [{ "check": "message", @@ -116,6 +125,7 @@ def test_check_commit_with_result_none(mocker): assert m_print_suggestion.call_count == 1 +@pytest.mark.benchmark def test_check_commit_signoff(mocker): checks = [{ "check": "commit_signoff", @@ -140,6 +150,7 @@ def test_check_commit_signoff(mocker): assert m_print_suggestion.call_count == 1 +@pytest.mark.benchmark def test_check_commit_signoff_with_empty_regex(mocker): checks = [{ "check": "commit_signoff", @@ -156,6 +167,7 @@ def test_check_commit_signoff_with_empty_regex(mocker): assert m_re_match.call_count == 0 +@pytest.mark.benchmark def test_check_commit_signoff_with_empty_checks(mocker): checks = [] m_re_match = mocker.patch( @@ -165,3 +177,224 @@ def test_check_commit_signoff_with_empty_checks(mocker): retval = check_commit_signoff(checks) assert retval == PASS assert m_re_match.call_count == 0 + + +@pytest.mark.benchmark +def test_check_imperative_pass(mocker): + """Test imperative mood check passes for valid imperative mood.""" + checks = [{ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood" + }] + + mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="feat: Add new feature\n\nThis adds a new feature to the application." + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == PASS + + +@pytest.mark.benchmark +def test_check_imperative_fail_past_tense(mocker): + """Test imperative mood check fails for past tense.""" + checks = [{ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood" + }] + + mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="feat: Added new feature" + ) + + m_print_error_message = mocker.patch( + f"{LOCATION}.print_error_message" + ) + m_print_suggestion = mocker.patch( + f"{LOCATION}.print_suggestion" + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == FAIL + assert m_print_error_message.call_count == 1 + assert m_print_suggestion.call_count == 1 + + +@pytest.mark.benchmark +def test_check_imperative_fail_present_continuous(mocker): + """Test imperative mood check fails for present continuous.""" + checks = [{ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood" + }] + + mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="feat: Adding new feature" + ) + + m_print_error_message = mocker.patch( + f"{LOCATION}.print_error_message" + ) + m_print_suggestion = mocker.patch( + f"{LOCATION}.print_suggestion" + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == FAIL + assert m_print_error_message.call_count == 1 + assert m_print_suggestion.call_count == 1 + + +@pytest.mark.benchmark +def test_check_imperative_skip_merge_commit(mocker): + """Test imperative mood check skips merge commits.""" + checks = [{ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood" + }] + + mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="Merge branch 'feature/test' into main" + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == PASS + + +@pytest.mark.benchmark +def test_check_imperative_different_check_type(mocker): + """Test imperative mood check skips different check types.""" + checks = [{ + "check": "message", + "regex": "dummy_regex" + }] + + m_read_commit_msg = mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="feat: Added new feature" + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == PASS + assert m_read_commit_msg.call_count == 0 + + +@pytest.mark.benchmark +def test_check_imperative_no_commits(mocker): + """Test imperative mood check passes when there are no commits.""" + checks = [{ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood" + }] + + mocker.patch("commit_check.commit.has_commits", return_value=False) + + retval = check_imperative(checks, MSG_FILE) + assert retval == PASS + + +@pytest.mark.benchmark +def test_check_imperative_empty_checks(mocker): + """Test imperative mood check with empty checks list.""" + checks = [] + + m_read_commit_msg = mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="feat: Added new feature" + ) + + retval = check_imperative(checks, MSG_FILE) + assert retval == PASS + assert m_read_commit_msg.call_count == 0 + + +@pytest.mark.benchmark +def test_is_imperative_valid_cases(): + """Test _is_imperative function with valid imperative mood cases.""" + from commit_check.commit import _is_imperative + + valid_cases = [ + "Add new feature", + "Fix bug in authentication", + "Update documentation", + "Remove deprecated code", + "Refactor user service", + "Optimize database queries", + "Create new component", + "Delete unused files", + "Improve error handling", + "Enhance user experience", + "Implement new API", + "Configure CI/CD pipeline", + "Setup testing framework", + "Handle edge cases", + "Process user input", + "Validate form data", + "Transform data format", + "Initialize application", + "Load configuration", + "Save user preferences", + "", # Empty description should pass + ] + + for case in valid_cases: + assert _is_imperative(case), f"'{case}' should be imperative mood" + + +@pytest.mark.benchmark +def test_is_imperative_invalid_cases(): + """Test _is_imperative function with invalid imperative mood cases.""" + from commit_check.commit import _is_imperative + + invalid_cases = [ + "Added new feature", + "Fixed bug in authentication", + "Updated documentation", + "Removed deprecated code", + "Refactored user service", + "Optimized database queries", + "Created new component", + "Deleted unused files", + "Improved error handling", + "Enhanced user experience", + "Implemented new API", + "Adding new feature", + "Fixing bug in authentication", + "Updating documentation", + "Removing deprecated code", + "Refactoring user service", + "Optimizing database queries", + "Creating new component", + "Deleting unused files", + "Improving error handling", + "Enhancing user experience", + "Implementing new API", + "Adds new feature", + "Fixes bug in authentication", + "Updates documentation", + "Removes deprecated code", + "Refactors user service", + "Optimizes database queries", + "Creates new component", + "Deletes unused files", + "Improves error handling", + "Enhances user experience", + "Implements new API", + ] + + for case in invalid_cases: + assert not _is_imperative(case), f"'{case}' should not be imperative mood" diff --git a/tests/error_test.py b/tests/error_test.py index d0a2880..ab9e948 100644 --- a/tests/error_test.py +++ b/tests/error_test.py @@ -3,6 +3,7 @@ from commit_check.error import error_handler, log_and_exit +@pytest.mark.benchmark def test_error_handler_RuntimeError(): with pytest.raises(SystemExit) as exit_info: with error_handler(): @@ -10,6 +11,7 @@ def test_error_handler_RuntimeError(): assert exit_info.value.code == 1 +@pytest.mark.benchmark def test_error_handler_KeyboardInterrupt(): with pytest.raises(SystemExit) as exit_info: with error_handler(): @@ -17,6 +19,7 @@ def test_error_handler_KeyboardInterrupt(): assert exit_info.value.code == 130 +@pytest.mark.benchmark def test_error_handler_unexpected_error(): with pytest.raises(SystemExit) as exit_info: with error_handler(): @@ -24,6 +27,7 @@ def test_error_handler_unexpected_error(): assert exit_info.value.code == 3 +@pytest.mark.benchmark def test_error_handler_cannot_access(mocker): with pytest.raises(SystemExit): store_dir = "/fake/commit-check" @@ -50,6 +54,7 @@ def test_error_handler_cannot_access(mocker): mock_open().write.assert_any_call(f"Failed to write to log at {log_path}\n") +@pytest.mark.benchmark @pytest.mark.xfail def test_log_and_exit(monkeypatch): monkeypatch.setenv("COMMIT_CHECK_HOME", "") diff --git a/tests/main_test.py b/tests/main_test.py index ca77032..4e277a6 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -7,20 +7,23 @@ class TestMain: - @pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count, check_commit_signoff_call_count, check_merge_base_call_count", [ - ([CMD, "--message"], 1, 0, 0, 0, 0), - ([CMD, "--branch"], 0, 1, 0, 0, 0), - ([CMD, "--author-name"], 0, 0, 1, 0, 0), - ([CMD, "--author-email"], 0, 0, 1, 0, 0), - ([CMD, "--commit-signoff"], 0, 0, 0, 1, 0), - ([CMD, "--merge-base"], 0, 0, 0, 0, 1), - ([CMD, "--message", "--author-email"], 1, 0, 1, 0, 0), - ([CMD, "--branch", "--message"], 1, 1, 0, 0, 0), - ([CMD, "--author-name", "--author-email"], 0, 0, 2, 0, 0), - ([CMD, "--message", "--branch", "--author-email"], 1, 1, 1, 0, 0), - ([CMD, "--branch", "--message", "--author-name", "--author-email"], 1, 1, 2, 0, 0), - ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base"], 1, 1, 2, 1, 1), - ([CMD, "--dry-run"], 0, 0, 0, 0, 0), + @pytest.mark.benchmark + @pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count, check_commit_signoff_call_count, check_merge_base_call_count, check_imperative_call_count", [ + ([CMD, "--message"], 1, 0, 0, 0, 0, 0), + ([CMD, "--branch"], 0, 1, 0, 0, 0, 0), + ([CMD, "--author-name"], 0, 0, 1, 0, 0, 0), + ([CMD, "--author-email"], 0, 0, 1, 0, 0, 0), + ([CMD, "--commit-signoff"], 0, 0, 0, 1, 0, 0), + ([CMD, "--merge-base"], 0, 0, 0, 0, 1, 0), + ([CMD, "--imperative"], 0, 0, 0, 0, 0, 1), + ([CMD, "--message", "--author-email"], 1, 0, 1, 0, 0, 0), + ([CMD, "--branch", "--message"], 1, 1, 0, 0, 0, 0), + ([CMD, "--author-name", "--author-email"], 0, 0, 2, 0, 0, 0), + ([CMD, "--message", "--branch", "--author-email"], 1, 1, 1, 0, 0, 0), + ([CMD, "--branch", "--message", "--author-name", "--author-email"], 1, 1, 2, 0, 0, 0), + ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base"], 1, 1, 2, 1, 1, 0), + ([CMD, "--message", "--imperative"], 1, 0, 0, 0, 0, 1), + ([CMD, "--dry-run"], 0, 0, 0, 0, 0, 0), ]) def test_main( self, @@ -31,6 +34,7 @@ def test_main( check_author_call_count, check_commit_signoff_call_count, check_merge_base_call_count, + check_imperative_call_count, ): mocker.patch( "commit_check.main.validate_config", @@ -45,6 +49,7 @@ def test_main( m_check_author = mocker.patch("commit_check.author.check_author") m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff") m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base") + m_check_imperative = mocker.patch("commit_check.commit.check_imperative") sys.argv = argv main() assert m_check_commit.call_count == check_commit_call_count @@ -52,7 +57,9 @@ def test_main( assert m_check_author.call_count == check_author_call_count assert m_check_commit_signoff.call_count == check_commit_signoff_call_count assert m_check_merge_base.call_count == check_merge_base_call_count + assert m_check_imperative.call_count == check_imperative_call_count + @pytest.mark.benchmark def test_main_help(self, mocker, capfd): mocker.patch( "commit_check.main.validate_config", @@ -78,6 +85,7 @@ def test_main_help(self, mocker, capfd): stdout, _ = capfd.readouterr() assert "usage: " in stdout + @pytest.mark.benchmark def test_main_version(self, mocker): mocker.patch( "commit_check.main.validate_config", @@ -101,6 +109,7 @@ def test_main_version(self, mocker): assert m_check_commit_signoff.call_count == 0 assert m_check_merge_base.call_count == 0 + @pytest.mark.benchmark def test_main_validate_config_ret_none(self, mocker): mocker.patch( "commit_check.main.validate_config", @@ -116,6 +125,7 @@ def test_main_validate_config_ret_none(self, mocker): assert m_check_commit.call_count == 1 assert m_check_commit.call_args[0][0] == DEFAULT_CONFIG["checks"] + @pytest.mark.benchmark @pytest.mark.parametrize( "argv, message_result, branch_result, author_name_result, author_email_result, commit_signoff_result, merge_base_result, final_result", [ @@ -163,6 +173,7 @@ def test_main_multiple_checks( mocker.patch( "commit_check.branch.check_merge_base", return_value=merge_base_result ) + mocker.patch("commit_check.commit.check_imperative", return_value=PASS) # this is messy. why isn't this a private implementation detail with a # public check_author_name and check_author email? diff --git a/tests/util_test.py b/tests/util_test.py index 42870ea..8937005 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -1,6 +1,7 @@ import pytest import subprocess from commit_check.util import get_branch_name +from commit_check.util import has_commits from commit_check.util import git_merge_base from commit_check.util import get_commit_info from commit_check.util import cmd_output @@ -14,6 +15,7 @@ class TestUtil: class TestGetBranchName: + @pytest.mark.benchmark def test_get_branch_name(self, mocker): # Must call cmd_output with given argument. m_cmd_output = mocker.patch( @@ -23,10 +25,11 @@ def test_get_branch_name(self, mocker): retval = get_branch_name() assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "rev-parse", "--abbrev-ref", "HEAD" + "git", "branch", "--show-current" ] assert retval == "fake_branch_name" + @pytest.mark.benchmark def test_get_branch_name_with_exception(self, mocker): # Must return empty string when exception raises in cmd_output. m_cmd_output = mocker.patch( @@ -42,11 +45,51 @@ def test_get_branch_name_with_exception(self, mocker): retval = get_branch_name() assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "rev-parse", "--abbrev-ref", "HEAD" + "git", "branch", "--show-current" ] assert retval == "" + class TestHasCommits: + @pytest.mark.benchmark + def test_has_commits_true(self, mocker): + # Must return True when git rev-parse HEAD succeeds + m_subprocess_run = mocker.patch( + "subprocess.run", + return_value=None + ) + retval = has_commits() + assert m_subprocess_run.call_count == 1 + assert m_subprocess_run.call_args[0][0] == [ + "git", "rev-parse", "--verify", "HEAD" + ] + assert m_subprocess_run.call_args[1] == { + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + 'check': True + } + assert retval is True + + @pytest.mark.benchmark + def test_has_commits_false(self, mocker): + # Must return False when git rev-parse HEAD fails + m_subprocess_run = mocker.patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError(128, "git rev-parse") + ) + retval = has_commits() + assert m_subprocess_run.call_count == 1 + assert m_subprocess_run.call_args[0][0] == [ + "git", "rev-parse", "--verify", "HEAD" + ] + assert m_subprocess_run.call_args[1] == { + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + 'check': True + } + assert retval is False + class TestGitMergeBase: + @pytest.mark.benchmark @pytest.mark.parametrize("returncode,expected", [ (0, 0), # ancestor exists (1, 1), # no ancestor @@ -71,6 +114,7 @@ def test_git_merge_base(self, mocker, returncode, expected): assert result == expected class TestGetCommitInfo: + @pytest.mark.benchmark @pytest.mark.parametrize("format_string", [ ("s"), ("an"), @@ -78,7 +122,11 @@ class TestGetCommitInfo: ] ) def test_get_commit_info(self, mocker, format_string): - # Must call get_commit_info with given argument. + # Must call get_commit_info with given argument when there are commits. + mocker.patch( + "commit_check.util.has_commits", + return_value=True + ) m_cmd_output = mocker.patch( "commit_check.util.cmd_output", return_value=" fake commit message " @@ -90,8 +138,29 @@ def test_get_commit_info(self, mocker, format_string): ] assert retval == " fake commit message " + @pytest.mark.benchmark + def test_get_commit_info_no_commits(self, mocker): + # Must return 'Repo has no commits yet.' when there are no commits. + mocker.patch( + "commit_check.util.has_commits", + return_value=False + ) + mocker.patch( + "commit_check.util.cmd_output", + return_value=" fake commit message " + ) + format_string = "s" + retval = get_commit_info(format_string) + assert retval == " fake commit message " + + + @pytest.mark.benchmark def test_get_commit_info_with_exception(self, mocker): # Must return empty string when exception raises in cmd_output. + mocker.patch( + "commit_check.util.has_commits", + return_value=True + ) m_cmd_output = mocker.patch( "commit_check.util.cmd_output", return_value=" fake commit message " @@ -118,6 +187,7 @@ def __init__(self, returncode, stdout, stderr): self.stdout = stdout self.stderr = stderr + @pytest.mark.benchmark def test_cmd_output(self, mocker): # Must subprocess.run with given argument. m_subprocess_run = mocker.patch( @@ -128,6 +198,7 @@ def test_cmd_output(self, mocker): assert m_subprocess_run.call_count == 1 assert retval == "ok" + @pytest.mark.benchmark @pytest.mark.parametrize("returncode, stdout, stderr", [ (1, "ok", "err"), (0, None, "err"), @@ -152,6 +223,7 @@ def test_cmd_output_err(self, mocker, returncode, stdout, stderr): "stdout": PIPE } + @pytest.mark.benchmark @pytest.mark.parametrize("returncode, stdout, stderr", [ (1, "ok", ""), (0, None, ""), @@ -177,6 +249,7 @@ def test_cmd_output_err_with_len0_stderr(self, mocker, returncode, stdout, stder } class TestValidateConfig: + @pytest.mark.benchmark def test_validate_config(self, mocker): # Must call yaml.safe_load. mocker.patch("builtins.open") @@ -189,6 +262,7 @@ def test_validate_config(self, mocker): assert m_yaml_safe_load.call_count == 1 assert retval == dummy_resp + @pytest.mark.benchmark def test_validate_config_file_not_found(self, mocker): # Must return empty dictionary when FileNotFoundError raises in built-in open. mocker.patch("builtins.open").side_effect = FileNotFoundError @@ -198,6 +272,7 @@ def test_validate_config_file_not_found(self, mocker): assert retval == {} class TestPrintErrorMessage: + @pytest.mark.benchmark def test_print_error_header(self, capfd): # Must print on stdout with given argument. print_error_header() @@ -205,6 +280,7 @@ def test_print_error_header(self, capfd): assert "Commit rejected by Commit-Check" in stdout assert "Commit rejected." in stdout + @pytest.mark.benchmark @pytest.mark.parametrize("check_type, type_failed_msg", [ ("message", "check failed =>"), ("branch", "check failed =>"), @@ -230,12 +306,14 @@ def test_print_error_message(self, capfd, check_type, type_failed_msg): assert dummy_error in stdout class TestPrintSuggestion: + @pytest.mark.benchmark def test_print_suggestion(self, capfd): # Must print on stdout with given argument. print_suggestion("dummy suggest") stdout, _ = capfd.readouterr() assert "Suggest:" in stdout + @pytest.mark.benchmark def test_print_suggestion_exit1(self, capfd): # Must exit with 1 when "" passed with pytest.raises(SystemExit) as e: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy