# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
"""Tests for releasekit.versioning (Conventional Commits + bump computation)."""
from __future__ import annotations
from pathlib import Path
import pytest
from releasekit.backends._run import CommandResult
from releasekit.errors import ReleaseKitError
from releasekit.versioning import (
BumpType,
_apply_bump,
_max_bump,
compute_bumps,
parse_conventional_commit,
)
from releasekit.workspace import Package
class FakeVCS:
"""Minimal VCS implementation for testing compute_bumps.
Simulates per-package path-scoped git log output.
"""
def __init__(
self,
log_by_path: dict[str, list[str]] | None = None,
tags: set[str] | None = None,
) -> None:
"""Initialize with optional path-scoped log and tag set."""
self._log_by_path = log_by_path or {}
self._tags = tags or set()
def log(
self,
*,
since_tag: str | None = None,
paths: list[str] | None = None,
format: str = '%H %s',
) -> list[str]:
"""Return log lines, optionally filtered by path."""
if paths:
result: list[str] = []
for p in paths:
result.extend(self._log_by_path.get(p, []))
return result
all_lines: list[str] = []
for lines in self._log_by_path.values():
all_lines.extend(lines)
return all_lines
def tag_exists(self, tag_name: str) -> bool:
"""Return True if tag is in the fake tag set."""
return tag_name in self._tags
def is_clean(self, *, dry_run: bool = False) -> bool:
"""Always returns True."""
return True
def is_shallow(self) -> bool:
"""Always returns False."""
return False
def current_sha(self) -> str:
"""Return a fake SHA."""
return 'fake_sha'
def diff_files(self, *, since_tag: str | None = None) -> list[str]:
"""Return empty list (not used in compute_bumps)."""
return []
def commit(self, message: str, *, paths: list[str] | None = None, dry_run: bool = False) -> CommandResult:
"""No-op commit."""
return CommandResult(command=[], returncode=0, stdout='', stderr='')
def tag(
self,
tag_name: str,
*,
message: str | None = None,
dry_run: bool = False,
) -> CommandResult:
"""No-op tag."""
return CommandResult(command=[], returncode=0, stdout='', stderr='')
def delete_tag(
self,
tag_name: str,
*,
remote: bool = False,
dry_run: bool = False,
) -> CommandResult:
"""No-op delete_tag."""
return CommandResult(command=[], returncode=0, stdout='', stderr='')
def push(
self,
*,
tags: bool = False,
remote: str = 'origin',
dry_run: bool = False,
) -> CommandResult:
"""No-op push."""
return CommandResult(command=[], returncode=0, stdout='', stderr='')
def checkout_branch(
self,
branch: str,
*,
create: bool = False,
dry_run: bool = False,
) -> CommandResult:
"""No-op checkout_branch."""
return CommandResult(command=[], returncode=0, stdout='', stderr='')
class TestParseConventionalCommit:
"""Tests for parse_conventional_commit."""
def test_feat(self) -> None:
"""A 'feat:' commit produces a MINOR bump."""
cc = parse_conventional_commit('feat: add streaming support')
assert cc is not None
assert cc.type == 'feat'
assert cc.bump == BumpType.MINOR
assert cc.description == 'add streaming support'
assert cc.breaking is False
def test_fix(self) -> None:
"""A 'fix:' commit produces a PATCH bump."""
cc = parse_conventional_commit('fix: resolve null pointer')
assert cc is not None
assert cc.type == 'fix'
assert cc.bump == BumpType.PATCH
def test_perf(self) -> None:
"""A 'perf:' commit produces a PATCH bump."""
cc = parse_conventional_commit('perf: optimize hot loop')
assert cc is not None
assert cc.type == 'perf'
assert cc.bump == BumpType.PATCH
def test_chore(self) -> None:
"""A 'chore:' commit produces no bump."""
cc = parse_conventional_commit('chore: update deps')
assert cc is not None
assert cc.type == 'chore'
assert cc.bump == BumpType.NONE
def test_docs(self) -> None:
"""A 'docs:' commit produces no bump."""
cc = parse_conventional_commit('docs: update README')
assert cc is not None
assert cc.bump == BumpType.NONE
def test_ci(self) -> None:
"""A 'ci:' commit produces no bump."""
cc = parse_conventional_commit('ci: add coverage')
assert cc is not None
assert cc.bump == BumpType.NONE
def test_scope(self) -> None:
"""Scoped commits are parsed correctly."""
cc = parse_conventional_commit('feat(auth): add OAuth2')
assert cc is not None
assert cc.type == 'feat'
assert cc.scope == 'auth'
assert cc.bump == BumpType.MINOR
def test_breaking_bang(self) -> None:
"""The '!' indicator marks a breaking change (MAJOR)."""
cc = parse_conventional_commit('fix!: remove deprecated API')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_breaking_scope_bang(self) -> None:
"""Breaking change with scope."""
cc = parse_conventional_commit('feat(api)!: redesign endpoints')
assert cc is not None
assert cc.type == 'feat'
assert cc.scope == 'api'
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_breaking_change_footer(self) -> None:
"""BREAKING CHANGE in message triggers MAJOR."""
cc = parse_conventional_commit('feat: new API BREAKING CHANGE')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_non_conventional(self) -> None:
"""Non-conventional messages return None."""
assert parse_conventional_commit('Update README.md') is None
assert parse_conventional_commit('Merge pull request #42') is None
assert parse_conventional_commit('WIP') is None
def test_sha_preserved(self) -> None:
"""The SHA is stored on the result."""
cc = parse_conventional_commit('fix: bug', sha='abc123')
assert cc is not None
assert cc.sha == 'abc123'
def test_empty_description(self) -> None:
"""Commit with no description after colon returns None."""
assert parse_conventional_commit('feat:') is None
assert parse_conventional_commit('feat: ') is None
class TestMaxBump:
"""Tests for _max_bump."""
def test_major_wins(self) -> None:
"""MAJOR always wins."""
assert _max_bump(BumpType.MAJOR, BumpType.MINOR) == BumpType.MAJOR
assert _max_bump(BumpType.MINOR, BumpType.MAJOR) == BumpType.MAJOR
def test_minor_over_patch(self) -> None:
"""MINOR beats PATCH."""
assert _max_bump(BumpType.MINOR, BumpType.PATCH) == BumpType.MINOR
def test_patch_over_none(self) -> None:
"""PATCH beats NONE."""
assert _max_bump(BumpType.PATCH, BumpType.NONE) == BumpType.PATCH
def test_same(self) -> None:
"""Same bump type returns itself."""
assert _max_bump(BumpType.MINOR, BumpType.MINOR) == BumpType.MINOR
class TestApplyBump:
"""Tests for _apply_bump."""
def test_major(self) -> None:
"""Major bump increments major, resets minor and patch."""
assert _apply_bump('1.2.3', BumpType.MAJOR) == '2.0.0'
def test_minor(self) -> None:
"""Minor bump increments minor, resets patch."""
assert _apply_bump('1.2.3', BumpType.MINOR) == '1.3.0'
def test_patch(self) -> None:
"""Patch bump increments patch."""
assert _apply_bump('1.2.3', BumpType.PATCH) == '1.2.4'
def test_none(self) -> None:
"""No bump returns same version."""
assert _apply_bump('1.2.3', BumpType.NONE) == '1.2.3'
def test_prerelease(self) -> None:
"""Prerelease bump adds label."""
assert _apply_bump('1.2.3', BumpType.PRERELEASE, 'rc') == '1.2.4rc1'
def test_prerelease_default_label(self) -> None:
"""Prerelease with no label defaults to 'rc'."""
assert _apply_bump('1.2.3', BumpType.PRERELEASE) == '1.2.4rc1'
def test_zero_major(self) -> None:
"""Bumping from 0.x.y works correctly."""
assert _apply_bump('0.4.0', BumpType.MINOR) == '0.5.0'
assert _apply_bump('0.4.0', BumpType.MAJOR) == '1.0.0'
def test_invalid_version_too_few_parts(self) -> None:
"""Raises ReleaseKitError for versions with fewer than 3 parts."""
with pytest.raises(ReleaseKitError):
_apply_bump('1.0', BumpType.PATCH)
def test_invalid_version_non_numeric(self) -> None:
"""Raises ReleaseKitError for non-numeric version parts."""
with pytest.raises(ReleaseKitError):
_apply_bump('a.b.c', BumpType.PATCH)
def test_strips_prerelease_metadata(self) -> None:
"""Existing prerelease metadata is stripped before bumping."""
assert _apply_bump('1.2.3-rc1', BumpType.PATCH) == '1.2.4'
assert _apply_bump('1.2.3+build42', BumpType.MINOR) == '1.3.0'
class TestComputeBumps:
"""Integration tests for compute_bumps with FakeVCS."""
def _make_pkg(self, name: str, version: str, path: str) -> Package:
"""Helper to create a Package for testing."""
return Package(
name=name,
version=version,
path=Path(path),
pyproject_path=Path(path) / 'pyproject.toml',
)
def test_scopes_commits_per_package(self) -> None:
"""Only commits touching a package's path bump that package."""
vcs = FakeVCS(
log_by_path={
'/workspace/packages/genkit': [
'aaa feat: add streaming',
],
'/workspace/plugins/google-genai': [
'bbb fix: resolve timeout',
],
},
)
packages = [
self._make_pkg('genkit', '0.4.0', '/workspace/packages/genkit'),
self._make_pkg('genkit-plugin-google-genai', '0.4.0', '/workspace/plugins/google-genai'),
]
results = compute_bumps(packages, vcs)
assert len(results) == 2
# genkit gets a feat → minor bump
assert results[0].name == 'genkit'
assert results[0].bump == 'minor'
assert results[0].new_version == '0.5.0'
assert results[0].skipped is False
# google-genai gets a fix → patch bump
assert results[1].name == 'genkit-plugin-google-genai'
assert results[1].bump == 'patch'
assert results[1].new_version == '0.4.1'
def test_no_commits_skips_package(self) -> None:
"""Packages with no commits are skipped."""
vcs = FakeVCS(
log_by_path={
'/workspace/packages/genkit': [
'aaa feat: add feature',
],
},
)
packages = [
self._make_pkg('genkit', '0.4.0', '/workspace/packages/genkit'),
self._make_pkg('genkit-plugin-foo', '0.4.0', '/workspace/plugins/foo'),
]
results = compute_bumps(packages, vcs)
assert results[0].skipped is False
assert results[1].skipped is True
assert results[1].new_version == '0.4.0'
def test_force_unchanged(self) -> None:
"""force_unchanged bumps skipped packages to patch."""
vcs = FakeVCS(log_by_path={})
packages = [
self._make_pkg('genkit', '1.0.0', '/workspace/packages/genkit'),
]
results = compute_bumps(packages, vcs, force_unchanged=True)
assert results[0].skipped is False
assert results[0].bump == 'patch'
assert results[0].new_version == '1.0.1'
def test_prerelease_mode(self) -> None:
"""Prerelease mode converts bumps to prerelease versions."""
vcs = FakeVCS(
log_by_path={
'/workspace/packages/genkit': [
'aaa feat: add feature',
],
},
)
packages = [
self._make_pkg('genkit', '0.4.0', '/workspace/packages/genkit'),
]
results = compute_bumps(packages, vcs, prerelease='rc')
assert results[0].bump == 'prerelease'
assert results[0].new_version == '0.4.1rc1'
def test_breaking_change_major_bump(self) -> None:
"""Breaking changes result in major version bumps."""
vcs = FakeVCS(
log_by_path={
'/workspace/packages/genkit': [
'aaa feat!: redesign API',
],
},
)
packages = [
self._make_pkg('genkit', '1.2.3', '/workspace/packages/genkit'),
]
results = compute_bumps(packages, vcs)
assert results[0].bump == 'major'
assert results[0].new_version == '2.0.0'
def test_strongest_bump_wins(self) -> None:
"""When multiple commits affect a package, the strongest bump wins."""
vcs = FakeVCS(
log_by_path={
'/workspace/packages/genkit': [
'aaa fix: small fix',
'bbb feat: new feature',
'ccc chore: update deps',
],
},
)
packages = [
self._make_pkg('genkit', '1.0.0', '/workspace/packages/genkit'),
]
results = compute_bumps(packages, vcs)
# feat (minor) > fix (patch) > chore (none)
assert results[0].bump == 'minor'
assert results[0].new_version == '1.1.0'