Skip to content

Commit 2234234

Browse files
authored
Fix AssertionError when cloning at annotated tag (#10719)
When cloning a Git repository at an annotated tag, if the peeled tag reference (refs/tags/v1.0.0^{}) is not available in the fetch result, Poetry would set HEAD to the tag object SHA instead of the commit SHA. This caused reset_index() to fail with: AssertionError: assert isinstance(obj, Commit) The fix peels tag objects recursively to extract the underlying commit SHA before setting HEAD. This ensures HEAD always points to a Commit object, not a Tag object. Fixes python-poetry#10658
1 parent 197571e commit 2234234

File tree

2 files changed

+147
-2
lines changed

2 files changed

+147
-2
lines changed

src/poetry/vcs/git/backend.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import dataclasses
45
import logging
56
import os
@@ -20,6 +21,7 @@
2021
from dulwich.errors import NotGitRepository
2122
from dulwich.file import FileLocked
2223
from dulwich.index import IndexEntry
24+
from dulwich.object_store import peel_sha
2325
from dulwich.objects import ObjectID
2426
from dulwich.protocol import PEELED_TAG_SUFFIX
2527
from dulwich.refs import Ref
@@ -96,7 +98,7 @@ def resolve(self, remote_refs: FetchPackResult, repo: Repo) -> None:
9698
Resolve the ref using the provided remote refs.
9799
"""
98100
self._normalise(remote_refs=remote_refs, repo=repo)
99-
self._set_head(remote_refs=remote_refs)
101+
self._set_head(remote_refs=remote_refs, repo=repo)
100102

101103
def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None:
102104
"""
@@ -142,7 +144,7 @@ def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None:
142144
self.revision = sha.decode("utf-8")
143145
return
144146

145-
def _set_head(self, remote_refs: FetchPackResult) -> None:
147+
def _set_head(self, remote_refs: FetchPackResult, repo: Repo) -> None:
146148
"""
147149
Internal helper method to populate ref and set it's sha as the remote's head
148150
and default ref.
@@ -165,6 +167,15 @@ def _set_head(self, remote_refs: FetchPackResult) -> None:
165167
)
166168
head = remote_refs.refs[self.ref]
167169

170+
# Peel tag objects to get the underlying commit SHA.
171+
# Annotated tags are Tag objects, not Commit objects. Operations like
172+
# reset_index() expect HEAD to point to a Commit, so we must peel tags
173+
# to extract the commit SHA they reference.
174+
# Object not in store yet will be handled during fetch
175+
if head is not None:
176+
with contextlib.suppress(KeyError):
177+
head = peel_sha(repo.object_store, head)[1].id
178+
168179
remote_refs.refs[self.ref] = remote_refs.refs[Ref(b"HEAD")] = head
169180

170181
@property

tests/vcs/git/test_backend.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import pytest
99

1010
from dulwich.client import FetchPackResult
11+
from dulwich.refs import HEADREF
12+
from dulwich.refs import Ref
1113
from dulwich.repo import Repo
1214

1315
from poetry.console.exceptions import PoetryRuntimeError
@@ -290,3 +292,135 @@ def test_clone_existing_locked_tag(tmp_path: Path, temp_repo: TempRepoFixture) -
290292
f"Try again later or remove the {tag_ref_lock} manually"
291293
" if you are sure no other process is holding it."
292294
)
295+
296+
297+
@pytest.mark.skip_git_mock
298+
def test_clone_annotated_tag(tmp_path: Path) -> None:
299+
"""Test cloning at an annotated tag (issue #10658)."""
300+
from dulwich import porcelain
301+
from dulwich.objects import Commit
302+
303+
# Create a source repository with an annotated tag
304+
source_path = tmp_path / "source-repo"
305+
source_path.mkdir()
306+
repo = Repo.init(str(source_path))
307+
308+
# Create initial commit
309+
test_file = source_path / "test.txt"
310+
test_file.write_text("test content", encoding="utf-8")
311+
porcelain.add(repo, str(test_file))
312+
expected_commit_sha = porcelain.commit(
313+
repo,
314+
message=b"Initial commit",
315+
author=b"Test <test@example.com>",
316+
committer=b"Test <test@example.com>",
317+
)
318+
319+
# Create an annotated tag
320+
porcelain.tag_create(
321+
repo,
322+
tag=b"v1.0.0",
323+
message=b"Release 1.0.0",
324+
author=b"Test <test@example.com>",
325+
annotated=True,
326+
)
327+
328+
# Clone at the annotated tag
329+
source_root_dir = tmp_path / "clone-root"
330+
source_root_dir.mkdir()
331+
cloned_repo = Git.clone(
332+
url=source_path.as_uri(),
333+
source_root=source_root_dir,
334+
name="clone-test",
335+
tag="v1.0.0",
336+
)
337+
338+
# Verify HEAD points to a commit, not a tag object
339+
head_sha = cloned_repo.refs[HEADREF]
340+
head_obj = cloned_repo.object_store[head_sha]
341+
assert isinstance(head_obj, Commit), (
342+
f"HEAD should point to a Commit, got {type(head_obj).__name__}"
343+
)
344+
# Verify it's the correct commit
345+
assert head_sha == expected_commit_sha, (
346+
f"HEAD should point to the expected commit {expected_commit_sha.hex()}, "
347+
f"got {head_sha.hex()}"
348+
)
349+
350+
# Verify the clone succeeded and files are present
351+
clone_dir = source_root_dir / "clone-test"
352+
assert (clone_dir / ".git").is_dir()
353+
assert (clone_dir / "test.txt").exists()
354+
assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "test content"
355+
356+
357+
@pytest.mark.skip_git_mock
358+
def test_clone_nested_annotated_tags(tmp_path: Path) -> None:
359+
"""Test cloning at a tag that points to another tag (nested tags)."""
360+
from dulwich import porcelain
361+
from dulwich.objects import Commit
362+
from dulwich.objects import Tag
363+
364+
# Create a source repository with nested annotated tags
365+
source_path = tmp_path / "source-repo"
366+
source_path.mkdir()
367+
repo = Repo.init(str(source_path))
368+
369+
# Create initial commit
370+
test_file = source_path / "test.txt"
371+
test_file.write_text("nested tag test", encoding="utf-8")
372+
porcelain.add(repo, paths=[b"test.txt"])
373+
commit_sha = porcelain.commit(
374+
repo,
375+
message=b"Initial commit",
376+
committer=b"Test <test@example.com>",
377+
author=b"Test <test@example.com>",
378+
)
379+
380+
# Create first annotated tag pointing to the commit
381+
tag1 = Tag()
382+
tag1.name = b"v1.0.0"
383+
tag1.object = (Commit, commit_sha)
384+
tag1.message = b"First tag"
385+
tag1.tag_time = 1234567890
386+
tag1.tag_timezone = 0
387+
tag1.tagger = b"Test <test@example.com>"
388+
repo.object_store.add_object(tag1)
389+
repo.refs[Ref(b"refs/tags/v1.0.0")] = tag1.id
390+
391+
# Create second annotated tag pointing to the first tag
392+
tag2 = Tag()
393+
tag2.name = b"v1.0.0-release"
394+
tag2.object = (Tag, tag1.id)
395+
tag2.message = b"Second tag (points to first tag)"
396+
tag2.tag_time = 1234567891
397+
tag2.tag_timezone = 0
398+
tag2.tagger = b"Test <test@example.com>"
399+
repo.object_store.add_object(tag2)
400+
repo.refs[Ref(b"refs/tags/v1.0.0-release")] = tag2.id
401+
402+
# Clone at the nested tag
403+
source_root_dir = tmp_path / "clone-root"
404+
source_root_dir.mkdir()
405+
cloned_repo = Git.clone(
406+
url=source_path.as_uri(),
407+
source_root=source_root_dir,
408+
name="clone-test",
409+
tag="v1.0.0-release",
410+
)
411+
412+
# Verify HEAD points to a commit, not a tag object
413+
head_sha = cloned_repo.refs[HEADREF]
414+
head_obj = cloned_repo.object_store[head_sha]
415+
assert isinstance(head_obj, Commit), (
416+
f"HEAD should point to a Commit (peeling nested tags), got {type(head_obj).__name__}"
417+
)
418+
419+
# Verify it's the correct commit
420+
assert head_sha == commit_sha
421+
422+
# Verify the clone succeeded and files are present
423+
clone_dir = source_root_dir / "clone-test"
424+
assert (clone_dir / ".git").is_dir()
425+
assert (clone_dir / "test.txt").exists()
426+
assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "nested tag test"

0 commit comments

Comments
 (0)