Adding upstream version 4.6.0+dfsg.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
f3ad83a1a5
commit
167a3f8553
275 changed files with 30423 additions and 0 deletions
3
.codacy.yaml
Normal file
3
.codacy.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
exclude_paths:
|
||||||
|
- 'tests/**'
|
||||||
|
- 'docs/**'
|
7
.github/.codecov.yml
vendored
Normal file
7
.github/.codecov.yml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
# minimum of 97% (real 96%)
|
||||||
|
target: 97%
|
||||||
|
threshold: 1%
|
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* @woile @Lee-W @noirbizarre
|
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
open_collective: commitizen-tools
|
||||||
|
github: commitizen-tools
|
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
name: 🛠 Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: Good bug title tells us about precise symptom, not about the root cause.
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what the bug is
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Run ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
description: What happens actually so you think this is a bug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: desired-behavior
|
||||||
|
attributes:
|
||||||
|
label: Desired behavior
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: |
|
||||||
|
For older commitizen versions, please include the output of the following commands manually
|
||||||
|
placeholder: |
|
||||||
|
- commitizen version: `cz version`
|
||||||
|
- python version: `python --version`
|
||||||
|
- operating system: `python3 -c "import platform; print(platform.system())"`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz version --report
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: true
|
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# Configuration: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository
|
||||||
|
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security Contact
|
||||||
|
about: Please report security vulnerabilities to santiwilly@gmail.com
|
29
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
name: 📖 Documentation
|
||||||
|
description: Suggest an improvement for the documentation of this project
|
||||||
|
title: Content to be added or fixed
|
||||||
|
labels: [documentation]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: Type
|
||||||
|
options:
|
||||||
|
- label: Content inaccurate
|
||||||
|
- label: Content missing
|
||||||
|
- label: Typo
|
||||||
|
- type: input
|
||||||
|
id: url
|
||||||
|
attributes:
|
||||||
|
label: URL
|
||||||
|
placeholder: |
|
||||||
|
URL to the code we did not clearly describe or the document page where the content is inaccurate
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what content should be added or fixed
|
||||||
|
validations:
|
||||||
|
required: true
|
31
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: 🚀 Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "<One feature request per issue>"
|
||||||
|
labels: [feature]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: |
|
||||||
|
A clear and concise description for us to know your idea.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: possible-solution
|
||||||
|
attributes:
|
||||||
|
label: Possible Solution
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
- type: textarea
|
||||||
|
id: related-issue
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
If applicable, add link to existing issue also help us know better.
|
24
.github/dependabot.yml
vendored
Normal file
24
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
-
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
labels:
|
||||||
|
- dependencies
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
include: "scope"
|
||||||
|
-
|
||||||
|
# Maintain python dependencies
|
||||||
|
package-ecosystem: pip
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
labels:
|
||||||
|
- dependencies
|
||||||
|
commit-message:
|
||||||
|
prefix: "build"
|
||||||
|
include: "scope"
|
3
.github/labeler.yml
vendored
Normal file
3
.github/labeler.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
'pr-status: wait-for-review':
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: '**'
|
29
.github/pull_request_template.md
vendored
Normal file
29
.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!--
|
||||||
|
Thanks for sending a pull request!
|
||||||
|
Please fill in the following content to let us know better about this change.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Describe what the change is -->
|
||||||
|
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Add test cases to all the changes you introduce
|
||||||
|
- [ ] Run `poetry all` 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
|
||||||
|
<!-- A clear and concise description of what you expected to happen -->
|
||||||
|
|
||||||
|
|
||||||
|
## Steps to Test This Pull Request
|
||||||
|
<!-- Steps to reproduce the behavior:
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ... -->
|
||||||
|
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
<!-- Add any other RELATED ISSUE, context or screenshots about the pull request here. -->
|
29
.github/workflows/bumpversion.yml
vendored
Normal file
29
.github/workflows/bumpversion.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
name: Bump version
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump-version:
|
||||||
|
if: "!startsWith(github.event.head_commit.message, 'bump:')"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "Bump version and create changelog with commitizen"
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
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 }}
|
||||||
|
changelog_increment_filename: body.md
|
||||||
|
- name: Release
|
||||||
|
uses: ncipollo/release-action@v1
|
||||||
|
with:
|
||||||
|
tag: v${{ env.REVISION }}
|
||||||
|
bodyFile: "body.md"
|
||||||
|
skipIfReleaseExists: true
|
78
.github/workflows/docspublish.yml
vendored
Normal file
78
.github/workflows/docspublish.yml
vendored
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
name: Publish documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-cli-screenshots:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip poetry poethepoet
|
||||||
|
poetry --version
|
||||||
|
poetry install --only main,script
|
||||||
|
- name: Update CLI screenshots
|
||||||
|
run: |
|
||||||
|
poetry doc:screenshots
|
||||||
|
- name: Commit and push updated CLI screenshots
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add docs/images/cli_help
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
git commit -m "docs(cli/screenshots): update CLI screenshots" -m "[skip ci]"
|
||||||
|
git push
|
||||||
|
else
|
||||||
|
echo "No changes to commit. Skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish-documentation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: update-cli-screenshots
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Pull latest changes
|
||||||
|
run: |
|
||||||
|
git pull origin master
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip poetry poethepoet
|
||||||
|
poetry --version
|
||||||
|
poetry install --no-root --only documentation
|
||||||
|
- name: Build docs
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
poetry doc:build
|
||||||
|
- name: Generate Sponsors 💖
|
||||||
|
uses: JamesIves/github-sponsors-readme-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PERSONAL_ACCESS_TOKEN_FOR_ORG }}
|
||||||
|
file: "docs/README.md"
|
||||||
|
- name: Push doc to Github Page
|
||||||
|
uses: peaceiris/actions-gh-pages@v4
|
||||||
|
with:
|
||||||
|
personal_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
|
publish_branch: gh-pages
|
||||||
|
publish_dir: ./site
|
||||||
|
user_name: "github-actions[bot]"
|
||||||
|
user_email: "github-actions[bot]@users.noreply.github.com"
|
32
.github/workflows/homebrewpublish.yml
vendored
Normal file
32
.github/workflows/homebrewpublish.yml
vendored
Normal file
|
@ -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@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
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@v4
|
||||||
|
with:
|
||||||
|
token: ${{secrets.PERSONAL_ACCESS_TOKEN}}
|
||||||
|
formula: commitizen
|
||||||
|
tag: v${{ env.project_version }}
|
||||||
|
force: true
|
23
.github/workflows/label_issues.yml
vendored
Normal file
23
.github/workflows/label_issues.yml
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
name: Label issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label-issue:
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.issues.addLabels({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: ['issue-status: needs-triage']
|
||||||
|
})
|
19
.github/workflows/label_pr.yml
vendored
Normal file
19
.github/workflows/label_pr.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
name: "Label Pull Request"
|
||||||
|
on:
|
||||||
|
- pull_request_target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label-pr:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: |
|
||||||
|
.github/labeler.yml
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
- uses: actions/labeler@v5
|
||||||
|
with:
|
||||||
|
configuration-path: .github/labeler.yml
|
38
.github/workflows/pythonpackage.yml
vendored
Normal file
38
.github/workflows/pythonpackage.yml
vendored
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
name: Python package
|
||||||
|
|
||||||
|
on: [workflow_dispatch, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-check:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
platform: [ubuntu-20.04, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip poetry poethepoet
|
||||||
|
poetry --version
|
||||||
|
poetry install --only main,linters,test
|
||||||
|
- name: Run tests and linters
|
||||||
|
run: |
|
||||||
|
git config --global user.email "action@github.com"
|
||||||
|
git config --global user.name "GitHub Action"
|
||||||
|
poetry ci
|
||||||
|
shell: bash
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
files: ./coverage.xml
|
||||||
|
flags: unittests
|
||||||
|
name: codecov-umbrella
|
28
.github/workflows/pythonpublish.yml
vendored
Normal file
28
.github/workflows/pythonpublish.yml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
name: Upload Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip poetry
|
||||||
|
poetry --version
|
||||||
|
- name: Publish
|
||||||
|
env:
|
||||||
|
POETRY_HTTP_BASIC_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||||
|
POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||||
|
run: poetry publish --build
|
115
.gitignore
vendored
Normal file
115
.gitignore
vendored
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
.static_storage/
|
||||||
|
.media/
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# macOSX
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache
|
72
.pre-commit-config.yaml
Normal file
72
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
default_install_hook_types:
|
||||||
|
- pre-commit
|
||||||
|
- commit-msg
|
||||||
|
- pre-push
|
||||||
|
|
||||||
|
default_stages:
|
||||||
|
- pre-commit
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: meta
|
||||||
|
hooks:
|
||||||
|
- id: check-hooks-apply
|
||||||
|
- id: check-useless-excludes
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: check-vcs-permalinks
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
exclude: "tests/((commands|data|providers/test_uv_provider)/|test_).+"
|
||||||
|
- id: trailing-whitespace
|
||||||
|
args: [ --markdown-linebreak-ext=md ]
|
||||||
|
exclude: '\.svg$'
|
||||||
|
- id: debug-statements
|
||||||
|
- id: no-commit-to-branch
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-yaml
|
||||||
|
args: [ '--unsafe' ] # for mkdocs.yml
|
||||||
|
- id: detect-private-key
|
||||||
|
|
||||||
|
- repo: https://github.com/asottile/blacken-docs
|
||||||
|
rev: 1.19.1
|
||||||
|
hooks:
|
||||||
|
- id: blacken-docs
|
||||||
|
additional_dependencies: [ black~=23.11 ]
|
||||||
|
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.4.1
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
name: Run codespell to check for common misspellings in files
|
||||||
|
# config section is within pyproject.toml
|
||||||
|
language: python
|
||||||
|
types: [ text ]
|
||||||
|
args: [ "--write-changes" ]
|
||||||
|
additional_dependencies:
|
||||||
|
- tomli
|
||||||
|
|
||||||
|
- repo: https://github.com/commitizen-tools/commitizen
|
||||||
|
rev: v4.6.0 # automatically updated by Commitizen
|
||||||
|
hooks:
|
||||||
|
- id: commitizen
|
||||||
|
- id: commitizen-branch
|
||||||
|
stages:
|
||||||
|
- post-commit
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: format
|
||||||
|
name: Format
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
entry: poetry format
|
||||||
|
types: [ python ]
|
||||||
|
|
||||||
|
- id: linter and test
|
||||||
|
name: Linters
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
entry: poetry lint
|
||||||
|
types: [ python ]
|
27
.pre-commit-hooks.yaml
Normal file
27
.pre-commit-hooks.yaml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
- id: commitizen
|
||||||
|
name: commitizen check
|
||||||
|
description: >
|
||||||
|
Check whether the current commit message follows committing rules. Allow
|
||||||
|
empty commit messages by default, because they typically indicate to Git
|
||||||
|
that the commit should be aborted.
|
||||||
|
entry: cz check
|
||||||
|
args: [--allow-abort, --commit-msg-file]
|
||||||
|
stages: [commit-msg]
|
||||||
|
language: python
|
||||||
|
language_version: python3
|
||||||
|
minimum_pre_commit_version: "1.4.3"
|
||||||
|
|
||||||
|
- id: commitizen-branch
|
||||||
|
name: commitizen check branch
|
||||||
|
description: >
|
||||||
|
Check all commit messages that are already on the current branch but not the
|
||||||
|
default branch on the origin repository. Useful for checking messages after
|
||||||
|
the fact (e.g., pre-push or in CI) without an expensive check of the entire
|
||||||
|
repository history.
|
||||||
|
entry: cz check
|
||||||
|
args: [--rev-range, origin/HEAD..HEAD]
|
||||||
|
always_run: true
|
||||||
|
pass_filenames: false
|
||||||
|
language: python
|
||||||
|
language_version: python3
|
||||||
|
minimum_pre_commit_version: "1.4.3"
|
1986
CHANGELOG.md
Normal file
1986
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 Santiago
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
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.
|
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
include .bumpversion.cfg
|
||||||
|
include LICENSE
|
||||||
|
include commitizen/cz/*.txt
|
||||||
|
global-exclude *.py[cod] __pycache__ *.so *.dylib
|
29
commitizen/__init__.py
Normal file
29
commitizen/__init__.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
from colorama import init # type: ignore
|
||||||
|
|
||||||
|
from commitizen.cz.base import BaseCommitizen
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": True,
|
||||||
|
"formatters": {"standard": {"format": "%(message)s"}},
|
||||||
|
"handlers": {
|
||||||
|
"default": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"formatter": "standard",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"commitizen": {"handlers": ["default"], "level": "INFO", "propagate": True}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
|
||||||
|
__all__ = ["BaseCommitizen"]
|
4
commitizen/__main__.py
Normal file
4
commitizen/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from commitizen.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
1
commitizen/__version__.py
Normal file
1
commitizen/__version__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
__version__ = "4.6.0"
|
153
commitizen/bump.py
Normal file
153
commitizen/bump.py
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import OrderedDict
|
||||||
|
from glob import iglob
|
||||||
|
from logging import getLogger
|
||||||
|
from string import Template
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from commitizen.defaults import MAJOR, MINOR, PATCH, bump_message, encoding
|
||||||
|
from commitizen.exceptions import CurrentVersionNotFoundError
|
||||||
|
from commitizen.git import GitCommit, smart_open
|
||||||
|
from commitizen.version_schemes import Increment, Version
|
||||||
|
|
||||||
|
VERSION_TYPES = [None, PATCH, MINOR, MAJOR]
|
||||||
|
|
||||||
|
logger = getLogger("commitizen")
|
||||||
|
|
||||||
|
|
||||||
|
def find_increment(
|
||||||
|
commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict
|
||||||
|
) -> Increment | None:
|
||||||
|
if isinstance(increments_map, dict):
|
||||||
|
increments_map = OrderedDict(increments_map)
|
||||||
|
|
||||||
|
# Most important cases are major and minor.
|
||||||
|
# Everything else will be considered patch.
|
||||||
|
select_pattern = re.compile(regex)
|
||||||
|
increment: str | None = None
|
||||||
|
|
||||||
|
for commit in commits:
|
||||||
|
for message in commit.message.split("\n"):
|
||||||
|
result = select_pattern.search(message)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
found_keyword = result.group(1)
|
||||||
|
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 new_increment is None:
|
||||||
|
logger.debug(
|
||||||
|
f"no increment needed for '{found_keyword}' in '{message}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if VERSION_TYPES.index(increment) < VERSION_TYPES.index(new_increment):
|
||||||
|
logger.debug(
|
||||||
|
f"increment detected is '{new_increment}' due to '{found_keyword}' in '{message}'"
|
||||||
|
)
|
||||||
|
increment = new_increment
|
||||||
|
|
||||||
|
if increment == MAJOR:
|
||||||
|
break
|
||||||
|
|
||||||
|
return cast(Increment, increment)
|
||||||
|
|
||||||
|
|
||||||
|
def update_version_in_files(
|
||||||
|
current_version: str,
|
||||||
|
new_version: str,
|
||||||
|
files: list[str],
|
||||||
|
*,
|
||||||
|
check_consistency: bool = False,
|
||||||
|
encoding: str = encoding,
|
||||||
|
) -> list[str]:
|
||||||
|
"""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`.
|
||||||
|
|
||||||
|
Returns the list of updated files.
|
||||||
|
"""
|
||||||
|
# TODO: separate check step and write step
|
||||||
|
updated = []
|
||||||
|
for path, regex in files_and_regexs(files, current_version):
|
||||||
|
current_version_found, version_file = _bump_with_regex(
|
||||||
|
path,
|
||||||
|
current_version,
|
||||||
|
new_version,
|
||||||
|
regex,
|
||||||
|
encoding=encoding,
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_consistency and not current_version_found:
|
||||||
|
raise CurrentVersionNotFoundError(
|
||||||
|
f"Current version {current_version} is not found in {path}.\n"
|
||||||
|
"The version defined in commitizen configuration and the ones in "
|
||||||
|
"version_files are possibly inconsistent."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write the file out again
|
||||||
|
with smart_open(path, "w", encoding=encoding) as file:
|
||||||
|
file.write(version_file)
|
||||||
|
updated.append(path)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def files_and_regexs(patterns: list[str], version: str) -> list[tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Resolve all distinct files with their regexp from a list of glob patterns with optional regexp
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for pattern in patterns:
|
||||||
|
drive, tail = os.path.splitdrive(pattern)
|
||||||
|
path, _, regex = tail.partition(":")
|
||||||
|
filepath = drive + path
|
||||||
|
if not regex:
|
||||||
|
regex = _version_to_regex(version)
|
||||||
|
|
||||||
|
for path in iglob(filepath):
|
||||||
|
out.append((path, regex))
|
||||||
|
return sorted(list(set(out)))
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_with_regex(
|
||||||
|
version_filepath: str,
|
||||||
|
current_version: str,
|
||||||
|
new_version: str,
|
||||||
|
regex: str,
|
||||||
|
encoding: str = encoding,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
current_version_found = False
|
||||||
|
lines = []
|
||||||
|
pattern = re.compile(regex)
|
||||||
|
with open(version_filepath, encoding=encoding) as f:
|
||||||
|
for line in f:
|
||||||
|
if pattern.search(line):
|
||||||
|
bumped_line = line.replace(current_version, new_version)
|
||||||
|
if bumped_line != line:
|
||||||
|
current_version_found = True
|
||||||
|
lines.append(bumped_line)
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
return current_version_found, "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_to_regex(version: str) -> str:
|
||||||
|
return version.replace(".", r"\.").replace("+", r"\+")
|
||||||
|
|
||||||
|
|
||||||
|
def create_commit_message(
|
||||||
|
current_version: Version | str,
|
||||||
|
new_version: Version | str,
|
||||||
|
message_template: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
if message_template is None:
|
||||||
|
message_template = bump_message
|
||||||
|
t = Template(message_template)
|
||||||
|
return t.safe_substitute(current_version=current_version, new_version=new_version)
|
353
commitizen/changelog.py
Normal file
353
commitizen/changelog.py
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
"""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
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import OrderedDict, defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from jinja2 import (
|
||||||
|
BaseLoader,
|
||||||
|
ChoiceLoader,
|
||||||
|
Environment,
|
||||||
|
FileSystemLoader,
|
||||||
|
Template,
|
||||||
|
)
|
||||||
|
|
||||||
|
from commitizen.cz.base import ChangelogReleaseHook
|
||||||
|
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
|
||||||
|
from commitizen.git import GitCommit, GitTag
|
||||||
|
from commitizen.tags import TagRules
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from commitizen.cz.base import MessageBuilderHook
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Metadata:
|
||||||
|
"""
|
||||||
|
Metadata extracted from the changelog produced by a plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
unreleased_start: int | None = None
|
||||||
|
unreleased_end: int | None = None
|
||||||
|
latest_version: str | None = None
|
||||||
|
latest_version_position: int | None = None
|
||||||
|
latest_version_tag: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.latest_version and not self.latest_version_tag:
|
||||||
|
# Test syntactic sugar
|
||||||
|
# latest version tag is optional if same as latest version
|
||||||
|
self.latest_version_tag = self.latest_version
|
||||||
|
|
||||||
|
|
||||||
|
def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None:
|
||||||
|
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,
|
||||||
|
unreleased_version: str | None = None,
|
||||||
|
change_type_map: dict[str, str] | None = None,
|
||||||
|
changelog_message_builder_hook: MessageBuilderHook | None = None,
|
||||||
|
changelog_release_hook: ChangelogReleaseHook | None = None,
|
||||||
|
rules: TagRules | None = 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)
|
||||||
|
current_tag: GitTag | None = None
|
||||||
|
rules = rules or TagRules()
|
||||||
|
|
||||||
|
# Check if the latest commit is not tagged
|
||||||
|
if commits:
|
||||||
|
latest_commit = commits[0]
|
||||||
|
current_tag = 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
|
||||||
|
and commit_tag not in used_tags
|
||||||
|
and rules.include_in_changelog(commit_tag)
|
||||||
|
):
|
||||||
|
used_tags.append(commit_tag)
|
||||||
|
release = {
|
||||||
|
"version": current_tag_name,
|
||||||
|
"date": current_tag_date,
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
if changelog_release_hook:
|
||||||
|
release = changelog_release_hook(release, commit_tag)
|
||||||
|
yield release
|
||||||
|
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
|
||||||
|
if message := map_pat.match(commit.message):
|
||||||
|
process_commit_message(
|
||||||
|
changelog_message_builder_hook,
|
||||||
|
message,
|
||||||
|
commit,
|
||||||
|
changes,
|
||||||
|
change_type_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process body from commit message
|
||||||
|
body_parts = commit.body.split("\n\n")
|
||||||
|
for body_part in body_parts:
|
||||||
|
if message := body_map_pat.match(body_part):
|
||||||
|
process_commit_message(
|
||||||
|
changelog_message_builder_hook,
|
||||||
|
message,
|
||||||
|
commit,
|
||||||
|
changes,
|
||||||
|
change_type_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
release = {
|
||||||
|
"version": current_tag_name,
|
||||||
|
"date": current_tag_date,
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
if changelog_release_hook:
|
||||||
|
release = changelog_release_hook(release, commit_tag)
|
||||||
|
yield release
|
||||||
|
|
||||||
|
|
||||||
|
def process_commit_message(
|
||||||
|
hook: MessageBuilderHook | None,
|
||||||
|
parsed: re.Match[str],
|
||||||
|
commit: GitCommit,
|
||||||
|
changes: dict[str | None, list],
|
||||||
|
change_type_map: dict[str, str] | None = None,
|
||||||
|
):
|
||||||
|
message: dict = {
|
||||||
|
"sha1": commit.rev,
|
||||||
|
"parents": commit.parents,
|
||||||
|
"author": commit.author,
|
||||||
|
"author_email": commit.author_email,
|
||||||
|
**parsed.groupdict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed := hook(message, commit) if hook else message:
|
||||||
|
messages = [processed] if isinstance(processed, dict) else processed
|
||||||
|
for msg in messages:
|
||||||
|
change_type = msg.pop("change_type", None)
|
||||||
|
if change_type_map:
|
||||||
|
change_type = change_type_map.get(change_type, change_type)
|
||||||
|
changes[change_type].append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
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 get_changelog_template(loader: BaseLoader, template: str) -> Template:
|
||||||
|
loader = ChoiceLoader(
|
||||||
|
[
|
||||||
|
FileSystemLoader("."),
|
||||||
|
loader,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
env = Environment(loader=loader, trim_blocks=True)
|
||||||
|
return env.get_template(template)
|
||||||
|
|
||||||
|
|
||||||
|
def render_changelog(
|
||||||
|
tree: Iterable,
|
||||||
|
loader: BaseLoader,
|
||||||
|
template: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
jinja_template = get_changelog_template(loader, template)
|
||||||
|
changelog: str = jinja_template.render(tree=tree, **kwargs)
|
||||||
|
return changelog
|
||||||
|
|
||||||
|
|
||||||
|
def incremental_build(
|
||||||
|
new_content: str, lines: list[str], metadata: Metadata
|
||||||
|
) -> list[str]:
|
||||||
|
"""Takes the original lines and updates with new_content.
|
||||||
|
|
||||||
|
The metadata governs how to remove the old unreleased section 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.unreleased_start
|
||||||
|
unreleased_end = metadata.unreleased_end
|
||||||
|
latest_version_position = metadata.latest_version_position
|
||||||
|
skip = False
|
||||||
|
output_lines: list[str] = []
|
||||||
|
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 index == latest_version_position:
|
||||||
|
output_lines.extend([new_content, "\n"])
|
||||||
|
|
||||||
|
output_lines.append(line)
|
||||||
|
if not isinstance(latest_version_position, int):
|
||||||
|
if output_lines and output_lines[-1].strip():
|
||||||
|
# Ensure at least one blank line between existing and new content.
|
||||||
|
output_lines.append("\n")
|
||||||
|
output_lines.append(new_content)
|
||||||
|
return output_lines
|
||||||
|
|
||||||
|
|
||||||
|
def get_smart_tag_range(
|
||||||
|
tags: list[GitTag], newest: str, oldest: str | None = None
|
||||||
|
) -> list[GitTag]:
|
||||||
|
"""Smart because it finds the N+1 tag.
|
||||||
|
|
||||||
|
This is because we need to find until the next tag
|
||||||
|
"""
|
||||||
|
accumulator = []
|
||||||
|
keep = False
|
||||||
|
if not oldest:
|
||||||
|
oldest = newest
|
||||||
|
for index, tag in enumerate(tags):
|
||||||
|
if tag.name == newest:
|
||||||
|
keep = True
|
||||||
|
if keep:
|
||||||
|
accumulator.append(tag)
|
||||||
|
if tag.name == oldest:
|
||||||
|
keep = False
|
||||||
|
try:
|
||||||
|
accumulator.append(tags[index + 1])
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
return accumulator
|
||||||
|
|
||||||
|
|
||||||
|
def get_oldest_and_newest_rev(
|
||||||
|
tags: list[GitTag],
|
||||||
|
version: str,
|
||||||
|
rules: TagRules,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Find the tags for the given version.
|
||||||
|
|
||||||
|
`version` may come in different formats:
|
||||||
|
- `0.1.0..0.4.0`: as a range
|
||||||
|
- `0.3.0`: as a single version
|
||||||
|
"""
|
||||||
|
oldest: str | None = None
|
||||||
|
newest: str | None = None
|
||||||
|
try:
|
||||||
|
oldest, newest = version.split("..")
|
||||||
|
except ValueError:
|
||||||
|
newest = version
|
||||||
|
if not (newest_tag := rules.find_tag_for(tags, newest)):
|
||||||
|
raise NoCommitsFoundError("Could not find a valid revision range.")
|
||||||
|
|
||||||
|
oldest_tag = None
|
||||||
|
oldest_tag_name = None
|
||||||
|
if oldest:
|
||||||
|
if not (oldest_tag := rules.find_tag_for(tags, oldest)):
|
||||||
|
raise NoCommitsFoundError("Could not find a valid revision range.")
|
||||||
|
oldest_tag_name = oldest_tag.name
|
||||||
|
|
||||||
|
tags_range = get_smart_tag_range(
|
||||||
|
tags, newest=newest_tag.name, oldest=oldest_tag_name
|
||||||
|
)
|
||||||
|
if not tags_range:
|
||||||
|
raise NoCommitsFoundError("Could not find a valid revision range.")
|
||||||
|
|
||||||
|
oldest_rev: str | None = tags_range[-1].name
|
||||||
|
newest_rev = newest_tag.name
|
||||||
|
|
||||||
|
# check if it's the first tag created
|
||||||
|
# and it's also being requested as part of the range
|
||||||
|
if oldest_rev == tags[-1].name and oldest_rev == oldest_tag_name:
|
||||||
|
return None, newest_rev
|
||||||
|
|
||||||
|
# when they are the same, and it's also the
|
||||||
|
# first tag created
|
||||||
|
if oldest_rev == newest_rev:
|
||||||
|
return None, newest_rev
|
||||||
|
|
||||||
|
return oldest_rev, newest_rev
|
93
commitizen/changelog_formats/__init__.py
Normal file
93
commitizen/changelog_formats/__init__.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import ClassVar, Protocol
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from importlib import metadata
|
||||||
|
else:
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
|
||||||
|
from commitizen.changelog import Metadata
|
||||||
|
from commitizen.config.base_config import BaseConfig
|
||||||
|
from commitizen.exceptions import ChangelogFormatUnknown
|
||||||
|
|
||||||
|
CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format"
|
||||||
|
TEMPLATE_EXTENSION = "j2"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogFormat(Protocol):
|
||||||
|
extension: ClassVar[str]
|
||||||
|
"""Standard known extension associated with this format"""
|
||||||
|
|
||||||
|
alternative_extensions: ClassVar[set[str]]
|
||||||
|
"""Known alternatives extensions for this format"""
|
||||||
|
|
||||||
|
config: BaseConfig
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ext(self) -> str:
|
||||||
|
"""Dotted version of extensions, as in `pathlib` and `os` modules"""
|
||||||
|
return f".{self.extension}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template(self) -> str:
|
||||||
|
"""Expected template name for this format"""
|
||||||
|
return f"CHANGELOG.{self.extension}.{TEMPLATE_EXTENSION}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_changelog_file(self) -> str:
|
||||||
|
return f"CHANGELOG.{self.extension}"
|
||||||
|
|
||||||
|
def get_metadata(self, filepath: str) -> Metadata:
|
||||||
|
"""
|
||||||
|
Extract the changelog metadata.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = {
|
||||||
|
ep.name: ep.load()
|
||||||
|
for ep in metadata.entry_points(group=CHANGELOG_FORMAT_ENTRYPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_changelog_format(
|
||||||
|
config: BaseConfig, filename: str | None = None
|
||||||
|
) -> ChangelogFormat:
|
||||||
|
"""
|
||||||
|
Get a format from its name
|
||||||
|
|
||||||
|
:raises FormatUnknown: if a non-empty name is provided but cannot be found in the known formats
|
||||||
|
"""
|
||||||
|
name: str | None = config.settings.get("changelog_format")
|
||||||
|
format: type[ChangelogFormat] | None = guess_changelog_format(filename)
|
||||||
|
|
||||||
|
if name and name in KNOWN_CHANGELOG_FORMATS:
|
||||||
|
format = KNOWN_CHANGELOG_FORMATS[name]
|
||||||
|
|
||||||
|
if not format:
|
||||||
|
raise ChangelogFormatUnknown(f"Unknown changelog format '{name}'")
|
||||||
|
|
||||||
|
return format(config)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_changelog_format(filename: str | None) -> type[ChangelogFormat] | None:
|
||||||
|
"""
|
||||||
|
Try guessing the file format from the filename.
|
||||||
|
|
||||||
|
Algorithm is basic, extension-based, and won't work
|
||||||
|
for extension-less file names like `CHANGELOG` or `NEWS`.
|
||||||
|
"""
|
||||||
|
if not filename or not isinstance(filename, str):
|
||||||
|
return None
|
||||||
|
for format in KNOWN_CHANGELOG_FORMATS.values():
|
||||||
|
if filename.endswith(f".{format.extension}"):
|
||||||
|
return format
|
||||||
|
for alt_extension in format.alternative_extensions:
|
||||||
|
if filename.endswith(f".{alt_extension}"):
|
||||||
|
return format
|
||||||
|
return None
|
28
commitizen/changelog_formats/asciidoc.py
Normal file
28
commitizen/changelog_formats/asciidoc.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .base import BaseFormat
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from commitizen.tags import VersionTag
|
||||||
|
|
||||||
|
|
||||||
|
class AsciiDoc(BaseFormat):
|
||||||
|
extension = "adoc"
|
||||||
|
|
||||||
|
RE_TITLE = re.compile(r"^(?P<level>=+) (?P<title>.*)$")
|
||||||
|
|
||||||
|
def parse_version_from_title(self, line: str) -> VersionTag | None:
|
||||||
|
m = self.RE_TITLE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
# Capture last match as AsciiDoc use postfixed URL labels
|
||||||
|
return self.tag_rules.search_version(m.group("title"), last=True)
|
||||||
|
|
||||||
|
def parse_title_level(self, line: str) -> int | None:
|
||||||
|
m = self.RE_TITLE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return len(m.group("level"))
|
86
commitizen/changelog_formats/base.py
Normal file
86
commitizen/changelog_formats/base.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from abc import ABCMeta
|
||||||
|
from typing import IO, Any, ClassVar
|
||||||
|
|
||||||
|
from commitizen.changelog import Metadata
|
||||||
|
from commitizen.config.base_config import BaseConfig
|
||||||
|
from commitizen.tags import TagRules, VersionTag
|
||||||
|
from commitizen.version_schemes import get_version_scheme
|
||||||
|
|
||||||
|
from . import ChangelogFormat
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
|
||||||
|
"""
|
||||||
|
Base class to extend to implement a changelog file format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
extension: ClassVar[str] = ""
|
||||||
|
alternative_extensions: ClassVar[set[str]] = set()
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig):
|
||||||
|
# Constructor needs to be redefined because `Protocol` prevent instantiation by default
|
||||||
|
# See: https://bugs.python.org/issue44807
|
||||||
|
self.config = config
|
||||||
|
self.encoding = self.config.settings["encoding"]
|
||||||
|
self.tag_format = self.config.settings["tag_format"]
|
||||||
|
self.tag_rules = TagRules(
|
||||||
|
scheme=get_version_scheme(self.config.settings),
|
||||||
|
tag_format=self.tag_format,
|
||||||
|
legacy_tag_formats=self.config.settings["legacy_tag_formats"],
|
||||||
|
ignored_tag_formats=self.config.settings["ignored_tag_formats"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_metadata(self, filepath: str) -> Metadata:
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
return Metadata()
|
||||||
|
|
||||||
|
with open(filepath, encoding=self.encoding) as changelog_file:
|
||||||
|
return self.get_metadata_from_file(changelog_file)
|
||||||
|
|
||||||
|
def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
|
||||||
|
meta = Metadata()
|
||||||
|
unreleased_level: int | None = None
|
||||||
|
for index, line in enumerate(file):
|
||||||
|
line = line.strip().lower()
|
||||||
|
|
||||||
|
unreleased: int | None = None
|
||||||
|
if "unreleased" in line:
|
||||||
|
unreleased = self.parse_title_level(line)
|
||||||
|
# Try to find beginning and end lines of the unreleased block
|
||||||
|
if unreleased:
|
||||||
|
meta.unreleased_start = index
|
||||||
|
unreleased_level = unreleased
|
||||||
|
continue
|
||||||
|
elif unreleased_level and self.parse_title_level(line) == unreleased_level:
|
||||||
|
meta.unreleased_end = index
|
||||||
|
|
||||||
|
# Try to find the latest release done
|
||||||
|
parsed = self.parse_version_from_title(line)
|
||||||
|
if parsed:
|
||||||
|
meta.latest_version = parsed.version
|
||||||
|
meta.latest_version_tag = parsed.tag
|
||||||
|
meta.latest_version_position = index
|
||||||
|
break # there's no need for more info
|
||||||
|
if meta.unreleased_start is not None and meta.unreleased_end is None:
|
||||||
|
meta.unreleased_end = index
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def parse_version_from_title(self, line: str) -> VersionTag | None:
|
||||||
|
"""
|
||||||
|
Extract the version from a title line if any
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Default `get_metadata_from_file` requires `parse_version_from_changelog` to be implemented"
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_title_level(self, line: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Get the title level/type of a line if any
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Default `get_metadata_from_file` requires `parse_title_type_of_line` to be implemented"
|
||||||
|
)
|
29
commitizen/changelog_formats/markdown.py
Normal file
29
commitizen/changelog_formats/markdown.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .base import BaseFormat
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from commitizen.tags import VersionTag
|
||||||
|
|
||||||
|
|
||||||
|
class Markdown(BaseFormat):
|
||||||
|
extension = "md"
|
||||||
|
|
||||||
|
alternative_extensions = {"markdown", "mkd"}
|
||||||
|
|
||||||
|
RE_TITLE = re.compile(r"^(?P<level>#+) (?P<title>.*)$")
|
||||||
|
|
||||||
|
def parse_version_from_title(self, line: str) -> VersionTag | None:
|
||||||
|
m = self.RE_TITLE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return self.tag_rules.search_version(m.group("title"))
|
||||||
|
|
||||||
|
def parse_title_level(self, line: str) -> int | None:
|
||||||
|
m = self.RE_TITLE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return len(m.group("level"))
|
92
commitizen/changelog_formats/restructuredtext.py
Normal file
92
commitizen/changelog_formats/restructuredtext.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from itertools import zip_longest
|
||||||
|
from typing import IO, TYPE_CHECKING, Any, Union
|
||||||
|
|
||||||
|
from commitizen.changelog import Metadata
|
||||||
|
|
||||||
|
from .base import BaseFormat
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# TypeAlias is Python 3.10+ but backported in typing-extensions
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from typing import TypeAlias
|
||||||
|
else:
|
||||||
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
|
|
||||||
|
# Can't use `|` operator and native type because of https://bugs.python.org/issue42233 only fixed in 3.10
|
||||||
|
TitleKind: TypeAlias = Union[str, tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class RestructuredText(BaseFormat):
|
||||||
|
extension = "rst"
|
||||||
|
|
||||||
|
def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
|
||||||
|
"""
|
||||||
|
RestructuredText section titles are not one-line-based,
|
||||||
|
they spread on 2 or 3 lines and levels are not predefined
|
||||||
|
but determined byt their occurrence order.
|
||||||
|
|
||||||
|
It requires its own algorithm.
|
||||||
|
|
||||||
|
For a more generic approach, you need to rely on `docutils`.
|
||||||
|
"""
|
||||||
|
meta = Metadata()
|
||||||
|
unreleased_title_kind: TitleKind | None = None
|
||||||
|
in_overlined_title = False
|
||||||
|
lines = file.readlines()
|
||||||
|
for index, (first, second, third) in enumerate(
|
||||||
|
zip_longest(lines, lines[1:], lines[2:], fillvalue="")
|
||||||
|
):
|
||||||
|
first = first.strip().lower()
|
||||||
|
second = second.strip().lower()
|
||||||
|
third = third.strip().lower()
|
||||||
|
title: str | None = None
|
||||||
|
kind: TitleKind | None = None
|
||||||
|
if self.is_overlined_title(first, second, third):
|
||||||
|
title = second
|
||||||
|
kind = (first[0], third[0])
|
||||||
|
in_overlined_title = True
|
||||||
|
elif not in_overlined_title and self.is_underlined_title(first, second):
|
||||||
|
title = first
|
||||||
|
kind = second[0]
|
||||||
|
else:
|
||||||
|
in_overlined_title = False
|
||||||
|
|
||||||
|
if title:
|
||||||
|
if "unreleased" in title:
|
||||||
|
unreleased_title_kind = kind
|
||||||
|
meta.unreleased_start = index
|
||||||
|
continue
|
||||||
|
elif unreleased_title_kind and unreleased_title_kind == kind:
|
||||||
|
meta.unreleased_end = index
|
||||||
|
# Try to find the latest release done
|
||||||
|
if version := self.tag_rules.search_version(title):
|
||||||
|
meta.latest_version = version[0]
|
||||||
|
meta.latest_version_tag = version[1]
|
||||||
|
meta.latest_version_position = index
|
||||||
|
break
|
||||||
|
if meta.unreleased_start is not None and meta.unreleased_end is None:
|
||||||
|
meta.unreleased_end = (
|
||||||
|
meta.latest_version_position if meta.latest_version else index + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def is_overlined_title(self, first: str, second: str, third: str) -> bool:
|
||||||
|
return (
|
||||||
|
len(first) >= len(second)
|
||||||
|
and len(first) == len(third)
|
||||||
|
and all(char == first[0] for char in first[1:])
|
||||||
|
and first[0] == third[0]
|
||||||
|
and self.is_underlined_title(second, third)
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_underlined_title(self, first: str, second: str) -> bool:
|
||||||
|
return (
|
||||||
|
len(second) >= len(first)
|
||||||
|
and not second.isalnum()
|
||||||
|
and all(char == second[0] for char in second[1:])
|
||||||
|
)
|
26
commitizen/changelog_formats/textile.py
Normal file
26
commitizen/changelog_formats/textile.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .base import BaseFormat
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from commitizen.tags import VersionTag
|
||||||
|
|
||||||
|
|
||||||
|
class Textile(BaseFormat):
|
||||||
|
extension = "textile"
|
||||||
|
|
||||||
|
RE_TITLE = re.compile(r"^h(?P<level>\d)\. (?P<title>.*)$")
|
||||||
|
|
||||||
|
def parse_version_from_title(self, line: str) -> VersionTag | None:
|
||||||
|
if not self.RE_TITLE.match(line):
|
||||||
|
return None
|
||||||
|
return self.tag_rules.search_version(line)
|
||||||
|
|
||||||
|
def parse_title_level(self, line: str) -> int | None:
|
||||||
|
m = self.RE_TITLE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return int(m.group("level"))
|
660
commitizen/cli.py
Normal file
660
commitizen/cli.py
Normal file
|
@ -0,0 +1,660 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from copy import deepcopy
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import argcomplete
|
||||||
|
from decli import cli
|
||||||
|
|
||||||
|
from commitizen import commands, config, out, version_schemes
|
||||||
|
from commitizen.exceptions import (
|
||||||
|
CommitizenException,
|
||||||
|
ExitCode,
|
||||||
|
ExpectedExit,
|
||||||
|
InvalidCommandArgumentError,
|
||||||
|
NoCommandFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseKwargs(argparse.Action):
|
||||||
|
"""
|
||||||
|
Parse arguments in the for `key=value`.
|
||||||
|
|
||||||
|
Quoted strings are automatically unquoted.
|
||||||
|
Can be submitted multiple times:
|
||||||
|
|
||||||
|
ex:
|
||||||
|
-k key=value -k double-quotes="value" -k single-quotes='value'
|
||||||
|
|
||||||
|
will result in
|
||||||
|
|
||||||
|
namespace["opt"] == {
|
||||||
|
"key": "value",
|
||||||
|
"double-quotes": "value",
|
||||||
|
"single-quotes": "value",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
parser: argparse.ArgumentParser,
|
||||||
|
namespace: argparse.Namespace,
|
||||||
|
kwarg: str | Sequence[Any] | None,
|
||||||
|
option_string: str | None = None,
|
||||||
|
):
|
||||||
|
if not isinstance(kwarg, str):
|
||||||
|
return
|
||||||
|
if "=" not in kwarg:
|
||||||
|
raise InvalidCommandArgumentError(
|
||||||
|
f"Option {option_string} expect a key=value format"
|
||||||
|
)
|
||||||
|
kwargs = getattr(namespace, self.dest, None) or {}
|
||||||
|
key, value = kwarg.split("=", 1)
|
||||||
|
if not key:
|
||||||
|
raise InvalidCommandArgumentError(
|
||||||
|
f"Option {option_string} expect a key=value format"
|
||||||
|
)
|
||||||
|
kwargs[key] = value.strip("'\"")
|
||||||
|
setattr(namespace, self.dest, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
tpl_arguments = (
|
||||||
|
{
|
||||||
|
"name": ["--template", "-t"],
|
||||||
|
"help": (
|
||||||
|
"changelog template file name (relative to the current working directory)"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--extra", "-e"],
|
||||||
|
"action": ParseKwargs,
|
||||||
|
"dest": "extras",
|
||||||
|
"metavar": "EXTRA",
|
||||||
|
"help": "a changelog extra variable (in the form 'key=value')",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"prog": "cz",
|
||||||
|
"description": (
|
||||||
|
"Commitizen is a cli tool to generate conventional commits.\n"
|
||||||
|
"For more information about the topic go to "
|
||||||
|
"https://conventionalcommits.org/"
|
||||||
|
),
|
||||||
|
"formatter_class": argparse.RawDescriptionHelpFormatter,
|
||||||
|
"arguments": [
|
||||||
|
{
|
||||||
|
"name": "--config",
|
||||||
|
"help": "the path of configuration file",
|
||||||
|
},
|
||||||
|
{"name": "--debug", "action": "store_true", "help": "use debug mode"},
|
||||||
|
{
|
||||||
|
"name": ["-n", "--name"],
|
||||||
|
"help": "use the given commitizen (default: cz_conventional_commits)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-nr", "--no-raise"],
|
||||||
|
"type": str,
|
||||||
|
"required": False,
|
||||||
|
"help": "comma separated error codes that won't rise error, e.g: cz -nr 1,2,3 bump. See codes at https://commitizen-tools.github.io/commitizen/exit_codes/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"subcommands": {
|
||||||
|
"title": "commands",
|
||||||
|
"required": True,
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": ["init"],
|
||||||
|
"description": "init commitizen configuration",
|
||||||
|
"help": "init commitizen configuration",
|
||||||
|
"func": commands.Init,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["commit", "c"],
|
||||||
|
"description": "create new commit",
|
||||||
|
"help": "create new commit",
|
||||||
|
"func": commands.Commit,
|
||||||
|
"arguments": [
|
||||||
|
{
|
||||||
|
"name": ["--retry"],
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "retry last commit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--no-retry"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "skip retry if retry_after_failure is set to true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--dry-run",
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "show output to stdout, no commit, no modified files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--write-message-to-file",
|
||||||
|
"type": Path,
|
||||||
|
"metavar": "FILE_PATH",
|
||||||
|
"help": "write message to file before committing (can be combined with --dry-run)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-s", "--signoff"],
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "sign off the commit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-a", "--all"],
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-e", "--edit"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "edit the commit message before committing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-l", "--message-length-limit"],
|
||||||
|
"type": int,
|
||||||
|
"default": 0,
|
||||||
|
"help": "length limit of the commit message; 0 for no limit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--"],
|
||||||
|
"action": "store_true",
|
||||||
|
"dest": "double_dash",
|
||||||
|
"help": "Positional arguments separator (recommended)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ls",
|
||||||
|
"description": "show available commitizens",
|
||||||
|
"help": "show available commitizens",
|
||||||
|
"func": commands.ListCz,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "example",
|
||||||
|
"description": "show commit example",
|
||||||
|
"help": "show commit example",
|
||||||
|
"func": commands.Example,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "info",
|
||||||
|
"description": "show information about the cz",
|
||||||
|
"help": "show information about the cz",
|
||||||
|
"func": commands.Info,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "schema",
|
||||||
|
"description": "show commit schema",
|
||||||
|
"help": "show commit schema",
|
||||||
|
"func": commands.Schema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bump",
|
||||||
|
"description": "bump semantic version based on the git log",
|
||||||
|
"help": "bump semantic version based on the git log",
|
||||||
|
"func": commands.Bump,
|
||||||
|
"arguments": [
|
||||||
|
{
|
||||||
|
"name": "--dry-run",
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "show output to stdout, no commit, no modified files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--files-only",
|
||||||
|
"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",
|
||||||
|
"help": "accept automatically questions done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--tag-format",
|
||||||
|
"help": (
|
||||||
|
"the format used to tag the commit and read it, "
|
||||||
|
"use it in existing projects, "
|
||||||
|
"wrap around simple quotes"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--bump-message",
|
||||||
|
"help": (
|
||||||
|
"template used to create the release commit, "
|
||||||
|
"useful when working with CI"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--prerelease", "-pr"],
|
||||||
|
"help": "choose type of prerelease",
|
||||||
|
"choices": ["alpha", "beta", "rc"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--devrelease", "-d"],
|
||||||
|
"help": "specify non-negative integer for dev. release",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--increment"],
|
||||||
|
"help": "manually specify the desired increment",
|
||||||
|
"choices": ["MAJOR", "MINOR", "PATCH"],
|
||||||
|
"type": str.upper,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--increment-mode"],
|
||||||
|
"choices": ["linear", "exact"],
|
||||||
|
"default": "linear",
|
||||||
|
"help": (
|
||||||
|
"set the method by which the new version is chosen. "
|
||||||
|
"'linear' (default) guesses the next version based on typical linear version progression, "
|
||||||
|
"such that bumping of a pre-release with lower precedence than the current pre-release "
|
||||||
|
"phase maintains the current phase of higher precedence. "
|
||||||
|
"'exact' applies the changes that have been specified (or determined from the commit log) "
|
||||||
|
"without interpretation, such that the increment and pre-release are always honored"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": ["--annotated-tag-message", "-atm"],
|
||||||
|
"help": "create annotated tag message",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--gpg-sign", "-s"],
|
||||||
|
"help": "sign tag instead of lightweight one",
|
||||||
|
"default": False,
|
||||||
|
"action": "store_true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--changelog-to-stdout"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "Output changelog to the stdout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--git-output-to-stderr"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "Redirect git output to stderr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--retry"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "retry commit if it fails the 1st time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--major-version-zero"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": None,
|
||||||
|
"help": "keep major version at zero, even for breaking changes",
|
||||||
|
},
|
||||||
|
*deepcopy(tpl_arguments),
|
||||||
|
{
|
||||||
|
"name": "--file-name",
|
||||||
|
"help": "file name of changelog (default: 'CHANGELOG.md')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--prerelease-offset"],
|
||||||
|
"type": int,
|
||||||
|
"default": None,
|
||||||
|
"help": "start pre-releases with this offset",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--version-scheme"],
|
||||||
|
"help": "choose version scheme",
|
||||||
|
"default": None,
|
||||||
|
"choices": version_schemes.KNOWN_SCHEMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--version-type"],
|
||||||
|
"help": "Deprecated, use --version-scheme",
|
||||||
|
"default": None,
|
||||||
|
"choices": version_schemes.KNOWN_SCHEMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_version",
|
||||||
|
"type": str,
|
||||||
|
"nargs": "?",
|
||||||
|
"help": "bump to the given version (e.g: 1.5.3)",
|
||||||
|
"metavar": "MANUAL_VERSION",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--build-metadata"],
|
||||||
|
"help": "Add additional build-metadata to the version-number",
|
||||||
|
"default": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--get-next"],
|
||||||
|
"action": "store_true",
|
||||||
|
"help": "Determine the next version and write to stdout",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--allow-no-commit"],
|
||||||
|
"default": False,
|
||||||
|
"help": "bump version without eligible commits",
|
||||||
|
"action": "store_true",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["changelog", "ch"],
|
||||||
|
"description": (
|
||||||
|
"generate changelog (note that it will overwrite existing file)"
|
||||||
|
),
|
||||||
|
"help": (
|
||||||
|
"generate changelog (note that it will overwrite existing file)"
|
||||||
|
),
|
||||||
|
"func": commands.Changelog,
|
||||||
|
"arguments": [
|
||||||
|
{
|
||||||
|
"name": "--dry-run",
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "show changelog to stdout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--file-name",
|
||||||
|
"help": "file name of changelog (default: 'CHANGELOG.md')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--unreleased-version",
|
||||||
|
"help": (
|
||||||
|
"set the value for the new version (use the tag value), "
|
||||||
|
"instead of using unreleased"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--incremental",
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": (
|
||||||
|
"generates changelog from last created version, "
|
||||||
|
"useful if the changelog has been manually modified"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rev_range",
|
||||||
|
"type": str,
|
||||||
|
"nargs": "?",
|
||||||
|
"help": "generates changelog for the given version (e.g: 1.5.3) or version range (e.g: 1.5.3..1.7.9)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--start-rev",
|
||||||
|
"default": None,
|
||||||
|
"help": (
|
||||||
|
"start rev of the changelog. "
|
||||||
|
"If not set, it will generate changelog from the start"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--merge-prerelease",
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": (
|
||||||
|
"collect all changes from prereleases into next non-prerelease. "
|
||||||
|
"If not set, it will include prereleases in the changelog"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--version-scheme"],
|
||||||
|
"help": "choose version scheme",
|
||||||
|
"default": None,
|
||||||
|
"choices": version_schemes.KNOWN_SCHEMES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "--export-template",
|
||||||
|
"default": None,
|
||||||
|
"help": "Export the changelog template into this file instead of rendering it",
|
||||||
|
},
|
||||||
|
*deepcopy(tpl_arguments),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["check"],
|
||||||
|
"description": "validates that a commit message matches the commitizen schema",
|
||||||
|
"help": "validates that a commit message matches the commitizen schema",
|
||||||
|
"func": commands.Check,
|
||||||
|
"arguments": [
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
),
|
||||||
|
"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": ["--allow-abort"],
|
||||||
|
"action": "store_true",
|
||||||
|
"default": False,
|
||||||
|
"help": "allow empty commit messages, which typically abort a commit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["--allowed-prefixes"],
|
||||||
|
"nargs": "*",
|
||||||
|
"help": "allowed commit message prefixes. "
|
||||||
|
"If the message starts by one of these prefixes, "
|
||||||
|
"the message won't be checked against the regex",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["-l", "--message-length-limit"],
|
||||||
|
"type": int,
|
||||||
|
"default": 0,
|
||||||
|
"help": "length limit of the commit message; 0 for no limit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ["version"],
|
||||||
|
"description": (
|
||||||
|
"get the version of the installed commitizen or the current project"
|
||||||
|
" (default: installed commitizen)"
|
||||||
|
),
|
||||||
|
"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, traceback, debug=False, no_raise: list[int] | None = None
|
||||||
|
):
|
||||||
|
traceback = traceback if isinstance(traceback, TracebackType) else None
|
||||||
|
if not no_raise:
|
||||||
|
no_raise = []
|
||||||
|
if isinstance(value, CommitizenException):
|
||||||
|
if value.message:
|
||||||
|
value.output_method(value.message)
|
||||||
|
if debug:
|
||||||
|
original_excepthook(type, value, traceback)
|
||||||
|
exit_code = value.exit_code
|
||||||
|
if exit_code in no_raise:
|
||||||
|
exit_code = ExitCode.EXPECTED_EXIT
|
||||||
|
sys.exit(exit_code)
|
||||||
|
else:
|
||||||
|
original_excepthook(type, value, traceback)
|
||||||
|
|
||||||
|
|
||||||
|
commitizen_debug_excepthook = partial(commitizen_excepthook, debug=True)
|
||||||
|
|
||||||
|
sys.excepthook = commitizen_excepthook
|
||||||
|
|
||||||
|
|
||||||
|
def parse_no_raise(comma_separated_no_raise: str) -> list[int]:
|
||||||
|
"""Convert the given string to exit codes.
|
||||||
|
|
||||||
|
Receives digits and strings and outputs the parsed integer which
|
||||||
|
represents the exit code found in exceptions.
|
||||||
|
"""
|
||||||
|
no_raise_items: list[str] = comma_separated_no_raise.split(",")
|
||||||
|
no_raise_codes = []
|
||||||
|
for item in no_raise_items:
|
||||||
|
if item.isdecimal():
|
||||||
|
no_raise_codes.append(int(item))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exit_code = ExitCode[item.strip()]
|
||||||
|
except KeyError:
|
||||||
|
out.warn(f"WARN: no_raise key `{item}` does not exist. Skipping.")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
no_raise_codes.append(exit_code.value)
|
||||||
|
return no_raise_codes
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = cli(data)
|
||||||
|
argcomplete.autocomplete(parser)
|
||||||
|
# Show help if no arg provided
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
parser.print_help(sys.stderr)
|
||||||
|
raise ExpectedExit()
|
||||||
|
|
||||||
|
# This is for the command required constraint in 2.0
|
||||||
|
try:
|
||||||
|
args, unknown_args = parser.parse_known_args()
|
||||||
|
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
|
||||||
|
|
||||||
|
arguments = vars(args)
|
||||||
|
if unknown_args:
|
||||||
|
# Raise error for extra-args without -- separation
|
||||||
|
if "--" not in unknown_args:
|
||||||
|
raise InvalidCommandArgumentError(
|
||||||
|
f"Invalid commitizen arguments were found: `{' '.join(unknown_args)}`. "
|
||||||
|
"Please use -- separator for extra git args"
|
||||||
|
)
|
||||||
|
# Raise error for extra-args before --
|
||||||
|
elif unknown_args[0] != "--":
|
||||||
|
pos = unknown_args.index("--")
|
||||||
|
raise InvalidCommandArgumentError(
|
||||||
|
f"Invalid commitizen arguments were found before -- separator: `{' '.join(unknown_args[:pos])}`. "
|
||||||
|
)
|
||||||
|
# Log warning for -- without any extra args
|
||||||
|
elif len(unknown_args) == 1:
|
||||||
|
logger.warning(
|
||||||
|
"\nWARN: Incomplete commit command: received -- separator without any following git arguments\n"
|
||||||
|
)
|
||||||
|
extra_args = " ".join(unknown_args[1:])
|
||||||
|
arguments["extra_cli_args"] = extra_args
|
||||||
|
|
||||||
|
if args.config:
|
||||||
|
conf = config.read_cfg(args.config)
|
||||||
|
else:
|
||||||
|
conf = config.read_cfg()
|
||||||
|
|
||||||
|
if args.name:
|
||||||
|
conf.update({"name": args.name})
|
||||||
|
elif not args.name and not conf.path:
|
||||||
|
conf.update({"name": "cz_conventional_commits"})
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
logging.getLogger("commitizen").setLevel(logging.DEBUG)
|
||||||
|
sys.excepthook = commitizen_debug_excepthook
|
||||||
|
elif args.no_raise:
|
||||||
|
no_raise_exit_codes = parse_no_raise(args.no_raise)
|
||||||
|
no_raise_debug_excepthook = partial(
|
||||||
|
commitizen_excepthook, no_raise=no_raise_exit_codes
|
||||||
|
)
|
||||||
|
sys.excepthook = no_raise_debug_excepthook
|
||||||
|
|
||||||
|
args.func(conf, arguments)()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
50
commitizen/cmd.py
Normal file
50
commitizen/cmd.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from charset_normalizer import from_bytes
|
||||||
|
|
||||||
|
from commitizen.exceptions import CharacterSetDecodeError
|
||||||
|
|
||||||
|
|
||||||
|
class Command(NamedTuple):
|
||||||
|
out: str
|
||||||
|
err: str
|
||||||
|
stdout: bytes
|
||||||
|
stderr: bytes
|
||||||
|
return_code: int
|
||||||
|
|
||||||
|
|
||||||
|
def _try_decode(bytes_: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return bytes_.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
charset_match = from_bytes(bytes_).best()
|
||||||
|
if charset_match is None:
|
||||||
|
raise CharacterSetDecodeError()
|
||||||
|
try:
|
||||||
|
return bytes_.decode(charset_match.encoding)
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise CharacterSetDecodeError() from e
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: str, env=None) -> Command:
|
||||||
|
if env is not None:
|
||||||
|
env = {**os.environ, **env}
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
return_code = process.returncode
|
||||||
|
return Command(
|
||||||
|
_try_decode(stdout),
|
||||||
|
_try_decode(stderr),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
return_code,
|
||||||
|
)
|
23
commitizen/commands/__init__.py
Normal file
23
commitizen/commands/__init__.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"Bump",
|
||||||
|
"Check",
|
||||||
|
"Commit",
|
||||||
|
"Changelog",
|
||||||
|
"Example",
|
||||||
|
"Info",
|
||||||
|
"ListCz",
|
||||||
|
"Schema",
|
||||||
|
"Version",
|
||||||
|
"Init",
|
||||||
|
)
|
439
commitizen/commands/bump.py
Normal file
439
commitizen/commands/bump.py
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import questionary
|
||||||
|
|
||||||
|
from commitizen import bump, factory, git, hooks, out
|
||||||
|
from commitizen.changelog_formats import get_changelog_format
|
||||||
|
from commitizen.commands.changelog import Changelog
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.defaults import Settings
|
||||||
|
from commitizen.exceptions import (
|
||||||
|
BumpCommitFailedError,
|
||||||
|
BumpTagFailedError,
|
||||||
|
DryRunExit,
|
||||||
|
ExpectedExit,
|
||||||
|
GetNextExit,
|
||||||
|
InvalidManualVersion,
|
||||||
|
NoCommitsFoundError,
|
||||||
|
NoneIncrementExit,
|
||||||
|
NoPatternMapError,
|
||||||
|
NotAGitProjectError,
|
||||||
|
NotAllowed,
|
||||||
|
NoVersionSpecifiedError,
|
||||||
|
)
|
||||||
|
from commitizen.providers import get_provider
|
||||||
|
from commitizen.tags import TagRules
|
||||||
|
from commitizen.version_schemes import (
|
||||||
|
Increment,
|
||||||
|
InvalidVersion,
|
||||||
|
Prerelease,
|
||||||
|
get_version_scheme,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = getLogger("commitizen")
|
||||||
|
|
||||||
|
|
||||||
|
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.encoding = config.settings["encoding"]
|
||||||
|
self.arguments: dict = arguments
|
||||||
|
self.bump_settings: dict = {
|
||||||
|
**config.settings,
|
||||||
|
**{
|
||||||
|
key: arguments[key]
|
||||||
|
for key in [
|
||||||
|
"tag_format",
|
||||||
|
"prerelease",
|
||||||
|
"increment",
|
||||||
|
"increment_mode",
|
||||||
|
"bump_message",
|
||||||
|
"gpg_sign",
|
||||||
|
"annotated_tag",
|
||||||
|
"annotated_tag_message",
|
||||||
|
"major_version_zero",
|
||||||
|
"prerelease_offset",
|
||||||
|
"template",
|
||||||
|
"file_name",
|
||||||
|
]
|
||||||
|
if arguments[key] is not None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
self.changelog_flag = arguments["changelog"]
|
||||||
|
self.changelog_config = self.config.settings.get("update_changelog_on_bump")
|
||||||
|
self.changelog_to_stdout = arguments["changelog_to_stdout"]
|
||||||
|
self.git_output_to_stderr = arguments["git_output_to_stderr"]
|
||||||
|
self.no_verify = arguments["no_verify"]
|
||||||
|
self.check_consistency = arguments["check_consistency"]
|
||||||
|
self.retry = arguments["retry"]
|
||||||
|
self.pre_bump_hooks = self.config.settings["pre_bump_hooks"]
|
||||||
|
self.post_bump_hooks = self.config.settings["post_bump_hooks"]
|
||||||
|
deprecated_version_type = arguments.get("version_type")
|
||||||
|
if deprecated_version_type:
|
||||||
|
warnings.warn(
|
||||||
|
DeprecationWarning(
|
||||||
|
"`--version-type` parameter is deprecated and will be removed in commitizen 4. "
|
||||||
|
"Please use `--version-scheme` instead"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.scheme = get_version_scheme(
|
||||||
|
self.config.settings, arguments["version_scheme"] or deprecated_version_type
|
||||||
|
)
|
||||||
|
self.file_name = arguments["file_name"] or self.config.settings.get(
|
||||||
|
"changelog_file"
|
||||||
|
)
|
||||||
|
self.changelog_format = get_changelog_format(self.config, self.file_name)
|
||||||
|
|
||||||
|
self.template = (
|
||||||
|
arguments["template"]
|
||||||
|
or self.config.settings.get("template")
|
||||||
|
or self.changelog_format.template
|
||||||
|
)
|
||||||
|
self.extras = arguments["extras"]
|
||||||
|
|
||||||
|
def is_initial_tag(
|
||||||
|
self, current_tag: git.GitTag | None, is_yes: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Check if reading the whole git tree up to HEAD is needed."""
|
||||||
|
is_initial = False
|
||||||
|
if not current_tag:
|
||||||
|
if is_yes:
|
||||||
|
is_initial = True
|
||||||
|
else:
|
||||||
|
out.info("No tag matching configuration could not be found.")
|
||||||
|
out.info(
|
||||||
|
"Possible causes:\n"
|
||||||
|
"- version in configuration is not the current version\n"
|
||||||
|
"- tag_format or legacy_tag_formats is missing, check them using 'git tag --list'\n"
|
||||||
|
)
|
||||||
|
is_initial = questionary.confirm("Is this the first tag created?").ask()
|
||||||
|
return is_initial
|
||||||
|
|
||||||
|
def find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
|
||||||
|
# Update the bump map to ensure major version doesn't increment.
|
||||||
|
is_major_version_zero: bool = self.bump_settings["major_version_zero"]
|
||||||
|
# self.cz.bump_map = defaults.bump_map_major_version_zero
|
||||||
|
bump_map = (
|
||||||
|
self.cz.bump_map_major_version_zero
|
||||||
|
if is_major_version_zero
|
||||||
|
else self.cz.bump_map
|
||||||
|
)
|
||||||
|
bump_pattern = self.cz.bump_pattern
|
||||||
|
|
||||||
|
if not bump_map or not bump_pattern:
|
||||||
|
raise NoPatternMapError(
|
||||||
|
f"'{self.config.settings['name']}' rule does not support bump"
|
||||||
|
)
|
||||||
|
increment = bump.find_increment(
|
||||||
|
commits, regex=bump_pattern, increments_map=bump_map
|
||||||
|
)
|
||||||
|
return increment
|
||||||
|
|
||||||
|
def __call__(self) -> None: # noqa: C901
|
||||||
|
"""Steps executed to bump."""
|
||||||
|
provider = get_provider(self.config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_version = self.scheme(provider.get_version())
|
||||||
|
except TypeError:
|
||||||
|
raise NoVersionSpecifiedError()
|
||||||
|
|
||||||
|
bump_commit_message: str = self.bump_settings["bump_message"]
|
||||||
|
version_files: list[str] = self.bump_settings["version_files"]
|
||||||
|
major_version_zero: bool = self.bump_settings["major_version_zero"]
|
||||||
|
prerelease_offset: int = self.bump_settings["prerelease_offset"]
|
||||||
|
|
||||||
|
dry_run: bool = self.arguments["dry_run"]
|
||||||
|
is_yes: bool = self.arguments["yes"]
|
||||||
|
increment: Increment | None = self.arguments["increment"]
|
||||||
|
prerelease: Prerelease | None = self.arguments["prerelease"]
|
||||||
|
devrelease: int | None = self.arguments["devrelease"]
|
||||||
|
is_files_only: bool | None = self.arguments["files_only"]
|
||||||
|
is_local_version: bool = self.arguments["local_version"]
|
||||||
|
manual_version = self.arguments["manual_version"]
|
||||||
|
build_metadata = self.arguments["build_metadata"]
|
||||||
|
increment_mode: str = self.arguments["increment_mode"]
|
||||||
|
get_next: bool = self.arguments["get_next"]
|
||||||
|
allow_no_commit: bool | None = self.arguments["allow_no_commit"]
|
||||||
|
|
||||||
|
if manual_version:
|
||||||
|
if increment:
|
||||||
|
raise NotAllowed("--increment cannot be combined with MANUAL_VERSION")
|
||||||
|
|
||||||
|
if prerelease:
|
||||||
|
raise NotAllowed("--prerelease cannot be combined with MANUAL_VERSION")
|
||||||
|
|
||||||
|
if devrelease is not None:
|
||||||
|
raise NotAllowed("--devrelease cannot be combined with MANUAL_VERSION")
|
||||||
|
|
||||||
|
if is_local_version:
|
||||||
|
raise NotAllowed(
|
||||||
|
"--local-version cannot be combined with MANUAL_VERSION"
|
||||||
|
)
|
||||||
|
|
||||||
|
if build_metadata:
|
||||||
|
raise NotAllowed(
|
||||||
|
"--build-metadata cannot be combined with MANUAL_VERSION"
|
||||||
|
)
|
||||||
|
|
||||||
|
if major_version_zero:
|
||||||
|
raise NotAllowed(
|
||||||
|
"--major-version-zero cannot be combined with MANUAL_VERSION"
|
||||||
|
)
|
||||||
|
|
||||||
|
if get_next:
|
||||||
|
raise NotAllowed("--get-next cannot be combined with MANUAL_VERSION")
|
||||||
|
|
||||||
|
if major_version_zero:
|
||||||
|
if not current_version.release[0] == 0:
|
||||||
|
raise NotAllowed(
|
||||||
|
f"--major-version-zero is meaningless for current version {current_version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if build_metadata:
|
||||||
|
if is_local_version:
|
||||||
|
raise NotAllowed(
|
||||||
|
"--local-version cannot be combined with --build-metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
if get_next:
|
||||||
|
# if trying to use --get-next, we should not allow --changelog or --changelog-to-stdout
|
||||||
|
if self.changelog_flag or bool(self.changelog_to_stdout):
|
||||||
|
raise NotAllowed(
|
||||||
|
"--changelog or --changelog-to-stdout is not allowed with --get-next"
|
||||||
|
)
|
||||||
|
# --get-next is a special case, taking precedence over config for 'update_changelog_on_bump'
|
||||||
|
self.changelog_config = False
|
||||||
|
# Setting dry_run to prevent any unwanted changes to the repo or files
|
||||||
|
self.dry_run = True
|
||||||
|
else:
|
||||||
|
# If user specified changelog_to_stdout, they probably want the
|
||||||
|
# changelog to be generated as well, this is the most intuitive solution
|
||||||
|
self.changelog_flag = (
|
||||||
|
self.changelog_flag
|
||||||
|
or bool(self.changelog_to_stdout)
|
||||||
|
or self.changelog_config
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = TagRules.from_settings(cast(Settings, self.bump_settings))
|
||||||
|
current_tag = rules.find_tag_for(git.get_tags(), current_version)
|
||||||
|
current_tag_version = getattr(
|
||||||
|
current_tag, "name", rules.normalize_tag(current_version)
|
||||||
|
)
|
||||||
|
|
||||||
|
is_initial = self.is_initial_tag(current_tag, is_yes)
|
||||||
|
|
||||||
|
if manual_version:
|
||||||
|
try:
|
||||||
|
new_version = self.scheme(manual_version)
|
||||||
|
except InvalidVersion as exc:
|
||||||
|
raise InvalidManualVersion(
|
||||||
|
"[INVALID_MANUAL_VERSION]\n"
|
||||||
|
f"Invalid manual version: '{manual_version}'"
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
if increment is None:
|
||||||
|
if current_tag:
|
||||||
|
commits = git.get_commits(current_tag.name)
|
||||||
|
else:
|
||||||
|
commits = git.get_commits()
|
||||||
|
|
||||||
|
# No commits, there is no need to create an empty tag.
|
||||||
|
# Unless we previously had a prerelease.
|
||||||
|
if (
|
||||||
|
not commits
|
||||||
|
and not current_version.is_prerelease
|
||||||
|
and not allow_no_commit
|
||||||
|
):
|
||||||
|
raise NoCommitsFoundError(
|
||||||
|
"[NO_COMMITS_FOUND]\nNo new commits found."
|
||||||
|
)
|
||||||
|
|
||||||
|
increment = self.find_increment(commits)
|
||||||
|
|
||||||
|
# It may happen that there are commits, but they are not eligible
|
||||||
|
# for an increment, this generates a problem when using prerelease (#281)
|
||||||
|
if prerelease and increment is None and not current_version.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`"
|
||||||
|
)
|
||||||
|
|
||||||
|
# we create an empty PATCH increment for empty tag
|
||||||
|
if increment is None and allow_no_commit:
|
||||||
|
increment = "PATCH"
|
||||||
|
|
||||||
|
new_version = current_version.bump(
|
||||||
|
increment,
|
||||||
|
prerelease=prerelease,
|
||||||
|
prerelease_offset=prerelease_offset,
|
||||||
|
devrelease=devrelease,
|
||||||
|
is_local_version=is_local_version,
|
||||||
|
build_metadata=build_metadata,
|
||||||
|
exact_increment=increment_mode == "exact",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_tag_version = rules.normalize_tag(new_version)
|
||||||
|
message = bump.create_commit_message(
|
||||||
|
current_version, new_version, bump_commit_message
|
||||||
|
)
|
||||||
|
|
||||||
|
if get_next:
|
||||||
|
if increment is None and new_tag_version == current_tag_version:
|
||||||
|
raise NoneIncrementExit(
|
||||||
|
"[NO_COMMITS_TO_BUMP]\n"
|
||||||
|
"The commits found are not eligible to be bumped"
|
||||||
|
)
|
||||||
|
|
||||||
|
out.write(str(new_version))
|
||||||
|
raise GetNextExit()
|
||||||
|
|
||||||
|
# Report found information
|
||||||
|
information = f"{message}\ntag to create: {new_tag_version}\n"
|
||||||
|
if increment:
|
||||||
|
information += 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(
|
||||||
|
"[NO_COMMITS_TO_BUMP]\nThe commits found are not eligible to be bumped"
|
||||||
|
)
|
||||||
|
|
||||||
|
files: list[str] = []
|
||||||
|
if self.changelog_flag:
|
||||||
|
args = {
|
||||||
|
"unreleased_version": new_tag_version,
|
||||||
|
"template": self.template,
|
||||||
|
"extras": self.extras,
|
||||||
|
"incremental": True,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
if self.changelog_to_stdout:
|
||||||
|
changelog_cmd = Changelog(self.config, {**args, "dry_run": True})
|
||||||
|
try:
|
||||||
|
changelog_cmd()
|
||||||
|
except DryRunExit:
|
||||||
|
pass
|
||||||
|
|
||||||
|
args["file_name"] = self.file_name
|
||||||
|
changelog_cmd = Changelog(self.config, args)
|
||||||
|
changelog_cmd()
|
||||||
|
files.append(changelog_cmd.file_name)
|
||||||
|
|
||||||
|
# Do not perform operations over files or git.
|
||||||
|
if dry_run:
|
||||||
|
raise DryRunExit()
|
||||||
|
|
||||||
|
files.extend(
|
||||||
|
bump.update_version_in_files(
|
||||||
|
str(current_version),
|
||||||
|
str(new_version),
|
||||||
|
version_files,
|
||||||
|
check_consistency=self.check_consistency,
|
||||||
|
encoding=self.encoding,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
provider.set_version(str(new_version))
|
||||||
|
|
||||||
|
if self.pre_bump_hooks:
|
||||||
|
hooks.run(
|
||||||
|
self.pre_bump_hooks,
|
||||||
|
_env_prefix="CZ_PRE_",
|
||||||
|
is_initial=is_initial,
|
||||||
|
current_version=str(current_version),
|
||||||
|
current_tag_version=current_tag_version,
|
||||||
|
new_version=new_version.public,
|
||||||
|
new_tag_version=new_tag_version,
|
||||||
|
message=message,
|
||||||
|
increment=increment,
|
||||||
|
changelog_file_name=changelog_cmd.file_name
|
||||||
|
if self.changelog_flag
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_files_only:
|
||||||
|
raise ExpectedExit()
|
||||||
|
|
||||||
|
# FIXME: check if any changes have been staged
|
||||||
|
git.add(*files)
|
||||||
|
c = git.commit(message, args=self._get_commit_args())
|
||||||
|
if self.retry and c.return_code != 0 and self.changelog_flag:
|
||||||
|
# Maybe pre-commit reformatted some files? Retry once
|
||||||
|
logger.debug("1st git.commit error: %s", c.err)
|
||||||
|
logger.info("1st commit attempt failed; retrying once")
|
||||||
|
git.add(*files)
|
||||||
|
c = git.commit(message, args=self._get_commit_args())
|
||||||
|
if c.return_code != 0:
|
||||||
|
err = c.err.strip() or c.out
|
||||||
|
raise BumpCommitFailedError(f'2nd git.commit error: "{err}"')
|
||||||
|
|
||||||
|
if c.out:
|
||||||
|
if self.git_output_to_stderr:
|
||||||
|
out.diagnostic(c.out)
|
||||||
|
else:
|
||||||
|
out.write(c.out)
|
||||||
|
if c.err:
|
||||||
|
if self.git_output_to_stderr:
|
||||||
|
out.diagnostic(c.err)
|
||||||
|
else:
|
||||||
|
out.write(c.err)
|
||||||
|
|
||||||
|
c = git.tag(
|
||||||
|
new_tag_version,
|
||||||
|
signed=self.bump_settings.get("gpg_sign", False)
|
||||||
|
or bool(self.config.settings.get("gpg_sign", False)),
|
||||||
|
annotated=self.bump_settings.get("annotated_tag", False)
|
||||||
|
or bool(self.config.settings.get("annotated_tag", False))
|
||||||
|
or bool(self.bump_settings.get("annotated_tag_message", False)),
|
||||||
|
msg=self.bump_settings.get("annotated_tag_message", None),
|
||||||
|
# TODO: also get from self.config.settings?
|
||||||
|
)
|
||||||
|
if c.return_code != 0:
|
||||||
|
raise BumpTagFailedError(c.err)
|
||||||
|
|
||||||
|
if self.post_bump_hooks:
|
||||||
|
hooks.run(
|
||||||
|
self.post_bump_hooks,
|
||||||
|
_env_prefix="CZ_POST_",
|
||||||
|
was_initial=is_initial,
|
||||||
|
previous_version=str(current_version),
|
||||||
|
previous_tag_version=current_tag_version,
|
||||||
|
current_version=new_version.public,
|
||||||
|
current_tag_version=new_tag_version,
|
||||||
|
message=message,
|
||||||
|
increment=increment,
|
||||||
|
changelog_file_name=changelog_cmd.file_name
|
||||||
|
if self.changelog_flag
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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) -> str:
|
||||||
|
commit_args = ["-a"]
|
||||||
|
if self.no_verify:
|
||||||
|
commit_args.append("--no-verify")
|
||||||
|
return " ".join(commit_args)
|
232
commitizen/commands/changelog.py
Normal file
232
commitizen/commands/changelog.py
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from operator import itemgetter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, cast
|
||||||
|
|
||||||
|
from commitizen import changelog, defaults, factory, git, out
|
||||||
|
from commitizen.changelog_formats import get_changelog_format
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.cz.base import ChangelogReleaseHook, MessageBuilderHook
|
||||||
|
from commitizen.cz.utils import strip_local_version
|
||||||
|
from commitizen.exceptions import (
|
||||||
|
DryRunExit,
|
||||||
|
NoCommitsFoundError,
|
||||||
|
NoPatternMapError,
|
||||||
|
NoRevisionError,
|
||||||
|
NotAGitProjectError,
|
||||||
|
NotAllowed,
|
||||||
|
)
|
||||||
|
from commitizen.git import GitTag, smart_open
|
||||||
|
from commitizen.tags import TagRules
|
||||||
|
from commitizen.version_schemes import get_version_scheme
|
||||||
|
|
||||||
|
|
||||||
|
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.encoding = self.config.settings["encoding"]
|
||||||
|
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 cast(
|
||||||
|
str, self.config.settings.get("changelog_file")
|
||||||
|
)
|
||||||
|
if not isinstance(self.file_name, str):
|
||||||
|
raise NotAllowed(
|
||||||
|
"Changelog file name is broken.\n"
|
||||||
|
"Check the flag `--file-name` in the terminal "
|
||||||
|
f"or the setting `changelog_file` in {self.config.path}"
|
||||||
|
)
|
||||||
|
self.changelog_format = get_changelog_format(self.config, self.file_name)
|
||||||
|
|
||||||
|
self.incremental = args["incremental"] or self.config.settings.get(
|
||||||
|
"changelog_incremental"
|
||||||
|
)
|
||||||
|
self.dry_run = args["dry_run"]
|
||||||
|
|
||||||
|
self.scheme = get_version_scheme(
|
||||||
|
self.config.settings, args.get("version_scheme")
|
||||||
|
)
|
||||||
|
|
||||||
|
current_version = (
|
||||||
|
args.get("current_version", config.settings.get("version")) or ""
|
||||||
|
)
|
||||||
|
self.current_version = self.scheme(current_version) if current_version else None
|
||||||
|
|
||||||
|
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
|
||||||
|
or defaults.change_type_order
|
||||||
|
)
|
||||||
|
self.rev_range = args.get("rev_range")
|
||||||
|
self.tag_format: str = (
|
||||||
|
args.get("tag_format") or self.config.settings["tag_format"]
|
||||||
|
)
|
||||||
|
self.tag_rules = TagRules(
|
||||||
|
scheme=self.scheme,
|
||||||
|
tag_format=self.tag_format,
|
||||||
|
legacy_tag_formats=self.config.settings["legacy_tag_formats"],
|
||||||
|
ignored_tag_formats=self.config.settings["ignored_tag_formats"],
|
||||||
|
merge_prereleases=args.get("merge_prerelease")
|
||||||
|
or self.config.settings["changelog_merge_prerelease"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.template = (
|
||||||
|
args.get("template")
|
||||||
|
or self.config.settings.get("template")
|
||||||
|
or self.changelog_format.template
|
||||||
|
)
|
||||||
|
self.extras = args.get("extras") or {}
|
||||||
|
self.export_template_to = args.get("export_template")
|
||||||
|
|
||||||
|
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, strip_local_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 write_changelog(
|
||||||
|
self, changelog_out: str, lines: list[str], changelog_meta: changelog.Metadata
|
||||||
|
):
|
||||||
|
changelog_hook: Callable | None = self.cz.changelog_hook
|
||||||
|
with smart_open(self.file_name, "w", encoding=self.encoding) as changelog_file:
|
||||||
|
partial_changelog: str | None = 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)
|
||||||
|
|
||||||
|
def export_template(self):
|
||||||
|
tpl = changelog.get_changelog_template(self.cz.template_loader, self.template)
|
||||||
|
src = Path(tpl.filename)
|
||||||
|
Path(self.export_template_to).write_text(src.read_text())
|
||||||
|
|
||||||
|
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 = changelog.Metadata()
|
||||||
|
change_type_map: dict | None = self.change_type_map
|
||||||
|
changelog_message_builder_hook: MessageBuilderHook | None = (
|
||||||
|
self.cz.changelog_message_builder_hook
|
||||||
|
)
|
||||||
|
changelog_release_hook: ChangelogReleaseHook | None = (
|
||||||
|
self.cz.changelog_release_hook
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.export_template_to:
|
||||||
|
return self.export_template()
|
||||||
|
|
||||||
|
if not changelog_pattern or not commit_parser:
|
||||||
|
raise NoPatternMapError(
|
||||||
|
f"'{self.config.settings['name']}' rule does not support changelog"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.incremental and self.rev_range:
|
||||||
|
raise NotAllowed("--incremental cannot be combined with a rev_range")
|
||||||
|
|
||||||
|
# Don't continue if no `file_name` specified.
|
||||||
|
assert self.file_name
|
||||||
|
|
||||||
|
tags = self.tag_rules.get_version_tags(git.get_tags(), warn=True)
|
||||||
|
end_rev = ""
|
||||||
|
if self.incremental:
|
||||||
|
changelog_meta = self.changelog_format.get_metadata(self.file_name)
|
||||||
|
if changelog_meta.latest_version:
|
||||||
|
start_rev = self._find_incremental_rev(
|
||||||
|
strip_local_version(changelog_meta.latest_version_tag), tags
|
||||||
|
)
|
||||||
|
if self.rev_range:
|
||||||
|
start_rev, end_rev = changelog.get_oldest_and_newest_rev(
|
||||||
|
tags,
|
||||||
|
self.rev_range,
|
||||||
|
self.tag_rules,
|
||||||
|
)
|
||||||
|
commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order")
|
||||||
|
if not commits and (
|
||||||
|
self.current_version is None or not self.current_version.is_prerelease
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
changelog_release_hook=changelog_release_hook,
|
||||||
|
rules=self.tag_rules,
|
||||||
|
)
|
||||||
|
if self.change_type_order:
|
||||||
|
tree = changelog.order_changelog_tree(tree, self.change_type_order)
|
||||||
|
|
||||||
|
extras = self.cz.template_extras.copy()
|
||||||
|
extras.update(self.config.settings["extras"])
|
||||||
|
extras.update(self.extras)
|
||||||
|
changelog_out = changelog.render_changelog(
|
||||||
|
tree, loader=self.cz.template_loader, template=self.template, **extras
|
||||||
|
)
|
||||||
|
changelog_out = changelog_out.lstrip("\n")
|
||||||
|
|
||||||
|
# Dry_run is executed here to avoid checking and reading the files
|
||||||
|
if self.dry_run:
|
||||||
|
changelog_hook: Callable | None = self.cz.changelog_hook
|
||||||
|
if changelog_hook:
|
||||||
|
changelog_out = changelog_hook(changelog_out, "")
|
||||||
|
out.write(changelog_out)
|
||||||
|
raise DryRunExit()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if self.incremental and os.path.isfile(self.file_name):
|
||||||
|
with open(self.file_name, encoding=self.encoding) as changelog_file:
|
||||||
|
lines = changelog_file.readlines()
|
||||||
|
|
||||||
|
self.write_changelog(changelog_out, lines, changelog_meta)
|
155
commitizen/commands/check.py
Normal file
155
commitizen/commands/check.py
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from commitizen import factory, git, out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
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[str, Any], 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: str | None = arguments.get("commit_msg_file")
|
||||||
|
self.commit_msg: str | None = arguments.get("message")
|
||||||
|
self.rev_range: str | None = arguments.get("rev_range")
|
||||||
|
self.allow_abort: bool = bool(
|
||||||
|
arguments.get("allow_abort", config.settings["allow_abort"])
|
||||||
|
)
|
||||||
|
self.max_msg_length: int = arguments.get("message_length_limit", 0)
|
||||||
|
|
||||||
|
# we need to distinguish between None and [], which is a valid value
|
||||||
|
|
||||||
|
allowed_prefixes = arguments.get("allowed_prefixes")
|
||||||
|
self.allowed_prefixes: list[str] = (
|
||||||
|
allowed_prefixes
|
||||||
|
if allowed_prefixes is not None
|
||||||
|
else config.settings["allowed_prefixes"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self._valid_command_argument()
|
||||||
|
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
self.encoding = config.settings["encoding"]
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
|
||||||
|
def _valid_command_argument(self):
|
||||||
|
num_exclusive_args_provided = sum(
|
||||||
|
arg is not None
|
||||||
|
for arg in (self.commit_msg_file, self.commit_msg, self.rev_range)
|
||||||
|
)
|
||||||
|
if num_exclusive_args_provided == 0 and not sys.stdin.isatty():
|
||||||
|
self.commit_msg = sys.stdin.read()
|
||||||
|
elif num_exclusive_args_provided != 1:
|
||||||
|
raise InvalidCommandArgumentError(
|
||||||
|
"Only one of --rev-range, --message, and --commit-msg-file is permitted by check command! "
|
||||||
|
"See 'cz check -h' for more information"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
"""Validate if commit messages follows the conventional pattern.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidCommitMessageError: if the commit provided not follows the conventional pattern
|
||||||
|
"""
|
||||||
|
commits = self._get_commits()
|
||||||
|
if not commits:
|
||||||
|
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")
|
||||||
|
|
||||||
|
pattern = self.cz.schema_pattern()
|
||||||
|
ill_formated_commits = [
|
||||||
|
commit
|
||||||
|
for commit in commits
|
||||||
|
if not self.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):
|
||||||
|
msg = None
|
||||||
|
# Get commit message from file (--commit-msg-file)
|
||||||
|
if self.commit_msg_file is not None:
|
||||||
|
# Enter this branch if commit_msg_file is "".
|
||||||
|
with open(self.commit_msg_file, encoding=self.encoding) as commit_file:
|
||||||
|
msg = commit_file.read()
|
||||||
|
# Get commit message from command line (--message)
|
||||||
|
elif self.commit_msg is not None:
|
||||||
|
msg = self.commit_msg
|
||||||
|
if msg is not None:
|
||||||
|
msg = self._filter_comments(msg)
|
||||||
|
return [git.GitCommit(rev="", title="", body=msg)]
|
||||||
|
|
||||||
|
# Get commit messages from git log (--rev-range)
|
||||||
|
if self.rev_range:
|
||||||
|
return git.get_commits(end=self.rev_range)
|
||||||
|
return git.get_commits()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_comments(msg: str) -> str:
|
||||||
|
"""Filter the commit message by removing comments.
|
||||||
|
|
||||||
|
When using `git commit --verbose`, we exclude the diff that is going to
|
||||||
|
generated, like the following example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
...
|
||||||
|
# ------------------------ >8 ------------------------
|
||||||
|
# Do not modify or remove the line above.
|
||||||
|
# Everything below it will be ignored.
|
||||||
|
diff --git a/... b/...
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: The commit message to filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The filtered commit message without comments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for line in msg.split("\n"):
|
||||||
|
if "# ------------------------ >8 ------------------------" in line:
|
||||||
|
break
|
||||||
|
if not line.startswith("#"):
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def validate_commit_message(self, commit_msg: str, pattern: str) -> bool:
|
||||||
|
if not commit_msg:
|
||||||
|
return self.allow_abort
|
||||||
|
|
||||||
|
if any(map(commit_msg.startswith, self.allowed_prefixes)):
|
||||||
|
return True
|
||||||
|
if self.max_msg_length:
|
||||||
|
msg_len = len(commit_msg.partition("\n")[0].strip())
|
||||||
|
if msg_len > self.max_msg_length:
|
||||||
|
return False
|
||||||
|
return bool(re.match(pattern, commit_msg))
|
170
commitizen/commands/commit.py
Normal file
170
commitizen/commands/commit.py
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import questionary
|
||||||
|
|
||||||
|
from commitizen import factory, git, out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.cz.exceptions import CzException
|
||||||
|
from commitizen.cz.utils import get_backup_file_path
|
||||||
|
from commitizen.exceptions import (
|
||||||
|
CommitError,
|
||||||
|
CommitMessageLengthExceededError,
|
||||||
|
CustomError,
|
||||||
|
DryRunExit,
|
||||||
|
NoAnswersError,
|
||||||
|
NoCommitBackupError,
|
||||||
|
NotAGitProjectError,
|
||||||
|
NotAllowed,
|
||||||
|
NothingToCommitError,
|
||||||
|
)
|
||||||
|
from commitizen.git import smart_open
|
||||||
|
|
||||||
|
|
||||||
|
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.encoding = config.settings["encoding"]
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
self.arguments = arguments
|
||||||
|
self.temp_file: str = get_backup_file_path()
|
||||||
|
|
||||||
|
def read_backup_message(self) -> str | None:
|
||||||
|
# Check the commit backup file exists
|
||||||
|
if not os.path.isfile(self.temp_file):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Read commit message from backup
|
||||||
|
with open(self.temp_file, encoding=self.encoding) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
|
||||||
|
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):
|
||||||
|
raise CustomError(root_err.__str__())
|
||||||
|
raise err
|
||||||
|
|
||||||
|
if not answers:
|
||||||
|
raise NoAnswersError()
|
||||||
|
|
||||||
|
message = cz.message(answers)
|
||||||
|
message_len = len(message.partition("\n")[0].strip())
|
||||||
|
message_length_limit: int = self.arguments.get("message_length_limit", 0)
|
||||||
|
if 0 < message_length_limit < message_len:
|
||||||
|
raise CommitMessageLengthExceededError(
|
||||||
|
f"Length of commit message exceeds limit ({message_len}/{message_length_limit})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def manual_edit(self, message: str) -> str:
|
||||||
|
editor = git.get_core_editor()
|
||||||
|
if editor is None:
|
||||||
|
raise RuntimeError("No 'editor' value given and no default available.")
|
||||||
|
exec_path = shutil.which(editor)
|
||||||
|
if exec_path is None:
|
||||||
|
raise RuntimeError(f"Editor '{editor}' not found.")
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as file:
|
||||||
|
file.write(message)
|
||||||
|
file_path = file.name
|
||||||
|
argv = [exec_path, file_path]
|
||||||
|
subprocess.call(argv)
|
||||||
|
with open(file_path) as temp_file:
|
||||||
|
message = temp_file.read().strip()
|
||||||
|
file.unlink()
|
||||||
|
return message
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
extra_args: str = self.arguments.get("extra_cli_args", "")
|
||||||
|
|
||||||
|
allow_empty: bool = "--allow-empty" in extra_args
|
||||||
|
|
||||||
|
dry_run: bool = self.arguments.get("dry_run")
|
||||||
|
write_message_to_file: bool = self.arguments.get("write_message_to_file")
|
||||||
|
manual_edit: bool = self.arguments.get("edit")
|
||||||
|
|
||||||
|
is_all: bool = self.arguments.get("all")
|
||||||
|
if is_all:
|
||||||
|
c = git.add("-u")
|
||||||
|
|
||||||
|
if git.is_staging_clean() and not (dry_run or allow_empty):
|
||||||
|
raise NothingToCommitError("No files added to staging!")
|
||||||
|
|
||||||
|
if write_message_to_file is not None and write_message_to_file.is_dir():
|
||||||
|
raise NotAllowed(f"{write_message_to_file} is a directory")
|
||||||
|
|
||||||
|
retry: bool = self.arguments.get("retry")
|
||||||
|
no_retry: bool = self.arguments.get("no_retry")
|
||||||
|
retry_after_failure: bool = self.config.settings.get("retry_after_failure")
|
||||||
|
|
||||||
|
if retry:
|
||||||
|
m = self.read_backup_message()
|
||||||
|
if m is None:
|
||||||
|
raise NoCommitBackupError()
|
||||||
|
elif retry_after_failure and not no_retry:
|
||||||
|
m = self.read_backup_message()
|
||||||
|
if m is None:
|
||||||
|
m = self.prompt_commit_questions()
|
||||||
|
else:
|
||||||
|
m = self.prompt_commit_questions()
|
||||||
|
|
||||||
|
if manual_edit:
|
||||||
|
m = self.manual_edit(m)
|
||||||
|
|
||||||
|
out.info(f"\n{m}\n")
|
||||||
|
|
||||||
|
if write_message_to_file:
|
||||||
|
with smart_open(write_message_to_file, "w", encoding=self.encoding) as file:
|
||||||
|
file.write(m)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
raise DryRunExit()
|
||||||
|
|
||||||
|
always_signoff: bool = self.config.settings["always_signoff"]
|
||||||
|
signoff: bool = self.arguments.get("signoff")
|
||||||
|
|
||||||
|
if signoff:
|
||||||
|
out.warn(
|
||||||
|
"signoff mechanic is deprecated, please use `cz commit -- -s` instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
if always_signoff or signoff:
|
||||||
|
extra_args = f"{extra_args} -s".strip()
|
||||||
|
|
||||||
|
c = git.commit(m, args=extra_args)
|
||||||
|
|
||||||
|
if c.return_code != 0:
|
||||||
|
out.error(c.err)
|
||||||
|
|
||||||
|
# Create commit backup
|
||||||
|
with smart_open(self.temp_file, "w", encoding=self.encoding) as f:
|
||||||
|
f.write(m)
|
||||||
|
|
||||||
|
raise CommitError()
|
||||||
|
|
||||||
|
if "nothing added" in c.out or "no changes added to commit" in c.out:
|
||||||
|
out.error(c.out)
|
||||||
|
else:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.remove(self.temp_file)
|
||||||
|
out.write(c.err)
|
||||||
|
out.write(c.out)
|
||||||
|
out.success("Commit successful!")
|
13
commitizen/commands/example.py
Normal file
13
commitizen/commands/example.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from commitizen import factory, out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Example:
|
||||||
|
"""Show an example so people understands the rules."""
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig, *args):
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
out.write(self.cz.example())
|
13
commitizen/commands/info.py
Normal file
13
commitizen/commands/info.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from commitizen import factory, out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Info:
|
||||||
|
"""Show in depth explanation of your rules."""
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig, *args):
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
out.write(self.cz.info())
|
374
commitizen/commands/init.py
Normal file
374
commitizen/commands/init.py
Normal file
|
@ -0,0 +1,374 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import questionary
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
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.defaults import DEFAULT_SETTINGS, config_files
|
||||||
|
from commitizen.exceptions import InitFailedError, NoAnswersError
|
||||||
|
from commitizen.git import get_latest_tag_name, get_tag_names, smart_open
|
||||||
|
from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectInfo:
|
||||||
|
"""Discover information about the current folder."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pyproject(self) -> bool:
|
||||||
|
return os.path.isfile("pyproject.toml")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_uv_lock(self) -> bool:
|
||||||
|
return os.path.isfile("uv.lock")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_setup(self) -> bool:
|
||||||
|
return os.path.isfile("setup.py")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pre_commit_config(self) -> bool:
|
||||||
|
return os.path.isfile(".pre-commit-config.yaml")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_python_uv(self) -> bool:
|
||||||
|
return self.has_pyproject and self.has_uv_lock
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_python_poetry(self) -> bool:
|
||||||
|
if not self.has_pyproject:
|
||||||
|
return False
|
||||||
|
with open("pyproject.toml") as f:
|
||||||
|
return "[tool.poetry]" in f.read()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_python(self) -> bool:
|
||||||
|
return self.has_pyproject or self.has_setup
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rust_cargo(self) -> bool:
|
||||||
|
return os.path.isfile("Cargo.toml")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_npm_package(self) -> bool:
|
||||||
|
return os.path.isfile("package.json")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_php_composer(self) -> bool:
|
||||||
|
return os.path.isfile("composer.json")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_tag(self) -> str | None:
|
||||||
|
return get_latest_tag_name()
|
||||||
|
|
||||||
|
def tags(self) -> list | None:
|
||||||
|
"""Not a property, only use if necessary"""
|
||||||
|
if self.latest_tag is None:
|
||||||
|
return None
|
||||||
|
return get_tag_names()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pre_commit_installed(self) -> bool:
|
||||||
|
return bool(shutil.which("pre-commit"))
|
||||||
|
|
||||||
|
|
||||||
|
class Init:
|
||||||
|
def __init__(self, config: BaseConfig, *args):
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
self.encoding = config.settings["encoding"]
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
self.project_info = ProjectInfo()
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
if self.config.path:
|
||||||
|
out.line(f"Config file {self.config.path} already exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
out.info("Welcome to commitizen!\n")
|
||||||
|
out.line(
|
||||||
|
"Answer the questions to configure your project.\n"
|
||||||
|
"For further configuration visit:\n"
|
||||||
|
"\n"
|
||||||
|
"https://commitizen-tools.github.io/commitizen/config/"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect information
|
||||||
|
try:
|
||||||
|
config_path = self._ask_config_path() # select
|
||||||
|
cz_name = self._ask_name() # select
|
||||||
|
version_provider = self._ask_version_provider() # select
|
||||||
|
tag = self._ask_tag() # confirm & select
|
||||||
|
version_scheme = self._ask_version_scheme() # select
|
||||||
|
version = get_version_scheme(self.config.settings, version_scheme)(tag)
|
||||||
|
tag_format = self._ask_tag_format(tag) # confirm & text
|
||||||
|
update_changelog_on_bump = self._ask_update_changelog_on_bump() # confirm
|
||||||
|
major_version_zero = self._ask_major_version_zero(version) # confirm
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise InitFailedError("Stopped by user")
|
||||||
|
|
||||||
|
# Initialize configuration
|
||||||
|
if "toml" in config_path:
|
||||||
|
self.config = TomlConfig(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)
|
||||||
|
values_to_add = {}
|
||||||
|
values_to_add["name"] = cz_name
|
||||||
|
values_to_add["tag_format"] = tag_format
|
||||||
|
values_to_add["version_scheme"] = version_scheme
|
||||||
|
|
||||||
|
if version_provider == "commitizen":
|
||||||
|
values_to_add["version"] = version.public
|
||||||
|
else:
|
||||||
|
values_to_add["version_provider"] = version_provider
|
||||||
|
|
||||||
|
if update_changelog_on_bump:
|
||||||
|
values_to_add["update_changelog_on_bump"] = update_changelog_on_bump
|
||||||
|
|
||||||
|
if major_version_zero:
|
||||||
|
values_to_add["major_version_zero"] = major_version_zero
|
||||||
|
|
||||||
|
# Collect hook data
|
||||||
|
hook_types = questionary.checkbox(
|
||||||
|
"What types of pre-commit hook you want to install? (Leave blank if you don't want to install)",
|
||||||
|
choices=[
|
||||||
|
questionary.Choice("commit-msg", checked=False),
|
||||||
|
questionary.Choice("pre-push", checked=False),
|
||||||
|
],
|
||||||
|
).unsafe_ask()
|
||||||
|
if hook_types:
|
||||||
|
try:
|
||||||
|
self._install_pre_commit_hook(hook_types)
|
||||||
|
except InitFailedError as e:
|
||||||
|
raise InitFailedError(f"Failed to install pre-commit hook.\n{e}")
|
||||||
|
|
||||||
|
# Create and initialize config
|
||||||
|
self.config.init_empty_config_content()
|
||||||
|
self._update_config_file(values_to_add)
|
||||||
|
|
||||||
|
out.write("\nYou can bump the version running:\n")
|
||||||
|
out.info("\tcz bump\n")
|
||||||
|
out.success("Configuration complete 🚀")
|
||||||
|
|
||||||
|
def _ask_config_path(self) -> str:
|
||||||
|
default_path = ".cz.toml"
|
||||||
|
if self.project_info.has_pyproject:
|
||||||
|
default_path = "pyproject.toml"
|
||||||
|
|
||||||
|
name: str = questionary.select(
|
||||||
|
"Please choose a supported config file: ",
|
||||||
|
choices=config_files,
|
||||||
|
default=default_path,
|
||||||
|
style=self.cz.style,
|
||||||
|
).unsafe_ask()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _ask_name(self) -> str:
|
||||||
|
name: str = questionary.select(
|
||||||
|
"Please choose a cz (commit rule): (default: cz_conventional_commits)",
|
||||||
|
choices=list(registry.keys()),
|
||||||
|
default="cz_conventional_commits",
|
||||||
|
style=self.cz.style,
|
||||||
|
).unsafe_ask()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _ask_tag(self) -> str:
|
||||||
|
latest_tag = self.project_info.latest_tag
|
||||||
|
if not latest_tag:
|
||||||
|
out.error("No Existing Tag. Set tag to v0.0.1")
|
||||||
|
return "0.0.1"
|
||||||
|
|
||||||
|
is_correct_tag = questionary.confirm(
|
||||||
|
f"Is {latest_tag} the latest tag?", style=self.cz.style, default=False
|
||||||
|
).unsafe_ask()
|
||||||
|
if not is_correct_tag:
|
||||||
|
tags = self.project_info.tags()
|
||||||
|
if not tags:
|
||||||
|
out.error("No Existing Tag. Set tag to v0.0.1")
|
||||||
|
return "0.0.1"
|
||||||
|
|
||||||
|
# the latest tag is most likely with the largest number. Thus list the tags in reverse order makes more sense
|
||||||
|
sorted_tags = sorted(tags, reverse=True)
|
||||||
|
latest_tag = questionary.select(
|
||||||
|
"Please choose the latest tag: ",
|
||||||
|
choices=sorted_tags,
|
||||||
|
style=self.cz.style,
|
||||||
|
).unsafe_ask()
|
||||||
|
|
||||||
|
if not latest_tag:
|
||||||
|
raise NoAnswersError("Tag is required!")
|
||||||
|
return latest_tag
|
||||||
|
|
||||||
|
def _ask_tag_format(self, latest_tag) -> str:
|
||||||
|
is_correct_format = False
|
||||||
|
if latest_tag.startswith("v"):
|
||||||
|
tag_format = r"v$version"
|
||||||
|
is_correct_format = questionary.confirm(
|
||||||
|
f'Is "{tag_format}" the correct tag format?', style=self.cz.style
|
||||||
|
).unsafe_ask()
|
||||||
|
|
||||||
|
default_format = DEFAULT_SETTINGS["tag_format"]
|
||||||
|
if not is_correct_format:
|
||||||
|
tag_format = questionary.text(
|
||||||
|
f'Please enter the correct version format: (default: "{default_format}")',
|
||||||
|
style=self.cz.style,
|
||||||
|
).unsafe_ask()
|
||||||
|
|
||||||
|
if not tag_format:
|
||||||
|
tag_format = default_format
|
||||||
|
return tag_format
|
||||||
|
|
||||||
|
def _ask_version_provider(self) -> str:
|
||||||
|
"""Ask for setting: version_provider"""
|
||||||
|
|
||||||
|
OPTS = {
|
||||||
|
"commitizen": "commitizen: Fetch and set version in commitizen config (default)",
|
||||||
|
"cargo": "cargo: Get and set version from Cargo.toml:project.version field",
|
||||||
|
"composer": "composer: Get and set version from composer.json:project.version field",
|
||||||
|
"npm": "npm: Get and set version from package.json:project.version field",
|
||||||
|
"pep621": "pep621: Get and set version from pyproject.toml:project.version field",
|
||||||
|
"poetry": "poetry: Get and set version from pyproject.toml:tool.poetry.version field",
|
||||||
|
"uv": "uv: Get and Get and set version from pyproject.toml and uv.lock",
|
||||||
|
"scm": "scm: Fetch the version from git and does not need to set it back",
|
||||||
|
}
|
||||||
|
|
||||||
|
default_val = "commitizen"
|
||||||
|
if self.project_info.is_python:
|
||||||
|
if self.project_info.is_python_poetry:
|
||||||
|
default_val = "poetry"
|
||||||
|
elif self.project_info.is_python_uv:
|
||||||
|
default_val = "uv"
|
||||||
|
else:
|
||||||
|
default_val = "pep621"
|
||||||
|
elif self.project_info.is_rust_cargo:
|
||||||
|
default_val = "cargo"
|
||||||
|
elif self.project_info.is_npm_package:
|
||||||
|
default_val = "npm"
|
||||||
|
elif self.project_info.is_php_composer:
|
||||||
|
default_val = "composer"
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
questionary.Choice(title=title, value=value)
|
||||||
|
for value, title in OPTS.items()
|
||||||
|
]
|
||||||
|
default = next(filter(lambda x: x.value == default_val, choices))
|
||||||
|
version_provider: str = questionary.select(
|
||||||
|
"Choose the source of the version:",
|
||||||
|
choices=choices,
|
||||||
|
style=self.cz.style,
|
||||||
|
default=default,
|
||||||
|
).unsafe_ask()
|
||||||
|
return version_provider
|
||||||
|
|
||||||
|
def _ask_version_scheme(self) -> str:
|
||||||
|
"""Ask for setting: version_scheme"""
|
||||||
|
default = "semver"
|
||||||
|
if self.project_info.is_python:
|
||||||
|
default = "pep440"
|
||||||
|
|
||||||
|
scheme: str = questionary.select(
|
||||||
|
"Choose version scheme: ",
|
||||||
|
choices=list(KNOWN_SCHEMES),
|
||||||
|
style=self.cz.style,
|
||||||
|
default=default,
|
||||||
|
).unsafe_ask()
|
||||||
|
return scheme
|
||||||
|
|
||||||
|
def _ask_major_version_zero(self, version: Version) -> bool:
|
||||||
|
"""Ask for setting: major_version_zero"""
|
||||||
|
if version.major > 0:
|
||||||
|
return False
|
||||||
|
major_version_zero: bool = questionary.confirm(
|
||||||
|
"Keep major version zero (0.x) during breaking changes",
|
||||||
|
default=True,
|
||||||
|
auto_enter=True,
|
||||||
|
).unsafe_ask()
|
||||||
|
return major_version_zero
|
||||||
|
|
||||||
|
def _ask_update_changelog_on_bump(self) -> bool:
|
||||||
|
"Ask for setting: update_changelog_on_bump"
|
||||||
|
update_changelog_on_bump: bool = questionary.confirm(
|
||||||
|
"Create changelog automatically on bump",
|
||||||
|
default=True,
|
||||||
|
auto_enter=True,
|
||||||
|
).unsafe_ask()
|
||||||
|
return update_changelog_on_bump
|
||||||
|
|
||||||
|
def _exec_install_pre_commit_hook(self, hook_types: list[str]):
|
||||||
|
cmd_str = self._gen_pre_commit_cmd(hook_types)
|
||||||
|
c = cmd.run(cmd_str)
|
||||||
|
if c.return_code != 0:
|
||||||
|
err_msg = (
|
||||||
|
f"Error running {cmd_str}."
|
||||||
|
"Outputs are attached below:\n"
|
||||||
|
f"stdout: {c.out}\n"
|
||||||
|
f"stderr: {c.err}"
|
||||||
|
)
|
||||||
|
raise InitFailedError(err_msg)
|
||||||
|
|
||||||
|
def _gen_pre_commit_cmd(self, hook_types: list[str]) -> str:
|
||||||
|
"""Generate pre-commit command according to given hook types"""
|
||||||
|
if not hook_types:
|
||||||
|
raise ValueError("At least 1 hook type should be provided.")
|
||||||
|
cmd_str = "pre-commit install " + " ".join(
|
||||||
|
f"--hook-type {ty}" for ty in hook_types
|
||||||
|
)
|
||||||
|
return cmd_str
|
||||||
|
|
||||||
|
def _install_pre_commit_hook(self, hook_types: list[str] | None = None):
|
||||||
|
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"},
|
||||||
|
{"id": "commitizen-branch", "stages": ["push"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
config_data = {}
|
||||||
|
if not self.project_info.has_pre_commit_config:
|
||||||
|
# .pre-commit-config.yaml does not exist
|
||||||
|
config_data["repos"] = [cz_hook_config]
|
||||||
|
else:
|
||||||
|
with open(
|
||||||
|
pre_commit_config_filename, encoding=self.encoding
|
||||||
|
) 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.yaml exists but there's no "repos" key
|
||||||
|
config_data["repos"] = [cz_hook_config]
|
||||||
|
|
||||||
|
with smart_open(
|
||||||
|
pre_commit_config_filename, "w", encoding=self.encoding
|
||||||
|
) as config_file:
|
||||||
|
yaml.safe_dump(config_data, stream=config_file)
|
||||||
|
|
||||||
|
if not self.project_info.is_pre_commit_installed:
|
||||||
|
raise InitFailedError("pre-commit is not installed in current environment.")
|
||||||
|
if hook_types is None:
|
||||||
|
hook_types = ["commit-msg", "pre-push"]
|
||||||
|
self._exec_install_pre_commit_hook(hook_types)
|
||||||
|
out.write("commitizen pre-commit hook is now installed in your '.git'\n")
|
||||||
|
|
||||||
|
def _update_config_file(self, values: dict[str, Any]):
|
||||||
|
for key, value in values.items():
|
||||||
|
self.config.set_key(key, value)
|
13
commitizen/commands/list_cz.py
Normal file
13
commitizen/commands/list_cz.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from commitizen import out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.cz import registry
|
||||||
|
|
||||||
|
|
||||||
|
class ListCz:
|
||||||
|
"""List currently installed rules."""
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig, *args):
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
out.write("\n".join(registry.keys()))
|
13
commitizen/commands/schema.py
Normal file
13
commitizen/commands/schema.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from commitizen import factory, out
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Schema:
|
||||||
|
"""Show structure of the rule."""
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig, *args):
|
||||||
|
self.config: BaseConfig = config
|
||||||
|
self.cz = factory.commiter_factory(self.config)
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
out.write(self.cz.schema())
|
39
commitizen/commands/version.py
Normal file
39
commitizen/commands/version.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from commitizen import out
|
||||||
|
from commitizen.__version__ import __version__
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.providers import get_provider
|
||||||
|
|
||||||
|
|
||||||
|
class Version:
|
||||||
|
"""Get the version of the installed commitizen or the current project."""
|
||||||
|
|
||||||
|
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("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 = get_provider(self.config).get_version()
|
||||||
|
if version:
|
||||||
|
out.write(f"{version}")
|
||||||
|
else:
|
||||||
|
out.error("No project information in this project.")
|
||||||
|
elif self.parameter.get("verbose"):
|
||||||
|
out.write(f"Installed Commitizen Version: {__version__}")
|
||||||
|
version = get_provider(self.config).get_version()
|
||||||
|
if version:
|
||||||
|
out.write(f"Project Version: {version}")
|
||||||
|
else:
|
||||||
|
out.error("No project information in this project.")
|
||||||
|
else:
|
||||||
|
# if no argument is given, show installed commitizen version
|
||||||
|
out.write(f"{__version__}")
|
58
commitizen/config/__init__.py
Normal file
58
commitizen/config/__init__.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from commitizen import defaults, git
|
||||||
|
from commitizen.exceptions import ConfigFileIsEmpty, ConfigFileNotFound
|
||||||
|
|
||||||
|
from .base_config import BaseConfig
|
||||||
|
from .json_config import JsonConfig
|
||||||
|
from .toml_config import TomlConfig
|
||||||
|
from .yaml_config import YAMLConfig
|
||||||
|
|
||||||
|
|
||||||
|
def read_cfg(filepath: str | None = None) -> BaseConfig:
|
||||||
|
conf = BaseConfig()
|
||||||
|
|
||||||
|
if filepath is not None:
|
||||||
|
if not Path(filepath).exists():
|
||||||
|
raise ConfigFileNotFound()
|
||||||
|
|
||||||
|
cfg_paths = (path for path in (Path(filepath),))
|
||||||
|
else:
|
||||||
|
git_project_root = git.find_git_project_root()
|
||||||
|
cfg_search_paths = [Path(".")]
|
||||||
|
if git_project_root:
|
||||||
|
cfg_search_paths.append(git_project_root)
|
||||||
|
|
||||||
|
cfg_paths = (
|
||||||
|
path / Path(filename)
|
||||||
|
for path in cfg_search_paths
|
||||||
|
for filename in defaults.config_files
|
||||||
|
)
|
||||||
|
|
||||||
|
for filename in cfg_paths:
|
||||||
|
if not filename.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
_conf: TomlConfig | JsonConfig | YAMLConfig
|
||||||
|
|
||||||
|
with open(filename, "rb") as f:
|
||||||
|
data: bytes = f.read()
|
||||||
|
|
||||||
|
if "toml" in filename.suffix:
|
||||||
|
_conf = TomlConfig(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 filepath is not None and _conf.is_empty_config:
|
||||||
|
raise ConfigFileIsEmpty()
|
||||||
|
elif _conf.is_empty_config:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
conf = _conf
|
||||||
|
break
|
||||||
|
|
||||||
|
return conf
|
37
commitizen/config/base_config.py
Normal file
37
commitizen/config/base_config.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from commitizen.defaults import DEFAULT_SETTINGS, Settings
|
||||||
|
|
||||||
|
|
||||||
|
class BaseConfig:
|
||||||
|
def __init__(self):
|
||||||
|
self._settings: Settings = DEFAULT_SETTINGS.copy()
|
||||||
|
self.encoding = self.settings["encoding"]
|
||||||
|
self._path: Path | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings(self) -> Settings:
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path | None:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def update(self, data: Settings) -> None:
|
||||||
|
self._settings.update(data)
|
||||||
|
|
||||||
|
def add_path(self, path: str | Path) -> None:
|
||||||
|
self._path = Path(path)
|
||||||
|
|
||||||
|
def _parse_setting(self, data: bytes | str) -> None:
|
||||||
|
raise NotImplementedError()
|
56
commitizen/config/json_config.py
Normal file
56
commitizen/config/json_config.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from commitizen.exceptions import InvalidConfigurationError
|
||||||
|
from commitizen.git import smart_open
|
||||||
|
|
||||||
|
from .base_config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class JsonConfig(BaseConfig):
|
||||||
|
def __init__(self, *, data: bytes | str, path: Path | str):
|
||||||
|
super().__init__()
|
||||||
|
self.is_empty_config = False
|
||||||
|
self.add_path(path)
|
||||||
|
self._parse_setting(data)
|
||||||
|
|
||||||
|
def init_empty_config_content(self):
|
||||||
|
with smart_open(self.path, "a", encoding=self.encoding) 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 smart_open(self.path, "w", encoding=self.encoding) as f:
|
||||||
|
json.dump(parser, f, indent=2)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _parse_setting(self, data: bytes | str) -> None:
|
||||||
|
"""We expect to have a section in .cz.json looking like
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"commitizen": {
|
||||||
|
"name": "cz_conventional_commits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = json.loads(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.settings.update(doc["commitizen"])
|
||||||
|
except KeyError:
|
||||||
|
self.is_empty_config = True
|
63
commitizen/config/toml_config.py
Normal file
63
commitizen/config/toml_config.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tomlkit import exceptions, parse, table
|
||||||
|
|
||||||
|
from commitizen.exceptions import InvalidConfigurationError
|
||||||
|
|
||||||
|
from .base_config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TomlConfig(BaseConfig):
|
||||||
|
def __init__(self, *, data: bytes | str, path: Path | str):
|
||||||
|
super().__init__()
|
||||||
|
self.is_empty_config = False
|
||||||
|
self.add_path(path)
|
||||||
|
self._parse_setting(data)
|
||||||
|
|
||||||
|
def init_empty_config_content(self):
|
||||||
|
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(self.encoding))
|
||||||
|
|
||||||
|
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 = parse(f.read())
|
||||||
|
|
||||||
|
parser["tool"]["commitizen"][key] = value
|
||||||
|
with open(self.path, "wb") as f:
|
||||||
|
f.write(parser.as_string().encode(self.encoding))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _parse_setting(self, data: bytes | str) -> None:
|
||||||
|
"""We expect to have a section in pyproject looking like
|
||||||
|
|
||||||
|
```
|
||||||
|
[tool.commitizen]
|
||||||
|
name = "cz_conventional_commits"
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = parse(data)
|
||||||
|
except exceptions.ParseError as e:
|
||||||
|
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.settings.update(doc["tool"]["commitizen"]) # type: ignore
|
||||||
|
except exceptions.NonExistentKey:
|
||||||
|
self.is_empty_config = True
|
57
commitizen/config/yaml_config.py
Normal file
57
commitizen/config/yaml_config.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from commitizen.exceptions import InvalidConfigurationError
|
||||||
|
from commitizen.git import smart_open
|
||||||
|
|
||||||
|
from .base_config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class YAMLConfig(BaseConfig):
|
||||||
|
def __init__(self, *, data: bytes | str, path: Path | str):
|
||||||
|
super().__init__()
|
||||||
|
self.is_empty_config = False
|
||||||
|
self.add_path(path)
|
||||||
|
self._parse_setting(data)
|
||||||
|
|
||||||
|
def init_empty_config_content(self):
|
||||||
|
with smart_open(self.path, "a", encoding=self.encoding) as json_file:
|
||||||
|
yaml.dump({"commitizen": {}}, json_file, explicit_start=True)
|
||||||
|
|
||||||
|
def _parse_setting(self, data: bytes | str) -> None:
|
||||||
|
"""We expect to have a section in cz.yaml looking like
|
||||||
|
|
||||||
|
```
|
||||||
|
commitizen:
|
||||||
|
name: cz_conventional_commits
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
import yaml.scanner
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(data)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
|
||||||
|
|
||||||
|
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 smart_open(self.path, "w", encoding=self.encoding) as yaml_file:
|
||||||
|
yaml.dump(parser, yaml_file, explicit_start=True)
|
||||||
|
|
||||||
|
return self
|
44
commitizen/cz/__init__.py
Normal file
44
commitizen/cz/__init__.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from importlib import metadata
|
||||||
|
else:
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
|
||||||
|
from commitizen.cz.base import BaseCommitizen
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins(
|
||||||
|
path: Iterable[str] | None = None,
|
||||||
|
) -> dict[str, type[BaseCommitizen]]:
|
||||||
|
"""Discover commitizen plugins on the path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path, optional): If provided, 'path' should be either None or a list of paths to look for
|
||||||
|
modules in. If path is None, all top-level modules on sys.path.. Defaults to None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Type[BaseCommitizen]]: Registry with found plugins
|
||||||
|
"""
|
||||||
|
for _, name, _ in pkgutil.iter_modules(path):
|
||||||
|
if name.startswith("cz_"):
|
||||||
|
mod = importlib.import_module(name)
|
||||||
|
if hasattr(mod, "discover_this"):
|
||||||
|
warnings.warn(
|
||||||
|
UserWarning(
|
||||||
|
f"Legacy plugin '{name}' has been ignored: please expose it the 'commitizen.plugin' entrypoint"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ep.name: ep.load() for ep in metadata.entry_points(group="commitizen.plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
registry: dict[str, type[BaseCommitizen]] = discover_plugins()
|
108
commitizen/cz/base.py
Normal file
108
commitizen/cz/base.py
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
from jinja2 import BaseLoader, PackageLoader
|
||||||
|
from prompt_toolkit.styles import Style, merge_styles
|
||||||
|
|
||||||
|
from commitizen import git
|
||||||
|
from commitizen.config.base_config import BaseConfig
|
||||||
|
from commitizen.defaults import Questions
|
||||||
|
|
||||||
|
|
||||||
|
class MessageBuilderHook(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self, message: dict[str, Any], commit: git.GitCommit
|
||||||
|
) -> dict[str, Any] | Iterable[dict[str, Any]] | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogReleaseHook(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self, release: dict[str, Any], tag: git.GitTag | None
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCommitizen(metaclass=ABCMeta):
|
||||||
|
bump_pattern: str | None = None
|
||||||
|
bump_map: dict[str, str] | None = None
|
||||||
|
bump_map_major_version_zero: dict[str, str] | None = None
|
||||||
|
default_style_config: list[tuple[str, str]] = [
|
||||||
|
("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 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: str | None = r"(?P<message>.*)"
|
||||||
|
changelog_pattern: str | None = r".*"
|
||||||
|
change_type_map: dict[str, str] | None = None
|
||||||
|
change_type_order: list[str] | None = None
|
||||||
|
|
||||||
|
# Executed per message parsed by the commitizen
|
||||||
|
changelog_message_builder_hook: MessageBuilderHook | None = None
|
||||||
|
|
||||||
|
# Executed only at the end of the changelog generation
|
||||||
|
changelog_hook: Callable[[str, str | None], str] | None = None
|
||||||
|
|
||||||
|
# Executed for each release in the changelog
|
||||||
|
changelog_release_hook: ChangelogReleaseHook | None = None
|
||||||
|
|
||||||
|
# Plugins can override templates and provide extra template data
|
||||||
|
template_loader: BaseLoader = PackageLoader("commitizen", "templates")
|
||||||
|
template_extras: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
if not self.config.settings.get("style"):
|
||||||
|
self.config.settings.update({"style": BaseCommitizen.default_style_config})
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def questions(self) -> Questions:
|
||||||
|
"""Questions regarding the commit message."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def message(self, answers: dict) -> str:
|
||||||
|
"""Format your git message."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def style(self):
|
||||||
|
return merge_styles(
|
||||||
|
[
|
||||||
|
Style(BaseCommitizen.default_style_config),
|
||||||
|
Style(self.config.settings["style"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def example(self) -> str:
|
||||||
|
"""Example of the commit message."""
|
||||||
|
raise NotImplementedError("Not Implemented yet")
|
||||||
|
|
||||||
|
def schema(self) -> 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."""
|
||||||
|
raise NotImplementedError("Not Implemented yet")
|
||||||
|
|
||||||
|
def info(self) -> 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]
|
1
commitizen/cz/conventional_commits/__init__.py
Normal file
1
commitizen/cz/conventional_commits/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .conventional_commits import ConventionalCommitsCz # noqa: F401
|
212
commitizen/cz/conventional_commits/conventional_commits.py
Normal file
212
commitizen/cz/conventional_commits/conventional_commits.py
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from commitizen import defaults
|
||||||
|
from commitizen.cz.base import BaseCommitizen
|
||||||
|
from commitizen.cz.utils import multiple_line_breaker, required_validator
|
||||||
|
from commitizen.defaults import Questions
|
||||||
|
|
||||||
|
__all__ = ["ConventionalCommitsCz"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scope(text):
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
scope = text.strip().split()
|
||||||
|
if len(scope) == 1:
|
||||||
|
return scope[0]
|
||||||
|
|
||||||
|
return "-".join(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_subject(text):
|
||||||
|
if isinstance(text, str):
|
||||||
|
text = text.strip(".").strip()
|
||||||
|
|
||||||
|
return required_validator(text, msg="Subject is required.")
|
||||||
|
|
||||||
|
|
||||||
|
class ConventionalCommitsCz(BaseCommitizen):
|
||||||
|
bump_pattern = defaults.bump_pattern
|
||||||
|
bump_map = defaults.bump_map
|
||||||
|
bump_map_major_version_zero = defaults.bump_map_major_version_zero
|
||||||
|
commit_parser = r"^((?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?|\w+!):\s(?P<message>.*)?" # noqa
|
||||||
|
change_type_map = {
|
||||||
|
"feat": "Feat",
|
||||||
|
"fix": "Fix",
|
||||||
|
"refactor": "Refactor",
|
||||||
|
"perf": "Perf",
|
||||||
|
}
|
||||||
|
changelog_pattern = defaults.bump_pattern
|
||||||
|
|
||||||
|
def questions(self) -> Questions:
|
||||||
|
questions: Questions = [
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"name": "prefix",
|
||||||
|
"message": "Select the type of change you are committing",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"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": "style",
|
||||||
|
"name": (
|
||||||
|
"style: Changes that do not affect the "
|
||||||
|
"meaning of the code (white-space, formatting,"
|
||||||
|
" missing semi-colons, etc)"
|
||||||
|
),
|
||||||
|
"key": "s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "refactor",
|
||||||
|
"name": (
|
||||||
|
"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",
|
||||||
|
"name": (
|
||||||
|
"build: Changes that affect the build system or "
|
||||||
|
"external dependencies (example scopes: pip, docker, npm)"
|
||||||
|
),
|
||||||
|
"key": "b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "ci",
|
||||||
|
"name": (
|
||||||
|
"ci: Changes to CI configuration files and "
|
||||||
|
"scripts (example scopes: GitLabCI)"
|
||||||
|
),
|
||||||
|
"key": "c",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "scope",
|
||||||
|
"message": (
|
||||||
|
"What is the scope of this change? (class or file name): (press [enter] to skip)\n"
|
||||||
|
),
|
||||||
|
"filter": parse_scope,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "subject",
|
||||||
|
"filter": parse_subject,
|
||||||
|
"message": (
|
||||||
|
"Write a short and imperative summary of the code changes: (lower case and no period)\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "body",
|
||||||
|
"message": (
|
||||||
|
"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: (press [enter] to skip)\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return questions
|
||||||
|
|
||||||
|
def message(self, answers: dict) -> str:
|
||||||
|
prefix = answers["prefix"]
|
||||||
|
scope = answers["scope"]
|
||||||
|
subject = answers["subject"]
|
||||||
|
body = answers["body"]
|
||||||
|
footer = answers["footer"]
|
||||||
|
is_breaking_change = answers["is_breaking_change"]
|
||||||
|
|
||||||
|
if scope:
|
||||||
|
scope = f"({scope})"
|
||||||
|
if body:
|
||||||
|
body = f"\n\n{body}"
|
||||||
|
if is_breaking_change:
|
||||||
|
footer = f"BREAKING CHANGE: {footer}"
|
||||||
|
if footer:
|
||||||
|
footer = f"\n\n{footer}"
|
||||||
|
|
||||||
|
message = f"{prefix}{scope}: {subject}{body}{footer}"
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def example(self) -> str:
|
||||||
|
return (
|
||||||
|
"fix: correct minor typos in code\n"
|
||||||
|
"\n"
|
||||||
|
"see the issue for details on the typos fixed\n"
|
||||||
|
"\n"
|
||||||
|
"closes issue #12"
|
||||||
|
)
|
||||||
|
|
||||||
|
def schema(self) -> str:
|
||||||
|
return (
|
||||||
|
"<type>(<scope>): <subject>\n"
|
||||||
|
"<BLANK LINE>\n"
|
||||||
|
"<body>\n"
|
||||||
|
"<BLANK LINE>\n"
|
||||||
|
"(BREAKING CHANGE: )<footer>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def schema_pattern(self) -> str:
|
||||||
|
PATTERN = (
|
||||||
|
r"(?s)" # To explicitly make . match new line
|
||||||
|
r"(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)" # type
|
||||||
|
r"(\(\S+\))?!?:" # scope
|
||||||
|
r"( [^\n\r]+)" # subject
|
||||||
|
r"((\n\n.*)|(\s*))?$"
|
||||||
|
)
|
||||||
|
return PATTERN
|
||||||
|
|
||||||
|
def info(self) -> str:
|
||||||
|
dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
filepath = os.path.join(dir_path, "conventional_commits_info.txt")
|
||||||
|
with open(filepath, encoding=self.config.settings["encoding"]) 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()
|
|
@ -0,0 +1,31 @@
|
||||||
|
The commit contains the following structural elements, to communicate
|
||||||
|
intent to the consumers of your library:
|
||||||
|
|
||||||
|
fix: a commit of the type fix patches a bug in your codebase
|
||||||
|
(this correlates with PATCH in semantic versioning).
|
||||||
|
|
||||||
|
feat: a commit of the type feat introduces a new feature to the codebase
|
||||||
|
(this correlates with MINOR in semantic versioning).
|
||||||
|
|
||||||
|
BREAKING CHANGE: a commit that has the text BREAKING CHANGE: at the beginning of
|
||||||
|
its optional body or footer section introduces a breaking API change
|
||||||
|
(correlating with MAJOR in semantic versioning).
|
||||||
|
A BREAKING CHANGE can be part of commits of any type.
|
||||||
|
|
||||||
|
Others: commit types other than fix: and feat: are allowed,
|
||||||
|
like chore:, docs:, style:, refactor:, perf:, test:, and others.
|
||||||
|
|
||||||
|
We also recommend improvement for commits that improve a current
|
||||||
|
implementation without adding a new feature or fixing a bug.
|
||||||
|
|
||||||
|
Notice these types are not mandated by the conventional commits specification,
|
||||||
|
and have no implicit effect in semantic versioning (unless they include a BREAKING CHANGE).
|
||||||
|
|
||||||
|
A scope may be provided to a commit’s type, to provide additional contextual
|
||||||
|
information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays.
|
||||||
|
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
1
commitizen/cz/customize/__init__.py
Normal file
1
commitizen/cz/customize/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .customize import CustomizeCommitsCz # noqa
|
94
commitizen/cz/customize/customize.py
Normal file
94
commitizen/cz/customize/customize.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from jinja2 import Template
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from jinja2 import Template
|
||||||
|
except ImportError:
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
|
||||||
|
from commitizen import defaults
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.cz.base import BaseCommitizen
|
||||||
|
from commitizen.defaults import Questions
|
||||||
|
from commitizen.exceptions import MissingCzCustomizeConfigError
|
||||||
|
|
||||||
|
__all__ = ["CustomizeCommitsCz"]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizeCommitsCz(BaseCommitizen):
|
||||||
|
bump_pattern = defaults.bump_pattern
|
||||||
|
bump_map = defaults.bump_map
|
||||||
|
bump_map_major_version_zero = defaults.bump_map_major_version_zero
|
||||||
|
change_type_order = defaults.change_type_order
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
|
||||||
|
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:
|
||||||
|
self.bump_pattern = custom_bump_pattern
|
||||||
|
|
||||||
|
custom_bump_map = self.custom_settings.get("bump_map")
|
||||||
|
if custom_bump_map:
|
||||||
|
self.bump_map = custom_bump_map
|
||||||
|
|
||||||
|
custom_bump_map_major_version_zero = self.custom_settings.get(
|
||||||
|
"bump_map_major_version_zero"
|
||||||
|
)
|
||||||
|
if custom_bump_map_major_version_zero:
|
||||||
|
self.bump_map_major_version_zero = custom_bump_map_major_version_zero
|
||||||
|
|
||||||
|
custom_change_type_order = self.custom_settings.get("change_type_order")
|
||||||
|
if custom_change_type_order:
|
||||||
|
self.change_type_order = custom_change_type_order
|
||||||
|
|
||||||
|
commit_parser = self.custom_settings.get("commit_parser")
|
||||||
|
if commit_parser:
|
||||||
|
self.commit_parser = commit_parser
|
||||||
|
|
||||||
|
changelog_pattern = self.custom_settings.get("changelog_pattern")
|
||||||
|
if changelog_pattern:
|
||||||
|
self.changelog_pattern = changelog_pattern
|
||||||
|
|
||||||
|
change_type_map = self.custom_settings.get("change_type_map")
|
||||||
|
if change_type_map:
|
||||||
|
self.change_type_map = change_type_map
|
||||||
|
|
||||||
|
def questions(self) -> Questions:
|
||||||
|
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) # type: ignore
|
||||||
|
else:
|
||||||
|
return message_template.render(**answers)
|
||||||
|
|
||||||
|
def example(self) -> str:
|
||||||
|
return self.custom_settings.get("example") or ""
|
||||||
|
|
||||||
|
def schema_pattern(self) -> str:
|
||||||
|
return self.custom_settings.get("schema_pattern") or ""
|
||||||
|
|
||||||
|
def schema(self) -> str:
|
||||||
|
return self.custom_settings.get("schema") or ""
|
||||||
|
|
||||||
|
def info(self) -> str:
|
||||||
|
info_path = self.custom_settings.get("info_path")
|
||||||
|
info = self.custom_settings.get("info")
|
||||||
|
if info_path:
|
||||||
|
with open(info_path, encoding=self.config.settings["encoding"]) as f:
|
||||||
|
content = f.read()
|
||||||
|
return content
|
||||||
|
elif info:
|
||||||
|
return info
|
||||||
|
return ""
|
0
commitizen/cz/customize/customize_info.txt
Normal file
0
commitizen/cz/customize/customize_info.txt
Normal file
4
commitizen/cz/exceptions.py
Normal file
4
commitizen/cz/exceptions.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class CzException(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerRequiredError(CzException): ...
|
3
commitizen/cz/jira/__init__.py
Normal file
3
commitizen/cz/jira/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .jira import JiraSmartCz
|
||||||
|
|
||||||
|
__all__ = ["JiraSmartCz"]
|
81
commitizen/cz/jira/jira.py
Normal file
81
commitizen/cz/jira/jira.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from commitizen.cz.base import BaseCommitizen
|
||||||
|
from commitizen.defaults import Questions
|
||||||
|
|
||||||
|
__all__ = ["JiraSmartCz"]
|
||||||
|
|
||||||
|
|
||||||
|
class JiraSmartCz(BaseCommitizen):
|
||||||
|
def questions(self) -> Questions:
|
||||||
|
questions = [
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "message",
|
||||||
|
"message": "Git commit message (required):\n",
|
||||||
|
# 'validate': RequiredValidator,
|
||||||
|
"filter": lambda x: x.strip(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "issues",
|
||||||
|
"message": "Jira Issue ID(s) separated by spaces (required):\n",
|
||||||
|
# 'validate': RequiredValidator,
|
||||||
|
"filter": lambda x: x.strip(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "workflow",
|
||||||
|
"message": "Workflow command (testing, closed, etc.) (optional):\n",
|
||||||
|
"filter": lambda x: "#" + x.strip().replace(" ", "-") if x else "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "time",
|
||||||
|
"message": "Time spent (i.e. 3h 15m) (optional):\n",
|
||||||
|
"filter": lambda x: "#time " + x if x else "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"name": "comment",
|
||||||
|
"message": "Jira comment (optional):\n",
|
||||||
|
"filter": lambda x: "#comment " + x if x else "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return questions
|
||||||
|
|
||||||
|
def message(self, answers: dict) -> str:
|
||||||
|
return " ".join(
|
||||||
|
filter(
|
||||||
|
bool,
|
||||||
|
[
|
||||||
|
answers["message"],
|
||||||
|
answers["issues"],
|
||||||
|
answers["workflow"],
|
||||||
|
answers["time"],
|
||||||
|
answers["comment"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def example(self) -> str:
|
||||||
|
return (
|
||||||
|
"JRA-34 #comment corrected indent issue\n"
|
||||||
|
"JRA-35 #time 1w 2d 4h 30m Total work logged\n"
|
||||||
|
"JRA-123 JRA-234 JRA-345 #resolve\n"
|
||||||
|
"JRA-123 JRA-234 JRA-345 #resolve #time 2d 5h #comment Task completed "
|
||||||
|
"ahead of schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) -> str:
|
||||||
|
dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
filepath = os.path.join(dir_path, "jira_info.txt")
|
||||||
|
with open(filepath, encoding=self.config.settings["encoding"]) as f:
|
||||||
|
content = f.read()
|
||||||
|
return content
|
84
commitizen/cz/jira/jira_info.txt
Normal file
84
commitizen/cz/jira/jira_info.txt
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
Smart Commits allow repository committers to perform actions such as transitioning JIRA Software
|
||||||
|
issues or creating Crucible code reviews by embedding specific commands into their commit messages.
|
||||||
|
|
||||||
|
You can:
|
||||||
|
* comment on issues
|
||||||
|
* record time tracking information against issues
|
||||||
|
* transition issues to any status defined in the JIRA Software project's workflow.
|
||||||
|
|
||||||
|
There are other actions available if you use Crucible.
|
||||||
|
|
||||||
|
Each Smart Commit message must not span more than one line (i.e. you cannot use a carriage return in
|
||||||
|
the command), but you can add multiple commands to the same line. See this example below.
|
||||||
|
|
||||||
|
Smart Commit commands
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
The basic command line syntax for a Smart Commit message is:
|
||||||
|
|
||||||
|
<ignored text> <ISSUE_KEY> <ignored text> #<COMMAND> <optional COMMAND_ARGUMENTS>
|
||||||
|
|
||||||
|
Any text between the issue key and the Smart Commit command is ignored.
|
||||||
|
|
||||||
|
There are three Smart Commit commands you can use in your commit messages:
|
||||||
|
* comment
|
||||||
|
* time
|
||||||
|
* transition
|
||||||
|
|
||||||
|
|
||||||
|
Comment
|
||||||
|
-------
|
||||||
|
|
||||||
|
Description
|
||||||
|
|
||||||
|
Adds a comment to a JIRA Software issue.
|
||||||
|
|
||||||
|
Syntax
|
||||||
|
|
||||||
|
<ignored text> ISSUE_KEY <ignored text> #comment <comment_string>
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
JRA-34 #comment corrected indent issue
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
The committer's email address must match the email address of a single JIRA Software user with
|
||||||
|
permission to comment on issues in that particular project.
|
||||||
|
|
||||||
|
|
||||||
|
Time
|
||||||
|
----
|
||||||
|
|
||||||
|
Description
|
||||||
|
|
||||||
|
Records time tracking information against an issue.
|
||||||
|
|
||||||
|
Syntax
|
||||||
|
|
||||||
|
<ignored text> ISSUE_KEY <ignored text> #time <value>w <value>d <value>h <value>m <comment_string>
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
JRA-34 #time 1w 2d 4h 30m Total work logged
|
||||||
|
|
||||||
|
|
||||||
|
Workflow transitions
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Description
|
||||||
|
|
||||||
|
Transitions a JIRA Software issue to a particular workflow state.
|
||||||
|
|
||||||
|
Syntax
|
||||||
|
|
||||||
|
<ignored text> ISSUE_KEY <ignored text> #<transition_name> <comment_string>
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
JRA-090 #close Fixed this today
|
||||||
|
|
||||||
|
|
||||||
|
More information
|
||||||
|
----------------
|
||||||
|
https://confluence.atlassian.com/fisheye/using-smart-commits-298976812.html
|
32
commitizen/cz/utils.py
Normal file
32
commitizen/cz/utils.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from commitizen import git
|
||||||
|
from commitizen.cz import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def required_validator(answer, msg=None):
|
||||||
|
if not answer:
|
||||||
|
raise exceptions.AnswerRequiredError(msg)
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
|
def multiple_line_breaker(answer, sep="|"):
|
||||||
|
return "\n".join(line.strip() for line in answer.split(sep) if line)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_local_version(version: str) -> str:
|
||||||
|
return re.sub(r"\+.+", "", version)
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_file_path() -> str:
|
||||||
|
project_root = git.find_git_project_root()
|
||||||
|
|
||||||
|
if project_root is None:
|
||||||
|
project = ""
|
||||||
|
else:
|
||||||
|
project = project_root.as_posix().replace("/", "%")
|
||||||
|
|
||||||
|
user = os.environ.get("USER", "")
|
||||||
|
return os.path.join(tempfile.gettempdir(), f"cz.commit%{user}%{project}.backup")
|
156
commitizen/defaults.py
Normal file
156
commitizen/defaults.py
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Iterable, MutableMapping, Sequence
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
# Type
|
||||||
|
Questions = Iterable[MutableMapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class CzSettings(TypedDict, total=False):
|
||||||
|
bump_pattern: str
|
||||||
|
bump_map: OrderedDict[str, str]
|
||||||
|
bump_map_major_version_zero: OrderedDict[str, str]
|
||||||
|
change_type_order: list[str]
|
||||||
|
|
||||||
|
questions: Questions
|
||||||
|
example: str | None
|
||||||
|
schema_pattern: str | None
|
||||||
|
schema: str | None
|
||||||
|
info_path: str | pathlib.Path
|
||||||
|
info: str
|
||||||
|
message_template: str
|
||||||
|
commit_parser: str | None
|
||||||
|
changelog_pattern: str | None
|
||||||
|
change_type_map: dict[str, str] | None
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(TypedDict, total=False):
|
||||||
|
name: str
|
||||||
|
version: str | None
|
||||||
|
version_files: list[str]
|
||||||
|
version_provider: str | None
|
||||||
|
version_scheme: str | None
|
||||||
|
version_type: str | None
|
||||||
|
tag_format: str
|
||||||
|
legacy_tag_formats: Sequence[str]
|
||||||
|
ignored_tag_formats: Sequence[str]
|
||||||
|
bump_message: str | None
|
||||||
|
retry_after_failure: bool
|
||||||
|
allow_abort: bool
|
||||||
|
allowed_prefixes: list[str]
|
||||||
|
changelog_file: str
|
||||||
|
changelog_format: str | None
|
||||||
|
changelog_incremental: bool
|
||||||
|
changelog_start_rev: str | None
|
||||||
|
changelog_merge_prerelease: bool
|
||||||
|
update_changelog_on_bump: bool
|
||||||
|
use_shortcuts: bool
|
||||||
|
style: list[tuple[str, str]]
|
||||||
|
customize: CzSettings
|
||||||
|
major_version_zero: bool
|
||||||
|
pre_bump_hooks: list[str] | None
|
||||||
|
post_bump_hooks: list[str] | None
|
||||||
|
prerelease_offset: int
|
||||||
|
encoding: str
|
||||||
|
always_signoff: bool
|
||||||
|
template: str | None
|
||||||
|
extras: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
name: str = "cz_conventional_commits"
|
||||||
|
config_files: list[str] = [
|
||||||
|
"pyproject.toml",
|
||||||
|
".cz.toml",
|
||||||
|
".cz.json",
|
||||||
|
"cz.json",
|
||||||
|
".cz.yaml",
|
||||||
|
"cz.yaml",
|
||||||
|
"cz.toml",
|
||||||
|
]
|
||||||
|
encoding: str = "utf-8"
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS: Settings = {
|
||||||
|
"name": name,
|
||||||
|
"version": None,
|
||||||
|
"version_files": [],
|
||||||
|
"version_provider": "commitizen",
|
||||||
|
"version_scheme": None,
|
||||||
|
"tag_format": "$version", # example v$version
|
||||||
|
"legacy_tag_formats": [],
|
||||||
|
"ignored_tag_formats": [],
|
||||||
|
"bump_message": None, # bumped v$current_version to $new_version
|
||||||
|
"retry_after_failure": False,
|
||||||
|
"allow_abort": False,
|
||||||
|
"allowed_prefixes": [
|
||||||
|
"Merge",
|
||||||
|
"Revert",
|
||||||
|
"Pull request",
|
||||||
|
"fixup!",
|
||||||
|
"squash!",
|
||||||
|
],
|
||||||
|
"changelog_file": "CHANGELOG.md",
|
||||||
|
"changelog_format": None, # default guessed from changelog_file
|
||||||
|
"changelog_incremental": False,
|
||||||
|
"changelog_start_rev": None,
|
||||||
|
"changelog_merge_prerelease": False,
|
||||||
|
"update_changelog_on_bump": False,
|
||||||
|
"use_shortcuts": False,
|
||||||
|
"major_version_zero": False,
|
||||||
|
"pre_bump_hooks": [],
|
||||||
|
"post_bump_hooks": [],
|
||||||
|
"prerelease_offset": 0,
|
||||||
|
"encoding": encoding,
|
||||||
|
"always_signoff": False,
|
||||||
|
"template": None, # default provided by plugin
|
||||||
|
"extras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
MAJOR = "MAJOR"
|
||||||
|
MINOR = "MINOR"
|
||||||
|
PATCH = "PATCH"
|
||||||
|
|
||||||
|
CHANGELOG_FORMAT = "markdown"
|
||||||
|
|
||||||
|
bump_pattern = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):"
|
||||||
|
bump_map = OrderedDict(
|
||||||
|
(
|
||||||
|
(r"^.+!$", MAJOR),
|
||||||
|
(r"^BREAKING[\-\ ]CHANGE", MAJOR),
|
||||||
|
(r"^feat", MINOR),
|
||||||
|
(r"^fix", PATCH),
|
||||||
|
(r"^refactor", PATCH),
|
||||||
|
(r"^perf", PATCH),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bump_map_major_version_zero = OrderedDict(
|
||||||
|
(
|
||||||
|
(r"^.+!$", MINOR),
|
||||||
|
(r"^BREAKING[\-\ ]CHANGE", MINOR),
|
||||||
|
(r"^feat", MINOR),
|
||||||
|
(r"^fix", PATCH),
|
||||||
|
(r"^refactor", PATCH),
|
||||||
|
(r"^perf", PATCH),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
change_type_order = ["BREAKING CHANGE", "Feat", "Fix", "Refactor", "Perf"]
|
||||||
|
bump_message = "bump: version $current_version → $new_version"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_regexes(
|
||||||
|
version_regex: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
regexs = {
|
||||||
|
"version": version_regex,
|
||||||
|
"major": r"(?P<major>\d+)",
|
||||||
|
"minor": r"(?P<minor>\d+)",
|
||||||
|
"patch": r"(?P<patch>\d+)",
|
||||||
|
"prerelease": r"(?P<prerelease>\w+\d+)?",
|
||||||
|
"devrelease": r"(?P<devrelease>\.dev\d+)?",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**{f"${k}": v for k, v in regexs.items()},
|
||||||
|
**{f"${{{k}}}": v for k, v in regexs.items()},
|
||||||
|
}
|
213
commitizen/exceptions.py
Normal file
213
commitizen/exceptions.py
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
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
|
||||||
|
NOT_ALLOWED = 20
|
||||||
|
NO_INCREMENT = 21
|
||||||
|
UNRECOGNIZED_CHARACTERSET_ENCODING = 22
|
||||||
|
GIT_COMMAND_ERROR = 23
|
||||||
|
INVALID_MANUAL_VERSION = 24
|
||||||
|
INIT_FAILED = 25
|
||||||
|
RUN_HOOK_FAILED = 26
|
||||||
|
VERSION_PROVIDER_UNKNOWN = 27
|
||||||
|
VERSION_SCHEME_UNKNOWN = 28
|
||||||
|
CHANGELOG_FORMAT_UNKNOWN = 29
|
||||||
|
CONFIG_FILE_NOT_FOUND = 30
|
||||||
|
CONFIG_FILE_IS_EMPTY = 31
|
||||||
|
COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED = 32
|
||||||
|
|
||||||
|
|
||||||
|
class CommitizenException(Exception):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.output_method = kwargs.get("output_method") or out.error
|
||||||
|
self.exit_code: ExitCode = 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 GetNextExit(ExpectedExit):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NoneIncrementExit(CommitizenException):
|
||||||
|
exit_code = ExitCode.NO_INCREMENT
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class NotAllowed(CommitizenException):
|
||||||
|
exit_code = ExitCode.NOT_ALLOWED
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterSetDecodeError(CommitizenException):
|
||||||
|
exit_code = ExitCode.UNRECOGNIZED_CHARACTERSET_ENCODING
|
||||||
|
|
||||||
|
|
||||||
|
class GitCommandError(CommitizenException):
|
||||||
|
exit_code = ExitCode.GIT_COMMAND_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidManualVersion(CommitizenException):
|
||||||
|
exit_code = ExitCode.INVALID_MANUAL_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class InitFailedError(CommitizenException):
|
||||||
|
exit_code = ExitCode.INIT_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
class RunHookError(CommitizenException):
|
||||||
|
exit_code = ExitCode.RUN_HOOK_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
class VersionProviderUnknown(CommitizenException):
|
||||||
|
exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class VersionSchemeUnknown(CommitizenException):
|
||||||
|
exit_code = ExitCode.VERSION_SCHEME_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogFormatUnknown(CommitizenException):
|
||||||
|
exit_code = ExitCode.CHANGELOG_FORMAT_UNKNOWN
|
||||||
|
message = "Unknown changelog format identifier"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileNotFound(CommitizenException):
|
||||||
|
exit_code = ExitCode.CONFIG_FILE_NOT_FOUND
|
||||||
|
message = "Cannot found the config file, please check your file path again."
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileIsEmpty(CommitizenException):
|
||||||
|
exit_code = ExitCode.CONFIG_FILE_IS_EMPTY
|
||||||
|
message = "Config file is empty, please check your file path again."
|
||||||
|
|
||||||
|
|
||||||
|
class CommitMessageLengthExceededError(CommitizenException):
|
||||||
|
exit_code = ExitCode.COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED
|
||||||
|
message = "Length of commit message exceeds the given limit."
|
19
commitizen/factory.py
Normal file
19
commitizen/factory.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from commitizen import BaseCommitizen
|
||||||
|
from commitizen.config import BaseConfig
|
||||||
|
from commitizen.cz import registry
|
||||||
|
from commitizen.exceptions import NoCommitizenFoundException
|
||||||
|
|
||||||
|
|
||||||
|
def commiter_factory(config: BaseConfig) -> BaseCommitizen:
|
||||||
|
"""Return the correct commitizen existing in the registry."""
|
||||||
|
name: str = config.settings["name"]
|
||||||
|
try:
|
||||||
|
_cz = registry[name](config)
|
||||||
|
except KeyError:
|
||||||
|
msg_error = (
|
||||||
|
"The committer has not been found in the system.\n\n"
|
||||||
|
f"Try running 'pip install {name}'\n"
|
||||||
|
)
|
||||||
|
raise NoCommitizenFoundException(msg_error)
|
||||||
|
else:
|
||||||
|
return _cz
|
312
commitizen/git.py
Normal file
312
commitizen/git.py
Normal file
|
@ -0,0 +1,312 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from enum import Enum
|
||||||
|
from os import linesep
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
from commitizen import cmd, out
|
||||||
|
from commitizen.exceptions import GitCommandError
|
||||||
|
|
||||||
|
UNIX_EOL = "\n"
|
||||||
|
WINDOWS_EOL = "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
class EOLTypes(Enum):
|
||||||
|
"""The EOL type from `git config core.eol`."""
|
||||||
|
|
||||||
|
LF = "lf"
|
||||||
|
CRLF = "crlf"
|
||||||
|
NATIVE = "native"
|
||||||
|
|
||||||
|
def get_eol_for_open(self) -> str:
|
||||||
|
"""Get the EOL character for `open()`."""
|
||||||
|
map = {
|
||||||
|
EOLTypes.CRLF: WINDOWS_EOL,
|
||||||
|
EOLTypes.LF: UNIX_EOL,
|
||||||
|
EOLTypes.NATIVE: linesep,
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[self]
|
||||||
|
|
||||||
|
|
||||||
|
class GitObject:
|
||||||
|
rev: str
|
||||||
|
name: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
def __eq__(self, other) -> bool:
|
||||||
|
if not hasattr(other, "rev"):
|
||||||
|
return False
|
||||||
|
return self.rev == other.rev # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class GitCommit(GitObject):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rev,
|
||||||
|
title,
|
||||||
|
body: str = "",
|
||||||
|
author: str = "",
|
||||||
|
author_email: str = "",
|
||||||
|
parents: list[str] | None = None,
|
||||||
|
):
|
||||||
|
self.rev = rev.strip()
|
||||||
|
self.title = title.strip()
|
||||||
|
self.body = body.strip()
|
||||||
|
self.author = author.strip()
|
||||||
|
self.author_email = author_email.strip()
|
||||||
|
self.parents = parents or []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message(self):
|
||||||
|
return f"{self.title}\n\n{self.body}".strip()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"{self.title} ({self.rev})"
|
||||||
|
|
||||||
|
|
||||||
|
class GitTag(GitObject):
|
||||||
|
def __init__(self, name, rev, date):
|
||||||
|
self.rev = rev.strip()
|
||||||
|
self.name = name.strip()
|
||||||
|
self._date = date.strip()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"GitTag('{self.name}', '{self.rev}', '{self.date}')"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date(self):
|
||||||
|
return 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, annotated: bool = False, signed: bool = False, msg: str | None = None
|
||||||
|
) -> cmd.Command:
|
||||||
|
_opt = ""
|
||||||
|
if annotated:
|
||||||
|
_opt = f"-a {tag} -m"
|
||||||
|
if signed:
|
||||||
|
_opt = f"-s {tag} -m"
|
||||||
|
|
||||||
|
# according to https://git-scm.com/book/en/v2/Git-Basics-Tagging,
|
||||||
|
# we're not able to create lightweight tag with message.
|
||||||
|
# by adding message, we make it a annotated tags
|
||||||
|
c = cmd.run(f'git tag {_opt} "{tag if _opt == "" or msg is None else msg}"')
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def add(*args: str) -> cmd.Command:
|
||||||
|
c = cmd.run(f"git add {' '.join(args)}")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def commit(
|
||||||
|
message: str,
|
||||||
|
args: str = "",
|
||||||
|
committer_date: str | None = None,
|
||||||
|
) -> cmd.Command:
|
||||||
|
f = NamedTemporaryFile("wb", delete=False)
|
||||||
|
f.write(message.encode("utf-8"))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
command = f'git commit {args} -F "{f.name}"'
|
||||||
|
|
||||||
|
if committer_date and os.name == "nt": # pragma: no cover
|
||||||
|
# Using `cmd /v /c "{command}"` sets environment variables only for that command
|
||||||
|
command = f'cmd /v /c "set GIT_COMMITTER_DATE={committer_date}&& {command}"'
|
||||||
|
elif committer_date:
|
||||||
|
command = f"GIT_COMMITTER_DATE={committer_date} {command}"
|
||||||
|
|
||||||
|
c = cmd.run(command)
|
||||||
|
os.unlink(f.name)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def get_commits(
|
||||||
|
start: str | None = None,
|
||||||
|
end: str = "HEAD",
|
||||||
|
*,
|
||||||
|
args: str = "",
|
||||||
|
) -> list[GitCommit]:
|
||||||
|
"""Get the commits between start and end."""
|
||||||
|
git_log_entries = _get_log_as_str_list(start, end, args)
|
||||||
|
git_commits = []
|
||||||
|
for rev_and_commit in git_log_entries:
|
||||||
|
if not rev_and_commit:
|
||||||
|
continue
|
||||||
|
rev, parents, 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(),
|
||||||
|
author=author,
|
||||||
|
author_email=author_email,
|
||||||
|
parents=[p for p in parents.strip().split(" ") if p],
|
||||||
|
)
|
||||||
|
git_commits.append(git_commit)
|
||||||
|
return git_commits
|
||||||
|
|
||||||
|
|
||||||
|
def get_filenames_in_commit(git_reference: str = ""):
|
||||||
|
"""Get the list of files that were committed in the requested git reference.
|
||||||
|
|
||||||
|
:param git_reference: a git reference as accepted by `git show`, default: the last commit
|
||||||
|
|
||||||
|
:returns: file names committed in the last commit by default or inside the passed git reference
|
||||||
|
"""
|
||||||
|
c = cmd.run(f"git show --name-only --pretty=format: {git_reference}")
|
||||||
|
if c.return_code == 0:
|
||||||
|
return c.out.strip().split("\n")
|
||||||
|
else:
|
||||||
|
raise GitCommandError(c.err)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags(
|
||||||
|
dateformat: str = "%Y-%m-%d", reachable_only: bool = False
|
||||||
|
) -> list[GitTag]:
|
||||||
|
inner_delimiter = "---inner_delimiter---"
|
||||||
|
formatter = (
|
||||||
|
f'"%(refname:lstrip=2){inner_delimiter}'
|
||||||
|
f"%(objectname){inner_delimiter}"
|
||||||
|
f"%(creatordate:format:{dateformat}){inner_delimiter}"
|
||||||
|
f'%(object)"'
|
||||||
|
)
|
||||||
|
extra = "--merged" if reachable_only else ""
|
||||||
|
# Force the default language for parsing
|
||||||
|
env = {"LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"}
|
||||||
|
c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}", env=env)
|
||||||
|
if c.return_code != 0:
|
||||||
|
if reachable_only and c.err == "fatal: malformed object name HEAD\n":
|
||||||
|
# this can happen if there are no commits in the repo yet
|
||||||
|
return []
|
||||||
|
raise GitCommandError(c.err)
|
||||||
|
|
||||||
|
if c.err:
|
||||||
|
out.warn(f"Attempting to proceed after: {c.err}")
|
||||||
|
|
||||||
|
if not c.out:
|
||||||
|
return []
|
||||||
|
|
||||||
|
git_tags = [
|
||||||
|
GitTag.from_line(line=line, inner_delimiter=inner_delimiter)
|
||||||
|
for line in c.out.split("\n")[:-1]
|
||||||
|
]
|
||||||
|
|
||||||
|
return git_tags
|
||||||
|
|
||||||
|
|
||||||
|
def tag_exist(tag: str) -> bool:
|
||||||
|
c = cmd.run(f"git tag --list {tag}")
|
||||||
|
return tag in c.out
|
||||||
|
|
||||||
|
|
||||||
|
def is_signed_tag(tag: str) -> bool:
|
||||||
|
return cmd.run(f"git tag -v {tag}").return_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_tag_name() -> str | None:
|
||||||
|
c = cmd.run("git describe --abbrev=0 --tags")
|
||||||
|
if c.err:
|
||||||
|
return None
|
||||||
|
return c.out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_message(tag: str) -> str | None:
|
||||||
|
c = cmd.run(f"git tag -l --format='%(contents:subject)' {tag}")
|
||||||
|
if c.err:
|
||||||
|
return None
|
||||||
|
return c.out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_names() -> list[str | None]:
|
||||||
|
c = cmd.run("git tag --list")
|
||||||
|
if c.err:
|
||||||
|
return []
|
||||||
|
return [tag.strip() for tag in c.out.split("\n") if tag.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def find_git_project_root() -> Path | None:
|
||||||
|
c = cmd.run("git rev-parse --show-toplevel")
|
||||||
|
if not c.err:
|
||||||
|
return Path(c.out.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_staging_clean() -> bool:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
def get_eol_style() -> EOLTypes:
|
||||||
|
c = cmd.run("git config core.eol")
|
||||||
|
eol = c.out.strip().lower()
|
||||||
|
|
||||||
|
# We enumerate the EOL types of the response of
|
||||||
|
# `git config core.eol`, and map it to our enumration EOLTypes.
|
||||||
|
#
|
||||||
|
# It is just like the variant of the "match" syntax.
|
||||||
|
map = {
|
||||||
|
"lf": EOLTypes.LF,
|
||||||
|
"crlf": EOLTypes.CRLF,
|
||||||
|
"native": EOLTypes.NATIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the response of `git config core.eol` is in the map:
|
||||||
|
if eol in map:
|
||||||
|
return map[eol]
|
||||||
|
else:
|
||||||
|
# The default value is "native".
|
||||||
|
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreeol
|
||||||
|
return map["native"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_core_editor() -> str | None:
|
||||||
|
c = cmd.run("git var GIT_EDITOR")
|
||||||
|
if c.out:
|
||||||
|
return c.out.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def smart_open(*args, **kargs):
|
||||||
|
"""Open a file with the EOL style determined from Git."""
|
||||||
|
return open(*args, newline=get_eol_style().get_eol_for_open(), **kargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_log_as_str_list(start: str | None, end: str, args: str) -> list[str]:
|
||||||
|
"""Get string representation of each log entry"""
|
||||||
|
delimiter = "----------commit-delimiter----------"
|
||||||
|
log_format: str = "%H%n%P%n%s%n%an%n%ae%n%b"
|
||||||
|
git_log_cmd = (
|
||||||
|
f"git -c log.showSignature=False log --pretty={log_format}{delimiter} {args}"
|
||||||
|
)
|
||||||
|
if start:
|
||||||
|
command = f"{git_log_cmd} {start}..{end}"
|
||||||
|
else:
|
||||||
|
command = f"{git_log_cmd} {end}"
|
||||||
|
c = cmd.run(command)
|
||||||
|
if c.return_code != 0:
|
||||||
|
raise GitCommandError(c.err)
|
||||||
|
if not c.out:
|
||||||
|
return []
|
||||||
|
return c.out.split(f"{delimiter}\n")
|
36
commitizen/hooks.py
Normal file
36
commitizen/hooks.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from commitizen import cmd, out
|
||||||
|
from commitizen.exceptions import RunHookError
|
||||||
|
|
||||||
|
|
||||||
|
def run(hooks, _env_prefix="CZ_", **env):
|
||||||
|
if isinstance(hooks, str):
|
||||||
|
hooks = [hooks]
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
out.info(f"Running hook '{hook}'")
|
||||||
|
|
||||||
|
c = cmd.run(hook, env=_format_env(_env_prefix, env))
|
||||||
|
|
||||||
|
if c.out:
|
||||||
|
out.write(c.out)
|
||||||
|
if c.err:
|
||||||
|
out.error(c.err)
|
||||||
|
|
||||||
|
if c.return_code != 0:
|
||||||
|
raise RunHookError(f"Running hook '{hook}' failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_env(prefix: str, env: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""_format_env() prefixes all given environment variables with the given
|
||||||
|
prefix so it can be passed directly to cmd.run()."""
|
||||||
|
penv = dict(os.environ)
|
||||||
|
for name, value in env.items():
|
||||||
|
name = prefix + name.upper()
|
||||||
|
value = str(value) if value is not None else ""
|
||||||
|
penv[name] = value
|
||||||
|
|
||||||
|
return penv
|
42
commitizen/out.py
Normal file
42
commitizen/out.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from termcolor import colored
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if isinstance(sys.stdout, io.TextIOWrapper) and sys.version_info >= (3, 7):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write(value: str, *args) -> None:
|
||||||
|
"""Intended to be used when value is multiline."""
|
||||||
|
print(value, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def line(value: str, *args, **kwargs) -> None:
|
||||||
|
"""Wrapper in case I want to do something different later."""
|
||||||
|
print(value, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def error(value: str) -> None:
|
||||||
|
message = colored(value, "red")
|
||||||
|
line(message, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def success(value: str) -> None:
|
||||||
|
message = colored(value, "green")
|
||||||
|
line(message)
|
||||||
|
|
||||||
|
|
||||||
|
def info(value: str) -> None:
|
||||||
|
message = colored(value, "blue")
|
||||||
|
line(message)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostic(value: str):
|
||||||
|
line(value, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def warn(value: str) -> None:
|
||||||
|
message = colored(value, "magenta")
|
||||||
|
line(message, file=sys.stderr)
|
51
commitizen/providers/__init__.py
Normal file
51
commitizen/providers/__init__.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from importlib import metadata
|
||||||
|
else:
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
|
||||||
|
from commitizen.config.base_config import BaseConfig
|
||||||
|
from commitizen.exceptions import VersionProviderUnknown
|
||||||
|
from commitizen.providers.base_provider import VersionProvider
|
||||||
|
from commitizen.providers.cargo_provider import CargoProvider
|
||||||
|
from commitizen.providers.commitizen_provider import CommitizenProvider
|
||||||
|
from commitizen.providers.composer_provider import ComposerProvider
|
||||||
|
from commitizen.providers.npm_provider import NpmProvider
|
||||||
|
from commitizen.providers.pep621_provider import Pep621Provider
|
||||||
|
from commitizen.providers.poetry_provider import PoetryProvider
|
||||||
|
from commitizen.providers.scm_provider import ScmProvider
|
||||||
|
from commitizen.providers.uv_provider import UvProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_provider",
|
||||||
|
"CargoProvider",
|
||||||
|
"CommitizenProvider",
|
||||||
|
"ComposerProvider",
|
||||||
|
"NpmProvider",
|
||||||
|
"Pep621Provider",
|
||||||
|
"PoetryProvider",
|
||||||
|
"ScmProvider",
|
||||||
|
"UvProvider",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROVIDER_ENTRYPOINT = "commitizen.provider"
|
||||||
|
DEFAULT_PROVIDER = "commitizen"
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(config: BaseConfig) -> VersionProvider:
|
||||||
|
"""
|
||||||
|
Get the version provider as defined in the configuration
|
||||||
|
|
||||||
|
:raises VersionProviderUnknown: if the provider named by `version_provider` is not found.
|
||||||
|
"""
|
||||||
|
provider_name = config.settings["version_provider"] or DEFAULT_PROVIDER
|
||||||
|
try:
|
||||||
|
(ep,) = metadata.entry_points(name=provider_name, group=PROVIDER_ENTRYPOINT)
|
||||||
|
except ValueError:
|
||||||
|
raise VersionProviderUnknown(f'Version Provider "{provider_name}" unknown.')
|
||||||
|
provider_cls = ep.load()
|
||||||
|
return cast(VersionProvider, provider_cls(config))
|
91
commitizen/providers/base_provider.py
Normal file
91
commitizen/providers/base_provider.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from commitizen.config.base_config import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class VersionProvider(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for version providers.
|
||||||
|
|
||||||
|
Each version provider should inherit and implement this class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: BaseConfig
|
||||||
|
|
||||||
|
def __init__(self, config: BaseConfig):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_version(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the current version
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_version(self, version: str):
|
||||||
|
"""
|
||||||
|
Set the new current version
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FileProvider(VersionProvider):
|
||||||
|
"""
|
||||||
|
Base class for file-based version providers
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename: ClassVar[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file(self) -> Path:
|
||||||
|
return Path() / self.filename
|
||||||
|
|
||||||
|
|
||||||
|
class JsonProvider(FileProvider):
|
||||||
|
"""
|
||||||
|
Base class for JSON-based version providers
|
||||||
|
"""
|
||||||
|
|
||||||
|
indent: ClassVar[int] = 2
|
||||||
|
|
||||||
|
def get_version(self) -> str:
|
||||||
|
document = json.loads(self.file.read_text())
|
||||||
|
return self.get(document)
|
||||||
|
|
||||||
|
def set_version(self, version: str):
|
||||||
|
document = json.loads(self.file.read_text())
|
||||||
|
self.set(document, version)
|
||||||
|
self.file.write_text(json.dumps(document, indent=self.indent) + "\n")
|
||||||
|
|
||||||
|
def get(self, document: dict[str, Any]) -> str:
|
||||||
|
return document["version"] # type: ignore
|
||||||
|
|
||||||
|
def set(self, document: dict[str, Any], version: str):
|
||||||
|
document["version"] = version
|
||||||
|
|
||||||
|
|
||||||
|
class TomlProvider(FileProvider):
|
||||||
|
"""
|
||||||
|
Base class for TOML-based version providers
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_version(self) -> str:
|
||||||
|
document = tomlkit.parse(self.file.read_text())
|
||||||
|
return self.get(document)
|
||||||
|
|
||||||
|
def set_version(self, version: str):
|
||||||
|
document = tomlkit.parse(self.file.read_text())
|
||||||
|
self.set(document, version)
|
||||||
|
self.file.write_text(tomlkit.dumps(document))
|
||||||
|
|
||||||
|
def get(self, document: tomlkit.TOMLDocument) -> str:
|
||||||
|
return document["project"]["version"] # type: ignore
|
||||||
|
|
||||||
|
def set(self, document: tomlkit.TOMLDocument, version: str):
|
||||||
|
document["project"]["version"] = version # type: ignore
|
30
commitizen/providers/cargo_provider.py
Normal file
30
commitizen/providers/cargo_provider.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import TomlProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CargoProvider(TomlProvider):
|
||||||
|
"""
|
||||||
|
Cargo version management
|
||||||
|
|
||||||
|
With support for `workspaces`
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = "Cargo.toml"
|
||||||
|
|
||||||
|
def get(self, document: tomlkit.TOMLDocument) -> str:
|
||||||
|
try:
|
||||||
|
return document["package"]["version"] # type: ignore
|
||||||
|
except tomlkit.exceptions.NonExistentKey:
|
||||||
|
...
|
||||||
|
return document["workspace"]["package"]["version"] # type: ignore
|
||||||
|
|
||||||
|
def set(self, document: tomlkit.TOMLDocument, version: str):
|
||||||
|
try:
|
||||||
|
document["workspace"]["package"]["version"] = version # type: ignore
|
||||||
|
return
|
||||||
|
except tomlkit.exceptions.NonExistentKey:
|
||||||
|
...
|
||||||
|
document["package"]["version"] = version # type: ignore
|
15
commitizen/providers/commitizen_provider.py
Normal file
15
commitizen/providers/commitizen_provider.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import VersionProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CommitizenProvider(VersionProvider):
|
||||||
|
"""
|
||||||
|
Default version provider: Fetch and set version in commitizen config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_version(self) -> str:
|
||||||
|
return self.config.settings["version"] # type: ignore
|
||||||
|
|
||||||
|
def set_version(self, version: str):
|
||||||
|
self.config.set_key("version", version)
|
12
commitizen/providers/composer_provider.py
Normal file
12
commitizen/providers/composer_provider.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import JsonProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ComposerProvider(JsonProvider):
|
||||||
|
"""
|
||||||
|
Composer version management
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = "composer.json"
|
||||||
|
indent = 4
|
82
commitizen/providers/npm_provider.py
Normal file
82
commitizen/providers/npm_provider.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import VersionProvider
|
||||||
|
|
||||||
|
|
||||||
|
class NpmProvider(VersionProvider):
|
||||||
|
"""
|
||||||
|
npm package.json and package-lock.json version management
|
||||||
|
"""
|
||||||
|
|
||||||
|
indent: ClassVar[int] = 2
|
||||||
|
package_filename = "package.json"
|
||||||
|
lock_filename = "package-lock.json"
|
||||||
|
shrinkwrap_filename = "npm-shrinkwrap.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def package_file(self) -> Path:
|
||||||
|
return Path() / self.package_filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lock_file(self) -> Path:
|
||||||
|
return Path() / self.lock_filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shrinkwrap_file(self) -> Path:
|
||||||
|
return Path() / self.shrinkwrap_filename
|
||||||
|
|
||||||
|
def get_version(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the current version from package.json
|
||||||
|
"""
|
||||||
|
package_document = json.loads(self.package_file.read_text())
|
||||||
|
return self.get_package_version(package_document)
|
||||||
|
|
||||||
|
def set_version(self, version: str) -> None:
|
||||||
|
package_document = self.set_package_version(
|
||||||
|
json.loads(self.package_file.read_text()), version
|
||||||
|
)
|
||||||
|
self.package_file.write_text(
|
||||||
|
json.dumps(package_document, indent=self.indent) + "\n"
|
||||||
|
)
|
||||||
|
if self.lock_file.exists():
|
||||||
|
lock_document = self.set_lock_version(
|
||||||
|
json.loads(self.lock_file.read_text()), version
|
||||||
|
)
|
||||||
|
self.lock_file.write_text(
|
||||||
|
json.dumps(lock_document, indent=self.indent) + "\n"
|
||||||
|
)
|
||||||
|
if self.shrinkwrap_file.exists():
|
||||||
|
shrinkwrap_document = self.set_shrinkwrap_version(
|
||||||
|
json.loads(self.shrinkwrap_file.read_text()), version
|
||||||
|
)
|
||||||
|
self.shrinkwrap_file.write_text(
|
||||||
|
json.dumps(shrinkwrap_document, indent=self.indent) + "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_package_version(self, document: dict[str, Any]) -> str:
|
||||||
|
return document["version"] # type: ignore
|
||||||
|
|
||||||
|
def set_package_version(
|
||||||
|
self, document: dict[str, Any], version: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
document["version"] = version
|
||||||
|
return document
|
||||||
|
|
||||||
|
def set_lock_version(
|
||||||
|
self, document: dict[str, Any], version: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
document["version"] = version
|
||||||
|
document["packages"][""]["version"] = version
|
||||||
|
return document
|
||||||
|
|
||||||
|
def set_shrinkwrap_version(
|
||||||
|
self, document: dict[str, Any], version: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
document["version"] = version
|
||||||
|
document["packages"][""]["version"] = version
|
||||||
|
return document
|
11
commitizen/providers/pep621_provider.py
Normal file
11
commitizen/providers/pep621_provider.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import TomlProvider
|
||||||
|
|
||||||
|
|
||||||
|
class Pep621Provider(TomlProvider):
|
||||||
|
"""
|
||||||
|
PEP-621 version management
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = "pyproject.toml"
|
19
commitizen/providers/poetry_provider.py
Normal file
19
commitizen/providers/poetry_provider.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import TomlProvider
|
||||||
|
|
||||||
|
|
||||||
|
class PoetryProvider(TomlProvider):
|
||||||
|
"""
|
||||||
|
Poetry version management
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = "pyproject.toml"
|
||||||
|
|
||||||
|
def get(self, pyproject: tomlkit.TOMLDocument) -> str:
|
||||||
|
return pyproject["tool"]["poetry"]["version"] # type: ignore
|
||||||
|
|
||||||
|
def set(self, pyproject: tomlkit.TOMLDocument, version: str):
|
||||||
|
pyproject["tool"]["poetry"]["version"] = version # type: ignore
|
28
commitizen/providers/scm_provider.py
Normal file
28
commitizen/providers/scm_provider.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from commitizen.git import get_tags
|
||||||
|
from commitizen.providers.base_provider import VersionProvider
|
||||||
|
from commitizen.tags import TagRules
|
||||||
|
|
||||||
|
|
||||||
|
class ScmProvider(VersionProvider):
|
||||||
|
"""
|
||||||
|
A provider fetching the current/last version from the repository history
|
||||||
|
|
||||||
|
The version is fetched using `git describe` and is never set.
|
||||||
|
|
||||||
|
It is meant for `setuptools-scm` or any package manager `*-scm` provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_version(self) -> str:
|
||||||
|
rules = TagRules.from_settings(self.config.settings)
|
||||||
|
tags = get_tags(reachable_only=True)
|
||||||
|
version_tags = rules.get_version_tags(tags)
|
||||||
|
versions = sorted(rules.extract_version(t) for t in version_tags)
|
||||||
|
if not versions:
|
||||||
|
return "0.0.0"
|
||||||
|
return str(versions[-1])
|
||||||
|
|
||||||
|
def set_version(self, version: str):
|
||||||
|
# Not necessary
|
||||||
|
pass
|
41
commitizen/providers/uv_provider.py
Normal file
41
commitizen/providers/uv_provider.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from commitizen.providers.base_provider import TomlProvider
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import tomlkit.items
|
||||||
|
|
||||||
|
|
||||||
|
class UvProvider(TomlProvider):
|
||||||
|
"""
|
||||||
|
uv.lock and pyproject.tom version management
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = "pyproject.toml"
|
||||||
|
lock_filename = "uv.lock"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lock_file(self) -> Path:
|
||||||
|
return Path() / self.lock_filename
|
||||||
|
|
||||||
|
def set_version(self, version: str) -> None:
|
||||||
|
super().set_version(version)
|
||||||
|
self.set_lock_version(version)
|
||||||
|
|
||||||
|
def set_lock_version(self, version: str) -> None:
|
||||||
|
pyproject_toml_content = tomlkit.parse(self.file.read_text())
|
||||||
|
project_name = pyproject_toml_content["project"]["name"] # type: ignore[index]
|
||||||
|
|
||||||
|
document = tomlkit.parse(self.lock_file.read_text())
|
||||||
|
|
||||||
|
packages: tomlkit.items.AoT = document["package"] # type: ignore[assignment]
|
||||||
|
for i, package in enumerate(packages):
|
||||||
|
if package["name"] == project_name:
|
||||||
|
document["package"][i]["version"] = version # type: ignore[index]
|
||||||
|
break
|
||||||
|
self.lock_file.write_text(tomlkit.dumps(document))
|
0
commitizen/py.typed
Normal file
0
commitizen/py.typed
Normal file
267
commitizen/tags.py
Normal file
267
commitizen/tags.py
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import cached_property
|
||||||
|
from string import Template
|
||||||
|
from typing import TYPE_CHECKING, NamedTuple
|
||||||
|
|
||||||
|
from commitizen import out
|
||||||
|
from commitizen.defaults import DEFAULT_SETTINGS, Settings, get_tag_regexes
|
||||||
|
from commitizen.git import GitTag
|
||||||
|
from commitizen.version_schemes import (
|
||||||
|
DEFAULT_SCHEME,
|
||||||
|
InvalidVersion,
|
||||||
|
Version,
|
||||||
|
VersionScheme,
|
||||||
|
get_version_scheme,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from commitizen.version_schemes import VersionScheme
|
||||||
|
|
||||||
|
# Self is Python 3.11+ but backported in typing-extensions
|
||||||
|
if sys.version_info < (3, 11):
|
||||||
|
from typing_extensions import Self
|
||||||
|
else:
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
|
||||||
|
class VersionTag(NamedTuple):
|
||||||
|
"""Represent a version and its matching tag form."""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
tag: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TagRules:
|
||||||
|
"""
|
||||||
|
Encapsulate tag-related rules.
|
||||||
|
|
||||||
|
It allows to filter or match tags according to rules provided in settings:
|
||||||
|
- `tag_format`: the current format of the tags generated on `bump`
|
||||||
|
- `legacy_tag_formats`: previous known formats of the tag
|
||||||
|
- `ignored_tag_formats`: known formats that should be ignored
|
||||||
|
- `merge_prereleases`: if `True`, prereleases will be merged with their release counterpart
|
||||||
|
- `version_scheme`: the version scheme to use, which will be used to parse and format versions
|
||||||
|
|
||||||
|
This class is meant to abstract and centralize all the logic related to tags.
|
||||||
|
To ensure consistency, it is recommended to use this class to handle tags.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = DEFAULT_SETTINGS.clone()
|
||||||
|
settings.update(
|
||||||
|
{
|
||||||
|
"tag_format": "v{version}",
|
||||||
|
"legacy_tag_formats": ["version{version}", "ver{version}"],
|
||||||
|
"ignored_tag_formats": ["ignored{version}"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = TagRules.from_settings(settings)
|
||||||
|
|
||||||
|
assert rules.is_version_tag("v1.0.0")
|
||||||
|
assert rules.is_version_tag("version1.0.0")
|
||||||
|
assert rules.is_version_tag("ver1.0.0")
|
||||||
|
assert not rules.is_version_tag("ignored1.0.0", warn=True) # Does not warn
|
||||||
|
assert not rules.is_version_tag("warn1.0.0", warn=True) # Does warn
|
||||||
|
|
||||||
|
assert rules.search_version("# My v1.0.0 version").version == "1.0.0"
|
||||||
|
assert rules.extract_version("v1.0.0") == Version("1.0.0")
|
||||||
|
try:
|
||||||
|
assert rules.extract_version("not-a-v1.0.0")
|
||||||
|
except InvalidVersion:
|
||||||
|
print("Does not match a tag format")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
scheme: VersionScheme = DEFAULT_SCHEME
|
||||||
|
tag_format: str = DEFAULT_SETTINGS["tag_format"]
|
||||||
|
legacy_tag_formats: Sequence[str] = field(default_factory=list)
|
||||||
|
ignored_tag_formats: Sequence[str] = field(default_factory=list)
|
||||||
|
merge_prereleases: bool = False
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def version_regexes(self) -> Sequence[re.Pattern]:
|
||||||
|
"""Regexes for all legit tag formats, current and legacy"""
|
||||||
|
tag_formats = [self.tag_format, *self.legacy_tag_formats]
|
||||||
|
regexes = (self._format_regex(p) for p in tag_formats)
|
||||||
|
return [re.compile(r) for r in regexes]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def ignored_regexes(self) -> Sequence[re.Pattern]:
|
||||||
|
"""Regexes for known but ignored tag formats"""
|
||||||
|
regexes = (self._format_regex(p, star=True) for p in self.ignored_tag_formats)
|
||||||
|
return [re.compile(r) for r in regexes]
|
||||||
|
|
||||||
|
def _format_regex(self, tag_pattern: str, star: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Format a tag pattern into a regex pattern.
|
||||||
|
|
||||||
|
If star is `True`, the `*` character will be considered as a wildcard.
|
||||||
|
"""
|
||||||
|
tag_regexes = get_tag_regexes(self.scheme.parser.pattern)
|
||||||
|
format_regex = tag_pattern.replace("*", "(?:.*?)") if star else tag_pattern
|
||||||
|
for pattern, regex in tag_regexes.items():
|
||||||
|
format_regex = format_regex.replace(pattern, regex)
|
||||||
|
return format_regex
|
||||||
|
|
||||||
|
def is_version_tag(self, tag: str | GitTag, warn: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
True if a given tag is a legit version tag.
|
||||||
|
|
||||||
|
if `warn` is `True`, it will print a warning message if the tag is not a version tag.
|
||||||
|
"""
|
||||||
|
tag = tag.name if isinstance(tag, GitTag) else tag
|
||||||
|
is_legit = any(regex.match(tag) for regex in self.version_regexes)
|
||||||
|
if warn and not is_legit and not self.is_ignored_tag(tag):
|
||||||
|
out.warn(f"InvalidVersion {tag} doesn't match any configured tag format")
|
||||||
|
return is_legit
|
||||||
|
|
||||||
|
def is_ignored_tag(self, tag: str | GitTag) -> bool:
|
||||||
|
"""True if a given tag can be ignored"""
|
||||||
|
tag = tag.name if isinstance(tag, GitTag) else tag
|
||||||
|
return any(regex.match(tag) for regex in self.ignored_regexes)
|
||||||
|
|
||||||
|
def get_version_tags(
|
||||||
|
self, tags: Sequence[GitTag], warn: bool = False
|
||||||
|
) -> Sequence[GitTag]:
|
||||||
|
"""Filter in version tags and warn on unexpected tags"""
|
||||||
|
return [tag for tag in tags if self.is_version_tag(tag, warn)]
|
||||||
|
|
||||||
|
def extract_version(self, tag: GitTag) -> Version:
|
||||||
|
"""
|
||||||
|
Extract a version from the tag as defined in tag formats.
|
||||||
|
|
||||||
|
Raises `InvalidVersion` if the tag does not match any format.
|
||||||
|
"""
|
||||||
|
candidates = (
|
||||||
|
m for regex in self.version_regexes if (m := regex.fullmatch(tag.name))
|
||||||
|
)
|
||||||
|
if not (m := next(candidates, None)):
|
||||||
|
raise InvalidVersion(
|
||||||
|
f"Invalid version tag: '{tag.name}' does not match any configured tag format"
|
||||||
|
)
|
||||||
|
if "version" in m.groupdict():
|
||||||
|
return self.scheme(m.group("version"))
|
||||||
|
|
||||||
|
parts = m.groupdict()
|
||||||
|
version = parts["major"]
|
||||||
|
|
||||||
|
if minor := parts.get("minor"):
|
||||||
|
version = f"{version}.{minor}"
|
||||||
|
if patch := parts.get("patch"):
|
||||||
|
version = f"{version}.{patch}"
|
||||||
|
|
||||||
|
if parts.get("prerelease"):
|
||||||
|
version = f"{version}-{parts['prerelease']}"
|
||||||
|
if parts.get("devrelease"):
|
||||||
|
version = f"{version}{parts['devrelease']}"
|
||||||
|
return self.scheme(version)
|
||||||
|
|
||||||
|
def include_in_changelog(self, tag: GitTag) -> bool:
|
||||||
|
"""Check if a tag should be included in the changelog"""
|
||||||
|
try:
|
||||||
|
version = self.extract_version(tag)
|
||||||
|
except InvalidVersion:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.merge_prereleases and version.is_prerelease:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def search_version(self, text: str, last: bool = False) -> VersionTag | None:
|
||||||
|
"""
|
||||||
|
Search the first or last version tag occurrence in text.
|
||||||
|
|
||||||
|
It searches for complete versions only (aka `major`, `minor` and `patch`)
|
||||||
|
"""
|
||||||
|
candidates = (
|
||||||
|
m for regex in self.version_regexes if len(m := list(regex.finditer(text)))
|
||||||
|
)
|
||||||
|
if not (matches := next(candidates, [])):
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = matches[-1 if last else 0]
|
||||||
|
|
||||||
|
if "version" in match.groupdict():
|
||||||
|
return VersionTag(match.group("version"), match.group(0))
|
||||||
|
|
||||||
|
parts = match.groupdict()
|
||||||
|
try:
|
||||||
|
version = f"{parts['major']}.{parts['minor']}.{parts['patch']}"
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parts.get("prerelease"):
|
||||||
|
version = f"{version}-{parts['prerelease']}"
|
||||||
|
if parts.get("devrelease"):
|
||||||
|
version = f"{version}{parts['devrelease']}"
|
||||||
|
return VersionTag(version, match.group(0))
|
||||||
|
|
||||||
|
def normalize_tag(
|
||||||
|
self, version: Version | str, tag_format: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
The tag and the software version might be different.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
"""
|
||||||
|
version = self.scheme(version) if isinstance(version, str) else version
|
||||||
|
tag_format = tag_format or self.tag_format
|
||||||
|
|
||||||
|
major, minor, patch = version.release
|
||||||
|
prerelease = version.prerelease or ""
|
||||||
|
|
||||||
|
t = Template(tag_format)
|
||||||
|
return t.safe_substitute(
|
||||||
|
version=version,
|
||||||
|
major=major,
|
||||||
|
minor=minor,
|
||||||
|
patch=patch,
|
||||||
|
prerelease=prerelease,
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_tag_for(
|
||||||
|
self, tags: Sequence[GitTag], version: Version | str
|
||||||
|
) -> GitTag | None:
|
||||||
|
"""Find the first matching tag for a given version."""
|
||||||
|
version = self.scheme(version) if isinstance(version, str) else version
|
||||||
|
possible_tags = [
|
||||||
|
self.normalize_tag(version, f)
|
||||||
|
for f in (self.tag_format, *self.legacy_tag_formats)
|
||||||
|
]
|
||||||
|
candidates = [t for t in tags if any(t.name == p for p in possible_tags)]
|
||||||
|
if len(candidates) > 1:
|
||||||
|
warnings.warn(
|
||||||
|
UserWarning(
|
||||||
|
f"Multiple tags found for version {version}: {', '.join(t.name for t in candidates)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return next(iter(candidates), None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_settings(cls, settings: Settings) -> Self:
|
||||||
|
"""Extract tag rules from settings"""
|
||||||
|
return cls(
|
||||||
|
scheme=get_version_scheme(settings),
|
||||||
|
tag_format=settings["tag_format"],
|
||||||
|
legacy_tag_formats=settings["legacy_tag_formats"],
|
||||||
|
ignored_tag_formats=settings["ignored_tag_formats"],
|
||||||
|
merge_prereleases=settings["changelog_merge_prerelease"],
|
||||||
|
)
|
19
commitizen/templates/CHANGELOG.adoc.j2
Normal file
19
commitizen/templates/CHANGELOG.adoc.j2
Normal file
|
@ -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 %}
|
19
commitizen/templates/CHANGELOG.md.j2
Normal file
19
commitizen/templates/CHANGELOG.md.j2
Normal file
|
@ -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 %}
|
23
commitizen/templates/CHANGELOG.rst.j2
Normal file
23
commitizen/templates/CHANGELOG.rst.j2
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{% for entry in tree %}
|
||||||
|
|
||||||
|
{% set entry_title -%}
|
||||||
|
{{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif -%}
|
||||||
|
{%- endset %}
|
||||||
|
{{ entry_title }}
|
||||||
|
{{ "=" * entry_title|length }}
|
||||||
|
{% for change_key, changes in entry.changes.items() %}
|
||||||
|
|
||||||
|
{% if change_key -%}
|
||||||
|
{{ change_key }}
|
||||||
|
{{ "-" * change_key|length }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for change in changes %}
|
||||||
|
{% if change.scope %}
|
||||||
|
- **{{ change.scope }}**: {{ change.message }}
|
||||||
|
{% elif change.message %}
|
||||||
|
- {{ change.message }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
19
commitizen/templates/CHANGELOG.textile.j2
Normal file
19
commitizen/templates/CHANGELOG.textile.j2
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{% for entry in tree %}
|
||||||
|
|
||||||
|
h2. {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %}
|
||||||
|
|
||||||
|
{% for change_key, changes in entry.changes.items() %}
|
||||||
|
|
||||||
|
{% if change_key %}
|
||||||
|
h3. {{ change_key }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for change in changes %}
|
||||||
|
{% if change.scope %}
|
||||||
|
- *{{ change.scope }}*: {{ change.message }}
|
||||||
|
{% elif change.message %}
|
||||||
|
- {{ change.message }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
439
commitizen/version_schemes.py
Normal file
439
commitizen/version_schemes.py
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from itertools import zip_longest
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
ClassVar,
|
||||||
|
Literal,
|
||||||
|
Protocol,
|
||||||
|
cast,
|
||||||
|
runtime_checkable,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from importlib import metadata
|
||||||
|
else:
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
|
||||||
|
from packaging.version import InvalidVersion # noqa: F401: Rexpose the common exception
|
||||||
|
from packaging.version import Version as _BaseVersion
|
||||||
|
|
||||||
|
from commitizen.defaults import MAJOR, MINOR, PATCH, Settings
|
||||||
|
from commitizen.exceptions import VersionSchemeUnknown
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# TypeAlias is Python 3.10+ but backported in typing-extensions
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from typing import TypeAlias
|
||||||
|
else:
|
||||||
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
|
# Self is Python 3.11+ but backported in typing-extensions
|
||||||
|
if sys.version_info < (3, 11):
|
||||||
|
from typing_extensions import Self
|
||||||
|
else:
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
|
||||||
|
Increment: TypeAlias = Literal["MAJOR", "MINOR", "PATCH"]
|
||||||
|
Prerelease: TypeAlias = Literal["alpha", "beta", "rc"]
|
||||||
|
DEFAULT_VERSION_PARSER = r"v?(?P<version>([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.]+)?(\w+)?)"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class VersionProtocol(Protocol):
|
||||||
|
parser: ClassVar[re.Pattern]
|
||||||
|
"""Regex capturing this version scheme into a `version` group"""
|
||||||
|
|
||||||
|
def __init__(self, version: str):
|
||||||
|
"""
|
||||||
|
Initialize a version object from its string representation.
|
||||||
|
|
||||||
|
:raises InvalidVersion: If the ``version`` does not conform to the scheme in any way.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""A string representation of the version that can be rounded-tripped."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheme(self) -> VersionScheme:
|
||||||
|
"""The version scheme this version follows."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release(self) -> tuple[int, ...]:
|
||||||
|
"""The components of the "release" segment of the version."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prerelease(self) -> bool:
|
||||||
|
"""Whether this version is a pre-release."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prerelease(self) -> str | None:
|
||||||
|
"""The prelease potion of the version is this is a prerelease."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public(self) -> str:
|
||||||
|
"""The public portion of the version."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def local(self) -> str | None:
|
||||||
|
"""The local version segment of the version."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def major(self) -> int:
|
||||||
|
"""The first item of :attr:`release` or ``0`` if unavailable."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minor(self) -> int:
|
||||||
|
"""The second item of :attr:`release` or ``0`` if unavailable."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def micro(self) -> int:
|
||||||
|
"""The third item of :attr:`release` or ``0`` if unavailable."""
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __lt__(self, other: Any) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __le__(self, other: Any) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __ge__(self, other: Any) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __gt__(self, other: Any) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def __ne__(self, other: object) -> bool:
|
||||||
|
raise NotImplementedError("must be implemented")
|
||||||
|
|
||||||
|
def bump(
|
||||||
|
self,
|
||||||
|
increment: Increment | None,
|
||||||
|
prerelease: Prerelease | None = None,
|
||||||
|
prerelease_offset: int = 0,
|
||||||
|
devrelease: int | None = None,
|
||||||
|
is_local_version: bool = False,
|
||||||
|
build_metadata: str | None = None,
|
||||||
|
exact_increment: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Based on the given increment, generate the next bumped version according to the version scheme
|
||||||
|
|
||||||
|
Args:
|
||||||
|
increment: The component to increase
|
||||||
|
prerelease: The type of prerelease, if Any
|
||||||
|
is_local_version: Whether to increment the local version instead
|
||||||
|
exact_increment: Treat the increment and prerelease arguments explicitly. Disables logic
|
||||||
|
that attempts to deduce the correct increment when a prelease suffix is present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# With PEP 440 and SemVer semantic, Scheme is the type, Version is an instance
|
||||||
|
Version: TypeAlias = VersionProtocol
|
||||||
|
VersionScheme: TypeAlias = type[VersionProtocol]
|
||||||
|
|
||||||
|
|
||||||
|
class BaseVersion(_BaseVersion):
|
||||||
|
"""
|
||||||
|
A base class implementing the `VersionProtocol` for PEP440-like versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser: ClassVar[re.Pattern] = re.compile(DEFAULT_VERSION_PARSER)
|
||||||
|
"""Regex capturing this version scheme into a `version` group"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheme(self) -> VersionScheme:
|
||||||
|
return self.__class__
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prerelease(self) -> str | None:
|
||||||
|
# version.pre is needed for mypy check
|
||||||
|
if self.is_prerelease and self.pre:
|
||||||
|
return f"{self.pre[0]}{self.pre[1]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_prerelease(
|
||||||
|
self, prerelease: str | None = None, offset: int = 0
|
||||||
|
) -> str:
|
||||||
|
"""Generate prerelease
|
||||||
|
|
||||||
|
X.YaN # Alpha release
|
||||||
|
X.YbN # Beta release
|
||||||
|
X.YrcN # Release Candidate
|
||||||
|
X.Y # Final
|
||||||
|
|
||||||
|
This function might return something like 'alpha1'
|
||||||
|
but it will be handled by Version.
|
||||||
|
"""
|
||||||
|
if not prerelease:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# prevent down-bumping the pre-release phase, e.g. from 'b1' to 'a2'
|
||||||
|
# https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases
|
||||||
|
# https://semver.org/#spec-item-11
|
||||||
|
if self.is_prerelease and self.pre:
|
||||||
|
prerelease = max(prerelease, self.pre[0])
|
||||||
|
|
||||||
|
# version.pre is needed for mypy check
|
||||||
|
if self.is_prerelease and self.pre and prerelease.startswith(self.pre[0]):
|
||||||
|
prev_prerelease: int = self.pre[1]
|
||||||
|
new_prerelease_number = prev_prerelease + 1
|
||||||
|
else:
|
||||||
|
new_prerelease_number = offset
|
||||||
|
pre_version = f"{prerelease}{new_prerelease_number}"
|
||||||
|
return pre_version
|
||||||
|
|
||||||
|
def generate_devrelease(self, devrelease: int | None) -> str:
|
||||||
|
"""Generate devrelease
|
||||||
|
|
||||||
|
The devrelease version should be passed directly and is not
|
||||||
|
inferred based on the previous version.
|
||||||
|
"""
|
||||||
|
if devrelease is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return f"dev{devrelease}"
|
||||||
|
|
||||||
|
def generate_build_metadata(self, build_metadata: str | None) -> str:
|
||||||
|
"""Generate build-metadata
|
||||||
|
|
||||||
|
Build-metadata (local version) is not used in version calculations
|
||||||
|
but added after + statically.
|
||||||
|
"""
|
||||||
|
if build_metadata is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return f"+{build_metadata}"
|
||||||
|
|
||||||
|
def increment_base(self, increment: Increment | None = None) -> str:
|
||||||
|
prev_release = list(self.release)
|
||||||
|
increments = [MAJOR, MINOR, PATCH]
|
||||||
|
base = dict(zip_longest(increments, prev_release, fillvalue=0))
|
||||||
|
|
||||||
|
if increment == MAJOR:
|
||||||
|
base[MAJOR] += 1
|
||||||
|
base[MINOR] = 0
|
||||||
|
base[PATCH] = 0
|
||||||
|
elif increment == MINOR:
|
||||||
|
base[MINOR] += 1
|
||||||
|
base[PATCH] = 0
|
||||||
|
elif increment == PATCH:
|
||||||
|
base[PATCH] += 1
|
||||||
|
|
||||||
|
return f"{base[MAJOR]}.{base[MINOR]}.{base[PATCH]}"
|
||||||
|
|
||||||
|
def bump(
|
||||||
|
self,
|
||||||
|
increment: Increment | None,
|
||||||
|
prerelease: Prerelease | None = None,
|
||||||
|
prerelease_offset: int = 0,
|
||||||
|
devrelease: int | None = None,
|
||||||
|
is_local_version: bool = False,
|
||||||
|
build_metadata: str | None = None,
|
||||||
|
exact_increment: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""Based on the given increment a proper semver will be generated.
|
||||||
|
|
||||||
|
For now the rules and versioning scheme is based on
|
||||||
|
python's PEP 0440.
|
||||||
|
More info: https://www.python.org/dev/peps/pep-0440/
|
||||||
|
|
||||||
|
Example:
|
||||||
|
PATCH 1.0.0 -> 1.0.1
|
||||||
|
MINOR 1.0.0 -> 1.1.0
|
||||||
|
MAJOR 1.0.0 -> 2.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.local and is_local_version:
|
||||||
|
local_version = self.scheme(self.local).bump(increment)
|
||||||
|
return self.scheme(f"{self.public}+{local_version}") # type: ignore
|
||||||
|
else:
|
||||||
|
if not self.is_prerelease:
|
||||||
|
base = self.increment_base(increment)
|
||||||
|
elif exact_increment:
|
||||||
|
base = self.increment_base(increment)
|
||||||
|
else:
|
||||||
|
base = f"{self.major}.{self.minor}.{self.micro}"
|
||||||
|
if increment == PATCH:
|
||||||
|
pass
|
||||||
|
elif increment == MINOR:
|
||||||
|
if self.micro != 0:
|
||||||
|
base = self.increment_base(increment)
|
||||||
|
elif increment == MAJOR:
|
||||||
|
if self.minor != 0 or self.micro != 0:
|
||||||
|
base = self.increment_base(increment)
|
||||||
|
dev_version = self.generate_devrelease(devrelease)
|
||||||
|
|
||||||
|
release = list(self.release)
|
||||||
|
if len(release) < 3:
|
||||||
|
release += [0] * (3 - len(release))
|
||||||
|
current_base = ".".join(str(part) for part in release)
|
||||||
|
if base == current_base:
|
||||||
|
pre_version = self.generate_prerelease(
|
||||||
|
prerelease, offset=prerelease_offset
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base_version = cast(BaseVersion, self.scheme(base))
|
||||||
|
pre_version = base_version.generate_prerelease(
|
||||||
|
prerelease, offset=prerelease_offset
|
||||||
|
)
|
||||||
|
build_metadata = self.generate_build_metadata(build_metadata)
|
||||||
|
# TODO: post version
|
||||||
|
return self.scheme(f"{base}{pre_version}{dev_version}{build_metadata}") # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Pep440(BaseVersion):
|
||||||
|
"""
|
||||||
|
PEP 440 Version Scheme
|
||||||
|
|
||||||
|
See: https://peps.python.org/pep-0440/
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SemVer(BaseVersion):
|
||||||
|
"""
|
||||||
|
Semantic Versioning (SemVer) scheme
|
||||||
|
|
||||||
|
See: https://semver.org/spec/v1.0.0.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Epoch
|
||||||
|
if self.epoch != 0:
|
||||||
|
parts.append(f"{self.epoch}!")
|
||||||
|
|
||||||
|
# Release segment
|
||||||
|
parts.append(".".join(str(x) for x in self.release))
|
||||||
|
|
||||||
|
# Pre-release
|
||||||
|
if self.prerelease:
|
||||||
|
parts.append(f"-{self.prerelease}")
|
||||||
|
|
||||||
|
# Post-release
|
||||||
|
if self.post is not None:
|
||||||
|
parts.append(f"-post{self.post}")
|
||||||
|
|
||||||
|
# Development release
|
||||||
|
if self.dev is not None:
|
||||||
|
parts.append(f"-dev{self.dev}")
|
||||||
|
|
||||||
|
# Local version segment
|
||||||
|
if self.local:
|
||||||
|
parts.append(f"+{self.local}")
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class SemVer2(SemVer):
|
||||||
|
"""
|
||||||
|
Semantic Versioning 2.0 (SemVer2) schema
|
||||||
|
|
||||||
|
See: https://semver.org/spec/v2.0.0.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
_STD_PRELEASES = {
|
||||||
|
"a": "alpha",
|
||||||
|
"b": "beta",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prerelease(self) -> str | None:
|
||||||
|
if self.is_prerelease and self.pre:
|
||||||
|
prerelease_type = self._STD_PRELEASES.get(self.pre[0], self.pre[0])
|
||||||
|
return f"{prerelease_type}.{self.pre[1]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Epoch
|
||||||
|
if self.epoch != 0:
|
||||||
|
parts.append(f"{self.epoch}!")
|
||||||
|
|
||||||
|
# Release segment
|
||||||
|
parts.append(".".join(str(x) for x in self.release))
|
||||||
|
|
||||||
|
# Pre-release identifiers
|
||||||
|
# See: https://semver.org/spec/v2.0.0.html#spec-item-9
|
||||||
|
prerelease_parts = []
|
||||||
|
if self.prerelease:
|
||||||
|
prerelease_parts.append(f"{self.prerelease}")
|
||||||
|
|
||||||
|
# Post-release
|
||||||
|
if self.post is not None:
|
||||||
|
prerelease_parts.append(f"post.{self.post}")
|
||||||
|
|
||||||
|
# Development release
|
||||||
|
if self.dev is not None:
|
||||||
|
prerelease_parts.append(f"dev.{self.dev}")
|
||||||
|
|
||||||
|
if prerelease_parts:
|
||||||
|
parts.append("-")
|
||||||
|
parts.append(".".join(prerelease_parts))
|
||||||
|
|
||||||
|
# Local version segment
|
||||||
|
if self.local:
|
||||||
|
parts.append(f"+{self.local}")
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SCHEME: VersionScheme = Pep440
|
||||||
|
|
||||||
|
SCHEMES_ENTRYPOINT = "commitizen.scheme"
|
||||||
|
"""Schemes entrypoints group"""
|
||||||
|
|
||||||
|
KNOWN_SCHEMES = [ep.name for ep in metadata.entry_points(group=SCHEMES_ENTRYPOINT)]
|
||||||
|
"""All known registered version schemes"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_scheme(settings: Settings, name: str | None = None) -> VersionScheme:
|
||||||
|
"""
|
||||||
|
Get the version scheme as defined in the configuration
|
||||||
|
or from an overridden `name`
|
||||||
|
|
||||||
|
:raises VersionSchemeUnknown: if the version scheme is not found.
|
||||||
|
"""
|
||||||
|
# TODO: Remove the deprecated `version_type` handling
|
||||||
|
deprecated_setting: str | None = settings.get("version_type")
|
||||||
|
if deprecated_setting:
|
||||||
|
warnings.warn(
|
||||||
|
DeprecationWarning(
|
||||||
|
"`version_type` setting is deprecated and will be removed in commitizen 4. "
|
||||||
|
"Please use `version_scheme` instead"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
name = name or settings.get("version_scheme") or deprecated_setting
|
||||||
|
if not name:
|
||||||
|
return DEFAULT_SCHEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
(ep,) = metadata.entry_points(name=name, group=SCHEMES_ENTRYPOINT)
|
||||||
|
except ValueError:
|
||||||
|
raise VersionSchemeUnknown(f'Version scheme "{name}" unknown.')
|
||||||
|
scheme = cast(VersionScheme, ep.load())
|
||||||
|
|
||||||
|
if not isinstance(scheme, VersionProtocol):
|
||||||
|
warnings.warn(f"Version scheme {name} does not implement the VersionProtocol")
|
||||||
|
|
||||||
|
return scheme
|
183
docs/README.md
Normal file
183
docs/README.md
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
[](https://github.com/commitizen-tools/commitizen/actions)
|
||||||
|
[](https://conventionalcommits.org)
|
||||||
|
[](https://pypi.org/project/commitizen/)
|
||||||
|
[](https://pypi.org/project/commitizen/)
|
||||||
|
[](https://pypi.org/project/commitizen/)
|
||||||
|
[](https://anaconda.org/conda-forge/commitizen)
|
||||||
|
[](https://formulae.brew.sh/formula/commitizen)
|
||||||
|
[](https://codecov.io/gh/commitizen-tools/commitizen)
|
||||||
|
[](https://github.com/pre-commit/pre-commit)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation:** [https://commitizen-tools.github.io/commitizen/](https://commitizen-tools.github.io/commitizen/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Commitizen is release management tool designed for teams.
|
||||||
|
|
||||||
|
Commitizen assumes your team uses a standard way of committing rules
|
||||||
|
and from that foundation, it can bump your project's version, create
|
||||||
|
the changelog, and update files.
|
||||||
|
|
||||||
|
By default, commitizen uses [conventional commits][conventional_commits], but you
|
||||||
|
can build your own set of rules, and publish them.
|
||||||
|
|
||||||
|
Using a standardized set of rules to write commits, makes commits easier to read, and enforces writing
|
||||||
|
descriptive commits.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Command-line utility to create commits with your rules. Defaults: [Conventional commits][conventional_commits]
|
||||||
|
- Bump version automatically using [semantic versioning][semver] based on the commits. [Read More](./commands/bump.md)
|
||||||
|
- Generate a changelog using [Keep a changelog][keepchangelog]
|
||||||
|
- Update your project's version files automatically
|
||||||
|
- Display information about your commit rules (commands: schema, example, info)
|
||||||
|
- Create your own set of rules and publish them to pip. Read more on [Customization](./customization.md)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
[Python](https://www.python.org/downloads/) `3.9+`
|
||||||
|
|
||||||
|
[Git][gitscm] `1.8.5.2+`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install commitizen in your system using `pipx` (Recommended, <https://pypa.github.io/pipx/installation/>):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx ensurepath
|
||||||
|
pipx install commitizen
|
||||||
|
pipx upgrade commitizen
|
||||||
|
```
|
||||||
|
|
||||||
|
Install commitizen using `pip` with `--user` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --user -U commitizen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python project
|
||||||
|
|
||||||
|
You can add it to your local project using one of the following.
|
||||||
|
|
||||||
|
With `pip`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U commitizen
|
||||||
|
```
|
||||||
|
|
||||||
|
With `conda`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda install -c conda-forge commitizen
|
||||||
|
```
|
||||||
|
|
||||||
|
With Poetry >= 1.2.0:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry add commitizen --group dev
|
||||||
|
```
|
||||||
|
|
||||||
|
With Poetry < 1.2.0:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry add commitizen --dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
via [homebrew](https://formulae.brew.sh/formula/commitizen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install commitizen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Most of the time this is the only command you'll run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz bump
|
||||||
|
```
|
||||||
|
|
||||||
|
On top of that, you can use commitizen to assist you with the creation of commits:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Read more in the section [Getting Started](./getting_started.md).
|
||||||
|
|
||||||
|
### Help
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cz --help
|
||||||
|
usage: cz [-h] [--debug] [-n NAME] [-nr NO_RAISE] {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
|
||||||
|
--config the path of configuration file
|
||||||
|
--debug use debug mode
|
||||||
|
-n NAME, --name NAME use the given commitizen (default: cz_conventional_commits)
|
||||||
|
-nr NO_RAISE, --no-raise NO_RAISE
|
||||||
|
comma separated error codes that won't rise error, e.g: cz -nr 1,2,3 bump. See codes at https://commitizen-
|
||||||
|
tools.github.io/commitizen/exit_codes/
|
||||||
|
|
||||||
|
commands:
|
||||||
|
{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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(register-python-argcomplete cz)"
|
||||||
|
```
|
||||||
|
|
||||||
|
For further information on activation, please visit the [argcomplete website](https://kislyuk.github.io/argcomplete/).
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
These are our cool sponsors!
|
||||||
|
|
||||||
|
<!-- sponsors --><!-- sponsors -->
|
||||||
|
|
||||||
|
[conventional_commits]: https://www.conventionalcommits.org
|
||||||
|
[semver]: https://semver.org/
|
||||||
|
[keepchangelog]: https://keepachangelog.com/
|
||||||
|
[gitscm]: https://git-scm.com/downloads
|
636
docs/commands/bump.md
Normal file
636
docs/commands/bump.md
Normal file
|
@ -0,0 +1,636 @@
|
||||||
|

|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
`cz bump` **automatically** increases the version, based on the commits.
|
||||||
|
|
||||||
|
The commits should follow the rules established by the committer in order to be parsed correctly.
|
||||||
|
|
||||||
|
**prerelease** versions are supported (alpha, beta, release candidate).
|
||||||
|
|
||||||
|
The version can also be **manually** bumped.
|
||||||
|
|
||||||
|
The version format follows [PEP 0440][pep440] and [semantic versioning][semver].
|
||||||
|
|
||||||
|
This means `MAJOR.MINOR.PATCH`
|
||||||
|
|
||||||
|
| Increment | Description | Conventional commit map |
|
||||||
|
| --------- | --------------------------- | ----------------------- |
|
||||||
|
| `MAJOR` | Breaking changes introduced | `BREAKING CHANGE` |
|
||||||
|
| `MINOR` | New features | `feat` |
|
||||||
|
| `PATCH` | Fixes | `fix` + everything else |
|
||||||
|
|
||||||
|
[PEP 0440][pep440] is the default, you can switch by using the setting `version_scheme` or the cli:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz bump --version-scheme semver
|
||||||
|
```
|
||||||
|
|
||||||
|
Some examples of pep440:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0.9.0
|
||||||
|
0.9.1
|
||||||
|
0.9.2
|
||||||
|
0.9.10
|
||||||
|
0.9.11
|
||||||
|
1.0.0a0 # alpha
|
||||||
|
1.0.0a1
|
||||||
|
1.0.0b0 # beta
|
||||||
|
1.0.0rc0 # release candidate
|
||||||
|
1.0.0rc1
|
||||||
|
1.0.0
|
||||||
|
1.0.1
|
||||||
|
1.1.0
|
||||||
|
2.0.0
|
||||||
|
2.0.1a
|
||||||
|
```
|
||||||
|
|
||||||
|
`post` releases are not supported yet.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### `--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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--prerelease`
|
||||||
|
|
||||||
|
The bump is a pre-release bump, meaning that in addition to a possible version bump the new version receives a
|
||||||
|
pre-release segment compatible with the bump’s version scheme, where the segment consist of a _phase_ and a
|
||||||
|
non-negative number. Supported options for `--prerelease` are the following phase names `alpha`, `beta`, or
|
||||||
|
`rc` (release candidate). For more details, refer to the
|
||||||
|
[Python Packaging User Guide](https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases).
|
||||||
|
|
||||||
|
Note that as per [semantic versioning spec](https://semver.org/#spec-item-9)
|
||||||
|
|
||||||
|
> Pre-release versions have a lower precedence than the associated normal version. A pre-release version
|
||||||
|
> indicates that the version is unstable and might not satisfy the intended compatibility requirements
|
||||||
|
> as denoted by its associated normal version.
|
||||||
|
|
||||||
|
For example, the following versions (using the [PEP 440](https://peps.python.org/pep-0440/) scheme) are ordered
|
||||||
|
by their precedence and showcase how a release might flow through a development cycle:
|
||||||
|
|
||||||
|
- `1.0.0` is the current published version
|
||||||
|
- `1.0.1a0` after committing a `fix:` for pre-release
|
||||||
|
- `1.1.0a1` after committing an additional `feat:` for pre-release
|
||||||
|
- `1.1.0b0` after bumping a beta release
|
||||||
|
- `1.1.0rc0` after bumping the release candidate
|
||||||
|
- `1.1.0` next feature release
|
||||||
|
|
||||||
|
### `--increment-mode`
|
||||||
|
|
||||||
|
By default, `--increment-mode` is set to `linear`, which ensures that bumping pre-releases _maintains linearity_:
|
||||||
|
bumping of a pre-release with lower precedence than the current pre-release phase maintains the current phase of
|
||||||
|
higher precedence. For example, if the current version is `1.0.0b1` then bumping with `--prerelease alpha` will
|
||||||
|
continue to bump the “beta” phase.
|
||||||
|
|
||||||
|
Setting `--increment-mode` to `exact` instructs `cz bump` to instead apply the
|
||||||
|
exact changes that have been specified with `--increment` or determined from the commit log. For example,
|
||||||
|
`--prerelease beta` will always result in a `b` tag, and `--increment PATCH` will always increase the patch component.
|
||||||
|
|
||||||
|
Below are some examples that illustrate the difference in behavior:
|
||||||
|
|
||||||
|
| Increment | Pre-release | Start Version | `--increment-mode=linear` | `--increment-mode=exact` |
|
||||||
|
|-----------|-------------|---------------|---------------------------|--------------------------|
|
||||||
|
| `MAJOR` | | `2.0.0b0` | `2.0.0` | `3.0.0` |
|
||||||
|
| `MINOR` | | `2.0.0b0` | `2.0.0` | `2.1.0` |
|
||||||
|
| `PATCH` | | `2.0.0b0` | `2.0.0` | `2.0.1` |
|
||||||
|
| `MAJOR` | `alpha` | `2.0.0b0` | `3.0.0a0` | `3.0.0a0` |
|
||||||
|
| `MINOR` | `alpha` | `2.0.0b0` | `2.0.0b1` | `2.1.0a0` |
|
||||||
|
| `PATCH` | `alpha` | `2.0.0b0` | `2.0.0b1` | `2.0.1a0` |
|
||||||
|
|
||||||
|
### `--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
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(..., 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`.
|
||||||
|
|
||||||
|
### `--annotated-tag-message`
|
||||||
|
|
||||||
|
If `--annotated-tag-message` is used, commitizen will create annotated tags with the given message.
|
||||||
|
|
||||||
|
### `--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 recommended 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--git-output-to-stderr`
|
||||||
|
|
||||||
|
If `--git-output-to-stderr` is used, git commands output is redirected to stderr.
|
||||||
|
|
||||||
|
This command is useful when used with `--changelog-to-stdout` and piping the output to a file,
|
||||||
|
and you don't want the `git commit` output polluting the stdout.
|
||||||
|
|
||||||
|
### `--retry`
|
||||||
|
|
||||||
|
If you use tools like [pre-commit](https://pre-commit.com/), add this flag.
|
||||||
|
It will retry the commit if it fails the 1st time.
|
||||||
|
|
||||||
|
Useful to combine with code formatters, like [Prettier](https://prettier.io/).
|
||||||
|
|
||||||
|
### `--major-version-zero`
|
||||||
|
|
||||||
|
A project in its initial development should have a major version zero, and even breaking changes
|
||||||
|
should not bump that major version from zero. This command ensures that behavior.
|
||||||
|
|
||||||
|
If `--major-version-zero` is used for projects that have a version number greater than zero it fails.
|
||||||
|
If used together with a manual version the command also fails.
|
||||||
|
|
||||||
|
We recommend setting `major_version_zero = true` in your configuration file while a project
|
||||||
|
is in its initial development. Remove that configuration using a breaking-change commit to bump
|
||||||
|
your project’s major version to `v1.0.0` once your project has reached maturity.
|
||||||
|
|
||||||
|
### `--version-scheme`
|
||||||
|
|
||||||
|
Choose the version format, options: `pep440`, `semver`.
|
||||||
|
|
||||||
|
Default: `pep440`
|
||||||
|
|
||||||
|
Recommended for python: `pep440`
|
||||||
|
|
||||||
|
Recommended for other: `semver`
|
||||||
|
|
||||||
|
You can also set this in the [configuration](#version_scheme) with `version_scheme = "semver"`.
|
||||||
|
|
||||||
|
[pep440][pep440] and [semver][semver] are quite similar, their difference lies in
|
||||||
|
how the prereleases look.
|
||||||
|
|
||||||
|
| schemes | pep440 | semver |
|
||||||
|
| -------------- | -------------- | --------------- |
|
||||||
|
| non-prerelease | `0.1.0` | `0.1.0` |
|
||||||
|
| prerelease | `0.3.1a0` | `0.3.1-a0` |
|
||||||
|
| devrelease | `0.1.1.dev1` | `0.1.1-dev1` |
|
||||||
|
| dev and pre | `1.0.0a3.dev1` | `1.0.0-a3-dev1` |
|
||||||
|
|
||||||
|
Can I transition from one to the other?
|
||||||
|
|
||||||
|
Yes, you shouldn't have any issues.
|
||||||
|
|
||||||
|
### `--template`
|
||||||
|
|
||||||
|
Provides your own changelog jinja template.
|
||||||
|
See [the template customization section](../customization.md#customizing-the-changelog-template)
|
||||||
|
|
||||||
|
### `--extra`
|
||||||
|
|
||||||
|
Provides your own changelog extra variables by using the `extras` settings or the `--extra/-e` parameter.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz bump --changelog --extra key=value -e short="quoted value"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [the template customization section](../customization.md#customizing-the-changelog-template).
|
||||||
|
|
||||||
|
### `--build-metadata`
|
||||||
|
|
||||||
|
Provides a way to specify additional metadata in the version string. This parameter is not compatible with `--local-version` as it uses the same part of the version string.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz bump --build-metadata yourmetadata
|
||||||
|
```
|
||||||
|
|
||||||
|
Will create a version like `1.1.2+yourmetadata`.
|
||||||
|
This can be useful for multiple things
|
||||||
|
- Git hash in version
|
||||||
|
- Labeling the version with additional metadata.
|
||||||
|
|
||||||
|
Note that Commitizen ignores everything after `+` when it bumps the version. It is therefore safe to write different build-metadata between versions.
|
||||||
|
|
||||||
|
You should normally not use this functionality, but if you decide to do, keep in mind that
|
||||||
|
- Version `1.2.3+a`, and `1.2.3+b` are the same version! Tools should not use the string after `+` for version calculation. This is probably not a guarantee (example in helm) even tho it is in the spec.
|
||||||
|
- It might be problematic having the metadata in place when doing upgrades depending on what tool you use.
|
||||||
|
|
||||||
|
### `--get-next`
|
||||||
|
|
||||||
|
Provides a way to determine the next version and write it to stdout. This parameter is not compatible with `--changelog`
|
||||||
|
and `manual version`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz bump --get-next
|
||||||
|
```
|
||||||
|
|
||||||
|
Will output the next version, e.g., `1.2.3`. This can be useful for determining the next version based on CI for non
|
||||||
|
production environments/builds.
|
||||||
|
|
||||||
|
This behavior differs from the `--dry-run` flag. The `--dry-run` flag provides a more detailed output and can also show
|
||||||
|
the changes as they would appear in the changelog file.
|
||||||
|
|
||||||
|
The following output is the result of `cz bump --dry-run`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bump: version 3.28.0 → 3.29.0
|
||||||
|
tag to create: v3.29.0
|
||||||
|
increment detected: MINOR
|
||||||
|
```
|
||||||
|
|
||||||
|
The following output is the result of `cz bump --get-next`:
|
||||||
|
|
||||||
|
```
|
||||||
|
3.29.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--get-next` flag will raise a `NoneIncrementExit` if the found commits are not eligible for a version bump.
|
||||||
|
|
||||||
|
For information on how to suppress this exit, see [avoid raising errors](#avoid-raising-errors).
|
||||||
|
|
||||||
|
### `--allow-no-commit`
|
||||||
|
|
||||||
|
Allow the project version to be bumped even when there's no eligible version. This is most useful when used with `--increment {MAJOR,MINOR,PATCH}` or `[MANUL_VERSION]`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# bump a minor version even when there's only bug fixes, documentation changes or even no commits
|
||||||
|
cz bump --incremental MINOR --allow-no-commit
|
||||||
|
|
||||||
|
# bump version to 2.0.0 even when there's no breaking changes changes or even no commits
|
||||||
|
cz bump --allow-no-commit 2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avoid raising errors
|
||||||
|
|
||||||
|
Some situations from commitizen raise an exit code different than 0.
|
||||||
|
If the error code is different than 0, any CI or script running commitizen might be interrupted.
|
||||||
|
|
||||||
|
If you have a special use case, where you don't want to raise one of this error codes, you can
|
||||||
|
tell commitizen to not raise them.
|
||||||
|
|
||||||
|
### Recommended use case
|
||||||
|
|
||||||
|
At the moment, we've identified that the most common error code to skip is
|
||||||
|
|
||||||
|
| Error name | Exit code |
|
||||||
|
| ----------------- | --------- |
|
||||||
|
| NoneIncrementExit | 21 |
|
||||||
|
|
||||||
|
There are some situations where you don't want to get an error code when some
|
||||||
|
commits do not match your rules, you just want those commits to be skipped.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz -nr 21 bump
|
||||||
|
```
|
||||||
|
|
||||||
|
### Easy way
|
||||||
|
|
||||||
|
Check which error code was raised by commitizen by running in the terminal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
The output should be an integer like this
|
||||||
|
|
||||||
|
```sh
|
||||||
|
3
|
||||||
|
```
|
||||||
|
|
||||||
|
And then you can tell commitizen to ignore it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz --no-raise 3
|
||||||
|
```
|
||||||
|
|
||||||
|
You can tell commitizen to skip more than one if needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz --no-raise 3,4,5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Longer way
|
||||||
|
|
||||||
|
Check the list of [exit_codes](../exit_codes.md) and understand which one you have
|
||||||
|
to skip and why.
|
||||||
|
|
||||||
|
Remember to document somewhere this, because you'll forget.
|
||||||
|
|
||||||
|
For example if the system raises a `NoneIncrementExit` error, you look it up
|
||||||
|
on the list and then you can use the exit code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz -nr 21 bump
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### `tag_format`
|
||||||
|
|
||||||
|
`tag_format` and `version_scheme` are combined to make Git tag names from versions.
|
||||||
|
|
||||||
|
These are used in:
|
||||||
|
|
||||||
|
- `cz bump`: Find previous release tag (exact match) and generate new tag.
|
||||||
|
- Find previous release tags in `cz changelog`.
|
||||||
|
- If `--incremental`: Using latest version found in the changelog, scan existing Git tags with 89\% similarity match.
|
||||||
|
- `--rev-range` is converted to Git tag names with `tag_format` before searching Git history.
|
||||||
|
- If the `scm` `version_provider` is used, it uses different regexes to find the previous version tags:
|
||||||
|
- If `tag_format` is set to `$version` (default): `VersionProtocol.parser` (allows `v` prefix)
|
||||||
|
- If `tag_format` is set: Custom regex similar to SemVer (not as lenient as PEP440 e.g. on dev-releases)
|
||||||
|
|
||||||
|
Commitizen supports 2 types of formats, a simple and a more complex.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz bump --tag-format="v$version"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz bump --tag-format="v$minor.$major.$patch$prerelease.$devrelease"
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `pyproject.toml` or `.cz.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
tag_format = "v$major.$minor.$patch$prerelease"
|
||||||
|
```
|
||||||
|
|
||||||
|
The variables must be preceded by a `$` sign and optionally can be wrapped in `{}` . Default is `$version`.
|
||||||
|
|
||||||
|
Supported variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|--------------------------------|---------------------------------------------|
|
||||||
|
| `$version`, `${version}` | full generated version |
|
||||||
|
| `$major`, `${major}` | MAJOR increment |
|
||||||
|
| `$minor`, `${minor}` | MINOR increment |
|
||||||
|
| `$patch`, `${patch}` | PATCH increment |
|
||||||
|
| `$prerelease`, `${prerelease}` | Prerelease (alpha, beta, release candidate) |
|
||||||
|
| `$devrelease`, ${devrelease}` | Development release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `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 (`:`).
|
||||||
|
|
||||||
|
Commitizen will update its configuration file automatically (`pyproject.toml`, `.cz`) when bumping,
|
||||||
|
regarding if the file is present or not in `version_files`.
|
||||||
|
|
||||||
|
\* Renamed from `files` to `version_files`.
|
||||||
|
|
||||||
|
Some examples
|
||||||
|
|
||||||
|
`pyproject.toml`, `.cz.toml` or `cz.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.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.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Files can be specified using relative (to the execution) paths, absolute paths
|
||||||
|
or glob patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `bump_message`
|
||||||
|
|
||||||
|
Template used to specify the commit message generated when bumping.
|
||||||
|
|
||||||
|
defaults to: `bump: version $current_version → $new_version`
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------------ | ----------------------------------- |
|
||||||
|
| `$current_version` | the version existing before bumping |
|
||||||
|
| `$new_version` | version generated after bumping |
|
||||||
|
|
||||||
|
Some examples
|
||||||
|
|
||||||
|
`pyproject.toml`, `.cz.toml` or `cz.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
bump_message = "release $current_version → $new_version [skip-ci]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `annotated_tag`
|
||||||
|
|
||||||
|
When set to `true` commitizen will create annotated tags.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
annotated_tag = true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `gpg_sign`
|
||||||
|
|
||||||
|
When set to `true` commitizen will create gpg signed tags.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
gpg_sign = true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `major_version_zero`
|
||||||
|
|
||||||
|
When set to `true` commitizen will keep the major version at zero.
|
||||||
|
Useful during the initial development stage of your project.
|
||||||
|
|
||||||
|
Defaults to: `false`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
major_version_zero = true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `pre_bump_hooks`
|
||||||
|
|
||||||
|
A list of optional commands that will run right _after_ updating `version_files`
|
||||||
|
and _before_ actual committing and tagging the release.
|
||||||
|
|
||||||
|
Useful when you need to generate documentation based on the new version. During
|
||||||
|
execution of the script, some environment variables are available:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `CZ_PRE_IS_INITIAL` | `True` when this is the initial release, `False` otherwise |
|
||||||
|
| `CZ_PRE_CURRENT_VERSION` | Current version, before the bump |
|
||||||
|
| `CZ_PRE_CURRENT_TAG_VERSION` | Current version tag, before the bump |
|
||||||
|
| `CZ_PRE_NEW_VERSION` | New version, after the bump |
|
||||||
|
| `CZ_PRE_NEW_TAG_VERSION` | New version tag, after the bump |
|
||||||
|
| `CZ_PRE_MESSAGE` | Commit message of the bump |
|
||||||
|
| `CZ_PRE_INCREMENT` | Whether this is a `MAJOR`, `MINOR` or `PATH` release |
|
||||||
|
| `CZ_PRE_CHANGELOG_FILE_NAME` | Path to the changelog file, if available |
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
pre_bump_hooks = [
|
||||||
|
"scripts/generate_documentation.sh"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `post_bump_hooks`
|
||||||
|
|
||||||
|
A list of optional commands that will run right _after_ committing and tagging the release.
|
||||||
|
|
||||||
|
Useful when you need to send notifications about a release, or further automate deploying the
|
||||||
|
release. During execution of the script, some environment variables are available:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------- |
|
||||||
|
| `CZ_POST_WAS_INITIAL` | `True` when this was the initial release, `False` otherwise |
|
||||||
|
| `CZ_POST_PREVIOUS_VERSION` | Previous version, before the bump |
|
||||||
|
| `CZ_POST_PREVIOUS_TAG_VERSION` | Previous version tag, before the bump |
|
||||||
|
| `CZ_POST_CURRENT_VERSION` | Current version, after the bump |
|
||||||
|
| `CZ_POST_CURRENT_TAG_VERSION` | Current version tag, after the bump |
|
||||||
|
| `CZ_POST_MESSAGE` | Commit message of the bump |
|
||||||
|
| `CZ_POST_INCREMENT` | Whether this was a `MAJOR`, `MINOR` or `PATH` release |
|
||||||
|
| `CZ_POST_CHANGELOG_FILE_NAME` | Path to the changelog file, if available |
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
post_bump_hooks = [
|
||||||
|
"scripts/slack_notification.sh"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `prerelease_offset`
|
||||||
|
|
||||||
|
Offset with which to start counting prereleases.
|
||||||
|
|
||||||
|
Defaults to: `0`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
prerelease_offset = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### `version_scheme`
|
||||||
|
|
||||||
|
Choose version scheme
|
||||||
|
|
||||||
|
| schemes | pep440 | semver | semver2 |
|
||||||
|
| -------------- | -------------- | --------------- | --------------------- |
|
||||||
|
| non-prerelease | `0.1.0` | `0.1.0` | `0.1.0` |
|
||||||
|
| prerelease | `0.3.1a0` | `0.3.1-a0` | `0.3.1-alpha.0` |
|
||||||
|
| devrelease | `0.1.1.dev1` | `0.1.1-dev1` | `0.1.1-dev.1` |
|
||||||
|
| dev and pre | `1.0.0a3.dev1` | `1.0.0-a3-dev1` | `1.0.0-alpha.3.dev.1` |
|
||||||
|
|
||||||
|
Options: `pep440`, `semver`, `semver2`
|
||||||
|
|
||||||
|
Defaults to: `pep440`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
version_scheme = "semver"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom bump
|
||||||
|
|
||||||
|
Read the [customizing section](../customization.md).
|
||||||
|
|
||||||
|
[pep440]: https://www.python.org/dev/peps/pep-0440/
|
||||||
|
[semver]: https://semver.org/
|
195
docs/commands/changelog.md
Normal file
195
docs/commands/changelog.md
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
## About
|
||||||
|
|
||||||
|
This command will generate a changelog following the committing rules established.
|
||||||
|
|
||||||
|
To create the changelog automatically on bump, add the setting [update_changelog_on_bump](./bump.md#update_changelog_on_bump)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.commitizen]
|
||||||
|
update_changelog_on_bump = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Generate full changelog
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz changelog
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz ch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get the changelog for the given version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz changelog 0.3.0 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get the changelog for the given version range
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz changelog 0.3.0..0.4.0 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 a chicken and egg 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### merge-prerelease
|
||||||
|
|
||||||
|
This flag can be set in the `toml` file with the key `changelog_merge_prerelease` under `tools.commitizen`
|
||||||
|
|
||||||
|
Collects changes from prereleases into the next non-prerelease. This means that if you have a prerelease version, and then a normal release, the changelog will show the prerelease changes as part of the changes of the normal release. If not set, it will include prereleases in the changelog.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz changelog --merge-prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tools.commitizen]
|
||||||
|
# ...
|
||||||
|
changelog_merge_prerelease = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `template`
|
||||||
|
|
||||||
|
Provides your own changelog jinja template by using the `template` settings or the `--template` parameter.
|
||||||
|
See [the template customization section](../customization.md#customizing-the-changelog-template)
|
||||||
|
|
||||||
|
### `extras`
|
||||||
|
|
||||||
|
Provides your own changelog extra variables by using the `extras` settings or the `--extra/-e` parameter.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz changelog --extra key=value -e short="quoted value"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [the template customization section](../customization.md#customizing-the-changelog-template)
|
||||||
|
|
||||||
|
## 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
|
87
docs/commands/check.md
Normal file
87
docs/commands/check.md
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
# Check
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
This feature checks whether the commit message follows the given committing rules. And comment in git message will be ignored.
|
||||||
|
|
||||||
|
If you want to setup an automatic check before every git commit, please refer to
|
||||||
|
[Automatically check message before commit](../tutorials/auto_check.md).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
There are three mutually exclusive ways to use `cz check`:
|
||||||
|
|
||||||
|
- with `--rev-range` to check a range of pre-existing commits
|
||||||
|
- with `--message` or by piping the message to it to check a given string
|
||||||
|
- or with `--commit-msg-file` to read the commit message from a file
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cz check --rev-range REV_RANGE
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Commit Message
|
||||||
|
|
||||||
|
There are two ways you can provide your plain message and check it.
|
||||||
|
|
||||||
|
#### Method 1: use -m or --message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cz check --message MESSAGE
|
||||||
|
```
|
||||||
|
|
||||||
|
In this option, MESSAGE is the commit message to be checked.
|
||||||
|
|
||||||
|
#### Method 2: use pipe to pipe it to `cz check`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ echo MESSAGE | cz check
|
||||||
|
```
|
||||||
|
|
||||||
|
In this option, MESSAGE is piped to cz check and would be checked.
|
||||||
|
|
||||||
|
### Commit Message File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cz check --commit-msg-file COMMIT_MSG_FILE
|
||||||
|
```
|
||||||
|
|
||||||
|
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](../tutorials/auto_check.md) for more information about how to use this argument with git hook.
|
||||||
|
|
||||||
|
### Allow Abort
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz check --message MESSAGE --allow-abort
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty commit messages typically instruct Git to abort a commit, so you can pass `--allow-abort` to
|
||||||
|
permit them. Since `git commit` accepts an `--allow-empty-message` flag (primarily for wrapper scripts), you may wish to disallow such commits in CI. `--allow-abort` may be used in conjunction with any of the other options.
|
||||||
|
|
||||||
|
### Allowed Prefixes
|
||||||
|
|
||||||
|
If the commit message starts by some specific prefixes, `cz check` returns `True` without checkign the regex.
|
||||||
|
By default, the the following prefixes are allowed: `Merge`, `Revert`, `Pull request`, `fixup!` and `squash!`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cz check --message MESSAGE --allowed-prefixes 'Merge' 'Revert' 'Custom Prefix'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit message length limit
|
||||||
|
|
||||||
|
The argument `-l` (or `--message-length-limmit`) followed by a positive number, can limit the length of commit messages.
|
||||||
|
For example, `cz check --message MESSAGE -l 3` would fail the check, since `MESSAGE` is more than 3 characters long.
|
||||||
|
By default, the limit is set to 0, which means no limit on the length.
|
||||||
|
|
||||||
|
**Note that the limit applies only to the first line of the message.***
|
||||||
|
Specifically, for `ConventionalCommitsCz` the length only counts from the type of change to the subject,
|
||||||
|
while the body, and the footer are not counted.
|
52
docs/commands/commit.md
Normal file
52
docs/commands/commit.md
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|

|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
In your terminal run `cz commit` or the shortcut `cz c` to generate a guided git commit.
|
||||||
|
|
||||||
|
You can run `cz commit --write-message-to-file COMMIT_MSG_FILE` to additionally save the
|
||||||
|
generated message to a file. This can be combined with the `--dry-run` flag to only
|
||||||
|
write the message to a file and not modify files and create a commit. A possible use
|
||||||
|
case for this is to [automatically prepare a commit message](../tutorials/auto_prepare_commit_message.md).
|
||||||
|
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
To maintain platform compatibility, the `commit` command disable ANSI escaping in its output.
|
||||||
|
In particular pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### git options
|
||||||
|
|
||||||
|
`git` command options that are not implemented by commitizen can be use via the `--` syntax for the `commit` command.
|
||||||
|
The syntax separates commitizen arguments from `git commit` arguments by a double dash. This is the resulting syntax:
|
||||||
|
```sh
|
||||||
|
cz commit <commitizen-args> -- <git-cli-args>
|
||||||
|
|
||||||
|
# e.g., cz commit --dry-run -- -a -S
|
||||||
|
```
|
||||||
|
For example, using the `-S` option on `git commit` to sign a commit is now commitizen compatible: `cz c -- -S`
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Deprecation warning: A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`.
|
||||||
|
This syntax is now deprecated in favor of the new `cz commit -- -s` syntax.
|
||||||
|
|
||||||
|
### Retry
|
||||||
|
|
||||||
|
You can use `cz commit --retry` to reuse the last commit message when the previous commit attempt failed.
|
||||||
|
To automatically retry when running `cz commit`, you can set the `retry_after_failure`
|
||||||
|
configuration option to `true`. Running `cz commit --no-retry` makes commitizen ignore `retry_after_failure`, forcing
|
||||||
|
a new commit message to be prompted.
|
||||||
|
|
||||||
|
### Commit message length limit
|
||||||
|
|
||||||
|
The argument `-l` (or `--message-length-limit`) followed by a positive number can limit the length of commit messages.
|
||||||
|
An exception would be raised when the message length exceeds the limit.
|
||||||
|
For example, `cz commit -l 72` will limit the length of commit messages to 72 characters.
|
||||||
|
By default the limit is set to 0, which means no limit on the length.
|
||||||
|
|
||||||
|
**Note that the limit applies only to the first line of the message.**
|
||||||
|
Specifically, for `ConventionalCommitsCz` the length only counts from the type of change to the subject,
|
||||||
|
while the body and the footer are not counted.
|
5
docs/commands/example.md
Normal file
5
docs/commands/example.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Show commit example
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
5
docs/commands/info.md
Normal file
5
docs/commands/info.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Show information about the cz
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
27
docs/commands/init.md
Normal file
27
docs/commands/init.md
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
To start using commitizen, the recommended approach is to run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cz init
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This command will ask you for information about the project and will
|
||||||
|
configure the selected file type (`pyproject.toml`, `.cz.toml`, etc.).
|
||||||
|
|
||||||
|
The `init` will help you with
|
||||||
|
|
||||||
|
1. Choose a convention rules (`name`)
|
||||||
|
2. Choosing a version provider (`commitizen` or for example `Cargo.toml`)
|
||||||
|
3. Detecting your project's version
|
||||||
|
4. Detecting the tag format used
|
||||||
|
5. Choosing a version type (`semver` or `pep440`)
|
||||||
|
6. Whether to create the changelog automatically or not during bump
|
||||||
|
7. Whether you want to keep the major as zero while building alpha software.
|
||||||
|
8. Whether to setup pre-commit hooks.
|
3
docs/commands/ls.md
Normal file
3
docs/commands/ls.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
5
docs/commands/schema.md
Normal file
5
docs/commands/schema.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Show commit schema
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
5
docs/commands/version.md
Normal file
5
docs/commands/version.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Get the version of the installed commitizen or the current project (default: installed commitizen)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|

|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue