diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index d83be9894..31366c82e 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -43,6 +43,7 @@ from semantic_release.version.algorithm import ( next_version, tags_and_versions, + tags_and_versions_in_history, ) from semantic_release.version.translator import VersionTranslator @@ -96,7 +97,7 @@ def version_from_forced_level( repo_dir: Path, forced_level_bump: LevelBump, translator: VersionTranslator ) -> Version: with Repo(str(repo_dir)) as git_repo: - ts_and_vs = tags_and_versions(git_repo.tags, translator) + ts_and_vs = tags_and_versions_in_history(git_repo, translator) # If we have no tags, return the default version if not ts_and_vs: diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 0057cfcce..cf31df57e 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -61,6 +61,34 @@ def tags_and_versions( return sorted(ts_and_vs, reverse=True, key=lambda v: v[1]) +def tags_and_versions_in_history( + repo: Repo, translator: VersionTranslator +) -> list[tuple[Tag, Version]]: + """Return parsed versions whose tags are reachable from the active branch.""" + parsed_tags_and_versions = tags_and_versions(repo.tags, translator) + + # A noop version command reports that it would unshallow the repository but + # deliberately leaves the history incomplete. Preserve the existing forced-bump + # behavior until the full graph is available. + if repo.git.rev_parse("--is-shallow-repository") == "true": + return parsed_tags_and_versions + + commit_hash_set = { + commit.hexsha + for commit in _traverse_graph_for_commits(head_commit=repo.active_branch.commit) + } + + historic_tags_and_versions: list[tuple[Tag, Version]] = [] + for tag, version in parsed_tags_and_versions: + # Tags pointing to tags are resolved automatically, but tags pointing to + # blobs or trees cannot be part of a branch's commit history. + with suppress(ValueError): + if tag.commit.hexsha in commit_hash_set: + historic_tags_and_versions.append((tag, version)) + + return historic_tags_and_versions + + def _traverse_graph_for_commits( head_commit: Commit, latest_release_tag_str: str = "", @@ -266,24 +294,10 @@ def next_version( "Translator was unable to parse the embedded default version" ) - # Step 1. All tags, sorted descending by semver ordering rules - all_git_tags_as_versions = tags_and_versions(repo.tags, translator) - - # Retrieve all commit hashes (regardless of merges) in the current branch's history from repo origin - commit_hash_set = { - commit.hexsha - for commit in _traverse_graph_for_commits(head_commit=repo.active_branch.commit) - } - - # Filter all releases that are not found in the current branch's history - historic_versions: list[Version] = [] - for tag, version in all_git_tags_as_versions: - # TODO: move this to tags_and_versions() function? - # Ignore the error that is raised when tag points to a Blob or Tree object rather - # than a commit object (tags that point to tags that then point to commits are resolved automatically) - with suppress(ValueError): - if tag.commit.hexsha in commit_hash_set: - historic_versions.append(version) + # Step 1. All versions in the current branch's history, sorted descending by semver + historic_versions = [ + version for _, version in tags_and_versions_in_history(repo, translator) + ] # Step 2. Get the latest final release version in the history of the current branch # or fallback to the default 0.0.0 starting version value if none are found diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index cd4ed2406..e6435c243 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -156,6 +156,39 @@ def test_version_print_next_version( assert post_mocker.call_count == 0 +@pytest.mark.parametrize( + "repo_result", + [lazy_fixture(repo_w_trunk_only_conventional_commits.__name__)], +) +def test_version_print_forced_bump_ignores_tags_outside_branch_history( + repo_result: BuiltRepoResult, + file_in_repo: str, + run_cli: RunCliFn, +): + """ + Given a higher version tag exists on a divergent branch, + When forcing a patch bump on the release branch, + Then the next version is based on the latest tag in the release branch history. + """ + repo = repo_result["repo"] + release_branch = repo.active_branch + + divergent_branch = repo.create_head("divergent-release").checkout() + add_text_to_file(repo, file_in_repo) + repo.git.commit(m="feat: divergent release", a=True) + repo.create_tag("v1.0.0") + release_branch.checkout() + + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print", "--patch"] + result = run_cli(cli_cmd[1:]) + + assert_successful_exit_code(result, cli_cmd) + assert result.stdout == "0.1.2\n" + assert divergent_branch.commit.hexsha not in { + commit.hexsha for commit in repo.iter_commits(release_branch) + } + + @pytest.mark.parametrize( "repo_result, commits, force_args, next_release_version", [