Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,115 @@
# python-version-controller
conventional version controller for python
# PyVCC - Python Version Controller

**Automatic semantic versioning based on conventional commits**

PyVCC analyzes your Git repository's commit history and automatically determines the appropriate semantic version based on [Conventional Commits](https://www.conventionalcommits.org/) specification.

[![codecov](https://codecov.io/gh/lcavalcante/python-version-controller/graph/badge.svg?token=H65NZV7N3Z)](https://codecov.io/gh/lcavalcante/python-version-controller)
[![tests](https://github.com/lcavalcante/python-version-controller/actions/workflows/code-quality.yml/badge.svg)](https://github.com/lcavalcante/python-version-controller/actions/workflows/code-quality.yml)

## Features

- ✅ Automatic semantic version calculation (MAJOR.MINOR.PATCH)
- ✅ Conventional Commits specification support
- ✅ Breaking change detection (BREAKING CHANGE: footer or ! suffix)
- ✅ Customizable starting version and commit
- ✅ Fast Git repository analysis using pygit2

## Installation

```bash
pip install pyvcc
```

## Usage

### Basic Usage

```bash
# Run from your Git repository root
pyvcc
```

### Options

```bash
# Start from a specific version
pyvcc --initial-version 1.0.0

# Start analysis from a specific commit
pyvcc --initial-commit abc1234

# Verbose output
pyvcc --verbose

# Silent mode (errors only)
pyvcc --silent
```

### Environment Variables

```bash
# Set repository root
PYVC_REPO_ROOT=/path/to/repo pyvcc

# Set initial version
PYVC_INITIAL_VERSION=2.0.0 pyvcc

# Set initial commit
PYVC_INITIAL_COMMIT=abc1234 pyvcc
```

## How It Works

PyVCC analyzes each commit message in your Git history and applies semantic versioning rules:

- **MAJOR**: Incremented for breaking changes (commit type with ! or BREAKING CHANGE: footer)
- **MINOR**: Incremented for new features (feat: commits)
- **PATCH**: Incremented for bug fixes (fix: commits)
- **NO BUMP**: Other commit types (chore, docs, style, refactor, test, ci, build, perf)

### Commit Message Examples

```markdown
# Major version bump (breaking change)
feat!: Redesign API endpoint structure

# OR
feat: Migrate to new database schema

BREAKING CHANGE: Database schema has changed and requires migration

# Minor version bump (new feature)
feat: Add new user authentication API

# Patch version bump (bug fix)
fix: Resolve login error on Safari

# No version bump
chore: Update dependencies
docs: Update README installation instructions
style: Format code according to new style guide
```

## Requirements

- Python 3.10+
- Git repository with commit history
- Commit messages following Conventional Commits specification

## Development

```bash
# Install development dependencies
pip install -e .[dev]

# Run tests
pytest

# Run with coverage
pytest --cov=pyvcc
```

## License

MIT License - See [LICENSE](LICENSE) for details.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "PyVCC"
version = "1.3.1"
version = "1.3.2"
description = "PyVCC python version controller using conventional commits"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
31 changes: 24 additions & 7 deletions pyvcc/semver.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ def __repr__(self):
def is_breaking_change(type: str, message: str) -> bool:
"""
SemVer defines that a breaking change is a commit with a type ending in '!'
OR that contains 'BREAKING CHANGE:' in the messsage body
OR that contains 'BREAKING CHANGE:' in the message body

# Parameters:
type (str): Commit message type (feat, fix, chore, etc)
message (str): commit message content
"""
return type[-1] == "!" or "BREAKING CHANGE:" in message
return (type and type[-1] == "!") or "BREAKING CHANGE:" in message

@classmethod
def semver_from_string(cls, str_version: str) -> Self:
Expand Down Expand Up @@ -96,17 +96,34 @@ def bump_type(cls, message: str) -> BumpEnum:
message (str): commit message content
"""

regex = re.compile(r"[.*]?([a-z]*!?)(\(.*\))?:\s(.*)$")
regex = re.compile(r"^(?:Merged?\s+)?(\w+!?)(?:\(([^)]*)\))?(!?)(?::\s+(.+))?$")
bump = BumpEnum.NO_BUMP
head = message.split("\n")[0]

parsed_head = regex.search(head)

if parsed_head is not None:
commit_type = parsed_head.groups()[0].upper()
log.debug("trying bump", type=commit_type, message=head)

if cls.is_breaking_change(commit_type, message):
groups = parsed_head.groups()
type_with_breaking = groups[0].upper()
breaking_indicator = groups[2] # Captures '!' after scope, e.g. feat(api)!:

# Combine type+breaking indicator for is_breaking_change check
# e.g. "FEAT!" from type or "FEAT" + "!" from scope suffix
effective_type = (
type_with_breaking
if type_with_breaking.endswith("!")
else type_with_breaking + breaking_indicator
)
is_breaking = cls.is_breaking_change(effective_type, message)

# Clean the type by removing trailing !
commit_type = type_with_breaking.rstrip("!")

log.debug(
"trying bump", type=commit_type, message=head, breaking=is_breaking
)

if is_breaking:
bump = BumpEnum.MAJOR
elif commit_type == CommitEnum.FEAT.name:
bump = BumpEnum.MINOR
Expand Down
82 changes: 82 additions & 0 deletions tests/semver/test_bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,88 @@ def test_bump_multiple5():
assert str(version) == "1.0.0"


def test_bump_scope_with_slash():
"""Test commit with scope containing slash character"""
version = SemVer(1, 0, 0)
message = "feat(api/v2): Add new endpoint"
version.bump_version(message)
assert str(version) == "1.1.0" # Should be MINOR bump


def test_bump_scope_with_hyphen():
"""Test commit with scope containing hyphen"""
version = SemVer(1, 0, 0)
message = "fix(core-library): Resolve memory issue"
version.bump_version(message)
assert str(version) == "1.0.1" # Should be PATCH bump


def test_bump_breaking_with_scope():
"""Test breaking change commit with scope"""
version = SemVer(1, 0, 0)
message = "feat(api)!: Complete API redesign"
version.bump_version(message)
assert str(version) == "2.0.0" # Should be MAJOR bump


def test_bump_extra_spaces():
"""Test commit with extra spaces after colon"""
version = SemVer(1, 0, 0)
message = "feat: Add new feature with extra spaces"
version.bump_version(message)
assert str(version) == "1.1.0" # Should be MINOR bump


def test_bump_scope_with_underscores():
"""Test commit with scope containing underscores"""
version = SemVer(1, 0, 0)
message = "fix(user_auth): Resolve login issue"
version.bump_version(message)
assert str(version) == "1.0.1" # Should be PATCH bump


def test_bump_uppercase_type():
"""Test commit with uppercase type (should handle gracefully)"""
version = SemVer(1, 0, 0)
message = "FEAT: Uppercase type commit"
version.bump_version(message)
assert str(version) == "1.1.0" # Should be MINOR bump


def test_bump_trailing_exclamation_in_description():
"""Trailing ! in description is NOT a breaking change indicator"""
version = SemVer(1, 0, 0)
message = "feat: Description with breaking change!"
version.bump_version(message)
assert str(version) == "1.1.0" # Should be MINOR bump, not MAJOR


def test_bump_trailing_exclamation_in_fix_description():
"""Trailing ! in fix commit description should NOT trigger MAJOR bump"""
version = SemVer(1, 0, 0)
message = "fix: Critical bug fix!"
version.bump_version(message)
assert str(version) == "1.0.1" # Should be PATCH, not MAJOR


def test_bump_no_space_after_colon():
"""Test commit without space after colon (should not match conventional commit spec)"""
version = SemVer(1, 0, 0)
message = "feat:Description without space"
version.bump_version(message)
assert str(version) == "1.0.0" # Should NOT bump (no conventional commit match)


def test_breaking_change_null_type():
"""Test is_breaking_change with None type (null safety check)"""
version = SemVer(1, 0, 0)
# This tests the null safety check in is_breaking_change method
# where (type and type[-1] == "!") handles None type gracefully
message = "Some commit message without conventional format"
version.bump_version(message)
assert str(version) == "1.0.0" # Should NOT bump (no conventional commit match)


def test_parse_semver_str():
version = SemVer.semver_from_string("1.1.0")
assert str(version) == "1.1.0"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading