diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index d398964..e13894b 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -24,6 +24,10 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" + - name: install libgit2 + run: | + sudo apt-get update && sudo apt-get install -y libgit2-dev + - name: Sync dependencies run: | uv sync --all-extras diff --git a/README.md b/README.md index 95c9bfa..ef2b751 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 6a27f7e..c7ab20b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/pyvcc/semver.py b/pyvcc/semver.py index a6f191a..6d0bdd6 100644 --- a/pyvcc/semver.py +++ b/pyvcc/semver.py @@ -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: @@ -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 diff --git a/tests/semver/test_bump_version.py b/tests/semver/test_bump_version.py index 41b0ed9..a7cc830 100644 --- a/tests/semver/test_bump_version.py +++ b/tests/semver/test_bump_version.py @@ -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" diff --git a/uv.lock b/uv.lock index ee1420b..98b4300 100644 --- a/uv.lock +++ b/uv.lock @@ -356,7 +356,7 @@ wheels = [ [[package]] name = "pyvcc" -version = "1.2.2" +version = "1.3.1" source = { editable = "." } dependencies = [ { name = "pygit2" },