1
0
Fork 0

Adding upstream version 4.6.0+dfsg.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-04-21 10:42:01 +02:00
parent f3ad83a1a5
commit 167a3f8553
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
275 changed files with 30423 additions and 0 deletions

3
.codacy.yaml Normal file
View file

@ -0,0 +1,3 @@
exclude_paths:
- 'tests/**'
- 'docs/**'

7
.github/.codecov.yml vendored Normal file
View file

@ -0,0 +1,7 @@
coverage:
status:
project:
default:
# minimum of 97% (real 96%)
target: 97%
threshold: 1%

1
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1 @@
* @woile @Lee-W @noirbizarre

4
.github/FUNDING.yml vendored Normal file
View 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
View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

21
LICENSE Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
from commitizen.cli import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1 @@
__version__ = "4.6.0"

153
commitizen/bump.py Normal file
View 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
View 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

View 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

View 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"))

View 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"
)

View 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"))

View 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:])
)

View 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
View 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
View 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,
)

View 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
View 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)

View 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)

View 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))

View 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!")

View 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())

View 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
View 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)

View 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()))

View 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())

View 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__}")

View 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

View 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()

View 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

View 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

View 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
View 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
View 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]

View file

@ -0,0 +1 @@
from .conventional_commits import ConventionalCommitsCz # noqa: F401

View 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()

View file

@ -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 commits 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]

View file

@ -0,0 +1 @@
from .customize import CustomizeCommitsCz # noqa

View 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 ""

View file

@ -0,0 +1,4 @@
class CzException(Exception): ...
class AnswerRequiredError(CzException): ...

View file

@ -0,0 +1,3 @@
from .jira import JiraSmartCz
__all__ = ["JiraSmartCz"]

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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))

View 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

View 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

View 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)

View 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

View 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

View 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"

View 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

View 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

View 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
View file

267
commitizen/tags.py Normal file
View 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"],
)

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View file

@ -0,0 +1,183 @@
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/commitizen-tools/commitizen/pythonpackage.yml?label=python%20package&logo=github&logoColor=white&style=flat-square)](https://github.com/commitizen-tools/commitizen/actions)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org)
[![PyPI Package latest release](https://img.shields.io/pypi/v/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/)
[![PyPI Package download count (per month)](https://img.shields.io/pypi/dm/commitizen?style=flat-square)](https://pypi.org/project/commitizen/)
[![Supported versions](https://img.shields.io/pypi/pyversions/commitizen.svg?style=flat-square)](https://pypi.org/project/commitizen/)
[![Conda Version](https://img.shields.io/conda/vn/conda-forge/commitizen?style=flat-square)](https://anaconda.org/conda-forge/commitizen)
[![homebrew](https://img.shields.io/homebrew/v/commitizen?color=teal&style=flat-square)](https://formulae.brew.sh/formula/commitizen)
[![Codecov](https://img.shields.io/codecov/c/github/commitizen-tools/commitizen.svg?style=flat-square)](https://codecov.io/gh/commitizen-tools/commitizen)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=flat-square&logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
![Using commitizen cli](images/demo.gif)
---
**Documentation:** [https://commitizen-tools.github.io/commitizen/](https://commitizen-tools.github.io/commitizen/)
---
## About
Commitizen is 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
View file

@ -0,0 +1,636 @@
![Bump version](../images/bump.gif)
## 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
![cz bump --help](../images/cli_help/cz_bump___help.svg)
### `--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 bumps 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 projects 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
View 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
![cz changelog --help](../images/cli_help/cz_changelog___help.svg)
### 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
View 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
![cz check --help](../images/cli_help/cz_check___help.svg)
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
View file

@ -0,0 +1,52 @@
![Using commitizen cli](../images/demo.gif)
## 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
![cz commit --help](../images/cli_help/cz_commit___help.svg)
### 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
View file

@ -0,0 +1,5 @@
Show commit example
## Usage
![cz example --help](../images/cli_help/cz_example___help.svg)

5
docs/commands/info.md Normal file
View file

@ -0,0 +1,5 @@
Show information about the cz
## Usage
![cz info --help](../images/cli_help/cz_info___help.svg)

27
docs/commands/init.md Normal file
View file

@ -0,0 +1,27 @@
## Usage
![cz init --help](../images/cli_help/cz_init___help.svg)
## Example
To start using commitizen, the recommended approach is to run
```sh
cz init
```
![init](../images/init.gif)
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
View file

@ -0,0 +1,3 @@
## Usage
![cz ls --help](../images/cli_help/cz_ls___help.svg)

5
docs/commands/schema.md Normal file
View file

@ -0,0 +1,5 @@
Show commit schema
## Usage
![cz schema --help](../images/cli_help/cz_schema___help.svg)

5
docs/commands/version.md Normal file
View file

@ -0,0 +1,5 @@
Get the version of the installed commitizen or the current project (default: installed commitizen)
## Usage
![cz version --help](../images/cli_help/cz_version___help.svg)

Some files were not shown because too many files have changed in this diff Show more