diff --git a/.codacy.yaml b/.codacy.yaml index 3a31bdb7be..cab97400c0 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -1,3 +1,3 @@ exclude_paths: - 'tests/**' - - 'docs/**' \ No newline at end of file + - 'docs/**' diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 73998f1162..0000000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -exclude = .git,__pycache__,docs/source/conf.py,old,build,dist -max-complexity = 10 -max-line-length = 100 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..944a95723e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: ๐Ÿ›  Bug report +about: Create a report to help us improve +title: "Good bug title tells us about precise symptom, not about the root cause." +labels: "bug" +assignees: "" +--- + +## Description + + +## Steps to reproduce + + +## Current behavior + + +## Desired behavior + + +## Environment + +Add output of the following command to include the following +- commitizen version: +- python version: +- operating system: +```bash +cz version --report +``` diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000000..f96cfff7bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,19 @@ +--- +name: ๐Ÿ“– Documentation +about: Suggest an improvement for the documentation of this project +title: "Content to be added or fixed" +labels: "documentation" +assignees: "" +--- + +## Type + +* [ ] Content inaccurate +* [ ] Content missing +* [ ] Typo + +## URL + + +## Description + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..b9d48126a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: ๐Ÿš€ Feature request +about: Suggest an idea for this project +title: "" +labels: "feature" +assignees: "" +--- + +## Description + + +## Possible Solution + + +## Additional context + + +## Related Issue + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..d0d5eba17c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +## Description + + + +## Checklist + +- [ ] Add test cases to all the changes you introduce +- [ ] Run `./script/format` and `./script/test` locally to ensure this change passes linter check and test +- [ ] Test the changes on the local machine manually +- [ ] Update the documentation for the changes + +## Expected behavior + + + +## Steps to Test This Pull Request + + + +## Additional context + diff --git a/.github/workflows/bumpversion.yml b/.github/workflows/bumpversion.yml index f7559b420b..86f6901793 100644 --- a/.github/workflows/bumpversion.yml +++ b/.github/workflows/bumpversion.yml @@ -6,36 +6,17 @@ on: - master jobs: - build: - if: "!contains(github.event.head_commit.message, 'bump')" + bump-version: + if: "!startsWith(github.event.head_commit.message, 'bump:')" runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.x'] + name: "Bump version and create changelog with commitizen" steps: - - uses: actions/checkout@v2 - with: - token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}' - fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python --version - python -m pip install -U commitizen - - name: Configure repo - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git pull origin master --tags - - name: Create bump - run: | - cz bump --yes - git tag - - name: Push changes - uses: Woile/github-push-action@master - with: - github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - tags: "true" + - name: Check out + uses: actions/checkout@v2 + with: + fetch-depth: 0 + token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}' + - name: Create bump and changelog + uses: commitizen-tools/commitizen-action@master + with: + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/docspublish.yaml b/.github/workflows/docspublish.yaml new file mode 100644 index 0000000000..4cb3b47c32 --- /dev/null +++ b/.github/workflows/docspublish.yaml @@ -0,0 +1,33 @@ +name: Publish documentation + +on: + push: + branches: + - master + +jobs: + publish-documentation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}' + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install -U mkdocs mkdocs-material + - name: Build docs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python -m mkdocs build + - name: Push doc to Github Page + uses: peaceiris/actions-gh-pages@v2 + env: + PERSONAL_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + PUBLISH_BRANCH: gh-pages + PUBLISH_DIR: ./site diff --git a/.github/workflows/homebrewpublish.yaml b/.github/workflows/homebrewpublish.yaml new file mode 100644 index 0000000000..2ff28078b0 --- /dev/null +++ b/.github/workflows/homebrewpublish.yaml @@ -0,0 +1,32 @@ +name: Publish to Homebrew + +on: + workflow_run: + workflows: ["Upload Python Package"] + types: + - completed + +jobs: + deploy: + runs-on: macos-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install -U commitizen + - name: Set Project version env variable + run: | + echo "project_version=$(cz version --project)" >> $GITHUB_ENV + - name: Update Homebrew formula + uses: dawidd6/action-homebrew-bump-formula@v3 + with: + token: ${{secrets.PERSONAL_ACCESS_TOKEN}} + formula: commitizen + tag: v${{ env.project_version }} + force: true diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4efc8d90e0..82078d4b93 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -3,26 +3,26 @@ name: Python package on: [pull_request] jobs: - build: - - runs-on: ubuntu-latest + python-check: strategy: - max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8] - + python-version: [3.6, 3.8, 3.9] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --pre -U poetry + python -m pip install -U pip poetry poetry --version poetry install - - name: Run tests + - name: Run tests and linters run: | git config --global user.email "action@github.com" git config --global user.name "GitHub Action" diff --git a/.github/workflows/pythonpublish.yaml b/.github/workflows/pythonpublish.yaml index e6d5cad1ee..e060663ba7 100644 --- a/.github/workflows/pythonpublish.yaml +++ b/.github/workflows/pythonpublish.yaml @@ -19,20 +19,12 @@ jobs: python-version: '3.x' - name: Install dependencies run: | - python -m pip install --pre -U poetry mkdocs mkdocs-material + python -m pip install -U pip poetry mkdocs mkdocs-material poetry --version poetry install - - name: Build and publish + - name: Publish env: PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ./scripts/publish - poetry run mkdocs build - - name: Push doc to Github Page - uses: peaceiris/actions-gh-pages@v2 - env: - PERSONAL_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - PUBLISH_BRANCH: gh-pages - PUBLISH_DIR: ./site diff --git a/.gitignore b/.gitignore index 5cec5fb4a0..5b4e933e35 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ venv.bak/ # mypy .mypy_cache/ +.idea .vscode/ *.bak diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..a5c2d68036 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +default_stages: [push] +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.1.0 + hooks: + - id: check-vcs-permalinks + - id: end-of-file-fixer + exclude: "tests/[test_*|data]/*" + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: debug-statements + - id: no-commit-to-branch + + - repo: https://github.com/Woile/commitizen + rev: v1.23.0 + hooks: + - id: commitizen + stages: [commit-msg] + + - repo: local + hooks: + - id: format + name: format + language: system + pass_filenames: false + entry: ./scripts/format + types: [python] + + - id: linter and test + name: linter and test + language: system + pass_filenames: false + entry: ./scripts/test + types: [python] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 956f15250e..6c45efbb0f 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -6,3 +6,11 @@ language_version: python3 require_serial: true minimum_pre_commit_version: "0.15.4" +- id: commitizen-prepare-commit-msg + name: commitizen prepare commit msg + description: "prepare commit message" + entry: cz commit --commit-msg-file + language: python + language_version: python3 + require_serial: true + minimum_pre_commit_version: "0.15.4" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8ecc162156..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" -dist: xenial -sudo: true -install: - - pip install -U poetry - - poetry install - -script: - - ./scripts/test - -after_success: - - codecov \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d614abaf..ac409aef82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,139 +1,1024 @@ -# CHANGELOG -## v1.12.0 +## v2.20.0 (2021-10-06) -### Feature +### Feat + +- **cli.py**: add shortcut for signoff command +- add signoff parameter to commit command + +## v2.19.0 (2021-09-27) + +### Feat + +- utility for showing system information + +## v2.18.2 (2021-09-27) + +### Fix + +- **cli**: handle argparse different behavior after python 3.9 + +## v2.18.1 (2021-09-12) + +### Fix + +- **commit**: correct the stage checker before commiting + +## v2.18.0 (2021-08-13) + +### Refactor + +- **shortcuts**: move check for shortcut config setting to apply to any list select + +### Feat + +- **prompt**: add keyboard shortcuts with config option + +## v2.17.13 (2021-07-14) + +## v2.17.12 (2021-07-06) + +### Fix + +- **git.py**: ensure signed commits in changelog when git config log.showsignature=true + +## v2.17.11 (2021-06-24) + +### Fix + +- correct indentation for json config for better readability + +## v2.17.10 (2021-06-22) + +### Fix + +- add support for jinja2 v3 + +## v2.17.9 (2021-06-11) + +### Fix + +- **changelog**: generating changelog after a pre-release + +## v2.17.8 (2021-05-28) + +### Fix + +- **changelog**: annotated tags not generating proper changelog + +## v2.17.7 (2021-05-26) + +### Fix + +- **bump**: fix error due to bumping version file without eol through regex +- **bump**: fix offset error due to partially match + +## v2.17.6 (2021-05-06) + +### Fix + +- **cz/conventional_commits**: optionally expect '!' right before ':' in schema_pattern + +## v2.17.5 (2021-05-06) + +## v2.17.4 (2021-04-22) + +### Fix + +- version update in a docker-compose.yaml file + +## v2.17.3 (2021-04-19) + +### Fix + +- fix multiple versions bumps when version changes the string size + +## v2.17.2 (2021-04-10) + +### Fix + +- **bump**: replace all occurances that match regex +- **wip**: add test for current breaking change + +## v2.17.1 (2021-04-08) + +### Fix + +- **commands/init**: fix toml config format error + +## v2.17.0 (2021-04-02) + +### Feat + +- Support versions on random positions + +## v2.16.0 (2021-03-08) + +### Feat + +- **bump**: send incremental changelog to stdout and bump output to stderr + +## v2.15.3 (2021-02-26) + +### Fix + +- add utf-8 encode when write toml file + +## v2.15.2 (2021-02-24) + +### Fix + +- **git**: fix get_commits deliminator + +## v2.15.1 (2021-02-21) + +### Fix + +- **config**: change read mode from `r` to `rb` + +## v2.15.0 (2021-02-21) + +### Feat + +- **changelog**: add support for multiline BREAKING paragraph + +## v2.14.2 (2021-02-06) + +### Fix + +- **git**: handle the empty commit and empty email cases + +## v2.14.1 (2021-02-02) + +### Fix + +- remove yaml warnings when using '.cz.yaml' + +## v2.14.0 (2021-01-20) + +### Feat + +- **#271**: enable creation of annotated tags when bumping + +## v2.13.0 (2021-01-01) + +### Refactor + +- raise an InvalidConfigurationError +- **#323**: address PR feedback +- move expected COMMITS_TREE to global + +### Feat + +- **#319**: add optional change_type_order + +## v2.12.1 (2020-12-30) + +### Fix + +- read commit_msg_file with utf-8 + +## v2.12.0 (2020-12-30) + +### Feat + +- **deps**: Update and relax tomlkit version requirement + +## v2.11.1 (2020-12-16) + +### Fix + +- **commit**: attach user info to backup for permission denied issue + +## v2.11.0 (2020-12-10) + +### Feat + +- add yaml as a config option + +### feat + +- **config**: add support for the new class YAMLConfig at the root of the confi internal package + +## v2.10.0 (2020-12-02) + +### Feat + +- **commitizen/cli**: add the integration with argcomplete + +## v2.9.0 (2020-12-02) + +### Fix + +- **json_config**: fix the emtpy_config_content method + +### Feat + +- **Init**: add the json config support as an option at Init +- **commitizen/config/json_config**: add json support for configuration + +## v2.8.2 (2020-11-21) + +### Fix + +- support `!` in cz check command + +## v2.8.1 (2020-11-21) + +### Fix + +- prevent prerelase from creating a bump when there are no commits + +## v2.8.0 (2020-11-15) + +### Feat + +- allow files-only to set config version and create changelog + +## v2.7.0 (2020-11-14) + +### Feat + +- **bump**: add flag `--local-version` that supports bumping only the local version instead of the public + +## v2.6.0 (2020-11-04) + +### Feat + +- **commands/bump**: add config option to create changelog on bump + +## v2.5.0 (2020-11-04) + +### Feat + +- **commands/changelog**: add config file options for start_rev and incremental + +## v2.4.2 (2020-10-26) + +### Fix + +- **init.py**: mypy error (types) +- **commands/bump**: Add NoneIncrementExit to fix git fatal error when creating existing tag + +### Refactor + +- **commands/bump**: Remove comment and changed ... for pass + +## v2.4.1 (2020-10-04) + +### Fix + +- **cz_customize**: make schema_pattern customiziable through config for cz_customize + +## v2.4.0 (2020-09-18) + +### Feat + +- **cz_check**: cz check can read commit message from pipe + +## v2.3.1 (2020-09-07) + +### Fix + +- conventional commit schema + +## v2.3.0 (2020-09-03) + +### Fix + +- **cli**: add guideline for subject input +- **cli**: wrap the word enter with brackets + +### Feat + +- **cli**: rewrite cli instructions to be more succinct about what they require + +## v2.2.0 (2020-08-31) + +### Feat + +- **cz_check**: cz check can read from a string input + +## v2.1.0 (2020-08-06) + +### Refactor + +- **cz_check**: Refactor _get_commits to return GitCommit instead of dict + +### Feat + +- **cz_check**: Add rev to all displayed ill-formatted commits +- **cz_check**: Update to show all ill-formatted commits + +## v2.0.2 (2020-08-03) + +### Fix + +- **git**: use double quotation mark in get_tags + +## v2.0.1 (2020-08-02) + +### Fix + +- **commands/changelog**: add exception message when failing to find an incremental revision +- **commands/bump**: display message variable properly + +## v2.0.0 (2020-07-26) + +### Fix + +- add missing `pyyaml` dependency +- **cli**: make command required for commitizen + +### Feat + +- **init**: enable setting up pre-commit hook through "cz init" + +### Refactor + +- **config**: drop "files" configure support. Please use "version_files" instead +- **config**: remove ini configuration support +- **cli**: remove "--version" argument + +### BREAKING CHANGE + +- setup.cfg, .cz and .cz.cfg are no longer supported +- Use "cz verion" instead +- "cz --debug" will no longer work + +## v1.25.0 (2020-07-26) + +### Feat + +- **conventional_commits**: use and proper support for conventional commits v1.0.0 + +## v1.24.0 (2020-07-26) + +### Feat + +- add author and author_email to git commit + +## v1.23.4 (2020-07-26) + +### Refactor + +- **changelog**: remove pkg_resources dependency + +## v1.23.3 (2020-07-25) + +### Fix + +- **commands/bump**: use `return_code` in commands used by bump +- **commands/commit**: use return_code to raise commit error, not stderr + +### Refactor + +- **cmd**: add return code to Command + +## v1.23.2 (2020-07-25) + +### Fix + +- **bump**: add changelog file into stage when running `cz bump --changelog` + +## v1.23.1 (2020-07-14) + +### Fix + +- Raise NotAGitProjectError only in git related command + +## v1.23.0 (2020-06-14) + +### Refactor + +- **exception**: rename MissingConfigError as MissingCzCustomizeConfigError +- **exception**: Rename CommitFailedError and TagFailedError with Bump prefix +- **commands/init**: add test case and remove unaccessible code +- **exception**: move output message related to exception into exception +- **exception**: implement message handling mechanism for CommitizenException +- **cli**: do not show traceback if the raised exception is CommitizenException +- introduce DryRunExit, ExpectedExit, NoCommandFoundError, InvalidCommandArgumentError +- use custom exception for error handling +- **error_codes**: remove unused NO_COMMIT_MSG error code + +### Feat + +- **cli**: enable displaying all traceback for CommitizenException when --debug flag is used + +## v1.22.3 (2020-06-10) + +## v1.22.2 (2020-05-29) + +### Fix + +- **changelog**: empty lines at the beginning of the CHANGELOG + +## v1.22.1 (2020-05-23) + +### Fix + +- **templates**: remove trailing space in keep_a_changelog + +## v1.22.0 (2020-05-13) + +### Fix + +- **changelog**: rename `message_hook` -> `changelog_message_builder_hook` + +### Feat + +- **changelog**: add support for `changelog_hook` when changelog finishes the generation +- **changelog**: add support for `message_hook` method +- **changelog**: add support for modifying the change_type in the title of the changelog + +## v1.21.0 (2020-05-09) + +### Feat + +- **commands/bump**: add "--check-consistency" optional + +## v1.20.0 (2020-05-06) + +### Feat + +- **bump**: add optional --no-verify argument for bump command + +## v1.19.3 (2020-05-04) + +### Fix + +- **docs**: change old url woile.github.io to commitizen-tools.github.io +- **changelog**: generate today's date when using an unreleased_version + +## v1.19.2 (2020-05-03) + +### Fix + +- **changelog**: sort the commits properly to their version + +## v1.19.1 (2020-05-03) + +### Fix + +- **commands/check**: Show warning if no commit to check when running `cz check --rev-range` + +### Refactor + +- **cli**: add explicit category for deprecation warnings + +## v1.19.0 (2020-05-02) + +### Fix + +- **git**: missing dependency removed +- **changelog**: check get_metadata for existing changelog file + +### Feat + +- **changelog**: add support for any commit rule system +- **changelog**: add incremental flag + +## v1.18.3 (2020-04-22) + +### Refactor + +- **commands/init**: fix typo + +## v1.18.2 (2020-04-22) + +### Refactor + +- **git**: replace GitCommit.message code with one-liner +- **changelog**: use functions from changelog.py +- **changelog**: rename category to change_type to fit 'keep a changelog' +- **templates**: rename as "keep_a_changelog_template.j2" +- **templates**: remove unneeded __init__ file +- **cli**: reorder commands +- **templates**: move changelog_template from cz to templates +- **tests/utils**: move create_file_and_commit to tests/utils +- **commands/changelog**: remove redundant if statement +- **commands/changelog**: use jinja2 template instead of string concatenation to build changelog + +### Fix + +- **git**: fix returned value for GitCommit.message when body is empty +- **cz/conventional_commits**: fix schema_pattern break due to rebase +- **changelog_template**: fix list format +- **commitizen/cz**: set changelog_map, changelog_pattern to none as default +- **commands/changelog**: remove --skip-merge argument +- **cli**: add changelog arguments + +### Feat + +- **commands/changelog**: make changelog_file an option in config +- **commands/changelog**: exit when there is no commit exists +- **commands/changlog**: add --start-rev argument to `cz changelog` +- **changelog**: generate changelog based on git log +- **commands/changelog**: generate changelog_tree from all past commits +- **cz/conventinal_commits**: add changelog_map, changelog_pattern and implement process_commit +- **cz/base**: add default process_commit for processing commit message +- **changelog**: changelog tree generation from markdown + +## v1.18.1 (2020-04-16) + +### Fix + +- **config**: display ini config deprecation warning only when commitizen config is inside + +## v1.18.0 (2020-04-13) + +### Refactor + +- **cz/customize**: remove unused mypy ignore +- **mypy**: fix mypy check by checking version.pre exists +- **cz**: add type annotation to registry +- **commands/check**: fix type annotation +- **config/base**: use Dict to replace dict in base_config +- **cz/base**: fix config type used in base cz +- **cz**: add type annotation for each function in cz +- **config**: fix mypy warning for _conf + +### Fix + +- **cz/customize**: add error handling when customize detail is not set + +### Feat + +- **bump**: support for ! as BREAKING change in commit message + +## v1.17.1 (2020-03-24) + +### Fix + +- **commands/check**: add help text for check command without argument + +### Refactor + +- **cli**: fix typo + +## v1.17.0 (2020-03-15) + +### Refactor + +- **tests/bump**: use parameterize to group similliar tests +- **cz/connventional_commit**: use \S to check scope +- **git**: remove unnecessary dot between git range + +### Fix + +- **bump**: fix bump find_increment error + +### Feat + +- **commands/check**: add --rev-range argument for checking commits within some range + +## v1.16.4 (2020-03-03) + +### Fix + +- **commands/init**: fix clean up file when initialize commitizen config + +### Refactor + +- **defaults**: split config files into long term support and deprecated ones + +## v1.16.3 (2020-02-20) + +### Fix + +- replace README.rst with docs/README.md in config files + +### Refactor + +- **docs**: remove README.rst and use docs/README.md + +## v1.16.2 (2020-02-01) + +### Fix + +- **commands/check**: add bump into valid commit message of convention commit pattern + +## v1.16.1 (2020-02-01) + +### Fix + +- **pre-commit**: set pre-commit check stage to commit-msg + +## v1.16.0 (2020-01-21) + +### Refactor + +- **commands/bump**: rename parameter into bump_setting to distinguish bump_setting and argument +- **git**: rename get tag function to distinguish return str and GitTag +- **cmd**: reimplement how cmd is run +- **git**: Use GitCommit, GitTag object to store commit and git information +- **git**: make arguments other then start and end in get_commit keyword arguments +- **git**: Change get_commits into returning commits instead of lines of messsages + +### Feat + +- **git**: get_commits default from first_commit + +## v1.15.1 (2020-01-20) + +## v1.15.0 (2020-01-20) + +### Refactor + +- **tests/commands/bump**: use tmp_dir to replace self implemented tmp dir behavior +- **git**: make find_git_project_root return None if it's not a git project +- **config/base_config**: make set_key not implemented +- **error_codes**: move all the error_codes to a module +- **config**: replace string type path with pathlib.Path +- **test_bump_command**: rename camel case variables +- **tests/commands/check**: use pytest fixture tmpdir replace self implemented contextmanager +- **test/commands/other**: replace unit test style mock with mocker fixture +- **tests/commands**: separate command unit tests into modules +- **tests/commands**: make commands related tests a module + +### Fix + +- **git**: remove breakline in the return value of find_git_project_root +- **cli**: fix --version not functional + +### Feat + +- **config**: look up configuration in git project root +- **git**: add find_git_project_root + +## v1.14.2 (2020-01-14) + +### Fix + +- **github_workflow/pythonpublish**: use peaceiris/actions-gh-pages@v2 to publish docs + +## v1.14.1 (2020-01-11) + +## v1.14.0 (2020-01-06) + +### Refactor + +- **pre-commit-hooks**: add metadata for the check hook + +### Feat + +- **pre-commit-hooks**: add pre-commit hook + +### Fix + +- **cli**: fix the way default handled for name argument +- **cli**: fix name cannot be overwritten through config in newly refactored config design + +## v1.13.1 (2019-12-31) + +### Fix + +- **github_workflow/pythonpackage**: set git config for unit testing +- **scripts/test**: ensure the script fails once the first failure happens + +## v1.13.0 (2019-12-30) + +### Feat + +- add project version to command init + +## v1.12.0 (2019-12-30) + +### Feat - new init command -## v1.11.0 +## v1.10.3 (2019-12-29) -Ignore this version +### Refactor -## v1.10.0 +- **commands/bump**: use "version_files" internally +- **config**: set "files" to alias of "version_files" -### Feature +## v1.10.2 (2019-12-27) -- new argument `--files-only` in bump +### Refactor -## v1.9.2 +- new config system where each config type has its own class +- **config**: add type annotation to config property +- **config**: fix wrongly type annoated functions +- **config/ini_config**: move deprecation warning into class initialization +- **config**: use add_path instead of directly assigning _path +- **all**: replace all the _settings invoke with settings.update +- **cz/customize**: remove unnecessary statement "raise NotImplementedError("Not Implemented yet")" +- **config**: move default settings back to defaults +- **config**: Make config a class and each type of config (e.g., toml, ini) a child class ### Fix -- `--commit-msg-file` is now a required argument +- **config**: handle empty config file +- **config**: fix load global_conf even if it doesn't exist +- **config/ini_config**: replase outdated _parse_ini_settings with _parse_settings -## v1.9.1 +## v1.10.1 (2019-12-10) ### Fix -- exception `AnswerRequiredException` not caught +- **cli**: overwrite "name" only when it's not given +- **config**: fix typo -## v1.9.0 +## v1.10.0 (2019-11-28) -### Feature +### Feat -- new `version` command. `--version` will be deprecated in `2.0.0` -- new `git-cz` entrypoint. After installing `commitizen` you can run `git cz c` (#60) -- new `--dry-run` argument in `commit` (#56) -- new `cz check` command which checks if the message is valid with the rules (#59). Useful for git hooks. -- create a commiting rule directly in the config file (#54) -- support for multi-line body (#6) -- support for jinja templates. Install doign `pip install -U commitizen[jinja2]`. -- support for `.cz.toml`. The confs depending on `ConfigParser` will be deprecated in `2.0.0`. +- support for different commitizens in `cz check` +- **bump**: new argument --files-only +## v1.9.2 (2019-11-23) ### Fix -- tests were fixed -- windows error when removing folders (#67) -- typos in docs +- **commands/check.py**: --commit-msg-file is now a required argument + +## v1.9.1 (2019-11-23) + +### Fix + +- **cz/exceptions**: exception AnswerRequiredException not caught (#89) + +## v1.9.0 (2019-11-22) + +### Feat -### Docs -- tutorial for gitlab ci -- tutorial for github actions +- **Commands/check**: enforce the project to always use conventional commits +- **config**: add deprecation warning for loading config from ini files +- **cz/customize**: add jinja support to enhance template flexibility +- **cz/filters**: add required_validator and multiple_line_breaker +- **cz/cz_customize**: implement info to support info and info_path +- **cz/cz_customize**: enable bump_pattern bump_map customization +- **cz/cz_customize**: implement customizable cz +- **Commands/commit**: add ยด--dry-runยด flag to the Commit command +- new 'git-cz' entrypoint -## v1.8.0 +### Refactor -### Feature +- **config**: remove has_pyproject which is no longer used +- **cz/customize**: make jinja2 a custom requirement. if not installed use string.Tempalte instead +- **cz/utils**: rename filters as utils +- **cli**: add back --version and remove subcommand required constraint -- new custom exception for commitizen -- commit is aborted if nothing in staging +### Fix + +- commit dry-run doesnt require staging to be clean +- correct typo to spell "convention" +- removing folder in windows throwing a PermissionError +- **scripts**: add back the delelte poetry prefix +- **test_cli**: testing the version command -## v1.7.0 +## v1.8.0 (2019-11-12) + +### Fix -### Feature +- **commands/commit**: catch exception raised by customization cz +- **cli**: handle the exception that command is not given +- **cli**: enforce subcommand as required -- new styles for the prompt -- new configuration option for the prompt styles +### Refactor -## v1.6.0 +- **cz/conventional_commit**: make NoSubjectException inherit CzException and add error message +- **command/version**: use out.write instead of out.line +- **command**: make version a command instead of an argument -### Feature +### Feat -- new retry argument to execute previous commit again +- **cz**: add a base exception for cz customization +- **commands/commit**: abort commit if there is nothing to commit +- **git**: add is_staging_clean to check if there is any file in git staging -## v1.5.1 +## v1.7.0 (2019-11-08) ### Fix -- issue in poetry add preventing the installation in py36 +- **cz**: fix bug in BaseCommitizen.style +- **cz**: fix merge_style usage error +- **cz**: remove breakpoint -## v1.5.0 +### Refactor -### Feature +- **cz**: change the color of default style -- it is possible to specify a pattern to be matched in configuration `files` when doing bump. +### Feat -## v1.4.0 +- **config**: update style instead of overwrite +- **config**: parse style in config +- **commit**: make style configurable for commit command -### Feature +## v1.6.0 (2019-11-05) -- new argument (--yes) in bump to accept prompt questions +### Feat + +- **commit**: new retry argument to execute previous commit again + +## v1.5.1 (2019-06-04) ### Fix -- error is shown when commiting fails. +- #28 allows poetry add on py36 envs -## v1.3.0 +## v1.5.0 (2019-05-11) -### Feature +### Feat -- bump: new commit message template, useful when having to skip ci. +- **bump**: it is now possible to specify a pattern in the files attr to replace the version -## v1.2.1 +## v1.4.0 (2019-04-26) ### Fix -- prefixes like docs, build, etc no longer generate a PATCH +- **bump**: handle commit and create tag failure + +### Feat -## v1.2.0 +- added argument yes to bump in order to accept questions -### Feature +## v1.3.0 (2019-04-24) + +### Feat + +- **bump**: new commit message template + +## v1.2.1 (2019-04-21) + +### Fix + +- **bump**: prefixes like docs, build, etc no longer generate a PATCH + +## v1.2.0 (2019-04-19) + +### Feat - custom cz plugins now support bumping version -## v1.1.1 +## v1.1.1 (2019-04-18) + +### Refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +### Fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +## v1.1.0 (2019-04-14) + +### Feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementaiton ### Fix -- breaking change is now part of the body, instead of being in the subject +- removed all from commit +- fix config file not working + +### Refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### Refactor + +- removed delegator, added decli and many tests + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### Feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### Fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### Fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### Fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### Fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### Fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### Refactor + +- **conventionalCommit**: moved fitlers to questions instead of message + +### Fix + +- **manifest**: inluded missing files + +## v0.9.5 (2018-08-24) + +### Fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### Feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### Feat + +- **commiter**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +## v0.9.1 (2017-11-11) + +### Fix + +- **setup.py**: future is now required for every python version + +## v0.9.0 (2017-11-08) + +### Refactor + +- python 2 support + +## v0.8.6 (2017-11-08) + +## v0.8.5 (2017-11-08) + +## v0.8.4 (2017-11-08) + +## v0.8.3 (2017-11-08) + +## v0.8.2 (2017-10-08) + +## v0.8.1 (2017-10-08) + +## v0.8.0 (2017-10-08) + +### Feat + +- **cz**: jira smart commits + +## v0.7.0 (2017-10-08) + +### Refactor + +- **cli**: renamed all to ls command +- **cz**: renamed angular cz to conventional changelog cz + +## v0.6.0 (2017-10-08) + +### Feat + +- info command for angular -## v1.1.0 +## v0.5.0 (2017-10-07) -### Features +## v0.4.0 (2017-10-07) -- auto bump version based on conventional commits using sem ver -- pyproject support (see [pyproject.toml](./pyproject.toml) for usage) +## v0.3.0 (2017-10-07) -## v1.0.0 +## v0.2.0 (2017-10-07) -### Features +### Feat -- more documentation -- added tests -- support for conventional commits 1.0.0 +- **config**: new loads from ~/.cz and working project .cz .cz.cfg and setup.cfg +- package discovery -### BREAKING CHANGES +### Refactor -- use of questionary to generate the prompt (so we depend on promptkit 2.0) -- python 3 only +- **cz_angular**: improved messages diff --git a/LICENSE b/LICENSE index 881035d1d0..05cf267ae5 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/commitizen/__version__.py b/commitizen/__version__.py index 67f76446fd..3ea83e0a69 100644 --- a/commitizen/__version__.py +++ b/commitizen/__version__.py @@ -1 +1 @@ -__version__ = "1.16.4" +__version__ = "2.20.0" diff --git a/commitizen/bump.py b/commitizen/bump.py index bfda1c4541..9312491044 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -1,12 +1,11 @@ import re -from collections import defaultdict +from collections import OrderedDict from itertools import zip_longest from string import Template from typing import List, Optional, Union from packaging.version import Version -from commitizen.git import GitCommit from commitizen.defaults import ( MAJOR, MINOR, @@ -15,37 +14,48 @@ bump_message, bump_pattern, ) +from commitizen.exceptions import CurrentVersionNotFoundError +from commitizen.git import GitCommit def find_increment( - commits: List[GitCommit], regex: str = bump_pattern, increments_map: dict = bump_map + commits: List[GitCommit], + regex: str = bump_pattern, + increments_map: Union[dict, OrderedDict] = bump_map, ) -> Optional[str]: + if isinstance(increments_map, dict): + increments_map = OrderedDict(increments_map) + # Most important cases are major and minor. # Everything else will be considered patch. - increments_map_default = defaultdict(lambda: None, increments_map) - pattern = re.compile(regex) + select_pattern = re.compile(regex) increment = None for commit in commits: for message in commit.message.split("\n"): - result = pattern.search(message) - if not result: - continue - found_keyword = result.group(0) - new_increment = increments_map_default[found_keyword] - if new_increment == "MAJOR": - increment = new_increment - break - elif increment == "MINOR" and new_increment == "PATCH": - continue - increment = new_increment + result = select_pattern.search(message) + if result: + found_keyword = result.group(0) + new_increment = None + for match_pattern in increments_map.keys(): + if re.match(match_pattern, found_keyword): + new_increment = increments_map[match_pattern] + break + + if increment == "MAJOR": + continue + elif increment == "MINOR" and new_increment == "MAJOR": + increment = new_increment + elif increment == "PATCH" or increment is None: + increment = new_increment return increment def prerelease_generator(current_version: str, prerelease: Optional[str] = None) -> str: - """ + """Generate prerelease + X.YaN # Alpha release X.YbN # Beta release X.YrcN # Release Candidate @@ -58,10 +68,12 @@ def prerelease_generator(current_version: str, prerelease: Optional[str] = None) return "" version = Version(current_version) - new_prerelease_number: int = 0 - if version.is_prerelease and prerelease.startswith(version.pre[0]): - prev_prerelease: int = list(version.pre)[1] + # version.pre is needed for mypy check + if version.is_prerelease and version.pre and prerelease.startswith(version.pre[0]): + prev_prerelease: int = version.pre[1] new_prerelease_number = prev_prerelease + 1 + else: + new_prerelease_number = 0 pre_version = f"{prerelease}{new_prerelease_number}" return pre_version @@ -96,7 +108,10 @@ def semver_generator(current_version: str, increment: str = None) -> str: def generate_version( - current_version: str, increment: str, prerelease: Optional[str] = None + current_version: str, + increment: str, + prerelease: Optional[str] = None, + is_local_version: bool = False, ) -> Version: """Based on the given increment a proper semver will be generated. @@ -109,41 +124,85 @@ def generate_version( MINOR 1.0.0 -> 1.1.0 MAJOR 1.0.0 -> 2.0.0 """ - pre_version = prerelease_generator(current_version, prerelease=prerelease) - semver = semver_generator(current_version, increment=increment) - # TODO: post version - # TODO: dev version - return Version(f"{semver}{pre_version}") + if is_local_version: + version = Version(current_version) + pre_version = prerelease_generator(str(version.local), prerelease=prerelease) + semver = semver_generator(str(version.local), increment=increment) + + return Version(f"{version.public}+{semver}{pre_version}") + else: + pre_version = prerelease_generator(current_version, prerelease=prerelease) + semver = semver_generator(current_version, increment=increment) + # TODO: post version + # TODO: dev version + return Version(f"{semver}{pre_version}") -def update_version_in_files(current_version: str, new_version: str, files: list): + +def update_version_in_files( + current_version: str, new_version: str, files: List[str], *, check_consistency=False +): """Change old version to the new one in every file given. Note that this version is not the tag formatted one. So for example, your tag could look like `v1.0.0` while your version in the package like `1.0.0`. """ + # TODO: separate check step and write step for location in files: - filepath, *regex = location.split(":", maxsplit=1) - if len(regex) > 0: - regex = regex[0] + filepath, *regexes = location.split(":") + regex = regexes[0] if regexes else None - # Read in the file - filedata = [] with open(filepath, "r") as f: - for line in f: - if regex: - is_match = re.search(regex, line) - if not is_match: - filedata.append(line) - continue - - # Replace the target string - filedata.append(line.replace(current_version, new_version)) + version_file = f.read() + + if regex: + current_version_found, version_file = _bump_with_regex( + version_file, current_version, new_version, regex + ) + else: + current_version_regex = _version_to_regex(current_version) + current_version_found = bool(current_version_regex.search(version_file)) + version_file = current_version_regex.sub(new_version, version_file) + + if check_consistency and not current_version_found: + raise CurrentVersionNotFoundError( + f"Current version {current_version} is not found in {location}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ) # Write the file out again with open(filepath, "w") as file: - file.write("".join(filedata)) + file.write("".join(version_file)) + + +def _bump_with_regex(version_file_contents, current_version, new_version, regex): + current_version_found = False + # Bumping versions that change the string length move the offset on the file contents as finditer keeps a + # reference to the initial string that was used and calling search many times would lead in infinite loops + # e.g.: 1.1.9 -> 1.1.20 + offset = 0 + for match in re.finditer(regex, version_file_contents, re.MULTILINE): + left = version_file_contents[: match.end() + offset] + right = version_file_contents[match.end() + offset :] + + line_break = right.find("\n") + middle = right[:line_break] + right = right[line_break:] + + if current_version in middle: + offset += len(new_version) - len(current_version) + current_version_found = True + version_file_contents = ( + left + middle.replace(current_version, new_version) + right + ) + return current_version_found, version_file_contents + + +def _version_to_regex(version: str): + clean_regex = version.replace(".", r"\.").replace("+", r"\+") + return re.compile(f"{clean_regex}") def create_tag(version: Union[Version, str], tag_format: Optional[str] = None): @@ -152,23 +211,22 @@ def create_tag(version: Union[Version, str], tag_format: Optional[str] = None): That's why this function exists. Example: - | tag | version (PEP 0440) | | --- | ------- | | v0.9.0 | 0.9.0 | | ver1.0.0 | 1.0.0 | | ver1.0.0.a0 | 1.0.0a0 | - """ if isinstance(version, str): version = Version(version) if not tag_format: - return version.public + return str(version) major, minor, patch = version.release prerelease = "" - if version.is_prerelease: + # version.pre is needed for mypy check + if version.is_prerelease and version.pre: prerelease = f"{version.pre[0]}{version.pre[1]}" t = Template(tag_format) diff --git a/commitizen/changelog.py b/commitizen/changelog.py new file mode 100644 index 0000000000..7bb9007cdc --- /dev/null +++ b/commitizen/changelog.py @@ -0,0 +1,283 @@ +"""Design + +## Metadata CHANGELOG.md + +1. Identify irrelevant information (possible: changelog title, first paragraph) +2. Identify Unreleased area +3. Identify latest version (to be able to write on top of it) + +## Parse git log + +1. get commits between versions +2. filter commits with the current cz rules +3. parse commit information +4. yield tree nodes +5. format tree nodes +6. produce full tree +7. generate changelog + +Extra: +- [x] Generate full or partial changelog +- [x] Include in tree from file all the extra comments added manually +- [x] Add unreleased value +- [x] hook after message is parsed (add extra information like hyperlinks) +- [x] hook after changelog is generated (api calls) +- [x] add support for change_type maps +""" + +import os +import re +from collections import OrderedDict, defaultdict +from datetime import date +from typing import Callable, Dict, Iterable, List, Optional + +from jinja2 import Environment, PackageLoader + +from commitizen import defaults +from commitizen.exceptions import InvalidConfigurationError +from commitizen.git import GitCommit, GitTag + +CATEGORIES = [ + ("fix", "fix"), + ("breaking", "BREAKING CHANGES"), + ("feat", "feat"), + ("refactor", "refactor"), + ("perf", "perf"), + ("test", "test"), + ("build", "build"), + ("ci", "ci"), + ("chore", "chore"), +] + + +def transform_change_type(change_type: str) -> str: + # TODO: Use again to parse, for this we have to wait until the maps get + # defined again. + _change_type_lower = change_type.lower() + for match_value, output in CATEGORIES: + if re.search(match_value, _change_type_lower): + return output + else: + raise ValueError(f"Could not match a change_type with {change_type}") + + +def get_commit_tag(commit: GitCommit, tags: List[GitTag]) -> Optional[GitTag]: + return next((tag for tag in tags if tag.rev == commit.rev), None) + + +def generate_tree_from_commits( + commits: List[GitCommit], + tags: List[GitTag], + commit_parser: str, + changelog_pattern: str = defaults.bump_pattern, + unreleased_version: Optional[str] = None, + change_type_map: Optional[Dict[str, str]] = None, + changelog_message_builder_hook: Optional[Callable] = None, +) -> Iterable[Dict]: + pat = re.compile(changelog_pattern) + map_pat = re.compile(commit_parser, re.MULTILINE) + body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL) + + # Check if the latest commit is not tagged + latest_commit = commits[0] + current_tag: Optional[GitTag] = get_commit_tag(latest_commit, tags) + + current_tag_name: str = unreleased_version or "Unreleased" + current_tag_date: str = "" + if unreleased_version is not None: + current_tag_date = date.today().isoformat() + if current_tag is not None and current_tag.name: + current_tag_name = current_tag.name + current_tag_date = current_tag.date + + changes: Dict = defaultdict(list) + used_tags: List = [current_tag] + for commit in commits: + commit_tag = get_commit_tag(commit, tags) + + if commit_tag is not None and commit_tag not in used_tags: + used_tags.append(commit_tag) + yield { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + # TODO: Check if tag matches the version pattern, otherwise skip it. + # This in order to prevent tags that are not versions. + current_tag_name = commit_tag.name + current_tag_date = commit_tag.date + changes = defaultdict(list) + + matches = pat.match(commit.message) + if not matches: + continue + + # Process subject from commit message + message = map_pat.match(commit.message) + if message: + parsed_message: Dict = message.groupdict() + # change_type becomes optional by providing None + change_type = parsed_message.pop("change_type", None) + + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + if changelog_message_builder_hook: + parsed_message = changelog_message_builder_hook(parsed_message, commit) + changes[change_type].append(parsed_message) + + # Process body from commit message + body_parts = commit.body.split("\n\n") + for body_part in body_parts: + message_body = body_map_pat.match(body_part) + if not message_body: + continue + parsed_message_body: Dict = message_body.groupdict() + + change_type = parsed_message_body.pop("change_type", None) + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + changes[change_type].append(parsed_message_body) + + yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} + + +def order_changelog_tree(tree: Iterable, change_type_order: List[str]) -> Iterable: + if len(set(change_type_order)) != len(change_type_order): + raise InvalidConfigurationError( + f"Change types contain duplicates types ({change_type_order})" + ) + + sorted_tree = [] + for entry in tree: + ordered_change_types = change_type_order + sorted( + set(entry["changes"].keys()) - set(change_type_order) + ) + changes = [ + (ct, entry["changes"][ct]) + for ct in ordered_change_types + if ct in entry["changes"] + ] + sorted_tree.append({**entry, **{"changes": OrderedDict(changes)}}) + return sorted_tree + + +def render_changelog(tree: Iterable) -> str: + loader = PackageLoader("commitizen", "templates") + env = Environment(loader=loader, trim_blocks=True) + jinja_template = env.get_template("keep_a_changelog_template.j2") + changelog: str = jinja_template.render(tree=tree) + return changelog + + +def parse_version_from_markdown(value: str) -> Optional[str]: + if not value.startswith("#"): + return None + m = re.search(defaults.version_parser, value) + if not m: + return None + return m.groupdict().get("version") + + +def parse_title_type_of_line(value: str) -> Optional[str]: + md_title_parser = r"^(?P#+)" + m = re.search(md_title_parser, value) + if not m: + return None + return m.groupdict().get("title") + + +def get_metadata(filepath: str) -> Dict: + unreleased_start: Optional[int] = None + unreleased_end: Optional[int] = None + unreleased_title: Optional[str] = None + latest_version: Optional[str] = None + latest_version_position: Optional[int] = None + if not os.path.isfile(filepath): + return { + "unreleased_start": None, + "unreleased_end": None, + "latest_version": None, + "latest_version_position": None, + } + + with open(filepath, "r") as changelog_file: + for index, line in enumerate(changelog_file): + line = line.strip().lower() + + unreleased: Optional[str] = None + if "unreleased" in line: + unreleased = parse_title_type_of_line(line) + # Try to find beginning and end lines of the unreleased block + if unreleased: + unreleased_start = index + unreleased_title = unreleased + continue + elif ( + isinstance(unreleased_title, str) + and parse_title_type_of_line(line) == unreleased_title + ): + unreleased_end = index + + # Try to find the latest release done + version = parse_version_from_markdown(line) + if version: + latest_version = version + latest_version_position = index + break # there's no need for more info + if unreleased_start is not None and unreleased_end is None: + unreleased_end = index + return { + "unreleased_start": unreleased_start, + "unreleased_end": unreleased_end, + "latest_version": latest_version, + "latest_version_position": latest_version_position, + } + + +def incremental_build(new_content: str, lines: List, metadata: Dict) -> List: + """Takes the original lines and updates with new_content. + + The metadata holds information enough to remove the old unreleased and + where to place the new content + + Args: + lines: The lines from the changelog + new_content: This should be placed somewhere in the lines + metadata: Information about the changelog + + Returns: + Updated lines + """ + unreleased_start = metadata.get("unreleased_start") + unreleased_end = metadata.get("unreleased_end") + latest_version_position = metadata.get("latest_version_position") + skip = False + output_lines: List = [] + for index, line in enumerate(lines): + if index == unreleased_start: + skip = True + elif index == unreleased_end: + skip = False + if ( + latest_version_position is None + or isinstance(latest_version_position, int) + and isinstance(unreleased_end, int) + and latest_version_position > unreleased_end + ): + continue + + if skip: + continue + + if ( + isinstance(latest_version_position, int) + and index == latest_version_position + ): + + output_lines.append(new_content) + output_lines.append("\n") + + output_lines.append(line) + if not isinstance(latest_version_position, int): + output_lines.append(new_content) + return output_lines diff --git a/commitizen/changelog_parser.py b/commitizen/changelog_parser.py new file mode 100644 index 0000000000..e53c52893a --- /dev/null +++ b/commitizen/changelog_parser.py @@ -0,0 +1,131 @@ +"""CHNAGLOG PARSER DESIGN + +## Parse CHANGELOG.md + +1. Get LATEST VERSION from CONFIG +1. Parse the file version to version +2. Build a dict (tree) of that particular version +3. Transform tree into markdown again +""" +import re +from collections import defaultdict +from typing import Dict, Generator, Iterable, List + +MD_VERSION_RE = r"^##\s(?P<version>[a-zA-Z0-9.+]+)\s?\(?(?P<date>[0-9-]+)?\)?" +MD_CHANGE_TYPE_RE = r"^###\s(?P<change_type>[a-zA-Z0-9.+\s]+)" +MD_MESSAGE_RE = ( + r"^-\s(\*{2}(?P<scope>[a-zA-Z0-9]+)\*{2}:\s)?(?P<message>.+)(?P<breaking>!)?" +) +md_version_c = re.compile(MD_VERSION_RE) +md_change_type_c = re.compile(MD_CHANGE_TYPE_RE) +md_message_c = re.compile(MD_MESSAGE_RE) + + +CATEGORIES = [ + ("fix", "fix"), + ("breaking", "BREAKING CHANGES"), + ("feat", "feat"), + ("refactor", "refactor"), + ("perf", "perf"), + ("test", "test"), + ("build", "build"), + ("ci", "ci"), + ("chore", "chore"), +] + + +def find_version_blocks(filepath: str) -> Generator: + """Find version block (version block: contains all the information about a version.) + + E.g: + ``` + ## 1.2.1 (2019-07-20) + + ### Fix + + - username validation not working + + ### Feat + + - new login system + + ``` + """ + with open(filepath, "r") as f: + block: list = [] + for line in f: + line = line.strip("\n") + if not line: + continue + + if line.startswith("## "): + if len(block) > 0: + yield block + block = [line] + else: + block.append(line) + yield block + + +def parse_md_version(md_version: str) -> Dict: + m = md_version_c.match(md_version) + if not m: + return {} + return m.groupdict() + + +def parse_md_change_type(md_change_type: str) -> Dict: + m = md_change_type_c.match(md_change_type) + if not m: + return {} + return m.groupdict() + + +def parse_md_message(md_message: str) -> Dict: + m = md_message_c.match(md_message) + if not m: + return {} + return m.groupdict() + + +def transform_change_type(change_type: str) -> str: + # TODO: Use again to parse, for this we have to wait until the maps get + # defined again. + _change_type_lower = change_type.lower() + for match_value, output in CATEGORIES: + if re.search(match_value, _change_type_lower): + return output + else: + raise ValueError(f"Could not match a change_type with {change_type}") + + +def generate_block_tree(block: List[str]) -> Dict: + # tree: Dict = {"commits": []} + changes: Dict = defaultdict(list) + tree: Dict = {"changes": changes} + + change_type = None + for line in block: + if line.startswith("## "): + # version identified + change_type = None + tree = {**tree, **parse_md_version(line)} + elif line.startswith("### "): + # change_type identified + result = parse_md_change_type(line) + if not result: + continue + change_type = result.get("change_type", "").lower() + + elif line.startswith("- "): + # message identified + commit = parse_md_message(line) + changes[change_type].append(commit) + else: + print("it's something else: ", line) + return tree + + +def generate_full_tree(blocks: Iterable) -> Iterable[Dict]: + for block in blocks: + yield generate_block_tree(block) diff --git a/commitizen/cli.py b/commitizen/cli.py index f7e9f28687..19a465d7ab 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -1,11 +1,13 @@ import argparse import logging import sys -import warnings +from functools import partial +import argcomplete from decli import cli -from commitizen import commands, config, out +from commitizen import commands, config +from commitizen.exceptions import CommitizenException, ExpectedExit, NoCommandFoundError logger = logging.getLogger(__name__) data = { @@ -22,21 +24,15 @@ "name": ["-n", "--name"], "help": "use the given commitizen (default: cz_conventional_commits)", }, - { - "name": ["--version"], - "action": "store_true", - "help": "get the version of the installed commitizen", - }, ], "subcommands": { "title": "commands", - # TODO: Add this constraint back in 2.0 - # "required": True, + "required": True, "commands": [ { - "name": "ls", - "help": "show available commitizens", - "func": commands.ListCz, + "name": ["init"], + "help": "init commitizen configuration", + "func": commands.Init, }, { "name": ["commit", "c"], @@ -53,8 +49,26 @@ "action": "store_true", "help": "show output to stdout, no commit, no modified files", }, + { + "name": ["-s", "--signoff"], + "action": "store_true", + "help": "Sign off the commit", + }, + { + "name": "--commit-msg-file", + "help": ( + "ask for the name of the temporal file that contains " + "the commit message. " + "Using it in a git hook script: MSG_FILE=$1" + ), + }, ], }, + { + "name": "ls", + "help": "show available commitizens", + "func": commands.ListCz, + }, { "name": "example", "help": "show commit example", @@ -81,6 +95,23 @@ "action": "store_true", "help": "bump version in the files from the config", }, + { + "name": "--local-version", + "action": "store_true", + "help": "bump only the local version portion", + }, + { + "name": ["--changelog", "-ch"], + "action": "store_true", + "default": False, + "help": "generate the changelog for the newest version", + }, + { + "name": ["--no-verify"], + "action": "store_true", + "default": False, + "help": "this option bypasses the pre-commit and commit-msg hooks", + }, { "name": "--yes", "action": "store_true", @@ -97,7 +128,7 @@ { "name": "--bump-message", "help": ( - "template used to create the release commmit, " + "template used to create the release commit, " "useful when working with CI" ), }, @@ -111,36 +142,67 @@ "help": "manually specify the desired increment", "choices": ["MAJOR", "MINOR", "PATCH"], }, + { + "name": ["--check-consistency", "-cc"], + "help": ( + "check consistency among versions defined in " + "commitizen configuration and version_files" + ), + "action": "store_true", + }, + { + "name": ["--annotated-tag", "-at"], + "help": "create annotated tag instead of lightweight one", + "action": "store_true", + }, + { + "name": ["--changelog-to-stdout"], + "action": "store_true", + "default": False, + "help": "Output changelog to the stdout", + }, ], }, { - "name": ["version"], + "name": ["changelog", "ch"], "help": ( - "get the version of the installed commitizen or the current project" - " (default: installed commitizen)" + "generate changelog (note that it will overwrite existing file)" ), - "func": commands.Version, + "func": commands.Changelog, "arguments": [ { - "name": ["-p", "--project"], - "help": "get the version of the current project", + "name": "--dry-run", "action": "store_true", - "exclusive_group": "group1", + "default": False, + "help": "show changelog to stdout", }, { - "name": ["-c", "--commitizen"], - "help": "get the version of the installed commitizen", - "action": "store_true", - "exclusive_group": "group1", + "name": "--file-name", + "help": "file name of changelog (default: 'CHANGELOG.md')", }, { - "name": ["-v", "--verbose"], + "name": "--unreleased-version", "help": ( - "get the version of both the installed commitizen " - "and the current project" + "set the value for the new version (use the tag value), " + "instead of using unreleased" ), + }, + { + "name": "--incremental", "action": "store_true", - "exclusive_group": "group1", + "default": False, + "help": ( + "generates changelog from last created version, " + "useful if the changelog has been manually modified" + ), + }, + { + "name": "--start-rev", + "default": None, + "help": ( + "start rev of the changelog." + "If not set, it will generate changelog from the start" + ), }, ], }, @@ -151,64 +213,117 @@ "arguments": [ { "name": "--commit-msg-file", - "required": True, "help": ( "ask for the name of the temporal file that contains " "the commit message. " "Using it in a git hook script: MSG_FILE=$1" ), - } + "exclusive_group": "group1", + }, + { + "name": "--rev-range", + "help": "a range of git rev to check. e.g, master..HEAD", + "exclusive_group": "group1", + }, + { + "name": ["-m", "--message"], + "help": "commit message that needs to be checked", + "exclusive_group": "group1", + }, ], }, { - "name": ["init"], - "help": "init commitizen configuration", - "func": commands.Init, + "name": ["version"], + "help": ( + "get the version of the installed commitizen or the current project" + " (default: installed commitizen)" + ), + "func": commands.Version, + "arguments": [ + { + "name": ["-r", "--report"], + "help": "get system information for reporting bugs", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-p", "--project"], + "help": "get the version of the current project", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-c", "--commitizen"], + "help": "get the version of the installed commitizen", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-v", "--verbose"], + "help": ( + "get the version of both the installed commitizen " + "and the current project" + ), + "action": "store_true", + "exclusive_group": "group1", + }, + ], }, ], }, } +original_excepthook = sys.excepthook + + +def commitizen_excepthook(type, value, tracekback, debug=False): + if isinstance(value, CommitizenException): + if value.message: + value.output_method(value.message) + if debug: + original_excepthook(type, value, tracekback) + sys.exit(value.exit_code) + else: + original_excepthook(type, value, tracekback) + + +commitizen_debug_excepthook = partial(commitizen_excepthook, debug=True) + +sys.excepthook = commitizen_excepthook + def main(): conf = config.read_cfg() parser = cli(data) + argcomplete.autocomplete(parser) # Show help if no arg provided if len(sys.argv) == 1: parser.print_help(sys.stderr) - raise SystemExit() + raise ExpectedExit() # This is for the command required constraint in 2.0 try: args = parser.parse_args() - except TypeError: - out.error("Command is required") - raise SystemExit() + except (TypeError, SystemExit) as e: + # https://github.com/commitizen-tools/commitizen/issues/429 + # argparse raises TypeError when non exist command is provided on Python < 3.9 + # but raise SystemExit with exit code == 2 on Python 3.9 + if isinstance(e, TypeError) or (isinstance(e, SystemExit) and e.code == 2): + raise NoCommandFoundError() + raise e if args.name: conf.update({"name": args.name}) elif not args.name and not conf.path: conf.update({"name": "cz_conventional_commits"}) - if args.version: - warnings.warn( - "'cz --version' will be deprecated in next major version. " - "Please use 'cz version' command from your scripts" - ) - args.func = commands.Version - if args.debug: - warnings.warn( - "Debug will be deprecated in next major version. " - "Please remove it from your scripts" - ) logging.getLogger("commitizen").setLevel(logging.DEBUG) + sys.excepthook = commitizen_debug_excepthook - # TODO: This try block can be removed after command is required in 2.0 - # Handle the case that argument is given, but no command is provided - try: - args.func(conf, vars(args))() - except AttributeError: - out.error("Command is required") - raise SystemExit() + args.func(conf, vars(args))() + + +if __name__ == "__main__": + main() diff --git a/commitizen/cmd.py b/commitizen/cmd.py index 67c7cf6bd0..fb041c9faf 100644 --- a/commitizen/cmd.py +++ b/commitizen/cmd.py @@ -7,6 +7,7 @@ class Command(NamedTuple): err: str stdout: bytes stderr: bytes + return_code: int def run(cmd: str) -> Command: @@ -18,4 +19,5 @@ def run(cmd: str) -> Command: stdin=subprocess.PIPE, ) stdout, stderr = process.communicate() - return Command(stdout.decode(), stderr.decode(), stdout, stderr) + return_code = process.returncode + return Command(stdout.decode(), stderr.decode(), stdout, stderr, return_code) diff --git a/commitizen/commands/__init__.py b/commitizen/commands/__init__.py index e315a987c9..806e384522 100644 --- a/commitizen/commands/__init__.py +++ b/commitizen/commands/__init__.py @@ -1,18 +1,19 @@ from .bump import Bump +from .changelog import Changelog from .check import Check from .commit import Commit from .example import Example from .info import Info +from .init import Init from .list_cz import ListCz from .schema import Schema from .version import Version -from .init import Init - __all__ = ( "Bump", "Check", "Commit", + "Changelog", "Example", "Info", "ListCz", diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 605fde9878..4f2a0d3981 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -1,16 +1,21 @@ -from typing import Optional, List +from typing import List, Optional import questionary from packaging.version import Version -from commitizen import bump, factory, git, out +from commitizen import bump, cmd, factory, git, out +from commitizen.commands.changelog import Changelog from commitizen.config import BaseConfig -from commitizen.error_codes import ( - NO_COMMITS_FOUND, - NO_VERSION_SPECIFIED, - NO_PATTERN_MAP, - COMMIT_FAILED, - TAG_FAILED, +from commitizen.exceptions import ( + BumpCommitFailedError, + BumpTagFailedError, + DryRunExit, + ExpectedExit, + NoCommitsFoundError, + NoneIncrementExit, + NoPatternMapError, + NotAGitProjectError, + NoVersionSpecifiedError, ) @@ -18,17 +23,32 @@ class Bump: """Show prompt for the user to create a guided commit.""" def __init__(self, config: BaseConfig, arguments: dict): + if not git.is_git_project(): + raise NotAGitProjectError() + self.config: BaseConfig = config self.arguments: dict = arguments self.bump_settings: dict = { **config.settings, **{ key: arguments[key] - for key in ["tag_format", "prerelease", "increment", "bump_message"] + for key in [ + "tag_format", + "prerelease", + "increment", + "bump_message", + "annotated_tag", + ] if arguments[key] is not None }, } self.cz = factory.commiter_factory(self.config) + self.changelog = arguments["changelog"] or self.config.settings.get( + "update_changelog_on_bump" + ) + self.changelog_to_stdout = arguments["changelog_to_stdout"] + self.no_verify = arguments["no_verify"] + self.check_consistency = arguments["check_consistency"] def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool: """Check if reading the whole git tree up to HEAD is needed.""" @@ -52,8 +72,9 @@ def find_increment(self, commits: List[git.GitCommit]) -> Optional[str]: bump_pattern = self.cz.bump_pattern bump_map = self.cz.bump_map if not bump_map or not bump_pattern: - out.error(f"'{self.config.settings['name']}' rule does not support bump") - raise SystemExit(NO_PATTERN_MAP) + raise NoPatternMapError( + f"'{self.config.settings['name']}' rule does not support bump" + ) increment = bump.find_increment( commits, regex=bump_pattern, increments_map=bump_map ) @@ -64,25 +85,21 @@ def __call__(self): # noqa: C901 try: current_version_instance: Version = Version(self.bump_settings["version"]) except TypeError: - out.error( - "[NO_VERSION_SPECIFIED]\n" - "Check if current version is specified in config file, like:\n" - "version = 0.4.3\n" - ) - raise SystemExit(NO_VERSION_SPECIFIED) + raise NoVersionSpecifiedError() # Initialize values from sources (conf) current_version: str = self.config.settings["version"] tag_format: str = self.bump_settings["tag_format"] bump_commit_message: str = self.bump_settings["bump_message"] - version_files: list = self.bump_settings["version_files"] + version_files: List[str] = self.bump_settings["version_files"] dry_run: bool = self.arguments["dry_run"] is_yes: bool = self.arguments["yes"] increment: Optional[str] = self.arguments["increment"] prerelease: str = self.arguments["prerelease"] is_files_only: Optional[bool] = self.arguments["files_only"] + is_local_version: Optional[bool] = self.arguments["local_version"] current_tag_version: str = bump.create_tag( current_version, tag_format=tag_format @@ -94,50 +111,127 @@ def __call__(self): # noqa: C901 else: commits = git.get_commits(current_tag_version) + # If user specified changelog_to_stdout, they probably want the + # changelog to be generated as well, this is the most intuitive solution + if not self.changelog and self.changelog_to_stdout: + self.changelog = True + # No commits, there is no need to create an empty tag. # Unless we previously had a prerelease. if not commits and not current_version_instance.is_prerelease: - out.error("[NO_COMMITS_FOUND]\n" "No new commits found.") - raise SystemExit(NO_COMMITS_FOUND) + raise NoCommitsFoundError("[NO_COMMITS_FOUND]\n" "No new commits found.") if increment is None: increment = self.find_increment(commits) + # It may happen that there are commits, but they are not elegible + # for an increment, this generates a problem when using prerelease (#281) + if ( + prerelease + and increment is None + and not current_version_instance.is_prerelease + ): + raise NoCommitsFoundError( + "[NO_COMMITS_FOUND]\n" + "No commits found to generate a pre-release.\n" + "To avoid this error, manually specify the type of increment with `--increment`" + ) + # Increment is removed when current and next version # are expected to be prereleases. if prerelease and current_version_instance.is_prerelease: increment = None new_version = bump.generate_version( - current_version, increment, prerelease=prerelease + current_version, + increment, + prerelease=prerelease, + is_local_version=is_local_version, ) + new_tag_version = bump.create_tag(new_version, tag_format=tag_format) message = bump.create_commit_message( current_version, new_version, bump_commit_message ) # Report found information - out.write( - f"message\n" + information = ( + f"{message}\n" f"tag to create: {new_tag_version}\n" f"increment detected: {increment}\n" ) + if self.changelog_to_stdout: + # When the changelog goes to stdout, we want to send + # the bump information to stderr, this way the + # changelog output can be captured + out.diagnostic(information) + else: + out.write(information) + + if increment is None and new_tag_version == current_tag_version: + raise NoneIncrementExit() + # Do not perform operations over files or git. if dry_run: - raise SystemExit() + raise DryRunExit() + + bump.update_version_in_files( + current_version, + str(new_version), + version_files, + check_consistency=self.check_consistency, + ) + + if self.changelog: + if self.changelog_to_stdout: + changelog_cmd = Changelog( + self.config, + { + "unreleased_version": new_tag_version, + "incremental": True, + "dry_run": True, + }, + ) + try: + changelog_cmd() + except DryRunExit: + pass + changelog_cmd = Changelog( + self.config, + { + "unreleased_version": new_tag_version, + "incremental": True, + "dry_run": dry_run, + }, + ) + changelog_cmd() + c = cmd.run(f"git add {changelog_cmd.file_name}") + + self.config.set_key("version", str(new_version)) - bump.update_version_in_files(current_version, new_version.public, version_files) if is_files_only: - raise SystemExit() - - self.config.set_key("version", new_version.public) - c = git.commit(message, args="-a") - if c.err: - out.error('git.commit errror: "{}"'.format(c.err.strip())) - raise SystemExit(COMMIT_FAILED) - c = git.tag(new_tag_version) - if c.err: - out.error(c.err) - raise SystemExit(TAG_FAILED) - out.success("Done!") + raise ExpectedExit() + + c = git.commit(message, args=self._get_commit_args()) + if c.return_code != 0: + raise BumpCommitFailedError(f'git.commit error: "{c.err.strip()}"') + c = git.tag( + new_tag_version, + annotated=self.bump_settings.get("annotated_tag", False) + or bool(self.config.settings.get("annotated_tag", False)), + ) + if c.return_code != 0: + raise BumpTagFailedError(c.err) + + # TODO: For v3 output this only as diagnostic and remove this if + if self.changelog_to_stdout: + out.diagnostic("Done!") + else: + out.success("Done!") + + def _get_commit_args(self): + commit_args = ["-a"] + if self.no_verify: + commit_args.append("--no-verify") + return " ".join(commit_args) diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py new file mode 100644 index 0000000000..535632dfc0 --- /dev/null +++ b/commitizen/commands/changelog.py @@ -0,0 +1,140 @@ +import os.path +from difflib import SequenceMatcher +from operator import itemgetter +from typing import Callable, Dict, List, Optional + +from commitizen import changelog, factory, git, out +from commitizen.config import BaseConfig +from commitizen.exceptions import ( + DryRunExit, + NoCommitsFoundError, + NoPatternMapError, + NoRevisionError, + NotAGitProjectError, +) +from commitizen.git import GitTag + + +def similar(a, b): + return SequenceMatcher(None, a, b).ratio() + + +class Changelog: + """Generate a changelog based on the commit history.""" + + def __init__(self, config: BaseConfig, args): + if not git.is_git_project(): + raise NotAGitProjectError() + + self.config: BaseConfig = config + self.cz = factory.commiter_factory(self.config) + + self.start_rev = args.get("start_rev") or self.config.settings.get( + "changelog_start_rev" + ) + self.file_name = args.get("file_name") or self.config.settings.get( + "changelog_file" + ) + self.incremental = args["incremental"] or self.config.settings.get( + "changelog_incremental" + ) + self.dry_run = args["dry_run"] + self.unreleased_version = args["unreleased_version"] + self.change_type_map = ( + self.config.settings.get("change_type_map") or self.cz.change_type_map + ) + self.change_type_order = ( + self.config.settings.get("change_type_order") or self.cz.change_type_order + ) + + def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: + """Try to find the 'start_rev'. + + We use a similarity approach. We know how to parse the version from the markdown + changelog, but not the whole tag, we don't even know how's the tag made. + + This 'smart' function tries to find a similarity between the found version number + and the available tag. + + The SIMILARITY_THRESHOLD is an empirical value, it may have to be adjusted based + on our experience. + """ + SIMILARITY_THRESHOLD = 0.89 + tag_ratio = map( + lambda tag: (SequenceMatcher(None, latest_version, tag.name).ratio(), tag), + tags, + ) + try: + score, tag = max(tag_ratio, key=itemgetter(0)) + except ValueError: + raise NoRevisionError() + if score < SIMILARITY_THRESHOLD: + raise NoRevisionError() + start_rev = tag.name + return start_rev + + def __call__(self): + commit_parser = self.cz.commit_parser + changelog_pattern = self.cz.changelog_pattern + start_rev = self.start_rev + unreleased_version = self.unreleased_version + changelog_meta: Dict = {} + change_type_map: Optional[Dict] = self.change_type_map + changelog_message_builder_hook: Optional[ + Callable + ] = self.cz.changelog_message_builder_hook + changelog_hook: Optional[Callable] = self.cz.changelog_hook + if not changelog_pattern or not commit_parser: + raise NoPatternMapError( + f"'{self.config.settings['name']}' rule does not support changelog" + ) + + tags = git.get_tags() + if not tags: + tags = [] + + if self.incremental: + changelog_meta = changelog.get_metadata(self.file_name) + latest_version = changelog_meta.get("latest_version") + if latest_version: + start_rev = self._find_incremental_rev(latest_version, tags) + + commits = git.get_commits(start=start_rev, args="--author-date-order") + if not commits: + raise NoCommitsFoundError("No commits found") + + tree = changelog.generate_tree_from_commits( + commits, + tags, + commit_parser, + changelog_pattern, + unreleased_version, + change_type_map=change_type_map, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + if self.change_type_order: + tree = changelog.order_changelog_tree(tree, self.change_type_order) + changelog_out = changelog.render_changelog(tree) + changelog_out = changelog_out.lstrip("\n") + + if self.dry_run: + out.write(changelog_out) + raise DryRunExit() + + lines = [] + if self.incremental and os.path.isfile(self.file_name): + with open(self.file_name, "r") as changelog_file: + lines = changelog_file.readlines() + + with open(self.file_name, "w") as changelog_file: + partial_changelog: Optional[str] = None + if self.incremental: + new_lines = changelog.incremental_build( + changelog_out, lines, changelog_meta + ) + changelog_out = "".join(new_lines) + partial_changelog = changelog_out + + if changelog_hook: + changelog_out = changelog_hook(changelog_out, partial_changelog) + changelog_file.write(changelog_out) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index de5f5e39f9..33fd08a7d0 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -1,51 +1,97 @@ import os import re +import sys +from typing import Dict, Optional -from commitizen import factory, out +from commitizen import factory, git, out from commitizen.config import BaseConfig -from commitizen.error_codes import INVALID_COMMIT_MSG +from commitizen.exceptions import ( + InvalidCommandArgumentError, + InvalidCommitMessageError, + NoCommitsFoundError, +) class Check: """Check if the current commit msg matches the commitizen format.""" - def __init__(self, config: BaseConfig, arguments: dict, cwd=os.getcwd()): - """Init method. - - Parameters - ---------- - config : BaseConfig - the config object required for the command to perform its action - arguments : dict - the arguments object that contains all - the flags provided by the user + def __init__(self, config: BaseConfig, arguments: Dict[str, str], cwd=os.getcwd()): + """Initial check command. + Args: + config: The config object required for the command to perform its action + arguments: All the flags provided by the user + cwd: Current work directory """ + self.commit_msg_file: Optional[str] = arguments.get("commit_msg_file") + self.commit_msg: Optional[str] = arguments.get("message") + self.rev_range: Optional[str] = arguments.get("rev_range") + + self._valid_command_argument() + self.config: BaseConfig = config self.cz = factory.commiter_factory(self.config) - self.arguments: dict = arguments - def __call__(self): - """Validate if a commit message follows the conventional pattern. + def _valid_command_argument(self): + number_args_provided = ( + bool(self.commit_msg_file) + bool(self.commit_msg) + bool(self.rev_range) + ) + if number_args_provided == 0 and not os.isatty(0): + self.commit_msg: Optional[str] = sys.stdin.read() + elif number_args_provided != 1: + raise InvalidCommandArgumentError( + ( + "One and only one argument is required for check command! " + "See 'cz check -h' for more information" + ) + ) - Raises - ------ - SystemExit - if the commit provided not follows the conventional pattern + def __call__(self): + """Validate if commit messages follows the conventional pattern. + Raises: + InvalidCommitMessageError: if the commit provided not follows the conventional pattern """ - commit_msg_content = self._get_commit_msg() + commits = self._get_commits() + if not commits: + raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'") + pattern = self.cz.schema_pattern() - if self._has_proper_format(pattern, commit_msg_content) is not None: - out.success("Commit validation: successful!") - else: - out.error("commit validation: failed!") - out.error("please enter a commit message in the commitizen format.") - raise SystemExit(INVALID_COMMIT_MSG) - - def _get_commit_msg(self): - temp_filename: str = self.arguments.get("commit_msg_file") - return open(temp_filename, "r").read() - - def _has_proper_format(self, pattern, commit_msg): - return re.match(pattern, commit_msg) + ill_formated_commits = [ + commit + for commit in commits + if not Check.validate_commit_message(commit.message, pattern) + ] + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}"' + for commit in ill_formated_commits + ] + ) + if displayed_msgs_content: + raise InvalidCommitMessageError( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {pattern}" + ) + out.success("Commit validation: successful!") + + def _get_commits(self): + # Get commit message from file (--commit-msg-file) + if self.commit_msg_file: + with open(self.commit_msg_file, "r", encoding="utf-8") as commit_file: + commit_title = commit_file.readline() + commit_body = commit_file.read() + return [git.GitCommit(rev="", title=commit_title, body=commit_body)] + elif self.commit_msg: + return [git.GitCommit(rev="", title="", body=self.commit_msg)] + + # Get commit messages from git log (--rev-range) + return git.get_commits(end=self.rev_range) + + @staticmethod + def validate_commit_message(commit_msg: str, pattern: str) -> bool: + if commit_msg.startswith("Merge") or commit_msg.startswith("Revert"): + return True + return bool(re.match(pattern, commit_msg)) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 0dbbe8fe9d..1baad30b95 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -1,35 +1,78 @@ import contextlib import os +import selectors +import sys import tempfile +from asyncio import DefaultEventLoopPolicy, get_event_loop_policy, set_event_loop_policy +from io import IOBase import questionary from commitizen import factory, git, out -from commitizen.cz.exceptions import CzException from commitizen.config import BaseConfig -from commitizen.error_codes import ( - NO_ANSWERS, - COMMIT_ERROR, - NO_COMMIT_BACKUP, - NOTHING_TO_COMMIT, - CUSTOM_ERROR, +from commitizen.cz.exceptions import CzException +from commitizen.exceptions import ( + CommitError, + CustomError, + DryRunExit, + NoAnswersError, + NoCommitBackupError, + NotAGitProjectError, + NothingToCommitError, ) +class CZEventLoopPolicy(DefaultEventLoopPolicy): + def get_event_loop(self): + self.set_event_loop(self._loop_factory(selectors.SelectSelector())) + return self._local._loop + + +class WrapStdx: + def __init__(self, stdx: IOBase): + self._fileno = stdx.fileno() + if sys.platform == "linux": + if self._fileno == 0: + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + tty = open(fd, "wb+", buffering=0) + else: + tty = open("/dev/tty", "w") + else: + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + if self._fileno == 0: + tty = open(fd, "wb+", buffering=0) + else: + tty = open(fd, "rb+", buffering=0) + self.tty = tty + + def __getattr__(self, key): + if key == "encoding" and (sys.platform != "linux" or self._fileno == 0): + return "UTF-8" + return getattr(self.tty, key) + + def __del__(self): + self.tty.close() + + class Commit: """Show prompt for the user to create a guided commit.""" def __init__(self, config: BaseConfig, arguments: dict): + if not git.is_git_project(): + raise NotAGitProjectError() + self.config: BaseConfig = config self.cz = factory.commiter_factory(self.config) self.arguments = arguments - self.temp_file: str = os.path.join(tempfile.gettempdir(), "cz.commit.backup") + self.temp_file: str = os.path.join( + tempfile.gettempdir(), + "cz.commit{user}.backup".format(user=os.environ.get("USER", "")), + ) def read_backup_message(self) -> str: # Check the commit backup file exists if not os.path.isfile(self.temp_file): - out.error("No commit backup found") - raise SystemExit(NO_COMMIT_BACKUP) + raise NoCommitBackupError() # Read commit message from backup with open(self.temp_file, "r") as f: @@ -39,25 +82,36 @@ def prompt_commit_questions(self) -> str: # Prompt user for the commit message cz = self.cz questions = cz.questions() + for question in filter(lambda q: q["type"] == "list", questions): + question["use_shortcuts"] = self.config.settings["use_shortcuts"] try: answers = questionary.prompt(questions, style=cz.style) except ValueError as err: root_err = err.__context__ if isinstance(root_err, CzException): - out.error(root_err.__str__()) - raise SystemExit(CUSTOM_ERROR) + raise CustomError(root_err.__str__()) raise err if not answers: - raise SystemExit(NO_ANSWERS) + raise NoAnswersError() return cz.message(answers) def __call__(self): dry_run: bool = self.arguments.get("dry_run") + commit_msg_file: str = self.arguments.get("commit_msg_file") + if commit_msg_file: + old_stdin = sys.stdin + old_stdout = sys.stdout + old_stderr = sys.stderr + old_event_loop_policy = get_event_loop_policy() + set_event_loop_policy(CZEventLoopPolicy()) + sys.stdin = WrapStdx(sys.stdin) + sys.stdout = WrapStdx(sys.stdout) + sys.stderr = WrapStdx(sys.stderr) + if git.is_staging_clean() and not dry_run: - out.write("No files added to staging!") - raise SystemExit(NOTHING_TO_COMMIT) + raise NothingToCommitError("No files added to staging!") retry: bool = self.arguments.get("retry") @@ -69,25 +123,52 @@ def __call__(self): out.info(f"\n{m}\n") if dry_run: - raise SystemExit(NOTHING_TO_COMMIT) + raise DryRunExit() + + if commit_msg_file: + sys.stdin.close() + sys.stdout.close() + sys.stderr.close() + set_event_loop_policy(old_event_loop_policy) + sys.stdin = old_stdin + sys.stdout = old_stdout + sys.stderr = old_stderr + defaultmesaage = "" + with open(commit_msg_file) as f: + defaultmesaage = f.read() + with open(commit_msg_file, "w") as f: + f.write(m) + f.write(defaultmesaage) + out.success("Commit message is successful!") + return + signoff: bool = self.arguments.get("signoff") + + if signoff: + c = git.commit(m, "-s") + else: + c = git.commit(m) + + signoff: bool = self.arguments.get("signoff") - c = git.commit(m) + if signoff: + c = git.commit(m, "-s") + else: + c = git.commit(m) - if c.err: + if c.return_code != 0: out.error(c.err) # Create commit backup with open(self.temp_file, "w") as f: f.write(m) - raise SystemExit(COMMIT_ERROR) + raise CommitError() if "nothing added" in c.out or "no changes added to commit" in c.out: out.error(c.out) - elif c.err: - out.error(c.err) else: with contextlib.suppress(FileNotFoundError): os.remove(self.temp_file) + out.write(c.err) out.write(c.out) out.success("Commit successful!") diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index cd10edf7f7..d4a4180aa3 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -1,12 +1,16 @@ -from packaging.version import Version +import os import questionary +import yaml +from packaging.version import Version -from commitizen import factory, out +from commitizen import cmd, factory, out +from commitizen.__version__ import __version__ +from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig from commitizen.cz import registry -from commitizen.config import BaseConfig, TomlConfig, IniConfig +from commitizen.defaults import config_files +from commitizen.exceptions import NoAnswersError from commitizen.git import get_latest_tag_name, get_tag_names -from commitizen.defaults import long_term_support_config_files class Init: @@ -20,11 +24,12 @@ def __call__(self): # No config for commitizen exist if not self.config.path: config_path = self._ask_config_path() - if "toml" in config_path: self.config = TomlConfig(data="", path=config_path) - else: - self.config = IniConfig(data="", path=config_path) + elif "json" in config_path: + self.config = JsonConfig(data="{}", path=config_path) + elif "yaml" in config_path: + self.config = YAMLConfig(data="", path=config_path) self.config.init_empty_config_content() @@ -33,15 +38,20 @@ def __call__(self): values_to_add["version"] = Version(tag).public values_to_add["tag_format"] = self._ask_tag_format(tag) self._update_config_file(values_to_add) - out.write("The configuration are all set.") + + if questionary.confirm("Do you want to install pre-commit hook?").ask(): + self._install_pre_commit_hook() + + out.write("You can bump the version and create changelog running:\n") + out.info("cz bump --changelog") + out.success("The configuration are all set.") else: - # TODO: handle the case that config file exist but no value out.line(f"Config file {self.config.path} already exists") def _ask_config_path(self) -> str: name = questionary.select( - "Please choose a supported config file: (default: pyproject.tml)", - choices=long_term_support_config_files, + "Please choose a supported config file: (default: pyproject.toml)", + choices=config_files, default="pyproject.toml", style=self.cz.style, ).ask() @@ -49,7 +59,7 @@ def _ask_config_path(self) -> str: def _ask_name(self) -> str: name = questionary.select( - "Please choose a cz: (default: cz_conventional_commits)", + "Please choose a cz (commit rule): (default: cz_conventional_commits)", choices=list(registry.keys()), default="cz_conventional_commits", style=self.cz.style, @@ -73,13 +83,12 @@ def _ask_tag(self) -> str: latest_tag = questionary.select( "Please choose the latest tag: ", - choices=get_tag_names(), + choices=get_tag_names(), # type: ignore style=self.cz.style, ).ask() if not latest_tag: - out.error("Tag is required!") - raise SystemExit() + raise NoAnswersError("Tag is required!") return latest_tag def _ask_tag_format(self, latest_tag) -> str: @@ -100,10 +109,49 @@ def _ask_tag_format(self, latest_tag) -> str: tag_format = "$version" return tag_format - def _update_config_file(self, values): - if not values: - out.write("The configuration were all set. Nothing to add.") - raise SystemExit() + def _install_pre_commit_hook(self): + pre_commit_config_filename = ".pre-commit-config.yaml" + cz_hook_config = { + "repo": "https://github.com/commitizen-tools/commitizen", + "rev": f"v{__version__}", + "hooks": [{"id": "commitizen", "stages": ["commit-msg"]}], + } + + config_data = {} + if not os.path.isfile(pre_commit_config_filename): + # .pre-commit-config does not exist + config_data["repos"] = [cz_hook_config] + else: + with open(pre_commit_config_filename) as config_file: + yaml_data = yaml.safe_load(config_file) + if yaml_data: + config_data = yaml_data + + if "repos" in config_data: + for pre_commit_hook in config_data["repos"]: + if "commitizen" in pre_commit_hook["repo"]: + out.write("commitizen already in pre-commit config") + break + else: + config_data["repos"].append(cz_hook_config) + else: + # .pre-commit-config exists but there's no "repos" key + config_data["repos"] = [cz_hook_config] + + with open(pre_commit_config_filename, "w") as config_file: + yaml.safe_dump(config_data, stream=config_file) + + c = cmd.run("pre-commit install --hook-type commit-msg") + if c.return_code == 127: + out.error( + "pre-commit is not installed in current environement.\n" + "Run 'pre-commit install --hook-type commit-msg' again after it's installed" + ) + elif c.return_code != 0: + out.error(c.err) + else: + out.write("commitizen pre-commit hook is now installed in your '.git'\n") + def _update_config_file(self, values): for key, value in values.items(): self.config.set_key(key, value) diff --git a/commitizen/commands/list_cz.py b/commitizen/commands/list_cz.py index 3f418f440b..99701865af 100644 --- a/commitizen/commands/list_cz.py +++ b/commitizen/commands/list_cz.py @@ -1,6 +1,6 @@ from commitizen import out -from commitizen.cz import registry from commitizen.config import BaseConfig +from commitizen.cz import registry class ListCz: diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index d64d8d82af..dc47e7aa0c 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -1,6 +1,9 @@ +import platform +import sys + from commitizen import out -from commitizen.config import BaseConfig from commitizen.__version__ import __version__ +from commitizen.config import BaseConfig class Version: @@ -9,21 +12,27 @@ class Version: def __init__(self, config: BaseConfig, *args): self.config: BaseConfig = config self.parameter = args[0] + self.operating_system = platform.system() + self.python_version = sys.version def __call__(self): - if self.parameter.get("project"): + if self.parameter.get("report"): + out.write(f"Commitizen Version: {__version__}") + out.write(f"Python Version: {self.python_version}") + out.write(f"Operating System: {self.operating_system}") + elif self.parameter.get("project"): version = self.config.settings["version"] if version: out.write(f"{version}") else: - out.error(f"No project information in this project.") + out.error("No project information in this project.") elif self.parameter.get("verbose"): out.write(f"Installed Commitizen Version: {__version__}") version = self.config.settings["version"] if version: out.write(f"Project Version: {version}") else: - out.error(f"No project information in this project.") + out.error("No project information in this project.") else: # if no argument is given, show installed commitizen version out.write(f"{__version__}") diff --git a/commitizen/config/__init__.py b/commitizen/config/__init__.py index 88949f327d..e4e414437c 100644 --- a/commitizen/config/__init__.py +++ b/commitizen/config/__init__.py @@ -1,69 +1,42 @@ -import warnings from pathlib import Path -from typing import Optional +from typing import Union + +from commitizen import defaults, git -from commitizen import defaults, git, out -from commitizen.error_codes import NOT_A_GIT_PROJECT from .base_config import BaseConfig +from .json_config import JsonConfig from .toml_config import TomlConfig -from .ini_config import IniConfig - - -def load_global_conf() -> Optional[IniConfig]: - home = Path.home() - global_cfg = home / Path(".cz") - if not global_cfg.exists(): - return None - - # global conf doesnt make sense with commitizen bump - # so I'm deprecating it and won't test it - message = ( - "Global conf will be deprecated in next major version. " - "Use per project configuration. " - "Remove '~/.cz' file from your conf folder." - ) - warnings.simplefilter("always", DeprecationWarning) - warnings.warn(message, category=DeprecationWarning) - - with open(global_cfg, "r") as f: - data = f.read() - - conf = IniConfig(data) - return conf +from .yaml_config import YAMLConfig def read_cfg() -> BaseConfig: conf = BaseConfig() git_project_root = git.find_git_project_root() - if not git_project_root: - out.error( - "fatal: not a git repository (or any of the parent directories): .git" - ) - raise SystemExit(NOT_A_GIT_PROJECT) + cfg_search_paths = [Path(".")] + if git_project_root: + cfg_search_paths.append(git_project_root) - allowed_cfg_files = defaults.config_files cfg_paths = ( path / Path(filename) - for path in [Path("."), git_project_root] - for filename in allowed_cfg_files + for path in cfg_search_paths + for filename in defaults.config_files ) for filename in cfg_paths: if not filename.exists(): continue - with open(filename, "r") as f: - data: str = f.read() + _conf: Union[TomlConfig, JsonConfig, YAMLConfig] + + with open(filename, "rb") as f: + data: bytes = f.read() if "toml" in filename.suffix: _conf = TomlConfig(data=data, path=filename) - else: - warnings.warn( - ".cz, setup.cfg, and .cz.cfg will be deprecated " - "in next major version. \n" - 'Please use "pyproject.toml", ".cz.toml" instead' - ) - _conf = IniConfig(data=data, path=filename) + elif "json" in filename.suffix: + _conf = JsonConfig(data=data, path=filename) + elif "yaml" in filename.suffix: + _conf = YAMLConfig(data=data, path=filename) if _conf.is_empty_config: continue @@ -71,9 +44,4 @@ def read_cfg() -> BaseConfig: conf = _conf break - if not conf.path: - global_conf = load_global_conf() - if global_conf: - conf = global_conf - return conf diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py index 7f5951d790..76cd1706e1 100644 --- a/commitizen/config/base_config.py +++ b/commitizen/config/base_config.py @@ -1,20 +1,20 @@ -import warnings -from typing import Optional +from pathlib import Path +from typing import Any, Dict, Optional, Union from commitizen.defaults import DEFAULT_SETTINGS class BaseConfig: def __init__(self): - self._settings: dict = DEFAULT_SETTINGS.copy() - self._path: Optional[str] = None + self._settings: Dict[str, Any] = DEFAULT_SETTINGS.copy() + self._path: Optional[Path] = None @property - def settings(self) -> dict: + def settings(self) -> Dict[str, Any]: return self._settings @property - def path(self) -> str: + def path(self) -> Optional[Path]: return self._path def set_key(self, key, value): @@ -28,21 +28,8 @@ def set_key(self, key, value): def update(self, data: dict): self._settings.update(data) - def add_path(self, path: str): - self._path = path + def add_path(self, path: Union[str, Path]): + self._path = Path(path) - def _parse_setting(self, data: str) -> dict: + def _parse_setting(self, data: Union[bytes, str]) -> dict: raise NotImplementedError() - - # TODO: remove "files" supported in 2.0 - @classmethod - def _show_files_column_deprecated_warning(cls): - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - ( - '"files" is renamed as "version_files" ' - "and will be deprecated in next major version\n" - 'Please repalce "files" with "version_files"' - ), - category=DeprecationWarning, - ) diff --git a/commitizen/config/ini_config.py b/commitizen/config/ini_config.py deleted file mode 100644 index 39f7488bc4..0000000000 --- a/commitizen/config/ini_config.py +++ /dev/null @@ -1,74 +0,0 @@ -import configparser -import json -import warnings - -from .base_config import BaseConfig - - -class IniConfig(BaseConfig): - def __init__(self, *, data: str, path: str): - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - ( - ".cz, setup.cfg, and .cz.cfg will be deprecated " - "in next major version. \n" - 'Please use "pyproject.toml", ".cz.toml" instead' - ), - category=DeprecationWarning, - ) - - super(IniConfig, self).__init__() - self.is_empty_config = False - self._parse_setting(data) - self.add_path(path) - - def init_empty_config_file(self): - with open(self.path, "w") as toml_file: - toml_file.write("[commitizen]") - - def set_key(self, key, value): - """Set or update a key in the conf. - - For now only strings are supported. - We use to update the version number. - """ - parser = configparser.ConfigParser() - parser.read(self.path) - parser["commitizen"][key] = value - with open(self.path, "w") as f: - parser.write(f) - return self - - def _parse_setting(self, data: str): - """We expect to have a section like this - - ``` - [commitizen] - name = cz_jira - version_files = [ - "commitizen/__version__.py", - "pyproject.toml" - ] # this tab at the end is important - style = [ - ["pointer", "reverse"], - ["question", "underline"] - ] # this tab at the end is important - ``` - """ - config = configparser.ConfigParser(allow_no_value=True) - config.read_string(data) - try: - _data: dict = dict(config["commitizen"]) - if "files" in _data: - IniConfig._show_files_column_deprecated_warning() - _data.update({"version_files": json.loads(_data["files"])}) - - if "version_files" in _data: - _data.update({"version_files": json.loads(_data["version_files"])}) - - if "style" in _data: - _data.update({"style": json.loads(_data["style"])}) - - self._settings.update(_data) - except KeyError: - self.is_empty_config = True diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py new file mode 100644 index 0000000000..445f2aac5f --- /dev/null +++ b/commitizen/config/json_config.py @@ -0,0 +1,48 @@ +import json +from pathlib import Path +from typing import Union + +from .base_config import BaseConfig + + +class JsonConfig(BaseConfig): + def __init__(self, *, data: Union[bytes, str], path: Union[Path, str]): + super(JsonConfig, self).__init__() + self.is_empty_config = False + self._parse_setting(data) + self.add_path(path) + + def init_empty_config_content(self): + with open(self.path, "a") as json_file: + json.dump({"commitizen": {}}, json_file) + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + with open(self.path, "rb") as f: + parser = json.load(f) + + parser["commitizen"][key] = value + with open(self.path, "w") as f: + json.dump(parser, f, indent=2) + return self + + def _parse_setting(self, data: Union[bytes, str]): + """We expect to have a section in .cz.json looking like + + ``` + { + "commitizen": { + "name": "cz_conventional_commits" + } + } + ``` + """ + doc = json.loads(data) + try: + self.settings.update(doc["commitizen"]) + except KeyError: + self.is_empty_config = True diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 21ffd62e8d..b5b7f7b2a1 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -1,18 +1,31 @@ -from tomlkit import exceptions, parse +import os +from pathlib import Path +from typing import Union + +from tomlkit import exceptions, parse, table from .base_config import BaseConfig class TomlConfig(BaseConfig): - def __init__(self, *, data: str, path: str): + def __init__(self, *, data: Union[bytes, str], path: Union[Path, str]): super(TomlConfig, self).__init__() self.is_empty_config = False self._parse_setting(data) self.add_path(path) def init_empty_config_content(self): - with open(self.path, "a") as toml_file: - toml_file.write("[tool.commitizen]") + if os.path.isfile(self.path): + with open(self.path, "rb") as input_toml_file: + parser = parse(input_toml_file.read()) + else: + parser = parse("") + + with open(self.path, "wb") as output_toml_file: + if parser.get("tool") is None: + parser["tool"] = table() + parser["tool"]["commitizen"] = table() + output_toml_file.write(parser.as_string().encode("utf-8")) def set_key(self, key, value): """Set or update a key in the conf. @@ -20,15 +33,15 @@ def set_key(self, key, value): For now only strings are supported. We use to update the version number. """ - with open(self.path, "r") as f: + with open(self.path, "rb") as f: parser = parse(f.read()) parser["tool"]["commitizen"][key] = value - with open(self.path, "w") as f: - f.write(parser.as_string()) + with open(self.path, "wb") as f: + f.write(parser.as_string().encode("utf-8")) return self - def _parse_setting(self, data: str): + def _parse_setting(self, data: Union[bytes, str]): """We expect to have a section in pyproject looking like ``` @@ -36,12 +49,8 @@ def _parse_setting(self, data: str): name = "cz_conventional_commits" ``` """ - doc = parse(data) + doc = parse(data) # type: ignore try: - self.settings.update(doc["tool"]["commitizen"]) + self.settings.update(doc["tool"]["commitizen"]) # type: ignore except exceptions.NonExistentKey: self.is_empty_config = True - - if "files" in self.settings: - self.settings["version_files"] = self.settings["files"] - TomlConfig._show_files_column_deprecated_warning diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py new file mode 100644 index 0000000000..5d199c1929 --- /dev/null +++ b/commitizen/config/yaml_config.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Union + +import yaml + +from .base_config import BaseConfig + + +class YAMLConfig(BaseConfig): + def __init__(self, *, data: Union[bytes, str], path: Union[Path, str]): + super(YAMLConfig, self).__init__() + self.is_empty_config = False + self._parse_setting(data) + self.add_path(path) + + def init_empty_config_content(self): + with open(self.path, "a") as json_file: + yaml.dump({"commitizen": {}}, json_file) + + def _parse_setting(self, data: Union[bytes, str]): + """We expect to have a section in cz.yaml looking like + + ``` + commitizen: + name: cz_conventional_commits + ``` + """ + doc = yaml.safe_load(data) + try: + self.settings.update(doc["commitizen"]) + except (KeyError, TypeError): + self.is_empty_config = True + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + with open(self.path, "rb") as yaml_file: + parser = yaml.load(yaml_file, Loader=yaml.FullLoader) + + parser["commitizen"][key] = value + with open(self.path, "w") as yaml_file: + yaml.dump(parser, yaml_file) + + return self diff --git a/commitizen/cz/__init__.py b/commitizen/cz/__init__.py index 3c379f3c6b..a14ea95edf 100644 --- a/commitizen/cz/__init__.py +++ b/commitizen/cz/__init__.py @@ -1,17 +1,19 @@ import importlib import pkgutil +from typing import Dict, Type +from commitizen.cz.base import BaseCommitizen from commitizen.cz.conventional_commits import ConventionalCommitsCz from commitizen.cz.customize import CustomizeCommitsCz from commitizen.cz.jira import JiraSmartCz -registry = { +registry: Dict[str, Type[BaseCommitizen]] = { "cz_conventional_commits": ConventionalCommitsCz, "cz_jira": JiraSmartCz, "cz_customize": CustomizeCommitsCz, } plugins = { - name: importlib.import_module(name).discover_this + name: importlib.import_module(name).discover_this # type: ignore for finder, name, ispkg in pkgutil.iter_modules() if name.startswith("cz_") } diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 258f43ca5e..734852c868 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,8 +1,11 @@ from abc import ABCMeta, abstractmethod -from typing import List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from prompt_toolkit.styles import Style, merge_styles +from commitizen import git +from commitizen.config.base_config import BaseConfig + class BaseCommitizen(metaclass=ABCMeta): bump_pattern: Optional[str] = None @@ -20,7 +23,23 @@ class BaseCommitizen(metaclass=ABCMeta): ("disabled", "fg:#858585 italic"), ] - def __init__(self, config: dict): + # The whole subject will be parsed as message by default + # This allows supporting changelog for any rule system. + # It can be modified per rule + commit_parser: Optional[str] = r"(?P<message>.*)" + changelog_pattern: Optional[str] = r".*" + change_type_map: Optional[Dict[str, str]] = None + change_type_order: Optional[List[str]] = None + + # Executed per message parsed by the commitizen + changelog_message_builder_hook: Optional[ + Callable[[Dict, git.GitCommit], Dict] + ] = None + + # Executed only at the end of the changelog generation + changelog_hook: Optional[Callable[[str, Optional[str]], str]] = None + + def __init__(self, config: BaseConfig): self.config = config if not self.config.settings.get("style"): self.config.settings.update({"style": BaseCommitizen.default_style_config}) @@ -42,18 +61,25 @@ def style(self): ] ) - def example(self) -> str: + def example(self) -> Optional[str]: """Example of the commit message.""" raise NotImplementedError("Not Implemented yet") - def schema(self) -> str: + def schema(self) -> Optional[str]: """Schema definition of the commit message.""" raise NotImplementedError("Not Implemented yet") - def schema_pattern(self) -> str: - """Regex matching the schema used for message validation""" + def schema_pattern(self) -> Optional[str]: + """Regex matching the schema used for message validation.""" raise NotImplementedError("Not Implemented yet") - def info(self) -> str: + def info(self) -> Optional[str]: """Information about the standardized commit message.""" raise NotImplementedError("Not Implemented yet") + + def process_commit(self, commit: str) -> str: + """Process commit for changelog. + + If not overwritten, it returns the first line of commit. + """ + return commit.split("\n")[0] diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index c67b2a5a0d..61f7b7f94b 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -1,4 +1,6 @@ import os +import re +from typing import Any, Dict, List from commitizen import defaults from commitizen.cz.base import BaseCommitizen @@ -28,9 +30,17 @@ def parse_subject(text): class ConventionalCommitsCz(BaseCommitizen): bump_pattern = defaults.bump_pattern bump_map = defaults.bump_map - - def questions(self) -> list: - questions = [ + commit_parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + change_type_map = { + "feat": "Feat", + "fix": "Fix", + "refactor": "Refactor", + "perf": "Perf", + } + + def questions(self) -> List[Dict[str, Any]]: + questions: List[Dict[str, Any]] = [ { "type": "list", "name": "prefix", @@ -39,12 +49,18 @@ def questions(self) -> list: { "value": "fix", "name": "fix: A bug fix. Correlates with PATCH in SemVer", + "key": "x", }, { "value": "feat", "name": "feat: A new feature. Correlates with MINOR in SemVer", + "key": "f", + }, + { + "value": "docs", + "name": "docs: Documentation only changes", + "key": "d", }, - {"value": "docs", "name": "docs: Documentation only changes"}, { "value": "style", "name": ( @@ -52,6 +68,7 @@ def questions(self) -> list: "meaning of the code (white-space, formatting," " missing semi-colons, etc)" ), + "key": "s", }, { "value": "refactor", @@ -59,16 +76,19 @@ def questions(self) -> list: "refactor: A code change that neither fixes " "a bug nor adds a feature" ), + "key": "r", }, { "value": "perf", "name": "perf: A code change that improves performance", + "key": "p", }, { "value": "test", "name": ( "test: Adding missing or correcting " "existing tests" ), + "key": "t", }, { "value": "build", @@ -76,6 +96,7 @@ def questions(self) -> list: "build: Changes that affect the build system or " "external dependencies (example scopes: pip, docker, npm)" ), + "key": "b", }, { "value": "ci", @@ -83,6 +104,7 @@ def questions(self) -> list: "ci: Changes to our CI configuration files and " "scripts (example scopes: GitLabCI)" ), + "key": "c", }, ], }, @@ -90,8 +112,7 @@ def questions(self) -> list: "type": "input", "name": "scope", "message": ( - "Scope. Could be anything specifying place of the " - "commit change (users, db, poll):\n" + "What is the scope of this change? (class or file name): (press [enter] to skip)\n" ), "filter": parse_scope, }, @@ -100,31 +121,29 @@ def questions(self) -> list: "name": "subject", "filter": parse_subject, "message": ( - "Subject. Concise description of the changes. " - "Imperative, lower case and no final dot:\n" + "Write a short and imperative summary of the code changes: (lower case and no period)\n" ), }, - { - "type": "confirm", - "message": "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer", - "name": "is_breaking_change", - "default": False, - }, { "type": "input", "name": "body", "message": ( - "Body. Motivation for the change and contrast this " - "with previous behavior:\n" + "Provide additional contextual information about the code changes: (press [enter] to skip)\n" ), "filter": multiple_line_breaker, }, + { + "type": "confirm", + "message": "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer", + "name": "is_breaking_change", + "default": False, + }, { "type": "input", "name": "footer", "message": ( "Footer. Information about Breaking Changes and " - "reference issues that this commit closes:\n" + "reference issues that this commit closes: (press [enter] to skip)\n" ), }, ] @@ -140,10 +159,10 @@ def message(self, answers: dict) -> str: if scope: scope = f"({scope})" - if is_breaking_change: - body = f"BREAKING CHANGE: {body}" if body: body = f"\n\n{body}" + if is_breaking_change: + footer = f"BREAKING CHANGE: {footer}" if footer: footer = f"\n\n{footer}" @@ -164,15 +183,15 @@ def schema(self) -> str: return ( "<type>(<scope>): <subject>\n" "<BLANK LINE>\n" - "(BREAKING CHANGE: )<body>\n" + "<body>\n" "<BLANK LINE>\n" - "<footer>" + "(BREAKING CHANGE: )<footer>" ) def schema_pattern(self) -> str: PATTERN = ( r"(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)" - r"(\([\w\-]+\))?:\s.*" + r"(\(\S+\))?!?:(\s.*)" ) return PATTERN @@ -182,3 +201,10 @@ def info(self) -> str: with open(filepath, "r") as f: content = f.read() return content + + def process_commit(self, commit: str) -> str: + pat = re.compile(self.schema_pattern()) + m = re.match(pat, commit) + if m is None: + return "" + return m.group(3).strip() diff --git a/commitizen/cz/conventional_commits/conventional_commits_info.txt b/commitizen/cz/conventional_commits/conventional_commits_info.txt index f1b5633e07..a076e4f5ec 100644 --- a/commitizen/cz/conventional_commits/conventional_commits_info.txt +++ b/commitizen/cz/conventional_commits/conventional_commits_info.txt @@ -28,4 +28,4 @@ information and is contained within parenthesis, e.g., feat(parser): add ability [optional body] -[optional footer] \ No newline at end of file +[optional footer] diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 46ea0602d9..acf205d06e 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -1,11 +1,14 @@ try: from jinja2 import Template except ImportError: - from string import Template + from string import Template # type: ignore + +from typing import Any, Dict, List, Optional from commitizen import defaults -from commitizen.cz.base import BaseCommitizen from commitizen.config import BaseConfig +from commitizen.cz.base import BaseCommitizen +from commitizen.exceptions import MissingCzCustomizeConfigError __all__ = ["CustomizeCommitsCz"] @@ -13,10 +16,14 @@ class CustomizeCommitsCz(BaseCommitizen): bump_pattern = defaults.bump_pattern bump_map = defaults.bump_map + change_type_order = defaults.change_type_order def __init__(self, config: BaseConfig): super(CustomizeCommitsCz, self).__init__(config) - self.custom_settings = self.config.settings.get("customize") + + if "customize" not in self.config.settings: + raise MissingCzCustomizeConfigError() + self.custom_settings = self.config.settings["customize"] custom_bump_pattern = self.custom_settings.get("bump_pattern") if custom_bump_pattern: @@ -26,23 +33,30 @@ def __init__(self, config: BaseConfig): if custom_bump_map: self.bump_map = custom_bump_map - def questions(self) -> list: + custom_change_type_order = self.custom_settings.get("change_type_order") + if custom_change_type_order: + self.change_type_order = custom_change_type_order + + def questions(self) -> List[Dict[str, Any]]: return self.custom_settings.get("questions") def message(self, answers: dict) -> str: message_template = Template(self.custom_settings.get("message_template")) if getattr(Template, "substitute", None): - return message_template.substitute(**answers) + return message_template.substitute(**answers) # type: ignore else: return message_template.render(**answers) - def example(self) -> str: + def example(self) -> Optional[str]: return self.custom_settings.get("example") - def schema(self) -> str: + def schema_pattern(self) -> Optional[str]: + return self.custom_settings.get("schema_pattern") + + def schema(self) -> Optional[str]: return self.custom_settings.get("schema") - def info(self) -> str: + def info(self) -> Optional[str]: info_path = self.custom_settings.get("info_path") info = self.custom_settings.get("info") if info_path: @@ -51,3 +65,4 @@ def info(self) -> str: return content elif info: return info + return None diff --git a/commitizen/cz/jira/jira.py b/commitizen/cz/jira/jira.py index b853855fba..46c5965c46 100644 --- a/commitizen/cz/jira/jira.py +++ b/commitizen/cz/jira/jira.py @@ -1,4 +1,5 @@ import os +from typing import Any, Dict, List from commitizen.cz.base import BaseCommitizen @@ -6,7 +7,7 @@ class JiraSmartCz(BaseCommitizen): - def questions(self): + def questions(self) -> List[Dict[str, Any]]: questions = [ { "type": "input", @@ -43,7 +44,7 @@ def questions(self): ] return questions - def message(self, answers): + def message(self, answers) -> str: return " ".join( filter( bool, @@ -57,7 +58,7 @@ def message(self, answers): ) ) - def example(self): + def example(self) -> str: return ( "JRA-34 #comment corrected indent issue\n" "JRA-35 #time 1w 2d 4h 30m Total work logged\n" @@ -66,13 +67,13 @@ def example(self): "ahead of schedule" ) - def schema(self): + def schema(self) -> str: return "<ignored text> <ISSUE_KEY> <ignored text> #<COMMAND> <optional COMMAND_ARGUMENTS>" # noqa def schema_pattern(self) -> str: return r".*[A-Z]{2,}\-[0-9]+( #| .* #).+( #.+)*" - def info(self): + def info(self) -> str: dir_path = os.path.dirname(os.path.realpath(__file__)) filepath = os.path.join(dir_path, "jira_info.txt") with open(filepath, "r") as f: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 0dfee12246..e460bbd6f7 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -1,27 +1,47 @@ +from collections import OrderedDict +from typing import Any, Dict, List + name: str = "cz_conventional_commits" -# TODO: .cz, setup.cfg, .cz.cfg should be removed in 2.0 -long_term_support_config_files: list = ["pyproject.toml", ".cz.toml"] -deprcated_config_files: list = [".cz", "setup.cfg", ".cz.cfg"] -config_files: list = long_term_support_config_files + deprcated_config_files +config_files: List[str] = [ + "pyproject.toml", + ".cz.toml", + ".cz.json", + "cz.json", + ".cz.yaml", + "cz.yaml", +] -DEFAULT_SETTINGS = { +DEFAULT_SETTINGS: Dict[str, Any] = { "name": "cz_conventional_commits", "version": None, "version_files": [], "tag_format": None, # example v$version "bump_message": None, # bumped v$current_version to $new_version + "changelog_file": "CHANGELOG.md", + "changelog_incremental": False, + "changelog_start_rev": None, + "update_changelog_on_bump": False, + "use_shortcuts": False, } MAJOR = "MAJOR" MINOR = "MINOR" PATCH = "PATCH" -bump_pattern = r"^(BREAKING CHANGE|feat|fix|refactor|perf)" -bump_map = { - "BREAKING CHANGE": MAJOR, - "feat": MINOR, - "fix": PATCH, - "refactor": PATCH, - "perf": PATCH, -} +bump_pattern = r"^(BREAKING[\-\ ]CHANGE|feat|fix|refactor|perf)(\(.+\))?(!)?" +bump_map = OrderedDict( + ( + (r"^.+!$", MAJOR), + (r"^BREAKING[\-\ ]CHANGE", MAJOR), + (r"^feat", MINOR), + (r"^fix", PATCH), + (r"^refactor", PATCH), + (r"^perf", PATCH), + ) +) bump_message = "bump: version $current_version โ†’ $new_version" + +change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] + +commit_parser = r"^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?:\s(?P<message>.*)?" # noqa +version_parser = r"(?P<version>([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)" diff --git a/commitizen/error_codes.py b/commitizen/error_codes.py deleted file mode 100644 index c6eeac7a27..0000000000 --- a/commitizen/error_codes.py +++ /dev/null @@ -1,23 +0,0 @@ -# Commitizen factory -NO_COMMITIZEN_FOUND = 1 - -# Config -NOT_A_GIT_PROJECT = 2 - -# Bump -NO_COMMITS_FOUND = 3 -NO_VERSION_SPECIFIED = 4 -NO_PATTERN_MAP = 5 -COMMIT_FAILED = 6 -TAG_FAILED = 7 - -# Commit -NO_ANSWERS = 8 -COMMIT_ERROR = 9 -NO_COMMIT_BACKUP = 10 -NOTHING_TO_COMMIT = 11 -CUSTOM_ERROR = 12 - -# Check -NO_COMMIT_MSG = 13 -INVALID_COMMIT_MSG = 14 diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py new file mode 100644 index 0000000000..8293688715 --- /dev/null +++ b/commitizen/exceptions.py @@ -0,0 +1,144 @@ +import enum + +from commitizen import out + + +class ExitCode(enum.IntEnum): + EXPECTED_EXIT = 0 + NO_COMMITIZEN_FOUND = 1 + NOT_A_GIT_PROJECT = 2 + NO_COMMITS_FOUND = 3 + NO_VERSION_SPECIFIED = 4 + NO_PATTERN_MAP = 5 + BUMP_COMMIT_FAILED = 6 + BUMP_TAG_FAILED = 7 + NO_ANSWERS = 8 + COMMIT_ERROR = 9 + NO_COMMIT_BACKUP = 10 + NOTHING_TO_COMMIT = 11 + CUSTOM_ERROR = 12 + NO_COMMAND_FOUND = 13 + INVALID_COMMIT_MSG = 14 + MISSING_CZ_CUSTOMIZE_CONFIG = 15 + NO_REVISION = 16 + CURRENT_VERSION_NOT_FOUND = 17 + INVALID_COMMAND_ARGUMENT = 18 + INVALID_CONFIGURATION = 19 + + +class CommitizenException(Exception): + def __init__(self, *args, **kwargs): + self.output_method = kwargs.get("output_method") or out.error + self.exit_code = self.__class__.exit_code + if args: + self.message = args[0] + elif hasattr(self.__class__, "message"): + self.message = self.__class__.message + else: + self.message = "" + + def __str__(self): + return self.message + + +class ExpectedExit(CommitizenException): + exit_code = ExitCode.EXPECTED_EXIT + + def __init__(self, *args, **kwargs): + output_method = kwargs.get("output_method") or out.write + kwargs["output_method"] = output_method + super().__init__(*args, **kwargs) + + +class DryRunExit(ExpectedExit): + pass + + +class NoneIncrementExit(ExpectedExit): + pass + + +class NoCommitizenFoundException(CommitizenException): + exit_code = ExitCode.NO_COMMITIZEN_FOUND + + +class NotAGitProjectError(CommitizenException): + exit_code = ExitCode.NOT_A_GIT_PROJECT + message = "fatal: not a git repository (or any of the parent directories): .git" + + +class MissingCzCustomizeConfigError(CommitizenException): + exit_code = ExitCode.MISSING_CZ_CUSTOMIZE_CONFIG + message = "fatal: customize is not set in configuration file." + + +class NoCommitsFoundError(CommitizenException): + exit_code = ExitCode.NO_COMMITS_FOUND + + +class NoVersionSpecifiedError(CommitizenException): + exit_code = ExitCode.NO_VERSION_SPECIFIED + message = ( + "[NO_VERSION_SPECIFIED]\n" + "Check if current version is specified in config file, like:\n" + "version = 0.4.3\n" + ) + + +class NoPatternMapError(CommitizenException): + exit_code = ExitCode.NO_PATTERN_MAP + + +class BumpCommitFailedError(CommitizenException): + exit_code = ExitCode.BUMP_COMMIT_FAILED + + +class BumpTagFailedError(CommitizenException): + exit_code = ExitCode.BUMP_TAG_FAILED + + +class CurrentVersionNotFoundError(CommitizenException): + exit_code = ExitCode.CURRENT_VERSION_NOT_FOUND + + +class NoAnswersError(CommitizenException): + exit_code = ExitCode.NO_ANSWERS + + +class CommitError(CommitizenException): + exit_code = ExitCode.COMMIT_ERROR + + +class NoCommitBackupError(CommitizenException): + exit_code = ExitCode.NO_COMMIT_BACKUP + message = "No commit backup found" + + +class NothingToCommitError(CommitizenException): + exit_code = ExitCode.NOTHING_TO_COMMIT + + +class CustomError(CommitizenException): + exit_code = ExitCode.CUSTOM_ERROR + + +class InvalidCommitMessageError(CommitizenException): + exit_code = ExitCode.INVALID_COMMIT_MSG + + +class NoRevisionError(CommitizenException): + exit_code = ExitCode.NO_REVISION + message = "No tag found to do an incremental changelog" + + +class NoCommandFoundError(CommitizenException): + exit_code = ExitCode.NO_COMMAND_FOUND + message = "Command is required" + + +class InvalidCommandArgumentError(CommitizenException): + exit_code = ExitCode.INVALID_COMMAND_ARGUMENT + + +class InvalidConfigurationError(CommitizenException): + exit_code = ExitCode.INVALID_CONFIGURATION diff --git a/commitizen/factory.py b/commitizen/factory.py index 62b7ba9cfa..09af5fd0f7 100644 --- a/commitizen/factory.py +++ b/commitizen/factory.py @@ -1,7 +1,7 @@ -from commitizen import BaseCommitizen, out -from commitizen.cz import registry +from commitizen import BaseCommitizen from commitizen.config import BaseConfig -from commitizen.error_codes import NO_COMMITIZEN_FOUND +from commitizen.cz import registry +from commitizen.exceptions import NoCommitizenFoundException def commiter_factory(config: BaseConfig) -> BaseCommitizen: @@ -11,10 +11,9 @@ def commiter_factory(config: BaseConfig) -> BaseCommitizen: _cz = registry[name](config) except KeyError: msg_error = ( - "The commiter has not been found in the system.\n\n" + "The committer has not been found in the system.\n\n" f"Try running 'pip install {name}'\n" ) - out.error(msg_error) - raise SystemExit(NO_COMMITIZEN_FOUND) + raise NoCommitizenFoundException(msg_error) else: return _cz diff --git a/commitizen/git.py b/commitizen/git.py index 0853d76005..b196f15115 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -1,27 +1,35 @@ import os from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Optional, List +from typing import List, Optional from commitizen import cmd class GitObject: - def __eq__(self, other): - if not isinstance(other, GitObject): + rev: str + name: str + date: str + + def __eq__(self, other) -> bool: + if not hasattr(other, "rev"): return False return self.rev == other.rev class GitCommit(GitObject): - def __init__(self, rev, title, body=""): + def __init__( + self, rev, title, body: str = "", author: str = "", author_email: str = "" + ): self.rev = rev.strip() self.title = title.strip() self.body = body.strip() + self.author = author.strip() + self.author_email = author_email.strip() @property def message(self): - return f"{self.title}\n\n{self.body}" + return f"{self.title}\n\n{self.body}".strip() def __repr__(self): return f"{self.title} ({self.rev})" @@ -34,15 +42,24 @@ def __init__(self, name, rev, date): self.date = date.strip() def __repr__(self): - return f"{self.name} ({self.rev})" + return f"GitTag('{self.name}', '{self.rev}', '{self.date}')" + + @classmethod + def from_line(cls, line: str, inner_delimiter: str) -> "GitTag": + + name, objectname, date, obj = line.split(inner_delimiter) + if not obj: + obj = objectname + return cls(name=name, rev=obj, date=date) -def tag(tag: str): - c = cmd.run(f"git tag {tag}") + +def tag(tag: str, annotated: bool = False): + c = cmd.run(f"git tag -a {tag} -m {tag}" if annotated else f"git tag {tag}") return c -def commit(message: str, args=""): +def commit(message: str, args: str = ""): f = NamedTemporaryFile("wb", delete=False) f.write(message.encode("utf-8")) f.close() @@ -55,16 +72,17 @@ def get_commits( start: Optional[str] = None, end: str = "HEAD", *, - log_format: str = "%H%n%s%n%b", + log_format: str = "%H%n%s%n%an%n%ae%n%b", delimiter: str = "----------commit-delimiter----------", + args: str = "", ) -> List[GitCommit]: - """ - Get the commits betweeen start and end - """ - git_log_cmd = f"git log --pretty={log_format}{delimiter}" + """Get the commits between start and end.""" + git_log_cmd = ( + f"git -c log.showSignature=False log --pretty={log_format}{delimiter} {args}" + ) if start: - c = cmd.run(f"{git_log_cmd} {start}...{end}") + c = cmd.run(f"{git_log_cmd} {start}..{end}") else: c = cmd.run(f"{git_log_cmd} {end}") @@ -72,15 +90,17 @@ def get_commits( return [] git_commits = [] - for rev_and_commit in c.out.split(delimiter): - rev_and_commit = rev_and_commit.strip() + for rev_and_commit in c.out.split(f"{delimiter}\n"): if not rev_and_commit: continue - rev, title, *body_list = rev_and_commit.split("\n") - + rev, title, author, author_email, *body_list = rev_and_commit.split("\n") if rev_and_commit: git_commit = GitCommit( - rev=rev.strip(), title=title.strip(), body="\n".join(body_list).strip() + rev=rev.strip(), + title=title.strip(), + body="\n".join(body_list).strip(), + author=author, + author_email=author_email, ) git_commits.append(git_commit) return git_commits @@ -89,15 +109,21 @@ def get_commits( def get_tags(dateformat: str = "%Y-%m-%d") -> List[GitTag]: inner_delimiter = "---inner_delimiter---" formatter = ( - f"'%(refname:lstrip=2){inner_delimiter}" + f'"%(refname:lstrip=2){inner_delimiter}' f"%(objectname){inner_delimiter}" - f"%(committerdate:format:{dateformat})'" + f"%(creatordate:format:{dateformat}){inner_delimiter}" + f'%(object)"' ) - c = cmd.run(f"git tag --format={formatter} --sort=-committerdate") + c = cmd.run(f"git tag --format={formatter} --sort=-creatordate") + if c.err or not c.out: return [] - git_tags = [GitTag(*line.split(inner_delimiter)) for line in c.out.split("\n")[:-1]] + git_tags = [ + GitTag.from_line(line=line, inner_delimiter=inner_delimiter) + for line in c.out.split("\n")[:-1] + ] + return git_tags @@ -113,7 +139,7 @@ def get_latest_tag_name() -> Optional[str]: return c.out.strip() -def get_tag_names() -> Optional[List[str]]: +def get_tag_names() -> List[Optional[str]]: c = cmd.run("git tag --list") if c.err: return [] @@ -128,7 +154,13 @@ def find_git_project_root() -> Optional[Path]: def is_staging_clean() -> bool: - """Check if staing is clean""" - c = cmd.run("git diff --no-ext-diff --name-only") - c_cached = cmd.run("git diff --no-ext-diff --cached --name-only") - return not (bool(c.out) or bool(c_cached.out)) + """Check if staging is clean.""" + c = cmd.run("git diff --no-ext-diff --cached --name-only") + return not bool(c.out) + + +def is_git_project() -> bool: + c = cmd.run("git rev-parse --is-inside-work-tree") + if c.out.strip() == "true": + return True + return False diff --git a/commitizen/out.py b/commitizen/out.py index 268f02e29f..7ac5ba420b 100644 --- a/commitizen/out.py +++ b/commitizen/out.py @@ -26,3 +26,7 @@ def success(value: str): def info(value: str): message = colored(value, "blue") line(message) + + +def diagnostic(value: str): + line(value, file=sys.stderr) diff --git a/commitizen/templates/keep_a_changelog_template.j2 b/commitizen/templates/keep_a_changelog_template.j2 new file mode 100644 index 0000000000..de63880d66 --- /dev/null +++ b/commitizen/templates/keep_a_changelog_template.j2 @@ -0,0 +1,19 @@ +{% for entry in tree %} + +## {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %} + +{% for change_key, changes in entry.changes.items() %} + +{% if change_key %} +### {{ change_key }} +{% endif %} + +{% for change in changes %} +{% if change.scope %} +- **{{ change.scope }}**: {{ change.message }} +{% elif change.message %} +- {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/docs/README.md b/docs/README.md index 561d9487e1..6791891d22 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,14 +1,20 @@ -[![Github Actions](https://github.com/Woile/commitizen/workflows/Python%20package/badge.svg?style=flat-square)](https://github.com/Woile/commitizen/actions) -[![Conventional -Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) -[![PyPI Package latest -release](https://img.shields.io/pypi/v/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/) -[![Supported -versions](https://img.shields.io/pypi/pyversions/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/) -[![Codecov](https://img.shields.io/codecov/c/github/Woile/commitizen.svg?style=flat-square)](https://codecov.io/gh/Woile/commitizen) +[![Github Actions](https://github.com/commitizen-tools/commitizen/workflows/Python%20package/badge.svg?style=flat-square)](https://github.com/commitizen-tools/commitizen/actions) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) +[![PyPI Package latest release](https://img.shields.io/pypi/v/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/) +[![PyPI Package download count (per month)](https://img.shields.io/pypi/dm/commitizen?style=flat-square)](https://pypi.org/project/commitizen/) +[![Supported versions](https://img.shields.io/pypi/pyversions/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/) +[![homebrew](https://img.shields.io/homebrew/v/commitizen?color=teal&style=flat-square)](https://formulae.brew.sh/formula/commitizen) +[![Codecov](https://img.shields.io/codecov/c/github/commitizen-tools/commitizen.svg?style=flat-square)](https://codecov.io/gh/commitizen-tools/commitizen) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=flat-square&logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) ![Using commitizen cli](images/demo.gif) +--- + +**Documentation:** [https://commitizen-tools.github.io/commitizen/](https://commitizen-tools.github.io/commitizen/) + +--- + ## About Commitizen is a tool designed for teams. @@ -27,8 +33,8 @@ the version or a changelog. - Command-line utility to create commits with your rules. Defaults: [Conventional commits][conventional_commits] - Display information about your commit rules (commands: schema, example, info) -- Bump version automatically using [semantic verisoning][semver] based on the commits. [Read More](./bump.md) -- Generate a changelog using [Keep a changelog][keepchangelog] (Planned feature) +- Bump version automatically using [semantic versioning][semver] based on the commits. [Read More](./bump.md) +- Generate a changelog using [Keep a changelog][keepchangelog] ## Requirements @@ -56,9 +62,17 @@ pip install -U commitizen poetry add commitizen --dev ``` +### macOS + +On macOS, it can also be installed via [homebrew](https://formulae.brew.sh/formula/commitizen): + +```bash +brew install commitizen +``` + ## Usage -### Commiting +### Committing Run in your terminal @@ -72,47 +86,106 @@ or the shortcut cz c ``` +#### Sign off the commit + +Run in the terminal + +```bash +cz commit --signoff +``` + +or the shortcut + +```bash +cz commit -s +``` + +### Integrating with Pre-commit +Commitizen can lint your commit message for you with `cz check` and `cz commit`. +You can integrate this in your [pre-commit](https://pre-commit.com/) config with: + +```yaml +--- +repos: + - repo: https://github.com/commitizen-tools/commitizen + rev: master + hooks: + - id: commitizen + stages: [commit-msg] + - id: commitizen-prepare-commit-msg + stages: [prepare-commit-msg] +``` + +After the configuration is added, you'll need to run + +```sh +pre-commit install --hook-type commit-msg +``` + +Read more about the `check` command [here](check.md). + ### Help ```bash $ cz --help usage: cz [-h] [--debug] [-n NAME] [--version] - {ls,commit,c,example,info,schema,bump} ... + {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} + ... Commitizen is a cli tool to generate conventional commits. For more information about the topic go to https://conventionalcommits.org/ optional arguments: --h, --help show this help message and exit ---debug use debug mode --n NAME, --name NAME use the given commitizen ---version get the version of the installed commitizen + -h, --help show this help message and exit + --debug use debug mode + -n NAME, --name NAME use the given commitizen (default: + cz_conventional_commits) + --version get the version of the installed commitizen commands: -{ls,commit,c,example,info,schema,bump} - ls show available commitizens + {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} + init init commitizen configuration commit (c) create new commit + ls show available commitizens example show commit example info show information about the cz schema show commit schema bump bump semantic version based on the git log + changelog (ch) generate changelog (note that it will overwrite + existing file) + check validates that a commit message matches the commitizen + schema version get the version of the installed commitizen or the current project (default: installed commitizen) - check validates that a commit message matches the commitizen schema - init init commitizen configuration ``` -## Contributing +## Setting up bash completion + +When using bash as your shell (limited support for zsh, fish, and tcsh is available), Commitizen can use [argcomplete](https://kislyuk.github.io/argcomplete/) for auto-completion. For this argcomplete needs to be enabled. + +argcomplete is installed when you install Commitizen since it's a dependency. + +If Commitizen is installed globally, global activation can be executed: + +```bash +sudo activate-global-python-argcomplete +``` + +For permanent (but not global) Commitizen activation, use: + +```bash +register-python-argcomplete cz >> ~/.bashrc +``` + +For one-time activation of argcomplete for Commitizen only, use: -Feel free to create a PR. +```bash +eval "$(register-python-argcomplete cz)" +``` -1. Clone the repo. -2. Add your modifications -3. Create a virtualenv -4. Run `./scripts/test` +For further information on activation, please visit the [argcomplete website](https://kislyuk.github.io/argcomplete/). [conventional_commits]: https://www.conventionalcommits.org [semver]: https://semver.org/ [keepchangelog]: https://keepachangelog.com/ [gitscm]: https://git-scm.com/downloads -[travis]: https://img.shields.io/travis/Woile/commitizen.svg?style=flat-square diff --git a/docs/auto_check.md b/docs/auto_check.md new file mode 100644 index 0000000000..755f1af8b7 --- /dev/null +++ b/docs/auto_check.md @@ -0,0 +1,64 @@ +# Automatically check message before commit + +## About +To automatically check a commit message prior to committing, you can use a [git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks). + +## How to +There are two common methods for installing the hook: +### Method 1: Add git hook through [pre-commit](https://pre-commit.com/) + +* Step 1: Install [pre-commit](https://pre-commit.com/) + +```sh +python -m pip install pre-commit +``` + +* Step 2: Create `.pre-commit-config.yaml` at your root directory with the following content + +```yaml +--- +repos: + - repo: https://github.com/commitizen-tools/commitizen + rev: v1.17.0 + hooks: + - id: commitizen + stages: [commit-msg] +``` + +* Step 3: Install the configuration into git hook through `pre-commit` + +```bash +pre-commit install --hook-type commit-msg +``` + +### Method 2: Manually add git hook +The command might be included inside of a Git hook (inside of `.git/hooks/` at the root of the project). + +The selected hook might be the file called commit-msg. + +This example shows how to use the check command inside of commit-msg. + +At the root of the project: + +```bash +cd .git/hooks +touch commit-msg +chmod +x commit-msg +``` + +Open the file and edit it: + +```sh +#!/bin/bash +MSG_FILE=$1 +cz check --commit-msg-file $MSG_FILE +``` + +Where `$1` is the name of the temporary file that contains the current commit message. To be more explicit, the previous variable is stored in another variable called `$MSG_FILE`, for didactic purposes. + +The `--commit-msg-file` flag is required, not optional. + +Each time you create a commit, automatically, this hook will analyze it. +If the commit message is invalid, it'll be rejected. + +The commit should follow the given committing rules; otherwise, it won't be accepted. diff --git a/docs/bump.md b/docs/bump.md index bac589a3b2..38e4f5272b 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -54,26 +54,129 @@ Some examples: ```bash $ cz bump --help -usage: cz bump [-h] [--dry-run] [--files-only] [--yes] - [--tag-format TAG_FORMAT] [--bump-message BUMP_MESSAGE] +usage: cz bump [-h] [--dry-run] [--files-only] [--changelog] [--no-verify] [--local-version] + [--yes] [--tag-format TAG_FORMAT] [--bump-message BUMP_MESSAGE] [--prerelease {alpha,beta,rc}] - [--increment {MAJOR,MINOR,PATCH}] + [--increment {MAJOR,MINOR,PATCH}] [--check-consistency] [--annotated-tag] optional arguments: -h, --help show this help message and exit --dry-run show output to stdout, no commit, no modified files --files-only bump version in the files from the config + --changelog, -ch generate the changelog for the newest version + --no-verify this option bypasses the pre-commit and commit-msg hooks --yes accept automatically questions done + --local-version bump the local portion of the version --tag-format TAG_FORMAT - the format used to tag the commit and read it, use it in - existing projects, wrap around simple quotes + the format used to tag the commit and read it, use it in existing projects, wrap + around simple quotes --bump-message BUMP_MESSAGE - template used to create the release commmit, useful - when working with CI + template used to create the release commit, useful when working with CI --prerelease {alpha,beta,rc}, -pr {alpha,beta,rc} choose type of prerelease --increment {MAJOR,MINOR,PATCH} manually specify the desired increment + --check-consistency, -cc + check consistency among versions defined in commitizen configuration and + version_files + --annotated-tag, -at create annotated tag instead of lightweight one +``` + +### `--files-only` + +Bumps the version in the files defined in `version_files` without creating a commit and tag on the git repository, + +```bash +cz bump --files-only +``` + +### `--changelog` + +Generate a **changelog** along with the new version and tag when bumping. + +```bash +cz bump --changelog +``` + +### `--check-consistency` + +Check whether the versions defined in `version_files` and the version in commitizen +configuration are consistent before bumping version. + +```bash +cz bump --check-consistency +``` + +For example, if we have `pyproject.toml` + +```toml +[tool.commitizen] +version = "1.21.0" +version_files = [ + "src/__version__.py", + "setup.py", +] +``` + +`src/__version__.py`, + +```python +__version__ = "1.21.0" +``` + +and `setup.py`. + +```python +... + version="1.0.5" +... +``` + +If `--check-consistency` is used, commitizen will check whether the current version in `pyproject.toml` +exists in all version_files and find out it does not exist in `setup.py` and fails. +However, it will still update `pyproject.toml` and `src/__version__.py`. + +To fix it, you'll first `git checkout .` to reset to the status before trying to bump and update +the version in `setup.py` to `1.21.0` + +### `--local-version` + +Bump the local portion of the version. + +```bash +cz bump --local-version +``` + +For example, if we have `pyproject.toml` + +```toml +[tool.commitizen] +version = "5.3.5+0.1.0" +``` + +If `--local-version` is used, it will bump only the local version `0.1.0` and keep the public version `5.3.5` intact, bumping to the version `5.3.5+0.2.0`. + +### `--annotated-tag` + +If `--annotated-tag` is used, commitizen will create annotated tags. Also available via configuration, in `pyproject.toml` or `.cz.toml`. + +### `--changelog-to-stdout` + +If `--changelog-to-stdout` is used, the incremental changelog generated by the bump +will be sent to the stdout, and any other message generated by the bump will be +sent to stderr. + +If `--changelog` is not used with this command, it is still smart enough to +understand that the user wants to create a changelog. It is recommened to be +explicit and use `--changelog` (or the setting `update_changelog_on_bump`). + +This command is useful to "transport" the newly created changelog. +It can be sent to an auditing system, or to create a Github Release. + +Example: + +```bash +cz bump --changelog --changelog-to-stdout > body.md ``` ## Configuration @@ -85,30 +188,23 @@ It is used to read the format from the git tags, and also to generate the tags. Commitizen supports 2 types of formats, a simple and a more complex. ```bash -cz bump --tag_format="v$version" +cz bump --tag-format="v$version" ``` ```bash -cz bump --tag_format="v$minor.$major.$patch$prerelease" +cz bump --tag-format="v$minor.$major.$patch$prerelease" ``` In your `pyproject.toml` or `.cz.toml` ```toml [tool.commitizen] -tag_format = "v$minor.$major.$patch$prerelease" -``` - -Or in your `.cz` (TO BE DEPRECATED) - -```ini -[commitizen] -tag_format = v$minor.$major.$patch$prerelease +tag_format = "v$major.$minor.$patch$prerelease" ``` The variables must be preceded by a `$` sign. -Suppported variables: +Supported variables: | Variable | Description | | ------------- | ------------------------------------------ | @@ -120,7 +216,7 @@ Suppported variables: --- -### `version_files` * +### `version_files` \* It is used to identify the files which should be updated with the new version. It is also possible to provide a pattern for each file, separated by colons (`:`). @@ -142,16 +238,6 @@ version_files = [ ] ``` -`.cz` (TO BE DEPRECATED) - -```ini -[commitizen] -version_files = [ - "src/__version__.py", - "setup.py:version" - ] -``` - In the example above, we can see the reference `"setup.py:version"`. This means that it will find a file `setup.py` and will only make a change in a line containing the `version` substring. @@ -178,11 +264,28 @@ Some examples bump_message = "release $current_version โ†’ $new_version [skip-ci]" ``` -`.cz` (TO BE DEPRECATED) +--- + +### `update_changelog_on_bump` + +When set to `true` the changelog is always updated incrementally when running `cz bump`, so the user does not have to provide the `--changelog` flag every time. + +defaults to: `false` + +```toml +[tool.commitizen] +update_changelog_on_bump = true +``` + +--- -```ini -[commitizen] -bump_message = release $current_version โ†’ $new_version [skip-ci] +### `annotated_tag` + +When set to `true` commitizen will create annotated tags. + +```toml +[tool.commitizen] +annotated_tag = true ``` ## Custom bump diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000000..f5dd5fd5e3 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,162 @@ +## About + +This command will generate a changelog following the committing rules established. + +## Usage + +```bash +$ cz changelog --help +usage: cz changelog [-h] [--dry-run] [--file-name FILE_NAME] + [--unreleased-version UNRELEASED_VERSION] [--incremental] + [--start-rev START_REV] + +optional arguments: + -h, --help show this help message and exit + --dry-run show changelog to stdout + --file-name FILE_NAME + file name of changelog (default: 'CHANGELOG.md') + --unreleased-version UNRELEASED_VERSION + set the value for the new version (use the tag value), + instead of using unreleased + --incremental generates changelog from last created version, useful + if the changelog has been manually modified + --start-rev START_REV + start rev of the changelog.If not set, it will + generate changelog from the start +``` + +### Examples + +```bash +cz changelog +``` + +```bash +cz ch +``` + +## Constrains + +changelog generation is constrained only to **markdown** files. + +## Description + +These are the variables used by the changelog generator. + +```md +# <version> (<date>) + +## <change_type> + +- **<scope>**: <message> +``` + +It will create a full block like above per version found in the tags. +And it will create a list of the commits found. +The `change_type` and the `scope` are optional, they don't need to be provided, +but if your regex does they will be rendered. + +The format followed by the changelog is the one from [keep a changelog][keepachangelog] +and the following variables are expected: + +| Variable | Description | Source | +| ------------- | ---------------------------------------------------------------------------------------------- | -------------- | +| `version` | Version number which should follow [semver][semver] | `tags` | +| `date` | Date in which the tag was created | `tags` | +| `change_type` | The group where the commit belongs to, this is optional. Example: fix | `commit regex` | +| `message`\* | Information extracted from the commit message | `commit regex` | +| `scope` | Contextual information. Should be parsed using the regex from the message, it will be **bold** | `commit regex` | +| `breaking` | Whether is a breaking change or not | `commit regex` | + +- **required**: is the only one required to be parsed by the regex + +## Configuration + +### `unreleased_version` + +There is usually an egg and chicken situation when automatically +bumping the version and creating the changelog. +If you bump the version first, you have no changelog, you have to +create it later, and it won't be included in +the release of the created version. + +If you create the changelog before bumping the version, then you +usually don't have the latest tag, and the _Unreleased_ title appears. + +By introducing `unreleased_version` you can prevent this situation. + +Before bumping you can run: + +```bash +cz changelog --unreleased-version="v1.0.0" +``` + +Remember to use the tag instead of the raw version number + +For example if the format of your tag includes a `v` (`v1.0.0`), then you should use that, +if your tag is the same as the raw version, then ignore this. + +Alternatively you can directly bump the version and create the changelog by doing + +```bash +cz bump --changelog +``` + +### `file-name` + +This value can be updated in the `toml` file with the key `changelog_file` under `tools.commitizen` + +Specify the name of the output file, remember that changelog only works with markdown. + +```bash +cz changelog --file-name="CHANGES.md" +``` + +### `incremental` + +This flag can be set in the `toml` file with the key `changelog_incremental` under `tools.commitizen` + +Benefits: + +- Build from latest version found in changelog, this is useful if you have a different changelog and want to use commitizen +- Update unreleased area +- Allows users to manually touch the changelog without being rewritten. + +```bash +cz changelog --incremental +``` + +```toml +[tools.commitizen] +# ... +changelog_incremental = true +``` + +### `start-rev` + +This value can be set in the `toml` file with the key `changelog_start_rev` under `tools.commitizen` + +Start from a given git rev to generate the changelog. Commits before that rev will not be considered. This is especially useful for long-running projects adopting conventional commits, where old commit messages might fail to be parsed for changelog generation. + +```bash +cz changelog --start-rev="v0.2.0" +``` + +```toml +[tools.commitizen] +# ... +changelog_start_rev = "v0.2.0" +``` + +## Hooks + +Supported hook methods: + +- per parsed message: useful to add links +- end of changelog generation: useful to send slack or chat message, or notify another department + +Read more about hooks in the [customization page][customization] + +[keepachangelog]: https://keepachangelog.com/ +[semver]: https://semver.org/ +[customization]: ./customization.md diff --git a/docs/check.md b/docs/check.md index e43878700f..d1e1b72056 100644 --- a/docs/check.md +++ b/docs/check.md @@ -1,62 +1,47 @@ -## About +# Check +## About This feature checks whether the commit message follows the given committing rules. -You can use either of the following methods to enforce the check. +If you want to setup an automatic check before every git commit, please refer to +[Automatically check message before commit](auto_check.md). -### Method 1: Add git hook through [pre-commit](https://pre-commit.com/) +## Usage +There are three arguments that you can use one of them to check commit message. -* Step 1: Install [pre-commit](https://pre-commit.com/) +### Git Rev Range +If you'd like to check a commit's message after it has already been created, then you can specify the range of commits to check with `--rev-range REV_RANGE`. -```sh -python -m pip install pre-commit +```bash +$ cz check --rev-range REV_RANGE ``` -* Step 2: Create `.pre-commit-config.yaml` at your root directory with the following content +For example, if you'd like to check all commits on a branch, you can use `--rev-range master..HEAD`. Or, if you'd like to check all commits starting from when you first implemented commit message linting, you can use `--rev-range <first_commit_sha>..HEAD`. -```yaml ---- -repos: - - repo: https://github.com/Woile/commitizen - rev: v1.16.2 - hooks: - - id: commitizen - stages: [commit-msg] -``` +For more info on how git commit ranges work, you can check the [git documentation](https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection#_commit_ranges). -* Step 3: Install the configuration into git hook through `pre-commit` +### Commit Message +There are two ways you can provide your plain message and check it. +#### Method 1: use -m or --message ```bash -pre-commit install --hook-type commit-msg +$ cz check --message MESSAGE ``` -### Method 2: Manually add git hook -The command might be included inside of a Git hook (inside of `.git/hooks/` at the root of the project). - -The selected hook might be the file called commit-msg. - -This example shows how to use the check command inside of commit-msg. +In this option, MESSAGE is the commit message to be checked. -At the root of the project: +#### Method 2: use pipe to pipe it to `cz check` ```bash -cd .git/hooks -touch commit-msg -chmod +x commit-msg +$ echo MESSAGE | cz check ``` -Open the file and edit it: +In this option, MESSAGE is piped to cz check and would be checked. -```sh -#!/bin/bash -MSG_FILE=$1 -cz check --commit-msg-file $MSG_FILE -``` - -Where `$1` is the name of the temporary file that contains the current commit message. To be more explicit, the previous variable is stored in another variable called `$MSG_FILE`, for didactic purposes. - -The `--commit-msg-file` flag is required, not optional. +### Commit Message File -Each time you create a commit, automatically, this hook will analyze it. -If the commit message is invalid, it'll be rejected. +```bash +$ cz check --commit-msg-file COMMIT_MSG_FILE +``` -The commit should follow the given commiting rules; otherwise, it won't be accepted. +In this option, COMMIT_MSG_FILE is the path of the temporal file that contains the commit message. +This argument can be useful when cooperating with git hook, please check [Automatically check message before commit](auto_check.md) for more information about how to use this argument with git hook. diff --git a/docs/config.md b/docs/config.md index 88f4850d45..342219fef2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,7 +1,5 @@ # Configuration -Commitizen has support for `toml` and `ini` files. It first looks up the configuration file in the current working directory and then the root directory of the git project. - ## pyproject.toml or .cz.toml Add an entry to `pyproject.toml` or `.cz.toml`. Recommended for **python** projects. @@ -28,47 +26,117 @@ style = [ ] ``` -## INI files - -**INI files will not be supported in the next major version. Please use toml instead** +`.cz.toml` is recommended for **other languages** projects (js, go, etc). -Supported files: `.cz`, `.cz.cfg`, `setup.cfg`, and `$HOME/.cz` +## .cz.json or cz.json -The format is slightly different to the `toml`, so pay attention. -Recommended for **other languages** projects (js, go, etc). +JSON might be a more common configuration format for non-python projects, so Commitizen supports JSON config files, now. -```ini -[commitizen] -name = cz_conventional_commits -version = 0.1.0 -version_files = [ - "src/__version__.py", - "pyproject.toml:version" - ] -style = [ - ["qmark", "fg:#ff9d00 bold"], - ["question", "bold"], - ["answer", "fg:#ff9d00 bold"], - ["pointer", "fg:#ff9d00 bold"], - ["highlighted", "fg:#ff9d00 bold"], - ["selected", "fg:#cc5454"], - ["separator", "fg:#cc5454"], - ["instruction", ""], - ["text", ""], - ["disabled", "fg:#858585 italic"] - ] +```json +{ + "commitizen": { + "name": "cz_conventional_commits", + "version": "0.1.0", + "version_files": [ + "src/__version__.py", + "pyproject.toml:version" + ], + "style": [ + [ + "qmark", + "fg:#ff9d00 bold" + ], + [ + "question", + "bold" + ], + [ + "answer", + "fg:#ff9d00 bold" + ], + [ + "pointer", + "fg:#ff9d00 bold" + ], + [ + "highlighted", + "fg:#ff9d00 bold" + ], + [ + "selected", + "fg:#cc5454" + ], + [ + "separator", + "fg:#cc5454" + ], + [ + "instruction", + "" + ], + [ + "text", + "" + ], + [ + "disabled", + "fg:#858585 italic" + ] + ] + } +} ``` -The extra tab before the square brackets (`]`) at the end is required. +## .cz.yaml or cz.yaml +YAML is another format for **non-python** proyects as well, supported by Commitizen: + +```yaml +commitizen: + name: cz_conventional_commits + version: 0.1.0 + version_files: + - src/__version__.py + - pyproject.toml:version + style: + - - qmark + - fg:#ff9d00 bold + - - question + - bold + - - answer + - fg:#ff9d00 bold + - - pointer + - fg:#ff9d00 bold + - - highlighted + - fg:#ff9d00 bold + - - selected + - fg:#cc5454 + - - separator + - fg:#cc5454 + - - instruction + - '' + - - text + - '' + - - disabled + - fg:#858585 italic +``` ## Settings -| Variable | Type | Default | Description | -| -------- | ---- | ------- | ----------- | -| `name` | `str` | `"cz_conventional_commits"` | Name of the commiting rules to use | -| `version` | `str` | `None` | Current version. Example: "0.1.2" | -| `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more](https://woile.github.io/commitizen/bump#files) | -| `tag_format` | `str` | `None` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more](https://woile.github.io/commitizen/bump#tag_format) | -| `bump_message` | `str` | `None` | Create custom commit message, useful to skip ci. [See more](https://woile.github.io/commitizen/bump#bump_message) | -| `style` | `list` | see above | Style for the prompts (It will merge this value with default style.) [See More (Styling your prompts with your favorite colors)](https://github.com/tmbo/questionary#additional-features) | -| `customize` | `dict` | `None` | **This is only supported when config through `toml`.** Custom rules for committing and bumping. [See more](https://woile.github.io/commitizen/customization/) | +| Variable | Type | Default | Description | +| ---------------- | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `str` | `"cz_conventional_commits"` | Name of the committing rules to use | +| `version` | `str` | `None` | Current version. Example: "0.1.2" | +| `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more][version_files] | +| `tag_format` | `str` | `None` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more][tag_format] | +| `bump_message` | `str` | `None` | Create custom commit message, useful to skip ci. [See more][bump_message] | +| `changelog_file` | `str` | `CHANGELOG.md` | filename of exported changelog | +| `style` | `list` | see above | Style for the prompts (It will merge this value with default style.) [See More (Styling your prompts with your favorite colors)][additional-features] | +| `customize` | `dict` | `None` | **This is only supported when config through `toml`.** Custom rules for committing and bumping. [See more][customization] | +| `use_shortcuts` | `bool` | `false` | If enabled, commitizen will show keyboard shortcuts when selecting from a list. Define a `key` for each of your choices to set the key. [See more][shortcuts] | + +[version_files]: bump.md#version_files +[tag_format]: bump.md#tag_format +[bump_message]: bump.md#bump_message +[additional-features]: https://github.com/tmbo/questionary#additional-features +[customization]: customization.md +[shortcuts]: customization.md#shortcut-keys diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000000..0dcd5c5bff --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,24 @@ +## Contributing to commitizen + +First of all, thank you for taking the time to contribute! ๐ŸŽ‰ + +When contributing to [commitizen](https://github.com/commitizen-tools/commitizen), please first create an [issue](https://github.com/commitizen-tools/commitizen/issues) to discuss the change you wish to make before making a change. + +If you're a first-time contributor, you can check the issues with [good first issue](https://github.com/commitizen-tools/commitizen/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. + +## Before making a pull request + +1. Fork [the repository](https://github.com/commitizen-tools/commitizen). +2. Clone the repository from your GitHub. +3. Setup development environment through [poetry](https://python-poetry.org/) (`poetry install`). +4. Setup [pre-commit](https://pre-commit.com/) hook (`pre-commit install -t pre-commit -t pre-push -t commit-msg`) +5. Check out a new branch and add your modification. +6. Add test cases for all your changes. + (We use [CodeCov](https://codecov.io/) to ensure our test coverage does not drop.) +7. Use [commitizen](https://github.com/commitizen-tools/commitizen) to do git commit. We follow [conventional commmits][conventional-commmits] +8. Run `./scripts/format` and `./scripts/test` to ensure you follow the coding style and the tests pass. +9. Update `README.md`. Do **not** update the `CHANGELOG.md`, it will be automatically created after merging to `master`. +10. If your changes are about documentation. Run `poetry run mkdocs serve` to serve documentation locally and check whether there is any warning or error. +11. Send a [pull request](https://github.com/commitizen-tools/commitizen/pulls) ๐Ÿ™ + +[conventional-commmits]: https://www.conventionalcommits.org/ diff --git a/docs/customization.md b/docs/customization.md index 09c996d88c..74ac0707b6 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,12 +1,183 @@ Customizing commitizen is not hard at all. +We have two different ways to do so. -## Customize through customizing a class +## 1. Customize in configuration file + +**This is only supported when configuring through `toml` or `json` (e.g., `pyproject.toml`, `.cz.toml`, `.cz.json`, and `cz.json`)** + +The basic steps are: + +1. Define your custom committing or bumping rules in the configuration file. +2. Declare `name = "cz_customize"` in your configuration file, or add `-n cz_customize` when running commitizen. + +Example: + +```toml +[tool.commitizen] +name = "cz_customize" + +[tool.commitizen.customize] +message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" +example = "feature: this feature enable customize through config file" +schema = "<type>: <body>" +schema_pattern = "(feature|bug fix):(\\s.*)" +bump_pattern = "^(break|new|fix|hotfix)" +bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} +change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] +info_path = "cz_customize_info.txt" +info = """ +This is customized info +""" + +[[tool.commitizen.customize.questions]] +type = "list" +name = "change_type" +choices = [{value = "feature", name = "feature: A new feature."}, {value = "bug fix", name = "bug fix: A bug fix."}] +# choices = ["feature", "fix"] # short version +message = "Select the type of change you are committing" + +[[tool.commitizen.customize.questions]] +type = "input" +name = "message" +message = "Body." + +[[tool.commitizen.customize.questions]] +type = "confirm" +name = "show_message" +message = "Do you want to add body message in commit?" +``` + +The equivalent example for a json config file: + +```json +{ + "commitizen": { + "name": "cz_customize", + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enable customize through config file", + "schema": "<type>: <body>", + "schema_pattern": "(feature|bug fix):(\\s.*)", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "change_type_order": ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"], + "info_path": "cz_customize_info.txt", + "info": "This is customized info", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "feature", + "name": "feature: A new feature." + }, + { + "value": "bug fix", + "name": "bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } +} +``` + +And the correspondent example for a yaml json file: + +```yaml +commitizen: + name: cz_customize + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enable customize through config file' + schema: "<type>: <body>" + schema_pattern: "(feature|bug fix):(\\s.*)" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + change_type_order: ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] + info_path: cz_customize_info.txt + info: This is customized info + questions: + - type: list + name: change_type + choices: + - value: feature + name: 'feature: A new feature.' + - value: bug fix + name: 'bug fix: A bug fix.' + message: Select the type of change you are committing + - type: input + name: message + message: Body. + - type: confirm + name: show_message + message: Do you want to add body message in commit? +``` + +### Customize configuration + +| Parameter | Type | Default | Description | +| ------------------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `questions` | `dict` | `None` | Questions regarding the commit message. Detailed below. | +| `message_template` | `str` | `None` | The template for generating message from the given answers. `message_template` should either follow [Jinja2][jinja2] formatting specification, and all the variables in this template should be defined in `name` in `questions` | +| `example` | `str` | `None` | (OPTIONAL) Provide an example to help understand the style. Used by `cz example`. | +| `schema` | `str` | `None` | (OPTIONAL) Show the schema used. Used by `cz schema`. | +| `schema_pattern` | `str` | `None` | (OPTIONAL) The regular expression used to do commit message validation. Used by `cz check`. | +| `info_path` | `str` | `None` | (OPTIONAL) The path to the file that contains explanation of the commit rules. Used by `cz info`. If not provided `cz info`, will load `info` instead. | +| `info` | `str` | `None` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. | +| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) | +| `change_type_order` | `str` | `None` | (OPTIONAL) List of strings used to order the Changelog. All other types will be sorted alphabetically. Default is `["BREAKING CHANGE", "feat", "fix", "refactor", "perf"]` | + +[jinja2]: https://jinja.palletsprojects.com/en/2.10.x/ +#### Detailed `questions` content + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `str` | `None` | The type of questions. Valid type: `list`, `input` and etc. [See More][different-question-types] | +| `name` | `str` | `None` | The key for the value answered by user. It's used in `message_template` | +| `message` | `str` | `None` | Detail description for the question. | +| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = list`. Either use a list of values or a list of dictionaries with `name` and `value` keys. Keyboard shortcuts can be defined via `key`. See examples above. | +| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. | +| `filter` | `str` | `None` | (Optional) Validator for user's answer. **(Work in Progress)** | + +[different-question-types]: https://github.com/tmbo/questionary#different-question-types + +#### Shortcut keys + +When the [`use_shortcuts`](config.md#settings) config option is enabled, commitizen can show and use keyboard shortcuts to select items from lists directly. +For example, when using the `cz_conventional_commits` commitizen template, shortcut keys are shown when selecting the commit type. Unless otherwise defined, keyboard shortcuts will be numbered automatically. +To specify keyboard shortcuts for your custom choices, provide the shortcut using the `key` parameter in dictionary form for each choice you would like to customize. + +## 2. Customize through customizing a class The basic steps are: 1. Inheriting from `BaseCommitizen` 2. Give a name to your rules. -3. expose the class at the end of your file assigning it to `discover_this` +3. Expose the class at the end of your file assigning it to `discover_this` 4. Create a python package starting with `cz_` using `setup.py`, `poetry`, etc Check an [example](convcomms) on how to configure `BaseCommitizen`. @@ -14,14 +185,14 @@ Check an [example](convcomms) on how to configure `BaseCommitizen`. You can also automate the steps above through [cookiecutter](https://cookiecutter.readthedocs.io/en/1.7.0/). ```sh -cookiecutter gh:Lee-W/commitizen_cz_template +cookiecutter gh:commitizen-tools/commitizen_cz_template ``` -See [Lee-W/commitizen_cz_template](https://github.com/Lee-W/commitizen_cz_template) for detail. +See [commitizen_cz_template](https://github.com/commitizen-tools/commitizen_cz_template) for detail. ### Custom commit rules -Create a file starting with `cz_`, for example `cz_jira.py`. This prefix is used to detect the plugin. Same method [flask uses] +Create a file starting with `cz_`, for example `cz_jira.py`. This prefix is used to detect the plug-in. Same method [flask uses] Inherit from `BaseCommitizen`, and you must define `questions` and `message`. The others are optional. @@ -73,7 +244,7 @@ class JiraCz(BaseCommitizen): return 'We use this because is useful' -discover_this = JiraCz # used by the plugin system +discover_this = JiraCz # used by the plug-in system ``` The next file required is `setup.py` modified from flask version. @@ -106,12 +277,12 @@ If you feel like it should be part of this repo, create a PR. ### Custom bump rules -You need to define 2 parameters inside `BaseCommitizen`. +You need to define 2 parameters inside your custom `BaseCommitizen`. -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) | -| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| Parameter | Type | Default | Description | +| -------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------- | +| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) | +| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | Let's see an example. @@ -130,81 +301,69 @@ That's it, your commitizen now supports custom rules, and you can run. cz -n cz_strange bump ``` -[convcomms]: https://github.com/Woile/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py +[convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py -### Raise Customize Exception +### Custom changelog generator -If you want `commitizen` to catch your exception and print the message, you'll have to inherit `CzException`. +The changelog generator should just work in a very basic manner without touching anything. +You can customize it of course, and this are the variables you need to add to your custom `BaseCommitizen`. + +| Parameter | Type | Required | Description | +| -------------------------------- | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `commit_parser` | `str` | NO | Regex which should provide the variables explained in the [changelog description][changelog-des] | +| `changelog_pattern` | `str` | NO | Regex to validate the commits, this is useful to skip commits that don't meet your ruling standards like a Merge. Usually the same as bump_pattern | +| `change_type_map` | `dict` | NO | Convert the title of the change type that will appear in the changelog, if a value is not found, the original will be provided | +| `changelog_message_builder_hook` | `method: (dict, git.GitCommit) -> dict` | NO | Customize with extra information your message output, like adding links, this function is executed per parsed commit. Each GitCommit contains the following attrs: `rev`, `title`, `body`, `author`, `author_email` | +| `changelog_hook` | `method: (full_changelog: str, partial_changelog: Optional[str]) -> str` | NO | Receives the whole and partial (if used incremental) changelog. Useful to send slack messages or notify a compliance department. Must return the full_changelog | ```python -from commitizen.cz.exception import CzException +from commitizen.cz.base import BaseCommitizen +import chat +import compliance -class NoSubjectProvidedException(CzException): - ... +class StrangeCommitizen(BaseCommitizen): + changelog_pattern = r"^(break|new|fix|hotfix)" + commit_parser = r"^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?:\s(?P<message>.*)?" + change_type_map = { + "feat": "Features", + "fix": "Bug Fixes", + "refactor": "Code Refactor", + "perf": "Performance improvements" + } + + def changelog_message_builder_hook(self, parsed_message: dict, commit: git.GitCommit) -> dict: + rev = commit.rev + m = parsed_message["message"] + parsed_message["message"] = f"{m} {rev} [{commit.author}]({commit.author_email})" + return parsed_message + + def changelog_hook(self, full_changelog: str, partial_changelog: Optional[str]) -> str: + """Executed at the end of the changelog generation + + full_changelog: it's the output about to being written into the file + partial_changelog: it's the new stuff, this is useful to send slack messages or + similar + + Return: + the new updated full_changelog + """ + if partial_changelog: + chat.room("#committers").notify(partial_changelog) + if full_changelog: + compliance.send(full_changelog) + full_changelog.replace(' fix ', ' **fix** ') + return full_changelog ``` -## Customize in toml - -**This is only supported when configuring through `toml` (e.g., `pyproject.toml`, `.cz`, and `.cz.toml`)** - -The basic steps are: -1. Define your custom committing or bumping rules in the configuration file. -2. Declare `name = "cz_customize"` in your configuration file, or add `-n cz_customize` when running commitizen. - -Example: - -```toml -[tool.commitizen] -name = "cz_customize" +[changelog-des]: ./changelog.md#description -[tool.commitizen.customize] -message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" -example = "feature: this feature eanable customize through config file" -schema = "<type>: <body>" -bump_pattern = "^(break|new|fix|hotfix)" -bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} -info_path = "cz_customize_info.txt" -info = """ -This is customized info -""" +### Raise Customize Exception -[[tool.commitizen.customize.questions]] -type = "list" -name = "change_type" -choices = ["feature", "bug fix"] -message = "Select the type of change you are committing" +If you want `commitizen` to catch your exception and print the message, you'll have to inherit `CzException`. -[[tool.commitizen.customize.questions]] -type = "input" -name = "message" -message = "Body." +```python +from commitizen.cz.exception import CzException -[[tool.commitizen.customize.questions]] -type = "confirm" -name = "show_message" -message = "Do you want to add body message in commit?" +class NoSubjectProvidedException(CzException): + ... ``` - -### Customize configuration - -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `question` | `dict` | `None` | Questions regarding the commit message. Detatiled below. | -| `message_template` | `str` | `None` | The template for generating message from the given answers. `message_template` should either follow the [string.Template](https://docs.python.org/3/library/string.html#template-strings) or [Jinja2](https://jinja.palletsprojects.com/en/2.10.x/) formatting specification, and all the variables in this template should be defined in `name` in `questions`. Note that `Jinja2` is not installed by default. If not installed, commitizen will use `string.Template` formatting. | -| `example` | `str` | `None` | (OPTIONAL) Provide an example to help understand the style. Used by `cz example`. | -| `schema` | `str` | `None` | (OPTIONAL) Show the schema used. Used by `cz schema`. | -| `info_path` | `str` | `None` | (OPTIONAL) The path to the file that contains explanation of the commit rules. Used by `cz info`. If not provided `cz info`, will load `info` instead. | -| `info` | `str` | `None` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. | -| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | -| `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) | - -#### Detailed `question` content - -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `type` | `str` | `None` | The type of questions. Valid type: `list`, `input` and etc. [See More](https://github.com/tmbo/questionary#different-question-types) | -| `name` | `str` | `None` | The key for the value answered by user. It's used in `message_template` | -| `message` | `str` | `None` | Detail description for the question. | -| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = choice`. It should be list of dictionaries with `name` and `value`. (e.g., `[{value = "feature", name = "feature: A new feature."}, {value = "bug fix", name = "bug fix: A bug fix."}]`) | -| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. | -| `filter` | `str` | `None` | (Optional) Validator for user's answer. **(Work in Progress)** | diff --git a/docs/exit_codes.md b/docs/exit_codes.md new file mode 100644 index 0000000000..cae66b8bab --- /dev/null +++ b/docs/exit_codes.md @@ -0,0 +1,29 @@ +# Exit Codes + +Commitizen handles expected exceptions through `CommitizenException` and returns different exit codes for different situations. They could be useful if you want to ignore specific errors in your pipeline. + +These exit codes can be found in `commitizen/exceptions.py::ExitCode`. + +| Exception | Exit Code | Description | +| --------- | --------- | ----------- | +| ExpectedExit | 0 | Expected exit | +| DryRunExit | 0 | Exit due to passing `--dry-run` option | +| NoCommitizenFoundException | 1 | Using a cz (e.g., `cz_jira`) that cannot be found in your system | +| NotAGitProjectError | 2 | Not in a git project | +| NoCommitsFoundError | 4 | No commit found | +| NoVersionSpecifiedError | 4 | Version can not be found in configuration file | +| NoPatternMapError | 5 | bump / changelog pattern or map can not be found in configuration file | +| BumpCommitFailedError | 6 | Commit error when bumping version | +| BumpTagFailedError | 7 | Tag error when bumping version | +| NoAnswersError | 8 | No user response given | +| CommitError | 9 | git commit error | +| NoCommitBackupError | 10 | Commit back up file cannot be found | +| NothingToCommitError | 11 | Nothing in staging to be committed | +| CustomError | 12 | `CzException` raised | +| NoCommandFoundError | 13 | No command found when running commitizen cli (e.g., `cz --debug`) | +| InvalidCommitMessageError | 14 | The commit message does not pass `cz check` | +| MissingConfigError | 15 | Configuration missed for `cz_customize` | +| NoRevisionError | 16 | No revision found | +| CurrentVersionNotFoundError | 17 | current version cannot be found in *version_files* | +| InvalidCommandArgumentError | 18 | The argument provide to command is invalid (e.g. `cz check -commit-msg-file filename --rev-range master..`) | +| InvalidConfigurationError | 19 | An error was found in the Commitizen Configuration, such as duplicates in `change_type_order` | diff --git a/docs/external_links.md b/docs/external_links.md new file mode 100644 index 0000000000..69ad952c66 --- /dev/null +++ b/docs/external_links.md @@ -0,0 +1,16 @@ +> If you have written over commitizen, make a PR and add the link here ๐Ÿ’ช + +## Talks + +| Name | Speaker | Occasion | Language | Extra | +| ------------------------------------------------------------------------- | --------------- | ---------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| commitizen-tools: What can we gain from crafting a git message convention | Wei Lee | Taipey.py 2020 June Meetup, Remote Python Pizza 2020 | English | [slides](https://speakerdeck.com/leew/commitizen-tools-what-can-we-gain-from-crafting-a-git-message-convention-at-taipey-dot-py) | +| Automating release cycles | Santiago Fraire | PyAmsterdam June 24, 2020, Online | English | [slides](https://woile.github.io/commitizen-presentation/) | +| [Automatizando Releases con Commitizen y Github Actions][automatizando] | Santiago Fraire | PyConAr 2020, Remote | Espaรฑol | [slides](https://woile.github.io/automating-releases-github-actions-presentation/#/) | + +## Articles + +- [Python Table Manners - Commitizen: ่ฆๆ ผๅŒ– commit message](https://lee-w.github.io/posts/tech/2020/03/python-table-manners-commitizen/) (Written in Traditional Mandarin) +- [Automating semantic release with commitizen](https://woile.dev/posts/automating-semver-releases-with-commitizen/) (English) + +[automatizando]: https://youtu.be/t3aE2M8UPBo diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000000..7e1450e33c --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,88 @@ +## Support for PEP621 + +PEP621 establishes a `[project]` definition inside `pyproject.toml` + +```toml +[project] +name = "spam" +version = "2020.0.0" +``` + +Commitizen **won't** use the `project.version` as a source of truth because it's a +tool aimed for any kind of project. + +If we were to use it, it would increase the complexity of the tool. Also why +wouldn't we support other project files like `cargo.toml` or `package.json`? + +Instead of supporting all the different project files, you can use `version_files` +inside `[tool.commitizen]`, and it will cheaply keep any of these project files in sync + +```toml +[tool.commitizen] +version = "2.5.1" +version_files = [ + "pyproject.toml:^version", + "cargo.toml:^version", + "package.json:\"version\":" +] +``` + +## Why are `revert` and `chore` valid types in the check pattern of cz conventional_commits but not types we can select? + +`revert` and `chore` are added to the "pattern" in `cz check` in order to prevent backward errors, but officially they are not part of conventional commits, we are using the latest [types from Angular](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#type) (they used to but were removed). +However, you can create a customized `cz` with those extra types. (See [Customization](customization.md) + +See more discussion in issue [#142](https://github.com/commitizen-tools/commitizen/issues/142) and [#36](https://github.com/commitizen-tools/commitizen/issues/36) + +## How to revert a bump? + +If for any reason, the created tag and changelog were to be undone, this is the snippet: + +```sh +git tag --delete <created_tag> +git reset HEAD~ +git reset --hard HEAD +``` + +This will remove the last tag created, plus the commit containing the update to `.cz.toml` and the changelog generated for the version. + +In case the commit was pushed to the server you can remove it by running + +```sh +git push --delete origin <created_tag> +``` + +## Is this project affiliated with the Commitizen JS project? + +It is not affiliated. + +Both are used for similar purposes, parsing commits, generating changelog and version we presume. +This one is written in python to make integration easier for python projects and the other serves the JS packages. + +They differ a bit in design, not sure if cz-js does any of this, but these are some of the stuff you can do with this repo (python's commitizen): + +- create custom rules, version bumps and changelog generation, by default we use the popular conventional commits (I think cz-js allows this). +- single package, install one thing and it will work (cz-js is a monorepo, but you have to install different dependencies AFAIK) +- pre-commit integration +- works on any language project, as long as you create the `.cz.toml` file. + +Where do they cross paths? + +If you are using conventional commits in your git history, then you could swap one with the other in theory. + +Regarding the name, [cz-js][cz-js] came first, they used the word commitizen first. When this project was created originally, the creator read "be a good commitizen", and thought it was just a cool word that made sense, and this would be a package that helps you be a good "commit citizen". + +[cz-js]: https://github.com/commitizen/cz-cli + +## How to handle revert commits? + +```sh +git revert --no-commit <SHA> +git commit -m "revert: foo bar" +``` + +## I got `Exception [WinError 995] The I/O operation ...` error + +This error was caused by a Python bug on Windows. It's been fixed by [this PR](https://github.com/python/cpython/pull/22017), and according to Python's changelog, [3.8.6rc1](https://docs.python.org/3.8/whatsnew/changelog.html#python-3-8-6-release-candidate-1) and [3.9.0rc2](https://docs.python.org/3.9/whatsnew/changelog.html#python-3-9-0-release-candidate-2) should be the accurate versions first contain this fix. In conclusion, upgrade your Python version might solve this issue. + +More discussion can be found in issue [#318](https://github.com/commitizen-tools/commitizen/issues/318). diff --git a/docs/init.md b/docs/init.md index 38a3afd431..a8eb3afa9d 100644 --- a/docs/init.md +++ b/docs/init.md @@ -1,7 +1,7 @@ For new projects, it is possible to run `cz init`. This command will prompt the user for information about the project and will -configure the selected file type (`pyproject.toml`, `.cz.toml`, etc). +configure the selected file type (`pyproject.toml`, `.cz.toml`, etc.). This will help you quickly set up your project with `commitizen`. diff --git a/docs/third-party-commitizen.md b/docs/third-party-commitizen.md new file mode 100644 index 0000000000..ff6144e6b9 --- /dev/null +++ b/docs/third-party-commitizen.md @@ -0,0 +1,43 @@ +## Third-Party Commitizen Templates + +In addition to the native templates, some alternative commit format templates +are available as PyPI packages (installable with `pip`). + +### [Conventional JIRA](https://pypi.org/project/conventional-JIRA/) + +Just like *conventional commit* format, but the scope has been restricted to a +JIRA issue format, i.e. `project-issueNumber`. This standardises scopes in a +meaningful way. + +It can be installed with `pip install conventional-JIRA`. + +### [GitHub JIRA Conventional](https://pypi.org/project/cz-github-jira-conventional/) + +This plugin extends the commitizen tools by: +- requiring a JIRA issue id in the commit message +- creating links to GitHub commits in the CHANGELOG.md +- creating links to JIRA issues in the CHANGELOG.md + +It can be installed with `cz-github-jira-conventional`. + +For installation instructions (configuration and pre-commit) please visit https://github.com/apheris/cz-github-jira-conventional + +### [Commitizen emoji](https://pypi.org/project/commitizen-emoji/) + +Just like *conventional commit* format, but with emojis and optionally time spent and related tasks. + +It can be installed with `pip install commitizen-emoji`. + +Usage: `cz --name cz_commitizen_emoji commit`. + +### [Conventional Legacy (cz_legacy)][1] + +An extension of the *conventional commit* format to include user-specified +legacy change types in the `CHANGELOG` while preventing the legacy change types +from being used in new commit messages + +`cz_legacy` can be installed with `pip install cz_legacy` + +See the [README][1] for instructions on configuration + + [1]: https://pypi.org/project/cz_legacy diff --git a/docs/tutorials/github_actions.md b/docs/tutorials/github_actions.md index b151c9e77a..58726afcd2 100644 --- a/docs/tutorials/github_actions.md +++ b/docs/tutorials/github_actions.md @@ -4,11 +4,15 @@ To execute `cz bump` in your CI, and push the new commit and the new tag, back to your master branch, we have to: + 1. Create a personal access token. [Follow the instructions here](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line#creating-a-token). And copy the generated key 2. Create a secret called `PERSONAL_ACCESS_TOKEN`, with the copied key, by going to your -project repository and then `Settings > Secrets > Add new secret`. + project repository and then `Settings > Secrets > Add new secret`. 3. In your repository create a new file `.github/workflows/bumpversion.yml` -with the following content. + with the following content. + +!!! warning +If you use `GITHUB_TOKEN` instead of `PERSONAL_ACCESS_TOKEN`, the job won't trigger another workflow. It's like using `[skip ci]` in other CI's. ```yaml name: Bump version @@ -16,50 +20,65 @@ name: Bump version on: push: branches: - - master # another branch could be specified here + - master jobs: - build: - if: "!contains(github.event.head_commit.message, 'bump')" + bump-version: + if: "!startsWith(github.event.head_commit.message, 'bump:')" runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.x'] + name: "Bump version and create changelog with commitizen" steps: - - uses: actions/checkout@v2 - with: - token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}' - fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python --version - python -m pip install -U commitizen - - name: Create bump - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - cz bump --yes - - name: Push changes - uses: Woile/github-push-action@master - with: - github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - tags: "true" + - name: Check out + uses: actions/checkout@v2 + with: + token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + fetch-depth: 0 + - name: Create bump and changelog + uses: commitizen-tools/commitizen-action@master + with: + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} ``` Push to master and that's it. +### Creating a github release + +You can modify the previous action. + +Add the variable `changelog_increment_filename` in the `commitizen-action`, specifying +where to output the content of the changelog for the newly created version. + +And then add a step using a github action to create the release: `softprops/action-gh-release` + +The commitizen action creates an env variable called `REVISION`, containing the +newely created version. + +```yaml +- name: Create bump and changelog + uses: commitizen-tools/commitizen-action@master + with: + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + changelog_increment_filename: body.md +- name: Release + uses: softprops/action-gh-release@v1 + with: + body_path: "body.md" + tag_name: ${{ env.REVISION }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + ### Publishing a python package Once the new tag is created, triggering an automatic publish command would be desired. -In order to do so, the first two secrets need to be added with the information -of our pypi account. +In order to do so, the crendetial needs to be added with the information of our PyPI account. + +Instead of using username and password, we suggest using [api token](https://pypi.org/help/#apitoken) generated from PyPI. + +After generate api token, use the token as the PyPI password and `__token__` as the username. -Go to `Settings > Secrets > Add new secret` and add the secrets: `PYPI_USERNAME` and `PYPI_PASSWORD`. +Go to `Settings > Secrets > Add new secret` and add the secret: `PYPI_PASSWORD`. Create a file in `.github/workflows/pythonpublish.yaml` with the following content: @@ -69,31 +88,31 @@ name: Upload Python Package on: push: tags: - - '*' # Will trigger for every tag, alternative: 'v*' + - "*" # Will trigger for every tag, alternative: 'v*' jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --pre -U poetry - poetry --version - poetry install - - name: Build and publish - env: - PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} - PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - ./scripts/publish + - uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --pre -U poetry + poetry --version + poetry install + - name: Build and publish + env: + PYPI_USERNAME: __token__ + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + ./scripts/publish ``` -Notice that we are calling a bash script in `./scripts/publish`, you should -configure it with your tools (twine, poetry, etc.). Check [commitizen example](https://github.com/Woile/commitizen/blob/master/scripts/publish) +Notice that we are calling a bash script in `./scripts/publish`, you should configure it with your tools (twine, poetry, etc.). Check [commitizen example](https://github.com/commitizen-tools/commitizen/blob/master/scripts/publish) +You can also use [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) to publish your package. Push the changes and that's it. diff --git a/docs/tutorials/gitlab_ci.md b/docs/tutorials/gitlab_ci.md index e2da81d63d..2859db7318 100644 --- a/docs/tutorials/gitlab_ci.md +++ b/docs/tutorials/gitlab_ci.md @@ -1,18 +1,18 @@ -### Create a new release using GitLab CI +## Create a new release using GitLab CI For this example, we have a `python/django` application and `Docker` as a containerization tool. -*Goal*: Bump a new version every time that a change occurs on the `master` branch. The bump should be executed automatically by the `CI` process. +_Goal_: Bump a new version every time that a change occurs on the `master` branch. The bump should be executed automatically by the `CI` process. -#### Development Workflow: +### Development Workflow 1. A developer creates a new commit on any branch (except `master`) 2. A developer creates a merge request (MR) against `master` branch 3. When the `MR` is merged into master, the 2 stages of the CI are executed 4. For simplification, we store the software version in a file called `VERSION`. You can use any file that you want as `commitizen` supports it. -5. The commit message executed automatically by the `CI` must include `[skip-ci]` in the message; otherwise, the process will generate a loop. You can define the message structure in [commitizen](https://woile.github.io/commitizen/bump/) as well. +5. The commit message executed automatically by the `CI` must include `[skip-ci]` in the message; otherwise, the process will generate a loop. You can define the message structure in [commitizen](../bump.md) as well. -#### Gitlab Configuration: +### Gitlab Configuration To be able to change files and push new changes with `Gitlab CI` runners, we need to have a `ssh` key and configure a git user. @@ -38,17 +38,17 @@ The latest step is to create a `deploy key.` To do this, we should create it und If you have more projects under the same organization, you can reuse the deploy key created before, but you will have to repeat the step where we have created the environment variables (ssh key, email, and username). -tip: If the CI raise some errors, try to unprotect the private key. +tip: If the CI raise some errors, try to unprotected the private key. -#### Defining GitLab CI Pipeline +### Defining GitLab CI Pipeline 1. Create a `.gitlab-ci.yaml` file that contains `stages` and `jobs` configurations. You can find more info [here](https://docs.gitlab.com/ee/ci/quick_start/). 2. Define `stages` and `jobs`. For this example, we define two `stages` with one `job` each one. - * Test the application. - * Auto bump the version. This means changing the file/s that reflects the version, creating a new commit and git tag. + - Test the application. + - Auto bump the version. This means changing the file/s that reflects the version, creating a new commit and git tag. -#### Stages and Jobs +### Stages and Jobs ```yaml image: docker:latest @@ -76,7 +76,7 @@ auto-bump: stage: auto-bump image: python:3.6 before_script: - - 'which ssh-agent || ( apt-get update -qy && apt-get install openssh-client -qqy )' + - "which ssh-agent || ( apt-get update -qy && apt-get install openssh-client -qqy )" - eval `ssh-agent -s` - echo "${SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - > /dev/null # add ssh key - pip3 install -U Commitizen # install commitizen @@ -102,7 +102,7 @@ auto-bump: - master artifacts: paths: - - variables + - variables ``` So, every time that a developer push to any branch, the `test` job is executed. If the branch is `master` and the test jobs success, the `auto-bump` takes place. diff --git a/docs/tutorials/jenkins_pipeline.md b/docs/tutorials/jenkins_pipeline.md new file mode 100644 index 0000000000..0d5d0693e2 --- /dev/null +++ b/docs/tutorials/jenkins_pipeline.md @@ -0,0 +1,52 @@ +# Create a new release with Jenkins Pipelines + +For this we are using the modern approach of [declarative pipelines](https://www.jenkins.io/doc/book/pipeline/). + +You must also ensure your jenkins instance supports docker. +Most modern jenkins systems do have support for it, [they have embraced it](https://www.jenkins.io/doc/book/pipeline/docker/). + +```groovy +pipeline { + agent { + any + } + environment { + CI = 'true' + } + stages { + stage('Bump version') { + when { + beforeAgent true + branch 'master' + not { + changelog '^bump:.+' + } + } + steps { + script { + useCz { + sh "cz bump --changelog" + } + // Here push back to your repository the new commit and tag + } + } + } + } +} + +def useCz(String authorName = 'Jenkins CI Server', String authorEmail = 'your-jenkins@email.com', String image = 'registry.hub.docker.com/commitizen/commitizen:latest', Closure body) { + docker + .image(image) + .inside("-u 0 -v $WORKSPACE:/workspace -w /workspace -e GIT_AUTHOR_NAME='${authorName}' -e GIT_AUTHOR_EMAIL='${authorEmail}'") { + sh "git config --global user.email '${authorName}'" + sh "git config --global user.name '${authorEmail}'" + body() + } +} +``` + +!!! warning + Using jenkins pipeline with any git plugin may require many different configurations, + you'll have to tinker with it until your pipelines properly detects git events. Check your + webhook in your git repository and check the "behaviors" and "build strategies" in + your pipeline settings. diff --git a/docs/tutorials/writing_commits.md b/docs/tutorials/writing_commits.md index 37b83b36b7..dbeee66db8 100644 --- a/docs/tutorials/writing_commits.md +++ b/docs/tutorials/writing_commits.md @@ -1,20 +1,21 @@ -For this project to work well in your pipeline, a commit convention -must be followed. +For this project to work well in your pipeline, a commit convention must be followed. -By default commitizen uses the known [conventional commits][conventional_commits], but you can create -your own following the docs information over [customization][customization]. +By default commitizen uses the known [conventional commits][conventional_commits], but +you can create your own following the docs information over at +[customization][customization]. ## Conventional commits If you are using [conventional commits][conventional_commits], the most important -thing to know is that you must begin your commits with at least one of these tags: `fix`, `feat`. And if you introduce a breaking change, then, you must +thing to know is that you must begin your commits with at least one of these tags: +`fix`, `feat`. And if you introduce a breaking change, then, you must add to your commit body the following `BREAKING CHANGE`. Using these 3 keywords will allow the proper identification of the semantic version. Of course, there are other keywords, but I'll leave it to the reader to explore them. ## Writing commits -Not to the important part, when writing commits, it's important to think about: +Now to the important part, when writing commits, it's important to think about: - Your future self - Your colleagues @@ -22,18 +23,20 @@ Not to the important part, when writing commits, it's important to think about: You may think this is trivial, but it's not. It's important for the reader to understand what happened. -### Recomendations +### Recommendations - **Keep the message short**: Makes the list of commits more readable (~50 chars). - **Talk imperative**: Follow this rule: `If applied, this commit will <commit message>` - **Think about the CHANGELOG**: Your commits will probably end up in the changelog - so try writing for it, but also keep in mind that you can skip sending commits to the CHANGELOG by using different keywords (like `build`). -- **Use a commit per new feature**: if you introduce multiple things related to the same commit, squash them. This is useful for auto-generating CHANGELOG. + so try writing for it, but also keep in mind that you can skip sending commits to the + CHANGELOG by using different keywords (like `build`). +- **Use a commit per new feature**: if you introduce multiple things related to the same + commit, squash them. This is useful for auto-generating CHANGELOG. | Do's | Don'ts | | ---- | ------ | | `fix(commands): bump error when no user provided` | `fix: stuff` | | `feat: add new commit command` | `feat: commit command introduced` | -[customization]: https://woile.github.io/commitizen/customization/ +[customization]: ../customization.md [conventional_commits]: https://www.conventionalcommits.org diff --git a/mkdocs.yml b/mkdocs.yml index dfaa6e16d8..038581b0b2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,22 +4,29 @@ site_description: commit rules, semantic version, conventional commits theme: name: 'material' -repo_name: Woile/commitizen -repo_url: https://github.com/Woile/commitizen +repo_name: commitizen-tools/commitizen +repo_url: https://github.com/commitizen-tools/commitizen edit_uri: "" nav: - Introduction: 'README.md' - - Configuration: 'config.md' - Commands: + - Init: 'init.md' - Bump: 'bump.md' - Check: 'check.md' - - Init: 'init.md' + - Changelog: 'changelog.md' + - Configuration: 'config.md' - Customization: 'customization.md' - Tutorials: - Writing commits: 'tutorials/writing_commits.md' - GitLab CI: 'tutorials/gitlab_ci.md' - Github Actions: 'tutorials/github_actions.md' + - Jenkins pipeline: 'tutorials/jenkins_pipeline.md' + - FAQ: 'faq.md' + - Exit Codes: 'exit_codes.md' + - Third-Party Commitizen Templates: 'third-party-commitizen.md' + - Contributing: 'contributing.md' + - Resources: 'external_links.md' markdown_extensions: - markdown.extensions.codehilite: diff --git a/pyproject.toml b/pyproject.toml index a62517c2f6..d1d0e37a47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.commitizen] -version = "1.16.4" +version = "2.20.0" tag_format = "v$version" version_files = [ "pyproject.toml:version", @@ -29,13 +29,13 @@ exclude = ''' [tool.poetry] name = "commitizen" -version = "1.16.4" +version = "2.20.0" description = "Python commitizen client tool" authors = ["Santiago Fraire <santiwilly@gmail.com>"] license = "MIT" keywords = ["commitizen", "conventional", "commits", "git"] readme = "docs/README.md" -homepage = "https://github.com/woile/commitizen" +homepage = "https://github.com/commitizen-tools/commitizen" classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.6", @@ -44,14 +44,16 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.6" +python = "^3.6.1" questionary = "^1.4.0" -decli = "^0.5.0" +decli = "^0.5.2" colorama = "^0.4.1" termcolor = "^1.1" -packaging = ">=19,<21" -tomlkit = "^0.5.3" -jinja2 = {version = "^2.10.3", optional = true} +packaging = ">=19,<22" +tomlkit = ">=0.5.3,<1.0.0" +jinja2 = ">=2.10.3" +pyyaml = ">=3.08" +argcomplete = "^1.12.1" [tool.poetry.dev-dependencies] ipython = "^7.2" @@ -61,15 +63,26 @@ flake8 = "^3.6" pytest-cov = "^2.6" pytest-mock = "^2.0" codecov = "^2.0" -mypy = "^0.761" +mypy = "0.910" mkdocs = "^1.0" mkdocs-material = "^4.1" -isort = "^4.3.21" +isort = "^5.7.0" +freezegun = "^0.3.15" +pydocstyle = "^5.0.2" +pre-commit = "^2.6.0" +pytest-regressions = "^2.2.0" +pytest-freezegun = "^0.4.2" +types-PyYAML = "^5.4.3" +types-termcolor = "^0.1.1" [tool.poetry.scripts] cz = "commitizen.cli:main" git-cz = "commitizen.cli:main" +[tool.isort] +profile = "black" +known_first_party = ["commitizen", "tests"] + [tool.coverage] [tool.coverage.report] show_missing = true @@ -89,8 +102,14 @@ git-cz = "commitizen.cli:main" 'if 0:', 'if __name__ == .__main__.:' ] + omit = [ + 'env/*', + 'venv/*', + '*/virtualenv/*', + '*/virtualenvs/*', + '*/tests/*' + ] [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" - diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000000..47b2b90f44 --- /dev/null +++ b/scripts/format @@ -0,0 +1,11 @@ +#!/bin/sh -e + +export PREFIX="poetry run python -m " +if [ -d 'venv' ] ; then + export PREFIX="venv/bin/" +fi + +set -x + +${PREFIX}isort commitizen tests +${PREFIX}black commitizen tests diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index d737c8f476..0000000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -e - -export PREFIX="" -if [ -d 'venv' ] ; then - export PREFIX="venv/bin/" -fi - -set -x - -${PREFIX}isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 --recursive --apply commitizen tests -${PREFIX}black commitizen tests diff --git a/scripts/test b/scripts/test index 119cf67098..08c54035dc 100755 --- a/scripts/test +++ b/scripts/test @@ -7,4 +7,7 @@ fi ${PREFIX}pytest --cov-report term-missing --cov-report=xml:coverage.xml --cov=commitizen tests/ ${PREFIX}black commitizen tests --check -${PREFIX}flake8 --max-line-length=88 commitizen/ tests/ +${PREFIX}isort --check-only commitizen tests +${PREFIX}flake8 commitizen/ tests/ +${PREFIX}mypy commitizen/ tests/ +${PREFIX}pydocstyle --convention=google --add-ignore=D1,D415 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000..d4e21ad5b3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,38 @@ +[tool:pytest] +addopts = --strict-markers +norecursedirs = .* build dist CVS _darcs {arch} *.egg venv env virtualenv + + +[mypy] +files = commitizen, tests +ignore_missing_imports = true +# disallow_untyped_calls = True +# disallow_untyped_defs = True +# disallow_incomplete_defs = True +disallow_untyped_decorators = True +# disallow_any_generics = True +disallow_subclassing_any = True +# warn_return_any = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_unused_configs = True + + +[flake8] +ignore = + # F632: use ==/!= to compare str, bytes, and int literals + F632, + # W503: Line break occurred before a binary operator + W503, + # E501: Line too long + E501, + # E203: Whitespace before ':' (for black) + E203 +exclude = + .git, + __pycache__, + docs/source/conf.py, + build, + dist +max-line-length = 88 +max-complexity = 12 diff --git a/tests/CHANGELOG_FOR_TEST.md b/tests/CHANGELOG_FOR_TEST.md new file mode 100644 index 0000000000..e92ca1ce39 --- /dev/null +++ b/tests/CHANGELOG_FOR_TEST.md @@ -0,0 +1,129 @@ + +## v1.2.0 (2019-04-19) + +### feat + +- custom cz plugins now support bumping version + +## v1.1.1 (2019-04-18) + +### refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +### fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +## v1.1.0 (2019-04-14) + +### feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementation + +### fix + +- removed all from commit +- fix config file not working + +### refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### refactor + +- removed delegator, added decli and many tests + +### BREAKING CHANGE + +- API is stable + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### refactor + +- **conventionalCommit**: moved filters to questions instead of message + +### fix + +- **manifest**: included missing files + +## v0.9.5 (2018-08-24) + +### fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### feat + +- **committer**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +### refactor + +- renamed conventional_changelog to conventional_commits, not backward compatible + +## v0.9.1 (2017-11-11) + +### fix + +- **setup.py**: future is now required for every python version diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index cadf369f06..faae727918 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -1,3 +1,5 @@ +import os + import pytest from commitizen import defaults @@ -9,3 +11,13 @@ def config(): _config = BaseConfig() _config.settings.update({"name": defaults.name}) return _config + + +@pytest.fixture() # type: ignore +def changelog_path() -> str: + return os.path.join(os.getcwd(), "CHANGELOG.md") + + +@pytest.fixture() # type: ignore +def config_path() -> str: + return os.path.join(os.getcwd(), "pyproject.toml") diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index e5ef886f63..8508c66eee 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1,52 +1,122 @@ +import inspect import sys -import uuid -from pathlib import Path -from typing import Optional +from unittest.mock import MagicMock import pytest +import commitizen.commands.bump as bump from commitizen import cli, cmd, git +from commitizen.exceptions import ( + BumpTagFailedError, + CurrentVersionNotFoundError, + DryRunExit, + ExpectedExit, + NoCommitsFoundError, + NoneIncrementExit, + NoPatternMapError, + NotAGitProjectError, + NoVersionSpecifiedError, +) +from tests.utils import create_file_and_commit + + +@pytest.mark.parametrize( + "commit_msg", + ( + "fix: username exception", + "fix(user): username exception", + "refactor: remove ini configuration support", + "refactor(config): remove ini configuration support", + "perf: update to use multiproess", + "perf(worker): update to use multiproess", + ), +) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_patch_increment(commit_msg, mocker): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.1.1") + assert tag_exists is True -def create_file_and_commit(message: str, filename: Optional[str] = None): - if not filename: - filename = str(uuid.uuid4()) - - Path(f"./{filename}").touch() - cmd.run("git add .") - git.commit(message) +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_minor_increment(commit_msg, mocker): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "commit:refs/tags/0.2.0\n" in cmd_res.out +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) @pytest.mark.usefixtures("tmp_commitizen_project") -def test_bump_command(mocker): - # MINOR - create_file_and_commit("feat: new file") +def test_bump_minor_increment_annotated(commit_msg, mocker): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes", "--annotated-tag"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +def test_bump_minor_increment_annotated_config_file( + commit_msg, mocker, tmp_commitizen_project +): + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" f"annotated_tag = 1" + ) + create_file_and_commit(commit_msg) testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() - tag_exists = git.tag_exist("0.2.0") - assert tag_exists is True + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out - # PATCH - create_file_and_commit("fix: username exception") - testargs = ["cz", "bump"] +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "commit_msg", + ( + "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface", + "feat(user): new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface", + "BREAKING CHANGE: age is no longer supported", + "BREAKING-CHANGE: age is no longer supported", + ), +) +def test_bump_major_increment(commit_msg, mocker): + create_file_and_commit(commit_msg) + + testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() - tag_exists = git.tag_exist("0.2.1") + tag_exists = git.tag_exist("1.0.0") assert tag_exists is True + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease(mocker): # PRERELEASE create_file_and_commit("feat: location") - testargs = ["cz", "bump", "--prerelease", "alpha"] + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() - tag_exists = git.tag_exist("0.3.0a0") + tag_exists = git.tag_exist("0.2.0a0") assert tag_exists is True # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE @@ -54,67 +124,311 @@ def test_bump_command(mocker): mocker.patch.object(sys, "argv", testargs) cli.main() - tag_exists = git.tag_exist("0.3.0") + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_on_git_with_hooks_no_verify_disabled(mocker): + """Bump commit without --no-verify""" + cmd.run("mkdir .git/hooks") + with open(".git/hooks/pre-commit", "w") as f: + f.write("#!/usr/bin/env bash\n" 'echo "0.1.0"') + cmd.run("chmod +x .git/hooks/pre-commit") + + # MINOR + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_tag_exists_raises_exception(mocker): + cmd.run("mkdir .git/hooks") + with open(".git/hooks/post-commit", "w") as f: + f.write("#!/usr/bin/env bash\n" "exit 9") + cmd.run("chmod +x .git/hooks/post-commit") + + # MINOR + create_file_and_commit("feat: new file") + git.tag("0.2.0") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(BumpTagFailedError) as excinfo: + cli.main() + assert "0.2.0" in str(excinfo.value) # This should be a fatal error + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_on_git_with_hooks_no_verify_enabled(mocker): + cmd.run("mkdir .git/hooks") + with open(".git/hooks/pre-commit", "w") as f: + f.write("#!/usr/bin/env bash\n" 'echo "0.1.0"') + cmd.run("chmod +x .git/hooks/pre-commit") + + # MINOR + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--no-verify"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") assert tag_exists is True - # MAJOR + +def test_bump_when_bumpping_is_not_support(mocker, tmp_commitizen_project): create_file_and_commit( "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported" ) - testargs = ["cz", "bump"] + testargs = ["cz", "-n", "cz_jira", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoPatternMapError) as excinfo: + cli.main() + + assert "'cz_jira' rule does not support bump" in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_git_project") +def test_bump_when_version_is_not_specify(mocker): + mocker.patch.object(sys, "argv", ["cz", "bump"]) + + with pytest.raises(NoVersionSpecifiedError) as excinfo: + cli.main() + + assert NoVersionSpecifiedError.message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_when_no_new_commit(mocker): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoCommitsFoundError) as excinfo: + cli.main() + + expected_error_message = "[NO_COMMITS_FOUND]\n" "No new commits found." + assert expected_error_message in str(excinfo.value) + + +def test_bump_when_version_inconsistent_in_version_files( + tmp_commitizen_project, mocker +): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("100.999.10000") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'version_files = ["{str(tmp_version_file)}"]' + ) + + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--check-consistency"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(CurrentVersionNotFoundError) as excinfo: + cli.main() + + partial_expected_error_message = "Current version 0.1.0 is not found in" + assert partial_expected_error_message in str(excinfo.value) + + +def test_bump_files_only(mocker, tmp_commitizen_project): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("0.1.0") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'version_files = ["{str(tmp_version_file)}"]' + ) + + create_file_and_commit("feat: new user interface") + testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True - tag_exists = git.tag_exist("1.0.0") + create_file_and_commit("feat: another new feature") + testargs = ["cz", "bump", "--yes", "--files-only"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(ExpectedExit): + cli.main() + + tag_exists = git.tag_exist("0.3.0") + assert tag_exists is False + + with open(tmp_version_file, "r") as f: + assert "0.3.0" in f.read() + + with open(tmp_commitizen_cfg_file, "r") as f: + assert "0.3.0" in f.read() + + +def test_bump_local_version(mocker, tmp_commitizen_project): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("4.5.1+0.1.0") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"[tool.commitizen]\n" + 'version="4.5.1+0.1.0"\n' + f'version_files = ["{str(tmp_version_file)}"]' + ) + + create_file_and_commit("feat: new user interface") + testargs = ["cz", "bump", "--yes", "--local-version"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("4.5.1+0.2.0") assert tag_exists is True + with open(tmp_version_file, "r") as f: + assert "4.5.1+0.2.0" in f.read() + + +def test_bump_dry_run(mocker, capsys, tmp_commitizen_project): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + assert "0.2.0" in out + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is False + + +def test_bump_in_non_git_project(tmpdir, config, mocker): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) -def test_bump_when_bumpping_is_not_support(mocker, capsys, tmpdir): with tmpdir.as_cwd(): - with open("./pyproject.toml", "w") as f: - f.write("[tool.commitizen]\n" 'version="0.1.0"') + with pytest.raises(NotAGitProjectError): + with pytest.raises(ExpectedExit): + cli.main() - cmd.run("git init") - create_file_and_commit( - "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported" - ) - testargs = ["cz", "-n", "cz_jira", "bump", "--yes"] - mocker.patch.object(sys, "argv", testargs) +def test_none_increment_exit_should_be_a_class(): + assert inspect.isclass(NoneIncrementExit) - with pytest.raises(SystemExit): - cli.main() - _, err = capsys.readouterr() - assert "'cz_jira' rule does not support bump" in err +def test_none_increment_exit_should_be_expected_exit_subclass(): + assert issubclass(NoneIncrementExit, ExpectedExit) -@pytest.mark.usefixtures("tmp_git_project") -def test_bump_is_not_specify(mocker, capsys): - mocker.patch.object(sys, "argv", ["cz", "bump"]) +def test_none_increment_exit_should_exist_in_bump(): + assert hasattr(bump, "NoneIncrementExit") + - with pytest.raises(SystemExit): +def test_none_increment_exit_is_exception(): + assert bump.NoneIncrementExit == NoneIncrementExit + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_none_increment_should_not_call_git_tag(mocker, tmp_commitizen_project): + create_file_and_commit("test(test_get_all_droplets): fix bad comparison test") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + # stash git.tag for later restore + stashed_git_tag = git.tag + dummy_value = git.tag("0.0.2") + git.tag = MagicMock(return_value=dummy_value) + + with pytest.raises(NoneIncrementExit): cli.main() + git.tag.assert_not_called() - expected_error_message = ( - "[NO_VERSION_SPECIFIED]\n" - "Check if current version is specified in config file, like:\n" - "version = 0.4.3\n" - ) + # restore pop stashed + git.tag = stashed_git_tag + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_arg(mocker, changelog_path): + create_file_and_commit("feat(user): new file") + testargs = ["cz", "bump", "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True - _, err = capsys.readouterr() - assert expected_error_message in err + with open(changelog_path, "r") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out @pytest.mark.usefixtures("tmp_commitizen_project") -def test_bump_when_not_new_commit(mocker, capsys): +def test_bump_with_changelog_config(mocker, changelog_path, config_path): + create_file_and_commit("feat(user): new file") + with open(config_path, "a") as fp: + fp.write("update_changelog_on_bump = true\n") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, "r") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out + + +def test_prevent_prerelease_when_no_increment_detected( + mocker, capsys, tmp_commitizen_project +): + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] mocker.patch.object(sys, "argv", testargs) - with pytest.raises(SystemExit): + cli.main() + out, _ = capsys.readouterr() + + assert "0.2.0" in out + + create_file_and_commit("test: new file") + testargs = ["cz", "bump", "-pr", "beta"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoCommitsFoundError) as excinfo: cli.main() - expected_error_message = "[NO_COMMITS_FOUND]\n" "No new commits found." - _, err = capsys.readouterr() - assert expected_error_message in err + expected_error_message = ( + "[NO_COMMITS_FOUND]\n" "No commits found to generate a pre-release." + ) + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_to_stdout_arg(mocker, capsys, changelog_path): + create_file_and_commit("feat(user): this should appear in stdout") + testargs = ["cz", "bump", "--yes", "--changelog-to-stdout"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + + assert "this should appear in stdout" in out + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, "r") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py new file mode 100644 index 0000000000..02e0e74644 --- /dev/null +++ b/tests/commands/test_changelog_command.py @@ -0,0 +1,516 @@ +import sys +from datetime import date + +import pytest + +from commitizen import cli, git +from commitizen.commands.changelog import Changelog +from commitizen.exceptions import ( + DryRunExit, + NoCommitsFoundError, + NoRevisionError, + NotAGitProjectError, +) +from tests.utils import create_file_and_commit + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_on_empty_project(mocker): + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoCommitsFoundError) as excinfo: + cli.main() + + assert "No commits found" in str(excinfo) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_from_version_zero_point_two(mocker, capsys): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: not in changelog") + + # create tag + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: after 0.2") + + testargs = ["cz", "changelog", "--start-rev", "0.2.0", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + assert out == "## Unreleased\n\n### Feat\n\n- after 0.2\n- after 0.2.0\n\n" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_with_different_cz(mocker, capsys): + create_file_and_commit("JRA-34 #comment corrected indent issue") + create_file_and_commit("JRA-35 #time 1w 2d 4h 30m Total work logged") + + testargs = ["cz", "-n", "cz_jira", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + assert ( + out + == "## Unreleased\n\n\n- JRA-35 #time 1w 2d 4h 30m Total work logged\n- JRA-34 #comment corrected indent issue\n\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_from_start(mocker, capsys, changelog_path): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == "## Unreleased\n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_replacing_unreleased_using_incremental( + mocker, capsys, changelog_path +): + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + today = date.today().isoformat() + assert ( + out + == f"## Unreleased\n\n### Feat\n\n- add more stuff\n\n### Fix\n\n- mama gotta work\n\n## 0.2.0 ({today})\n\n### Fix\n\n- output glitch\n\n### Feat\n\n- add new output\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_is_persisted_using_incremental(mocker, capsys, changelog_path): + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "a") as f: + f.write("\nnote: this should be persisted using increment\n") + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + today = date.today().isoformat() + assert ( + out + == f"## Unreleased\n\n### Feat\n\n- add more stuff\n\n### Fix\n\n- mama gotta work\n\n## 0.2.0 ({today})\n\n### Fix\n\n- output glitch\n\n### Feat\n\n- add new output\n\nnote: this should be persisted using increment\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_angular_sample(mocker, capsys, changelog_path): + with open(changelog_path, "w") as f: + f.write( + "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)\n" + "\n" + "### Bug Fixes" + "\n" + "* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566)\n" + ) + create_file_and_commit("irrelevant commit") + git.tag("10.0.0-next.3") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == "## Unreleased\n\n### Feat\n\n- add more stuff\n- add new output\n\n### Fix\n\n- mama gotta work\n- output glitch\n\n# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)\n\n### Bug Fixes\n* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566)\n" + ) + + +KEEP_A_CHANGELOG = """# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). +""" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_keep_a_changelog_sample(mocker, capsys, changelog_path): + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert ( + out + == """# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## Unreleased\n\n### Feat\n\n- add more stuff\n- add new output\n\n### Fix\n\n- mama gotta work\n- output glitch\n\n## [1.0.0] - 2017-06-20\n### Added\n- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).\n- Version navigation.\n\n### Changed\n- Start using "changelog" over "change log" since it\'s the common usage.\n\n### Removed\n- Section about "changelog" vs "CHANGELOG".\n\n## [0.3.0] - 2015-12-03\n### Added\n- RU translation from [@aishek](https://github.com/aishek).\n""" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_hook(mocker, config): + changelog_hook_mock = mocker.Mock() + changelog_hook_mock.return_value = "cool changelog hook" + + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + config.settings["change_type_order"] = ["Refactor", "Feat"] + changelog = Changelog( + config, {"unreleased_version": None, "incremental": True, "dry_run": False} + ) + mocker.patch.object(changelog.cz, "changelog_hook", changelog_hook_mock) + changelog() + full_changelog = ( + "## Unreleased\n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + ) + + changelog_hook_mock.assert_called_with(full_changelog, full_changelog) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_multiple_incremental_do_not_add_new_lines( + mocker, capsys, changelog_path +): + """Test for bug https://github.com/commitizen-tools/commitizen/issues/192""" + create_file_and_commit("feat: add new output") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("feat: add more stuff") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert out.startswith("#") + + +def test_changelog_without_revision(mocker, tmp_commitizen_project): + changelog_file = tmp_commitizen_project.join("CHANGELOG.md") + changelog_file.write( + """ + # Unreleased + + ## v1.0.0 + """ + ) + + # create_file_and_commit("feat: new file") + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoRevisionError): + cli.main() + + +def test_changelog_with_different_tag_name_and_changelog_content( + mocker, tmp_commitizen_project +): + changelog_file = tmp_commitizen_project.join("CHANGELOG.md") + changelog_file.write( + """ + # Unreleased + + ## v1.0.0 + """ + ) + create_file_and_commit("feat: new file") + git.tag("2.0.0") + + # create_file_and_commit("feat: new file") + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoRevisionError): + cli.main() + + +def test_changelog_in_non_git_project(tmpdir, config, mocker): + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with tmpdir.as_cwd(): + with pytest.raises(NotAGitProjectError): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_beta(mocker, capsys): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "BREAKING CHANGE: migrate by renaming user to users\n\n" + "footer content" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + assert out == ( + "## Unreleased\n\n### Feat\n\n- **users**: email pattern corrected\n\n" + "### BREAKING CHANGE\n\n- migrate by renaming user to users\n\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1(mocker, capsys): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "body content\n\n" + "BREAKING CHANGE: migrate by renaming user to users" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + assert out == ( + "## Unreleased\n\n### Feat\n\n- **users**: email pattern corrected\n\n" + "### BREAKING CHANGE\n\n- migrate by renaming user to users\n\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_multiline(mocker, capsys): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "body content\n\n" + "BREAKING CHANGE: migrate by renaming user to users.\n" + "and then connect the thingy with the other thingy\n\n" + "footer content" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + assert out == ( + "## Unreleased\n\n### Feat\n\n- **users**: email pattern corrected\n\n" + "### BREAKING CHANGE\n\n- migrate by renaming user to users.\n" + "and then connect the thingy with the other thingy" + "\n\n" + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_config_flag_increment(mocker, changelog_path, config_path): + + with open(config_path, "a") as f: + f.write("changelog_incremental = true\n") + with open(changelog_path, "a") as f: + f.write("\nnote: this should be persisted using increment\n") + + create_file_and_commit("feat: add new output") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + assert "this should be persisted using increment" in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_config_start_rev_option(mocker, capsys, config_path): + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: after 0.2") + + with open(config_path, "a") as f: + f.write('changelog_start_rev = "0.2.0"\n') + + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + assert out == "## Unreleased\n\n### Feat\n\n- after 0.2\n- after 0.2.0\n\n" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag( + mocker, capsys, changelog_path, file_regression +): + """Fix #378""" + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0", annotated=True) + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"]) +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_incremental_with_release_candidate_version( + mocker, capsys, changelog_path, file_regression, test_input +): + """Fix #357""" + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0", annotated=True) + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--changelog", "--prerelease", test_input, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "r") as f: + out = f.read() + + file_regression.check(out, extension=".md") diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md new file mode 100644 index 0000000000..56e2cf81f5 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md new file mode 100644 index 0000000000..1fd5ca870d --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0a0 (2021-06-11) + +### Fix + +- output glitch + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md new file mode 100644 index 0000000000..6ef1c0daad --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0b0 (2021-06-11) + +### Fix + +- output glitch + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md new file mode 100644 index 0000000000..1898179dbf --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0rc0 (2021-06-11) + +### Fix + +- output glitch + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index a53e57b495..ac545e3a6a 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -1,22 +1,67 @@ import sys +from io import StringIO +from typing import List import pytest -from commitizen import cli -from commitizen import commands +from commitizen import cli, commands, git +from commitizen.exceptions import ( + InvalidCommandArgumentError, + InvalidCommitMessageError, + NoCommitsFoundError, +) + +COMMIT_LOG = [ + "refactor: A code change that neither fixes a bug nor adds a feature", + r"refactor(cz/connventional_commit): use \S to check scope", + "refactor(git): remove unnecessary dot between git range", + "bump: version 1.16.3 โ†’ 1.16.4", + ( + "Merge pull request #139 from Lee-W/fix-init-clean-config-file\n" + "Fix init clean config file" + ), + "ci(pyproject.toml): add configuration for coverage", + "fix(commands/init): fix clean up file when initialize commitizen config\n#138", + "refactor(defaults): split config files into long term support and deprecated ones", + "bump: version 1.16.2 โ†’ 1.16.3", + ( + "Merge pull request #136 from Lee-W/remove-redundant-readme\n" + "Remove redundant readme" + ), + "fix: replace README.rst with docs/README.md in config files", + ( + "refactor(docs): remove README.rst and use docs/README.md\n" + "By removing README.rst, we no longer need to maintain " + "two document with almost the same content\n" + "Github can read docs/README.md as README for the project." + ), + "docs(check): pin pre-commit to v1.16.2", + "docs(check): fix pre-commit setup", + "bump: version 1.16.1 โ†’ 1.16.2", + "Merge pull request #135 from Lee-W/fix-pre-commit-hook\nFix pre commit hook", + "docs(check): enforce cz check only whem committing", + ( + 'Revert "fix(pre-commit): set pre-commit check stage to commit-msg"\n' + "This reverts commit afc70133e4a81344928561fbf3bb20738dfc8a0b." + ), + "feat!: add user stuff", +] + + +def _build_fake_git_commits(commit_msgs: List[str]) -> List[git.GitCommit]: + return [git.GitCommit("test_rev", commit_msg) for commit_msg in commit_msgs] -def test_check_jira_fails(mocker, capsys): +def test_check_jira_fails(mocker): testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] mocker.patch.object(sys, "argv", testargs) mocker.patch( "commitizen.commands.check.open", mocker.mock_open(read_data="random message for J-2 #fake_command blah"), ) - with pytest.raises(SystemExit): + with pytest.raises(InvalidCommitMessageError) as excinfo: cli.main() - _, err = capsys.readouterr() - assert "commit validation: failed!" in err + assert "commit validation: failed!" in str(excinfo.value) def test_check_jira_command_after_issue_one_space(mocker, capsys): @@ -79,12 +124,15 @@ def test_check_conventional_commit_succeeds(mocker, capsys): assert "Commit validation: successful!" in out -def test_check_no_conventional_commit(config, mocker, tmpdir): - with pytest.raises(SystemExit): +@pytest.mark.parametrize( + "commit_msg", ("feat!(lang): removed polish language", "no conventional commit",), +) +def test_check_no_conventional_commit(commit_msg, config, mocker, tmpdir): + with pytest.raises(InvalidCommitMessageError): error_mock = mocker.patch("commitizen.out.error") tempfile = tmpdir.join("temp_commit_file") - tempfile.write("no conventional commit") + tempfile.write(commit_msg) check_cmd = commands.Check( config=config, arguments={"commit_msg_file": tempfile} @@ -96,6 +144,7 @@ def test_check_no_conventional_commit(config, mocker, tmpdir): @pytest.mark.parametrize( "commit_msg", ( + "feat(lang)!: removed polish language", "feat(lang): added polish language", "feat: add polish language", "bump: 0.0.1 -> 1.0.0", @@ -115,4 +164,110 @@ def test_check_conventional_commit(commit_msg, config, mocker, tmpdir): def test_check_command_when_commit_file_not_found(config): with pytest.raises(FileNotFoundError): - commands.Check(config=config, arguments={"commit_msg_file": ""})() + commands.Check(config=config, arguments={"commit_msg_file": "no_such_file"})() + + +def test_check_a_range_of_git_commits(config, mocker): + success_mock = mocker.patch("commitizen.out.success") + mocker.patch( + "commitizen.git.get_commits", return_value=_build_fake_git_commits(COMMIT_LOG) + ) + + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_a_range_of_git_commits_and_failed(config, mocker): + error_mock = mocker.patch("commitizen.out.error") + mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(["This commit does not follow rule"]), + ) + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_invalid_argment(config): + with pytest.raises(InvalidCommandArgumentError) as excinfo: + commands.Check( + config=config, + arguments={"commit_msg_file": "some_file", "rev_range": "HEAD~10..master"}, + ) + assert "One and only one argument is required for check command!" in str( + excinfo.value + ) + + +def test_check_command_with_empty_range(config, mocker): + check_cmd = commands.Check(config=config, arguments={"rev_range": "master..master"}) + with pytest.raises(NoCommitsFoundError) as excinfo: + check_cmd() + + assert "No commit found with range: 'master..master'" in str(excinfo) + + +def test_check_a_range_of_failed_git_commits(config, mocker): + ill_formated_commits_msgs = [ + "First commit does not follow rule", + "Second commit does not follow rule", + ("Third commit does not follow rule\n" "Ill-formatted commit with body"), + ] + mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(ill_formated_commits_msgs), + ) + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + with pytest.raises(InvalidCommitMessageError) as excinfo: + check_cmd() + assert all([msg in str(excinfo.value) for msg in ill_formated_commits_msgs]) + + +def test_check_command_with_valid_message(config, mocker): + success_mock = mocker.patch("commitizen.out.success") + check_cmd = commands.Check( + config=config, arguments={"message": "fix(scope): some commit message"} + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_invalid_message(config, mocker): + error_mock = mocker.patch("commitizen.out.error") + check_cmd = commands.Check(config=config, arguments={"message": "bad commit"}) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_pipe_message(mocker, capsys): + testargs = ["cz", "check"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch("sys.stdin", StringIO("fix(scope): some commit message")) + + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_command_with_pipe_message_and_failed(mocker): + testargs = ["cz", "check"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch("sys.stdin", StringIO("bad commit message")) + + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 97614696dd..0735146b58 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -1,9 +1,19 @@ import os +import sys import pytest -from commitizen import cmd, commands +from commitizen import cli, cmd, commands from commitizen.cz.exceptions import CzException +from commitizen.exceptions import ( + CommitError, + CustomError, + DryRunExit, + NoAnswersError, + NoCommitBackupError, + NotAGitProjectError, + NothingToCommitError, +) @pytest.fixture @@ -25,7 +35,7 @@ def test_commit(config, mocker): } commit_mock = mocker.patch("commitizen.git.commit") - commit_mock.return_value = cmd.Command("success", "", "", "") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) success_mock = mocker.patch("commitizen.out.success") commands.Commit(config, {})() @@ -35,11 +45,13 @@ def test_commit(config, mocker): @pytest.mark.usefixtures("staging_is_clean") def test_commit_retry_fails_no_backup(config, mocker): commit_mock = mocker.patch("commitizen.git.commit") - commit_mock.return_value = cmd.Command("success", "", "", "") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) - with pytest.raises(SystemExit): + with pytest.raises(NoCommitBackupError) as excinfo: commands.Commit(config, {"retry": True})() + assert NoCommitBackupError.message in str(excinfo.value) + @pytest.mark.usefixtures("staging_is_clean") def test_commit_retry_works(config, mocker): @@ -54,10 +66,10 @@ def test_commit_retry_works(config, mocker): } commit_mock = mocker.patch("commitizen.git.commit") - commit_mock.return_value = cmd.Command("", "error", "", "") + commit_mock.return_value = cmd.Command("", "error", "", "", 9) error_mock = mocker.patch("commitizen.out.error") - with pytest.raises(SystemExit): + with pytest.raises(CommitError): commit_cmd = commands.Commit(config, {}) temp_file = commit_cmd.temp_file commit_cmd() @@ -68,7 +80,7 @@ def test_commit_retry_works(config, mocker): # Previous commit failed, so retry should pick up the backup commit # commit_mock = mocker.patch("commitizen.git.commit") - commit_mock.return_value = cmd.Command("success", "", "", "") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) success_mock = mocker.patch("commitizen.out.success") commands.Commit(config, {"retry": True})() @@ -91,20 +103,40 @@ def test_commit_command_with_dry_run_option(config, mocker): "footer": "", } - with pytest.raises(SystemExit): + with pytest.raises(DryRunExit): commit_cmd = commands.Commit(config, {"dry_run": True}) commit_cmd() +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_signoff_option(config, mocker): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"signoff": True})() + success_mock.assert_called_once() + + def test_commit_when_nothing_to_commit(config, mocker): is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean") is_staging_clean_mock.return_value = True - with pytest.raises(SystemExit) as err: + with pytest.raises(NothingToCommitError) as excinfo: commit_cmd = commands.Commit(config, {}) commit_cmd() - assert err.value.code == commands.commit.NOTHING_TO_COMMIT + assert "No files added to staging!" in str(excinfo.value) @pytest.mark.usefixtures("staging_is_clean") @@ -114,12 +146,99 @@ def test_commit_when_customized_expected_raised(config, mocker, capsys): prompt_mock = mocker.patch("questionary.prompt") prompt_mock.side_effect = _err - with pytest.raises(SystemExit) as err: + with pytest.raises(CustomError) as excinfo: commit_cmd = commands.Commit(config, {}) commit_cmd() - assert err.value.code == commands.commit.CUSTOM_ERROR - # Assert only the content in the formatted text - captured = capsys.readouterr() - assert "This is the root custom err" in captured.err + assert "This is the root custom err" in str(excinfo.value) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_when_non_customized_expected_raised(config, mocker, capsys): + _err = ValueError() + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.side_effect = _err + + with pytest.raises(ValueError): + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_when_no_user_answer(config, mocker, capsys): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = None + + with pytest.raises(NoAnswersError): + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + +def test_commit_in_non_git_project(tmpdir, config): + with tmpdir.as_cwd(): + with pytest.raises(NotAGitProjectError): + commands.Commit(config, {}) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_from_pre_commit_msg_hook(config, mocker, capsys): + testargs = ["cz", "commit", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) + mocker.patch("commitizen.commands.commit.WrapStdx") + mocker.patch("os.open") + reader_mock = mocker.mock_open(read_data="\n\n#test\n") + mocker.patch("builtins.open", reader_mock, create=True) + + cli.main() + + out, _ = capsys.readouterr() + assert "Commit message is successful!" in out + commit_mock.assert_not_called() + + +def test_WrapStdx(mocker): + mocker.patch("os.open") + reader_mock = mocker.mock_open(read_data="data") + mocker.patch("builtins.open", reader_mock, create=True) + + stdin_mock_fileno = mocker.patch.object(sys.stdin, "fileno") + stdin_mock_fileno.return_value = 0 + wrap_stdin = commands.commit.WrapStdx(sys.stdin) + + assert wrap_stdin.encoding == "UTF-8" + assert wrap_stdin.read() == "data" + + writer_mock = mocker.mock_open(read_data="data") + mocker.patch("builtins.open", writer_mock, create=True) + stdout_mock_fileno = mocker.patch.object(sys.stdout, "fileno") + stdout_mock_fileno.return_value = 1 + wrap_stout = commands.commit.WrapStdx(sys.stdout) + wrap_stout.write("data") + + writer_mock.assert_called_once_with("/dev/tty", "w") + writer_mock().write.assert_called_once_with("data") + + writer_mock = mocker.mock_open(read_data="data") + mocker.patch("builtins.open", writer_mock, create=True) + stderr_mock_fileno = mocker.patch.object(sys.stdout, "fileno") + stderr_mock_fileno.return_value = 2 + wrap_sterr = commands.commit.WrapStdx(sys.stderr) + + wrap_sterr.write("data") + + writer_mock.assert_called_once_with("/dev/tty", "w") + writer_mock().write.assert_called_once_with("data") diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 1de8fef06a..e7f2c00edf 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -1,4 +1,12 @@ +import json +import os + +import pytest +import yaml + from commitizen import commands +from commitizen.__version__ import __version__ +from commitizen.exceptions import NoAnswersError class FakeQuestion: @@ -9,7 +17,31 @@ def ask(self): return self.expected_return -def test_init(tmpdir, mocker, config): +pre_commit_config_filename = ".pre-commit-config.yaml" +cz_hook_config = { + "repo": "https://github.com/commitizen-tools/commitizen", + "rev": f"v{__version__}", + "hooks": [{"id": "commitizen", "stages": ["commit-msg"]}], +} + +expected_config = ( + "[tool]\n" + "[tool.commitizen]\n" + 'name = "cz_conventional_commits"\n' + 'version = "0.0.1"\n' + 'tag_format = "$version"\n' +) + +EXPECTED_DICT_CONFIG = { + "commitizen": { + "name": "cz_conventional_commits", + "version": "0.0.1", + "tag_format": "$version", + } +} + + +def test_init_without_setup_pre_commit_hook(tmpdir, mocker, config): mocker.patch( "questionary.select", side_effect=[ @@ -17,23 +49,19 @@ def test_init(tmpdir, mocker, config): FakeQuestion("cz_conventional_commits"), ], ) - mocker.patch("questionary.confirm", return_value=FakeQuestion("y")) - mocker.patch("questionary.text", return_value=FakeQuestion("y")) - expected_config = ( - "[tool.commitizen]\n" - 'name = "cz_conventional_commits"\n' - 'version = "0.0.1"\n' - 'tag_format = "y"\n' - ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.confirm", return_value=FakeQuestion(False)) with tmpdir.as_cwd(): commands.Init(config)() with open("pyproject.toml", "r") as toml_file: config_data = toml_file.read() - assert config_data == expected_config + assert not os.path.isfile(pre_commit_config_filename) + def test_init_when_config_already_exists(config, capsys): # Set config path @@ -43,3 +71,135 @@ def test_init_when_config_already_exists(config, capsys): commands.Init(config)() captured = capsys.readouterr() assert captured.out == f"Config file {path} already exists\n" + + +def test_init_without_choosing_tag(config, mocker, tmpdir): + mocker.patch( + "commitizen.commands.init.get_tag_names", return_value=["0.0.1", "0.0.2"] + ) + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value="0.0.2") + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion(""), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(False)) + mocker.patch("questionary.text", return_value=FakeQuestion("y")) + + with tmpdir.as_cwd(): + with pytest.raises(NoAnswersError): + commands.Init(config)() + + +class TestPreCommitCases: + @pytest.fixture(scope="function", params=["pyproject.toml", ".cz.json", ".cz.yaml"]) + def default_choice(_, request, mocker): + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion(request.param), + FakeQuestion("cz_conventional_commits"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + return request.param + + def test_no_existing_pre_commit_conifg(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + commands.Init(config)() + + with open(default_choice, "r") as file: + if "json" in default_choice: + assert json.load(file) == EXPECTED_DICT_CONFIG + elif "yaml" in default_choice: + assert ( + yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG + ) + else: + config_data = file.read() + assert config_data == expected_config + + with open(pre_commit_config_filename, "r") as pre_commit_file: + pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) + assert pre_commit_config_data == {"repos": [cz_hook_config]} + + def test_empty_pre_commit_config(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write("") + + commands.Init(config)() + + with open(default_choice, "r") as file: + if "json" in default_choice: + assert json.load(file) == EXPECTED_DICT_CONFIG + elif "yaml" in default_choice: + assert ( + yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG + ) + else: + config_data = file.read() + assert config_data == expected_config + + with open(pre_commit_config_filename, "r") as pre_commit_file: + pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) + assert pre_commit_config_data == {"repos": [cz_hook_config]} + + def test_pre_commit_config_without_cz_hook(_, default_choice, tmpdir, config): + existing_hook_config = { + "repo": "https://github.com/pre-commit/pre-commit-hooks", + "rev": "v1.2.3", + "hooks": [{"id", "trailing-whitespace"}], + } + + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write(yaml.safe_dump({"repos": [existing_hook_config]})) + + commands.Init(config)() + + with open(default_choice, "r") as file: + if "json" in default_choice: + assert json.load(file) == EXPECTED_DICT_CONFIG + elif "yaml" in default_choice: + assert ( + yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG + ) + else: + config_data = file.read() + assert config_data == expected_config + + with open(pre_commit_config_filename, "r") as pre_commit_file: + pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) + assert pre_commit_config_data == { + "repos": [existing_hook_config, cz_hook_config] + } + + def test_cz_hook_exists_in_pre_commit_config(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write(yaml.safe_dump({"repos": [cz_hook_config]})) + + commands.Init(config)() + + with open(default_choice, "r") as file: + if "json" in default_choice: + assert json.load(file) == EXPECTED_DICT_CONFIG + elif "yaml" in default_choice: + assert ( + yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG + ) + else: + config_data = file.read() + assert config_data == expected_config + + with open(pre_commit_config_filename, "r") as pre_commit_file: + pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) + + # check that config is not duplicated + assert pre_commit_config_data == {"repos": [cz_hook_config]} diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index 527c0b53f1..7e6ec3c851 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -1,42 +1,72 @@ +import platform +import sys + from commitizen import commands from commitizen.__version__ import __version__ def test_version_for_showing_project_version(config, capsys): # No version exist - commands.Version(config, {"project": True, "commitizen": False, "verbose": False})() + commands.Version( + config, + {"report": False, "project": True, "commitizen": False, "verbose": False}, + )() captured = capsys.readouterr() assert "No project information in this project." in captured.err config.settings["version"] = "v0.0.1" - commands.Version(config, {"project": True, "commitizen": False, "verbose": False})() + commands.Version( + config, + {"report": False, "project": True, "commitizen": False, "verbose": False}, + )() captured = capsys.readouterr() assert "v0.0.1" in captured.out def test_version_for_showing_commitizen_version(config, capsys): - commands.Version(config, {"project": False, "commitizen": True, "verbose": False})() + commands.Version( + config, + {"report": False, "project": False, "commitizen": True, "verbose": False}, + )() captured = capsys.readouterr() assert f"{__version__}" in captured.out # default showing commitizen version commands.Version( - config, {"project": False, "commitizen": False, "verbose": False} + config, + {"report": False, "project": False, "commitizen": False, "verbose": False}, )() captured = capsys.readouterr() assert f"{__version__}" in captured.out def test_version_for_showing_both_versions(config, capsys): - commands.Version(config, {"project": False, "commitizen": False, "verbose": True})() + commands.Version( + config, + {"report": False, "project": False, "commitizen": False, "verbose": True}, + )() captured = capsys.readouterr() assert f"Installed Commitizen Version: {__version__}" in captured.out assert "No project information in this project." in captured.err config.settings["version"] = "v0.0.1" - commands.Version(config, {"project": False, "commitizen": False, "verbose": True})() + commands.Version( + config, + {"report": False, "project": False, "commitizen": False, "verbose": True}, + )() captured = capsys.readouterr() expected_out = ( f"Installed Commitizen Version: {__version__}\n" f"Project Version: v0.0.1" ) assert expected_out in captured.out + + +def test_version_for_showing_commitizen_system_info(config, capsys): + commands.Version( + config, + {"report": True, "project": False, "commitizen": False, "verbose": False}, + )() + captured = capsys.readouterr() + assert f"Commitizen Version: {__version__}" in captured.out + assert f"Python Version: {sys.version}" in captured.out + assert f"Operating System: {platform.system()}" in captured.out diff --git a/tests/conftest.py b/tests/conftest.py index 5d1acdcdfd..f293d5631b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,11 +8,13 @@ def tmp_git_project(tmpdir): with tmpdir.as_cwd(): cmd.run("git init") - yield + yield tmpdir @pytest.fixture(scope="function") -@pytest.mark.usefixtures("tmp_git_project") def tmp_commitizen_project(tmp_git_project): - with open("pyproject.toml", "w") as f: - f.write("[tool.commitizen]\n" 'version="0.1.0"') + with tmp_git_project.as_cwd(): + tmp_commitizen_cfg_file = tmp_git_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write("[tool.commitizen]\n" 'version="0.1.0"\n') + + yield tmp_git_project diff --git a/tests/data/inconsistent_version.py b/tests/data/inconsistent_version.py new file mode 100644 index 0000000000..47646762dc --- /dev/null +++ b/tests/data/inconsistent_version.py @@ -0,0 +1,4 @@ +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "2.10.3" diff --git a/tests/data/multiple_versions_to_update_pyproject.toml b/tests/data/multiple_versions_to_update_pyproject.toml new file mode 100644 index 0000000000..de4ead0343 --- /dev/null +++ b/tests/data/multiple_versions_to_update_pyproject.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.9" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.9" diff --git a/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml b/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml new file mode 100644 index 0000000000..e2746fa9eb --- /dev/null +++ b/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.9" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.9" \ No newline at end of file diff --git a/tests/data/repeated_version_number.json b/tests/data/repeated_version_number.json new file mode 100644 index 0000000000..8421026df9 --- /dev/null +++ b/tests/data/repeated_version_number.json @@ -0,0 +1,7 @@ +{ + "name": "magictool", + "version": "1.2.3", + "dependencies": { + "lodash": "1.2.3" + } +} diff --git a/tests/data/sample_cargo.lock b/tests/data/sample_cargo.lock new file mode 100644 index 0000000000..a3a0b1bb7a --- /dev/null +++ b/tests/data/sample_cargo.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "1.2.3" + +[[package]] +name = "there-i-fixed-it" +version = "1.2.3" + +[[package]] +name = "other-project" +version = "1.2.3" diff --git a/tests/data/sample_docker_compose.yaml b/tests/data/sample_docker_compose.yaml new file mode 100644 index 0000000000..9da8caf1f6 --- /dev/null +++ b/tests/data/sample_docker_compose.yaml @@ -0,0 +1,6 @@ +version: "3.3" + +services: + app: + image: my-repo/my-container:v1.2.3 + command: my-command diff --git a/tests/data/sample_pyproject.toml b/tests/data/sample_pyproject.toml new file mode 100644 index 0000000000..9f50155cb7 --- /dev/null +++ b/tests/data/sample_pyproject.toml @@ -0,0 +1,3 @@ +[tool.poetry] +name = "commitizen" +version = "1.2.3" diff --git a/tests/data/sample_version.py b/tests/data/sample_version.py new file mode 100644 index 0000000000..4dd4512dba --- /dev/null +++ b/tests/data/sample_version.py @@ -0,0 +1,4 @@ +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "1.2.3" diff --git a/tests/test_bump_create_tag.py b/tests/test_bump_create_tag.py index b6e06bcf51..0d6ee60d69 100644 --- a/tests/test_bump_create_tag.py +++ b/tests/test_bump_create_tag.py @@ -10,6 +10,9 @@ (("1.2.3", "ver$major.$minor.$patch"), "ver1.2.3"), (("1.2.3a0", "ver$major.$minor.$patch.$prerelease"), "ver1.2.3.a0"), (("1.2.3rc2", "$major.$minor.$patch.$prerelease-majestic"), "1.2.3.rc2-majestic"), + (("1.2.3+1.0.0", "v$version"), "v1.2.3+1.0.0"), + (("1.2.3+1.0.0", "v$version-local"), "v1.2.3+1.0.0-local"), + (("1.2.3+1.0.0", "ver$major.$minor.$patch"), "ver1.2.3"), ] diff --git a/tests/test_bump_find_increment.py b/tests/test_bump_find_increment.py index 405410712a..1f98694a2b 100644 --- a/tests/test_bump_find_increment.py +++ b/tests/test_bump_find_increment.py @@ -2,14 +2,16 @@ CC: Conventional commits SVE: Semantic version at the end """ +import pytest + from commitizen import bump from commitizen.git import GitCommit NONE_INCREMENT_CC = ["docs(README): motivation", "ci: added travis"] PATCH_INCREMENTS_CC = [ - "docs(README): motivation", "fix(setup.py): future is now required for every python version", + "docs(README): motivation", ] MINOR_INCREMENTS_CC = [ @@ -18,11 +20,24 @@ "fix(setup.py): future is now required for every python version", ] -MAJOR_INCREMENTS_CC = [ +MAJOR_INCREMENTS_BREAKING_CHANGE_CC = [ "feat(cli): added version", "docs(README): motivation", - "fix(setup.py): future is now required for every python version", "BREAKING CHANGE: `extends` key in config file is now used for extending other config files", # noqa + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "BREAKING-CHANGE: `extends` key in config file is now used for extending other config files", # noqa + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_CC = [ + "feat(cli)!: added version", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", ] PATCH_INCREMENTS_SVE = ["readme motivation PATCH", "fix setup.py PATCH"] @@ -44,58 +59,34 @@ semantic_version_map = {"MAJOR": "MAJOR", "MINOR": "MINOR", "PATCH": "PATCH"} -def test_find_increment_type_patch(): - messages = PATCH_INCREMENTS_CC - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment(commits) - assert increment_type == "PATCH" - - -def test_find_increment_type_minor(): - messages = MINOR_INCREMENTS_CC - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment(commits) - assert increment_type == "MINOR" - - -def test_find_increment_type_major(): - messages = MAJOR_INCREMENTS_CC +@pytest.mark.parametrize( + "messages, expected_type", + ( + (PATCH_INCREMENTS_CC, "PATCH"), + (MINOR_INCREMENTS_CC, "MINOR"), + (MAJOR_INCREMENTS_BREAKING_CHANGE_CC, "MAJOR"), + (MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC, "MAJOR"), + (MAJOR_INCREMENTS_EXCLAMATION_CC, "MAJOR"), + (NONE_INCREMENT_CC, None), + ), +) +def test_find_increment(messages, expected_type): commits = [GitCommit(rev="test", title=message) for message in messages] increment_type = bump.find_increment(commits) - assert increment_type == "MAJOR" - - -def test_find_increment_type_patch_sve(): - messages = PATCH_INCREMENTS_SVE - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment( - commits, regex=semantic_version_pattern, increments_map=semantic_version_map - ) - assert increment_type == "PATCH" - - -def test_find_increment_type_minor_sve(): - messages = MINOR_INCREMENTS_SVE - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment( - commits, regex=semantic_version_pattern, increments_map=semantic_version_map - ) - assert increment_type == "MINOR" - - -def test_find_increment_type_major_sve(): - messages = MAJOR_INCREMENTS_SVE - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment( - commits, regex=semantic_version_pattern, increments_map=semantic_version_map - ) - assert increment_type == "MAJOR" - - -def test_find_increment_type_none(): - messages = NONE_INCREMENT_CC + assert increment_type == expected_type + + +@pytest.mark.parametrize( + "messages, expected_type", + ( + (PATCH_INCREMENTS_SVE, "PATCH"), + (MINOR_INCREMENTS_SVE, "MINOR"), + (MAJOR_INCREMENTS_SVE, "MAJOR"), + ), +) +def test_find_increment_sve(messages, expected_type): commits = [GitCommit(rev="test", title=message) for message in messages] increment_type = bump.find_increment( commits, regex=semantic_version_pattern, increments_map=semantic_version_map ) - assert increment_type is None + assert increment_type == expected_type diff --git a/tests/test_bump_find_version.py b/tests/test_bump_find_version.py index ca1a6b57d5..1436d9bd1e 100644 --- a/tests/test_bump_find_version.py +++ b/tests/test_bump_find_version.py @@ -30,6 +30,12 @@ (("1.2.1", "MAJOR", None), "2.0.0"), ] +local_versions = [ + (("4.5.0+0.1.0", "PATCH", None), "4.5.0+0.1.1"), + (("4.5.0+0.1.1", "MINOR", None), "4.5.0+0.2.0"), + (("4.5.0+0.2.0", "MAJOR", None), "4.5.0+1.0.0"), +] + # this cases should be handled gracefully unexpected_cases = [ (("0.1.1rc0", None, "alpha"), "0.1.1a0"), @@ -73,3 +79,19 @@ def test_generate_version(test_input, expected): assert generate_version( current_version, increment=increment, prerelease=prerelease ) == Version(expected) + + +@pytest.mark.parametrize( + "test_input,expected", itertools.chain(local_versions), +) +def test_generate_version_local(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + is_local_version = True + assert generate_version( + current_version, + increment=increment, + prerelease=prerelease, + is_local_version=is_local_version, + ) == Version(expected) diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py index e94c5544c4..a00286df71 100644 --- a/tests/test_bump_update_version_in_files.py +++ b/tests/test_bump_update_version_in_files.py @@ -1,72 +1,203 @@ -import os +from shutil import copyfile import pytest +from py._path.local import LocalPath from commitizen import bump +from commitizen.exceptions import CurrentVersionNotFoundError -PYPROJECT = """ -[tool.poetry] -name = "commitizen" -version = "1.2.3" -""" - -VERSION_PY = """ -__title__ = 'requests' -__description__ = 'Python HTTP for Humans.' -__url__ = 'http://python-requests.org' -__version__ = '1.2.3' -""" - -REPEATED_VERSION_NUMBER = """ -{ - "name": "magictool", - "version": "1.2.3", - "dependencies": { - "lodash": "1.2.3" - } -} -""" - -files_with_content = ( - ("pyproject.toml", PYPROJECT), - ("__version__.py", VERSION_PY), - ("package.json", REPEATED_VERSION_NUMBER), +MULTIPLE_VERSIONS_INCREASE_STRING = 'version = "1.2.9"\n' * 30 +MULTIPLE_VERSIONS_REDUCE_STRING = 'version = "1.2.10"\n' * 30 + +TESTING_FILE_PREFIX = "tests/data" + + +def _copy_sample_file_to_tmpdir( + tmpdir: LocalPath, source_filename: str, dest_filename: str +) -> str: + tmp_file = tmpdir.join(dest_filename) + copyfile(f"{TESTING_FILE_PREFIX}/{source_filename}", str(tmp_file)) + return str(tmp_file) + + +@pytest.fixture(scope="function") +def commitizen_config_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "sample_pyproject.toml", "pyproject.toml" + ) + + +@pytest.fixture(scope="function") +def python_version_file(tmpdir, request): + return _copy_sample_file_to_tmpdir(tmpdir, "sample_version.py", "__version__.py") + + +@pytest.fixture(scope="function") +def inconsistent_python_version_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "inconsistent_version.py", "__version__.py" + ) + + +@pytest.fixture(scope="function") +def random_location_version_file(tmpdir): + return _copy_sample_file_to_tmpdir(tmpdir, "sample_cargo.lock", "Cargo.lock") + + +@pytest.fixture(scope="function") +def version_repeated_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "repeated_version_number.json", "package.json" + ) + + +@pytest.fixture(scope="function") +def docker_compose_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "sample_docker_compose.yaml", "docker-compose.yaml" + ) + + +@pytest.fixture( + scope="function", + params=( + "multiple_versions_to_update_pyproject.toml", + "multiple_versions_to_update_pyproject_wo_eol.toml", + ), + ids=("with_eol", "without_eol"), ) +def multiple_versions_to_update_poetry_lock(tmpdir, request): + return _copy_sample_file_to_tmpdir(tmpdir, request.param, "pyproject.toml") + + +@pytest.fixture(scope="function") +def multiple_versions_increase_string(tmpdir): + tmp_file = tmpdir.join("anyfile") + tmp_file.write(MULTIPLE_VERSIONS_INCREASE_STRING) + return str(tmp_file) + +@pytest.fixture(scope="function") +def multiple_versions_reduce_string(tmpdir): + tmp_file = tmpdir.join("anyfile") + tmp_file.write(MULTIPLE_VERSIONS_REDUCE_STRING) + return str(tmp_file) -@pytest.fixture -def create_files(): - files = [] - for fileconf in files_with_content: - filename, content = fileconf - filepath = os.path.join("tests", filename) - with open(filepath, "w") as f: - f.write(content) - files.append(filepath) - yield files - for filepath in files: - os.remove(filepath) +@pytest.fixture(scope="function") +def version_files( + commitizen_config_file, + python_version_file, + version_repeated_file, + docker_compose_file, +): + return ( + commitizen_config_file, + python_version_file, + version_repeated_file, + docker_compose_file, + ) -def test_update_version_in_files(create_files): + +def test_update_version_in_files(version_files, file_regression): old_version = "1.2.3" new_version = "2.0.0" - bump.update_version_in_files(old_version, new_version, create_files) - for filepath in create_files: + bump.update_version_in_files(old_version, new_version, version_files) + + file_contents = "" + for filepath in version_files: with open(filepath, "r") as f: - data = f.read() - assert new_version in data + file_contents += f.read() + file_regression.check(file_contents, extension=".txt") -def test_partial_update_of_file(create_files): +def test_partial_update_of_file(version_repeated_file, file_regression): old_version = "1.2.3" new_version = "2.0.0" - filepath = "tests/package.json" regex = "version" - location = f"{filepath}:{regex}" + location = f"{version_repeated_file}:{regex}" + + bump.update_version_in_files(old_version, new_version, [location]) + with open(version_repeated_file, "r") as f: + file_regression.check(f.read(), extension=".json") + + +def test_random_location(random_location_version_file, file_regression): + old_version = "1.2.3" + new_version = "2.0.0" + location = f"{random_location_version_file}:there-i-fixed-it.+\nversion" + + bump.update_version_in_files(old_version, new_version, [location]) + with open(random_location_version_file, "r") as f: + file_regression.check(f.read(), extension=".lock") + + +def test_duplicates_are_change_with_no_regex( + random_location_version_file, file_regression +): + old_version = "1.2.3" + new_version = "2.0.0" + location = f"{random_location_version_file}:version" + + bump.update_version_in_files(old_version, new_version, [location]) + with open(random_location_version_file, "r") as f: + file_regression.check(f.read(), extension=".lock") + + +def test_version_bump_increase_string_length( + multiple_versions_increase_string, file_regression +): + old_version = "1.2.9" + new_version = "1.2.10" + location = f"{multiple_versions_increase_string}:version" + + bump.update_version_in_files(old_version, new_version, [location]) + with open(multiple_versions_increase_string, "r") as f: + file_regression.check(f.read(), extension=".txt") + + +def test_version_bump_reduce_string_length( + multiple_versions_reduce_string, file_regression +): + old_version = "1.2.10" + new_version = "2.0.0" + location = f"{multiple_versions_reduce_string}:version" + + bump.update_version_in_files(old_version, new_version, [location]) + with open(multiple_versions_reduce_string, "r") as f: + file_regression.check(f.read(), extension=".txt") + + +def test_file_version_inconsistent_error( + commitizen_config_file, inconsistent_python_version_file, version_repeated_file +): + version_files = [ + commitizen_config_file, + inconsistent_python_version_file, + version_repeated_file, + ] + old_version = "1.2.3" + new_version = "2.0.0" + with pytest.raises(CurrentVersionNotFoundError) as excinfo: + bump.update_version_in_files( + old_version, new_version, version_files, check_consistency=True + ) + + expected_msg = ( + f"Current version 1.2.3 is not found in {inconsistent_python_version_file}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ) + assert expected_msg in str(excinfo.value) + + +def test_multiplt_versions_to_bump( + multiple_versions_to_update_poetry_lock, file_regression +): + old_version = "1.2.9" + new_version = "1.2.10" + location = f"{multiple_versions_to_update_poetry_lock}:version" bump.update_version_in_files(old_version, new_version, [location]) - with open(filepath, "r") as f: - data = f.read() - assert new_version in data - assert old_version in data + with open(multiple_versions_to_update_poetry_lock, "r") as f: + file_regression.check(f.read(), extension=".toml") diff --git a/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock b/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock new file mode 100644 index 0000000000..eed8f4c79d --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "2.0.0" + +[[package]] +name = "there-i-fixed-it" +version = "2.0.0" + +[[package]] +name = "other-project" +version = "2.0.0" diff --git a/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml new file mode 100644 index 0000000000..f279eb4d61 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" diff --git a/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml new file mode 100644 index 0000000000..47092b958b --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" \ No newline at end of file diff --git a/tests/test_bump_update_version_in_files/test_partial_update_of_file.json b/tests/test_bump_update_version_in_files/test_partial_update_of_file.json new file mode 100644 index 0000000000..59224bca04 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_partial_update_of_file.json @@ -0,0 +1,7 @@ +{ + "name": "magictool", + "version": "2.0.0", + "dependencies": { + "lodash": "1.2.3" + } +} diff --git a/tests/test_bump_update_version_in_files/test_random_location.lock b/tests/test_bump_update_version_in_files/test_random_location.lock new file mode 100644 index 0000000000..94422116aa --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_random_location.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "1.2.3" + +[[package]] +name = "there-i-fixed-it" +version = "2.0.0" + +[[package]] +name = "other-project" +version = "1.2.3" diff --git a/tests/test_bump_update_version_in_files/test_update_version_in_files.txt b/tests/test_bump_update_version_in_files/test_update_version_in_files.txt new file mode 100644 index 0000000000..c4e527ac47 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_update_version_in_files.txt @@ -0,0 +1,20 @@ +[tool.poetry] +name = "commitizen" +version = "2.0.0" +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "2.0.0" +{ + "name": "magictool", + "version": "2.0.0", + "dependencies": { + "lodash": "2.0.0" + } +} +version: "3.3" + +services: + app: + image: my-repo/my-container:v2.0.0 + command: my-command diff --git a/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt b/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt new file mode 100644 index 0000000000..4b6d6d64be --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt @@ -0,0 +1,30 @@ +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" diff --git a/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt b/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt new file mode 100644 index 0000000000..8e619de1ab --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt @@ -0,0 +1,30 @@ +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000000..055e4fc916 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,911 @@ +import pytest + +from commitizen import changelog, defaults, git +from commitizen.exceptions import InvalidConfigurationError + +COMMITS_DATA = [ + { + "rev": "141ee441c9c9da0809c554103a558eb17c30ed17", + "title": "bump: version 1.1.1 โ†’ 1.2.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "6c4948501031b7d6405b54b21d3d635827f9421b", + "title": "docs: how to create custom bumps", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ddd220ad515502200fe2dde443614c1075d26238", + "title": "feat: custom cz plugins now support bumping version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ad17acff2e3a2e141cbc3c6efd7705e4e6de9bfc", + "title": "docs: added bump gif", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "56c8a8da84e42b526bcbe130bd194306f7c7e813", + "title": "bump: version 1.1.0 โ†’ 1.1.1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "74c6134b1b2e6bb8b07ed53410faabe99b204f36", + "title": "refactor: changed stdout statements", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "cbc7b5f22c4e74deff4bc92d14e19bd93524711e", + "title": "fix(bump): commit message now fits better with semver", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1ba46f2a63cb9d6e7472eaece21528c8cd28b118", + "title": "fix: conventional commit 'breaking change' in body instead of title", + "body": "closes #16", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "c35dbffd1bb98bb0b3d1593797e79d1c3366af8f", + "title": "refactor(schema): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "25313397a4ac3dc5b5c986017bee2a614399509d", + "title": "refactor(info): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d2f13ac41b4e48995b3b619d931c82451886e6ff", + "title": "refactor(example): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d839e317e5b26671b010584ad8cc6bf362400fa1", + "title": "refactor(commit): moved most of the commit logic to the commit command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "12d0e65beda969f7983c444ceedc2a01584f4e08", + "title": "docs(README): updated documentation url)", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "fb4c85abe51c228e50773e424cbd885a8b6c610d", + "title": "docs: mkdocs documentation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "17efb44d2cd16f6621413691a543e467c7d2dda6", + "title": "Bump version 1.0.0 โ†’ 1.1.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "6012d9eecfce8163d75c8fff179788e9ad5347da", + "title": "test: fixed issues with conf", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0c7fb0ca0168864dfc55d83c210da57771a18319", + "title": "docs(README): some new information about bump", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "cb1dd2019d522644da5bdc2594dd6dee17122d7f", + "title": "feat: new working bump command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9c7450f85df6bf6be508e79abf00855a30c3c73c", + "title": "feat: create version tag", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9f3af3772baab167e3fd8775d37f041440184251", + "title": "docs: added new changelog", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b0d6a3defbfde14e676e7eb34946409297d0221b", + "title": "feat: update given files with new version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d630d07d912e420f0880551f3ac94e933f9d3beb", + "title": "fix: removed all from commit", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1792b8980c58787906dbe6836f93f31971b1ec2d", + "title": "feat(config): new set key, used to set version to cfg", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "52def1ea3555185ba4b936b463311949907e31ec", + "title": "feat: support for pyproject.toml", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "3127e05077288a5e2b62893345590bf1096141b7", + "title": "feat: first semantic version bump implementation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "fd480ed90a80a6ffa540549408403d5b60d0e90c", + "title": "fix: fix config file not working", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "e4840a059731c0bf488381ffc77e989e85dd81ad", + "title": "refactor: added commands folder, better integration with decli", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "aa44a92d68014d0da98965c0c2cb8c07957d4362", + "title": "Bump version: 1.0.0b2 โ†’ 1.0.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "58bb709765380dbd46b74ce6e8978515764eb955", + "title": "docs(README): new badges", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "97afb0bb48e72b6feca793091a8a23c706693257", + "title": "Merge pull request #10 from Woile/feat/decli", + "body": "Feat/decli", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9cecb9224aa7fa68d4afeac37eba2a25770ef251", + "title": "style: black to files", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "f5781d1a2954d71c14ade2a6a1a95b91310b2577", + "title": "ci: added travis", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "80105fb3c6d45369bc0cbf787bd329fba603864c", + "title": "refactor: removed delegator, added decli and many tests", + "body": "BREAKING CHANGE: API is stable", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "a96008496ffefb6b1dd9b251cb479eac6a0487f7", + "title": "docs: updated test command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "aab33d13110f26604fb786878856ec0b9e5fc32b", + "title": "Bump version: 1.0.0b1 โ†’ 1.0.0b2", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b73791563d2f218806786090fb49ef70faa51a3a", + "title": "docs(README): updated to reflect current state", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "7aa06a454fb717408b3657faa590731fb4ab3719", + "title": "Merge pull request #9 from Woile/dev", + "body": "feat: py3 only, tests and conventional commits 1.0", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", + "title": "Bump version: 0.9.11 โ†’ 1.0.0b1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ed830019581c83ba633bfd734720e6758eca6061", + "title": "feat: py3 only, tests and conventional commits 1.0", + "body": "more tests\npyproject instead of Pipfile\nquestionary instead of whaaaaat (promptkit 2.0.0 support)", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "c52eca6f74f844ab3ffbde61d98ef96071e132b7", + "title": "Bump version: 0.9.10 โ†’ 0.9.11", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0326652b2657083929507ee66d4d1a0899e861ba", + "title": "fix(config): load config reads in order without failing if there is no commitizen section", + "body": "Closes #8", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b3f89892222340150e32631ae6b7aab65230036f", + "title": "Bump version: 0.9.9 โ†’ 0.9.10", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "5e837bf8ef0735193597372cd2d85e31a8f715b9", + "title": "fix: parse scope (this is my punishment for not having tests)", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "684e0259cc95c7c5e94854608cd3dcebbd53219e", + "title": "Bump version: 0.9.8 โ†’ 0.9.9", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ca38eac6ff09870851b5c76a6ff0a2a8e5ecda15", + "title": "fix: parse scope empty", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "64168f18d4628718c49689ee16430549e96c5d4b", + "title": "Bump version: 0.9.7 โ†’ 0.9.8", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9d4def716ef235a1fa5ae61614366423fbc8256f", + "title": "fix(scope): parse correctly again", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", + "title": "Bump version: 0.9.6 โ†’ 0.9.7", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "696885e891ec35775daeb5fec3ba2ab92c2629e1", + "title": "fix(scope): parse correctly", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "bef4a86761a3bda309c962bae5d22ce9b57119e4", + "title": "Bump version: 0.9.5 โ†’ 0.9.6", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "72472efb80f08ee3fd844660afa012c8cb256e4b", + "title": "refactor(conventionalCommit): moved filters to questions instead of message", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b5561ce0ab3b56bb87712c8f90bcf37cf2474f1b", + "title": "fix(manifest): included missing files", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "3e31714dc737029d96898f412e4ecd2be1bcd0ce", + "title": "Bump version: 0.9.4 โ†’ 0.9.5", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9df721e06595fdd216884c36a28770438b4f4a39", + "title": "fix(config): home path for python versions between 3.0 and 3.5", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", + "title": "Bump version: 0.9.3 โ†’ 0.9.4", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "973c6b3e100f6f69a3fe48bd8ee55c135b96c318", + "title": "feat(cli): added version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "dacc86159b260ee98eb5f57941c99ba731a01399", + "title": "Bump version: 0.9.2 โ†’ 0.9.3", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "4368f3c3cbfd4a1ced339212230d854bc5bab496", + "title": "feat(committer): conventional commit is a bit more intelligent now", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "da94133288727d35dae9b91866a25045038f2d38", + "title": "docs(README): motivation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", + "title": "Bump version: 0.9.1 โ†’ 0.9.2", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ddc855a637b7879108308b8dbd85a0fd27c7e0e7", + "title": "refactor: renamed conventional_changelog to conventional_commits, not backward compatible", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "46e9032e18a819e466618c7a014bcb0e9981af9e", + "title": "Bump version: 0.9.0 โ†’ 0.9.1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0fef73cd7dc77a25b82e197e7c1d3144a58c1350", + "title": "fix(setup.py): future is now required for every python version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, +] + + +TAGS = [ + ("v1.2.0", "141ee441c9c9da0809c554103a558eb17c30ed17", "2019-04-19"), + ("v1.1.1", "56c8a8da84e42b526bcbe130bd194306f7c7e813", "2019-04-18"), + ("v1.1.0", "17efb44d2cd16f6621413691a543e467c7d2dda6", "2019-04-14"), + ("v1.0.0", "aa44a92d68014d0da98965c0c2cb8c07957d4362", "2019-03-01"), + ("1.0.0b2", "aab33d13110f26604fb786878856ec0b9e5fc32b", "2019-01-18"), + ("v1.0.0b1", "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", "2019-01-17"), + ("v0.9.11", "c52eca6f74f844ab3ffbde61d98ef96071e132b7", "2018-12-17"), + ("v0.9.10", "b3f89892222340150e32631ae6b7aab65230036f", "2018-09-22"), + ("v0.9.9", "684e0259cc95c7c5e94854608cd3dcebbd53219e", "2018-09-22"), + ("v0.9.8", "64168f18d4628718c49689ee16430549e96c5d4b", "2018-09-22"), + ("v0.9.7", "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", "2018-09-22"), + ("v0.9.6", "bef4a86761a3bda309c962bae5d22ce9b57119e4", "2018-09-19"), + ("v0.9.5", "3e31714dc737029d96898f412e4ecd2be1bcd0ce", "2018-08-24"), + ("v0.9.4", "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", "2018-08-02"), + ("v0.9.3", "dacc86159b260ee98eb5f57941c99ba731a01399", "2018-07-28"), + ("v0.9.2", "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", "2017-11-11"), + ("v0.9.1", "46e9032e18a819e466618c7a014bcb0e9981af9e", "2017-11-11"), +] + + +@pytest.fixture # type: ignore +def gitcommits() -> list: + commits = [ + git.GitCommit( + commit["rev"], + commit["title"], + commit["body"], + commit["author"], + commit["author_email"], + ) + for commit in COMMITS_DATA + ] + return commits + + +@pytest.fixture # type: ignore +def tags() -> list: + tags = [git.GitTag(*tag) for tag in TAGS] + return tags + + +@pytest.fixture # type: ignore +def changelog_content() -> str: + changelog_path = "tests/CHANGELOG_FOR_TEST.md" + with open(changelog_path, "r") as f: + return f.read() + + +def test_get_commit_tag_is_a_version(gitcommits, tags): + commit = gitcommits[0] + tag = git.GitTag(*TAGS[0]) + current_key = changelog.get_commit_tag(commit, tags) + assert current_key == tag + + +def test_get_commit_tag_is_None(gitcommits, tags): + commit = gitcommits[1] + current_key = changelog.get_commit_tag(commit, tags) + assert current_key is None + + +COMMITS_TREE = ( + { + "version": "v1.2.0", + "date": "2019-04-19", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "custom cz plugins now support bumping version", + } + ] + }, + }, + { + "version": "v1.1.1", + "date": "2019-04-18", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "changed stdout statements", + }, + { + "scope": "schema", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "info", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "example", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "commit", + "breaking": None, + "message": "moved most of the commit logic to the commit command", + }, + ], + "fix": [ + { + "scope": "bump", + "breaking": None, + "message": "commit message now fits better with semver", + }, + { + "scope": None, + "breaking": None, + "message": "conventional commit 'breaking change' in body instead of title", + }, + ], + }, + }, + { + "version": "v1.1.0", + "date": "2019-04-14", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "new working bump command", + }, + {"scope": None, "breaking": None, "message": "create version tag"}, + { + "scope": None, + "breaking": None, + "message": "update given files with new version", + }, + { + "scope": "config", + "breaking": None, + "message": "new set key, used to set version to cfg", + }, + { + "scope": None, + "breaking": None, + "message": "support for pyproject.toml", + }, + { + "scope": None, + "breaking": None, + "message": "first semantic version bump implementation", + }, + ], + "fix": [ + { + "scope": None, + "breaking": None, + "message": "removed all from commit", + }, + { + "scope": None, + "breaking": None, + "message": "fix config file not working", + }, + ], + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "added commands folder, better integration with decli", + } + ], + }, + }, + { + "version": "v1.0.0", + "date": "2019-03-01", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "removed delegator, added decli and many tests", + } + ], + "BREAKING CHANGE": [ + {"scope": None, "breaking": None, "message": "API is stable"} + ], + }, + }, + {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, + { + "version": "v1.0.0b1", + "date": "2019-01-17", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "py3 only, tests and conventional commits 1.0", + } + ] + }, + }, + { + "version": "v0.9.11", + "date": "2018-12-17", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "load config reads in order without failing if there is no commitizen section", + } + ] + }, + }, + { + "version": "v0.9.10", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "parse scope (this is my punishment for not having tests)", + } + ] + }, + }, + { + "version": "v0.9.9", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": None, "breaking": None, "message": "parse scope empty"}] + }, + }, + { + "version": "v0.9.8", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": "scope", + "breaking": None, + "message": "parse correctly again", + } + ] + }, + }, + { + "version": "v0.9.7", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": "scope", "breaking": None, "message": "parse correctly"}] + }, + }, + { + "version": "v0.9.6", + "date": "2018-09-19", + "changes": { + "refactor": [ + { + "scope": "conventionalCommit", + "breaking": None, + "message": "moved filters to questions instead of message", + } + ], + "fix": [ + { + "scope": "manifest", + "breaking": None, + "message": "included missing files", + } + ], + }, + }, + { + "version": "v0.9.5", + "date": "2018-08-24", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "home path for python versions between 3.0 and 3.5", + } + ] + }, + }, + { + "version": "v0.9.4", + "date": "2018-08-02", + "changes": { + "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] + }, + }, + { + "version": "v0.9.3", + "date": "2018-07-28", + "changes": { + "feat": [ + { + "scope": "committer", + "breaking": None, + "message": "conventional commit is a bit more intelligent now", + } + ] + }, + }, + { + "version": "v0.9.2", + "date": "2017-11-11", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "renamed conventional_changelog to conventional_commits, not backward compatible", + } + ] + }, + }, + { + "version": "v0.9.1", + "date": "2017-11-11", + "changes": { + "fix": [ + { + "scope": "setup.py", + "breaking": None, + "message": "future is now required for every python version", + } + ] + }, + }, +) + + +def test_generate_tree_from_commits(gitcommits, tags): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + + assert tuple(tree) == COMMITS_TREE + + +@pytest.mark.parametrize( + "change_type_order, expected_reordering", + ( + ([], {}), + ( + ["BREAKING CHANGE", "refactor"], + { + "1.1.0": { + "original": ["feat", "fix", "refactor"], + "sorted": ["refactor", "feat", "fix"], + }, + "1.0.0": { + "original": ["refactor", "BREAKING CHANGE"], + "sorted": ["BREAKING CHANGE", "refactor"], + }, + }, + ), + ), +) +def test_order_changelog_tree(change_type_order, expected_reordering): + tree = changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + for index, entry in enumerate(tuple(tree)): + version = tree[index]["version"] + if version in expected_reordering: + # Verify that all keys are present + assert [*tree[index].keys()] == [*COMMITS_TREE[index].keys()] + # Verify that the reorder only impacted the returned dict and not the original + expected = expected_reordering[version] + assert [*tree[index]["changes"].keys()] == expected["sorted"] + assert [*COMMITS_TREE[index]["changes"].keys()] == expected["original"] + else: + assert [*entry["changes"].keys()] == [*tree[index]["changes"].keys()] + + +def test_order_changelog_tree_raises(): + change_type_order = ["BREAKING CHANGE", "feat", "refactor", "feat"] + with pytest.raises(InvalidConfigurationError) as excinfo: + changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + assert "Change types contain duplicates types" in str(excinfo) + + +def test_render_changelog(gitcommits, tags, changelog_content): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + assert result == changelog_content + + +def test_render_changelog_unreleased(gitcommits): + some_commits = gitcommits[:7] + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + some_commits, [], parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + assert "Unreleased" in result + + +def test_render_changelog_tag_and_unreleased(gitcommits, tags): + some_commits = gitcommits[:7] + single_tag = [ + tag for tag in tags if tag.rev == "56c8a8da84e42b526bcbe130bd194306f7c7e813" + ] + + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + some_commits, single_tag, parser, changelog_pattern + ) + result = changelog.render_changelog(tree) + + assert "Unreleased" in result + assert "## v1.1.1" in result + + +def test_render_changelog_with_change_type(gitcommits, tags): + new_title = ":some-emoji: feature" + change_type_map = {"feat": new_title} + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, change_type_map=change_type_map + ) + result = changelog.render_changelog(tree) + assert new_title in result + + +def test_render_changelog_with_changelog_message_builder_hook(gitcommits, tags): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit) -> dict: + message[ + "message" + ] = f"{message['message']} [link](github.com/232323232) {commit.author} {commit.author_email}" + return message + + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree) + + assert "[link](github.com/232323232) Commitizen author@cz.dev" in result diff --git a/tests/test_changelog_meta.py b/tests/test_changelog_meta.py new file mode 100644 index 0000000000..505daefb83 --- /dev/null +++ b/tests/test_changelog_meta.py @@ -0,0 +1,165 @@ +import os + +import pytest + +from commitizen import changelog + +CHANGELOG_A = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +""".strip() + +CHANGELOG_B = """ +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## 1.2.0 +""".strip() + +CHANGELOG_C = """ +# Unreleased + +## v1.0.0 +""" + +CHANGELOG_D = """ +## Unreleased +- Start using "changelog" over "change log" since it's the common usage. +""" + + +@pytest.fixture +def changelog_a_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_A) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_b_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_B) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_c_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_C) + + yield changelog_path + + os.remove(changelog_path) + + +@pytest.fixture +def changelog_d_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_D) + + yield changelog_path + + os.remove(changelog_path) + + +VERSIONS_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", "1.0.0"), + ( + "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)", + "10.0.0-next.3", + ), + ("### 0.19.1 (Jan 7, 2020)", "0.19.1"), + ("## 1.0.0", "1.0.0"), + ("## v1.0.0", "1.0.0"), + ("## v1.0.0 - (2012-24-32)", "1.0.0"), + ("# version 2020.03.24", "2020.03.24"), + ("## [Unreleased]", None), + ("All notable changes to this project will be documented in this file.", None), + ("# Changelog", None), + ("### Bug Fixes", None), +] + + +@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES) +def test_changelog_detect_version(line_from_changelog, output_version): + version = changelog.parse_version_from_markdown(line_from_changelog) + assert version == output_version + + +TITLES_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", "##"), + ("## [Unreleased]", "##"), + ("# Unreleased", "#"), +] + + +@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES) +def test_parse_title_type_of_line(line_from_changelog, output_title): + title = changelog.parse_title_type_of_line(line_from_changelog) + assert title == output_title + + +def test_get_metadata_from_a(changelog_a_file): + meta = changelog.get_metadata(changelog_a_file) + assert meta == { + "latest_version": "1.0.0", + "latest_version_position": 10, + "unreleased_end": 10, + "unreleased_start": 7, + } + + +def test_get_metadata_from_b(changelog_b_file): + meta = changelog.get_metadata(changelog_b_file) + assert meta == { + "latest_version": "1.2.0", + "latest_version_position": 3, + "unreleased_end": 3, + "unreleased_start": 0, + } + + +def test_get_metadata_from_c(changelog_c_file): + meta = changelog.get_metadata(changelog_c_file) + assert meta == { + "latest_version": "1.0.0", + "latest_version_position": 3, + "unreleased_end": 3, + "unreleased_start": 1, + } + + +def test_get_metadata_from_d(changelog_d_file): + meta = changelog.get_metadata(changelog_d_file) + assert meta == { + "latest_version": None, + "latest_version_position": None, + "unreleased_end": 2, + "unreleased_start": 1, + } diff --git a/tests/test_changelog_parser.py b/tests/test_changelog_parser.py new file mode 100644 index 0000000000..438b2f766d --- /dev/null +++ b/tests/test_changelog_parser.py @@ -0,0 +1,188 @@ +import os + +import pytest + +from commitizen import changelog_parser + +CHANGELOG_TEMPLATE = """ +## 1.0.0 (2019-07-12) + +### Fix + +- issue in poetry add preventing the installation in py36 +- **users**: lorem ipsum apap + +### Feat + +- it is possible to specify a pattern to be matched in configuration files bump. + +## 0.9 (2019-07-11) + +### Fix + +- holis + +""" + + +@pytest.fixture # type: ignore +def changelog_content() -> str: + changelog_path = "tests/CHANGELOG_FOR_TEST.md" + with open(changelog_path, "r") as f: + return f.read() + + +@pytest.fixture +def existing_changelog_file(): + changelog_path = "tests/CHANGELOG.md" + + with open(changelog_path, "w") as f: + f.write(CHANGELOG_TEMPLATE) + + yield changelog_path + + os.remove(changelog_path) + + +def test_read_changelog_blocks(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + blocks = list(blocks) + amount_of_blocks = len(blocks) + assert amount_of_blocks == 2 + + +VERSION_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {"version": "1.0.0", "date": "2019-07-12"}), + ("## 2.3.0a0", {"version": "2.3.0a0", "date": None}), + ("## 0.10.0a0", {"version": "0.10.0a0", "date": None}), + ("## 1.0.0rc0", {"version": "1.0.0rc0", "date": None}), + ("## 1beta", {"version": "1beta", "date": None}), + ( + "## 1.0.0rc1+e20d7b57f3eb (2019-3-24)", + {"version": "1.0.0rc1+e20d7b57f3eb", "date": "2019-3-24"}, + ), + ("### Bug fixes", {}), + ("- issue in poetry add preventing the installation in py36", {}), +] + +CATEGORIES_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {}), + ("## 2.3.0a0", {}), + ("### Bug fixes", {"change_type": "Bug fixes"}), + ("### Features", {"change_type": "Features"}), + ("- issue in poetry add preventing the installation in py36", {}), +] + +CATEGORIES_TRANSFORMATIONS: list = [ + ("Bug fixes", "fix"), + ("Features", "feat"), + ("BREAKING CHANGES", "BREAKING CHANGES"), +] + +MESSAGES_CASES: list = [ + ("## 1.0.0 (2019-07-12)", {}), + ("## 2.3.0a0", {}), + ("### Fix", {}), + ( + "- name no longer accept invalid chars", + { + "message": "name no longer accept invalid chars", + "scope": None, + "breaking": None, + }, + ), + ( + "- **users**: lorem ipsum apap", + {"message": "lorem ipsum apap", "scope": "users", "breaking": None}, + ), +] + + +@pytest.mark.parametrize("test_input,expected", VERSION_CASES) +def test_parse_md_version(test_input, expected): + assert changelog_parser.parse_md_version(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", CATEGORIES_CASES) +def test_parse_md_change_type(test_input, expected): + assert changelog_parser.parse_md_change_type(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", CATEGORIES_TRANSFORMATIONS) +def test_transform_change_type(test_input, expected): + assert changelog_parser.transform_change_type(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", MESSAGES_CASES) +def test_parse_md_message(test_input, expected): + assert changelog_parser.parse_md_message(test_input) == expected + + +def test_transform_change_type_fail(): + with pytest.raises(ValueError) as excinfo: + changelog_parser.transform_change_type("Bugs") + assert "Could not match a change_type" in str(excinfo.value) + + +def test_generate_block_tree(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + block = next(blocks) + tree = changelog_parser.generate_block_tree(block) + assert tree == { + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "issue in poetry add preventing the installation in py36", + }, + {"scope": "users", "breaking": None, "message": "lorem ipsum apap"}, + ], + "feat": [ + { + "scope": None, + "breaking": None, + "message": ( + "it is possible to specify a pattern to be matched " + "in configuration files bump." + ), + } + ], + }, + "version": "1.0.0", + "date": "2019-07-12", + } + + +def test_generate_full_tree(existing_changelog_file): + blocks = changelog_parser.find_version_blocks(existing_changelog_file) + tree = list(changelog_parser.generate_full_tree(blocks)) + + assert tree == [ + { + "changes": { + "fix": [ + { + "scope": None, + "message": "issue in poetry add preventing the installation in py36", + "breaking": None, + }, + {"scope": "users", "message": "lorem ipsum apap", "breaking": None}, + ], + "feat": [ + { + "scope": None, + "message": "it is possible to specify a pattern to be matched in configuration files bump.", + "breaking": None, + } + ], + }, + "version": "1.0.0", + "date": "2019-07-12", + }, + { + "changes": {"fix": [{"scope": None, "message": "holis", "breaking": None}]}, + "version": "0.9", + "date": "2019-07-11", + }, + ] diff --git a/tests/test_cli.py b/tests/test_cli.py index e136096f37..2b73bbd5cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,29 +1,29 @@ +import subprocess import sys import pytest from commitizen import cli -from commitizen.__version__ import __version__ +from commitizen.exceptions import ExpectedExit, NoCommandFoundError, NotAGitProjectError def test_sysexit_no_argv(mocker, capsys): testargs = ["cz"] mocker.patch.object(sys, "argv", testargs) - with pytest.raises(SystemExit): + with pytest.raises(ExpectedExit): cli.main() out, _ = capsys.readouterr() assert out.startswith("usage") -def test_cz_with_arg_but_without_command(mocker, capsys): +def test_cz_with_arg_but_without_command(mocker): testargs = ["cz", "--name", "cz_jira"] mocker.patch.object(sys, "argv", testargs) - with pytest.raises(SystemExit): + with pytest.raises(NoCommandFoundError) as excinfo: cli.main() - _, err = capsys.readouterr() - assert "Command is required" in err + assert "Command is required" in str(excinfo.value) def test_name(mocker, capsys): @@ -55,10 +55,40 @@ def test_ls(mocker, capsys): assert isinstance(out, str) -def test_version(mocker, capsys): - testargs = ["cz", "--version"] +def test_arg_debug(mocker): + testargs = ["cz", "--debug", "info"] mocker.patch.object(sys, "argv", testargs) - cli.main() - out, _ = capsys.readouterr() - assert out.strip() == __version__ + assert sys.excepthook.keywords.get("debug") is True + + +def test_commitizen_excepthook(capsys): + with pytest.raises(SystemExit) as excinfo: + cli.commitizen_excepthook(NotAGitProjectError, NotAGitProjectError(), "") + + assert excinfo.type == SystemExit + assert excinfo.value.code == NotAGitProjectError.exit_code + + +def test_commitizen_debug_excepthook(capsys): + with pytest.raises(SystemExit) as excinfo: + cli.commitizen_excepthook( + NotAGitProjectError, NotAGitProjectError(), "", debug=True, + ) + + assert excinfo.type == SystemExit + assert excinfo.value.code == NotAGitProjectError.exit_code + assert "NotAGitProjectError" in str(excinfo.traceback[0]) + + +def test_argcomplete_activation(): + """ + This function is testing the one-time activation of argcomplete for + commitizen only. + + Equivalent to run: + $ eval "$(register-python-argcomplete pytest)" + """ + output = subprocess.run(["register-python-argcomplete", "cz"]) + + assert output.returncode == 0 diff --git a/tests/test_conf.py b/tests/test_conf.py index 5320051044..786af25756 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -1,11 +1,12 @@ +import json import os from pathlib import Path import pytest +import yaml from commitizen import config, defaults, git - PYPROJECT = """ [tool.commitizen] name = "cz_jira" @@ -24,19 +25,15 @@ target-version = ['py36', 'py37', 'py38'] """ -RAW_CONFIG = """ -[commitizen] -name = cz_jira -version = 1.0.0 -version_files = [ - "commitizen/__version__.py", - "pyproject.toml" - ] -style = [ - ["pointer", "reverse"], - ["question", "underline"] - ] -""" +DICT_CONFIG = { + "commitizen": { + "name": "cz_jira", + "version": "1.0.0", + "version_files": ["commitizen/__version__.py", "pyproject.toml"], + "style": [["pointer", "reverse"], ["question", "underline"]], + } +} + _settings = { "name": "cz_jira", @@ -45,6 +42,11 @@ "bump_message": None, "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], + "changelog_file": "CHANGELOG.md", + "changelog_incremental": False, + "changelog_start_rev": None, + "update_changelog_on_bump": False, + "use_shortcuts": False, } _new_settings = { @@ -54,6 +56,11 @@ "bump_message": None, "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], + "changelog_file": "CHANGELOG.md", + "changelog_incremental": False, + "changelog_start_rev": None, + "update_changelog_on_bump": False, + "use_shortcuts": False, } _read_settings = { @@ -61,86 +68,63 @@ "version": "1.0.0", "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], + "changelog_file": "CHANGELOG.md", } @pytest.fixture -def configure_supported_files(): - original = defaults.config_files.copy() +def config_files_manager(request, tmpdir): + with tmpdir.as_cwd(): + filename = request.param + with open(filename, "w") as f: + if "toml" in filename: + f.write(PYPROJECT) + elif "json" in filename: + json.dump(DICT_CONFIG, f) + elif "yaml" in filename: + yaml.dump(DICT_CONFIG, f) + yield - # patch the defaults to include tests - defaults.config_files = [os.path.join("tests", f) for f in defaults.config_files] - yield - defaults.config_files = original - - -@pytest.fixture -def config_files_manager(request): - filename = request.param - filepath = os.path.join("tests", filename) - with open(filepath, "w") as f: - if "toml" in filename: - f.write(PYPROJECT) - else: - f.write(RAW_CONFIG) - yield - os.remove(filepath) - - -@pytest.fixture -def empty_pyproject_ok_cz(): - pyproject = "tests/pyproject.toml" - cz = "tests/.cz" - with open(pyproject, "w") as f: - f.write("") - with open(cz, "w") as f: - f.write(RAW_CONFIG) - yield - os.remove(pyproject) - os.remove(cz) - - -@pytest.mark.parametrize( - "config_files_manager", defaults.config_files.copy(), indirect=True -) -def test_load_conf(config_files_manager, configure_supported_files): - cfg = config.read_cfg() - assert cfg.settings == _settings - - -def test_conf_is_loaded_with_empty_pyproject_but_ok_cz( - empty_pyproject_ok_cz, configure_supported_files -): - cfg = config.read_cfg() - assert cfg.settings == _settings +def test_find_git_project_root(tmpdir): + assert git.find_git_project_root() == Path(os.getcwd()) -def test_conf_returns_default_when_no_files(configure_supported_files): - cfg = config.read_cfg() - assert cfg.settings == defaults.DEFAULT_SETTINGS + with tmpdir.as_cwd() as _: + assert git.find_git_project_root() is None @pytest.mark.parametrize( "config_files_manager", defaults.config_files.copy(), indirect=True ) -def test_set_key(configure_supported_files, config_files_manager): +def test_set_key(config_files_manager): _conf = config.read_cfg() _conf.set_key("version", "2.0.0") cfg = config.read_cfg() assert cfg.settings == _new_settings -def test_find_git_project_root(tmpdir): - assert git.find_git_project_root() == Path(os.getcwd()) +class TestReadCfg: + @pytest.mark.parametrize( + "config_files_manager", defaults.config_files.copy(), indirect=True + ) + def test_load_conf(_, config_files_manager): + cfg = config.read_cfg() + assert cfg.settings == _settings - with tmpdir.as_cwd() as _: - assert git.find_git_project_root() is None + def test_conf_returns_default_when_no_files(_, tmpdir): + with tmpdir.as_cwd(): + cfg = config.read_cfg() + assert cfg.settings == defaults.DEFAULT_SETTINGS + def test_load_empty_pyproject_toml_and_cz_toml_with_config(_, tmpdir): + with tmpdir.as_cwd(): + p = tmpdir.join("pyproject.toml") + p.write("") + p = tmpdir.join(".cz.toml") + p.write(PYPROJECT) -def test_read_cfg_when_not_in_a_git_project(tmpdir): - with tmpdir.as_cwd() as _: - with pytest.raises(SystemExit): - config.read_cfg() + cfg = config.read_cfg() + assert cfg.settings == _settings class TestTomlConfig: @@ -150,7 +134,7 @@ def test_init_empty_config_content(self, tmpdir): toml_config.init_empty_config_content() with open(path, "r") as toml_file: - assert toml_file.read() == "[tool.commitizen]" + assert toml_file.read() == "[tool]\n[tool.commitizen]\n" def test_init_empty_config_content_with_existing_content(self, tmpdir): existing_content = "[tool.black]\n" "line-length = 88\n" @@ -161,4 +145,14 @@ def test_init_empty_config_content_with_existing_content(self, tmpdir): toml_config.init_empty_config_content() with open(path, "r") as toml_file: - assert toml_file.read() == existing_content + "[tool.commitizen]" + assert toml_file.read() == existing_content + "\n[tool.commitizen]\n" + + +class TestJsonConfig: + def test_init_empty_config_content(self, tmpdir): + path = tmpdir.mkdir("commitizen").join(".cz.json") + json_config = config.JsonConfig(data="{}", path=path) + json_config.init_empty_config_content() + + with open(path, "r") as json_file: + assert json.load(json_file) == {"commitizen": {}} diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 82925388ef..04cc7d9e83 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -1,8 +1,8 @@ import pytest from commitizen import defaults -from commitizen.cz.base import BaseCommitizen from commitizen.config import BaseConfig +from commitizen.cz.base import BaseCommitizen @pytest.fixture() @@ -51,3 +51,9 @@ def test_info(config): cz = DummyCz(config) with pytest.raises(NotImplementedError): cz.info() + + +def test_process_commit(config): + cz = DummyCz(config) + message = cz.process_commit("test(test_scope): this is test msg") + assert message == "test(test_scope): this is test msg" diff --git a/tests/test_cz_conventional_commits.py b/tests/test_cz_conventional_commits.py index dd6cfbff30..b84144e94c 100644 --- a/tests/test_cz_conventional_commits.py +++ b/tests/test_cz_conventional_commits.py @@ -1,14 +1,13 @@ import pytest from commitizen import defaults +from commitizen.config import BaseConfig from commitizen.cz.conventional_commits.conventional_commits import ( ConventionalCommitsCz, parse_scope, parse_subject, ) from commitizen.cz.exceptions import AnswerRequiredError -from commitizen.config import BaseConfig - valid_scopes = ["", "simple", "dash-separated", "camelCase" "UPPERCASE"] @@ -63,6 +62,15 @@ def test_questions(config): assert isinstance(questions[0], dict) +def test_choices_all_have_keyboard_shortcuts(config): + conventional_commits = ConventionalCommitsCz(config) + questions = conventional_commits.questions() + + list_questions = (q for q in questions if q["type"] == "list") + for select in list_questions: + assert all("key" in choice for choice in select["choices"]) + + def test_small_answer(config): conventional_commits = ConventionalCommitsCz(config) answers = { @@ -83,14 +91,32 @@ def test_long_answer(config): "prefix": "fix", "scope": "users", "subject": "email pattern corrected", - "is_breaking_change": True, + "is_breaking_change": False, "body": "complete content", "footer": "closes #24", } message = conventional_commits.message(answers) assert ( message - == "fix(users): email pattern corrected\n\nBREAKING CHANGE: complete content\n\ncloses #24" # noqa + == "fix(users): email pattern corrected\n\ncomplete content\n\ncloses #24" # noqa + ) + + +def test_breaking_change_in_footer(config): + conventional_commits = ConventionalCommitsCz(config) + answers = { + "prefix": "fix", + "scope": "users", + "subject": "email pattern corrected", + "is_breaking_change": True, + "body": "complete content", + "footer": "migrate by renaming user to users", + } + message = conventional_commits.message(answers) + print(message) + assert ( + message + == "fix(users): email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users" # noqa ) @@ -113,3 +139,17 @@ def test_info(config): conventional_commits = ConventionalCommitsCz(config) info = conventional_commits.info() assert isinstance(info, str) + + +@pytest.mark.parametrize( + ("commit_message", "expected_message"), + [ + ("test(test_scope): this is test msg", "this is test msg",), + ("test(test_scope)!: this is test msg", "this is test msg",), + ("test!(test_scope): this is test msg", "",), + ], +) +def test_process_commit(commit_message, expected_message, config): + conventional_commits = ConventionalCommitsCz(config) + message = conventional_commits.process_commit(commit_message) + assert message == expected_message diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 3ec448cc32..e919093e04 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -1,18 +1,19 @@ import pytest -from commitizen.config import TomlConfig +from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig from commitizen.cz.customize import CustomizeCommitsCz +from commitizen.exceptions import MissingCzCustomizeConfigError - -@pytest.fixture(scope="module") -def config(): - toml_str = r""" +TOML_STR = r""" [tool.commitizen.customize] message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" - example = "feature: this feature eanable customize through config file" + example = "feature: this feature enable customize through config file" schema = "<type>: <body>" + schema_pattern = "(feature|bug fix):(\\s.*)" + bump_pattern = "^(break|new|fix|hotfix)" bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] info = "This is a customized cz." [[tool.commitizen.customize.questions]] @@ -33,8 +34,232 @@ def config(): type = "confirm" name = "show_message" message = "Do you want to add body message in commit?" +""" + +JSON_STR = r""" + { + "commitizen": { + "name": "cz_jira", + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml" + ], + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enable customize through config file", + "schema": "<type>: <body>", + "schema_pattern": "(feature|bug fix):(\\s.*)", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], + "info": "This is a customized cz.", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "feature", + "name": "feature: A new feature." + }, + { + "value": "bug fix", + "name": "bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } + } +""" + +YAML_STR = """ +commitizen: + name: cz_jira + version: 1.0.0 + version_files: + - commitizen/__version__.py + - pyproject.toml + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enable customize through config file' + schema: "<type>: <body>" + schema_pattern: "(feature|bug fix):(\\s.*)" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + change_type_order: ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info: This is a customized cz. + questions: + - type: list + name: change_type + choices: + - value: feature + name: 'feature: A new feature.' + - value: bug fix + name: 'bug fix: A bug fix.' + message: Select the type of change you are committing + - type: input + name: message + message: Body. + - type: confirm + name: show_message + message: Do you want to add body message in commit? +""" + + +TOML_STR_INFO_PATH = """ + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enable customize through config file" + schema = "<type>: <body>" + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + info_path = "info.txt" +""" + +JSON_STR_INFO_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enable customize through config file", + "schema": "<type>: <body>", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "info_path": "info.txt" + } + } + } +""" + +YAML_STR_INFO_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enable customize through config file' + schema: "<type>: <body>" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + info_path: info.txt +""" + +TOML_STR_WITHOUT_INFO = """ + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enable customize through config file" + schema = "<type>: <body>" + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} +""" + +JSON_STR_WITHOUT_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enable customize through config file", + "schema": "<type>: <body>", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + } + } + } + } +""" + +YAML_STR_WITHOUT_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enable customize through config file' + schema: "<type>: <body>" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH +""" + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR, path="not_exist.toml"), + JsonConfig(data=JSON_STR, path="not_exist.json"), + ] +) +def config(request): + """Parametrize the config fixture + + This fixture allow to test multiple config formats, + without add the builtin parametrize decorator """ - return TomlConfig(data=toml_str, path="not_exist.toml") + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR_INFO_PATH, path="not_exist.toml"), + JsonConfig(data=JSON_STR_INFO_PATH, path="not_exist.json"), + YAMLConfig(data=YAML_STR_INFO_PATH, path="not_exist.yaml"), + ] +) +def config_info(request): + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR_WITHOUT_INFO, path="not_exist.toml"), + JsonConfig(data=JSON_STR_WITHOUT_PATH, path="not_exist.json"), + YAMLConfig(data=YAML_STR_WITHOUT_PATH, path="not_exist.yaml"), + ] +) +def config_without_info(request): + return request.param + + +def test_initialize_cz_customize_failed(): + with pytest.raises(MissingCzCustomizeConfigError) as excinfo: + config = BaseConfig() + _ = CustomizeCommitsCz(config) + + assert MissingCzCustomizeConfigError.message in str(excinfo.value) def test_bump_pattern(config): @@ -52,6 +277,17 @@ def test_bump_map(config): } +def test_change_type_order(config): + cz = CustomizeCommitsCz(config) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + def test_questions(config): cz = CustomizeCommitsCz(config) questions = cz.questions() @@ -79,16 +315,16 @@ def test_answer(config): cz = CustomizeCommitsCz(config) answers = { "change_type": "feature", - "message": "this feature eanable customize through config file", + "message": "this feature enaable customize through config file", "show_message": True, } message = cz.message(answers) - assert message == "feature: this feature eanable customize through config file" + assert message == "feature: this feature enaable customize through config file" cz = CustomizeCommitsCz(config) answers = { "change_type": "feature", - "message": "this feature eanable customize through config file", + "message": "this feature enaable customize through config file", "show_message": False, } message = cz.message(answers) @@ -97,7 +333,7 @@ def test_answer(config): def test_example(config): cz = CustomizeCommitsCz(config) - assert "feature: this feature eanable customize through config file" in cz.example() + assert "feature: this feature enable customize through config file" in cz.example() def test_schema(config): @@ -105,6 +341,25 @@ def test_schema(config): assert "<type>: <body>" in cz.schema() +def test_schema_pattern(config): + cz = CustomizeCommitsCz(config) + assert r"(feature|bug fix):(\s.*)" in cz.schema_pattern() + + def test_info(config): cz = CustomizeCommitsCz(config) assert "This is a customized cz." in cz.info() + + +def test_info_with_info_path(tmpdir, config_info): + with tmpdir.as_cwd(): + tmpfile = tmpdir.join("info.txt") + tmpfile.write("Test info") + + cz = CustomizeCommitsCz(config_info) + assert "Test info" in cz.info() + + +def test_info_without_info(config_without_info): + cz = CustomizeCommitsCz(config_without_info) + assert cz.info() is None diff --git a/tests/test_cz_jira.py b/tests/test_cz_jira.py index 0c05b46a3c..b725a46fa9 100644 --- a/tests/test_cz_jira.py +++ b/tests/test_cz_jira.py @@ -1,8 +1,8 @@ import pytest from commitizen import defaults -from commitizen.cz.jira import JiraSmartCz from commitizen.config import BaseConfig +from commitizen.cz.jira import JiraSmartCz @pytest.fixture() diff --git a/tests/test_cz_utils.py b/tests/test_cz_utils.py index a8a56d762c..94cc7f5b87 100644 --- a/tests/test_cz_utils.py +++ b/tests/test_cz_utils.py @@ -1,6 +1,6 @@ import pytest -from commitizen.cz import utils, exceptions +from commitizen.cz import exceptions, utils def test_required_validator(): diff --git a/tests/test_factory.py b/tests/test_factory.py index 5c5da7b139..1a2eb7178d 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -2,6 +2,7 @@ from commitizen import BaseCommitizen, defaults, factory from commitizen.config import BaseConfig +from commitizen.exceptions import NoCommitizenFoundException def test_factory(): @@ -14,5 +15,7 @@ def test_factory(): def test_factory_fails(): config = BaseConfig() config.settings.update({"name": "Nothing"}) - with pytest.raises(SystemExit): + with pytest.raises(NoCommitizenFoundException) as excinfo: factory.commiter_factory(config) + + assert "The committer has not been found in the system." in str(excinfo) diff --git a/tests/test_git.py b/tests/test_git.py index ef7cb2cdc9..c764a6d65a 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,10 +1,11 @@ -from commitizen import git +import inspect +import shutil +from typing import List, Optional +import pytest -class FakeCommand: - def __init__(self, out=None, err=None): - self.out = out - self.err = err +from commitizen import cmd, git +from tests.utils import FakeCommand, create_file_and_commit def test_git_object_eq(): @@ -19,9 +20,9 @@ def test_git_object_eq(): def test_get_tags(mocker): tag_str = ( - "v1.0.0---inner_delimiter---333---inner_delimiter---2020-01-20\n" - "v0.5.0---inner_delimiter---222---inner_delimiter---2020-01-17\n" - "v0.0.1---inner_delimiter---111---inner_delimiter---2020-01-17\n" + "v1.0.0---inner_delimiter---333---inner_delimiter---2020-01-20---inner_delimiter---\n" + "v0.5.0---inner_delimiter---222---inner_delimiter---2020-01-17---inner_delimiter---\n" + "v0.0.1---inner_delimiter---111---inner_delimiter---2020-01-17---inner_delimiter---\n" ) mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str)) @@ -47,3 +48,158 @@ def test_get_tag_names(mocker): "commitizen.cmd.run", return_value=FakeCommand(out="", err="No tag available") ) assert git.get_tag_names() == [] + + +def test_git_message_with_empty_body(): + commit_title = "Some Title" + commit = git.GitCommit("test_rev", "Some Title", body="") + + assert commit.message == commit_title + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_commits(): + create_file_and_commit("feat(users): add username") + create_file_and_commit("fix: username exception") + commits = git.get_commits() + assert len(commits) == 2 + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_commits_author_and_email(): + create_file_and_commit("fix: username exception") + commit = git.get_commits()[0] + + assert commit.author != "" + assert "@" in commit.author_email + + +def test_get_commits_without_email(mocker): + raw_commit = ( + "a515bb8f71c403f6f7d1c17b9d8ebf2ce3959395\n" + "\n" + "user name\n" + "\n" + "----------commit-delimiter----------\n" + "12d3b4bdaa996ea7067a07660bb5df4772297bdd\n" + "feat(users): add username\n" + "user name\n" + "\n" + "----------commit-delimiter----------\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=raw_commit)) + + commits = git.get_commits() + + assert commits[0].author == "user name" + assert commits[1].author == "user name" + + assert commits[0].author_email == "" + assert commits[1].author_email == "" + + assert commits[0].title == "" + assert commits[1].title == "feat(users): add username" + + +def test_get_commits_without_breakline_in_each_commit(mocker): + raw_commit = ( + "ae9ba6fc5526cf478f52ef901418d85505109744\n" + "bump: version 2.13.0 โ†’ 2.14.0\n" + "GitHub Action\n" + "action@github.com\n" + "----------commit-delimiter----------\n" + "ff2f56ca844de72a9d59590831087bf5a97bac84\n" + "Merge pull request #332 from cliles/feature/271-redux\n" + "User\n" + "user@email.com\n" + "Feature/271 redux----------commit-delimiter----------\n" + "20a54bf1b82cd7b573351db4d1e8814dd0be205d\n" + "feat(#271): enable creation of annotated tags when bumping\n" + "User 2\n" + "user@email.edu\n" + "----------commit-delimiter----------\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=raw_commit)) + + commits = git.get_commits() + + assert commits[0].author == "GitHub Action" + assert commits[1].author == "User" + assert commits[2].author == "User 2" + + assert commits[0].author_email == "action@github.com" + assert commits[1].author_email == "user@email.com" + assert commits[2].author_email == "user@email.edu" + + assert commits[0].title == "bump: version 2.13.0 โ†’ 2.14.0" + assert commits[1].title == "Merge pull request #332 from cliles/feature/271-redux" + assert ( + commits[2].title == "feat(#271): enable creation of annotated tags when bumping" + ) + + +def test_get_commits_with_signature(): + config_file = ".git/config" + config_backup = ".git/config.bak" + shutil.copy(config_file, config_backup) + + try: + # temporarily turn on --show-signature + cmd.run("git config log.showsignature true") + + # retrieve a commit that we know has a signature + commit = git.get_commits( + start="bec20ebf433f2281c70f1eb4b0b6a1d0ed83e9b2", + end="9eae518235d051f145807ddf971ceb79ad49953a", + )[0] + + assert commit.title.startswith("fix") + finally: + # restore the repo's original config + shutil.move(config_backup, config_file) + + +def test_get_tag_names_has_correct_arrow_annotation(): + arrow_annotation = inspect.getfullargspec(git.get_tag_names).annotations["return"] + + assert arrow_annotation == List[Optional[str]] + + +def test_get_latest_tag_name(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + tag_name = git.get_latest_tag_name() + assert tag_name is None + + create_file_and_commit("feat(test): test") + cmd.run("git tag 1.0") + tag_name = git.get_latest_tag_name() + assert tag_name == "1.0" + + +def test_is_staging_clean_when_adding_file(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + assert git.is_staging_clean() is True + + cmd.run("touch test_file") + + assert git.is_staging_clean() is True + + cmd.run("git add test_file") + + assert git.is_staging_clean() is False + + +def test_is_staging_clean_when_updating_file(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + assert git.is_staging_clean() is True + + cmd.run("touch test_file") + cmd.run("git add test_file") + cmd.run("git commit -m 'add test_file'") + cmd.run("echo 'test' > test_file") + + assert git.is_staging_clean() is True + + cmd.run("git add test_file") + + assert git.is_staging_clean() is False diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000000..7f5b2b87f3 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,21 @@ +import uuid +from pathlib import Path +from typing import Optional + +from commitizen import cmd, git + + +class FakeCommand: + def __init__(self, out=None, err=None, return_code=0): + self.out = out + self.err = err + self.return_code = return_code + + +def create_file_and_commit(message: str, filename: Optional[str] = None): + if not filename: + filename = str(uuid.uuid4()) + + Path(f"./{filename}").touch() + cmd.run("git add .") + git.commit(message)