diff --git a/src/poetry/vcs/git/backend.py b/src/poetry/vcs/git/backend.py index d0b028de639..dad4867cbbc 100644 --- a/src/poetry/vcs/git/backend.py +++ b/src/poetry/vcs/git/backend.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import dataclasses import logging import os @@ -20,6 +21,7 @@ from dulwich.errors import NotGitRepository from dulwich.file import FileLocked from dulwich.index import IndexEntry +from dulwich.object_store import peel_sha from dulwich.objects import ObjectID from dulwich.protocol import PEELED_TAG_SUFFIX from dulwich.refs import Ref @@ -96,7 +98,7 @@ def resolve(self, remote_refs: FetchPackResult, repo: Repo) -> None: Resolve the ref using the provided remote refs. """ self._normalise(remote_refs=remote_refs, repo=repo) - self._set_head(remote_refs=remote_refs) + self._set_head(remote_refs=remote_refs, repo=repo) def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None: """ @@ -142,7 +144,7 @@ def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None: self.revision = sha.decode("utf-8") return - def _set_head(self, remote_refs: FetchPackResult) -> None: + def _set_head(self, remote_refs: FetchPackResult, repo: Repo) -> None: """ Internal helper method to populate ref and set it's sha as the remote's head and default ref. @@ -165,6 +167,15 @@ def _set_head(self, remote_refs: FetchPackResult) -> None: ) head = remote_refs.refs[self.ref] + # Peel tag objects to get the underlying commit SHA. + # Annotated tags are Tag objects, not Commit objects. Operations like + # reset_index() expect HEAD to point to a Commit, so we must peel tags + # to extract the commit SHA they reference. + # Object not in store yet will be handled during fetch + if head is not None: + with contextlib.suppress(KeyError): + head = peel_sha(repo.object_store, head)[1].id + remote_refs.refs[self.ref] = remote_refs.refs[Ref(b"HEAD")] = head @property diff --git a/tests/vcs/git/test_backend.py b/tests/vcs/git/test_backend.py index 2531f76d466..b2c28aa93e7 100644 --- a/tests/vcs/git/test_backend.py +++ b/tests/vcs/git/test_backend.py @@ -8,6 +8,8 @@ import pytest from dulwich.client import FetchPackResult +from dulwich.refs import HEADREF +from dulwich.refs import Ref from dulwich.repo import Repo from poetry.console.exceptions import PoetryRuntimeError @@ -290,3 +292,135 @@ def test_clone_existing_locked_tag(tmp_path: Path, temp_repo: TempRepoFixture) - f"Try again later or remove the {tag_ref_lock} manually" " if you are sure no other process is holding it." ) + + +@pytest.mark.skip_git_mock +def test_clone_annotated_tag(tmp_path: Path) -> None: + """Test cloning at an annotated tag (issue #10658).""" + from dulwich import porcelain + from dulwich.objects import Commit + + # Create a source repository with an annotated tag + source_path = tmp_path / "source-repo" + source_path.mkdir() + repo = Repo.init(str(source_path)) + + # Create initial commit + test_file = source_path / "test.txt" + test_file.write_text("test content", encoding="utf-8") + porcelain.add(repo, str(test_file)) + expected_commit_sha = porcelain.commit( + repo, + message=b"Initial commit", + author=b"Test ", + committer=b"Test ", + ) + + # Create an annotated tag + porcelain.tag_create( + repo, + tag=b"v1.0.0", + message=b"Release 1.0.0", + author=b"Test ", + annotated=True, + ) + + # Clone at the annotated tag + source_root_dir = tmp_path / "clone-root" + source_root_dir.mkdir() + cloned_repo = Git.clone( + url=source_path.as_uri(), + source_root=source_root_dir, + name="clone-test", + tag="v1.0.0", + ) + + # Verify HEAD points to a commit, not a tag object + head_sha = cloned_repo.refs[HEADREF] + head_obj = cloned_repo.object_store[head_sha] + assert isinstance(head_obj, Commit), ( + f"HEAD should point to a Commit, got {type(head_obj).__name__}" + ) + # Verify it's the correct commit + assert head_sha == expected_commit_sha, ( + f"HEAD should point to the expected commit {expected_commit_sha.hex()}, " + f"got {head_sha.hex()}" + ) + + # Verify the clone succeeded and files are present + clone_dir = source_root_dir / "clone-test" + assert (clone_dir / ".git").is_dir() + assert (clone_dir / "test.txt").exists() + assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "test content" + + +@pytest.mark.skip_git_mock +def test_clone_nested_annotated_tags(tmp_path: Path) -> None: + """Test cloning at a tag that points to another tag (nested tags).""" + from dulwich import porcelain + from dulwich.objects import Commit + from dulwich.objects import Tag + + # Create a source repository with nested annotated tags + source_path = tmp_path / "source-repo" + source_path.mkdir() + repo = Repo.init(str(source_path)) + + # Create initial commit + test_file = source_path / "test.txt" + test_file.write_text("nested tag test", encoding="utf-8") + porcelain.add(repo, paths=[b"test.txt"]) + commit_sha = porcelain.commit( + repo, + message=b"Initial commit", + committer=b"Test ", + author=b"Test ", + ) + + # Create first annotated tag pointing to the commit + tag1 = Tag() + tag1.name = b"v1.0.0" + tag1.object = (Commit, commit_sha) + tag1.message = b"First tag" + tag1.tag_time = 1234567890 + tag1.tag_timezone = 0 + tag1.tagger = b"Test " + repo.object_store.add_object(tag1) + repo.refs[Ref(b"refs/tags/v1.0.0")] = tag1.id + + # Create second annotated tag pointing to the first tag + tag2 = Tag() + tag2.name = b"v1.0.0-release" + tag2.object = (Tag, tag1.id) + tag2.message = b"Second tag (points to first tag)" + tag2.tag_time = 1234567891 + tag2.tag_timezone = 0 + tag2.tagger = b"Test " + repo.object_store.add_object(tag2) + repo.refs[Ref(b"refs/tags/v1.0.0-release")] = tag2.id + + # Clone at the nested tag + source_root_dir = tmp_path / "clone-root" + source_root_dir.mkdir() + cloned_repo = Git.clone( + url=source_path.as_uri(), + source_root=source_root_dir, + name="clone-test", + tag="v1.0.0-release", + ) + + # Verify HEAD points to a commit, not a tag object + head_sha = cloned_repo.refs[HEADREF] + head_obj = cloned_repo.object_store[head_sha] + assert isinstance(head_obj, Commit), ( + f"HEAD should point to a Commit (peeling nested tags), got {type(head_obj).__name__}" + ) + + # Verify it's the correct commit + assert head_sha == commit_sha + + # Verify the clone succeeded and files are present + clone_dir = source_root_dir / "clone-test" + assert (clone_dir / ".git").is_dir() + assert (clone_dir / "test.txt").exists() + assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "nested tag test"