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
59 changes: 49 additions & 10 deletions src/convbump/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ def _run(
strict: bool,
directory: Optional[str] = None,
ignored_patterns: Optional[Iterable[str]] = None,
extra_dirs: Optional[Iterable[str]] = None,
) -> Tuple[Version, str]:
"""Find the next version and generate a changelog from
conventional commits.

If `strict` is True, non-conventional commits are not allowed and the command will fail.

If `directory` is not None, only commits that affect that directory and tags
containing this directory (normalized) are selected."""
containing this directory (normalized) are selected.

If `extra_dirs` is provided, commits affecting those directories are also
included in version calculation, but extra dirs do NOT influence tag scoping."""

if ignored_patterns is None:
ignored_patterns = []
Expand All @@ -50,14 +54,23 @@ def _run(
echo("Using default first version")
return DEFAULT_FIRST_VERSION, ""

if directory:
dirs_to_check = [directory] + list(extra_dirs or [])
else:
dirs_to_check = []

conventional_commits = []
for commit in git.list_commits(tag):
if not directory or commit.affects_dir(directory):
conventional_commit = ConventionalCommit.from_git_commit(commit, ignored_patterns)
if not dirs_to_check or any(commit.affects_dir(d) for d in dirs_to_check):
conventional_commit = ConventionalCommit.from_git_commit(
commit, ignored_patterns
)
logger.debug("Processed conventional_commit=%s", conventional_commit)
# Check if this is a non-conventional commit in strict mode
if not conventional_commit.is_conventional and strict:
raise ValueError(f"Non-conventional commit found in strict mode: {commit.subject}")
raise ValueError(
f"Non-conventional commit found in strict mode: {commit.subject}"
)

# Only include conventional commits for version calculation
if conventional_commit.is_conventional:
Expand Down Expand Up @@ -100,23 +113,34 @@ def convbump(verbose: bool) -> None:
@convbump.command()
@click.pass_context
@click.option(
"--project-path", default=".", type=click.Path(file_okay=False, exists=True, path_type=Path)
"--project-path",
default=".",
type=click.Path(file_okay=False, exists=True, path_type=Path),
)
@click.option(
"--strict", is_flag=True, default=False, help="Fail if non-Conventinal commits are found"
"--strict",
is_flag=True,
default=False,
help="Fail if non-Conventinal commits are found",
)
@click.option(
"--ignore-pattern",
is_flag=False,
multiple=True,
help="Commits with containing this pattern will be ignored",
)
@click.option(
"--extra-dir",
multiple=True,
help="Extra directory to detect changes in (does not affect tag naming). Can be repeated.",
)
@click.argument("directory", required=False)
def version(
ctx: click.Context,
project_path: Path,
strict: bool,
ignore_pattern: Tuple[str],
extra_dir: Tuple[str],
directory: Optional[str],
) -> None:
"""Calculate next version from Git history.
Expand All @@ -135,7 +159,9 @@ def version(
git = Git(project_path)

try:
next_version, changelog = _run(git, strict, directory, ignore_pattern)
next_version, changelog = _run(
git, strict, directory, ignore_pattern, extra_dir
)
except ValueError as e:
ctx.fail(e) # type: ignore

Expand All @@ -148,23 +174,34 @@ def version(
@convbump.command()
@click.pass_context
@click.option(
"--project-path", default=".", type=click.Path(file_okay=False, exists=True, path_type=Path)
"--project-path",
default=".",
type=click.Path(file_okay=False, exists=True, path_type=Path),
)
@click.option(
"--strict", is_flag=True, default=False, help="Fail if non-Conventinal commits are found"
"--strict",
is_flag=True,
default=False,
help="Fail if non-Conventinal commits are found",
)
@click.option(
"--ignore-pattern",
is_flag=False,
multiple=True,
help="Commits with containing this pattern will be ignored",
)
@click.option(
"--extra-dir",
multiple=True,
help="Extra directory to detect changes in (does not affect tag naming). Can be repeated.",
)
@click.argument("directory", required=False)
def changelog(
ctx: click.Context,
project_path: Path,
strict: bool,
ignore_pattern: Tuple[str],
extra_dir: Tuple[str],
directory: Optional[str],
) -> None:
"""Create a ChangeLog from Git history.
Expand All @@ -183,7 +220,9 @@ def changelog(
git = Git(project_path)

try:
next_version, changelog = _run(git, strict, directory, ignore_pattern)
next_version, changelog = _run(
git, strict, directory, ignore_pattern, extra_dir
)
except ValueError as e:
ctx.fail(e) # type: ignore

Expand Down
155 changes: 145 additions & 10 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import subprocess
from pathlib import Path
from typing import List

import pytest
Expand All @@ -13,6 +15,7 @@

from convbump.__main__ import _run, ignore_commit
from convbump.conventional import CommitType, ConventionalCommit
from convbump.git import Git
from convbump.version import DEFAULT_FIRST_VERSION


Expand All @@ -34,34 +37,46 @@ def test_no_new_commits_after_tag(create_git_repository: GitFactory) -> None:
_run(git, False)


def test_no_conventional_commit_after_tag_strict(create_git_repository: GitFactory) -> None:
def test_no_conventional_commit_after_tag_strict(
create_git_repository: GitFactory,
) -> None:
git = create_git_repository([(INITIAL_COMMIT, "v1.0.0"), "Non-conventional commit"])
with pytest.raises(ValueError):
_run(git, True)


def test_no_conventional_commit_after_tag_not_strict(create_git_repository: GitFactory) -> None:
def test_no_conventional_commit_after_tag_not_strict(
create_git_repository: GitFactory,
) -> None:
git = create_git_repository([(INITIAL_COMMIT, "v1.0.0"), "Non-conventional commit"])
with pytest.raises(ValueError):
_run(git, False)


def test_find_last_valid_version_tag(create_git_repository: GitFactory) -> None:
git = create_git_repository(
[(INITIAL_COMMIT, "v0.1.0"), ("Second commit", "not-a-version"), BREAKING_FEATURE_IN_BODY]
[
(INITIAL_COMMIT, "v0.1.0"),
("Second commit", "not-a-version"),
BREAKING_FEATURE_IN_BODY,
]
)
next_version, _ = _run(git, False)
assert next_version == DEFAULT_FIRST_VERSION.bump_major()


def test_non_conventional_commits_strict(create_git_repository: GitFactory) -> None:
git = create_git_repository([(INITIAL_COMMIT, "v0.1.0"), FEATURE, "Non-conventional commit"])
git = create_git_repository(
[(INITIAL_COMMIT, "v0.1.0"), FEATURE, "Non-conventional commit"]
)
with pytest.raises(ValueError):
_run(git, True)


def test_non_conventional_commits_not_strict(create_git_repository: GitFactory) -> None:
git = create_git_repository([(INITIAL_COMMIT, "v0.1.0"), FEATURE, "Non-conventional commit"])
git = create_git_repository(
[(INITIAL_COMMIT, "v0.1.0"), FEATURE, "Non-conventional commit"]
)
next_version, _ = _run(git, False)
assert next_version == DEFAULT_FIRST_VERSION.bump_minor()

Expand Down Expand Up @@ -181,7 +196,9 @@ def test_conventional_commits(create_git_repository: GitFactory) -> None:
),
],
)
def test_ignore_commit(patterns: List[str], commit: ConventionalCommit, result: bool) -> None:
def test_ignore_commit(
patterns: List[str], commit: ConventionalCommit, result: bool
) -> None:
assert ignore_commit(patterns, commit) is result


Expand Down Expand Up @@ -235,19 +252,27 @@ def test_squashed_merge_with_ignored_commits(create_git_repository: GitFactory)

With "chore" ignored, should select feat (highest priority among non-ignored).
"""
git = create_git_repository([(INITIAL_COMMIT, "v0.1.0"), SQUASHED_MERGE_WITH_IGNORED])
git = create_git_repository(
[(INITIAL_COMMIT, "v0.1.0"), SQUASHED_MERGE_WITH_IGNORED]
)

# Without ignore patterns - should select feat (highest priority)
next_version_no_ignore, _ = _run(git, False)
assert next_version_no_ignore == DEFAULT_FIRST_VERSION.bump_minor() # feat -> minor bump
assert (
next_version_no_ignore == DEFAULT_FIRST_VERSION.bump_minor()
) # feat -> minor bump

# With "chore" ignored - should still select feat (highest among non-ignored)
next_version_ignore_chore, _ = _run(git, False, ignored_patterns=["chore"])
assert next_version_ignore_chore == DEFAULT_FIRST_VERSION.bump_minor() # feat -> minor bump
assert (
next_version_ignore_chore == DEFAULT_FIRST_VERSION.bump_minor()
) # feat -> minor bump

# With "feat" ignored - should select fix (highest among non-ignored)
next_version_ignore_feat, _ = _run(git, False, ignored_patterns=["feat"])
assert next_version_ignore_feat == DEFAULT_FIRST_VERSION.bump_patch() # fix -> patch bump
assert (
next_version_ignore_feat == DEFAULT_FIRST_VERSION.bump_patch()
) # fix -> patch bump

# With both "feat" and "fix" ignored - should select chore (patch bump)
next_version_ignore_feat_fix, _ = _run(git, False, ignored_patterns=["feat", "fix"])
Expand All @@ -258,3 +283,113 @@ def test_squashed_merge_with_ignored_commits(create_git_repository: GitFactory)
# With all commit types ignored - should raise ValueError (no commits left)
with pytest.raises(ValueError): # No conventional commits left after ignoring
_run(git, False, ignored_patterns=["feat", "fix", "chore"])


def _commit_file(repo_path: Path, directory: str, filename: str, message: str) -> None:
"""Create a file in the given directory and commit it."""
dir_path = repo_path / directory
dir_path.mkdir(parents=True, exist_ok=True)
file_path = dir_path / filename
file_path.write_text(message)
subprocess.check_call(["git", "add", "."], cwd=repo_path)
subprocess.check_call(["git", "commit", "-m", message], cwd=repo_path)


def _init_repo_with_tag(tmp_path: Path, tag: str) -> Git:
"""Initialize a repo with a single commit and tag."""
subprocess.check_call(["git", "init"], cwd=tmp_path)
subprocess.check_call(
["git", "commit", "--allow-empty", "-m", INITIAL_COMMIT], cwd=tmp_path
)
subprocess.check_call(["git", "tag", "-a", "-m", "release", tag], cwd=tmp_path)
return Git(tmp_path)


def test_extra_dir_includes_commits_from_extra_directory(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""Commits touching only an extra-dir should be included in the version bump."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

# Commit only touches "shared/" — not the primary "core/" directory
_commit_file(tmp_path, "shared", "util.py", "feat: add shared utility")

next_version, _ = _run(git, False, directory="core", extra_dirs=["shared"])
assert next_version == DEFAULT_FIRST_VERSION.bump_minor()


def test_extra_dir_does_not_affect_tag_scoping(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""Tags should still be resolved using only the primary directory, not extra dirs."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

# Also create a higher-versioned tag scoped to "shared"
subprocess.check_call(
["git", "commit", "--allow-empty", "-m", "bump shared"], cwd=tmp_path
)
subprocess.check_call(
["git", "tag", "-a", "-m", "release", "shared/v9.0.0"], cwd=tmp_path
)

_commit_file(tmp_path, "core", "mod.py", "fix: core bugfix")

# Even though shared/v9.0.0 exists, tags are scoped to "core" only
next_version, _ = _run(git, False, directory="core", extra_dirs=["shared"])
assert next_version == DEFAULT_FIRST_VERSION.bump_patch()


def test_extra_dir_no_commits_raises(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""If no commits touch primary or extra dirs, should raise ValueError."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

_commit_file(tmp_path, "unrelated", "file.py", "feat: unrelated change")

with pytest.raises(ValueError):
_run(git, False, directory="core", extra_dirs=["shared"])


def test_extra_dir_combines_with_primary_directory(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""Commits from both primary and extra dirs are considered together."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

_commit_file(tmp_path, "core", "main.py", "fix: core fix")
_commit_file(tmp_path, "shared", "lib.py", "feat: shared feature")

# feat is the highest impact, so version should be a minor bump
next_version, _ = _run(git, False, directory="core", extra_dirs=["shared"])
assert next_version == DEFAULT_FIRST_VERSION.bump_minor()


def test_extra_dir_multiple_dirs(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""Multiple extra dirs can be specified."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

_commit_file(tmp_path, "lib_b", "mod.py", "feat: lib_b feature")

next_version, _ = _run(git, False, directory="core", extra_dirs=["lib_a", "lib_b"])
assert next_version == DEFAULT_FIRST_VERSION.bump_minor()


def test_extra_dir_empty_tuple_behaves_like_no_extra_dirs(
git_config: None, # pylint: disable=unused-argument
tmp_path: Path,
) -> None:
"""Passing an empty extra_dirs tuple should behave the same as not passing it."""
git = _init_repo_with_tag(tmp_path, "core/v0.1.0")

_commit_file(tmp_path, "unrelated", "file.py", "feat: unrelated change")

with pytest.raises(ValueError):
_run(git, False, directory="core", extra_dirs=())
Loading