Repository: canonical/snapcraft

Issue:
remote-build: create wrapper for the pygit2 library
### What needs to get done

Create a new module `snapcraft/remote_build/git.py` to wrap the [GitPython](https://gitpython.readthedocs.io/en/stable/) library.

Add the following functions:

- `init(exist_ok: bool)`
- `is_repo()`
- `is_dirty()`
- `add(add_all: bool)`
- `commit(message: str)`
- `push(refspec: str = “HEAD”, url: str)`

### Why it needs to get done

The legacy code uses snapcraft_legacy’s [Git](https://github.com/snapcore/snapcraft/blob/423826a119074fd7776e01f405c51a753292b6cd/snapcraft_legacy/internal/sources/_git.py#L27) class for git-related operations.  In the new code, this will be replaced with the [GitPython](https://gitpython.readthedocs.io/en/stable/) library, encapsulated by a wrapper class.


Discussion:
@sergiusens has recommended using [pygit2](https://www.pygit2.org) instead of GitPython

Resolving patch:
diff --git a/snapcraft/remote/__init__.py b/snapcraft/remote/__init__.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/remote/__init__.py
@@ -0,0 +1,27 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Remote-build and related utilities."""
+
+from .errors import GitError, RemoteBuildError
+from .git import GitRepo, is_repo
+
+__all__ = [
+    "is_repo",
+    "GitError",
+    "GitRepo",
+    "RemoteBuildError",
+]
diff --git a/snapcraft/remote/errors.py b/snapcraft/remote/errors.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/remote/errors.py
@@ -0,0 +1,52 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Remote build errors."""
+
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass(repr=True)
+class RemoteBuildError(Exception):
+    """Unexpected remote build error.
+
+    :param brief: Brief description of error.
+    :param details: Detailed information.
+    """
+
+    brief: str
+    details: Optional[str] = None
+
+    def __str__(self) -> str:
+        """Return the string representation of the error."""
+        components = [self.brief]
+
+        if self.details:
+            components.append(self.details)
+
+        return "\n".join(components)
+
+
+class GitError(RemoteBuildError):
+    """Git is not working as expected."""
+
+    def __init__(self, message: str) -> None:
+        self.message = message
+        brief = "Git operation failed."
+        details = message
+
+        super().__init__(brief=brief, details=details)
diff --git a/snapcraft/remote/git.py b/snapcraft/remote/git.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/remote/git.py
@@ -0,0 +1,190 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Git repository class and helper utilities."""
+
+import logging
+from pathlib import Path
+
+import pygit2
+
+from .errors import GitError
+
+logger = logging.getLogger(__name__)
+
+
+def is_repo(path: Path) -> bool:
+    """Check if a directory is a git repo.
+
+    :param path: filepath to check
+
+    :returns: True if path is a git repo.
+
+    :raises GitError: if git fails while checking for a repository
+    """
+    # `path.absolute().parent` prevents pygit2 from checking parent directories
+    try:
+        return bool(
+            pygit2.discover_repository(str(path), False, str(path.absolute().parent))
+        )
+    except pygit2.GitError as error:
+        raise GitError(
+            f"Could not check for git repository in {str(path)!r}."
+        ) from error
+
+
+class GitRepo:
+    """Git repository class."""
+
+    def __init__(self, path: Path) -> None:
+        """Initialize a git repo.
+
+        If a git repo does not already exist, a new repo will be initialized.
+
+        :param path: filepath of the repo
+
+        :raises FileNotFoundError: if the directory does not exist
+        :raises GitError: if the repo cannot be initialized
+        """
+        self.path = path
+
+        if not path.is_dir():
+            raise FileNotFoundError(
+                f"Could not initialize a git repository because {str(path)!r} does not "
+                "exist or is not a directory."
+            )
+
+        if not is_repo(path):
+            self._init_repo()
+
+        self._repo = pygit2.Repository(path)
+
+    def add_all(self) -> None:
+        """Add all changes from the working tree to the index.
+
+        :raises GitError: if the changes could not be added
+        """
+        logger.debug("Adding all changes.")
+
+        try:
+            self._repo.index.add_all()
+            self._repo.index.write()
+        except pygit2.GitError as error:
+            raise GitError(
+                f"Could not add changes for the git repository in {str(self.path)!r}."
+            ) from error
+
+    def commit(self, message: str = "auto commit") -> None:
+        """Commit changes to the repo.
+
+        :param message: the commit message
+
+        :raises GitError: if the commit could not be created
+        """
+        logger.debug("Committing changes.")
+
+        try:
+            tree = self._repo.index.write_tree()
+        except pygit2.GitError as error:
+            raise GitError(
+                f"Could not create a tree for the git repository in {str(self.path)!r}."
+            ) from error
+
+        author = pygit2.Signature("auto commit", "auto commit")
+
+        # a target is not needed for an unborn head (no existing commits in branch)
+        target = [] if self._repo.head_is_unborn else [self._repo.head.target]
+
+        try:
+            self._repo.create_commit("HEAD", author, author, message, tree, target)
+        except pygit2.GitError as error:
+            raise GitError(
+                "Could not create a commit for the git repository "
+                f"in {str(self.path)!r}."
+            ) from error
+
+    def is_clean(self) -> bool:
+        """Check if the repo is clean.
+
+        :returns: True if the repo is clean.
+
+        :raises GitError: if git fails while checking if the repo is clean
+        """
+        try:
+            # for a clean repo, `status()` will return an empty dict
+            return not bool(self._repo.status())
+        except pygit2.GitError as error:
+            raise GitError(
+                f"Could not check if the git repository in {str(self.path)!r} is clean."
+            ) from error
+
+    def _init_repo(self) -> None:
+        """Initialize a git repo.
+
+        :raises GitError: if the repo cannot be initialized
+        """
+        logger.debug("Initializing git repository in {str(self.path)!r}")
+
+        try:
+            pygit2.init_repository(self.path)
+        except pygit2.GitError as error:
+            raise GitError(
+                f"Could not initialize a git repository in {str(self.path)!r}."
+            ) from error
+
+    def push_url(self, remote_url: str, remote_branch: str, ref: str = "HEAD") -> None:
+        """Push a reference to a branch on a remote url.
+
+        :param remote_url: the remote repo URL to push to
+        :param remote_branch: the branch on the remote to push to
+        :param ref: name of shorthand ref to push (i.e. a branch, tag, or `HEAD`)
+
+        :raises GitError: if the ref cannot be resolved or pushed
+        """
+        resolved_ref = self._resolve_ref(ref)
+        refspec = f"{resolved_ref}:refs/heads/{remote_branch}"
+
+        logger.debug(
+            "Pushing %r to remote %r with refspec %r.", ref, remote_url, refspec
+        )
+
+        try:
+            self._repo.remotes.create_anonymous(remote_url).push([refspec])
+        except pygit2.GitError as error:
+            raise GitError(
+                f"Could not push {ref!r} to {remote_url!r} with refspec {refspec!r} "
+                f"for the git repository in {str(self.path)!r}."
+            ) from error
+
+    def _resolve_ref(self, ref: str) -> str:
+        """Get a full reference name for a shorthand ref.
+
+        :param ref: shorthand ref name (i.e. a branch, tag, or `HEAD`)
+
+        :returns: the full ref name (i.e. `refs/heads/main`)
+
+        raises GitError: if the name could not be resolved
+        """
+        try:
+            reference = self._repo.lookup_reference_dwim(ref).name
+            logger.debug("Resolved reference %r for name %r", reference, ref)
+            return reference
+        # raises a KeyError if the ref does not exist and a GitError for git errors
+        except (pygit2.GitError, KeyError) as error:
+            raise GitError(
+                f"Could not resolve reference {ref!r} for the git repository in "
+                f"{str(self.path)!r}."
+            ) from error


Test patch:
diff --git a/tests/unit/remote/__init__.py b/tests/unit/remote/__init__.py
new file mode 100644
diff --git a/tests/unit/remote/test_errors.py b/tests/unit/remote/test_errors.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/remote/test_errors.py
@@ -0,0 +1,30 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from snapcraft.remote import errors
+
+
+def test_git_error():
+    """Test GitError."""
+    error = errors.GitError("Error details.")
+
+    assert str(error) == "Git operation failed.\nError details."
+    assert (
+        repr(error)
+        == "GitError(brief='Git operation failed.', details='Error details.')"
+    )
+    assert error.brief == "Git operation failed."
+    assert error.details == "Error details."
diff --git a/tests/unit/remote/test_git.py b/tests/unit/remote/test_git.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/remote/test_git.py
@@ -0,0 +1,394 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+"""Tests for the pygit2 wrapper class."""
+
+import re
+from pathlib import Path
+
+import pygit2
+import pytest
+
+from snapcraft.remote import GitError, GitRepo, is_repo
+
+
+def test_is_repo(new_dir):
+    """Check if directory is a repo."""
+    GitRepo(new_dir)
+
+    assert is_repo(new_dir)
+
+
+def test_is_not_repo(new_dir):
+    """Check if a directory is not a repo."""
+    assert not is_repo(new_dir)
+
+
+def test_is_repo_path_only(new_dir):
+    """Only look at the path for a repo."""
+    Path("parent-repo/not-a-repo/child-repo").mkdir(parents=True)
+    # create the parent and child repos
+    GitRepo(Path("parent-repo"))
+    GitRepo(Path("parent-repo/not-a-repo/child-repo"))
+
+    assert is_repo(Path("parent-repo"))
+    assert not is_repo(Path("parent-repo/not-a-repo"))
+    assert is_repo(Path("parent-repo/not-a-repo/child-repo"))
+
+
+def test_is_repo_error(new_dir, mocker):
+    """Raise an error if git fails to check a repo."""
+    mocker.patch("pygit2.discover_repository", side_effect=pygit2.GitError)
+
+    with pytest.raises(GitError) as raised:
+        assert is_repo(new_dir)
+
+    assert raised.value.details == (
+        f"Could not check for git repository in {str(new_dir)!r}."
+    )
+
+
+def test_init_repo(new_dir):
+    """Initialize a GitRepo object."""
+    repo = GitRepo(new_dir)
+
+    assert is_repo(new_dir)
+    assert repo.path == new_dir
+
+
+def test_init_existing_repo(new_dir):
+    """Initialize a GitRepo object in an existing git repository."""
+    # initialize a repo
+    GitRepo(new_dir)
+
+    # creating a new GitRepo object will not re-initialize the repo
+    repo = GitRepo(new_dir)
+
+    assert is_repo(new_dir)
+    assert repo.path == new_dir
+
+
+def test_init_repo_no_directory(new_dir):
+    """Raise an error if the directory is missing."""
+    with pytest.raises(FileNotFoundError) as raised:
+        GitRepo(new_dir / "missing")
+
+    assert str(raised.value) == (
+        "Could not initialize a git repository because "
+        f"{str(new_dir / 'missing')!r} does not exist or is not a directory."
+    )
+
+
+def test_init_repo_not_a_directory(new_dir):
+    """Raise an error if the path is not a directory."""
+    Path("regular-file").touch()
+
+    with pytest.raises(FileNotFoundError) as raised:
+        GitRepo(new_dir / "regular-file")
+
+    assert str(raised.value) == (
+        "Could not initialize a git repository because "
+        f"{str(new_dir / 'regular-file')!r} does not exist or is not a directory."
+    )
+
+
+def test_init_repo_error(new_dir, mocker):
+    """Raise an error if the repo cannot be initialized."""
+    mocker.patch("pygit2.init_repository", side_effect=pygit2.GitError)
+
+    with pytest.raises(GitError) as raised:
+        GitRepo(new_dir)
+
+    assert raised.value.details == (
+        f"Could not initialize a git repository in {str(new_dir)!r}."
+    )
+
+
+def test_add_all(new_dir):
+    """Add all files."""
+    repo = GitRepo(new_dir)
+    (repo.path / "foo").touch()
+    (repo.path / "bar").touch()
+    repo.add_all()
+
+    status = pygit2.Repository(new_dir).status()
+
+    assert status == {
+        "foo": pygit2.GIT_STATUS_INDEX_NEW,
+        "bar": pygit2.GIT_STATUS_INDEX_NEW,
+    }
+
+
+def test_add_all_no_files_to_add(new_dir):
+    """`add_all` should succeed even if there are no files to add."""
+    repo = GitRepo(new_dir)
+    repo.add_all()
+
+    status = pygit2.Repository(new_dir).status()
+
+    assert status == {}
+
+
+def test_add_all_error(new_dir, mocker):
+    """Raise an error if the changes could not be added."""
+    mocker.patch("pygit2.Index.add_all", side_effect=pygit2.GitError)
+    repo = GitRepo(new_dir)
+
+    with pytest.raises(GitError) as raised:
+        repo.add_all()
+
+    assert raised.value.details == (
+        f"Could not add changes for the git repository in {str(new_dir)!r}."
+    )
+
+
+def test_commit(new_dir):
+    """Commit a file and confirm it is in the tree."""
+    repo = GitRepo(new_dir)
+    (repo.path / "test-file").touch()
+    repo.add_all()
+
+    repo.commit()
+
+    # verify commit (the `isinstance` checks are to satsify pyright)
+    commit = pygit2.Repository(new_dir).revparse_single("HEAD")
+    assert isinstance(commit, pygit2.Commit)
+    assert commit.message == "auto commit"
+    assert commit.committer.name == "auto commit"
+    assert commit.committer.email == "auto commit"
+
+    # verify tree
+    tree = commit.tree
+    assert isinstance(tree, pygit2.Tree)
+    assert len(tree) == 1
+
+    # verify contents of tree
+    blob = tree[0]
+    assert isinstance(blob, pygit2.Blob)
+    assert blob.name == "test-file"
+
+
+def test_commit_write_tree_error(new_dir, mocker):
+    """Raise an error if the tree cannot be created."""
+    mocker.patch("pygit2.Index.write_tree", side_effect=pygit2.GitError)
+    repo = GitRepo(new_dir)
+    (repo.path / "test-file").touch()
+    repo.add_all()
+
+    with pytest.raises(GitError) as raised:
+        repo.commit()
+
+    assert raised.value.details == (
+        f"Could not create a tree for the git repository in {str(new_dir)!r}."
+    )
+
+
+def test_commit_error(new_dir, mocker):
+    """Raise an error if the commit cannot be created."""
+    mocker.patch("pygit2.Repository.create_commit", side_effect=pygit2.GitError)
+    repo = GitRepo(new_dir)
+    (repo.path / "test-file").touch()
+    repo.add_all()
+
+    with pytest.raises(GitError) as raised:
+        repo.commit()
+
+    assert raised.value.details == (
+        f"Could not create a commit for the git repository in {str(new_dir)!r}."
+    )
+
+
+def test_is_clean(new_dir):
+    """Check if a repo is clean."""
+    repo = GitRepo(new_dir)
+
+    assert repo.is_clean()
+
+    (repo.path / "foo").touch()
+
+    assert not repo.is_clean()
+
+
+def test_is_clean_error(new_dir, mocker):
+    """Check if git fails when checking if the repo is clean."""
+    mocker.patch("pygit2.Repository.status", side_effect=pygit2.GitError)
+    repo = GitRepo(new_dir)
+
+    with pytest.raises(GitError) as raised:
+        repo.is_clean()
+
+    assert raised.value.details == (
+        f"Could not check if the git repository in {str(new_dir)!r} is clean."
+    )
+
+
+def test_push_url(new_dir):
+    """Push the default ref (HEAD) to a remote branch."""
+    # create a local repo and make a commit
+    Path("local-repo").mkdir()
+    repo = GitRepo(Path("local-repo"))
+    (repo.path / "test-file").touch()
+    repo.add_all()
+    repo.commit()
+    # create a bare remote repo
+    Path("remote-repo").mkdir()
+    remote = pygit2.init_repository(Path("remote-repo"), True)
+
+    repo.push_url(
+        remote_url=f"file://{str(Path('remote-repo').absolute())}",
+        remote_branch="test-branch",
+    )
+
+    # verify commit in remote (the `isinstance` checks are to satsify pyright)
+    commit = remote.revparse_single("test-branch")
+    assert isinstance(commit, pygit2.Commit)
+    assert commit.message == "auto commit"
+    assert commit.committer.name == "auto commit"
+    assert commit.committer.email == "auto commit"
+    # verify tree in remote
+    tree = commit.tree
+    assert isinstance(tree, pygit2.Tree)
+    assert len(tree) == 1
+    # verify contents of tree in remote
+    blob = tree[0]
+    assert isinstance(blob, pygit2.Blob)
+    assert blob.name == "test-file"
+
+
+def test_push_url_detached_head(new_dir):
+    """Push a detached HEAD to a remote branch."""
+    # create a local repo and make two commits
+    Path("local-repo").mkdir()
+    repo = GitRepo(Path("local-repo"))
+    (repo.path / "test-file-1").touch()
+    repo.add_all()
+    repo.commit()
+    (repo.path / "test-file-2").touch()
+    repo.add_all()
+    repo.commit()
+    # detach HEAD to first commit
+    first_commit = repo._repo.revparse_single("HEAD~1")
+    repo._repo.checkout_tree(first_commit)
+    repo._repo.set_head(first_commit.id)
+    # create a bare remote repo
+    Path("remote-repo").mkdir()
+    remote = pygit2.init_repository(Path("remote-repo"), True)
+
+    # push the detached HEAD to the remote
+    repo.push_url(
+        remote_url=f"file://{str(Path('remote-repo').absolute())}",
+        remote_branch="test-branch",
+    )
+
+    # verify commit in remote (the `isinstance` checks are to satsify pyright)
+    commit = remote.revparse_single("test-branch")
+    assert isinstance(commit, pygit2.Commit)
+    assert commit.message == "auto commit"
+    assert commit.committer.name == "auto commit"
+    assert commit.committer.email == "auto commit"
+    # verify tree in remote
+    tree = commit.tree
+    assert isinstance(tree, pygit2.Tree)
+    assert len(tree) == 1
+    # verify contents of tree in remote are from the first commit
+    blob = tree[0]
+    assert isinstance(blob, pygit2.Blob)
+    assert blob.name == "test-file-1"
+
+
+def test_push_url_branch(new_dir):
+    """Push a branch to a remote branch."""
+    # create a local repo and make a commit
+    Path("local-repo").mkdir()
+    repo = GitRepo(Path("local-repo"))
+    (repo.path / "test-file").touch()
+    repo.add_all()
+    repo.commit()
+    # create a bare remote repo
+    Path("remote-repo").mkdir()
+    remote = pygit2.init_repository(Path("remote-repo"), True)
+
+    repo.push_url(
+        remote_url=f"file://{str(Path('remote-repo').absolute())}",
+        remote_branch="test-branch",
+        # use the branch name
+        ref=repo._repo.head.shorthand,
+    )
+
+    # verify commit in remote (the `isinstance` checks are to satsify pyright)
+    commit = remote.revparse_single("test-branch")
+    assert isinstance(commit, pygit2.Commit)
+    assert commit.message == "auto commit"
+    assert commit.committer.name == "auto commit"
+    assert commit.committer.email == "auto commit"
+    # verify tree in remote
+    tree = commit.tree
+    assert isinstance(tree, pygit2.Tree)
+    assert len(tree) == 1
+    # verify contents of tree in remote
+    blob = tree[0]
+    assert isinstance(blob, pygit2.Blob)
+    assert blob.name == "test-file"
+
+
+def test_push_url_refspec_unknown_ref(new_dir):
+    """Raise an error for an unknown refspec."""
+    repo = GitRepo(new_dir)
+
+    with pytest.raises(GitError) as raised:
+        repo.push_url(remote_url="test-url", remote_branch="test-branch", ref="bad-ref")
+
+    assert raised.value.details == (
+        "Could not resolve reference 'bad-ref' for the git repository "
+        f"in {str(new_dir)!r}."
+    )
+
+
+def test_push_url_refspec_git_error(mocker, new_dir):
+    """Raise an error if git fails when looking for a refspec."""
+    mocker.patch(
+        "pygit2.Repository.lookup_reference_dwim",
+        side_effect=pygit2.GitError,
+    )
+    repo = GitRepo(new_dir)
+
+    with pytest.raises(GitError) as raised:
+        repo.push_url(remote_url="test-url", remote_branch="test-branch", ref="bad-ref")
+
+    assert raised.value.details == (
+        "Could not resolve reference 'bad-ref' for the git repository "
+        f"in {str(new_dir)!r}."
+    )
+
+
+def test_push_url_push_error(new_dir):
+    """Raise an error when the refspec cannot be pushed."""
+    repo = GitRepo(new_dir)
+    (repo.path / "test-file").touch()
+    repo.add_all()
+    repo.commit()
+    expected_error_details = (
+        "Could not push 'HEAD' to 'bad-url' with refspec "
+        "'.*:refs/heads/test-branch' for the git repository "
+        f"in {str(new_dir)!r}."
+    )
+
+    with pytest.raises(GitError) as raised:
+        repo.push_url(remote_url="bad-url", remote_branch="test-branch")
+
+    assert raised.value.details is not None
+    assert re.match(expected_error_details, raised.value.details)