From 94229132ade324c4cd951384eb47060cf77612c6 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 28 Jul 2023 13:58:32 +0900 Subject: [PATCH 1/2] git: support creating annotated tags --- src/scmrepo/git/backend/base.py | 2 +- src/scmrepo/git/backend/dulwich/__init__.py | 14 ++++++++++++-- src/scmrepo/git/backend/gitpython.py | 6 ++++-- src/scmrepo/git/backend/pygit2/__init__.py | 15 +++++++++++++-- tests/test_git.py | 14 +++++++++++++- tests/test_pygit2.py | 2 +- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/scmrepo/git/backend/base.py b/src/scmrepo/git/backend/base.py index 98637071..eca8bd24 100644 --- a/src/scmrepo/git/backend/base.py +++ b/src/scmrepo/git/backend/base.py @@ -110,7 +110,7 @@ def branch(self, branch: str): pass @abstractmethod - def tag(self, tag: str): + def tag(self, tag: str, annotated: bool = False, message: Optional[str] = None): pass @abstractmethod diff --git a/src/scmrepo/git/backend/dulwich/__init__.py b/src/scmrepo/git/backend/dulwich/__init__.py index e150de71..205f82c2 100644 --- a/src/scmrepo/git/backend/dulwich/__init__.py +++ b/src/scmrepo/git/backend/dulwich/__init__.py @@ -390,8 +390,18 @@ def branch(self, branch: str): except Error as exc: raise SCMError(f"Failed to create branch '{branch}'") from exc - def tag(self, tag: str): - raise NotImplementedError + def tag(self, tag: str, annotated: bool = False, message: Optional[str] = None): + from dulwich.porcelain import Error, tag_create + + if annotated and not message: + raise SCMError("message is required for annotated tag") + with reraise(Error, SCMError("Failed to create tag")): + tag_create( + self.repo, + os.fsencode(tag), + annotated=annotated, + message=message.encode("utf-8") if message else None, + ) def untracked_files(self) -> Iterable[str]: _staged, _unstaged, untracked = self.status() diff --git a/src/scmrepo/git/backend/gitpython.py b/src/scmrepo/git/backend/gitpython.py index 12df3781..25d0b55d 100644 --- a/src/scmrepo/git/backend/gitpython.py +++ b/src/scmrepo/git/backend/gitpython.py @@ -294,8 +294,10 @@ def push(self): def branch(self, branch): self.repo.git.branch(branch) - def tag(self, tag): - self.repo.git.tag(tag) + def tag(self, tag: str, annotated: bool = False, message: Optional[str] = None): + if annotated and not message: + raise SCMError("message is required for annotated tag") + self.repo.git.tag(tag, a=annotated, m=message) def untracked_files(self): files = self.repo.untracked_files diff --git a/src/scmrepo/git/backend/pygit2/__init__.py b/src/scmrepo/git/backend/pygit2/__init__.py index d68361e2..31cd1a9b 100644 --- a/src/scmrepo/git/backend/pygit2/__init__.py +++ b/src/scmrepo/git/backend/pygit2/__init__.py @@ -274,8 +274,19 @@ def branch(self, branch: str): except GitError as exc: raise SCMError(f"Failed to create branch '{branch}'") from exc - def tag(self, tag: str): - raise NotImplementedError + def tag(self, tag: str, annotated: bool = False, message: Optional[str] = None): + from pygit2 import GIT_OBJ_COMMIT, GitError + + if annotated and not message: + raise SCMError("message is required for annotated tag") + with reraise(GitError, SCMError("Failed to create tag")): + self.repo.create_tag( + tag, + self.repo.head.target, + GIT_OBJ_COMMIT, + self.default_signature, + message or "", + ) def untracked_files(self) -> Iterable[str]: raise NotImplementedError diff --git a/tests/test_git.py b/tests/test_git.py index ba533849..1222a85b 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -201,7 +201,7 @@ def test_get_ref(tmp_dir: TmpDir, scm: Git, git: Git): os.path.join(".git", "refs", "foo", "baz"): "ref: refs/heads/master", } ) - scm.tag(["-a", "annotated", "-m", "Annotated Tag"]) + scm.tag("annotated", annotated=True, message="Annotated Tag") assert init_rev == git.get_ref("refs/foo/bar") assert init_rev == git.get_ref("refs/foo/baz") @@ -1099,3 +1099,15 @@ def test_backend_func( mock = mocker.spy(backend, "add") scm.add(["foo"]) mock.assert_called_once_with(["foo"]) + + +def test_tag(tmp_dir: TmpDir, scm: Git, git: Git): + tmp_dir.gen("foo", "foo") + scm.add_commit("foo", message="init") + rev = scm.get_rev() + + git.tag("lightweight") + assert scm.resolve_rev("lightweight") == rev + + git.tag("annotated", annotated=True, message="message") + assert scm.resolve_rev("annotated") == rev diff --git a/tests/test_pygit2.py b/tests/test_pygit2.py index fa0ca63f..946f17bb 100644 --- a/tests/test_pygit2.py +++ b/tests/test_pygit2.py @@ -15,7 +15,7 @@ def test_pygit_resolve_refish(tmp_dir: TmpDir, scm: Git, use_sha: str): scm.add_commit("foo", message="foo") head = scm.get_rev() tag = "my_tag" - scm.gitpython.git.tag("-a", tag, "-m", "create annotated tag") + scm.tag(tag, annotated=True, message="create annotated tag") if use_sha: # refish will be annotated tag SHA (not commit SHA) From e35e7dfd5fe1223ae95f32804a61ab62cb3f7e1a Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 28 Jul 2023 15:17:32 +0900 Subject: [PATCH 2/2] git: support reading annotated tags with get_tag() --- src/scmrepo/git/__init__.py | 1 + src/scmrepo/git/backend/base.py | 15 +++++++++++- src/scmrepo/git/backend/dulwich/__init__.py | 23 +++++++++++++++++- src/scmrepo/git/backend/gitpython.py | 20 +++++++++++++++- src/scmrepo/git/backend/pygit2/__init__.py | 26 ++++++++++++++++++++- src/scmrepo/git/objects.py | 10 ++++++++ tests/test_git.py | 18 ++++++++++++++ 7 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/scmrepo/git/__init__.py b/src/scmrepo/git/__init__.py index f7390a1c..5a9fad99 100644 --- a/src/scmrepo/git/__init__.py +++ b/src/scmrepo/git/__init__.py @@ -381,6 +381,7 @@ def fetch_refspecs( merge = partialmethod(_backend_func, "merge") validate_git_remote = partialmethod(_backend_func, "validate_git_remote") check_ref_format = partialmethod(_backend_func, "check_ref_format") + get_tag = partialmethod(_backend_func, "get_tag") get_tree_obj = partialmethod(_backend_func, "get_tree_obj") diff --git a/src/scmrepo/git/backend/base.py b/src/scmrepo/git/backend/base.py index eca8bd24..f68ee908 100644 --- a/src/scmrepo/git/backend/base.py +++ b/src/scmrepo/git/backend/base.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from scmrepo.progress import GitProgressEvent - from ..objects import GitCommit + from ..objects import GitCommit, GitTag class NoGitBackendError(SCMError): @@ -391,3 +391,16 @@ def validate_git_remote(self, url: str, **kwargs): @abstractmethod def check_ref_format(self, refname: str) -> bool: """Check if a reference name is well formed.""" + + @abstractmethod + def get_tag(self, name: str) -> Optional[Union[str, "GitTag"]]: + """Return the specified tag object. + + Args: + name: Tag name (without 'refs/tags/' prefix). + + Returns: + None if the specified tag does not exist. + String SHA for the target object if the tag is a lightweight tag. + GitTag object if the tag is an annotated tag. + """ diff --git a/src/scmrepo/git/backend/dulwich/__init__.py b/src/scmrepo/git/backend/dulwich/__init__.py index 205f82c2..4be03622 100644 --- a/src/scmrepo/git/backend/dulwich/__init__.py +++ b/src/scmrepo/git/backend/dulwich/__init__.py @@ -26,7 +26,7 @@ from scmrepo.progress import GitProgressReporter from scmrepo.utils import relpath -from ...objects import GitObject +from ...objects import GitObject, GitTag from ..base import BaseGitBackend, SyncStatus if TYPE_CHECKING: @@ -878,3 +878,24 @@ def check_ref_format(self, refname: str) -> bool: from dulwich.refs import check_ref_format return check_ref_format(refname.encode()) + + def get_tag(self, name: str) -> Optional[Union[str, "GitTag"]]: + from dulwich.objects import Tag + + name_b = os.fsencode(f"refs/tags/{name}") + try: + ref = self.repo.refs[name_b] + except KeyError: + return None + if ref in self.repo and isinstance(self.repo[ref], Tag): + tag = self.repo[ref] + _typ, target_sha = tag.object + return GitTag( + os.fsdecode(tag.name), + tag.id, + target_sha.decode("ascii"), + tag.tag_time, + tag.tag_timezone, + tag.message.decode("utf-8"), + ) + return os.fsdecode(ref) diff --git a/src/scmrepo/git/backend/gitpython.py b/src/scmrepo/git/backend/gitpython.py index 25d0b55d..83b3ceb8 100644 --- a/src/scmrepo/git/backend/gitpython.py +++ b/src/scmrepo/git/backend/gitpython.py @@ -28,7 +28,7 @@ ) from scmrepo.utils import relpath -from ..objects import GitCommit, GitObject +from ..objects import GitCommit, GitObject, GitTag from .base import BaseGitBackend, SyncStatus if TYPE_CHECKING: @@ -684,3 +684,21 @@ def validate_git_remote(self, url: str, **kwargs): def check_ref_format(self, refname: str): raise NotImplementedError + + def get_tag(self, name: str) -> Optional[Union[str, "GitTag"]]: + try: + ref = self.repo.tags[name] + if not ref.tag: + return ref.commit.hexsha + tag = ref.tag + return GitTag( + tag.tag, + tag.hexsha, + tag.object.hexsha, + tag.tagged_date, + tag.tagger_tz_offset, + tag.message, + ) + except IndexError: + pass + return None diff --git a/src/scmrepo/git/backend/pygit2/__init__.py b/src/scmrepo/git/backend/pygit2/__init__.py index 31cd1a9b..277cc26f 100644 --- a/src/scmrepo/git/backend/pygit2/__init__.py +++ b/src/scmrepo/git/backend/pygit2/__init__.py @@ -23,7 +23,7 @@ from scmrepo.exceptions import CloneError, MergeConflictError, RevError, SCMError from scmrepo.git.backend.base import BaseGitBackend, SyncStatus -from scmrepo.git.objects import GitCommit, GitObject +from scmrepo.git.objects import GitCommit, GitObject, GitTag from scmrepo.utils import relpath logger = logging.getLogger(__name__) @@ -893,3 +893,27 @@ def validate_git_remote(self, url: str, **kwargs): def check_ref_format(self, refname: str): raise NotImplementedError + + def get_tag(self, name: str) -> Optional[Union[str, "GitTag"]]: + from pygit2 import InvalidSpecError, Tag + + try: + ref = self.repo.references.get(f"refs/tags/{name}") + except InvalidSpecError: + return None + if not ref: + return None + try: + tag = self.repo[ref.target] + if isinstance(tag, Tag): + return GitTag( + tag.name, + str(tag.oid), + str(tag.target), + tag.tagger.time, + tag.tagger.offset, + tag.message, + ) + except KeyError: + pass + return str(ref.target) diff --git a/src/scmrepo/git/objects.py b/src/scmrepo/git/objects.py index c58d4df3..9cc874bd 100644 --- a/src/scmrepo/git/objects.py +++ b/src/scmrepo/git/objects.py @@ -162,3 +162,13 @@ class GitCommit: commit_time_offset: int message: str parents: List[str] + + +@dataclass +class GitTag: + name: str + hexsha: str # SHA for the tag object itself + target: str # SHA for the object the tag points to + tag_time: int + tag_time_offset: int + message: str diff --git a/tests/test_git.py b/tests/test_git.py index 1222a85b..2fb10e2b 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -15,6 +15,7 @@ from scmrepo.exceptions import InvalidRemote, MergeConflictError, RevError, SCMError from scmrepo.git import Git +from scmrepo.git.objects import GitTag from .conftest import backends @@ -1111,3 +1112,20 @@ def test_tag(tmp_dir: TmpDir, scm: Git, git: Git): git.tag("annotated", annotated=True, message="message") assert scm.resolve_rev("annotated") == rev + + +def test_get_tag(tmp_dir, scm: Git, git: Git): + tmp_dir.gen("foo", "foo") + scm.add_commit("foo", message="init") + rev = scm.get_rev() + + assert git.get_tag("nonexistent") is None + + scm.tag("lightweight") + assert git.get_tag("lightweight") == rev + + scm.tag("annotated", annotated=True, message="message") + tag = git.get_tag("annotated") + assert isinstance(tag, GitTag) + assert tag.target == rev + assert tag.message.strip() == "message"