diff --git a/.commit-check.yml b/.commit-check.yml index 853c91b..d215d90 100644 --- a/.commit-check.yml +++ b/.commit-check.yml @@ -7,15 +7,33 @@ checks: [optional footer(s)]\n\n More details please refer to https://www.conventionalcommits.org" suggest: please check your commit message whether matches above regex + - check: branch - regex: ^(bugfix|feature|release|hotfix|task)\/.+|(master)|(main)|(HEAD)|(PR-.+) - error: "Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/" + regex: ^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+) + error: "Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/" suggest: run command `git checkout -b type/branch_name` + - check: author_name - regex: ^[A-Za-z ,.\'-]+$|.*(\[bot]) + regex: ^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.\'-]+$|.*(\[bot]) error: The committer name seems invalid suggest: run command `git config user.name "Your Name"` + - check: author_email - regex: ^\S+@\S+\.\S+$ + regex: ^.+@.+$ error: The committer email seems invalid suggest: run command `git config user.email yourname@example.com` + + - check: commit_signoff + regex: Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+> + error: Signed-off-by not found in latest commit + suggest: run command `git commit -m "conventional commit message" --signoff` + + - check: merge_base + 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 new file mode 100644 index 0000000..37cffb1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @commit-check/developers diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug-report.yml similarity index 97% rename from .github/ISSUE_TEMPLATE/bug.yml rename to .github/ISSUE_TEMPLATE/bug-report.yml index a9e720b..9c89022 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,6 @@ name: Bug report description: something went wrong +labels: ["bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..ee8099e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest an idea for this project +labels: ["enhancement"] + +body: + - type: markdown + attributes: + value: >- + Hi there! + + We'd appreciate it if you could search on commit-check's existing issues before filing + a feature request. + + - type: textarea + attributes: + label: What's the problem this feature will solve? + description: >- + What are you trying to do, that you are unable to achieve with commit-check as it currently stands? + validations: + required: true + + - type: textarea + attributes: + label: Describe the solution you'd like + description: >- + A clear and concise description of what you want to happen. Please use examples + of real-world use cases that this would help with, and how it solves the + the problem described above. + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: >- + Add any other context, links, etc. relevant to the feature request. + validations: + required: false 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/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..0d0b1c9 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1 @@ +_extends: .github 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 new file mode 100644 index 0000000..13e989a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,14 @@ +name: PR Autolabeler + +permissions: + contents: write + pull-requests: write + +on: + # pull_request event is required for autolabeler + pull_request: + types: [opened, reopened, synchronize] + +jobs: + draft-release: + uses: commit-check/.github/.github/workflows/release-drafter.yml@main diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 38edf36..3163c6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,8 @@ name: main +permissions: + contents: write + on: push: branches: @@ -7,49 +10,45 @@ on: pull_request: paths: - "**.py" - - "**requirements*.txt" - pyproject.toml - ".github/workflows/main.yml" + - ".pre-commit-config.yaml" - "!docs/**" workflow_dispatch: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.x' - - name: Install dependencies + - name: Install nox run: | - pip install -r requirements-dev.txt - pip install -e . + python -m pip install --upgrade pip + python -m pip install nox - name: Run pre-commit run: | - pre-commit run --all-files - pre-commit try-repo . + nox -s lint + nox -s test-hook - name: Build wheel - run: python3 -m pip wheel --no-deps -w dist . + run: nox -s build - name: Upload wheel as artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: commit-check_wheel path: ${{ github.workspace }}/dist/*.whl - name: Run commit-check - run: | - python3 -m pip install dist/*.whl - commit-check -h - commit-check --message --branch --author-email + run: nox -s commit-check - name: Collect Coverage - run: | - coverage run --source commit_check -m pytest - coverage report && coverage xml - - uses: codecov/codecov-action@v3 + run: nox -s coverage + + - uses: codecov/codecov-action@v5.0.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml @@ -61,17 +60,20 @@ jobs: strategy: fail-fast: false matrix: - py: ['3.7', '3.8', '3.9', '3.10'] - os: ['windows-latest', ubuntu-latest] + py: ['3.9', '3.10', '3.11', '3.12', '3.13'] + os: ['windows-latest', 'ubuntu-24.04', 'macos-latest'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.py }} + - run: | + pip install --upgrade pip + pip install .[dev] - name: Download wheel artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: commit-check_wheel path: dist @@ -79,31 +81,36 @@ jobs: - name: Install test # using a wildcard as filename on Windows requires a bash shell shell: bash - run: python3 -m pip install dist/*.whl + run: nox -s install-wheel docs: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} # get current branch name + - uses: actions/setup-python@v5 with: python-version: "3.10" - - run: python -m pip install . -r docs/requirements.txt + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox - name: Build docs - working-directory: docs - run: sphinx-build -E -W -b html . _build/html + run: nox -s docs - name: Save built docs as artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: "commit-check_docs" - path: ${{ github.workspace }}/docs/_build/html + path: ${{ github.workspace }}/_build/html - - name: Upload to github pages + - name: Upload docs to github pages # only publish doc changes from main branch if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/_build/html + publish_dir: ./_build/html diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml deleted file mode 100644 index 7825360..0000000 --- a/.github/workflows/publish-image.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: publish image - -on: - workflow_dispatch: - inputs: - tag: - description: 'which tag want to build' - default: '' - required: true - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - 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/publish-package.yml b/.github/workflows/publish-package.yml index 9f92a74..9dcac79 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -4,17 +4,22 @@ on: release: branches: [main] types: [published] + workflow_dispatch: + +permissions: + id-token: write + attestations: write jobs: publish: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # use fetch --all for setuptools_scm to work with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -27,6 +32,11 @@ jobs: # Check distribution twine check dist/commit_check* + - name: Create attestations + uses: actions/attest-build-provenance@v2 + with: + subject-path: "dist/commit_check*" + - name: Publish package to TestPyPI if: github.event_name == 'workflow_dispatch' && github.repository == 'commit-check/commit-check' env: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..182ecec --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Release Drafter + +on: + push: + branches: + - "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/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml deleted file mode 100644 index fb23251..0000000 --- a/.github/workflows/scorecard.yml +++ /dev/null @@ -1,72 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. They are provided -# by a third-party and are governed by separate terms of service, privacy -# policy, and support documentation. - -name: Scorecard supply-chain security -on: - # For Branch-Protection check. Only the default branch is supported. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection - branch_protection_rule: - # To guarantee Maintained check is occasionally updated. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained - schedule: - - cron: '24 17 * * 0' - push: - branches: [ "main" ] - -# Declare default permissions as read only. -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Needed to publish results and get a badge (see publish_results below). - id-token: write - # Uncomment the permissions below if installing in a private repository. - # contents: read - # actions: read - - steps: - - name: "Checkout code" - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3 - with: - results_file: results.sarif - results_format: sarif - # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: - # - you want to enable the Branch-Protection check on a *public* repository, or - # - you are installing Scorecard on a *private* repository - # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. - # repo_token: ${{ secrets.SCORECARD_TOKEN }} - - # Public repositories: - # - Publish results to OpenSSF REST API for easy access by consumers - # - Allows the repository to include the Scorecard badge. - # - See https://github.com/ossf/scorecard-action#publishing-results. - # For private repositories: - # - `publish_results` will always be set to `false`, regardless - # of the value entered here. - publish_results: true - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: "Upload artifact" - uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d186a2a36cc67bfa1b860e6170d37fb9634742c7 # v2.2.11 - with: - sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 94f592d..6e9ea54 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,15 @@ __pycache__ .mypy_cache .vscode venv +.venv UNKNOWN.egg-info dist build tests/__pycache__ .coverage coverage.xml +.nox +_build/ # docs docs/_build diff --git a/.gitpod.yml b/.gitpod.yml index e6de223..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 -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 8c433c7..58c47aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,15 @@ -# https://pre-commit.com/ +# https://pre-commit.ci/ ci: autofix_commit_msg: 'ci: auto fixes from pre-commit.com hooks' autoupdate_commit_msg: 'ci: pre-commit autoupdate' - skip: [pytest] + autoupdate_schedule: quarterly +# https://pre-commit.com/ +# prepare-commit-msg is used by hook id: check-message +default_install_hook_types: [pre-commit, prepare-commit-msg] repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: check-yaml - id: check-toml @@ -14,33 +17,30 @@ repos: - id: trailing-whitespace - id: name-tests-test - id: requirements-txt-fixer -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.12.2 hooks: - - id: flake8 - args: [--max-line-length=100, --ignore=E501] - exclude: ^commit_check/__init__.py + # Run the linter. + - id: ruff + args: [ --fix ] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.2.0 + rev: v1.16.1 hooks: - id: mypy - additional_dependencies: [types-all] + additional_dependencies: [types-PyYAML] exclude: ^testing/resources/ - repo: https://github.com/codespell-project/codespell - rev: v2.2.4 + rev: v2.4.1 hooks: - id: codespell - repo: https://github.com/commit-check/commit-check - rev: v0.5.6 + rev: v0.9.8 hooks: - id: check-message - - id: check-branch - - id: check-author-email -- repo: local - hooks: - - id: pytest - name: pytest - entry: pytest - language: system - pass_filenames: false - always_run: true + # - id: check-branch # uncomment if you need. + - id: check-author-name # uncomment if you need. + - id: check-author-email # 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 7327dbd..6a73596 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,29 +1,50 @@ - id: check-message name: check commit message - description: requiring commit message to match regex - entry: env IS_PRE_COMMIT=1 commit-check + description: ensures commit message to match regex + entry: commit-check args: [--message] - pass_filenames: false + pass_filenames: true language: python - stages: [commit-msg, commit] + stages: [commit-msg, prepare-commit-msg] - id: check-branch name: check branch naming - description: requiring branch naming to match regex + description: ensures branch naming to match regex entry: commit-check args: [--branch] pass_filenames: false language: python - id: check-author-name name: check committer name - description: requiring committer name to match regex + description: ensures committer name to match regex entry: commit-check args: [--author-name] pass_filenames: false language: python - id: check-author-email name: check committer email - description: requiring committer email to match regex + description: ensures committer email to match regex entry: commit-check args: [--author-email] pass_filenames: false language: python +- id: check-commit-signoff + name: check committer signoff + description: ensures committer to add a Signed-off-by trailer + entry: commit-check + args: [--commit-signoff] + pass_filenames: false + language: python +- id: check-merge-base + name: check merge base + description: ensures current branch is rebased onto target branch + entry: commit-check + 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b2b7160 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +Thank you for investing your time in contributing to our project! We welcome feedback, bug reports, and pull requests! + +## New contributor guide + +Our development branch is `main`. When submitting pull requests, please adhere to the following guidelines: + +* Add tests for any new features and bug fixes. +* Put a reasonable amount of comments into the code. +* Fork [commit-check](https://github.com/commit-check/commit-check) on your GitHub user account. +* Create a branch from `main`, make your changes on the new branch, and then create a PR against the `main` branch of the commit-check repository. +* Separate unrelated changes into multiple pull requests for better review and management. + +By contributing any code or documentation to this repository (by raising pull requests or otherwise), you explicitly agree to the [License Agreement](https://github.com/commit-check/commit-check/blob/main/LICENSE). + +We appreciate your contributions to make Commit Check even better! + +## Development + +### Debug commit-check pre-commit hook + +```bash +pre-commit try-repo ./../commit-check/ check-message --verbose --hook-stage prepare-commit-msg --commit-msg-filename .git/COMMIT_EDITMSG +``` + +### Debug commit-check wheel package + +```bash +python3 -m pip install --upgrade pip +pip install -e ./../commit-check/ +commit-check -m +``` + +### Test commit-check pre-commit hook on GitHub + +```yaml +- repo: https://github.com/commit-check/commit-check + rev: the tag or revision # update it to test commit hash + hooks: + - id: check-message + - id: check-branch + - id: check-author-email +``` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9d54507..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM python:3.11-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 - -ENTRYPOINT [ "commit-check" ] diff --git a/README.rst b/README.rst index 32e6071..bfda1a0 100644 --- a/README.rst +++ b/README.rst @@ -1,71 +1,59 @@ Commit Check ============ -.. image:: https://img.shields.io/pypi/v/commit-check?logo=python&logoColor=white +.. |pypi-version| image:: https://img.shields.io/pypi/v/commit-check?logo=python&logoColor=white :target: https://pypi.org/project/commit-check/ :alt: PyPI -.. image:: https://github.com/commit-check/commit-check/actions/workflows/main.yml/badge.svg +.. |ci-badge| image:: https://github.com/commit-check/commit-check/actions/workflows/main.yml/badge.svg :target: https://github.com/commit-check/commit-check/actions/workflows/main.yml :alt: CI -.. image:: https://sonarcloud.io/api/project_badges/measure?project=commit-check_commit-check&metric=alert_status +.. |sonar-badge| image:: https://sonarcloud.io/api/project_badges/measure?project=commit-check_commit-check&metric=alert_status :target: https://sonarcloud.io/summary/new_code?id=commit-check_commit-check :alt: Quality Gate Status -.. image:: https://codecov.io/gh/commit-check/commit-check/branch/main/graph/badge.svg?token=GC2U5V5ZRT +.. |codecov-badge| image:: https://codecov.io/gh/commit-check/commit-check/branch/main/graph/badge.svg?token=GC2U5V5ZRT :target: https://codecov.io/gh/commit-check/commit-check :alt: CodeCov -.. image:: https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white +.. |commit-check-badge| image:: https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white :target: https://github.com/commit-check/commit-check :alt: commit-check -Overview --------- - -Check commit message formatting, branch naming, committer name, email, and more. Alternative to Yet Another Commit Checker. +.. |slsa-badge| image:: https://slsa.dev/images/gh-badge-level3.svg + :target: https://slsa.dev + :alt: SLSA -- requiring commit message to match regex -- requiring branch naming to match regex -- requiring committer name and email to match regex -- customizing error message -- customizing suggest command +|pypi-version| |ci-badge| |sonar-badge| |codecov-badge| |commit-check-badge| |slsa-badge| -Purpose -------- +Overview +-------- -commit-check is a tool designed for teams. +**Commit Check** is a free, powerful tool that enforces commit metadata standards, including commit message, branch naming, committer name/email, commit signoff and more. -Its main purpose is to standardize the format of commit message, branch naming, etc, and makes it possible to: +Fully customizable with error messages and suggested commands, it ensures compliance across teams. -- writing descriptive commit is easy to read -- identify branch according to the branch type -- triggering the specific types of commit/branch CI build -- automatically generate changelogs +As an alternative to GitHub Enterprise `Metadata restrictions `_ and Bitbucket's paid plugin `Yet Another Commit Checker `_, Commit Check stands out by integrating DevOps principles and Infrastructure as Code (IaC). Configuration ------------- -Use custom configuration -~~~~~~~~~~~~~~~~~~~~~~~~ - -Create a config file ``.commit-check.yml`` under your repository root directory, e.g. `.commit-check.yml `_ - -Use default configuration +Use Default Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~ -- If you did'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. -- i.e. the commit message will follow the rules of `conventional commits `_, - branch naming follow bitbucket `branching model `_. +- 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 +~~~~~~~~~~~~~~~~~~~~~~~~ + +To customize the behavior, create a config file ``.commit-check.yml`` under your repository's root directory, e.g., `.commit-check.yml `_ Usage ----- -There are a variety of ways you can use commit-check as follows. - Running as GitHub Action ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -83,58 +71,59 @@ Running as pre-commit hook - repo: https://github.com/commit-check/commit-check rev: the tag or revision hooks: # support hooks - - id: check-message + - id: check-message # requires prepare-commit-msg hook - id: check-branch - id: check-author-name - id: check-author-email + - id: check-commit-signoff + - id: check-merge-base # requires download all git history + - id: check-imperative Running as CLI ~~~~~~~~~~~~~~ -Global installation +Install globally .. code-block:: bash sudo pip3 install -U commit-check -User installation +Install locally .. code-block:: bash pip install -U commit-check -Install from git repo +Install from source code .. code-block:: bash pip install git+https://github.com/commit-check/commit-check.git@main -Then you can run ``commit-check`` command line. More about ``commit-check --help`` please see `docs `_. +Then, run ``commit-check --help`` from the command line. For more information, see the `docs `_. Running as Git Hooks ~~~~~~~~~~~~~~~~~~~~ -To configure the hook, you need to create a new script file in the ``.git/hooks/`` directory of your Git repository. - -Here is an example script that you can use to set up the hook: +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-check --message --branch --author-name --author-email --commit-signoff --merge-base --imperative -Save the script file to ``pre-push`` and make it executable by running the following command: +Save the script file as ``pre-push`` and make it executable: .. code-block:: bash chmod +x .git/hooks/pre-push -Then when you run ``git push`` command, this push hook will be run automatically. +Now, ``git push`` will trigger this hook automatically. -Example -------- +Examples +-------- -Check commit message failed +Check Commit Message Failed .. code-block:: text @@ -149,7 +138,7 @@ Check commit message failed (.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.)(.-./`-´\.-.) `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ `-´ - Invalid commit message => test + Type message check failed => my test commit message It doesn't match regex: ^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*) The commit message should be structured as follows: @@ -162,7 +151,7 @@ Check commit message failed Suggest: please check your commit message whether matches above regex -Check branch naming failed +Check Branch Naming Failed .. code-block:: text @@ -179,17 +168,62 @@ Check branch naming failed Commit rejected. - Invalid branch name => test - It doesn't match regex: ^(bugfix|feature|release|hotfix|task)\/.+|(master)|(main)|(HEAD)|(PR-.+) - - Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ + Type branch check failed => patch-1 + It doesn't match regex: ^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+) + Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/ Suggest: run command `git checkout -b type/branch_name` +Check Commit Signature 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 commit_signoff check failed => c92ce259ff041c91859c7fb61afdbb391e769d0f + It doesn't match regex: Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+> + Signed-off-by not found in latest commit + 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 ----------------------- -You can add a badge to your repository to show your contributors / users that you use commit-check! +You can add a badge to your repository to show that you use commit-check! .. image:: https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white :target: https://github.com/commit-check/commit-check @@ -218,7 +252,7 @@ Versioning follows `Semantic Versioning `_. Have question or feedback? -------------------------- -To provide feedback (requesting a feature or reporting a bug) please post to `issues `_. +Please post to `issues `_ for feedback, feature requests, or bug reports. License ------- diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 6abc60a..8b78762 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -1,4 +1,5 @@ """The commit-check package's base module.""" +from importlib.metadata import version RED = '\033[0;31m' GREEN = "\033[32m" @@ -25,22 +26,40 @@ }, { 'check': 'branch', - 'regex': r'^(bugfix|feature|release|hotfix|task)\/.+|(master)|(main)|(HEAD)|(PR-.+)', - 'error': 'Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/', + 'regex': r'^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+)', + 'error': 'Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/', 'suggest': 'run command `git checkout -b type/branch_name`', }, { 'check': 'author_name', - 'regex': r'^[A-Za-z ,.\'-]+$|.*(\[bot])', + 'regex': r'^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.\'-]+$|.*(\[bot])', 'error': 'The committer name seems invalid', 'suggest': 'run command `git config user.name "Your Name"`', }, { 'check': 'author_email', - 'regex': r'^\S+@\S+\.\S+$', + 'regex': r'^.+@.+$', 'error': 'The committer\'s email seems invalid', 'suggest': 'run command `git config user.email yourname@example.com`', }, + { + 'check': 'commit_signoff', + 'regex': r'Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>', + 'error': 'Signed-off-by not found in latest commit', + 'suggest': 'run command `git commit -m "conventional commit message" --signoff`', + }, + { + 'check': 'merge_base', + 'regex': r'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': 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"', + }, ], } @@ -50,3 +69,5 @@ """ CONFIG_FILE = '.commit-check.yml' + +__version__ = version("commit-check") diff --git a/commit_check/author.py b/commit_check/author.py index 94a0b59..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_commits_info, 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'] == "": @@ -16,9 +19,11 @@ def check_author(checks: list, check_type: str) -> int: format_str = "an" if check_type == 'author_email': format_str = "ae" - config_value = str(get_commits_info(format_str)) + config_value = str(get_commit_info(format_str)) result = re.match(check['regex'], config_value) if result is None: + if not print_error_header.has_been_called: + print_error_header() print_error_message( check['check'], check['regex'], check['error'], config_value, diff --git a/commit_check/branch.py b/commit_check/branch.py index b1ef580..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, 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: @@ -15,6 +15,8 @@ def check_branch(checks: list) -> int: branch_name = get_branch_name() result = re.match(check['regex'], branch_name) if result is None: + if not print_error_header.has_been_called: + print_error_header() # pragma: no cover print_error_message( check['check'], check['regex'], check['error'], branch_name, @@ -23,3 +25,35 @@ def check_branch(checks: list) -> int: print_suggestion(check['suggest']) return FAIL return PASS + + +def check_merge_base(checks: list) -> int: + """Check if the current branch is based on the latest target branch. + params checks: List of check configurations containing merge_base rules + + :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'] == "": + print( + f"{YELLOW}Not found target branch for checking merge base. skip checking.{RESET_COLOR}", + ) + return PASS + target_branch = check['regex'] if "origin/" in check['regex'] else f"origin/{check['regex']}" + current_branch = get_branch_name() + result = git_merge_base(target_branch, current_branch) + if result != 0: + if not print_error_header.has_been_called: + print_error_header() # pragma: no cover + print_error_message( + check['check'], check['regex'], + check['error'], current_branch, + ) + if check['suggest']: + print_suggestion(check['suggest']) + return FAIL + return PASS diff --git a/commit_check/commit.py b/commit_check/commit.py index 6ceb1c7..fccc5da 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -1,12 +1,42 @@ """Check git commit message formatting""" import re -import os from pathlib import PurePath from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import cmd_output, get_commits_info, 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 check_commit_msg(checks: list) -> int: +def _load_imperatives() -> set: + """Load imperative verbs from imperatives module.""" + return IMPERATIVES + + +def get_default_commit_msg_file() -> str: + """Get the default commit message file.""" + git_dir = cmd_output(['git', 'rev-parse', '--git-dir']).strip() + return str(PurePath(git_dir, "COMMIT_EDITMSG")) + + +def read_commit_msg(commit_msg_file) -> str: + """Read the commit message from the specified file.""" + try: + with open(commit_msg_file, 'r') as f: + return f.read() + except FileNotFoundError: + # Commit message is composed by subject and body + return str(get_commit_info("s") + "\n\n" + get_commit_info("b")) + + +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['check'] == 'message': if check['regex'] == "": @@ -14,22 +44,11 @@ def check_commit_msg(checks: list) -> int: f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}", ) return PASS - commit_msg = "" - if os.environ.get("IS_PRE_COMMIT"): - # check the message of the current commit - git_dir = cmd_output(['git', 'rev-parse', '--git-dir']).strip() - commit_msg_file = PurePath(git_dir, "COMMIT_EDITMSG") - try: - with open(commit_msg_file, 'r') as f: - commit_msg = f.read() - except FileNotFoundError: - # check the message of the last commit - commit_msg = str(get_commits_info("s")) - else: - # check the message of the last commit - commit_msg = str(get_commits_info("s")) + result = re.match(check['regex'], commit_msg) if result is None: + if not print_error_header.has_been_called: + print_error_header() # pragma: no cover print_error_message( check['check'], check['regex'], check['error'], commit_msg, @@ -37,4 +56,126 @@ def check_commit_msg(checks: list) -> int: if check['suggest']: print_suggestion(check['suggest']) return FAIL + + return PASS + + +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() + + for check in checks: + if check['check'] == 'commit_signoff': + if check['regex'] == "": + print( + f"{YELLOW}Not found regex for commit signoff. skip checking.{RESET_COLOR}", + ) + return PASS + + commit_msg = read_commit_msg(commit_msg_file) + commit_hash = get_commit_info("H") + result = re.search(check['regex'], commit_msg) + if result is None: + if not print_error_header.has_been_called: + print_error_header() # pragma: no cover + print_error_message( + check['check'], check['regex'], + check['error'], commit_hash, + ) + if check['suggest']: + print_suggestion(check['suggest']) + 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/error.py b/commit_check/error.py index 3c6c801..2bdfa9b 100644 --- a/commit_check/error.py +++ b/commit_check/error.py @@ -28,8 +28,8 @@ def error_handler() -> Generator[None, None, None]: def log_and_exit(msg: str, ret_code: int, exc: BaseException, formatted: str) -> None: error_msg = f'{msg}: {type(exc).__name__}: {exc}' - git_version = cmd_output(['git', '--version']) commit_check_version = cmd_output(['commit-check', '--version']) + git_version = cmd_output(['git', '--version']) store_dir = os.environ.get('COMMIT_CHECK_HOME') or os.path.join( os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache'), 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 8c4ec84..f81af48 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -8,9 +8,9 @@ from commit_check import branch from commit_check import commit from commit_check import author -from commit_check.util import validate_config, get_version +from commit_check.util import validate_config from commit_check.error import error_handler -from . import RESET_COLOR, YELLOW, CONFIG_FILE, DEFAULT_CONFIG, PASS +from . import CONFIG_FILE, DEFAULT_CONFIG, PASS, FAIL, __version__ def get_parser() -> argparse.ArgumentParser: @@ -24,14 +24,14 @@ def get_parser() -> argparse.ArgumentParser: '-v', '--version', action='version', - version=f'%(prog)s {get_version()}', + version=f'%(prog)s {__version__}', ) parser.add_argument( '-c', '--config', default=CONFIG_FILE, - help='path to config file. default is .', + help='path to config file. default is . (current directory)', ) parser.add_argument( @@ -42,6 +42,8 @@ def get_parser() -> argparse.ArgumentParser: required=False, ) + parser.add_argument('commit_msg_file', nargs='?', help='commit message file') + parser.add_argument( '-b', '--branch', @@ -66,6 +68,22 @@ def get_parser() -> argparse.ArgumentParser: required=False, ) + parser.add_argument( + '-s', + '--commit-signoff', + help='check committer\'s signature', + action="store_true", + required=False, + ) + + parser.add_argument( + '-mb', + '--merge-base', + help='check branch is rebased onto target branch', + action="store_true", + required=False, + ) + parser.add_argument( '-d', '--dry-run', @@ -74,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 @@ -81,32 +107,33 @@ def main() -> int: """The main entrypoint of commit-check program.""" parser = get_parser() args = parser.parse_args() - retval = PASS - - if not any([args.message, args.branch, args.author_name, args.author_email]): - print( - f'\n{YELLOW}Nothing to do because `--message`, `--branch`, `--author-name`, `--author-email`', - f'was not specified.{RESET_COLOR}\n', - ) - parser.print_help() - else: - with error_handler(): - config = validate_config(args.config) if validate_config( - args.config, - ) else DEFAULT_CONFIG - checks = config['checks'] - if args.message: - retval = commit.check_commit_msg(checks) - if args.author_name: - retval = author.check_author(checks, "author_name") - if args.author_email: - retval = author.check_author(checks, "author_email") - if args.branch: - retval = branch.check_branch(checks) if args.dry_run: - retval = PASS - return retval + return PASS + + check_results: list[int] = [] + + with error_handler(): + config = validate_config(args.config) if validate_config( + args.config, + ) else DEFAULT_CONFIG + checks = config['checks'] + if args.message: + check_results.append(commit.check_commit_msg(checks, args.commit_msg_file)) + if args.author_name: + check_results.append(author.check_author(checks, "author_name")) + if args.author_email: + check_results.append(author.check_author(checks, "author_email")) + if args.branch: + check_results.append(branch.check_branch(checks)) + if args.commit_signoff: + 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 if __name__ == '__main__': diff --git a/commit_check/util.py b/commit_check/util.py index 4dfb0ad..6d3c33b 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -9,19 +9,7 @@ import yaml from pathlib import PurePath from subprocess import CalledProcessError -from commit_check import RED, GREEN, RESET_COLOR - - -def get_version() -> str: - """Get current tag name - :returns: A `str` describing the version. - """ - try: - commands = ['git', 'describe', '--tags'] - version = cmd_output(commands) - except CalledProcessError: - version = '' - return version +from commit_check import RED, GREEN, YELLOW, RESET_COLOR def get_branch_name() -> str: @@ -34,25 +22,44 @@ 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 get_commits_info(format_string: str) -> str: +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 - s - subject - an - author name - ae - author email + - b - body + - H - commit hash + more: https://git-scm.com/docs/pretty-formats :returns: A `str`. """ try: commands = [ - 'git', 'log', '-n', '1', f"--pretty=format:%{format_string}", + 'git', 'log', '-n', '1', f"--pretty=format:%{format_string}", f"{sha}", ] output = cmd_output(commands) except CalledProcessError: @@ -60,6 +67,23 @@ def get_commits_info(format_string: str) -> str: return output +def git_merge_base(target_branch: str, current_branch: str) -> int: + """Check ancestors for a given commit. + :param target_branch: target branch + :param current_branch: default is HEAD + + :returns: 0 if ancestor exists, 1 if not, 128 if git command fails. + """ + try: + commands = ['git', 'merge-base', '--is-ancestor', f'{target_branch}', f'{current_branch}'] + result = subprocess.run( + commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + ) + return result.returncode + except CalledProcessError: + return 128 + + def cmd_output(commands: list) -> str: """Run command :param commands: list of commands @@ -92,14 +116,18 @@ def validate_config(path_to_config: str) -> dict: return configuration -def print_error_message(check_type: str, regex: str, error: str, error_point: str): - """Print error message. - :param check_type: - :param regex: - :param error: - :param error_point: +def track_print_call(func): + def wrapper(*args, **kwargs): + wrapper.has_been_called = True + return func(*args, **kwargs) + wrapper.has_been_called = False # Initialize as False + return wrapper - :returns: Give error messages to user + +@track_print_call +def print_error_header(): + """Print error message. + :returns: Print error head to user """ print("Commit rejected by Commit-Check. ") print(" ") @@ -114,27 +142,20 @@ def print_error_message(check_type: str, regex: str, error: str, error_point: st print(" ") print("Commit rejected. ") print(" ") - if check_type == "message": - print( - f"Invalid commit message => {RED}{error_point}{RESET_COLOR} ", end='', - ) - elif check_type == "branch": - print( - f"Invalid branch name => {RED}{error_point}{RESET_COLOR} ", end='', - ) - elif check_type == "author_name": - print( - f"Invalid author name => {RED}{error_point}{RESET_COLOR} ", end='', - ) - elif check_type == "author_email": - print( - f"Invalid email address => {RED}{error_point}{RESET_COLOR} ", end='', - ) - else: - print(f"commit-check does not support {check_type} yet.") - raise SystemExit(1) - print(f"\nIt doesn't match regex: {regex}") + + +def print_error_message(check_type: str, regex: str, error: str, reason: str): + """Print error message. + :param check_type: + :param regex: + :param error: + :param reason: + + :returns: Give error messages to user + """ + print(f"Type {YELLOW}{check_type}{RESET_COLOR} check failed => {RED}{reason}{RESET_COLOR} ", end='',) print("") + print(f"It doesn't match regex: {regex}") print(error) diff --git a/docs/README.rst b/docs/README.rst index cdb46e5..d296410 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -9,15 +9,13 @@ From the root directory of the repository, do the following to steps .. code-block:: text - pip install -r docs/requirements.txt - - On Linux, you may need to use ``pip3`` instead. + pip install nox 2. Build the docs .. code-block:: text - sphinx-build docs docs/_build/html + nox -s docs - Browse the files in docs/_build/html with your internet browser to see the rendered + Browse the files in /_build/html with your internet browser to see the rendered output. diff --git a/docs/conf.py b/docs/conf.py index f006dc5..2f3e1d9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,7 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html import re +import datetime from pathlib import Path import io from sphinx.application import Sphinx @@ -12,7 +13,7 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "commit-check" -copyright = "2023, shenxianpeng" +copyright = f"{datetime.date.today().year}, shenxianpeng" author = "shenxianpeng" # -- General configuration --------------------------------------------------- @@ -24,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"] @@ -49,7 +45,6 @@ html_theme_options = { "repo_url": "https://github.com/commit-check/commit-check", "repo_name": "commit-check", - "repo_type": "github", "palette": [ { "media": "(prefers-color-scheme: light)", diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index d078f24..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -sphinx-immaterial diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..50599af --- /dev/null +++ b/noxfile.py @@ -0,0 +1,67 @@ +import nox +import glob + +nox.options.reuse_existing_virtualenvs = True +nox.options.sessions = ["lint"] + +# ----------------------------------------------------------------------------- +# Development Commands +# ----------------------------------------------------------------------------- + + +@nox.session() +def lint(session): + session.install("pre-commit") + # only need pre-commit hook for local development + session.run("pre-commit", "install", "--hook-type", "pre-commit") + if session.posargs: + args = session.posargs + ["--all-files"] + else: + args = ["--all-files", "--show-diff-on-failure"] + + session.run("pre-commit", "run", *args) + + +@nox.session(name="test-hook") +def test_hook(session): + session.install("-e", ".") + session.install("pre-commit") + session.run("pre-commit", "try-repo", ".") + + +@nox.session() +def build(session): + session.run("python3", "-m", "pip", "wheel", "--no-deps", "-w", "dist", ".") + + +@nox.session(name="install-wheel", requires=["build"]) +def install_wheel(session): + session.run("python3", "-m", "pip", "wheel", "--no-deps", "-w", "dist", ".") + whl_file = glob.glob("dist/*.whl") + session.install(str(whl_file[0])) + + +@nox.session(name="commit-check") +def commit_check(session): + session.install(".") + session.run("commit-check", "--message", "--branch", "--author-email") + + +@nox.session() +def coverage(session): + session.install('.[test]') + session.run("coverage", "run", "--source", "commit_check", "-m", "pytest") + session.run("coverage", "report") + session.run("coverage", "xml") + + +@nox.session() +def docs(session): + 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('.[docs]') + session.run("sphinx-autobuild", "-b", "html", "docs", "_build/html") diff --git a/pyproject.toml b/pyproject.toml index a672861..8c0db4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,29 @@ [build-system] -requires = ["setuptools>=61", "setuptools-scm"] +requires = ["setuptools>=77", "setuptools-scm"] build-backend = "setuptools.build_meta" [project] name = "commit-check" description = "Check commit message formatting, branch naming, commit author, email, and more." readme = "README.rst" -keywords = ["commit conventions", "conventional commits", "branch naming", "commit-check", "message", "lint message"] -license = {text = "MIT License"} +keywords = ["commit conventions", "conventional commits", "conventional branch", "branch naming", "commit-check", "message", "lint message", "devops"] +license = "MIT" authors = [ - { name = "Peter Shen", email = "xianpeng.shen@gmail.com" }, + { name = "Xianpeng Shen", email = "xianpeng.shen@gmail.com" }, ] +requires-python = ">=3.9" dependencies = ["pyyaml"] classifiers = [ # https://pypi.org/pypi?%3Aaction=list_classifiers - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Utilities" , "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Libraries :: Python Modules", @@ -43,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 787fd29..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -coverage -pre-commit -pytest -pytest-mock diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c3726e8..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyyaml diff --git a/tests/author_test.py b/tests/author_test.py index 04df99c..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 @@ -7,17 +8,19 @@ class TestAuthor: class TestAuthorName: - # used by get_commits_info mock + # used by get_commit_info mock 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_commits_info, re.match. + # Must call get_commit_info, re.match. checks = [{ "check": "author_name", "regex": "dummy_regex" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) m_re_match = mocker.patch( @@ -26,14 +29,35 @@ def test_check_author(self, mocker): ) retval = check_author(checks, "author_name") assert retval == PASS - assert m_get_commits_info.call_count == 1 + 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 = [{ + "check": "author_name", + "regex": "dummy_regex" + }] + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", + return_value=self.fake_accented_author_value_an + ) + m_re_match = mocker.patch( + "re.match", + return_value="fake_rematch_resp" + ) + retval = check_author(checks, "author_name") + assert retval == PASS + 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_commits_info, re.match. with `checks` param with length 0. + # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) m_re_match = mocker.patch( @@ -42,17 +66,18 @@ def test_check_author_with_empty_checks(self, mocker): ) retval = check_author(checks, "author_name") assert retval == PASS - assert m_get_commits_info.call_count == 0 + 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 = [{ "check": "message", "regex": "dummy_regex" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) m_re_match = mocker.patch( @@ -61,19 +86,20 @@ def test_check_author_with_different_check(self, mocker): ) retval = check_author(checks, "author_name") assert retval == PASS - assert m_get_commits_info.call_count == 0 + 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_commits_info, re.match with `regex` with length 0. + # Must NOT call get_commit_info, re.match with `regex` with length 0. checks = [ { "check": "author_name", "regex": "" } ] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) m_re_match = mocker.patch( @@ -82,11 +108,12 @@ def test_check_author_with_len0_regex(self, mocker, capfd): ) retval = check_author(checks, "author_name") assert retval == PASS - assert m_get_commits_info.call_count == 0 + assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 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 = [{ @@ -95,8 +122,8 @@ def test_check_author_with_result_none(self, mocker): "error": "error", "suggest": "suggest" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) m_re_match = mocker.patch( @@ -111,23 +138,24 @@ def test_check_author_with_result_none(self, mocker): ) retval = check_author(checks, "author_name") assert retval == FAIL - assert m_get_commits_info.call_count == 1 + assert m_get_commit_info.call_count == 1 assert m_re_match.call_count == 1 assert m_print_error_message.call_count == 1 assert m_print_suggestion.call_count == 1 class TestAuthorEmail: - # used by get_commits_info mock + # 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_commits_info, re.match. + # Must call get_commit_info, re.match. checks = [{ "check": "author_email", "regex": "dummy_regex" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) m_re_match = mocker.patch( @@ -136,14 +164,15 @@ def test_check_author(self, mocker): ) retval = check_author(checks, "author_email") assert retval == PASS - assert m_get_commits_info.call_count == 1 + 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_commits_info, re.match. with `checks` param with length 0. + # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) m_re_match = mocker.patch( @@ -152,17 +181,18 @@ def test_check_author_with_empty_checks(self, mocker): ) retval = check_author(checks, "author_email") assert retval == PASS - assert m_get_commits_info.call_count == 0 + 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 = [{ "check": "message", "regex": "dummy_regex" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) m_re_match = mocker.patch( @@ -171,19 +201,20 @@ def test_check_author_with_different_check(self, mocker): ) retval = check_author(checks, "author_email") assert retval == PASS - assert m_get_commits_info.call_count == 0 + 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_commits_info, re.match with `regex` with length 0. + # Must NOT call get_commit_info, re.match with `regex` with length 0. checks = [ { "check": "author_email", "regex": "" } ] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) m_re_match = mocker.patch( @@ -192,11 +223,12 @@ def test_check_author_with_len0_regex(self, mocker, capfd): ) retval = check_author(checks, "author_email") assert retval == PASS - assert m_get_commits_info.call_count == 0 + assert m_get_commit_info.call_count == 0 assert m_re_match.call_count == 0 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 = [{ @@ -205,8 +237,8 @@ def test_check_author_with_result_none(self, mocker): "error": "error", "suggest": "suggest" }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", + m_get_commit_info = mocker.patch( + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) m_re_match = mocker.patch( @@ -221,7 +253,7 @@ def test_check_author_with_result_none(self, mocker): ) retval = check_author(checks, "author_email") assert retval == FAIL - assert m_get_commits_info.call_count == 1 + assert m_get_commit_info.call_count == 1 assert m_re_match.call_count == 1 assert m_print_error_message.call_count == 1 assert m_print_suggestion.call_count == 1 diff --git a/tests/branch_test.py b/tests/branch_test.py index 7093eca..34e3a3b 100644 --- a/tests/branch_test.py +++ b/tests/branch_test.py @@ -1,13 +1,14 @@ +import pytest from commit_check import PASS, FAIL -from commit_check.branch import check_branch +from commit_check.branch import check_branch, check_merge_base # used by get_branch_name mock FAKE_BRANCH_NAME = "fake_branch_name" -# The location of check_branch() LOCATION = "commit_check.branch" -class TestBranch: +class TestCheckBranch: + @pytest.mark.benchmark def test_check_branch(self, mocker): # Must call get_branch_name, re.match at once. checks = [{ @@ -27,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 = [] @@ -43,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 = [{ @@ -62,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 = [ @@ -85,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 = [{ @@ -113,3 +118,52 @@ def test_check_branch_with_result_none(self, mocker): assert m_re_match.call_count == 1 assert m_print_error_message.call_count == 1 assert m_print_suggestion.call_count == 1 + + +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") + retval = check_merge_base(checks) + 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", + "regex": "" + }] + m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") + retval = check_merge_base(checks) + 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", + "regex": "main" + }] + m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") + retval = check_merge_base(checks) + 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", + "regex": "develop", + "error": "Current branch is not", + "suggest": "Please rebase" + }] + mocker.patch(f"{LOCATION}.check_merge_base", return_value=1) + m_print_error = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggest = mocker.patch(f"{LOCATION}.print_suggestion") + + retval = check_merge_base(checks) + assert retval == FAIL + assert "Current branch is not" in m_print_error.call_args[0][2] + assert "Please rebase" in m_print_suggest.call_args[0][0] diff --git a/tests/commit_test.py b/tests/commit_test.py index 25da670..9a1235d 100644 --- a/tests/commit_test.py +++ b/tests/commit_test.py @@ -1,137 +1,400 @@ -import os +import pytest from commit_check import PASS, FAIL -from commit_check.commit import check_commit_msg +from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff, check_imperative -# used by get_commits_info mock +# used by get_commit_info mock FAKE_BRANCH_NAME = "fake_commits_info" # The location of check_commit_msg() LOCATION = "commit_check.commit" +# Commit message file +MSG_FILE = '.git/COMMIT_EDITMSG' -class TestCommit: +@pytest.mark.benchmark +def test_get_default_commit_msg_file(mocker): + retval = get_default_commit_msg_file() + assert retval == ".git/COMMIT_EDITMSG" - def test_check_commit_without_env(self, mocker): - # Must call get_commits_info, re.match. - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" - ) - retval = check_commit_msg(checks) - assert retval == PASS - assert m_get_commits_info.call_count == 1 - assert m_re_match.call_count == 1 - - def test_check_commit_with_env(self, mocker): - # Must call get_commits_info, re.match. - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" - ) - os.environ["IS_PRE_COMMIT"] = "1" - retval = check_commit_msg(checks) - assert retval == PASS - assert m_get_commits_info.call_count == 1 - assert m_re_match.call_count == 1 - - def test_check_commit_with_empty_checks(self, mocker): - # Must NOT call get_commits_info, re.match. with `checks` param with length 0. - checks = [] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) - retval = check_commit_msg(checks) - assert retval == PASS - assert m_get_commits_info.call_count == 0 - assert m_re_match.call_count == 0 - - def test_check_commit_with_different_check(self, mocker): - # Must NOT call get_commit_info, re.match with not `message`. - checks = [{ - "check": "branch", - "regex": "dummy_regex" - }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) - retval = check_commit_msg(checks) - assert retval == PASS - assert m_get_commits_info.call_count == 0 - assert m_re_match.call_count == 0 - - def test_check_commit_with_len0_regex(self, mocker, capfd): - # Must NOT call get_commits_info, re.match with `regex` with length 0. - checks = [ - { - "check": "message", - "regex": "" - } - ] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" - ) - retval = check_commit_msg(checks) - assert retval == PASS - assert m_get_commits_info.call_count == 0 - assert m_re_match.call_count == 0 - out, _ = capfd.readouterr() - assert "Not found regex for commit message." in out - - def test_check_commit_with_result_none(self, mocker): - # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [{ + +@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." + commit_msg_file = tmp_path / "test_commit_msg.txt" + commit_msg_file.write_text(commit_msg_content) + + result = read_commit_msg(commit_msg_file) + 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", + return_value=".git/COMMIT_EDITMSG" + ) + mock_read_commit_msg = mocker.patch( + "commit_check.commit.read_commit_msg", + return_value="Sample commit message" + ) + + checks = [{"regex": ".*", "check": "message", "error": "Invalid", "suggest": None}] + + result = check_commit_msg(checks, commit_msg_file="") + + mock_get_default_commit_msg_file.assert_called_once() + mock_read_commit_msg.assert_called_once_with(".git/COMMIT_EDITMSG") + assert result == 0 + + +@pytest.mark.benchmark +def test_check_commit_with_empty_checks(mocker): + checks = [] + m_re_match = mocker.patch( + "re.match", + return_value="fake_commits_info" + ) + retval = check_commit_msg(checks, MSG_FILE) + assert retval == PASS + assert m_re_match.call_count == 0 + + +@pytest.mark.benchmark +def test_check_commit_with_different_check(mocker): + checks = [{ + "check": "branch", + "regex": "dummy_regex" + }] + m_re_match = mocker.patch( + "re.match", + return_value="fake_commits_info" + ) + retval = check_commit_msg(checks, MSG_FILE) + assert retval == PASS + assert m_re_match.call_count == 0 + + +@pytest.mark.benchmark +def test_check_commit_with_len0_regex(mocker, capfd): + checks = [ + { "check": "message", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] - m_get_commits_info = mocker.patch( - f"{LOCATION}.get_commits_info", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value=None - ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) - retval = check_commit_msg(checks) - assert retval == FAIL - assert m_get_commits_info.call_count == 1 - assert m_re_match.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 + "regex": "" + } + ] + m_re_match = mocker.patch( + "re.match", + return_value="fake_rematch_resp" + ) + retval = check_commit_msg(checks, MSG_FILE) + assert retval == PASS + assert m_re_match.call_count == 0 + out, _ = capfd.readouterr() + assert "Not found regex for commit message." in out + + +@pytest.mark.benchmark +def test_check_commit_with_result_none(mocker): + checks = [{ + "check": "message", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest" + }] + m_re_match = mocker.patch( + "re.match", + return_value=None + ) + m_print_error_message = mocker.patch( + f"{LOCATION}.print_error_message" + ) + m_print_suggestion = mocker.patch( + f"{LOCATION}.print_suggestion" + ) + retval = check_commit_msg(checks, MSG_FILE) + assert retval == FAIL + assert m_re_match.call_count == 1 + assert m_print_error_message.call_count == 1 + assert m_print_suggestion.call_count == 1 + + +@pytest.mark.benchmark +def test_check_commit_signoff(mocker): + checks = [{ + "check": "commit_signoff", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest" + }] + m_re_search = mocker.patch( + "re.search", + return_value=None + ) + m_print_error_message = mocker.patch( + f"{LOCATION}.print_error_message" + ) + m_print_suggestion = mocker.patch( + f"{LOCATION}.print_suggestion" + ) + retval = check_commit_signoff(checks) + assert retval == FAIL + assert m_re_search.call_count == 1 + assert m_print_error_message.call_count == 1 + assert m_print_suggestion.call_count == 1 + + +@pytest.mark.benchmark +def test_check_commit_signoff_with_empty_regex(mocker): + checks = [{ + "check": "commit_signoff", + "regex": "", + "error": "error", + "suggest": "suggest" + }] + m_re_match = mocker.patch( + "re.match", + return_value="fake_commits_info" + ) + retval = check_commit_signoff(checks) + assert retval == PASS + 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( + "re.match", + return_value="fake_commits_info" + ) + 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 188a2b1..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,34 @@ 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" + log_path = os.path.join(store_dir, "commit-check.log") + mocker.patch.dict(os.environ, {"COMMIT_CHECK_HOME": store_dir}) + mock_os_access = mocker.patch("os.access", return_value=False) + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.makedirs") + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("commit_check.util.cmd_output", return_value="mock_version") + mocker.patch("sys.version", "Mock Python Version") + mocker.patch("sys.executable", "/mock/path/to/python") + + from commit_check.error import log_and_exit + log_and_exit( + msg="Test error message", + ret_code=1, + exc=ValueError("Test exception"), + formatted="Mocked formatted stack trace" + ) + + mock_os_access.assert_called_once_with(store_dir, os.W_OK) + mock_open.assert_called_with(log_path, "a") + 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 ed9b78c..4e277a6 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,29 +1,29 @@ import sys import pytest from commit_check.main import main -from commit_check import DEFAULT_CONFIG +from commit_check import DEFAULT_CONFIG, PASS, FAIL CMD = "commit-check" class TestMain: - @pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count", [ - ([CMD, "--message"], 1, 0, 0), - ([CMD, "--branch"], 0, 1, 0), - ([CMD, "--author-name"], 0, 0, 1), - ([CMD, "--author-email"], 0, 0, 1), - ([CMD, "--message", "--author-email"], 1, 0, 1), - ([CMD, "--branch", "--message"], 1, 1, 0), - ([CMD, "--author-name", "--author-email"], 0, 0, 2), - ([CMD, "--message", "--branch", "--author-email"], 1, 1, 1), - ([ - CMD, - "--branch", - "--message", - "--author-name", - "--author-email" - ], 1, 1, 2), - ([CMD, "--dry-run"], 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,7 +31,10 @@ def test_main( argv, check_commit_call_count, check_branch_call_count, - check_author_call_count + 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", @@ -42,18 +45,21 @@ def test_main( } ) m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch( - "commit_check.branch.check_branch" - ) - m_check_author = mocker.patch( - "commit_check.author.check_author" - ) + m_check_branch = mocker.patch("commit_check.branch.check_branch") + 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 assert m_check_branch.call_count == check_branch_call_count 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", @@ -64,21 +70,22 @@ def test_main_help(self, mocker, capfd): } ) m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch( - "commit_check.branch.check_branch" - ) - m_check_author = mocker.patch( - "commit_check.author.check_author" - ) + m_check_branch = mocker.patch("commit_check.branch.check_branch") + 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") sys.argv = ["commit-check", "--h"] with pytest.raises(SystemExit): main() assert m_check_commit.call_count == 0 assert m_check_branch.call_count == 0 assert m_check_author.call_count == 0 + assert m_check_commit_signoff.call_count == 0 + assert m_check_merge_base.call_count == 0 stdout, _ = capfd.readouterr() assert "usage: " in stdout + @pytest.mark.benchmark def test_main_version(self, mocker): mocker.patch( "commit_check.main.validate_config", @@ -89,32 +96,95 @@ def test_main_version(self, mocker): } ) m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch( - "commit_check.branch.check_branch" - ) - m_check_author = mocker.patch( - "commit_check.author.check_author" - ) + m_check_branch = mocker.patch("commit_check.branch.check_branch") + 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") sys.argv = ["commit-check", "--v"] with pytest.raises(SystemExit): main() assert m_check_commit.call_count == 0 assert m_check_branch.call_count == 0 assert m_check_author.call_count == 0 + 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", return_value={} ) m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - mocker.patch( - "commit_check.branch.check_branch" - ) - mocker.patch( - "commit_check.author.check_author" - ) + mocker.patch("commit_check.branch.check_branch") + mocker.patch("commit_check.author.check_author") + mocker.patch("commit_check.commit.check_commit_signoff") + mocker.patch("commit_check.branch.check_merge_base") sys.argv = ["commit-check", "--message"] main() 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", + [ + ([CMD, "--message"], PASS, PASS, PASS, PASS, PASS, PASS, PASS), + ([CMD, "--message"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL), + ([CMD, "--message", "--commit-signoff"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), + ([CMD, "--message", "--commit-signoff"], PASS, PASS, PASS, PASS, FAIL, PASS, FAIL,), + ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, PASS, PASS, PASS, PASS, PASS,), + ([CMD, "--message", "--author-name", "--author-email"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), + ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, FAIL, PASS, PASS, PASS, FAIL,), + ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, PASS, FAIL, PASS, PASS, FAIL,), + ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, FAIL, FAIL, PASS, PASS, FAIL,), + ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], PASS, PASS, PASS, PASS, PASS, PASS, PASS,), + ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], FAIL, FAIL, FAIL, FAIL, FAIL, FAIL, FAIL,), + ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), + ([CMD, "--dry-run"], FAIL, FAIL, FAIL, FAIL, FAIL, FAIL, PASS), + ], + ) + def test_main_multiple_checks( + self, + mocker, + argv, + message_result, + branch_result, + author_name_result, + author_email_result, + commit_signoff_result, + merge_base_result, + final_result, + ): + mocker.patch( + "commit_check.main.validate_config", + return_value={}, + ) + + mocker.patch( + "commit_check.commit.check_commit_msg", return_value=message_result + ) + mocker.patch( + "commit_check.commit.check_commit_signoff", + return_value=commit_signoff_result, + ) + + mocker.patch("commit_check.branch.check_branch", return_value=branch_result) + 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? + def author_side_effect(_, check_type: str) -> int: # type: ignore[return] + assert check_type in ("author_name", "author_email") + if check_type == "author_name": + return author_name_result + elif check_type == "author_email": + return author_email_result + + mocker.patch("commit_check.author.check_author", side_effect=author_side_effect) + + sys.argv = argv + assert main() == final_result diff --git a/tests/util_test.py b/tests/util_test.py index 4e5f417..8937005 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -1,46 +1,21 @@ import pytest -from commit_check.util import get_version +import subprocess from commit_check.util import get_branch_name -from commit_check.util import get_commits_info +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 from commit_check.util import validate_config +from commit_check.util import print_error_header from commit_check.util import print_error_message from commit_check.util import print_suggestion from subprocess import CalledProcessError, PIPE +from unittest.mock import MagicMock class TestUtil: - class TestGetVersion: - def test_get_version(self, mocker): - m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value="fake_version" - ) - retval = get_version() - assert m_cmd_output.call_count == 1 - assert m_cmd_output.call_args[0][0] == [ - "git", "describe", "--tags" - ] - assert retval == "fake_version" - - def test_get_version_with_exception(self, mocker): - m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value="fake_version" - ) - dummy_ret_code, dummy_cmd_name = 1, "dcmd" - m_cmd_output.side_effect = CalledProcessError( - dummy_ret_code, - dummy_cmd_name - ) - retval = get_version() - assert m_cmd_output.call_count == 1 - assert m_cmd_output.call_args[0][0] == [ - "git", "describe", "--tags" - ] - assert retval == "" - class TestGetBranchName: + @pytest.mark.benchmark def test_get_branch_name(self, mocker): # Must call cmd_output with given argument. m_cmd_output = mocker.patch( @@ -50,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( @@ -69,32 +45,122 @@ 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 + (128, 128), # error case + ]) + def test_git_merge_base(self, mocker, returncode, expected): + mock_run = mocker.patch("subprocess.run") + if returncode == 128: + mock_run.side_effect = CalledProcessError(returncode, "git merge-base") + else: + mock_result = MagicMock() + mock_result.returncode = returncode + mock_run.return_value = mock_result + + result = git_merge_base("main", "feature") + + mock_run.assert_called_once_with( + ["git", "merge-base", "--is-ancestor", "main", "feature"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + ) + + assert result == expected + class TestGetCommitInfo: + @pytest.mark.benchmark @pytest.mark.parametrize("format_string", [ ("s"), ("an"), ("ae"), ] ) - def test_get_commits_info(self, mocker, format_string): - # Must call get_commits_info with given argument. + def test_get_commit_info(self, mocker, format_string): + # 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 " ) - retval = get_commits_info(format_string) + retval = get_commit_info(format_string) assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "log", "-n", "1", f"--pretty=format:%{format_string}" + "git", "log", "-n", "1", f"--pretty=format:%{format_string}", "HEAD" ] assert retval == " fake commit message " - def test_get_commits_info_with_exception(self, mocker): + @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 " @@ -106,10 +172,10 @@ def test_get_commits_info_with_exception(self, mocker): dummy_cmd_name ) format_string = "s" - retval = get_commits_info(format_string) + retval = get_commit_info(format_string) assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "log", "-n", "1", f"--pretty=format:%{format_string}" + "git", "log", "-n", "1", f"--pretty=format:%{format_string}", "HEAD" ] assert retval == "" @@ -121,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( @@ -131,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"), @@ -155,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, ""), @@ -180,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") @@ -192,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 @@ -201,52 +272,48 @@ def test_validate_config_file_not_found(self, mocker): assert retval == {} class TestPrintErrorMessage: - @pytest.mark.parametrize("check_type, invalid_type_msg", [ - ("message", "Invalid commit message"), - ("branch", "Invalid branch name"), - ("author_name", "Invalid author name"), - ("author_email", "Invalid email address"), + @pytest.mark.benchmark + def test_print_error_header(self, capfd): + # Must print on stdout with given argument. + print_error_header() + stdout, _ = capfd.readouterr() + 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 =>"), + ("author_name", "check failed =>"), + ("author_email", "check failed =>"), + ("commit_signoff", "check failed =>"), ]) - def test_print_error_message(self, capfd, check_type, invalid_type_msg): + def test_print_error_message(self, capfd, check_type, type_failed_msg): # Must print on stdout with given argument. dummy_regex = "dummy regex" - dummy_error_point = "error point" + dummy_reason = "failure reason" dummy_error = "dummy error" print_error_message( check_type, dummy_regex, dummy_error, - dummy_error_point + dummy_reason ) stdout, _ = capfd.readouterr() - assert "Commit rejected by Commit-Check" in stdout - assert "Commit rejected." in stdout - assert invalid_type_msg in stdout + assert check_type in stdout + assert type_failed_msg in stdout assert f"It doesn't match regex: {dummy_regex}" in stdout assert dummy_error in stdout - def test_print_error_message_exit1(self, capfd): - # Must exit with 1 when not supported check type passed. - with pytest.raises(SystemExit) as e: - print_error_message( - "not_supported_check_type", - "", - "", - "not supported check type error" - ) - assert e.value.code == 1 - stdout, _ = capfd.readouterr() - assert "Commit rejected by Commit-Check" in stdout - assert "Commit rejected." in stdout - assert "commit-check does not support" 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: