diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index c515ed474..cf8610c10 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -875,17 +875,16 @@ to the GitHub Release Assets as well. contents: write steps: - # Note: We checkout the repository at the branch that triggered the workflow - # with the entire history to ensure to match PSR's release branch detection - # and history evaluation. - # However, we forcefully reset the branch to the workflow sha because it is - # possible that the branch was updated while the workflow was running. This - # prevents accidentally releasing un-evaluated changes. + # Note: We checkout the repository at the branch that triggered the workflow. + # Python Semantic Release will automatically convert shallow clones to full clones + # if needed to ensure proper history evaluation. However, we forcefully reset the + # branch to the workflow sha because it is possible that the branch was updated + # while the workflow was running, which prevents accidentally releasing un-evaluated + # changes. - name: Setup | Checkout Repository on Release Branch uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: | @@ -959,11 +958,6 @@ to the GitHub Release Assets as well. one release job in the case if there are multiple pushes to ``main`` in a short period of time. -.. warning:: - You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since - Python Semantic Release needs access to the full history to build a changelog - and at least the latest tags to determine the next version. - .. warning:: The ``GITHUB_TOKEN`` secret is automatically configured by GitHub, with the same permissions role as the user who triggered the workflow run. This causes @@ -974,6 +968,14 @@ to the GitHub Release Assets as well. case, you will also need to pass the new token to ``actions/checkout`` (as the ``token`` input) in order to gain push access. +.. note:: + As of $NEW_RELEASE_TAG, Python Semantic Release automatically detects and converts + shallow clones to full clones when needed. While you can still use ``fetch-depth: 0`` + with ``actions/checkout@v4`` to fetch the full history upfront, it is no longer + required. If you use the default shallow clone, Python Semantic Release will + automatically fetch the full history before evaluating commits. If you are using + an older version of PSR, you will need to unshallow the repository prior to use. + .. note:: As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been integrated into PSR directly. If you are using an older version of PSR, you will need diff --git a/docs/configuration/configuration-guides/uv_integration.rst b/docs/configuration/configuration-guides/uv_integration.rst index bc794832e..ac9f2359e 100644 --- a/docs/configuration/configuration-guides/uv_integration.rst +++ b/docs/configuration/configuration-guides/uv_integration.rst @@ -161,7 +161,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.sha }} - fetch-depth: 0 - name: Setup | Force correct release branch on workflow sha run: git checkout -B ${{ github.ref_name }} @@ -259,7 +258,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: git reset --hard ${{ github.sha }} diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 627cc1fc6..ad60b95aa 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -496,6 +496,17 @@ def version( # noqa: C901 logger.info("Forcing use of %s as the prerelease token", prerelease_token) translator.prerelease_token = prerelease_token + # Check if the repository is shallow and unshallow it if necessary + # This ensures we have the full history for commit analysis + project = GitProject( + directory=runtime.repo_dir, + commit_author=runtime.commit_author, + credential_masker=runtime.masker, + ) + if project.is_shallow_clone(): + logger.info("Repository is a shallow clone, converting to full clone...") + project.git_unshallow(noop=opts.noop) + # Only push if we're committing changes if push_changes and not commit_changes and not create_tag: logger.info("changes will not be pushed because --no-commit disables pushing") @@ -688,12 +699,6 @@ def version( # noqa: C901 license_name="" if not isinstance(license_cfg, str) else license_cfg, ) - project = GitProject( - directory=runtime.repo_dir, - commit_author=runtime.commit_author, - credential_masker=runtime.masker, - ) - # Preparing for committing changes; we always stage files even if we're not committing them in order to support a two-stage commit project.git_add(paths=all_paths_to_add, noop=opts.noop) if commit_changes: diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 05c4b1015..a29bb41de 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -90,6 +90,42 @@ def is_dirty(self) -> bool: with Repo(str(self.project_root)) as repo: return repo.is_dirty() + def is_shallow_clone(self) -> bool: + """ + Check if the repository is a shallow clone. + + :return: True if the repository is a shallow clone, False otherwise + """ + with Repo(str(self.project_root)) as repo: + shallow_file = Path(repo.git_dir, "shallow") + return shallow_file.exists() + + def git_unshallow(self, noop: bool = False) -> None: + """ + Convert a shallow clone to a full clone by fetching the full history. + + :param noop: Whether or not to actually run the unshallow command + """ + if noop: + noop_report("would have run:\n" " git fetch --unshallow") + return + + with Repo(str(self.project_root)) as repo: + try: + self.logger.info("Converting shallow clone to full clone...") + repo.git.fetch("--unshallow") + self.logger.info("Repository unshallowed successfully") + except GitCommandError as err: + # If the repository is already a full clone, git fetch --unshallow will fail + # with "fatal: --unshallow on a complete repository does not make sense" + # We can safely ignore this error by checking the stderr message + stderr = str(err.stderr) if err.stderr else "" + if "does not make sense" in stderr or "complete repository" in stderr: + self.logger.debug("Repository is already a full clone") + else: + self.logger.exception(str(err)) + raise + def git_add( self, paths: Sequence[Path | str], diff --git a/tests/e2e/cmd_version/test_version_shallow.py b/tests/e2e/cmd_version/test_version_shallow.py new file mode 100644 index 000000000..fa088e548 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_shallow.py @@ -0,0 +1,306 @@ +"""Tests for version command with shallow repositories.""" + +from __future__ import annotations + +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos import repo_w_trunk_only_conventional_commits +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_w_shallow_repo_unshallows( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command automatically unshallows a shallow repository. + + Given a shallow repository, + When running the version command, + Then the repository should be unshallowed and release should succeed + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 1 + commit_sha_before = shallow_repo.head.commit.hexsha + + # Run PSR on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + parent_commit_shas = [ + parent.hexsha for parent in shallow_repo.head.commit.parents + ] + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file is gone (repo was unshallowed) + assert not shallow_file.exists(), "Repository should be unshallowed" + + # Verify release was successful + assert commit_sha_before != commit_sha_after, "Expected commit SHA to change" + assert ( + commit_sha_before in parent_commit_shas + ), "Expected new commit to be created on HEAD" + assert ( + latest_tag in different_tags + ), "Expected a new tag to be created and pushed to remote" + assert expected_vcs_url_post == post_mocker.call_count # 1x vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_noop_w_shallow_repo( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command in noop mode reports unshallow action. + + Given a shallow repository, + When running the version command with --noop, + Then the command should report what it would do but not actually unshallow + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 0 + commit_sha_before = shallow_repo.head.commit.hexsha + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Run PSR in noop mode on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--noop", VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file still exists (repo was NOT actually unshallowed in noop) + assert shallow_file.exists(), "Repository should still be shallow in noop mode" + + # Verify no actual changes were made + assert ( + commit_sha_before == commit_sha_after + ), "Expected commit SHA to remain unchanged in noop mode" + assert not different_tags, "Expected no new tags to be created in noop mode" + assert expected_vcs_url_post == post_mocker.call_count diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py index d5795fff7..7b37a9756 100644 --- a/tests/unit/semantic_release/test_gitproject.py +++ b/tests/unit/semantic_release/test_gitproject.py @@ -1,11 +1,14 @@ +"""Tests for the GitProject class.""" + from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, PropertyMock, patch import pytest from git import GitCommandError +import semantic_release.gitproject from semantic_release.errors import ( DetachedHeadGitError, GitFetchError, @@ -13,16 +16,36 @@ UnknownUpstreamBranchError, UpstreamBranchChangedError, ) -from semantic_release.gitproject import GitProject if TYPE_CHECKING: from pathlib import Path + from typing import Generator + + from semantic_release.gitproject import GitProject + + class MockGit(MagicMock): + """A mock Git object that can be used in tests.""" + + rev_parse: MagicMock + fetch: MagicMock + push: MagicMock + + class RepoMock(MagicMock): + """A mock Git repository that can be used in tests.""" + + active_branch: MagicMock + remotes: dict[str, MagicMock] + git: MockGit + git_dir: str + commit: MagicMock @pytest.fixture -def mock_repo(): +def mock_repo(tmp_path: Path) -> RepoMock: """Create a mock Git repository with proper structure for new implementation.""" - repo = MagicMock() + repo = cast("RepoMock", MagicMock()) + + repo.git_dir = str(tmp_path / ".git") # Mock active branch active_branch = MagicMock() @@ -60,17 +83,30 @@ def mock_repo(): return repo -def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock): - """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" - git_project = GitProject(directory=tmp_path) +@pytest.fixture +def git_project(tmp_path: Path) -> GitProject: + """Create a GitProject instance for testing.""" + return semantic_release.gitproject.GitProject(directory=tmp_path) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: + +@pytest.fixture +def mock_gitproject( + git_project: GitProject, mock_repo: RepoMock +) -> Generator[GitProject, None, None]: + """Patch the GitProject to use the mock Repo.""" + module_path = semantic_release.gitproject.__name__ + with patch(f"{module_path}.Repo") as mock_repo_class: mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + yield git_project - # Should not raise an exception - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + +def test_verify_upstream_unchanged_success( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) # Verify fetch was called mock_repo.remotes["origin"].fetch.assert_called_once() @@ -79,11 +115,9 @@ def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock) def test_verify_upstream_unchanged_fails_when_changed( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when upstream has changed.""" - git_project = GitProject(directory=tmp_path) - # Mock git operations with different SHAs mock_repo.git.rev_parse = MagicMock( return_value="def456" # Different from upstream @@ -95,152 +129,160 @@ def test_verify_upstream_unchanged_fails_when_changed( changed_commit.iter_parents = MagicMock(return_value=[]) mock_repo.commit = MagicMock(return_value=changed_commit) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - UpstreamBranchChangedError, match=r"Upstream branch .* has changed" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises( + UpstreamBranchChangedError, match=r"Upstream branch .* has changed" + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) -def test_verify_upstream_unchanged_noop(tmp_path: Path): +def test_verify_upstream_unchanged_noop( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged does nothing in noop mode.""" - git_project = GitProject(directory=tmp_path) - - mock_repo = MagicMock() - - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should not raise an exception and should not call git operations - git_project.verify_upstream_unchanged(noop=True) + # Should not raise an exception and should not call git operations + mock_gitproject.verify_upstream_unchanged(noop=True) # Verify Repo was not instantiated at all in noop mode - mock_repo_class.assert_not_called() + mock_repo.assert_not_called() def test_verify_upstream_unchanged_no_tracking_branch( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when no tracking branch exists.""" - git_project = GitProject(directory=tmp_path) - # Mock no tracking branch mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should raise UnknownUpstreamBranchError - with pytest.raises( - UnknownUpstreamBranchError, match="No upstream branch found" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + # Should raise UnknownUpstreamBranchError + with pytest.raises(UnknownUpstreamBranchError, match="No upstream branch found"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) -def test_verify_upstream_unchanged_detached_head(tmp_path: Path): +def test_verify_upstream_unchanged_detached_head( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged raises error in detached HEAD state.""" - git_project = GitProject(directory=tmp_path) - - mock_repo = MagicMock() # Simulate detached HEAD by having active_branch raise TypeError # This is what GitPython does when in a detached HEAD state type(mock_repo).active_branch = PropertyMock(side_effect=TypeError("detached HEAD")) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + # Should raise DetachedHeadGitError + with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) - # Should raise DetachedHeadGitError - with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) - -def test_verify_upstream_unchanged_fetch_fails(tmp_path: Path, mock_repo: MagicMock): +def test_verify_upstream_unchanged_fetch_fails( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged raises GitFetchError when fetch fails.""" - git_project = GitProject(directory=tmp_path) - # Mock fetch to raise an error mock_repo.remotes["origin"].fetch = MagicMock( side_effect=GitCommandError("fetch", "error") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises(GitFetchError, match="Failed to fetch from remote"): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises(GitFetchError, match="Failed to fetch from remote"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_upstream_sha_fails( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when upstream SHA cannot be determined.""" - git_project = GitProject(directory=tmp_path) - # Mock refs to raise AttributeError (simulating missing branch) mock_repo.remotes["origin"].refs = MagicMock() mock_repo.remotes["origin"].refs.__getitem__ = MagicMock( side_effect=AttributeError("No such ref") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - GitFetchError, match="Unable to determine upstream branch SHA" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises(GitFetchError, match="Unable to determine upstream branch SHA"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_local_ref_sha_fails( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when local ref SHA cannot be determined.""" - git_project = GitProject(directory=tmp_path) - # Mock git operations - rev_parse fails mock_repo.git.rev_parse = MagicMock( side_effect=GitCommandError("rev-parse", "error") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - LocalGitError, - match="Unable to determine the SHA for local ref", - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises( + LocalGitError, + match="Unable to determine the SHA for local ref", + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_with_custom_ref( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged works with a custom ref like HEAD~1.""" - git_project = GitProject(directory=tmp_path) - - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should not raise an exception - git_project.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) # Verify rev_parse was called with custom ref mock_repo.git.rev_parse.assert_called_once_with("HEAD~1") + + +def test_is_shallow_clone_true(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns True when shallow file exists.""" + # Create a shallow file + shallow_file = tmp_path / ".git" / "shallow" + shallow_file.parent.mkdir(parents=True, exist_ok=True) + shallow_file.touch() + + assert mock_gitproject.is_shallow_clone() is True + + +def test_is_shallow_clone_false(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns False when shallow file does not exist.""" + # Ensure shallow file does not exist + shallow_file = tmp_path / ".git" / "shallow" + if shallow_file.exists(): + shallow_file.unlink() + + assert mock_gitproject.is_shallow_clone() is False + + +def test_git_unshallow_success( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow successfully unshallows a repository.""" + mock_gitproject.git_unshallow(noop=False) + mock_repo.git.fetch.assert_called_once_with("--unshallow") + + +def test_git_unshallow_noop(mock_gitproject: GitProject, mock_repo: RepoMock) -> None: + """Test git_unshallow in noop mode does not execute the command.""" + mock_gitproject.git_unshallow(noop=True) + mock_repo.git.fetch.assert_not_called() + + +def test_git_unshallow_already_complete( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow handles already-complete repository gracefully.""" + # Simulate error from git when repo is already complete + error_msg = "fatal: --unshallow on a complete repository does not make sense" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should not raise an exception + mock_gitproject.git_unshallow(noop=False) + + +def test_git_unshallow_other_error( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow raises exception for other errors.""" + # Simulate a different error + error_msg = "fatal: some other error" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should raise the exception + with pytest.raises(GitCommandError): + mock_gitproject.git_unshallow(noop=False)