From db2c7ce2b5c86d88ea08834825551fa00bf8cb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Bo=C4=8Dek?= Date: Fri, 10 Apr 2026 13:08:47 +0200 Subject: [PATCH] feat: support extra directories for change gathering Useful for monorepos where you build a Python library out of another directory - say, a contracts library. --- src/convbump/__main__.py | 59 ++++++++++++--- tests/test_app.py | 155 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 194 insertions(+), 20 deletions(-) diff --git a/src/convbump/__main__.py b/src/convbump/__main__.py index 85ce16c..a07899b 100644 --- a/src/convbump/__main__.py +++ b/src/convbump/__main__.py @@ -32,6 +32,7 @@ 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. @@ -39,7 +40,10 @@ def _run( 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 = [] @@ -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: @@ -100,10 +113,15 @@ 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", @@ -111,12 +129,18 @@ def convbump(verbose: bool) -> None: 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. @@ -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 @@ -148,10 +174,15 @@ 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", @@ -159,12 +190,18 @@ def version( 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. @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index e07c75e..46586f5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,5 @@ +import subprocess +from pathlib import Path from typing import List import pytest @@ -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 @@ -34,13 +37,17 @@ 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) @@ -48,20 +55,28 @@ def test_no_conventional_commit_after_tag_not_strict(create_git_repository: GitF 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() @@ -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 @@ -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"]) @@ -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=())